Назад к блогу

Пользовательские умные указатели в Rust: как воспроизвести гибкость встроенных ссылок

Пользовательские умные указатели в Rust: как воспроизвести гибкость встроенных ссылок

Реализация `Deref` и `DerefMut` превращает пользовательский тип в почти полноценную замену встроенной ссылки: он начинает разыменовываться через `*`, участвовать в неявных приведениях и автоматически получать методы целевого типа. Разбираем, какие именно механизмы компилятора стоят за этой гибкостью, как её воспроизвести у себя и почему `Box`, `Rc` и `Arc` ведут себя как ссылки.

Встроенные ссылки &T и &mut T умеют то, чего не умеет произвольный тип: разыменовываться через *, автоматически приводиться к целевым типам в параметрах функций, неявно получать методы целевого типа и участвовать в поиске метода как получатель. Вопрос в том, какие именно трейты и правила компилятора дают эти свойства и что нужно реализовать в своём типе, чтобы он вёл себя так же. Нетривиальность в том, что за «гибкость ссылок» отвечают сразу несколько независимых механизмов, и часть из них компилятор включает неявно, без явного запроса с вашей стороны.

Что именно нужно воспроизвести

Чтобы пользовательский тип вёл себя «как ссылка», нужно реализовать трейт Deref. Он применяется для неизменяемого разыменования через *v и задаёт ассоциированный тип Target — тип, получаемый после разыменования. Реализация Deref даёт сразу несколько эффектов: в неизменяемых контекстах *v эквивалентно *Deref::deref(&v), значения &T приводятся к &U, а сам тип T неявно получает все методы типа U, принимающие получатель &self.

Для изменяемости через *v = 1 нужен отдельный трейт DerefMut. Он применяется для изменяемого разыменования и даёт в изменяемых контекстах эквивалентность *v и *DerefMut::deref_mut(&mut v), приведение &mut T к &mut U и неявное получение изменяемых методов типа U.

Автодереференс в вызовах методов обеспечивается тем, что при поиске кандидатов методов Rust исследует цепочку возможных Receiver, а Receiver автоматически реализуется для любого типа, реализующего Deref.

Для встроенных ссылок готовые реализации уже есть: &T реализует Deref с Target = T и явно не реализует DerefMut, а &mut T реализует и Deref, и DerefMut.

Как это устроено у Box, Rc и Arc

Box<T, A> реализует Deref, возвращая &T из самого себя, и DerefMut, возвращая &mut T, что даёт разыменование к содержимому. Rc<T, A> реализует Deref через обращение к полю value внутренней структуры RcInner. Arc<T, A> реализует Deref через обращение к полю data внутренней структуры.

Именно Receiver даёт «гибкость ссылок»: Box, Rc, Arc получают его через свой Deref. Отдельно существует DerefPure — он реализован для Box, Rc и Arc.

Трейт Deref: контракт и сигнатура

Трейт объявлен так:

pub trait Deref {
    type Target: ?Sized;
    fn deref(&self) -> &Self::Target;
}

Он содержит связанный тип Target и обязательный метод deref, принимающий &self и возвращающий ссылку на Target. Ограничение ?Sized снимает неявное требование Sized и позволяет целевым типам быть неразмерными — например, срезами или str. Метод deref помечен #[must_use].

Deref применяется для неизменяемого разыменования, в том числе неявно компилятором в механизме, называемом Deref coercion, при этом в изменяемых контекстах используется DerefMut. Реализация для ссылок задаёт type Target = T и возвращает self, то есть разыменование ссылки даёт саму ссылку.

Что такое deref coercion и чем он отличается от явного *v

Deref coercion — это механизм, при котором компилятор неявно вставляет вызовы Deref::deref (в изменяемых контекстах — DerefMut::deref_mut), тогда как явный *v — это явная операция разыменования.

Если T реализует Deref<Target = U>, то в неизменяемых контекстах *v (где T — не ссылка и не сырой указатель) эквивалентно *Deref::deref(&v), значения типа &T приводятся к &U, а T неявно получает все методы U, принимающие &self. В изменяемых контекстах аналогично работает DerefMut: *v эквивалентно *DerefMut::deref_mut(&mut v), &mut T приводится к &mut U, а T неявно получает изменяемые методы U.

