Обращение с умными указателями как с обычными ссылками с помощью Deref типажа

Используя трейт Deref, вы можете изменить поведение оператора разыменования * (не путать с операторами умножения или глобального подключения). Реализовав Deref таким образом, что умный указатель может рассматриваться как обычная ссылка, вы можете писать код, оперирующий ссылками, а также использовать этот код с умными указателями.

Давайте сначала посмотрим, как работает оператор разыменования с обычными ссылками. Затем мы попытаемся определить пользовательский тип, который ведёт себя как Box<T> и посмотрим, почему оператор разыменования не работает как ссылка для нового объявленного типа. Мы рассмотрим, как реализация типажа Deref делает возможным работу умных указателей аналогично ссылкам. Затем посмотрим на разыменованное приведение (deref coercion) в Rust и как оно позволяет работать с любыми ссылками или умными указателями.

Примечание: есть одна большая разница между типом MyBox<T>, который мы собираемся создать и реальным Box<T>: наша версия не будет хранить свои данные в куче. В примере мы сосредоточимся на типаже Deref, поэтому менее важно то, где данные хранятся, чем поведение подобное указателю.

Следуя за указателем на значение

Обычная ссылка - это разновидность указателя, а указатель можно рассматривать как своеобразную стрелочку направляющую к значению, хранящемуся в другом месте. В листинге 15-6 мы создаём ссылку на значение i32, а затем используем оператор разыменования для перехода от ссылки к значению:

Файл: src/main.rs

fn main() {
    let x = 5;
    let y = &x;

    assert_eq!(5, x);
    assert_eq!(5, *y);
}

Листинг 15-6: Использование оператора разыменования для следования по ссылке к значению i32

Переменной x присвоено значение5 типа i32. Мы установили в качестве значения y ссылку на x. Мы можем утверждать, что значение x равно 5. Однако, если мы хотим сделать утверждение о значении в y, мы должны использовать *y, чтобы перейти по ссылке к значению, на которое она указывает (таким образом, происходит разыменование), для того чтобы компилятор при сравнении мог использовать фактическое значение. Как только мы разыменуем y, мы получим доступ к целочисленному значению, на которое указывает y, которое и будем сравнивать с 5.

Если бы мы попытались написать assert_eq!(5, y);, то получили ошибку компиляции:

