【Rust】抽象化のテクニック【アーキテクチャ】
はじめに
edition = "2024"
トレイト
Rustのトレイトは他言語のインタフェースと似ており、構造体に実装することで異なる型の振る舞いを共通化することができます。
trait Animal {
fn make_sound(&self) -> String;
}
トレイトの実装
struct Dog;
impl Animal for Dog {
fn make_sound(&self) -> String {
"Woof".into()
}
}
fn main() {
print_sound(Dog);
}
静的ディスパッチ
fn print_sound<T: Animal>(animal: T) {
println!("{}", animal.make_sound());
}
このコードは静的ディスパッチであり、コンパイル時に呼び出されるメソッドが決定されるため、効率的に実行することができます。
動的ディスパッチ
一方で、追加の実行コストを払う代わりに動的ディスパッチを利用することで、より柔軟にコードを組み立てることもできます。
struct Cat;
impl Animal for Cat {
fn make_sound(&self) -> String {
"Meow".into()
}
}
fn print_sounds(animals: Vec<&dyn Animal>) {
for animal in animals {
println!("{}", animal.make_sound());
}
}
fn main() {
print_sound(Dog);
print_sounds(vec![&Cat, &Dog])
}
Dog構造体に加えてCat構造体を追加し、Animalトレイトを実装した型の配列を受け取るコードです。これは、動的ディスパッチされたトレイトがメソッドの呼び出し名と関数ポインタのマップ(vtable)を保持しているため実現できます。
コンパイルエラー
-fn print_sounds(animals: Vec<&dyn Animal>) {
+fn print_sounds(animals: Vec<&impl Animal>) {
for animal in animals {
println!("{}", animal.make_sound());
}
}
fn main() {
print_sound(Dog);
print_sounds(vec![&Cat, &Dog])
}
error[E0308]: mismatched types
--> src/main.rs:33:29
|
33 | print_sounds(vec![&Cat, &Dog])
| ^^^^ expected `&Cat`, found `&Dog`
|
= note: expected reference `&Cat`
found reference `&Dog`
ブランケット実装
ブランケット実装はトレイトを自動実装させる構文です。
Animalトレイトに続いてCarnivore[1]トレイトを定義し、Catへ実装します。
trait Carnivore {
fn eat<T: Animal>(&self, animal: T);
}
impl Carnivore for Cat {
fn eat<T: Animal>(&self, animal: T) {
drop(animal); // 所有権システムによりこのコードに意味はありません。
}
}
さらに、肉食動物を表すトレイトも定義していきます。
trait CarnivoreAnimal: Animal + Carnivore {}
このときに、ブランケット実装を定義することで、AnimalとCarnivoreを実装している型に対して、自動的にCarnivoreAnimalも実装させることができます。
impl<T: Carnivore + Animal> CarnivoreAnimal for T {}
食物連鎖を関数で表現する例
fn food_chain<T, U>(predator: T, prey: U)
where
T: CarnivoreAnimal,
U: Animal,
{
predator.eat(prey);
predator.make_sound();
}
非同期でトレイト
Carnivoreトレイトを非同期で実行するように変更します[2]。
trait Carnivore {
fn eat<T>(&self, animal: T) -> Pin<Box<dyn Future<Output = ()>>>
where
T: Animal + 'static;
}
'staticライフタイムが要求されていますが、ここで重要なのは参照が有効であることではなく、参照を含んでいないことです。
実装は次のようになります。
impl Carnivore for Cat {
fn eat<T>(&self, animal: T) -> Pin<Box<dyn Future<Output = ()>>>
where
T: Animal + 'static,
{
Box::pin(async move {
drop(animal);
})
}
}
#[tokio::main]
async fn main() {
Cat.eat(Mouse).await;
}
関連型でトレイトを返す
トレイトからトレイトを返す単純な方法はトレイトオブジェクトを返すことです。
trait UserRepository {
fn read(&self) -> Vec<User>;
fn save(&self, user: Vec<User>);
}
trait UserUnitOfWork {
fn begin(&self) -> Box<dyn UserRepository>;
fn commit(&self, storage: Box<dyn UserRepository>);
fn rollback(&self, storage: Box<dyn UserRepository>);
}
これに対して、関連型に対して、トレイト境界を指定することで、静的ディスパッチすることができます。
trait UserUnitOfWork {
type Inner: UserRepository;
fn begin(&self) -> Self::Inner;
fn commit(&self, storage: Self::Inner);
fn rollback(&self, storage: Self::Inner);
}
ワークスペース
Rustでは、ワークスペースとしてクレートを分離することができます。適当なディレクトリに、ワークスペースを定義したTOMLファイルを作成します。
[workspace]
resolver = "3"
上記のファイルがある場合は、cargo newでワークスペースを追加することができます。
cargo new hoge
[workspace]
resolver = "3"
members = ["hoge"]
同じCargoプロジェクトでもワークスペースの別memberであれば、お互いが別のクレートとして振る舞ったり、--featureフラグでコンパイルしたりしなかったりすることができます。
Newtypeパターンで異なるクレート同士でトレイトを実装する
RustにはOrphan ruleというものがあります。トレイトの実装は、そのトレイトか実装のうち少なくとも1つの型が現在のクレートで定義されている場合にのみ許可されるというものです。ワークスペースでドメイン知識と表示(プレゼンテーション)を分離したい場合を想定します。
hoge-domainクレート:
pub enum Gender {
Male,
Female,
NonBinary,
Genderfluid,
Agender,
}
hoge-presenクレート:
impl From<Gender> for u8 {
fn from(value: Gender) -> Self {
match value {
Gender::Male => 0,
Gender::Female => 1,
Gender::NonBinary => 2,
Gender::Genderfluid => 3,
Gender::Agender => 4,
}
}
}
これを実装しようとすると、次のコンパイルエラーが出力されます。
3 | impl From<Gender> for u8 {
| ^^^^^------------^^^^^--
| | |
| | `u8` is not defined in the current crate
| `Gender` is not defined in the current crate
これに対して用いられる手法がNewtypeパターンです。
pub struct GenderSchema(Gender);
impl From<GenderSchema> for u8 {
fn from(value: GenderSchema) -> Self {
match value.0 {
Gender::Male => 1,
Gender::Female => 2,
Gender::NonBinary => 3,
Gender::Genderfluid => 4,
Gender::Agender => 5,
}
}
}
pub struct GenderSchema(Gender)でラッパーのような型を定義し、それに対して別クレートの型を実装します。
hoge-domainクレート
pub enum Gender {
Male,
Female,
NonBinary,
Genderfluid,
Agender,
}
hoge-presenクレート
use hoge_domain::Gender;
pub struct GenderSchema(Gender);
impl From<GenderSchema> for u8 {
fn from(value: GenderSchema) -> Self {
match value.0 {
Gender::Male => 1,
Gender::Female => 2,
Gender::NonBinary => 3,
Gender::Genderfluid => 4,
Gender::Agender => 5,
}
}
}
impl From<Gender> for GenderSchema {
fn from(value: Gender) -> Self {
GenderSchema(value)
}
}
impl From<GenderSchema> for Gender {
fn from(value: GenderSchema) -> Self {
value.0
}
}
hogeクレート
use hoge_domain::Gender;
use hoge_presen::GenderSchema;
fn main() {
let s: u8 = GenderSchema::from(Gender::Male).into();
println!("{s}");
}
FromStr, Displayで文字列とドメイン知識を交換する
氏名は苗字、名前から構成されるというドメイン知識がある場合を考えます。
pub struct Fullname {
first_name: String,
last_name: String,
}
初期化の際に、Fullname::newを使用する他に、FromStrとDisplayを実装する方法があります。
代表例として"192.168.0.1".parse::<IpAddr>()のように使用できます。
let fullname = "John Doe".parse::<Fullname>().unwrap();
assert_eq!(fullname.first_name, "John");
assert_eq!(fullname.last_name, "Doe");
assert_eq!(fullname.to_string(), "John Doe");
FromStrとDisplayの実装
use std::{fmt::Display, str::FromStr};
impl FromStr for Fullname {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let parts: Vec<&str> = s.split(' ').collect();
if parts.len() != 2 {
return Err(format!("invalid format: {:?}", s));
}
Ok(Fullname {
first_name: parts[0].to_string(),
last_name: parts[1].to_string(),
})
}
}
impl Display for Fullname {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} {}", self.first_name, self.last_name)
}
}
参考
- The Rust Programming Language 日本語版. "トレイト: 共通の振る舞いを定義する"
- Rust Documentation. "keyword dyn"
- エディションガイド. "新しいキーワード"
- Implementations
Discussion