При поиске метода компилятор строит список типов-кандидатов, повторно разыменовывая тип получателя и добавляя &T и &mut T сразу после каждого T, а в конце пытается выполнить array unsized coercion. Например, для получателя типа Box<[i32;2]> кандидатами будут Box<[i32;2]>, &Box<[i32;2]>, &mut Box<[i32;2]>, [i32; 2], &[i32; 2], &mut [i32; 2], [i32], &[i32] и &mut [i32]. При индексации array[0] компилятор преобразует её в array.index(0) и, если Index не реализован, последовательно разыменовывает тип, пока не найдёт реализацию Index.

Почему deref не должен падать

В разделе «Fallibility» сказано, что метод этого трейта не должен неожиданно завершаться ошибкой, потому что deref-коэрция заставляет компилятор часто вставлять вызовы Deref::deref неявно, и сбой при разыменовании может быть крайне запутанным, когда Deref вызывается неявно. В большинстве случаев он должен быть безотказным, хотя паника допустима при неправильном использовании типа из-за ошибки программиста. При этом безотказность не принудительно обеспечивается и потому не гарантируется, поэтому unsafe-код не должен в общем случае полагаться на безотказность для корректности.

Про generic-реализации вроде Box<T> в этом разделе не говорится; там сказано, что такие реализации должны предоставлять мало методов или не предоставлять их вовсе, так как целевой тип неизвестен и каждый метод может столкнуться с методом целевого типа, что запутает пользователей, и что impl<T> Box<T> не имеет методов (хотя имеет несколько ассоциированных функций), отчасти по этой причине.

Трейт DerefMut: изменяемое разыменование

Сигнатура в документации:

pub trait DerefMut: Deref {
    fn deref_mut(&mut self) -> &mut Self::Target;
}

Это трейт с обязательным методом deref_mut, принимающим изменяемую ссылку на себя и возвращающим изменяемую ссылку на связанный тип Target. В исходном коде объявление выглядит как pub const trait DerefMut: [const] Deref + PointeeSized, где после двоеточия перечислены супертрейты, включая Deref.

Назначение метода — изменяемое разыменование, применяемое как явно через * в изменяемых контекстах, так и неявно компилятором в механизме «mutable deref coercion». Именно потому, что DerefMut расширяет Deref, тип, реализующий изменяемое разыменование, обязан также предоставлять неизменяемое: в неизменяемых контекстах используется Deref. Для реализации DerefMut сначала реализуют Deref с type Target = T и fn deref(&self) -> &Self::Target, а затем DerefMut с fn deref_mut(&mut self) -> &mut Self::Target.

Как работает mutable deref coercion

Для изменяющего разыменования *v = 1; используется трейт DerefMut: если T реализует DerefMut<Target = U>, а v имеет тип T, то в изменяемых контекстах *v (где T — не ссылка и не сырой указатель) эквивалентно *DerefMut::deref_mut(&mut v). Компилятор также неявно вставляет вызовы DerefMut::deref_mut в других ситуациях, и этот механизм называется «mutable deref coercion»; в неизменяемых контекстах вместо него используется Deref.

При передаче в функции значения типа &mut T приводятся (coerced) к значениям типа &mut U, а сам T неявно получает все изменяемые методы типа U. Выбор между deref_mut и deref определяется контекстом: в изменяемых контекстах применяется DerefMut, а в неизменяемых — Deref.

При поиске метода получатель автоматически разыменовывается и заимствуется: строится список типов-кандидатов путём повторного разыменования типа выражения-получателя, и для каждого кандидата T сразу после него добавляются &T и &mut T, после чего для каждого типа по порядку ищется видимый метод.

Автодереференс в вызовах методов: порядок поиска

Компилятор сначала строит список типов-кандидатов получателя: он многократно разыменовывает тип выражения-получателя, добавляя каждый встреченный тип в список, а в конце пытается выполнить array unsized coercion и добавляет тип-результат, если это удалось. Затем для каждого кандидата T в список сразу после T добавляются &T и &mut T.