$ cargo run
   Compiling deref-example v0.1.0 (file:///projects/deref-example)
error[E0277]: can't compare `{integer}` with `&{integer}`
 --> src/main.rs:6:5
  |
6 |     assert_eq!(5, y);
  |     ^^^^^^^^^^^^^^^^ no implementation for `{integer} == &{integer}`
  |
  = help: the trait `PartialEq<&{integer}>` is not implemented for `{integer}`
  = help: the following other types implement trait `PartialEq<Rhs>`:
            isize
            i8
            i16
            i32
            i64
            i128
            usize
            u8
          and 6 others
  = note: this error originates in the macro `assert_eq` (in Nightly builds, run with -Z macro-backtrace for more info)

For more information about this error, try `rustc --explain E0277`.
error: could not compile `deref-example` (bin "deref-example") due to 1 previous error

Сравнение числа и ссылки на число не допускается, потому что они различных типов. Мы должны использовать оператор разыменования, чтобы перейти по ссылке на значение, на которое она указывает.

Использование Box<T> как ссылку

Мы можем переписать код в листинге 15-6, чтобы использовать Box<T> вместо ссылки; оператор разыменования, используемый для Box<T> в листинге 15-7, работает так же, как оператор разыменования, используемый для ссылки в листинге 15-6:

Файл: src/main.rs

fn main() {
    let x = 5;
    let y = Box::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}

Листинг 15-7: Использование оператора разыменования с типом Box<i32>

Основное различие между листингом 15-7 и листингом 15-6 заключается в том, что здесь мы устанавливаем y как экземпляр Box<T>, указывающий на скопированное значение x, а не как ссылку, указывающую на значение x. В последнем утверждении мы можем использовать оператор разыменования, чтобы проследовать за указателем Box<T> так же, как мы это делали, когда y был ссылкой. Далее мы рассмотрим, что особенного в Box<T>, что позволяет нам использовать оператор разыменования, определяя наш собственный тип.

Определение собственного умного указателя

Давайте создадим умный указатель, похожий на тип Box<T> предоставляемый стандартной библиотекой, чтобы понять как поведение умных указателей отличается от поведения обычной ссылки. Затем мы рассмотрим вопрос, как добавить возможность использовать оператор разыменования.

Тип Box<T> в конечном итоге определяется как структура кортежа с одним элементом, поэтому в листинге 15-8 аналогичным образом определяется MyBox<T>. Мы также определим функцию new, чтобы она соответствовала функции new, определённой в Box<T>.

Файл: src/main.rs

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn main() {}

Листинг 15-8: Определение типа MyBox<T>

Мы определяем структуру с именем MyBox и объявляем обобщённый параметр T, потому что мы хотим, чтобы наш тип хранил значения любого типа. Тип MyBox является структурой кортежа с одним элементом типа T. Функция MyBox::new принимает один параметр типа T и возвращает экземпляр MyBox, который содержит переданное значение.

Давайте попробуем добавить функцию main из листинга 15-7 в листинг 15-8 и изменим её на использование типа MyBox<T>, который мы определили вместо Box<T>. Код в листинге 15-9 не будет компилироваться, потому что Rust не знает, как разыменовывать MyBox.

Файл: src/main.rs

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn main() {
    let x = 5;
    let y = MyBox::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}

Листинг 15-9. Попытка использовать MyBox<T> таким же образом, как мы использовали ссылки и Box<T>

Вот результат ошибки компиляции:

$ cargo run
   Compiling deref-example v0.1.0 (file:///projects/deref-example)
error[E0614]: type `MyBox<{integer}>` cannot be dereferenced
  --> src/main.rs:14:19
   |
14 |     assert_eq!(5, *y);
   |                   ^^

For more information about this error, try `rustc --explain E0614`.
error: could not compile `deref-example` (bin "deref-example") due to 1 previous error

Наш тип MyBox<T> не может быть разыменован, потому что мы не реализовали эту возможность. Чтобы включить разыменование с помощью оператора *, мы реализуем типаж Deref.

Трактование типа как ссылки реализуя типаж Deref

Как обсуждалось в разделе “Реализация трейта для типа” Главы 10, для реализации типажа нужно предоставить реализации требуемых методов типажа. Типаж Deref, предоставляемый стандартной библиотекой требует от нас реализации одного метода с именем deref, который заимствует self и возвращает ссылку на внутренние данные. Листинг 15-10 содержит реализацию Deref добавленную к определению MyBox:

Файл: src/main.rs

use std::ops::Deref;

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn main() {
    let x = 5;
    let y = MyBox::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}

Листинг 15-10: Реализация Deref для типа MyBox<T>

Синтаксис type Target = T; определяет связанный тип для использования у типажа Deref. Связанные типы - это немного другой способ объявления обобщённого параметра, но пока вам не нужно о них беспокоиться; мы рассмотрим их более подробно в главе 19.

Мы заполним тело метода deref оператором &self.0 , чтобы deref вернул ссылку на значение, к которому мы хотим получить доступ с помощью оператора *; вспомним из раздела "Using Tuple Structs without Named Fields to Create Different Types" главы 5, что .0 получает доступ к первому значению в кортежной структуре. Функция main в листинге 15-9, которая вызывает * для значения MyBox<T>, теперь компилируется, и проверки проходят!

Без типажа Deref компилятор может только разыменовывать & ссылки. Метод deref даёт компилятору возможность принимать значение любого типа, реализующего Deref и вызывать метод deref чтобы получить ссылку &, которую он знает, как разыменовывать.

Когда мы ввели *y в листинге 15-9, Rust фактически выполнил за кулисами такой код:

*(y.deref())

Rust заменяет оператор * вызовом метода deref и затем простое разыменование, поэтому нам не нужно думать о том, нужно ли нам вызывать метод deref. Эта функция Rust позволяет писать код, который функционирует одинаково, независимо от того, есть ли у нас обычная ссылка или тип, реализующий типаж Deref.

Причина, по которой метод deref возвращает ссылку на значение, и что простое разыменование вне круглых скобок в *(y.deref()) все ещё необходимо, связана с системой владения. Если бы метод deref возвращал значение напрямую, а не ссылку на него, значение переместилось бы из self. Мы не хотим передавать владение внутренним значением внутри MyBox<T> в этом случае и в большинстве случаев, когда мы используем оператор разыменования.

Обратите внимание, что оператор * заменён вызовом метода deref, а затем вызовом оператора * только один раз, каждый раз, когда мы используем * в коде. Поскольку замена оператора * не повторяется бесконечно, мы получаем данные типа i32, которые соответствуют 5 в assert_eq! листинга 15-9.

Неявные разыменованные приведения с функциями и методами

Разыменованное приведение преобразует ссылку на тип, который реализует признак Deref, в ссылку на другой тип. Например, deref coercion может преобразовать &String в &str, потому что String реализует признак Deref, который возвращает &str. Deref coercion - это удобный механизм, который Rust использует для аргументов функций и методов, и работает только для типов, реализующих признак Deref. Это происходит автоматически, когда мы передаём в качестве аргумента функции или метода ссылку на значение определённого типа, которое не соответствует типу параметра в определении функции или метода. В результате серии вызовов метода deref тип, который мы передали, преобразуется в тип, необходимый для параметра.

Разыменованное приведение было добавлено в Rust, так что программистам, пишущим вызовы функций и методов, не нужно добавлять множество явных ссылок и разыменований с помощью использования & и *. Функциональность разыменованного приведения также позволяет писать больше кода, который может работать как с ссылками, так и с умными указателями.

Чтобы увидеть разыменованное приведение в действии, давайте воспользуемся типом MyBox<T> определённым в листинге 15-8, а также реализацию Deref добавленную в листинге 15-10. Листинг 15-11 показывает определение функции, у которой есть параметр типа срез строки:

Файл: src/main.rs

fn hello(name: &str) {
    println!("Hello, {name}!");
}

fn main() {}

Листинг 15-11: Функция hello имеющая параметр name типа &str

Можно вызвать функцию hello со срезом строки в качестве аргумента, например hello("Rust");. Разыменованное приведение делает возможным вызов hello со ссылкой на значение типа MyBox<String>, как показано в листинге 15-12.

Файл: src/main.rs

use std::ops::Deref;

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &T {
        &self.0
    }
}

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn hello(name: &str) {
    println!("Hello, {name}!");
}

fn main() {
    let m = MyBox::new(String::from("Rust"));
    hello(&m);
}

Листинг 15-12: Вызов hello со ссылкой на значение MyBox<String>, которое работает из-за разыменованного приведения

Здесь мы вызываем функцию hello с аргументом &m, который является ссылкой на значение MyBox<String>. Поскольку мы реализовали типаж Deref для MyBox<T> в листинге 15-10, то Rust может преобразовать &MyBox<String> в &String вызывая deref. Стандартная библиотека предоставляет реализацию типажа Deref для типа String, которая возвращает срез строки, это описано в документации API типажа Deref. Rust снова вызывает deref, чтобы превратить &String в &str, что соответствует определению функции hello.

Если бы Rust не реализовал разыменованное приведение, мы должны были бы написать код в листинге 15-13 вместо кода в листинге 15-12 для вызова метода hello со значением типа &MyBox<String>.

Файл: src/main.rs

use std::ops::Deref;

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &T {
        &self.0
    }
}

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn hello(name: &str) {
    println!("Hello, {name}!");
}

