Типажи-объекты
Когда код включает в себя полиморфизм, то должен быть механизм, чтобы определить, какая конкретная версия будет фактически вызвана. Это называется 'диспетчеризация.' Есть две основные формы диспетчеризации: статическая и динамическая. Хотя Rust и отдает предпочтение статической диспетчеризации, он также поддерживает динамическую диспетчеризацию через механизм, называемый 'типажи-объекты.'
Подготовка
Для остальной части этой главы нам потребуется типаж и несколько его реализаций.
Давайте создадим простой типаж Foo
. Он содержит один метод, который возвращает
String
.
trait Foo {
fn method(&self) -> String;
}
Также мы реализуем этот типаж для u8
и String
:
# trait Foo { fn method(&self) -> String; }
impl Foo for u8 {
fn method(&self) -> String { format!("u8: {}", *self) }
}
impl Foo for String {
fn method(&self) -> String { format!("string: {}", *self) }
}
Статическая диспетчеризация
Мы можем использовать этот типаж для выполнения статической диспетчеризации с помощью ограничения типажом:
# trait Foo { fn method(&self) -> String; }
# impl Foo for u8 { fn method(&self) -> String { format!("u8: {}", *self) } }
# impl Foo for String { fn method(&self) -> String { format!("string: {}", *self) } }
fn do_something<T: Foo>(x: T) {
x.method();
}
fn main() {
let x = 5u8;
let y = "Hello".to_string();
do_something(x);
do_something(y);
}
Здесь Rust использует 'мономорфизацию' для статической диспетчеризации. Это
означает, что Rust создаст специальную версию do_something()
для каждого из
типов: u8
и String
, а затем заменит все места вызовов на вызовы этих
специализированных функций. Другими словами, Rust сгенерирует нечто вроде этого:
# trait Foo { fn method(&self) -> String; }
# impl Foo for u8 { fn method(&self) -> String { format!("u8: {}", *self) } }
# impl Foo for String { fn method(&self) -> String { format!("string: {}", *self) } }
fn do_something_u8(x: u8) {
x.method();
}
fn do_something_string(x: String) {
x.method();
}
fn main() {
let x = 5u8;
let y = "Hello".to_string();
do_something_u8(x);
do_something_string(y);
}
Статическая диспетчеризация имеет большой потенциал: она позволяет вызывать функцию, которая будет встроена, потому что вызываемая версия этой функции известна на этапе компиляции, а встраивание — это ключ к хорошей оптимизации. Статическая диспетчеризация быстра, но это достигается путем компромисса: происходит 'раздувание кода' в связи с большим количеством копий одной и той же функции, по одной для каждого типа, расположенных в бинарном файле.
Кроме того, компиляторы не совершенны и могут «оптимизировать» код так, что он
станет медленнее. Например, встроенные функции будут слишком охотно раздувать
кэш команд (правила кэширования все вокруг нас). Это одна из причин, по которой
#[inline]
и #[inline(always)]
следует использовать осторожно, и почему
использование динамической диспетчеризации иногда более эффективно.
Тем не менее, в общем случае более эффективно использовать статическую диспетчеризацию. Кроме того, всегда можно иметь тонкую статически- диспетчеризуемую обертку для функции, которая выполняет динамическую диспетчеризацию, но не наоборот. То есть статические вызовы являются более гибкими. По этой причине стандартная библиотека старается быть статически диспетчеризуемой везде, где это возможно.
Динамическая диспетчеризация
Rust обеспечивает динамическую диспетчеризацию через механизм под названием
'типажи-объекты'. Типажи-объекты, такие как &Foo
или Box<Foo>
, это обычные
переменные, хранящие значения любого типа, реализующего данный типаж.
Конкретный тип типажа-объекта может быть определен только на этапе выполнения.
Типаж-объект может быть получен из указателя на конкретный тип, который
реализует этот типаж, путем его явного приведения
(например, &x as &Foo
) или
неявного приведения
(например, используя &x
в качестве аргумента функции,
которая принимает &Foo
).
Явное и неявное приведение типажа-объекта также работает для таких указателей,
как &mut T
в &mut Foo
и Box<T>
в Box<Foo>
, но это все на данный момент.
Явное и неявное приведение идентичны.
Эта операция может рассматриваться как «затирание» знания компилятора о конкретном типе указателя, поэтому типажи-объекты иногда называют «затиранием типов».
Возвращаясь к примеру выше, мы можем использовать тот же самый типаж для выполнения динамической диспетчеризации с типажами-объектами путем явного приведения типа:
# trait Foo { fn method(&self) -> String; }
# impl Foo for u8 { fn method(&self) -> String { format!("u8: {}", *self) } }
# impl Foo for String { fn method(&self) -> String { format!("string: {}", *self) } }
fn do_something(x: &Foo) {
x.method();
}
fn main() {
let x = 5u8;
do_something(&x as &Foo);
}
или неявного приведения типа:
# trait Foo { fn method(&self) -> String; }
# impl Foo for u8 { fn method(&self) -> String { format!("u8: {}", *self) } }
# impl Foo for String { fn method(&self) -> String { format!("string: {}", *self) } }
fn do_something(x: &Foo) {
x.method();
}
fn main() {
let x = "Hello".to_string();
do_something(&x);
}
Функция, которая принимает типаж-объект, не обладает специализированными копиями
для каждого из типов, которые реализуют типаж Foo
: генерируется только одна
копия. Часто (но не всегда), в результате происходит уменьшение раздувания кода.
Тем не менее, это происходит за счет более медленного вызова виртуальных
функций, и, по существу, блокирования любой возможности встраивания и связанных
с этим оптимизаций.
Почему указатели?
В отличие от многих управляемых языков, Rust по умолчанию не размещает значения по указателю, так как типы могут иметь различные размеры. Знать размер значения во время компиляции важно прежде всего для выполнения таких задач, как передача значения в качестве аргумента в функцию, что вызывает помещение переданного значения в стек, и выделение (и освобождение) места на куче для сохранения значения там.
Для Foo
допускается иметь значение, которое может быть либо String
(24
байт), либо u8
(1 байт), либо любой другой тип, для которого в соответствующих
крейтах может быть реализован Foo
(возможно абсолютно любое число байт). Так
как этот другой тип может быть сколь угодно большими, то нет никакого способа,
гарантирующего, что последний вариант будет работать, если значения сохраняются
без указателя.
Размещение значения по указателю означает, что, когда мы имеем дело с типажом- объектом, размер самого значения не важен, а важен лишь размер указателя.
Представление
Методы типажа можно вызвать для типажа-объекта с помощью специальной записи указателей на функции, традиционно называемой 'виртуальная таблица' ('vtable') (создается и управляется компилятором).
Типажи-объекты являются одновременно и простыми и сложными: их основное представление и устройство довольно прямолинейно, но есть некоторые тонкости относительно обнаружения сообщений об ошибках и странного поведения.
Давайте начнем с простого, с рантайм представления типажа-объекта. Модуль
std::raw
содержит структуры с макетами, которые являются такими же, как и
сложные встроенные типы, в том числе типажи-объекты:
# mod foo {
pub struct TraitObject {
pub data: *mut (),
pub vtable: *mut (),
}
# }
То есть типаж-объект, такой как &Foo
, состоит из указателя на «данные» и
указателя на «виртуальную таблицу».
Указатель data
адресует данные (какого-то неизвестного типа T
), которые
хранит типаж-объект, а указатель vtable
указывает на виртуальную таблицу
(«таблица виртуальных методов»), которая соответствует реализации Foo
для T
.
По существу, виртуальная таблица — это структура указателей на функции,
указывающих на конкретный кусок машинного кода для каждого метода в реализации.
Вызов метода наподобие trait_object.method()
возвращает правильный указатель
из виртуальной таблицы, а затем динамически вызывает метод по этому указателю.
Например:
struct FooVtable {
destructor: fn(*mut ()),
size: usize,
align: usize,
method: fn(*const ()) -> String,
}
// u8:
fn call_method_on_u8(x: *const ()) -> String {
// компилятор гарантирует, что эта функция вызывается только
// с `x`, указывающим на u8
let byte: &u8 = unsafe { &*(x as *const u8) };
byte.method()
}
static Foo_for_u8_vtable: FooVtable = FooVtable {
destructor: /* магия компилятора */,
size: 1,
align: 1,
// преобразование в указатель на функцию
method: call_method_on_u8 as fn(*const ()) -> String,
};
// String:
fn call_method_on_String(x: *const ()) -> String {
// компилятор гарантирует, что эта функция вызывается только
// с `x`, указывающим на String
let string: &String = unsafe { &*(x as *const String) };
string.method()
}
static Foo_for_String_vtable: FooVtable = FooVtable {
destructor: /* магия компилятора */,
// значения для 64-битного компьютера, для 32-битного они в 2 раза меньше
size: 24,
align: 8,
method: call_method_on_String as fn(*const ()) -> String,
};
Поле destructor
в каждой виртуальной таблице указывает на функцию, которая
будет очищать любые ресурсы типа этой виртуальной таблицы, для u8
она
тривиальна, но для String
она будет освобождать память. Это необходимо для
владельцев типажей-объектов, таких как Box<Foo>
, для которых необходимо
очищать выделенную память как для Box
, так и для внутреннего типа, когда они
выходят из области видимости. Поля size
и align
хранят размер затёртого
типа, и его требования к выравниванию; по существу, они не использовался в
момент, так как информация встроенного в деструктор, но будет использоваться в
будущем, так как объекты отличительным признакам постепенно становится более
гибким.
Предположим, у нас есть несколько значений, которые реализуют Foo
, тогда явный
вид создания и использования типажей-объектов Foo
может выглядеть примерно как
(игнорируются несоответствия типов: в любом случае, они всего лишь указатели):
let a: String = "foo".to_string();
let x: u8 = 1;
// let b: &Foo = &a;
let b = TraitObject {
// store the data
data: &a,
// store the methods
vtable: &Foo_for_String_vtable
};
// let y: &Foo = x;
let y = TraitObject {
// store the data
data: &x,
// store the methods
vtable: &Foo_for_u8_vtable
};
// b.method();
(b.vtable.method)(b.data);
// y.method();
(y.vtable.method)(y.data);