Размышления о названиях трейтов итераторов

— 2025-01-20 Yoshua Wuyts Источник

  • глаголы, существительные и трейты
  • глагол для итерации
  • сбор элементов
  • асинхронность
  • заключение

В конце моего предыдущего поста я упомянул, что одна из главных проблем с трейтом IntoIterator — его довольно неприятно писать. Меня не было рядом, когда его впервые представили, но нетрудно понять, что первоначальные авторы предполагали, что Iterator будет основным интерфейсом, а IntoIterator — дополнительным удобством.

Однако так получилось не совсем, и общепринятой практикой стало использование IntoIterator как в ограничениях, так и в имплементациях. В редакции Rust 2024 мы меняем тип диапазона, чтобы он реализовывал IntoIterator, а не Iterator.1 И, например, в Swift эквивалентный трейт IntoIterator (Sequence) является основным интерфейсом, используемым для итерации. В то время как интерфейс, эквивалентный Iterator (IteratorProtocol), имеет гораздо более сложное для использования название.

Так если не Iterator, то какое имя мы могли бы использовать? Что ж, недавно я написал небольшую библиотеку под названием Iterate, которая пытается ответить на этот вопрос. Позвольте мне провести вас по ней.

Примечание: этот пост предназначен для публичного исследования, а не для конкретного предложения. Это отправная точка, задающая вопрос: «... а что, если?» Я твёрдо выступаю за то, чтобы делиться идеями публично, особенно если они ещё не до конца проработаны. На это есть много веских причин, но прежде всего: я думаю, это весело!

Глаголы, существительные и трейты

В Rust большинство интерфейсов используют глаголы в качестве своих названий. Чтобы читать байты из потока, вы используете трейт с названием Read. Чтобы записывать байты — Write. Чтобы отлаживать что-то — Debug. А для вычисления чисел вы можете использовать Add, Mul (умножение) или Sub (вычитание). Большинство трейтов в стандартной библиотеке Rust используются для выполнения конкретных операций, и для этого принято использовать глаголы.

В стандартной библиотеке есть одна особенно интересная пара в виде Hash (глагол) и Hasher (существительное). Из документации: «Типы, реализующие Hash, могут быть хешированы с помощью экземпляра Hasher». Или, иными словами: трейт Hash представляет операцию, а трейт Hasher представляет состояние.

#![allow(unused)]
fn main() {
/// Тип, который можно хешировать.
pub trait Hash {
    fn hash<H: Hasher>(&self, state: &mut H);
}

/// Представляет состояние, которое изменяется при хешировании данных.
pub trait Hasher {
    fn finish(&self) -> u64;
    fn write(&mut self, bytes: &[u8]);
}
}

Глагол для итерации

Что на самом деле представляет трейт IntoIterator — это «Итерируемый тип». А трейт Iterator можно разумно описать как: «Состояние, которое изменяется при итерации по элементам». Разделение глагол/существительное, присутствующее в Hash/Hasher, кажется, легко применимо и к итерации.

Если Iterator — это существительное, представляющее состояние итерации, то что же будет глаголом, представляющим способность? Очевидный выбор — Iterate. Что, как мне кажется, в итоге работает довольно неплохо. Чтобы итерировать элементы, вы реализуете Iterate, который предоставляет вам состояние Iterator.

#![allow(unused)]
fn main() {
/// Итерируемый тип.
pub trait Iterate {
    type Item;
    type Iterator: Iterator<Item = Self::Item>;
    fn iterate(self) -> Self::Iterator;
}

/// Представляет состояние, которое изменяется при итерации.
pub trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
}
}

С нашей целью сделать использование IntoIterator в интерфейсах менее раздражающим, название Iterate кажется не таким уж плохим. И оно аккуратно следует существующему разделению глагол/существительное, которое мы уже используем в других местах стандартной библиотеки.

Сбор элементов

Люди, знакомые со стандартной библиотекой Rust, быстро заметят, что Iterator и IntoIterator — не единственные трейты итерации в использовании. У нас также есть FromIterator, который функционирует как обратный к IntoIterator. Где один существует для преобразования типов в итераторы, другой — для преобразования итераторов обратно в типы. Последний обычно используется через функцию Iterator::collect.

Но у IntoIterator есть менее известный, но столь же полезный собрат: Extend. Где IntoIterator собирает элементы в новые экземпляры типов, трейт Extend используется для сбора элементов в существующие экземпляры типов. Было бы довольно странно переименовать IntoIterator в Iterate, но оставить FromIterator как есть. Что, если вместо того, чтобы рассматривать FromIterator как двойник IntoIterator, мы будем считать его собратом Extend. Очевидным глаголом для этого был бы Collect:

#![allow(unused)]
fn main() {
/// Создаёт коллекцию с содержимым итератора.
pub trait Collect<A>: Sized {
    fn collect<T>(iter: T) -> Self
    where
        T: Iterate<Item = A>;
}
}

Интересно отметить, что тип T в FromIterator ограничен IntoIterator, а не Iterator. Возможность использовать T: Iterate в качестве ограничения здесь определённо кажется немного приятнее. И, говоря о приятном: это также сделает предоставленные методы Iterator::collect и Iterator::collect_into немного лучше:

#![allow(unused)]
fn main() {
/// Представляет состояние, которое изменяется при итерации.
pub trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;

    /// Создать коллекцию с содержимым этого итератора.
    fn collect<C>(self) -> C
    where
        C: Collect<Self::Item>,
        Self: Sized,

    /// Расширить коллекцию содержимым этого итератора.
    fn collect_into<E>(self, collection: &mut E) -> &mut E
    where
        E: Extend<Self::Item>,
        Self: Sized;
}
}

Мне это не кажется плохим. И, честно говоря: это также может быть более последовательным в целом, поскольку трейты, представляющие другие эффекты, не имеют эквивалента FromIterator. Трейт Future имеет только IntoFuture, а варианты этого в экосистеме, такие как Race. Отсутствие трейта с названием FromIterator помогло бы устранить некоторую путаницу.

Асинхронность

Полагаю, мы затронули тему асинхронности, так что, наверное, стоит продолжить. Мы добавили трейт IntoFuture в Rust в 2022 году, потому что хотели получить эквивалент IntoIterator, но для эффекта async. Некоторые мотивирующие случаи использования этого можно найти в моём посте async builders (2019). Мы выбрали название IntoFuture, потому что оно соответствовало существующему соглашению, установленному IntoIterator/Iterator.

У нас уже есть Try для ошибочности, мы только что обсудили использование Iterate для итерации, каким было бы название трейта на основе глагола для асинхронности? Очевидный выбор — что-то вроде Await, так как это название операции:

#![allow(unused)]
fn main() {
trait Await {
    type Output;
    type Future = Future<Output = Self::Output>;
    fn await(self) -> Self::Future;
}
}

Однако это сталкивается с одним серьёзным ограничением: await — зарезервированное ключевое слово, что означает, что мы не можем использовать его в качестве названия метода. А значит, я не уверен, как на самом деле следует называть этот трейт. С итераторами нам повезло, что у нас нет недостатка в связанных словах: loop, iterate, generate, while, sequence и так далее. С async у нас слов немного меньше. Если у кого-то есть хорошие идеи для глаголов, которые можно здесь использовать, я буду рад услышать предложения!

Заключение

TLDR: Я бы совсем не возражал, если бы Iterate было основным названием интерфейса для итерации в Rust. Кажется, это было бы шагом вперёд по сравнению с написанием IntoIterator в ограничениях повсюду. Просто изменив название, без необходимости в каких-либо специальных новых языковых возможностях.

Теперь относительно того, следует ли нам вносить это изменение:... возможно? Честно говоря, я не знаю. Это не просто вопрос введения простого псевдонима трейта: названия методов и ассоциированные типы также различны, и мы не можем создать для них псевдонимы. И я не особо заинтересован в том, чтобы Rust начал экспериментировать с дополнительными иерархиями трейтов здесь. Итерация и так достаточно сложна, дополнительные супертрейты не сделают её проще.

#![allow(unused)]
fn main() {
//! Переименования и создания псевдонима для трейта недостаточно,
//! названия методов и ассоциированных типов также нужно будет заменить.
pub trait Iterate { .. }
pub trait IntoIterator = Iterate;
}

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

В любом случае: мне было очень весело писать этот пост. Если вы хотите попробовать трейт Iterate сегодня, чтобы лучше его прочувствовать, — загляните в крейт iterate-trait. В нём есть всё, что я описал в этом посте, а также комбинаторы итераторов, такие как map. Вероятно, не стоит использовать его для чего-то серьёзного, но определённо повеселитесь с ним.

Примечания


  1. Спасибо Лукасу Вирту (Lukas Wirth) за то, что указал, что изменение типа диапазона в итоге не попало в редакцию. Прошло пару месяцев с тех пор, как я проверял, и, кажется, его убрали для этой редакции. Насколько я понимаю, это изменение всё ещё желательно и может попасть в будущую редакцию.