После этого для каждого типа-кандидата T по порядку ищется видимый метод с получателем этого типа: сначала среди собственных (inherent) методов T, затем среди методов видимых трейтов, реализованных для T (для параметра типа сначала методы из trait bounds, затем все остальные методы в области видимости). Поиск идёт по каждому типу по порядку, что иногда даёт неожиданные результаты: например, методы с &self находятся раньше, чем метод структуры с &mut self.

Процесс не учитывает мутабельность или время жизни получателя и не учитывает, является ли метод unsafe; если метод найден, но не может быть вызван по этим причинам, это ошибка компилятора. Если на каком-то шаге возможно более одного метода, это ошибка компилятора, и требуется disambiguating function call syntax.

Неоднозначность и правило редакции 2021

Если на каком-то шаге оказывается более одного возможного метода — например, когда generic-методы или трейты считаются одинаковыми, — это ошибка компилятора, и требуются disambiguating function call syntax для вызова метода или функции. Если в результате получается несколько возможных кандидатов, это тоже ошибка, и получатель должен быть преобразован к подходящему типу получателя, чтобы сделать вызов метода.

Правило редакции 2021 касается другого случая: до редакции 2021 при поиске видимых методов, если тип-кандидат получателя является типом массива, методы, предоставляемые стандартной библиотекой через трейт IntoIterator, игнорировались. редакция. Используемая для этого правила редакция определяется токеном, и этот особый случай может быть удалён в будущем.

Цепочка разыменований на примере Rc<Box<[T; 3]>>

Сначала array[0] — это синтаксический сахар для трейта Index: компилятор преобразует array[0] в array.index(0), а затем проверяет, реализует ли array трейт Index, чтобы вызвать эту функцию. Для типа Rc<Box<[T; 3]>> проверка не проходит: ни сам Rc<Box<[T; 3]>>, ни &Rc<Box<[T; 3]>>, ни &mut Rc<Box<[T; 3]>> не реализуют Index, поэтому компилятор разыменовывает Rc<Box<[T; 3]>> в Box<[T; 3]> и пробует снова.

На типе Box<[T; 3]> (а также &Box<[T; 3]> и &mut Box<[T; 3]>) Index тоже не реализован, поэтому происходит ещё одно разыменование до [T; 3]. У [T; 3] и его автоссылок Index также нет, и разыменовать [T; 3] уже нельзя, поэтому компилятор выполняет unsize-преобразование массива в срез [T]. Именно [T] реализует Index, так что на этом шаге компилятор может вызвать настоящую функцию index.

Конфликт clone у &T и Clone::clone у T

При вызове value.clone() компилятор сначала строит список типов-кандидатов для получателя: многократно разыменовывает тип выражения-получателя, добавляя каждый встреченный тип, а затем сразу после каждого типа T добавляет &T и &mut T. Затем для каждого типа-кандидата по порядку ищется видимый метод: сначала собственные методы типа T, затем методы видимых трейтов, реализованных для T, причём для параметра типа сначала ищутся методы из ограничений трейтов на T.

В примере do_stuff<T: Clone> тип value — это &T, поэтому сначала проверяется вызов по значению: clone имеет сигнатуру fn clone(&T) -> T, и поскольку известно, что T: Clone, компилятор выводит cloned: T. Если ограничение T: Clone убрать, вызов по значению невозможен, так как для T нет реализации Clone, и компилятор пробует автоссылку: тогда функция имеет сигнатуру fn clone(&&T) -> &T, потому что Self = &T, компилятор видит, что &T: Clone, и выводит cloned: &T. Если в результате поиска оказывается несколько возможных кандидатов, это ошибка, и получатель нужно преобразовать к подходящему типу получателя, либо использовать однозначный синтаксис вызова функции.

Трейт Receiver: как тип становится получателем метода

Receiver помечен как «Indicates that a struct can be used as a method receiver», то есть указывает, что структуру можно использовать в качестве типа получателя метода — как тип self. У него есть ассоциированный тип Target — «The target type on which the method may be called», то есть тип, на котором фактически вызывается метод.

Для любого типа P, реализующего Deref<Target = T>, автоматически (blanket impl) реализуется Receiver с type Target = T, поэтому Box<T>, Rc<T>, &T и Pin<P> уже являются получателями. При поиске методов Rust перебирает цепочку возможных Receiver-ов, поэтому работают методы с self: &Box<Self> и self: &Rc<Box<Self>> при вызове на Rc<Box<MyContainedType>>.

