
2026/06/03 22:29
継承のない Rust で実装可能な 9 つの継承方法
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
核心メッセージは、Rust が従来の継承クラスを、強力なトレイト(特性)、実装(implementation)、そして組成(composition)のシステムに置き換えるという点にあります。オブジェクト指向言語ではサブクラスが自動的にフィールドやメソッドを継承する一方で、Rust ではタイプに振る舞いを付与するために硬直した階層構造を作成せずに、独自の機構を用います。このアプローチは、クラスによって以前対処されていた 9 つの特定の設計問題を効果的に解決します。これらは「トレイトデフォルトメソッド」「スーパートレイト」「拡張トレイト」「派生された振る舞い」「Derefラッピング」「汎用実装」「マクロ生成された impl」「制約によるゲート付きメソッド」「メソッドレベルの制約」などです。これらにより、「孤児規則(orphan rule)」や「メソッド整合性」といった厳格なルールを通じてコードの明確さが保証されます。
旧来のオブジェクト指向のパターンを単に翻訳するのではなく、開発者はロールが深いスーパークラスのツリーではなく、フラットなトレイトによって定義される機能的なマインドセットを採用する必要があります。この変化により、外部のタイプへのメソッド追加や整数のようなラッパーの作成において冗長なコードを排除することが可能になります。Rust を採用する企業は、機能継承を前提とした設計思考から離れ、代わりに汎用制約を通じて振る舞いを明示的に定義へとシフトさせなければなりません。究極的には、これがより柔軟で再利用可能なアーキテクチャを促進し、Rust の独自な強みを活用した流麗(fluent)なスタイルを採用することを促し、堅牢なソフトウェア工学の実現に向けたものとなります。
本文
Rust はクラス継承をサポートしない?9 つの「継承のような」パターンで実現する
画像を全サイズ表示するには、Enter キー を押下するか、クリックしてください。
赤い蟹は単純な部品から小さく、明確な構造を構築しますが、その背後にはクラス継承の木のような影が控えています。Rust は多くの「継承のような」効果を達成できますが、クラス階層ではなく、特性(traits)、実装(impls)、境界条件(bounds)、マクロ、そして合成(composition)を通じてそれを実現します。
この記事の動画バージョンについては、Rust Videos YouTube チャンネル にある「シカゴ Rust ユーザー・グループ向けのこの講演」をご覧ください。 このプロジェクトのコードについては、GitHub で確認できます。
なぜ継承が必要なのか?
Rust はクラス継承をサポートしていません。これは言語機能の狭い意味において事実です:クラスそのものはなく、サブクラスの宣言も、親クラスから継承されるフィールドもありません。
しかし、オブジェクト指向言語で開発者が「継承」を探す際に目指しているのは、通常以下のような 3 つの効果の一つ であることが多いです:
- 共有されたインターフェース:同じ汎用コードが、異なる具体的なタイプと連携できるようにする
- 共有された振る舞い:多くのタイプが 1 つの実装を再利用できるようにする
- ときおり、共有されたデータストレージ:サブタイプがスーパータイプからフィールドを受け継ぐ
Rust は第 3 の効果(共有されたデータ)について直接的にはサポートしていません。しかし、最初の 2 つの効果については驚くほど豊富なツールのセットを提供してくれます。
この記事では、9 つの「継承のような」問題事例と、それらを検証するための Rust の技術について詳しく解説します。
補足:この記事では、「抽象クラス」、「インターフェース」、「特性(trait)」を、役割や契約を表す 3 つの表現方法として使用します。Rust では、通常メカニズムとなるのは具体的タイプが実装できる何か(即ち「特性」)です。これに対して、「具体的なクラス」と言った場合は、値を実際に作成できる Rust の構造体(struct)または列挙型(enum)を想定してください。
この記事は、9 つの小さなパズルを中心に構成されています。各パズルはオブジェクト指向の設計で始まり、続いて「Rust で同じ考え方をどのように表現すべきか」と問われます。
9 つのパズルの概要
- すべての整数に共通のヘルパーメソッドを付与する
- 動くサーボモーターであっても、静止状態であればサーボとして扱うようにする
- 自分の所有していないタイプにメソッドを追加する
- タINY な列挙型に標準的な振る舞いの全てを与える
- ラッパーが内部のものをそのままのように感じるようにする
- 任意のレンジセット(RangeSet)コレクションに対して
メソッドを追加するunion() - 15 つの整数のようなタイプを同じ方法で扱う
のみに対し、バイト指向のメソッドを与えるOutputArray<8>- シリアライズ可能な値のみをフラッシュメモリに保存する
1. すべての整数に共通のヘルパーメソッドを付与する
RangeSetBlaze クレートは数学的な整数集合を格納します:u8, i16 などです。このクレートは、各整数タイプで定義すべき min_value および max_value などのメソッドを通じて整数と作業します。これらの必要なメソッドを用いて、さらに exhausted_range などの追加メソッドのための共有コードも提供したいのです。
オブジェクト指向のクラス図では、必要なメソッドを持つ抽象的な
Integer クラスを描き、具体的な整数タイプが継承する実装されたメソッドを 1 つ示すかもしれません。
画像を全サイズ表示するには、Enter キー を押下するか、クリックしてください。
パズル
これを Rust でどのように実装できるでしょうか?
解答:特性のデフォルトメソッド(Trait Default Methods)
Rust では、2 つの必要なメソッドと 1 つのデフォルトメソッド(
exhausted_range)を持つ特性を定義できます:
use std::ops::RangeInclusive; trait Integer: Copy + Ord { fn min_value() -> Self; fn max_value() -> Self; // デフォルト実装(共有された振る舞い) fn exhausted_range() -> RangeInclusive<Self> { debug_assert!(Self::min_value() < Self::max_value(), "Precondition"); Self::max_value()..=Self::min_value() } }
- 共有されたコードは特性の中に 1 度だけ存在します。
- 各実装元はその
およびmin_value
のコードを提供します:max_value
impl Integer for u8 { fn min_value() -> Self { u8::MIN } fn max_value() -> Self { u8::MAX } } impl Integer for i16 { fn min_value() -> Self { i16::MIN } fn max_value() -> Self { i16::MAX } }
これらを使用すると:
let r1 = u8::exhausted_range(); let r2 = i16::exhausted_range(); assert_eq!(r1, 255..=0); // 空の範囲 assert!(r2.is_empty()); // i16 も同様に有効な結果
このテクニックは「特性のデフォルトメソッド」と呼ばれます。
2. 動くサーボモーターであっても静止状態であればサーボとして扱うようにする
device-envoy クレートはマイクロコントローラー上で高水準の Rust アプリケーションを作成することを助けます。このクレートは ESP32 および Raspberry Pi Pico 族のマイクロコントローラーと連携します。
サーボモーターとは、特定の角度へと動かすように指示できる電気モーターです。
- 抽象的なクラス
(単純な制御)Servo - 抽象的なクラス
(アニメーション化された制御)ServoPlayer
これらは階層構造を持ちます:すべての
ServoPlayer は Servo の一種ですが、追加の機能(シーケンス)を持つため別々のクラスとして扱います。
パズル
Rust では、この階層をどのように実装すればよいでしょうか?
解答:スーパー特性(Supertraits)
Rust では、1 つの特性が別の特性を要求できます:
trait Servo { fn set_degrees(&self, degrees: u16); } trait ServoPlayer: Servo { // : Servo の部分が重要 fn animate(&self, steps: &[(u16, u64)]); }
の部分は、すべての: Servo
もServoPlayer
でなければならないことを意味します。Servo- Rust 用語では、
はServo
のスーパー特性です。ServoPlayer
実装例
Servo のみを実装するタイプ:
#[derive(Default)] struct ServoEsp; impl Servo for ServoEsp { fn set_degrees(&self, degrees: u16) { println!("[ServoEsp] set angle -> {degrees}°"); } }
ServoPlayer も実装するタイプ:
#[derive(Default)] struct ServoPlayerEsp; impl Servo for ServoPlayerEsp { // 明示的に実装が必要 fn set_degrees(&self, degrees: u16) { println!("[ServoPlayerEsp] set angle -> {degrees}°"); } } impl ServoPlayer for ServoPlayerEsp { fn animate(&self, steps: &[(u16, u64)]) { for (degrees, _milliseconds) in steps { self.set_degrees(*degrees); } } }
ポイント:継承ではない
は 2 つのServoPlayerEsp
ブロックで実装しています。これは、Rust が「impl
」をクラス継承のように解釈しないからです。ServoPlayer: Servo- これは「インターフェースのレベル」を構築しているだけであり、クラス階層ではありません。
汎用コードは必要な機能だけを要求できます:
fn center_servo(servo: &impl Servo) { /* ... */ } fn run_wave(player: &impl ServoPlayer) { /* ... */ }
ServoPlayerEsp は両方の関数に渡せますが、平らな ServoEsp は center_servo のみを受け取れます。
3. 自分の所有していないタイプにメソッドを追加する
例えば、
usize(Rust のサイズ用標準整数)に対して is_odd() を追加したいと仮定します。
パズル
既存のプリミティブ型
usize に新しいメソッドをどう追加すべきか?
解答:拡張特性(Extension Trait)
直接的な実装(固有の実装)は不可能です:
// impl usize { fn is_odd(self) -> bool { ... } } // error[E0390]: cannot define inherent `impl` for primitive types
しかし、独自の特性を定義し、それを実装することは可能です:
trait UsizeExtensions { fn is_odd(self) -> bool; } impl UsizeExtensions for usize { fn is_odd(self) -> bool { self & 1 != 0 } }
これで以下のように使用できます:
let count: usize = 7; assert!(count.is_odd()); // 拡張メソッドとして動作
なぜ直接実装できないのか?
コンパイラーは
usize を改変していないためです。独自の特性を定義し、「usize はこの役割を果たせる」と述べるに過ぎません。もしすべてのクレートが同じメソッドリスト(固有の実装)を追加できるなら、多くのクレートが衝突することになります。
4. タINY な列挙型に標準的な振る舞いの全てを与える
小さな列挙型に対して、標準的な振る舞いを望むことがよくあります:デフォルト値、印刷、同等性の判定、ハッシュ化など。
パズル
LedLevel にすべての標準的な振る舞いを手書きなしで与えるには?
解答:派生によって生成された実装(Derive-Generated Implementations)
Rust では標準的な特性が既に存在し、
derive 属性が実装を記述してくれます:
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Default)] enum LedLevel { On, #[default] // デフォルト値の指定 Off, }
これにより、以下のような動作が可能になります:
let default_level = LedLevel::default(); let on = LedLevel::On; assert_eq!(default_level, LedLevel::Off); assert!(on > off); // 順序付けも可能
derive 属性は、マクロが具体的なタイプのための通常の特性実装を生成するように求めています。
5. ラッパーが内部のものをそのままのように感じるようにする
次に別の種類のパズル:「継承したいもの」が特性でなく、具体的なタイプ(例:
String)であった場合どうすればよいでしょうか?
パズル
HtmlBuffer が String のように感じられるようにしながら、同時に独立したタイプである必要がある。
解答:Deref メソッド検索(Deref Method Lookup)
String を直接実装することはできません(特性ではないため)。代わりにラッピングし、Deref と DerefMut を実装します:
use std::ops::{Deref, DerefMut}; struct HtmlBuffer(String); // 内部的なストレージ impl Deref for HtmlBuffer { type Target = String; fn deref(&self) -> &Self::Target { &self.0 // String への参照を返す } } impl DerefMut for HtmlBuffer { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 } }
これで
HtmlBuffer を扱う際、多くの String メソッドが利用可能になります:
let mut page = HtmlBuffer::new(); page.push_str("<h1>Hello</h1>"); // String のメソッドをそのまま使える assert_eq!(page.len(), 25); // Deref を通じてアクセス
注意点
- 完全な同一性ではない:
はメソッド検索を助けますが、Deref
をHtmlBuffer
と完全に同一にしません(例:所有権の文脈など)。String - 過剰な公開のリスク:HTML バッファは単なる文字列ではありません。
の全体インターフェースを公開することで意図が曖昧になる可能性があります。String
6. 任意のレンジセットコレクションに対して union()
メソッドを追加する
union()すべてのイテレータブルな
RangeSetBlaze 参照のコレクションに対して、和集合(union)を取得できるメソッドを与えたいです。
パズル
予期しないコレクションタイプを含む、すべてのコレクションに
union() を追加するには?
解答:全般的実装(Blanket Implementations)
ここでは、コレクション特性を定義します:
use std::collections::BTreeSet; type Integer = u64; struct RangeSetBlaze { values: BTreeSet<Integer>, } trait RangeSetCollection<'a>: IntoIterator<Item = &'a RangeSetBlaze> { fn union(self) -> RangeSetBlaze { /* ... */ } } // 全般的実装:条件を満たす **すべての** タイプに振る舞いを追加 impl<'a, I> RangeSetCollection<'a> for I where I: IntoIterator<Item = &'a RangeSetBlaze> {}
- 「
のイテレーターになり得る**すべてのタイプ&RangeSetBlaze
」がこの特性を受け取ります。I - ベクター、配列、フィルタされた
などに自動的に適用されます。Option
7. 15 つの整数のようなタイプを同じように扱う
RangeSetBlaze は 15 つの整数(プリミティブ、Ipv4Addr, char など)を一様に扱う必要があります。
パズル
同じメソッド本体を 15 回書かず、すべてにインターフェースを与えるには?
解答:マクロによって生成された実装
まずは共通のインターフェース(特性)を定義:
trait Integer: Copy + Ord { fn add_one(self) -> Self; fn min_value() -> Self; fn max_value() -> Self; }
次に、ファミリーごとに異なるロジックを持つマクロを実装ブロックに使用します:
- 数値整数:
など単純な計算self + 1 - IP アドレス:内部の表現型(
,u32
)に変換して処理u128 - 文字(Char):ユニコードの代理範囲(Surrogate Pairs)をスキップする特殊なロジック
macro_rules! impl_integer_ops_num { ($t:ty) => { // ... 共通の実装コード ... }; } // 各タイプに対して一度だけ使用 impl Integer for u8 { impl_integer_ops_num!(u8); } impl Integer for i16 { impl_integer_ops_num!(i16); } // ... 他も同様
ポイント
- サードパーティクレート(例:
)の特性を使おうとすると、将来の互換性や排他的実装の衝突リスクがあります。num_traits - マクロは既知のタイプのリスト全体にコードを共有することを可能にし、クリーンな全般的実装を実現します。
8. OutputArray<8>
のみに対しバイト指向のメソッドを与える
OutputArray<8>OutputArray<N>(ブールの固定サイズ配列)を考えます。
すべての長さに共通の機能が必要です。ただし、長さ 8 の場合にのみ追加のメソッド set_from_bits(u8) を持てたいです。
パズル
条件付きに特定のメソッドを付け加えるには?
解答:制約ゲートされたメソッド(Constraint-Gated Methods)
共通の実装と、特定バージョンへの追加実装を分けます:
struct OutputArray<const N: usize> { levels: [bool; N], } // 全タイプに適用:基本的な機能 impl<const N: usize> OutputArray<N> { fn new() -> Self { /* ... */ } fn set_level_at_index(&mut self, index: usize, level: bool) { /* ... */ } } // 特定バージョン(N=8)への追加機能のみ impl OutputArray<8> { fn set_from_bits(&mut self, bits: u8) { /* ... */ } // 制約ゲート }
OutputArray::<4> は基本機能のみを持ち、set_from_bits は持いません。
9. シリアライズ可能な値のみをフラッシュメモリに保存する
値をフラッシュメモリに書き込みます。
,new
:常に利用可能clear
,save
:シリアライズ可能である場合にのみ利用可能load
パズル
タイプパラメータが必要な能力を持っている場合のみ、いくつかのメソッドを利用可能にするには?
解答:メソッドレベルの制約(Method-Level Constraints)
use serde::{Serialize, Deserialize}; struct FlashBlock { store: HashMap<String, Vec<u8>>, } impl FlashBlock { // 常に利用可能 fn new() -> Self { Self::default() } fn clear(&mut self) { self.store.clear(); } // シリアライズ可能な場合のみ有効(where 句による制約) fn save<T>(&mut self, key: &str, value: &T) -> Result<(), postcard::Error> where T: Serialize + DeserializeOwned { /* ... */ } fn load<T>(&self, key: &str) -> Option<T> where T: Serialize + DeserializeOwned { /* ... */ } }
- タイプ自体は広範に利用可能ですが、個別のメソッドは必要な能力(シリアライズ可能性)を述べるようにします。
- 型不一致の場合、
でエラーを出し、save
はload
を返す(安全な設計)。None
結論:9 つのパターンにあるパターン
要点です:
| パズル | Rust の対応概念 |
|---|---|
| 1. 共通ヘルパーメソッド | 特性のデフォルトメソッド |
| 2. サーボモーターの抽象化 | スーパー特性 |
| 3. プリミティブにメソッド追加 | 拡張特性(Extension Trait) |
| 4. 列挙型の標準機能 | 派生によって生成された実装 |
| 5. ラッパーが内部のものと同様 | Deref メソッド検索 |
| 6. コレクションの共通メソッド | 全般的実装(Blanket Implementations) |
| 7. 多数のタイプを一元管理 | マクロによって生成された実装 |
| 8. 特定のバージョン限定機能 | 制約ゲートされたメソッド |
| 9. 条件付きメソッド有効化 | メソッドレベルの制約 |
私はこれらを Rust で継承を行う 9 つの方法として考え始めました。終わりには、リストはそれより小さいように見えました。多くの技術は単なる数々のアイデアの組み合わせです:
- 特性は役割を定義する
ブロックは振る舞いを付着させるimpl- ジェネリクスは同じコードが多くのタイプに適用できるようにする
- 境界条件はジェネリックな振る舞いが存在するかどうかを決める
- マクロは反復を除去する
Rust はクラス継承をサポートしていません。しかし、継承から望んだことが共有されたインターフェースと共有された振る舞いである場合、Rust はそれのための構成要素を持っています。重要なのは、これらの構成要素を組み合わせることで、何を意味しているかを伝えることです。
補足:整合性(Coherence) 時おり、これら構成要素を組み合わせたときに Rust コンパイラーは不満を示します。Rust はこの関連するルールファミリーを「整合性」と呼びます。これは、与えられたタイプと特性に対して、Rust が適用される明確な 1 つの実装を望むことを意味します。オブジェクト指向言語は、クラス階層を通じてメソッド検索を行うことで回避しますが、Rust は明示的な実装を求めます。
私にとっての教訓は、「Rust でクラスをどのように再現するか」ではありません。「オブジェクト指向の設計から Rust への翻訳から始めて、特性、境界条件、実装、ジェネリクス、そして明示的な振る舞いといった Rust の独自言語でより流暢に考えるように移動する方法」です。
Rust の継承のような振る舞いのこのツアーについてご一緒にしてくださりありがとうございます。