Обобщения в Rust
Что вы узнаете: обобщённые параметры типов, мономорфизацию (обобщения без накладных расходов), ограничения трейтами и то, как обобщения Rust соотносятся с шаблонами C++ — с более понятными сообщениями об ошибках и без SFINAE.
- Обобщения позволяют повторно использовать один и тот же алгоритм или структуру данных для разных типов данных
- Обобщённый параметр записывается как идентификатор в
<>, например:<T>. Параметр может иметь любое допустимое имя, но обычно его делают коротким - Компилятор выполняет мономорфизацию на этапе компиляции, то есть генерирует новый тип для каждого варианта
T, который встречается в коде
- Обобщённый параметр записывается как идентификатор в
// Возвращает кортеж типа <T>, составленный из left и right типа <T>
fn pick<T>(x: u32, left: T, right: T) -> (T, T) {
if x == 42 {
(left, right)
} else {
(right, left)
}
}
fn main() {
let a = pick(42, true, false);
let b = pick(42, "hello", "world");
println!("{a:?}, {b:?}");
}
Обобщения в Rust: специализация
- Обобщения можно применять и к типам данных, и к ассоциированным методам. Реализацию можно специализировать для конкретного
<T>(например,f32иu32)
#[derive(Debug)] // Мы обсудим это позже
struct Point<T> {
x : T,
y : T,
}
impl<T> Point<T> {
fn new(x: T, y: T) -> Self {
Point {x, y}
}
fn set_x(&mut self, x: T) {
self.x = x;
}
fn set_y(&mut self, y: T) {
self.y = y;
}
}
impl Point<f32> {
fn is_secret(&self) -> bool {
self.x == 42.0
}
}
fn main() {
let mut p = Point::new(2, 4); // i32
let q = Point::new(2.0, 4.0); // f32
p.set_x(42);
p.set_y(43);
println!("{p:?} {q:?} {}", q.is_secret());
}
Упражнение: обобщения
🟢 Начальный уровень
- Измените тип
Point, чтобы для x и y использовались два разных типа (TиU)
Решение (нажмите, чтобы развернуть)
#[derive(Debug)]
struct Point<T, U> {
x: T,
y: U,
}
impl<T, U> Point<T, U> {
fn new(x: T, y: U) -> Self {
Point { x, y }
}
}
fn main() {
let p1 = Point::new(42, 3.14); // Point<i32, f64>
let p2 = Point::new("hello", true); // Point<&str, bool>
let p3 = Point::new(1u8, 1000u64); // Point<u8, u64>
println!("{p1:?}");
println!("{p2:?}");
println!("{p3:?}");
}
// Вывод:
// Point { x: 42, y: 3.14 }
// Point { x: "hello", y: true }
// Point { x: 1, y: 1000 }
Комбинирование трейтов и обобщений в Rust
- Трейты можно использовать для наложения ограничений на обобщённые типы (constraints)
- Ограничение задаётся через
:после обобщённого параметра типа или черезwhere. Ниже определена обобщённая функцияget_area, которая принимает любой типT, если он реализует трейтComputeArea
#![allow(unused)]
fn main() {
trait ComputeArea {
fn area(&self) -> u64;
}
fn get_area<T: ComputeArea>(t: &T) -> u64 {
t.area()
}
}
Комбинирование трейтов и обобщений в Rust: несколько ограничений
- Можно задать несколько ограничений трейтами
trait Fish {}
trait Mammal {}
struct Shark;
struct Whale;
impl Fish for Shark {}
impl Fish for Whale {}
impl Mammal for Whale {}
fn only_fish_and_mammals<T: Fish + Mammal>(_t: &T) {}
fn main() {
let w = Whale {};
only_fish_and_mammals(&w);
let _s = Shark {};
// Не скомпилируется
only_fish_and_mammals(&_s);
}
Ограничения трейтов в типах данных
- Ограничения трейтами можно комбинировать с обобщениями в типах данных
- В следующем примере мы определяем трейт
PrintDescriptionи обобщённую структуруShapeс полем, ограниченным этим трейтом
#![allow(unused)]
fn main() {
trait PrintDescription {
fn print_description(&self);
}
struct Shape<S: PrintDescription> {
shape: S,
}
// Обобщённая реализация Shape для любого типа, который реализует PrintDescription
impl<S: PrintDescription> Shape<S> {
fn print(&self) {
self.shape.print_description();
}
}
}
Упражнение: ограничения трейтов и обобщения
🟡 Средний уровень
- Реализуйте структуру с обобщённым полем
cipher, которое реализуетCipherText
#![allow(unused)]
fn main() {
trait CipherText {
fn encrypt(&self);
}
// TO DO
//struct Cipher<>
}
- Затем реализуйте метод
encryptдля структурыimpl, который вызываетencryptуcipher
#![allow(unused)]
fn main() {
// TO DO
impl for Cipher<> {}
}
- Далее реализуйте
CipherTextдля двух структур,CipherOneиCipherTwo(достаточноprintln()). СоздайтеCipherOneиCipherTwoи используйтеCipherдля их вызова
Решение (нажмите, чтобы развернуть)
trait CipherText {
fn encrypt(&self);
}
struct Cipher<T: CipherText> {
cipher: T,
}
impl<T: CipherText> Cipher<T> {
fn encrypt(&self) {
self.cipher.encrypt();
}
}
struct CipherOne;
struct CipherTwo;
impl CipherText for CipherOne {
fn encrypt(&self) {
println!("CipherOne encryption applied");
}
}
impl CipherText for CipherTwo {
fn encrypt(&self) {
println!("CipherTwo encryption applied");
}
}
fn main() {
let c1 = Cipher { cipher: CipherOne };
let c2 = Cipher { cipher: CipherTwo };
c1.encrypt();
c2.encrypt();
}
// Вывод:
// CipherOne encryption applied
// CipherTwo encryption applied
Паттерн состояний (type state) и обобщения в Rust
-
Типы Rust можно использовать, чтобы обеспечить переходы конечного автомата на этапе компиляции
- Рассмотрим
Droneс двумя состояниями:IdleиFlying. В состоянииIdleединственный допустимый метод —takeoff(). В состоянииFlyingразрешёнland()
- Рассмотрим
-
Один из подходов — смоделировать конечный автомат примерно так
#![allow(unused)]
fn main() {
enum DroneState {
Idle,
Flying
}
struct Drone {x: u64, y: u64, z: u64, state: DroneState} // x, y, z — координаты
}
- Это требует множества проверок во время выполнения, чтобы обеспечить семантику конечного автомата — ▶ попробуйте, чтобы увидеть почему
Паттерн состояний на обобщениях
- Обобщения позволяют обеспечить конечный автомат на этапе компиляции. Для этого нужен специальный обобщённый тип
PhantomData<T> PhantomData<T>— это маркерный тип данныхнулевого размера. В данном случае мы используем его для представления состоянийIdleиFlying, но во время выполнения он занимаетнольбайт- Обратите внимание, что методы
takeoffиlandпринимаютselfкак параметр. Это называетсяпоглощением(в отличие от&self, которое использует заимствование). По сути, после вызоваtakeoff()дляDrone<Idle>мы можем получить толькоDrone<Flying>и наоборот
#![allow(unused)]
fn main() {
struct Drone<T> {x: u64, y: u64, z: u64, state: PhantomData<T> }
impl Drone<Idle> {
fn takeoff(self) -> Drone<Flying> {...}
}
impl Drone<Flying> {
fn land(self) -> Drone<Idle> { ...}
}
}
- [▶ Попробуйте в Rust Playground](https://play.rust-lang.org/)
Паттерн состояний: ключевые выводы
- Основные выводы:
- Состояния можно представить структурами (нулевого размера)
- Состояние
Tможно комбинировать сPhantomData<T>(нулевого размера) - Реализация методов для конкретного этапа конечного автомата — это просто
impl State<T> - Для перехода из одного состояния в другое используйте метод, который поглощает
self - Это даёт абстракции
без накладных расходов. Компилятор может обеспечить конечный автомат на этапе компиляции, и невозможно вызвать методы, если состояние неподходящее
Паттерн-строитель в Rust
- Поглощение
selfможет быть полезным для паттерна-строителя - Рассмотрим конфигурацию GPIO с несколькими десятками выводов. Каждый вывод может быть установлен в высокий или низкий уровень (по умолчанию — низкий)
#![allow(unused)]
fn main() {
#[derive(default)]
enum PinState {
#[default]
Low,
High,
}
#[derive(default)]
struct GPIOConfig {
pin0: PinState,
pin1: PinState
...
}
}
- Паттерн-строитель можно использовать, чтобы собрать конфигурацию GPIO через цепочку вызовов — ▶ Попробуйте