Разрешение вызовов методов устроено так: сначала строится список типов-кандидатов путём повторного разыменования типа выражения-получателя, затем для каждого T в список сразу после него добавляются &T и &mut T, а в конце предпринимается array unsized coercion. Для каждого кандидата T ищутся inherent-методы T, затем методы видимых трейтов, реализованных для T.

Чем Receiver отличается от LegacyReceiver

Receiver требует указать связанный тип Target — тип, на котором метод может быть вызван. Он реализован обобщённо для всех типов, реализующих Deref, поэтому обычно реализовывать его вручную не нужно: «This trait is blanket implemented for any type which implements Deref».

LegacyReceiver реализован только для &T и &mut T, и он предназначен для использования без нестабильной возможности arbitrary_self_types: «Indicates that a struct can be used as a method receiver, without the arbitrary_self_types feature».

Ограничение для пользовательских умных указателей состоит в том, что LegacyReceiver реализован лишь для ссылок, поэтому собственный умный указатель через него получателем метода сделать нельзя; для этого нужен Receiver, но его ручная реализация оправдана только если тип не может реализовать Deref: «You'll typically do this only if you need to implement a smart pointer type which can't implement Deref». При этом сам Receiver помечен как нестабильный: #[unstable(feature = "arbitrary_self_types", issue = "44874")], а LegacyReceiver объявлен скрытым и подлежащим скорому удалению: «This trait will shortly be removed and replaced with a more generic facility».

Практика: newtype и deref coercion

В примере объявлена структура-обёртка struct Username(String), и для неё реализован трейт Deref с указанием целевого типа: type Target = str;. Метод fn deref(&self) -> &Self::Target возвращает &self.0, то есть ссылку на внутреннюю строку, приведённую к &str.

Благодаря этому компилятор при несовпадении типов автоматически вставляет вызов .deref() — это и есть deref coercion. Поэтому для значения name типа Username работают методы целевого типа, принимающие &self: name.len() и name.contains("A"). Также значение можно передать туда, где ожидается &str: greet(&name) работает автоматически, потому что значения типа &T приводятся к &U.

Когда Deref вредит

Паттерн с обёрткой и Deref стоит применять только тогда, когда обёртка логически является «умным указателем» или расширением внутреннего типа. Если же методы внутреннего типа способны нарушить инварианты структуры-обёртки, то обёртка логически не является внутренним типом, и вместо Deref следует делегировать методы вручную.

Вред Deref в таком случае в том, что компилятор молча вставляет вызовы Deref::deref, и через deref coercion обёртка неявно получает все методы целевого типа с получателем &self, что открывает доступ к методам, ломающим инварианты. Официальная документация также предостерегает: реализовывать deref-трейты не следует, если реализация deref может неожиданно завершиться ошибкой, если у типа есть методы, способные столкнуться с методами целевого типа, или если фиксация deref coercion в публичном API нежелательна.

Вариантность и PhantomData

Вариантность типа F<T> бывает ковариантной (подтип T даёт подтип F<T>), контравариантной (наоборот) или инвариантной (никаких отношений). У ссылок Rust распределение такое: &'a T ковариантен по 'a и по T, а &'a mut T ковариантен по 'a, но инвариантен по T, потому что через &mut T можно записать. Если бы &mut T был ковариантен, можно было бы взять &mut Vec<&'static str>, привести к &mut Vec<&'short str> и записать туда короткоживущую ссылку, оставив висячую ссылку внутри Vec<&'static str>.

Для обёртки вида struct MyCell<T> { ptr: *mut T } компилятор по умолчанию выводит вариантность из полей: у сырого указателя *mut T инвариантность по T, у *const T ковариантность. Явный контроль задаётся через PhantomData:

struct Covariant<T>(*const T, PhantomData<T>);      // ковариантен по T, как &T
struct Invariant<T>(*mut T, PhantomData<*mut T>);   // инвариантен по T, как &mut T или Cell<T>
struct Contravariant<T>(PhantomData<fn(T)>);        // контравариантен

