Хабрахабр

[Из песочницы] На пальцах: ассоциированные типы в Rust и в чём их отличие от аргументов типов

Разве недостаточно только последних, как во всех нормальных языках? Для чего в Rust есть ассоциированные типы (associated types), и в чём их отличие от аргументов типов (type arguments aka generics), ведь они так похожи? Давайте разбираться. У тех, кто только начинает изучать Rust, а особенно у людей, пришедших из других языков ("Это же дженерики!" — скажет умудрённый годами джавист), такой вопрос возникает регулярно.

TL;DR Первые контролирует вызываемый код, вторые — вызывающий.

Дженерики vs ассоциированные типы

Выглядит это примерно так: Итак, у нас уже есть аргументы типов, или всеми любимые "дженерики".

trait Foo<T> { fn bar(self, x: T);
}

Вроде бы этого должно быть достаточно всем (как 640 килобайт памяти). Здесь T как раз и есть аргумент типа. Но в Rust же есть ещё и ассоциированные типы, примерно такие:

trait Foo { type Bar; // Это ассоциированный тип fn bar(self, x: Self::Bar);
}

Зачем понадобилось вводить в язык ещё одну сущность? На первый взгляд те же яйца, но с другого ракурса. (Которой, кстати, в ранних версиях языка и не было.)

Даже если мы в месте вызова не укажем явно T, это сделает за нас компилятор, используя выведение типов. Аргументы типов — это именно аргументы, это означает что они передаются трейту в месте вызова, и контроль над тем, какой именно тип будет использован вместо T, принадлежит вызывающей стороне. (Разумеется, всё это происходит во время компиляции, а не в рантайме.) То есть неявно всё равно этот тип будет выведен на вызывающей стороне и передан как аргумент.

В стандартной библиотеке есть трейт AsRef, который позволяет одному типу на время прикинуться другим типом, преобразуя ссылку на себя в ссылку на что-то другое. Рассмотрим пример. Упрощённо этот трейт выглядит так (в реальности он чуть сложнее, я намеренно убрал всё лишнее, оставив лишь минимально необходимое для понимания):

trait AsRef<T> { fn as_ref(&self) -> &T;
}

Иными словами, именно вызывающая сторона решает, каким новым типом T будет прикидываться наш тип, реализующий этот трейт: Здесь тип T передаётся вызывающей стороной как аргумент, даже если это происходит неявно (если компилятор выведет этот тип за вас).

let foo = Foo::new();
let bar: &Bar = foo.as_ref();

Само собой, что тип Foo должен реализовывать трейт AsRef<Bar>, и помимо этого он может реализовать ещё сколько угодно других вариантов AsRef<T>, среди которых вызывающая сторона и выбирает нужный. Здесь компилятор, используя знание bar: &Bar, будет использовать реализацию AsRef<Bar> для вызова метода as_ref(), потому что именно тип Bar требуется вызывающей стороне.

Ассоциированный тип полностью контролируется тем, кто реализует данный трейт, а не вызывающей стороной. В случае с ассоциированным типом всё с точностью до наоборот.

Допустим, у нас есть коллекция, и мы хотим получить от неё итератор. Распространённый пример — итератор. В точности того, который содержится в этой коллекции! Значения какого типа должен возвращать итератор? Вот сокращённый код из стандартной библиотеки: Не вызывающая сторона должна решать, что вернёт итератор, а сам итератор лучше знает, что именно он умеет возвращать.

trait Iterator { type Item; fn next(&mut self) -> Option<Self::Item>;
}

Вместо этого, тип возвращаемого из метода next() значения определяется самим итератором с помощью ассоциированного типа, но при этом он не приколочен гвоздями, т.е. Заметьте, что у итератора нет параметра типа, который позволил бы вызывающей стороне выбрать, что должен вернуть итератор. каждая реализация итератора может выбрать свой тип.

Ну и что? Стоп. Представим на минутку, что мы используем обычный дженерик вместо ассоциированного типа. Всё равно непонятно, чем это лучше дженерика. Трейт итератора тогда будет выглядеть как-то так:

trait GenericIterator<T> { fn next(&mut self) -> Option<T>;
}

Вот пример: Но теперь, во-первых, тип T нужно снова и снова указывать в каждом месте где упоминается итератор, а во-вторых, теперь стало возможно реализовать этот трейт несколько раз с разными типами, что для итератора выглядит как-то странно.

