Типажи
Типаж --- это возможность объяснить компилятору, что данный тип должен предоставлять определённую функциональность.
Вы помните ключевое слово impl
, используемое для вызова функции через
синтаксис метода?
# #![feature(core)]
struct Circle {
x: f64,
y: f64,
radius: f64,
}
impl Circle {
fn area(&self) -> f64 {
std::f64::consts::PI * (self.radius * self.radius)
}
}
Типажи схожи, за исключением того, что мы определяем типаж, содержащий лишь сигнатуру метода, а затем реализуем этот типаж для нужной структуры. Например, как показано ниже:
# #![feature(core)]
struct Circle {
x: f64,
y: f64,
radius: f64,
}
trait HasArea {
fn area(&self) -> f64;
}
impl HasArea for Circle {
fn area(&self) -> f64 {
std::f64::consts::PI * (self.radius * self.radius)
}
}
Как вы можете видеть, блок trait
очень похож на блок impl
. Различие состоит
лишь в том, что тело метода не определяется, а определяется только его
сигнатура. Когда мы реализуем типаж, мы используем impl Trait for Item
, а не
просто impl Item
.
Мы можем использовать типажи для ограничения обобщённых типов. Рассмотрим похожую функцию, которая также не компилируется, и выводит ошибку:
fn print_area<T>(shape: T) {
println!("This shape has an area of {}", shape.area());
}
Rust выводит:
error: type `T` does not implement any method in scope named `area`
Поскольку T
может быть любого типа, мы не можем быть уверены, что он реализует
метод area
. Но мы можем добавить «ограничение по типажу» к нашему обобщённому
типу T
, гарантируя, что он будет соответствовать требованиям:
# trait HasArea {
# fn area(&self) -> f64;
# }
fn print_area<T: HasArea>(shape: T) {
println!("This shape has an area of {}", shape.area());
}
Синтаксис <T: HasArea>
означает «любой тип, реализующий типаж HasArea
».
Так как типажи определяют сигнатуры типов функций, мы можем быть уверены, что
любой тип, который реализует HasArea
, будет иметь метод .area()
.
Вот расширенный пример того, как это работает:
# #![feature(core)]
trait HasArea {
fn area(&self) -> f64;
}
struct Circle {
x: f64,
y: f64,
radius: f64,
}
impl HasArea for Circle {
fn area(&self) -> f64 {
std::f64::consts::PI * (self.radius * self.radius)
}
}
struct Square {
x: f64,
y: f64,
side: f64,
}
impl HasArea for Square {
fn area(&self) -> f64 {
self.side * self.side
}
}
fn print_area<T: HasArea>(shape: T) {
println!("Площадь этой фигуры равна {}", shape.area());
}
fn main() {
let c = Circle {
x: 0.0f64,
y: 0.0f64,
radius: 1.0f64,
};
let s = Square {
x: 0.0f64,
y: 0.0f64,
side: 1.0f64,
};
print_area(c);
print_area(s);
}
Ниже показан вывод программы:
Площадь этой фигуры равна 3.141593
Площадь этой фигуры равна 1
Как вы можете видеть, теперь print_area
не только является обобщённой
функцией, но и гарантирует, что будет получен корректный тип. Если же мы
передадим некорректный тип:
print_area(5);
Мы получим ошибку времени компиляции:
error: the trait `HasArea` is not implemented for the type `_` [E0277]
До сих пор мы добавляли реализации типажей лишь для структур, но реализовать
типаж можно для любого типа. Технически, мы могли бы реализовать HasArea
для
i32
:
trait HasArea {
fn area(&self) -> f64;
}
impl HasArea for i32 {
fn area(&self) -> f64 {
println!("это нелепо");
*self as f64
}
}
5.area();
Хотя технически это возможно, реализация методов для примитивных типов считается плохим стилем программирования.
Может показаться, что такой подход легко приводит к бардаку в коде, однако
есть два ограничения, связанные с реализацией типажей, которые мешают коду выйти
из-под контроля. Во-первых, если типаж не определён в нашей области видимости,
он не применяется. Например, стандартная библиотека предоставляет типаж
[Write
]write, который добавляет типу File
функциональность ввода-вывода.
По умолчанию у File
не будет этих методов:
let mut f = std::fs::File::open("foo.txt").ok().expect("Не могу открыть foo.txt");
let buf = b"whatever"; // литерал строки байт. buf: &[u8; 8]
let result = f.write(buf);
# result.unwrap(); // игнорируем ошибку
Вот ошибка:
error: type `std::fs::File` does not implement any method in scope named `write`
let result = f.write(buf);
^~~~~~~~~~
Сначала мы должны сделать use
для типажа Write
:
use std::io::Write;
let mut f = std::fs::File::open("foo.txt").ok().expect("Не могу открыть foo.txt");
let buf = b"whatever";
let result = f.write(buf);
# result.unwrap(); // игнорируем ошибку
Это скомпилируется без ошибки.
Благодаря такой логике работы, даже если кто-то сделает что-то страшное —
например, добавит методы i32
, это не коснётся вас, пока вы не импортируете
типаж.
Второе ограничение реализации типажей --- это то, что или типаж, или тип, для
которого вы реализуете типаж, должен быть реализован вами. Мы могли бы
определить HasArea
для i32
, потому что HasArea
— это наш код. Но если бы
мы попробовали реализовать для i32
ToString
— типаж, предоставляемый Rust —
мы бы не смогли сделать это, потому что ни типаж, ни тип не реализован нами.
Последнее, что нужно сказать о типажах: обобщённые функции с ограничением по типажам используют мономорфизацию (mono: один, morph: форма), поэтому они диспетчеризуются статически. Что это значит? Посмотрите главу Типажи-объекты, чтобы получить больше информации.
Множественные ограничения по типажам
Вы уже видели, как можно ограничить обобщённый параметр типа определённым типажом:
fn foo<T: Clone>(x: T) {
x.clone();
}
Если вам нужно больше одного ограничения, вы можете использовать +
:
use std::fmt::Debug;
fn foo<T: Clone + Debug>(x: T) {
x.clone();
println!("{:?}", x);
}
Теперь тип T
должен реализовавать как типаж Clone
, так и типаж Debug
.
Утверждение where
Написание функций с несколькими обобщёнными типами и небольшим количеством ограничений по типажам выглядит не так уж плохо, но, с увеличением количества зависимостей, синтаксис получается более неуклюжим:
use std::fmt::Debug;
fn foo<T: Clone, K: Clone + Debug>(x: T, y: K) {
x.clone();
y.clone();
println!("{:?}", y);
}
Имя функции находится слева, а список параметров — далеко справа. Ограничения загромождают место.
Есть решение и для этой проблемы, и оно называется «утверждение where
»:
use std::fmt::Debug;
fn foo<T: Clone, K: Clone + Debug>(x: T, y: K) {
x.clone();
y.clone();
println!("{:?}", y);
}
fn bar<T, K>(x: T, y: K) where T: Clone, K: Clone + Debug {
x.clone();
y.clone();
println!("{:?}", y);
}
fn main() {
foo("Привет", "мир");
bar("Привет", "мир");
}
foo()
использует синтаксис, показанный ранее, а bar()
использует утверждение
where
. Все, что нам нужно сделать, это убрать ограничения при определении
типов параметров, а затем добавить where
после списка параметров. В более
длинных списках можно использовать пробелы:
use std::fmt::Debug;
fn bar<T, K>(x: T, y: K)
where T: Clone,
K: Clone + Debug {
x.clone();
y.clone();
println!("{:?}", y);
}
Такая гибкость может добавить ясности в сложных ситуациях.
На самом деле where
не только упрощает написание, это более мощная
возможность. Например:
trait ConvertTo<Output> {
fn convert(&self) -> Output;
}
impl ConvertTo<i64> for i32 {
fn convert(&self) -> i64 { *self as i64 }
}
// может быть вызван с T == i32
fn normal<T: ConvertTo<i64>>(x: &T) -> i64 {
x.convert()
}
// может быть вызван с T == i64
fn inverse<T>() -> T
// использует ConvertTo как если бы это было «ConvertFrom<i32>»
where i32: ConvertTo<T> {
1i32.convert()
}
Этот код демонстрирует дополнительные преимущества использования утверждения
where
: оно позволяет задавать ограничение, где с левой стороны располагается
произвольный тип (в данном случае i32
), а не только простой параметр типа
(вроде T
).
Методы по умолчанию
Есть еще одна особенность типажей, о которой стоит поговорить: методы по умолчанию. Проще всего показать это на примере:
trait Foo {
fn is_valid(&self) -> bool;
fn is_invalid(&self) -> bool { !self.is_valid() }
}
В типах, реализующих типаж Foo
, нужно реализовать метод is_valid()
, а
is_invalid()
будет реализован по-умолчанию. Его поведение можно
переопределить:
# trait Foo {
# fn is_valid(&self) -> bool;
#
# fn is_invalid(&self) -> bool { !self.is_valid() }
# }
struct UseDefault;
impl Foo for UseDefault {
fn is_valid(&self) -> bool {
println!("Вызван UseDefault.is_valid.");
true
}
}
struct OverrideDefault;
impl Foo for OverrideDefault {
fn is_valid(&self) -> bool {
println!("Вызван OverrideDefault.is_valid.");
true
}
fn is_invalid(&self) -> bool {
println!("Вызван OverrideDefault.is_invalid!");
true // эта реализация противоречит сама себе!
}
}
let default = UseDefault;
assert!(!default.is_invalid()); // печатает «Вызван UseDefault.is_valid.»
let over = OverrideDefault;
assert!(over.is_invalid()); // печатает «Вызван OverrideDefault.is_invalid!»
Наследование
Иногда чтобы реализовать один типаж, нужно реализовать типажи, от которых он зависит:
trait Foo {
fn foo(&self);
}
trait FooBar : Foo {
fn foobar(&self);
}
Типы, реализующие FooBar
, должны реализовывать Foo
:
# trait Foo {
# fn foo(&self);
# }
# trait FooBar : Foo {
# fn foobar(&self);
# }
struct Baz;
impl Foo for Baz {
fn foo(&self) { println!("foo"); }
}
impl FooBar for Baz {
fn foobar(&self) { println!("foobar"); }
}
Если мы забудем реализовать Foo
, компилятор скажет нам об этом:
error: the trait `main::Foo` is not implemented for the type `main::Baz` [E0277]