Абстракция без накладных расходов: трейты в Rust
Перевод | Автор оригинала: Aaron Turon
Предыдущие статьи касались двух столпов дизайна Rust:
- Безопасность памяти без сборки мусора
- Параллелизм без гонок данных
В этом посте начинается изучение третьего столпа:
- Абстракция без накладных расходов
Одна из мантр C++, одно из качеств, делающих его подходящим для системного программирования, - это принцип абстракции с нулевой стоимостью:
Реализации C++ подчиняются принципу нулевых накладных расходов: то, что вы не используете, вы не платите [Stroustrup, 1994]. И еще: то, что вы используете, лучше не передать. -- Stroustrup
Эта мантра не всегда применима к Rust, который, например, имел обязательную сборку мусора. Но со временем амбиции Rust стали все более низкими, и абстракция с нулевой стоимостью теперь стала основным принципом.
Краеугольный камень абстракции в Rust - это трейты:
-
Трэйты - единственное понятие интерфейса Rust. Трэйт может быть реализован несколькими типами, и фактически новые трэйты могут предоставлять реализации для существующих типов. С другой стороны, когда вы хотите абстрагироваться от неизвестного типа, трэйты определяют то, как вы указываете несколько конкретных вещей, которые вам нужно знать об этом типе.
-
Трэйты могут быть отправлены статически. Подобно шаблонам C++, вы можете заставить компилятор генерировать отдельную копию абстракции для каждого способа ее создания. Это возвращает нас к мантре C++: «Что бы вы ни использовали, вы не можете передать код лучше» - абстракция в конечном итоге полностью стирается.
-
Трэйты могут быть отправлены динамически. Иногда вам действительно нужно косвенное обращение, и поэтому нет смысла «стирать» абстракцию во время выполнения. То же понятие интерфейса - трэйта - также можно использовать, когда вы хотите отправить во время выполнения.
-
Трэйты решают множество дополнительных задач, выходящих за рамки простой абстракции. Они используются как «маркеры» для типов, как маркер отправки, описанный в предыдущем посте. Их можно использовать для определения «методов расширения», то есть для добавления методов к внешнему определенному типу. Они в значительной степени устраняют необходимость в перегрузке традиционных методов. И они предоставляют простую схему перегрузки оператора.
В общем, система трэйтов - это секретный соус, который дает Rust эргономичное, выразительное ощущение языков высокого уровня, сохраняя при этом низкоуровневый контроль над выполнением кода и представлением данных.
Этот пост подробно рассмотрит каждый из вышеперечисленных пунктов, чтобы дать вам представление о том, как дизайн достигает этих целей, не слишком увязая в деталях.
Предыстория: методы в Rust
Прежде чем углубляться в особенности, нам нужно взглянуть на небольшую, но важную деталь языка: разницу между методами и функциями.
Rust предлагает как методы, так и автономные функции, которые очень тесно связаны:
struct Point {
x: f64,
y: f64,
}
// a free-standing function that converts a (borrowed) point to a string
fn point_to_string(point: &Point) -> String { ... }
// an "inherent impl" block defines the methods available directly on a type
impl Point {
// this method is available on any Point, and automatically borrows the
// Point value
fn to_string(&self) -> String { ... }
}
Такие методы, как to_string выше, называются «внутренними» методами, потому что они:
- привязаны к одному конкретному типу "self" (указанному в заголовке блока impl).
- Автоматически доступны для любого значения этого типа - то есть, в отличие от функций, собственные методы всегда находятся «в области видимости».
Первым параметром метода всегда является явное "self", которое может быть self, &mut self или &self, в зависимости от требуемого уровня владения. Методы вызываются с помощью. обозначение, знакомое из объектно-ориентированного программирования, а параметр self неявно заимствуется в соответствии с формой self, используемой в методе:
let p = Point { x: 1.2, y: -3.7 };
let s1 = point_to_string(&p); // calling a free function, explicit borrow
let s2 = p.to_string(); // calling a method, implicit borrow as &p
Методы и их автоматическое заимствование являются важным аспектом эргономики Rust, поддерживая «плавные» API, такие как API для порождения процессов:
let child = Command::new("/bin/cat")
.arg("rusty-ideas.txt")
.current_dir("/Users/aturon")
.stdout(Stdio::piped())
.spawn();
Трэйты - это интерфейсы
Интерфейсы определяют ожидания, которые одна часть кода возлагает на другую, позволяя отключать каждый из них независимо. Что касается трейтов, эта спецификация в основном вращается вокруг методов.
Возьмем, к примеру, следующую простую трэйту для хеширования:
trait Hash {
fn hash(&self) -> u64;
}
Чтобы реализовать эту трэйту для данного типа, вы должны предоставить хэш-метод с соответствующей подписью:
impl Hash for bool {
fn hash(&self) -> u64 {
if *self { 0 } else { 1 }
}
}
impl Hash for i64 {
fn hash(&self) -> u64 {
*self as u64
}
}
В отличие от интерфейсов в таких языках, как Java, C# или Scala, новые трэйты могут быть реализованы для существующих типов (как с Hash выше). Это означает, что абстракции можно создавать постфактум и применять к существующим библиотекам.
В отличие от неотъемлемых методов, методы черт входят в область видимости только тогда, когда есть их трэйта. Но если предположить, что Hash находится в области видимости, вы можете написать true.hash(), поэтому реализация трейта расширяет набор методов, доступных для типа.
И это все! Определение и реализация трейта на самом деле не что иное, как абстрагирование общего интерфейса, удовлетворяющего более чем одному типу.
Статическая диспечеризация
С другой стороны, все становится интереснее - поглощение трэйты характера. Чаще всего это можно сделать с помощью дженериков:
fn print_hash<T: Hash>(t: &T) {
println!("The hash is {}", t.hash())
}
Функция print_hash является универсальной для неизвестного типа T, но требует, чтобы T реализовал свойство Hash. Это означает, что мы можем использовать его со значениями bool и i64:
print_hash(&true); // instantiates T = bool
print_hash(&12_i64); // instantiates T = i64
Обобщения компилируются, что приводит к статической отправке. То есть, как и в случае с шаблонами C++, компилятор сгенерирует две копии метода print_hash для обработки вышеуказанного кода, по одной для каждого конкретного типа аргумента. Это, в свою очередь, означает, что внутренний вызов t.hash() - точка, где фактически используется абстракция - имеет нулевую стоимость: он будет скомпилирован в прямой статический вызов соответствующей реализации:
// The compiled code:
__print_hash_bool(&true); // invoke specialized bool version directly
__print_hash_i64(&12_i64); // invoke specialized i64 version directly
Эта модель компиляции не так полезна для такой функции, как print_hash, но очень полезна для более реалистичного использования хеширования. Предположим, мы также вводим трейт для сравнения на равенство:
trait Eq {
fn eq(&self, other: &Self) -> bool;
}
(Ссылка на Self здесь будет относиться к тому типу, для которого мы реализуем трейт; в impl Eq для bool он будет ссылаться на bool.)
Затем мы можем определить хэш-карту, которая является универсальной для типа T, реализующей как Hash, так и Eq:
struct HashMap<Key: Hash + Eq, Value> { ... }
Тогда статическая модель компиляции для дженериков даст несколько преимуществ:
-
Каждое использование HashMap с конкретными типами Key и Value приведет к другому конкретному типу HashMap, что означает, что HashMap может размещать ключи и значения в строке (без косвенного обращения) в своих сегментах. Это экономит место и косвенные ссылки, а также улучшает локальность кеша.
-
Каждый метод в HashMap аналогичным образом генерирует специализированный код. Это означает, что диспетчеризация вызовов hash и eq не требует дополнительных затрат, как указано выше. Это также означает, что оптимизатор начинает работать с полностью конкретным кодом, то есть с точки зрения оптимизатора абстракции нет. В частности, статическая диспетчеризация позволяет встраивать различные варианты использования дженериков.
В целом, как и в шаблонах C++, эти аспекты универсальных шаблонов означают, что вы можете писать довольно высокоуровневые абстракции, которые гарантированно скомпилируются до полностью конкретного кода, который «вы не могли бы передать код лучше».
Но, в отличие от шаблонов C++, клиенты трейтов заранее полностью проверяют тип. То есть, когда вы компилируете HashMap изолированно, его код проверяется один раз на правильность типа по абстрактным характеристикам Hash и Eq, а не повторно проверяется при применении к конкретным типам. Это означает более ранние и более четкие ошибки компиляции для авторов библиотеки и меньшие накладные расходы на проверку типов (т. Е. Более быструю компиляцию) для клиентов.
Динамическая диспечеризация
Мы видели одну модель компиляции для трейтов, в которой вся абстракция компилируется статически. Но иногда абстракция - это не только повторное использование или модульность - иногда абстракция играет важную роль во время выполнения, которую нельзя скомпилировать.
Например, фреймворки графического интерфейса часто включают обратные вызовы для ответа на события, такие как щелчки мыши:
trait ClickCallback {
fn on_click(&self, x: i64, y: i64);
}
Элементы графического интерфейса также часто позволяют регистрировать несколько обратных вызовов для одного события. С помощью дженериков вы можете представить, что пишете:
struct Button<T: ClickCallback> {
listeners: Vec<T>,
...
}
но проблема очевидна сразу: это будет означать, что каждая кнопка предназначена только для одного разработчика ClickCallback, и что тип кнопки отражает этот тип. Это совсем не то, что мы хотели! Вместо этого нам нужен один тип Button с набором разнородных слушателей, каждый из которых потенциально имеет свой конкретный тип, но каждый из них реализует ClickCallback.
Одна из непосредственных трудностей здесь заключается в том, что, если мы говорим о неоднородной группе типов, каждый из них будет иметь определенный размер - так как же мы можем даже расположить внутренний вектор? Ответ обычный: косвенное обращение. Мы будем хранить указатели на обратные вызовы в векторе:
struct Button {
listeners: Vec<Box<ClickCallback>>,
...
}
Здесь мы используем трейт ClickCallback, как если бы он был типом. На самом деле, в Rust трэйты являются типами, но они «безразмерные», что примерно означает, что им разрешено отображаться только за указателем типа Box (который указывает на кучу) или & (который может указывать куда угодно).
В Rust такой тип, как &ClickCallback или Box
Статическая и динамическая диспетчеризация - это взаимодополняющие инструменты, каждый из которых подходит для разных сценариев. Трэйты Rust обеспечивают единое простое понятие интерфейса, которое можно использовать в обоих стилях с минимальными предсказуемыми затратами. Объекты-трэйты удовлетворяют принципу Страуструпа «плати по мере использования»: у вас есть vtables, когда они вам нужны, но те же самые трэйты могут быть скомпилированы статически, когда вы этого не сделаете.
Многочисленные варианты использования черт
Мы видели много механики и базового использования трейтов выше, но они также сыграли несколько других важных ролей в Rust. Вот вкус:
-
Замыкания. В некоторой степени подобно трейту ClickCallback, замыкания в Rust - это просто особые трэйты. Вы можете узнать больше о том, как это работает, в подробном посте Хьюона Уилсона по этой теме.
-
Условные API. Дженерики позволяют условно реализовать трейт:
struct Pair<A, B> { first: A, second: B }
impl<A: Hash, B: Hash> Hash for Pair<A, B> {
fn hash(&self) -> u64 {
self.first.hash() ^ self.second.hash()
}
}
- Здесь тип Pair реализует Hash тогда и только тогда, когда это делают его компоненты - позволяя использовать один тип Pair в разных контекстах, поддерживая при этом самый большой API, доступный для каждого контекста. Это настолько распространенный шаблон в Rust, что есть встроенная поддержка для автоматического создания определенных видов "механических" реализаций:
#[derive(Hash)]
struct Pair<A, B> { .. }
-
Методы расширения. Для удобства свойства можно использовать для расширения существующего типа (определенного в другом месте) новыми методами, аналогично методам расширения C#. Это прямо выпадает из правил области видимости для трейтов: вы просто определяете новые методы в трейте, предоставляете реализацию для рассматриваемого типа, и вуаля, метод доступен.
-
Маркеры. В Rust есть несколько «маркеров», которые классифицируют типы: Отправить, Синхронизировать, Копировать, Размер. Эти маркеры представляют собой просто трэйти с пустыми телами, которые затем можно использовать как в универсальных, так и в характерных объектах. Маркеры могут быть определены в библиотеках, и они автоматически предоставляют реализации в стиле #[derive]: например, если все компоненты типа являются компонентами Send, то и тип также. Как мы видели ранее, эти маркеры могут быть очень мощными: маркер отправки - это то, как Rust гарантирует безопасность потоков.
-
Перегрузка. Rust не поддерживает традиционную перегрузку, когда один и тот же метод определяется с несколькими сигнатурами. Но трэйты предоставляют большую часть преимуществ перегрузки: если метод определен в общем для свойства, он может быть вызван с любым типом, реализующим эту трэйту. По сравнению с традиционной перегрузкой у этого есть два преимущества. Во-первых, это означает, что перегрузка менее спонтанная: как только вы поймете трэйту, вы сразу поймете схему перегрузки любых API-интерфейсов, использующих ее. Во-вторых, он расширяемый: вы можете эффективно предоставлять новые перегрузки ниже по течению от метода, предоставляя новые реализации трэйтов.
-
Операторы. Rust позволяет вам перегружать операторы вроде + на ваши собственные типы. Каждый из операторов определяется соответствующей характеристикой стандартной библиотеки, и любой тип, реализующий эту характеристику, также автоматически предоставляет оператор.
Дело в том, что, несмотря на кажущуюся простоту, трэйты представляют собой объединяющую концепцию, которая поддерживает широкий спектр вариантов использования и шаблонов, без необходимости нагромождения дополнительных языковых функций.
Будущее
Один из основных способов развития языков - это их средства абстракции, и Rust не исключение: многие из наших приоритетов после 1.0 являются расширениями системы трэйтов в том или ином направлении. Вот несколько основных моментов.
-
Статически отправленные выходы. Прямо сейчас функции могут использовать универсальные шаблоны для своих параметров, но для их результатов нет эквивалента: вы не можете сказать, что «эта функция возвращает значение некоторого типа, реализующего трэйту Iterator», и скомпилировать эту абстракцию. Это особенно проблематично, когда вы хотите вернуть закрытие, которое вы хотели бы отправлять статически - в сегодняшнем Rust вы просто не можете этого сделать. Мы хотим сделать это возможным, и у нас уже есть некоторые идеи.
-
Специализация. Rust не допускает перекрытия между реализациями трейтов, поэтому никогда не бывает двусмысленности в том, какой код запускать. С другой стороны, есть некоторые случаи, когда вы можете дать «общую» реализацию для широкого диапазона типов, но затем вы хотели бы предоставить более специализированную реализацию для нескольких случаев, часто по соображениям производительности. Надеемся предложить дизайн в ближайшее время.
-
Высокородные типы (HKT). Сегодня трэйты можно применять только к типам, но не к конструкторам типов, то есть к таким вещам, как Vec
, но не к самому Vec. Это ограничение затрудняет предоставление хорошего набора свойств контейнера, которые поэтому не включены в текущую стандартную библиотеку. HKT - это важная сквозная функция, которая представляет собой большой шаг вперед в возможностях абстракции в Rust. -
Эффективное повторное использование. Наконец, хотя трейты предоставляют некоторые механизмы для повторного использования кода (которые мы не рассматривали выше), все еще существуют некоторые шаблоны повторного использования, которые не подходят для современного языка, в частности, объектно-ориентированные иерархии, которые можно найти в таких вещах, как DOM, фреймворки GUI и многие игры. Учет этих вариантов использования без чрезмерного дублирования или усложнения - очень интересная проблема дизайна, о которой Нико Матсакис начал отдельную серию блогов. Пока не ясно, можно ли все это сделать с помощью трэйтов или нужны какие-то другие ингредиенты.
Конечно, мы находимся на пороге выпуска 1.0, и потребуется некоторое время, чтобы улеглась пыль, а у сообщества накопилось достаточно опыта, чтобы начать внедрять эти расширения. Но это делает сейчас захватывающее время для участия: от влияния на дизайн на этой ранней стадии до работы над реализацией, до опробования различных вариантов использования в вашем собственном коде - мы будем рады вашей помощи!