Абстракция без накладных расходов: трейты в Rust

Перевод | Автор оригинала: Aaron Turon

Предыдущие статьи касались двух столпов дизайна Rust:

В этом посте начинается изучение третьего столпа:

Одна из мантр C++, одно из качеств, делающих его подходящим для системного программирования, - это принцип абстракции с нулевой стоимостью:

Реализации C++ подчиняются принципу нулевых накладных расходов: то, что вы не используете, вы не платите [Stroustrup, 1994]. И еще: то, что вы используете, лучше не передать. -- Stroustrup

Эта мантра не всегда применима к Rust, который, например, имел обязательную сборку мусора. Но со временем амбиции Rust стали все более низкими, и абстракция с нулевой стоимостью теперь стала основным принципом.

Краеугольный камень абстракции в Rust - это трейты:

В общем, система трэйтов - это секретный соус, который дает 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", которое может быть 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> { ... }

Тогда статическая модель компиляции для дженериков даст несколько преимуществ:

В целом, как и в шаблонах 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, называется «типажным объектом» и включает указатель на экземпляр типа T, реализующий ClickCallback, и vtable: указатель на реализацию T каждого метода в трейте ( здесь, просто on_click). Этой информации достаточно для правильной отправки вызовов методов во время выполнения и для обеспечения единообразного представления для всех T. Итак, Button компилируется только один раз, а абстракция сохраняется во время выполнения.

Статическая и динамическая диспетчеризация - это взаимодополняющие инструменты, каждый из которых подходит для разных сценариев. Трэйты Rust обеспечивают единое простое понятие интерфейса, которое можно использовать в обоих стилях с минимальными предсказуемыми затратами. Объекты-трэйты удовлетворяют принципу Страуструпа «плати по мере использования»: у вас есть vtables, когда они вам нужны, но те же самые трэйты могут быть скомпилированы статически, когда вы этого не сделаете.

Многочисленные варианты использования черт

Мы видели много механики и базового использования трейтов выше, но они также сыграли несколько других важных ролей в Rust. Вот вкус:

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()
    }
}
#[derive(Hash)]
struct Pair<A, B> { .. }

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

Будущее

Один из основных способов развития языков - это их средства абстракции, и Rust не исключение: многие из наших приоритетов после 1.0 являются расширениями системы трэйтов в том или ином направлении. Вот несколько основных моментов.

Конечно, мы находимся на пороге выпуска 1.0, и потребуется некоторое время, чтобы улеглась пыль, а у сообщества накопилось достаточно опыта, чтобы начать внедрять эти расширения. Но это делает сейчас захватывающее время для участия: от влияния на дизайн на этой ранней стадии до работы над реализацией, до опробования различных вариантов использования в вашем собственном коде - мы будем рады вашей помощи!