Так же в std поле UnsafeCell<T> делает Cell<T> и RefCell<T> инвариантными, иначе можно было бы через ковариантность подделать тип.

Pin и !Unpin

Pin<Ptr> даёт изменяемый доступ к цели только тогда, когда цель реализует Unpin. В документации прямо сказано: «The Target type is restricted to Unpin types as it's not safe to obtain a mutable reference to a pinned value.» Это потому, что через &mut можно переместить значение (например, mem::swap), а для пришпиленного значения это нарушило бы инвариант Pin.

Изменяемое разыменование указателя внутри Pin выдаёт изменяемую ссылку на содержимое только при условии, что цель указателя реализует Unpin. Именно это условие и обеспечивает безопасность изменяемого доступа — разыменование допустимо лишь тогда, когда значение можно перемещать.

Для самоссылающихся структур (например, async-стейт-машин, где поля ссылаются на другие поля той же структуры) это означает, что получить &mut к содержимому Pin нельзя, иначе ссылки станут висячими при перемещении. Поэтому такие типы помечают !Unpin (например, через PhantomPinned), и работать с ними можно только через unsafe-методы вроде get_unchecked_mut, беря на себя обязательство не перемещать данные. Дополнительно реализация DerefMut для Pin устроена так, что сторонние крейты не могут реализовать её для Pin<&LocalType>, так как это позволило бы обойти ограничение Unpin.

Dropck и #[may_dangle]

Dropck — механизм, который проверяет, что в момент вызова Drop::drop для значения T все ссылки внутри T ещё валидны; по умолчанию компилятор требует, чтобы любой 'a внутри T пережил сам T.

[[term:#[may_dangle]|обещание компилятору, что в Drop значение типа T не будет использоваться, поэтому T может быть уже невалидным к моменту вызова]] живёт за unsafe, потому что нарушение обещания — UB. Vec<T> может позволить себе unsafe impl<#[may_dangle] T> Drop for Vec<T>, потому что его Drop не трогает T, а только освобождает аллокацию. Обычная обёртка без такого атрибута не может разрешить короткоживущие ссылки, так как dropck по умолчанию запрещает компилироваться даже коду, где Drop ссылку не трогает, потому что компилятор не верит на слово.

Аналогично Box<T, A> имеет unsafe impl<#[may_dangle] T: ?Sized, A: Allocator> Drop, и в его drop T уничтожается компилятором до запуска деструктора, а сам деструктор только освобождает память. Здесь A — это Allocator: он параметризует Box аллокатором, и деструктор освобождает память через него.

Что из этого следует на практике

  • Минимальный набор для «поведения ссылки» — Deref (даёт *v, приведение &T к &U, неявные методы с &self) и DerefMut (даёт *v = 1, приведение &mut T к &mut U, изменяемые методы). Receiver обычно не реализуют вручную: он появляется автоматически из Deref.
  • DerefMut нельзя реализовать без Deref — это супертрейт, и изменяемое разыменование всегда тянет за собой неизменяемое.
  • Поиск метода идёт по списку кандидатов, где &T и &mut T стоят сразу после T, поэтому методы с &self могут находиться раньше собственных методов с &mut self — это источник неожиданных выборов и ошибок неоднозначности.
  • deref должен быть безотказным: компилятор вставляет его неявно, и падение внутри запутает диагностику. Безотказность при этом не гарантируется, поэтому unsafe-код не должен на неё опираться.
  • Реализовывать Deref стоит только когда обёртка логически является расширением целевого типа: иначе deref coercion открывает наружу методы, ломающие инварианты, и вместо этого методы делегируют вручную.
  • Вариантность обёртки выводится из её полей: *mut T даёт инвариантность, *const T — ковариантность; явный контроль — через PhantomData. Инвариантность по T (как у &mut T) нужна там, где через обёртку можно записать значение.
  • Pin<Ptr> даёт DerefMut только для Unpin-целей; для !Unpin-типов изменяемый доступ возможен лишь через unsafe-методы с обязательством не перемещать данные.
  • Собственный владеющий указатель на T с Drop по умолчанию не сможет принимать короткоживущие ссылки, пока не получит #[may_dangle] — и только если Drop действительно не трогает T.

Где смотреть в коде

Источники

Похожее