Orphan rule и newtype в Rust
Практический блок: почему нельзя реализовать Display для Vec<u8>, как это обходят и во что обходится обёртка. Заодно проверяют понимание, зачем языку вообще нужна согласованность реализаций.
Стержень: для пары трейт-тип должна существовать не более одной реализации во всей программе, поэтому чужой трейт для чужого типа реализовать нельзя.
// Формулировки: «почему impl Display for Vec<u8> запрещён?», «что такое newtype и сколько он стоит?», «почему в Rust нет специализации?»
Почему нельзя чужое для чужого
Представь, что два независимых крейта реализовали Display для Vec<u8> по-своему. Пока они живут порознь - всё в порядке, но как только оба попали в одну программу, выбор становится неразрешимым, и семантика кода начинает зависеть от списка зависимостей.
Orphan rule закрывает этот сценарий: хотя бы одна сторона - трейт или тип - должна принадлежать вашему крейту. Иначе E0117. Это цена за свойство, которое называют согласованностью: для пары трейт-тип реализация во всей программе одна.
// Правило иногда мешает, но альтернатива хуже: конфликты, проявляющиеся только при определённом наборе зависимостей.
- orphan rule
- запрет реализовывать чужой трейт для чужого типа
- coherence
- не более одной реализации на пару трейт-тип
Newtype: обход и типобезопасность
Стандартный обход - обернуть чужой тип в свой: struct Hex(Vec<u8>), и Display пишется уже для Hex. Кортежная структура с одним полем имеет ровно ту же раскладку, что и поле, поэтому в рантайме обёртки нет - только на уровне типов.
Тот же приём используют и без всякого orphan rule, ради безопасности: Meters(f64) и Seconds(f64) перестают складываться друг с другом, а функция, ждущая UserId, не примет случайный i64. Ошибки такого рода ловятся компилятором и стоят ноль.
// Минус один: обёртка не наследует методы внутреннего типа. Нужные пробрасывают вручную или реализуют Deref, но с Deref стоит быть осторожным, он размывает границу абстракции.
struct Hex(Vec<u8>);
impl std::fmt::Display for Hex { /* .. */ }- newtype
- обёртка из одного поля с собственным типом
- нулевая стоимость
- раскладка обёртки совпадает с раскладкой поля
Blanket impl и отсутствие специализации
Сплошная реализация раздаёт поведение целому семейству: impl<T: Display> ToString for T в стандартной библиотеке - именно она. Побочный эффект в том, что после такого impl добавить частный случай для конкретного типа уже нельзя: компилятор увидит перекрытие.
Отсюда частый вопрос: почему в стабильном Rust нет специализации, когда более конкретная реализация побеждает общую. Проблема не техническая, а в согласованности: наивная специализация позволяет поведению зависеть от лайфтаймов, которые стираются, то есть от того, чего в рантайме уже нет. Фича живёт под флагом min_specialization и используется внутри std, где ограничения контролируются вручную.
// Ещё одна полезная сплошная реализация: из From<A> for B автоматически следует Into<B> for A. Поэтому в сигнатурах просят Into, а реализуют всегда From.
- blanket impl
- реализация трейта сразу для всех подходящих типов
- специализация
- приоритет более конкретной реализации; в стабильном Rust отсутствует
Как отвечать: «Почему нельзя реализовать Display для Vec<u8> и что делать?»
Мешает orphan rule: и трейт, и тип чужие, а язык гарантирует, что на пару трейт-тип во всей программе есть не больше одной реализации. Если бы два крейта дали Vec<u8> разные Display, при сборке вместе выбор стал бы неразрешимым. Обхожу это через newtype: заворачиваю в свою структуру Hex(Vec<u8>) и пишу Display уже для неё. В рантайме такая обёртка бесплатна - у кортежной структуры с одним полем та же раскладка, что у поля. Заодно она часто полезна сама по себе: типобезопасность вроде Meters и Seconds, которые не должны складываться.
Ты объясняешь причину запрета через конфликт реализаций, даёшь стандартный обход и добавляешь, что он ничего не стоит. Три вещи, которые интервьюер как раз и хочет проверить.
На чём валятся
- −Считают newtype дорогой обёрткой - в рантайме её нет.
- −Не могут объяснить, зачем нужен orphan rule, и говорят «просто такое правило».
- −Пишут blanket impl, а потом не могут добавить частный случай для одного типа.
- −Ждут, что специализация работает, как перегрузка в C++.
- −Реализуют Into вместо From и лишаются автоматического второго направления.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 12, остальные разбираются в тренажёре.
- Сколько стоит обёртка-newtype struct Meters(f64) в рантайме?A)Восемь байт сверху: обёртка хранит тег типаB)Ничего: раскладка совпадает с f64, обёртка исчезает при компиляцииC)Зависит от оптимизаций: в debug обёртка остаётся, в release пропадаетD)Один указатель: значение переезжает в кучу под обёртку
показать ответ и разбор
+B)Ничего: раскладка совпадает с f64, обёртка исчезает при компиляции// разбор: Кортежная структура с одним полем имеет ту же раскладку, что и поле: никаких тегов и косвенности. Поэтому newtype — стандартный приём и для обхода orphan rule, и для типобезопасности: Meters и Seconds перестают складываться друг с другом, а машинный код остаётся прежним.
- Что делает impl<T: Display> MyTrait for T { ... }?A)Реализует MyTrait разом для всех типов с Display — blanket implB)Создаёт заготовку, которую конкретные типы должны переопределитьC)Объявляет, что MyTrait требует Display как супертрейтD)Разрешает приводить Display-типы к MyTrait через as
показать ответ и разбор
+A)Реализует MyTrait разом для всех типов с Display — blanket impl// разбор: Сплошная реализация раздаёт поведение всему семейству типов сразу — так работает ToString: он реализован для каждого T: Display. Побочный эффект — конфликты: после такого impl отдельную реализацию MyTrait для конкретного типа с Display написать уже не выйдет, компилятор увидит перекрытие.
- Почему в стабильном Rust нет специализации — более конкретная реализация не перекрывает общую?A)Специализация требует рантайм-информации о типе, которой нетB)Компилятор не умеет сравнивать реализации по конкретностиC)Она несовместима с мономорфизацией дженериковD)Она ломает согласованность: выбор зависит от лайфтаймов
показать ответ и разбор
+D)Она ломает согласованность: выбор зависит от лайфтаймов// разбор: Наивная специализация позволяет заметить разницу там, где её быть не должно: лайфтаймы стираются, а поведение при этом менялось бы в зависимости от того, что компилятор знает в точке вызова — это дырка в надёжности. Фича живёт под флагом min_specialization и используется внутри стандартной библиотеки, где ограничения контролируются вручную.
- Реализовали From<u8> для своего типа C. Что появляется автоматически?A)TryFrom<u8> с проверкой диапазонаB)From<C> для u8 — обратное преобразованиеC)Into<C> для u8: 5u8.into() вернёт CD)Display для C через форматирование исходного значения
показать ответ и разбор
+C)Into<C> для u8: 5u8.into() вернёт C// разбор: В стандартной библиотеке есть сплошная реализация: если есть From<A> for B, то появляется Into<B> for A. Поэтому в API пишут ограничение impl Into<C>, а реализуют всегда From — так вызывающий получает и явный C::from(x), и короткий x.into(). Обратное преобразование и Display надо писать отдельно.
- Можно ли реализовать свой трейт для чужого типа, например для i32?A)Да, но только через newtype-обёртку вокруг исходного типаB)Нет: реализации для типов стандартной библиотеки запрещены во внешних крейтахC)Да: трейт ваш, и этого достаточно для соблюдения правила сиротстваD)Да, если пометить реализацию атрибутом, разрешающим внешние impl-блоки
показать ответ и разбор
+C)Да: трейт ваш, и этого достаточно для соблюдения правила сиротства// разбор: Правило требует, чтобы вашим был либо трейт, либо тип. Свой трейт для i32, для Vec<T>, для String — законная и очень частая практика: так работают крейты-расширения вроде itertools, добавляющие методы к чужим итераторам. Запрещена только комбинация «чужой трейт для чужого типа» — там и появляется newtype.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.