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: DefaultT: 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)
    }
}

UserBotはまったく異なる構造体だが,どちらも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>,
}

CopyCloneの区別はRustの所有権システムの核心に関わる:

  • Copy: ビット単位のコピーで完結する型にのみ実装可能.i32, f64, boolなどのプリミティブ型はすべてCopyを実装している.Copyな型の値は,代入や関数呼び出しで暗黙的にコピーされる(所有権が移動しない)

  • Clone: .clone() の明示的な呼び出しが必要.ヒープメモリの深いコピーなど,コストのかかる操作を伴いうる.StringVec<T>Cloneを実装しているがCopyは実装していない

CopyCloneのサブトレイト(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 が == 演算子を提供する
}

PartialEqEqが分離されている理由は数学的な背景にある:

  • PartialEq: 半等価関係.a == aが常にtrueになるとは限らない.f64NaN != NaNであるため,PartialEqのみを実装しEqは実装しない

  • Eq: 等価関係(反射律を満たす).a == aが常にtruei32, String, boolなどはEqを実装する

EqPartialEqのサブトレイト(Eq: PartialEq)であり,追加のメソッドを定義しない.しかし,HashMapのキーにはEqの実装が要求されるなど,型レベルの制約として機能する.

同様にPartialOrdOrdもある:

  • PartialOrd: 半順序.比較できないペアが存在しうる(f64NaN

  • 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のトレイトシステムの素晴らしさは,以下の点に集約される:

  1. ビルトイン機能のトレイトによる抽象化: 表示(Display),解放(Drop),複製(Copy/Clone),比較(Eq/PartialEq),演算子(Add/Sub/...) がすべてトレイトとして定義されている

  2. ユーザー定義型への一般化: ビルトイン型と同じトレイトを自分の型にも実装でき,同じ構文で使える

  3. ゼロコスト: 静的ディスパッチと単相化により,抽象化の実行時コストがゼロ

  4. 明示性: 暗黙の振る舞いが存在せず,型が持つ能力はすべてトレイト実装として明示される

この設計は,「型がどのような振る舞いを持つか」をトレイトという統一的な言語で記述し,それをコンパイル時に検証するというRustの型システムの根幹を成している.