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

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


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


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


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


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

Здесь T как раз и есть аргумент типа. Вроде бы этого должно быть достаточно всем (как 640 килобайт памяти). Но в 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();

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


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


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


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 {
    fn next(&mut self) -> Option<i32> { unimplemented!() }
}

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;
}

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


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


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


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));
}

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


Итого


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


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

  • +49
  • 4,6k
  • 8
Поделиться публикацией

Комментарии 8

    +6
    Спасибо за доходчивое объяснение. На днях как раз задавался этим вопросом и читал, что пишут на форумах по этой теме. Эта статья показалась понятнее того, что я видел до этого.
      +4
      Классная статья. Очень доходчиво объяснено. Хотелось бы побольше таких статей по Rust.
      Можно по futures такую же, хотя это уже не сам язык, а его библиотека. Хотя у Rust документировано все классно на оф. сайте. Но для закрепления такое — самое то.
        +5
        По futures наверно рановато писать, пусть стабилизируют там всё. А вот сделать хороший обзор библиотек навроде Rayon и Crossbeam, наверно, не помешает.
          +1
          Хотелось бы еще и разбора Lifetime в раст, для новичков. =х
            +1
            Кажется, что уж по лайфтаймам-то точно много всего написано, это ж основы. В том же растбуке вполне себе доходчиво, кмк.
          +1

          С итератором — не самый лучший пример, во многих языках итератор сделан именно как Iterator<T> и никаких проблем этим нет. Тип T тут хоть и указывается вроде как внешним кодом, но реально точно так же задаётся реализацией, ведь только реализация решает какие трейты она реализует. А реализация двух итераторов и неоднозначный next — это выдуманная проблема, так просто не надо делать и всё.


          Куда интереснее дело обстоит с вскользь упомянутыми коллекциями! Тот же трейт IntoIterator (называемый в Java Iterable, а в C# — IEnumerable), при попытке реализовать его как IntoIterator<T>, начинает требовать динамической диспетчеризации для итератора. В принципе, его можно было бы реализовать и как IntoIterator<T, TIter: Iterator<T>> — но это уже выглядит не так красиво, плюс ограничение TIter: Iterator<T> пришлось бы дублировать при каждом упоминании IntoIterator.

            0

            Хотел было ответить, что можно же объявить метод в трейте IntoIterator<T> как fn into_iter(self) -> impl Iterator<T>; но тут внезапно осознал, что impl Trait не поддерживается для методов трейта. Так что да, вы правы, спасибо за ценное дополнение.

              0
              никаких проблем этим нет

              Ну как нет, проблема именно в отсутствии гарантий неоднозначности, что затрудняет вывод типов. И потом у вас получаются всякие functional dependencies и injective type families.

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

            Самое читаемое