fn main() {
    let m = MyBox::new(String::from("Rust"));
    hello(&(*m)[..]);
}

Листинг 15-13: Код, который нам пришлось бы написать, если бы в Rust не было разыменованного приведения ссылок

Код (*m) разыменовывает MyBox<String> в String. Затем & и [..] принимают строковый срез String, равный всей строке, чтобы соответствовать сигнатуре hello. Код без разыменованного приведения сложнее читать, писать и понимать со всеми этими символами. Разыменованное приведение позволяет Rust обрабатывать эти преобразования для нас автоматически.

Когда типаж Deref определён для задействованных типов, Rust проанализирует типы и будет использовать Deref::deref столько раз, сколько необходимо, чтобы получить ссылку, соответствующую типу параметра. Количество раз, которое нужно вставить Deref::deref определяется во время компиляции, поэтому использование разыменованного приведения не имеет накладных расходов во время выполнения!

Как разыменованное приведение взаимодействует с изменяемостью

Подобно тому, как вы используете типаж Deref для переопределения оператора * у неизменяемых ссылок, вы можете использовать типаж DerefMut для переопределения оператора * у изменяемых ссылок.

Rust выполняет разыменованное приведение, когда находит типы и реализации типажей в трёх случаях:

  • Из типа &T в тип &U когда верно T: Deref<Target=U>
  • Из типа &mut T в тип &mut U когда верно T: DerefMut<Target=U>
  • Из типа &mut T в тип &U когда верно T: Deref<Target=U>

Первые два случая идентичны друг другу, за исключением того, что второй реализует изменяемость. В первом случае говорится, что если у вас есть &T, а T реализует Deref для некоторого типа U, вы сможете прозрачно получить &U. Во втором случае говорится, что такое же разыменованное приведение происходит и для изменяемых ссылок.

Третий случай хитрее: Rust также приводит изменяемую ссылку к неизменяемой. Но обратное не представляется возможным: неизменяемые ссылки никогда не приводятся к изменяемым ссылкам. Из-за правил заимствования, если у вас есть изменяемая ссылка, эта изменяемая ссылка должна быть единственной ссылкой на данные (в противном случае программа не будет компилироваться). Преобразование одной изменяемой ссылки в неизменяемую ссылку никогда не нарушит правила заимствования. Преобразование неизменяемой ссылки в изменяемую ссылку потребует наличия только одной неизменяемой ссылки на эти данные, и правила заимствования не гарантируют этого. Следовательно, Rust не может сделать предположение, что преобразование неизменяемой ссылки в изменяемую ссылку возможно.