Unit と trait impl による抽象化
Unit 型と型システムの統一性
式ベースのプログラミングの章で触れた Unit 型 () は,Rust の型システムの統一性を支える重要な要素だ.ここではまず Unit 型の本質をより深く見てから,トレイトの世界に入ろう.
Unit 型の正体
Unit 型 () は「値が一つしか存在しない型」であり,その唯一の値も () と書く.これは空のタプルと同一だ:
let unit: () = ();
// 0要素のタプル = Unit 型
let also_unit: () = ();
Unit 型が重要な理由は,型システムにおける「何もない」を「何かがある」として扱えるようにする 点にある.
void との決定的な違い
C/C++ の void と Rust の () は似て非なるものだ:
// C: void は「型ではない」特殊な存在
void foo(); // OK
void* ptr; // OK(型ではないが汎用ポインタとして使える)
// void x; // NG: void 型の変数は作れない
// sizeof(void); // 未定義動作(GCC 拡張では 1)
// Rust: () は普通の型
fn foo() -> () { () } // OK
let x: () = (); // OK: Unit 型の変数を作れる
std::mem::size_of::<()>() // 0(サイズゼロだが,正当な型)
// Vec<()> すら作れる
let v: Vec<()> = vec![(), (), ()];
assert_eq!(v.len(), 3);
() は正当な型であり,変数に束縛でき,ジェネリクスの型パラメータとして渡せる.これにより型システムに特殊ケースが生まれない.
Unit 型がもたらす一般化
Unit 型の真価は,ジェネリックな構造において「値を持たない」ケースを自然に表現できる点にある:
// Result<(), Error>: 成功時に値がない操作
fn delete_file(path: &str) -> Result<(), std::io::Error> {
std::fs::remove_file(path)
}
// HashMap<String, ()>: 値がない → 実質的に HashSet と同等
use std::collections::HashMap;
let mut set: HashMap<String, ()> = HashMap::new();
set.insert("key".to_string(), ());
// Option<()>: 「存在するかどうか」だけを表現
let exists: Option<()> = Some(());
Result<(), E> は Rust で非常に頻繁に登場するパターンだ.「成功/失敗はあるが,成功時に返すデータはない」という操作を,専用の型を導入することなく表現できる.
Unit 型とトレイト
Unit 型はトレイトも実装できる通常の型だ:
// () に対して Display を実装することもできる(実際に標準ライブラリで実装済み)
// () は Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default を実装している
let a: () = Default::default(); // () のデフォルト値は ()
assert_eq!((), ()); // PartialEq が使える
Unit 型がこれらのトレイトを実装していることで,T: Default や T: Eq のようなトレイト境界を持つジェネリック関数に () を渡すことができる.型システムに穴が開かない.
トレイトとは何か
Bitterless Rust では,#[derive(Debug, Clone, PartialEq)] を「おまじない」と紹介した.この章では,その背後にある トレイト (trait) の仕組みを正確に理解する.
トレイトは,型が満たすべき振る舞いのインターフェースを定義するものだ.Java の interface や Haskell の型クラス (type class) に近い概念だが,Rust のトレイトには独自の強力な特性がある.
trait Greet {
fn greet(&self) -> String;
}
この定義は,「Greet トレイトを実装する型は,greet というメソッドを持たなければならない」ということを意味する.
トレイトの実装 (impl)
トレイトは impl Trait for Type 構文で任意の型に実装できる:
struct User {
name: String,
}
impl Greet for User {
fn greet(&self) -> String {
format!("Hello, I'm {}!", self.name)
}
}
struct Bot {
id: u32,
}
impl Greet for Bot {
fn greet(&self) -> String {
format!("Beep boop, I'm Bot #{}.", self.id)
}
}
User と Bot はまったく異なる構造体だが,どちらも Greet トレイトを実装しているため,greet() メソッドを持つ.
標準ライブラリのトレイト: ビルトイン機能の抽象化
Rust の設計で特に素晴らしいのは,言語のビルトイン機能がトレイトによって抽象化されている 点だ.多くの言語でコンパイラのハードコードされた振る舞いとして実装されているものが,Rust ではトレイトとして明示的に定義され,ユーザー定義型にも同じように実装できる.
Display: 文字列表示
println!("{}", x) で {} に値を表示するとき,内部では Display トレイトが呼ばれる:
use std::fmt;
struct Point {
x: f64,
y: f64,
}
impl fmt::Display for Point {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "({}, {})", self.x, self.y)
}
}
fn main() {
let p = Point { x: 1.0, y: 2.0 };
println!("{}", p); // (1.0, 2.0)
// Display を実装すると .to_string() も自動的に使える
let s: String = p.to_string();
}
Java では toString() メソッドが Object クラスに定義されており,すべてのオブジェクトが暗黙にこれを継承する.Rust では Display はトレイトとして明示的に実装する.実装しなければ {} で表示はできない.これは「暗黙の振る舞い」を排除する Rust の設計哲学を体現している.
Debug トレイト({:?} で使われる)も同様で,#[derive(Debug)] はコンパイラが Debug トレイトの実装を自動生成するマクロにすぎない.
Drop: リソース解放
値がスコープを抜けるとき,Rust は Drop トレイトの drop メソッドを自動的に呼び出す:
struct FileHandle {
name: String,
}
impl Drop for FileHandle {
fn drop(&mut self) {
println!("Closing file: {}", self.name);
}
}
fn main() {
let _f = FileHandle { name: String::from("data.txt") };
println!("File opened");
// ← ここで _f がスコープを抜け,drop が自動的に呼ばれる
}
// 出力:
// File opened
// Closing file: data.txt
C++ の RAII (Resource Acquisition Is Initialization) パターンと同様だが,Rust では Drop というトレイトとして明示的に定義されている.ファイルハンドル,ネットワークソケット,ロック — これらのリソース解放はすべて Drop トレイトの実装として統一的に扱われる.
所有権システムと Drop トレイトの組み合わせにより,リソースリークを構造的に防ぐことができる.
Copy と Clone: 値の複製
Rust には値の複製に関する 2 つのトレイトがある:
// Copy: 暗黙的なビット単位のコピー
// 値の代入や関数への受け渡し時に自動的にコピーされる
#[derive(Copy, Clone)]
struct Point {
x: f64,
y: f64,
}
// Clone: 明示的な深いコピー
// .clone() メソッドの呼び出しが必要
#[derive(Clone)]
struct Buffer {
data: Vec<u8>,
}
Copy と Clone の区別は Rust の所有権システムの核心に関わる:
Copy: ビット単位のコピーで完結する型にのみ実装可能.i32,f64,boolなどのプリミティブ型はすべてCopyを実装している.Copyな型の値は,代入や関数呼び出しで暗黙的にコピーされる(所有権が移動しない)Clone:.clone()の明示的な呼び出しが必要.ヒープメモリの深いコピーなど,コストのかかる操作を伴いうる.StringやVec<T>はCloneを実装しているがCopyは実装していない
Copy は Clone のサブトレイト(Copy: Clone)であるため,Copy を実装する型は必ず Clone も実装する.
この設計により,「コストの低いコピー」と「コストの高いコピー」が型レベルで明確に区別される.Bitterless Rust で「.clone() すれば解決」と言ったのは,Clone トレイトのメソッドを呼んでいたのだ.
PartialEq と Eq: 等値比較
#[derive(PartialEq)]
struct Point {
x: f64,
y: f64,
}
fn main() {
let a = Point { x: 1.0, y: 2.0 };
let b = Point { x: 1.0, y: 2.0 };
assert!(a == b); // PartialEq が == 演算子を提供する
}
PartialEq と Eq が分離されている理由は数学的な背景にある:
PartialEq: 半等価関係.a == aが常にtrueになるとは限らない.f64はNaN != NaNであるため,PartialEqのみを実装しEqは実装しないEq: 等価関係(反射律を満たす).a == aが常にtrue.i32,String,boolなどはEqを実装する
Eq は PartialEq のサブトレイト(Eq: PartialEq)であり,追加のメソッドを定義しない.しかし,HashMap のキーには Eq の実装が要求されるなど,型レベルの制約として機能する.
同様に PartialOrd と Ord もある:
PartialOrd: 半順序.比較できないペアが存在しうる(f64のNaN)Ord: 全順序.すべてのペアが比較可能
Add, Sub, Mul, ...: 演算子オーバーロード
Rust の演算子はトレイトとして定義されている:
use std::ops::Add;
#[derive(Debug, Clone, Copy)]
struct Vec2 {
x: f64,
y: f64,
}
impl Add for Vec2 {
type Output = Vec2;
fn add(self, rhs: Vec2) -> Vec2 {
Vec2 {
x: self.x + rhs.x,
y: self.y + rhs.y,
}
}
}
fn main() {
let a = Vec2 { x: 1.0, y: 2.0 };
let b = Vec2 { x: 3.0, y: 4.0 };
let c = a + b; // Add トレイトの add メソッドが呼ばれる
println!("{:?}", c); // Vec2 { x: 4.0, y: 6.0 }
}
+ 演算子は Add トレイト,- は Sub,* は Mul,/ は Div,% は Rem,-(単項)は Neg — すべてがトレイトだ.
他にも:
| 演算子/操作 | トレイト |
|---|---|
[] (インデックスアクセス) |
Index / IndexMut |
* (参照外し) |
Deref / DerefMut |
() (関数呼び出し) |
Fn / FnMut / FnOnce |
for x in ... |
IntoIterator |
.. (Range) |
RangeBounds |
これらがすべてトレイトとして定義されていることの意味は大きい.ユーザー定義型に対しても,ビルトイン型と同じ演算子を同じ構文で使える. コンパイラの特別扱いではなく,言語のメカニズムとして統一的に提供されている.
ゼロコスト抽象化としてのトレイト
トレイトは抽象化のメカニズムだが,パフォーマンスの章で触れる通り,単相化により実行時コストが発生しない:
fn print_all(items: &[impl std::fmt::Display]) {
for item in items {
println!("{}", item);
}
}
impl Display はコンパイル時に具体的な型に展開されるため,Display トレイトを通じた間接呼び出しのオーバーヘッドはゼロだ.
デフォルト実装
トレイトはメソッドのデフォルト実装を持つことができる:
trait Summary {
fn title(&self) -> String;
// デフォルト実装: オーバーライド可能
fn summarize(&self) -> String {
format!("{} - (Read more...)", self.title())
}
}
struct Article {
title: String,
content: String,
}
impl Summary for Article {
fn title(&self) -> String {
self.title.clone()
}
// summarize はデフォルト実装がそのまま使われる
}
デフォルト実装は他のトレイトメソッドを呼び出すことができるため,最小限の実装で豊富な機能を提供する設計が可能になる.
孤児ルール (Orphan Rule)
トレイトの実装には重要な制約がある: トレイトまたは型のどちらかが,自分のクレートで定義されたものでなければ,impl Trait for Type を書くことはできない. これを孤児ルール (Orphan Rule) と呼ぶ.
// OK: 自分の型に外部トレイトを実装
impl fmt::Display for MyType { ... }
// OK: 自分のトレイトを外部型に実装
impl MyTrait for Vec<i32> { ... }
// NG: 外部トレイトを外部型に実装
// impl fmt::Display for Vec<i32> { ... } // コンパイルエラー
この制約は,異なるクレートが同じ型に対して同じトレイトを矛盾する形で実装することを防ぐ.制約として不便に感じることもあるが,エコシステム全体の一貫性を保つために不可欠なルールだ.
まとめ
Rust のトレイトシステムの素晴らしさは,以下の点に集約される:
- ビルトイン機能のトレイトによる抽象化: 表示 (
Display),解放 (Drop),複製 (Copy/Clone),比較 (Eq/PartialEq),演算子 (Add/Sub/...) がすべてトレイトとして定義されている - ユーザー定義型への一般化: ビルトイン型と同じトレイトを自分の型にも実装でき,同じ構文で使える
- ゼロコスト: 静的ディスパッチと単相化により,抽象化の実行時コストがゼロ
- 明示性: 暗黙の振る舞いが存在せず,型が持つ能力はすべてトレイト実装として明示される
この設計は,「型がどのような振る舞いを持つか」をトレイトという統一的な言語で記述し,それをコンパイル時に検証するという Rust の型システムの根幹を成している.