dyn Trait и impl Trait в языке программирования Rust
Trait - особенность, характерная черта, характеристика, типаж. Есть множество переводов этого термина, но я считаю, что лучше использовать без перевода - трейт - для Rust это более понятно.
Один из самых тонких аспектов языка программирования Rust — это использование трейтов в качестве типов. В этой статье я попытаюсь подробно рассмотреть, как использовать трейты в качестве типов и как выбирать между различными формами.
Трейты не являются типами
Тип описывает набор возможных значений. Типы переменных, объектов задаются при объявлении переменных (например, в x: u8, u8 — это тип) или могут быть выведены компилятором (например, в let x = 42u8; тип x выводится как u8). В Rust типы определяются встроенными типами (например, u8, bool и т. д.) и объявлениями типов данных, например, если есть объявление struct Foo { ... }, то Foo — это тип.
Трейты не являются типами. Если есть объявление трейта Bar { ... }, вы не можете написать x: Bar. Трейт похожа на интерфейс (функциональность типа), который могут реализовать типы данных. Когда тип реализует трейт, его можно рассматривать абстрактно как этот трейт для использования в виде обобщённых типов или трейт-объектов. В то же время трейт можно рассматривать как некоторое ограничение типа. При объявлении переменной типа мы можем ограничить её с помощью указания имени трейта, и эта граница ограничит типы, которые может принимать эта переменная типа. Например, мы можем написать <T: Bar>, и переменная типа T может быть экземляром только типов, реализующих Bar.
Пример
В следующих нескольких разделах мы будем использовать несколько вариантов простого примера, предполагая следующее:
trait Bar {
fn bar(&self) -> Vec<Self>;
}
impl Bar for Foo { ... }
impl Bar for Baz { ... }
Требование состоит в том, что мы хотим написать функцию f, которая принимает любое значение, реализующее Bar:
fn f(b: Bar) -> usize {
b.bar().len()
}
которая не скомпилируется, поскольку, как я уже упоминал выше, трейты не являются типами. В следующих нескольких разделах мы рассмотрим версии, которые будут компилироваться.
Использование механизма обобщённых (generic) типов
Мы можем использовать параметр обобщённого типа:
fn f<B: Bar>(b: B) -> usize
Эта версия принимает b по значению, мы также могли бы принять заимствованную ссылку (b: &B) или блочную ссылку в куче b: Box<B> и т. д. При необходимости этой функции можно передать любое значение, имеющее тип, реализующий Bar.
impl Trait
В позиции аргумента impl Trait — это просто сокращение для вышеуказанной общей версии:
fn f(b: impl Bar) -> usize
Эта версия функции f полностью идентична предыдущей, но имеет другой синтаксис. Вы также можете использовать b: &impl Bar или b: Box<impl Bar> и т. д.
Поскольку трейт записывается в указании типа параметра, а не отдельно, при чтении сокращённой версии на одну косвенную ссылку меньше, что может облегчить чтение кода. Я бы рекомендовал использовать impl Bar вместо предыдущей обобщённой версии, если только не используются сложные границы.
Если вы хотите использовать переменную типа в нескольких местах, вам потребуется более длинная версия. Например,
fn f(b1: impl Bar, b2: impl Bar) -> usize
это эквивалентно следующему:
fn f<B1: Bar, B2: Bar>(b1: B1, b2: B2) -> usize
но не этому
fn f<B: Bar>(b1: B, b2: B) -> usize
Сокращённая запись может быть использована для указания типов, реализующих трейт, в аргументах функций и возвращаемых значений функций. Тип трейт как параметр , как возвращаемое значение. keyword impl
dyn Trait
Трейт-объект всегда передаётся указателем (заимствованной ссылкой, Box или другим умным указателем) и имеет таблицу методов vtable, что позволяет динамически связывать методы с определённым объектом. Тип трейт-объектов использует синтаксис dyn Trait, например, &dyn Bar или Box<dyn Bar>. В отличие от impl Trait, dyn Trait нельзя использовать как самостоятельный тип без указателя-обёртки.
Во время компиляции известно только то, что объект реализует трейт; во время выполнения любой конкретный тип, реализующий этот трейт, может быть использован в качестве объекта трейта посредством неявного приведения, например, let obj: &dyn Bar = &Foo { ... };.
Мы можем написать версию нашей функции, используя трейт-объект:
fn f(b: &dyn Bar) -> usize
Это не универсальная (обобщённого типа) функция, она принимает значения только одного типа, но этот тип — тип трейт-объекта (точнее, ссылка), а с трейт-объектом могут связаны значения разных конкретных типов. В результате получается функция, которая может принимать значение любого типа, реализующего Bar (но только по ссылке, а не по значению).
impl Trait в качестве возвращаемого значения
impl Trait также может использоваться как тип, возвращаемый функцией. В этом случае он не является сокращением для параметра обобщенного типа, а имеет несколько иное значение. Ключевое различие заключается в том, кто выбирает конкретный тип — вызывающий или вызываемый. При использовании обобщенного параметра (например, fn f<T: Bar>(...) -> T) вызывающий выбирает конкретный тип, поэтому вызываемый должен предоставить функции с любым типом возвращаемого значения, который может выбрать вызывающий. При использовании impl Trait (например, fn f(...) -> impl Bar) вызываемый выбирает конкретный тип (т. е. компилятор выводит конкретный тип из тела функции). Таким образом, всегда существует только один конкретный тип, однако этот конкретный тип неизвестен вызывающему, поэтому вызывающий может только предполагать, что возвращаемое значение реализует определённый трейт.
Пример
fn f() -> impl Bar {
Foo { ... }
}
fn main() {
let b = f();
let _ = b.bar();
}
В этом случае реализатор функции f выбрал возврат экземпляра Foo, но вызывающий знает только, что возвращаемое значение - это некоторая реализация Bar. Реализацию f можно изменить так, чтобы она возвращала экземпляр Baz (или любую другую реализацию Bar), не меняя сигнатуру функции.
Обратите внимание, что может быть только один конкретный тип. Ниже приведена ошибка, несмотря на то, что оба типа реализуют Bar:
fn f(a: bool) -> impl Bar {
if a {
Foo { ... }
} else {
Baz { ... }
}
}
Следует заметить, что тот же самый пример, но с dyn Trait вполне работающий:
fn f(a: bool) -> Box<dyn Bar> {
if a {
Box::new(Foo)
} else {
Box::new(Baz)
}
}
Реализация
Чтобы выбрать подходящий тип, полезно понимать, как они реализованы. Примечание: компилятор довольно умён, и возможно ваш код может выглядеть немного иначе из-за оптимизации.
Обобщённые функции и impl Trait в позиции аргумента реализуются с помощью мономорфизации. Это означает, что компилятор создаёт копию функции для каждого конкретного типа (или комбинации типов), используемых для вызова функции. Например, если наша функция fn f(b: impl Bar) вызывается со значениями Foo и Baz, то компилятор создаст две копии функции: одну, принимающую b: Foo, и другую, принимающую b: Baz. По сути, всё то же самое, как и при использовании функции с обобщёнными типами, можно считать, что impl Trait - это некоторая сокращённая форма для этого.
Следовательно, вызов обобщенной функции не требует какой-либо косвенной адресации, это простой вызов функции. Однако код функции дублируется, возможно, многократно.
impl Trait в возвращаемой позиции не требует мономорфизации, абстрактный тип можно просто заменить конкретным типом в вызывающем коде. Но следует помнить, что возвращаемый тип может быть только один (как было показано в примере ранее).
Использование трейт-объектов (dyn Trait) не требует мономорфизации, поскольку функция, принимающая трейт-объект, не является обобщенной функцией, а принимает только один тип. Сами трейт-объекты реализованы как толстые указатели. Это означает, что тип, подобный &dyn Bar, — это не просто указатель на значение, а два указателя, передаваемых вместе (или в одном месте, если угодно): один указатель на значение и один указатель на виртуальную таблицу методов vtable, которая используется для сопоставления методов, объявленных в трейте, с методами конкретного типа.
Это означает, что вызов функции для трейт-объекта подразумевает косвенное обращение через vtable, т. е. динамическую диспетчеризацию, а не простой вызов функции.
Выбор: impl Trait или dyn Trait
У нас есть два разных типа с некоторыми схожими свойствами. Как же выбрать, какой из них использовать?
Как и во многих других вопросах в программной инженерии, здесь есть компромисс:
Преимущества impl Trait или обобщённых типов
обобщённые типы (generic types):
- тонкое управление свойствами типов с помощью предложений
where, - может иметь несколько ограничений трейта (например,
impl (Foo + Qux)допускается, аdyn (Foo + Qux)— нет),
Недостатки impl Trait или обобщённых типов
- мономорфизация приводит к увеличению размера кода.
Преимущества dyn Trait
- одна переменная, аргумент или возвращаемое значение могут принимать значения нескольких различных типов,
- одна реализация функции для множества различных типов.
Недостатки dyn Trait
- виртуальная диспетчеризация приводит к более медленным вызовам методов,
- объекты всегда должны передаваться по указателю,
- требует объектной безопасности.
Еще немного подробностей
Безопасность объектов
Не все трейты можно преобразовать в трейт-объекты, а только те, которые являются объектно-безопасными. Безопасность объектов существует для того, чтобы трейт-объекты могли удовлетворять ограничениям трейтов, другими словами, чтобы можно было передать объект типа &dyn Foo функции, ожидающей &impl Foo. Это может показаться тривиальным, но это не так. Фактически, существует неявная реализация impl<T: Trait> T for dyn T {...} для всех трейтов; обратите внимание, что многоточие здесь выполняет большую работу: каждый метод должен быть реализован для каждого типа, чтобы делегировать управление трейт-объекту.
Если бы вы записали эту реализацию, вы бы обнаружили, что для некоторых характеристик её невозможно написать без ошибок. Грубо говоря, безопасность объекта — это консервативная мера тех характеристик, для которых реализация может быть написана без ошибок.
Трейт является объектно-безопасным, если он не связан с Sized (например, trait Foo: Sized) и для всех методов трейта, в которых в предложении where нет Self: Sized:
- метод не является статическим (т.е. обязательно присутствует аргумент
self), - метод не использует
Selfв качестве аргумента или возвращаемого типа, - метод не параметров типа.
Неявные границы
Автоматические трейты (ранее называвшиеся OIBIT) — это трейты, такие как Send и Sync, которые не требуют явной реализации, но реализуются по умолчанию, если все компоненты типа реализуют этот трейт.
При использовании impl Trait в качестве возвращаемого значения (но в качестве аргумента) для возвращаемого значения из тела функции будут неявно выведены автоматические трейты (auto traits). Это означает, что вам не нужно писать + Send + Sync для impl Trait возвращаемого значения. Это не относится к трейтам-объектам, где необходимо включать явно указывать трейты.
Границы времени жизни ещё более тонкие. dyn Trait включает границу времени жизни по умолчанию 'static (если вы не укажете время жизни). Параметры типа и impl Trait в позиции аргумента не имеют неявной границы времени жизни. Конкретный тип, лежащий в основе impl Trait, может зависеть от любых параметров типа в области видимости. Следовательно, любая граница любого параметра типа в области видимости становится границей типа impl Trait (как и любые явные границы времени жизни, например, 'a в impl (Foo + 'a)). Если в области видимости нет времён жизни, заданных явными границами или параметрами типа, то impl Trait имеет границу 'static (как и обобщенные типы, у которых не будет границ).
Дополнительно по теме
impl Trait(return position)impl Trait(argument position, refinements)dyn Trait- return position impl Trait in traits
- Finalize syntax of
impl Traitanddyn Traitwith multiple bounds before stabilization of these features impl Traitin associated types and type aliases- The Rust Reference:
impl Trait,dyn Trait(trait object) - The Rust Programming Language: Traits: Defining Shared Behavior