struct MyIterator; impl GenericIterator<i32> for MyIterator
} impl GenericIterator<String> for MyIterator { fn next(&mut self) -> Option<String> { unimplemented!() }
} fn test() { let mut iter = MyIterator; let lolwhat: Option<_> = iter.next(); // Error! Which impl of GenericIterator to use?
}

Мы не можем просто взять и вызвать iter.next() без приседаний — нужно обязательно дать компилятору знать, явно или неявно, какой тип будет возвращаться. Видите подвох? А всё потому, что мы смогли имплементировать трейт GenericIterator дважды с разным параметром для одного и того же MyIterator, что с точки зрения семантики итератора также выглядит нелепицей: с чего это вдруг один и тот же итератор может возвращать значения разных типов? Да и выглядит это неуклюже: зачем нам, на стороне вызова, знать (и сообщать компилятору!) тип, который вернёт итератор, тогда как это итератор должен лучше нас знать, какой тип он возвращает?!

Если же вернуться к варианту с ассоциированным типом, то всех этих проблем можно избежать:

struct MyIter; impl Iterator for MyIter { type Item = String; fn next(&mut self) -> Option<Self::Item> { unimplemented!() }
} fn test() { let mut iter = MyIter; let value = iter.next();
}

Здесь, во-первых, компилятор без лишних слов правильно выведет тип value: Option<String>, а во-вторых, не получится реализовать трейт Iterator для MyIter второй раз с другим типом возвращаемого значения, и тем самым всё испортить.

Коллекция может реализовать вот такой трейт, чтобы уметь превращать себя в итератор: Для закрепления.

trait IntoIterator { type Item; type IntoIter: Iterator<Item=Self::Item>; fn into_iter(self) -> Self::IntoIter;
}

И опять-таки, тут именно коллекция решает, какой это будет итератор, а именно: итератор, тип возвращаемого значения которого совпадает с типом элементов самой коллекции, и никакой другой.

Ещё более "на пальцах"

Аргументы типов можно рассматривать как "входную" информацию, которую мы предоставляем, чтобы трейт работал. Если примеры выше всё равно непонятны, то вот ещё менее научное но более доходчивое объяснение. Ассоциированные типы можно рассматривать как "выходную" информацию, которую трейт предоставляет нам, чтобы мы могли воспользоваться результатами его работы.

Для этого нужно реализовать один из соответствующих трейтов из стандартной библиотеки. В стандартной библиотеке есть возможность перегружать для своих типов математические операторы (сложение, вычитание, умножение, деление и тому подобное). Вот, например, как выглядит этот трейт для операции сложения (опять же, упрощённо):

trait Add<RHS> { type Output; fn add(self, rhs: RHS) -> Self::Output;
}

И есть "выходной" аргумент Add::Output — это тот тип, который получится в результате сложения. Тут у нас есть "входной" аргумент RHS — это тип, к которому мы будем применять операцию сложения с нашим типом. Первый задан с помощью аргумента типа, второй — с помощью асоциированного типа. В общем случае он может отличаться от типа слагаемых, которые, в свою очередь, тоже могут быть разных типов (к синему прибавить вкусное, и получить мягкое — а что, я так всё время делаю).

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

Попробуем реализовать этот трейт:

use std::ops::Add; struct Foo(&'static str); #[derive(PartialEq, Debug)]
struct Bar(&'static str, i32); impl Add<i32> for Foo { type Output = Bar; fn add(self, rhs: i32) -> Bar { Bar(self.0, rhs) }
} fn test() { let x = Foo("test"); let y = x + 42; // Компилятор преобразует это в вызов <Foo as Add>::add(42) для x assert_eq!(y, Bar("test", 42));
}

Было бы очень странно, если бы было возможно написать что-то вроде let y: Baz = x + 42, то есть заставить операцию сложения вернуть результат какого-то постороннего типа. В этом примере тип переменной y определяется алгоритмом сложения, а не вызывающим кодом. Как раз от таких вещей нас и страхует ассоциированный тип Add::Output.

Итого

Используем ассоциированные типы там, где мы хотим иметь одну "каноничную" реализацию, которая сама контролирует типы. Используем дженерики там, где мы не против иметь несколько реализаций трейта для одного типа, и где приемлемо указывать конкретную реализацию на стороне вызова. Сочетаем и смешиваем в нужных пропорциях, как в последнем примере.

Добейте меня комментариями. Провалилась монетка?

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть