struct と trait

16 分で読了
このページの目次

struct はデータと型のアイデンティティを記述し、trait は要求される振る舞いや関連型、定数を記述します。Rust は impl を通じて振る舞いを型に結びつけ、すべての再利用関係をクラス継承ツリーで整理する必要はありません。

3つの構造体と新しい型

名前付きフィールドは、フィールドの意味が必要なデータに適しています。タプル構造体は、位置が明確なラッパーに適しています。ユニット構造体はフィールドを持たず、マーカやステートレスな戦略として使用できます。type Meters = f64 は単なるエイリアスであり、struct Meters(f64) は異なる型です。

外部の生入力例: f64 によるメートル数1コンストラクタによる検証NaN、無限大、負数を拒否2プライベートフィールドの Metersメソッドは、構築時に確立された不変条件に依存できる3新しい型は制御されたエントリポイントを介してセマンティクスを維持する型名を変更するだけでは不十分です。フィールドへの任意の書き込みが公開されている場合、呼び出し側は検証を回避できます。
mod units {
    #[derive(Debug, Clone, Copy, PartialEq)]
    pub struct Meters(f64);
    impl Meters {
        pub fn new(value: f64) -> Option<Self> {
            if value.is_finite() && value >= 0.0 { Some(Self(value)) } else { None }
        }
        pub fn value(self) -> f64 { self.0 }
    }
}
fn main() {
    let distance = units::Meters::new(12.5).unwrap();
    assert_eq!(distance.value(), 12.5);
    assert!(units::Meters::new(-1.0).is_none());
    assert!(units::Meters::new(f64::NAN).is_none());
}

モジュール外ではプライベートフィールドを直接構築または変更できないため、指定されたエントリポイントを介してのみ値を作成できます。新しい型は、Feet をそのまま Meters の引数として渡すことを防ぎますが、生フィールドを公開して自由に加算すると、単位のエラーが発生する可能性があります。

単一フィールドのラッパーは、内部の型と同じ ABI を自動的に保証するものではありません。適切なレイアウト制約を満たす必要がある場合は、repr(transparent) を検討しますが、「ゼロコスト」をすべての表現の詳細に対する保証として扱ってはなりません。

新しい型の不変条件は、すべての変更エントリポイントをカバーする必要があります

Meters::new は負数、NaN、無限大を拒否し、「現在の値が有限で非負である」という不変条件を確立します。後で add メソッドを追加し、直接 f64 の加算を実行した場合、2つの有限値の和がオーバーフローして無限大になる可能性があるため、メソッドも結果を再検証する必要があります。

DerefMut<Target = f64> を提供するかどうかもカプセル化に影響します。これにより、呼び出し側は任意の f64 を直接書き込み、構築時の検証を回避できます。シリアライズ・デシリアライズ、From/TryFrom、デフォルト値、算術トレイトも、値を作成または変更するエントリポイントです。プライベートフィールドに書き込むだけで、不変条件の証明が自動的に完了するわけではありません。

以下に、有限の整数範囲を用いて、「検証後、代入」という失敗の動作を示します。set がエラーを返す場合、旧値が保持されるため、呼び出し側は失敗後にオブジェクトが部分的に変更されたかどうかを推測する必要がありません。

mod limits {
    #[derive(Debug, PartialEq)]
    pub struct Percent(u8);
    impl TryFrom<u16> for Percent {
        type Error = &'static str;
        fn try_from(value: u16) -> Result<Self, Self::Error> {
            if value <= 100 { Ok(Self(value as u8)) } else { Err("out of range") }
        }
    }
    impl Percent {
        pub fn value(&self) -> u8 { self.0 }
        pub fn set(&mut self, value: u16) -> Result<(), &'static str> {
            let checked = Self::try_from(value)?;
            *self = checked;
            Ok(())
        }
    }
}
fn main() {
    let mut value = limits::Percent::try_from(40).unwrap();
    assert_eq!(value.set(101), Err("out of range"));
    assert_eq!(value.value(), 40);
    value.set(100).unwrap();
    assert_eq!(value.value(), 100);
}

From は失敗がなく、変換のセマンティクスに適合する場合に適しており、TryFrom は失敗の可能性を型に明示的に記述します。.into() の利便性のために、無効な値を黙って切り捨てて別の意味にしないでください。ここでは as u8 がすでに <= 100 をチェックしたブランチ内に配置されており、安全な範囲は前置条件によって確立されます。TryFrom

構造体更新構文は Clone を呼び出さない

Config { retries: 5, ..old } は、明示的に提供されていないフィールドを old から取得します。Copy 属性を持つフィールドはコピーされ、他のフィールドはムーブされます。これはオブジェクト全体をクローンするものでも、別のインスタンスを継承するものでもありません。

struct Config { endpoint: String, retries: u8 }
fn main() {
    let old = Config { endpoint: "localhost".into(), retries: 2 };
    let next = Config { retries: 5, ..old };
    assert_eq!(next.endpoint, "localhost");
    assert_eq!(next.retries, 5);
    // endpoint はムーブされたが、retries はムーブされていないため、個別に読み取ることができる。
    assert_eq!(old.retries, 2);
}

その後、old 全体を借用して印刷したり、完全なオブジェクトに依存するメソッドを呼び出したりすることはできません。更新構文はフィールドの可視性や Drop などのルールによって影響を受けます。モジュール外では、..old を介してプライベートフィールドを迂回してオブジェクトを構築することはできません。Struct update

trait は能力を宣言し、impl は実装を提供する

トレイトメソッドにはデフォルト実装を持たせることができ、関連型は通常、実装によって指定されます。デフォルトメソッドは、トレイトが約束した能力を使用できます。&self による借用、&mut self による排他的借用、self による値の消費は、インターフェースの振る舞いの重要な構成要素です。

trait Summary {
    fn summary(&self) -> String;
    fn decorated(&self) -> String { format!("[{}]", self.summary()) }
}
struct Point { x: i32, y: i32 }
impl Summary for Point {
    fn summary(&self) -> String { format!("{},{}", self.x, self.y) }
}
fn render<T: Summary>(item: &T) -> String { item.decorated() }
fn main() {
    assert_eq!(render(&Point { x: 2, y: 3 }), "[2,3]");
}

コンパイラはシグネチャと型の要件をチェックできますが、すべてのビジネスロジックの契約を証明するわけではありません。例えば、あるメソッドが結果をソートすることを約束している場合や、Eq/Hash が一貫している必要がある場合など、これらのセマンティクスは実装側が維持する必要があります。トレイトは単なる関数名のリストではありません。Traits

受信者はリソースのライフサイクル設計である

&self:共有アクセスを借用読み取り専用インターフェースによりデータにアクセス可能;内部可変性には別のルールがある&mut self:排他的アクセスを借用メソッドは不変条件の維持を担当し、返す前に変更を確認してからコミットできるself:この値を手放すメソッドの受信者は、呼び出し側が何を渡すかを決定するfinish / into_inner に適している;消費後は元の値として使い続けることはできない受信者の制約はインターフェースの一部です。コンパイルが通ったからといって、メソッドがソート、等価性、エラー回復などのセマンティクスに従っていることを証明するものではありません。

借用と消費は単なる書き方の好みではありません。finish(self) は、オブジェクトが最終段階に入ったことを意味し、呼び出し側は所有権を手放します。finish(&mut self) の場合、完了後にオブジェクトがどのような利用可能な状態にあるかを定義する必要があります。再利用可能なバッファの場合、後者が適している可能性があります。再利用できないビルダーの場合、前者が意図をより明確に表現できます。

共有参照が必ずしも「メソッドが一切変更しない」ことを意味するわけではありません。Cell、RefCell、Mutex などの内部可変性により、それぞれのルールに従って内部状態を変更することができます。トレイトのドキュメントには、スレッド、再帰呼び出し、エラー後の状態、副作用の制約を補足する必要があります。&self だけで純粋関数であると推測することはできません。

同じメソッド名:完全修飾表記で曖昧さを解消する

trait HumanLabel { fn label(&self) -> &'static str; }
trait MachineLabel { fn label(&self) -> &'static str; }
struct Device;
impl HumanLabel for Device { fn label(&self) -> &'static str { "device" } }
impl MachineLabel for Device { fn label(&self) -> &'static str { "dev-7" } }
fn main() {
    let value = Device;
    assert_eq!(<Device as HumanLabel>::label(&value), "device");
    assert_eq!(<Device as MachineLabel>::label(&value), "dev-7");
}

メソッド呼び出しは、自動 deref、自動借用、スコープ内の可視なトレイトの候補検索を組み合わせます。「常に固有メソッドを優先する」というのは過度な単純化であり、異なる受信者の候補には優先順位があるためです。曖昧さが生じた場合、盲目的に clone を追加するよりも、型、トレイト、受信者を明示する方が役立ちます。メソッド探索

derive:実装を生成し、制約も導入する

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct GridPoint { x: i32, y: i32 }

fn main() {
    let point = GridPoint { x: 1, y: 2 };
    assert_eq!(point, point);
    let mut points = std::collections::HashSet::new();
    points.insert(point);
    assert!(points.contains(&point));
}

f64 フィールドに置き換えると、Eq や Hash を直接 derive できなくなります。NaN などの浮動小数点のセマンティクスは明示的に処理する必要があり、整数型の派生リストを機械的にコピーすることはできません。derive Clone も、自動的に再帰的な「ディープコピー」を行うわけではありません。Arc フィールドの clone は、共有所有者数を増やすだけです。

derive はマクロメカニズムです。標準ライブラリは一組の組み込み派生を提供し、Serialize などはサードパーティのプロシージャルマクロから提供されます。自動生成されたジェネリック制約は、手動実装よりも保守的になることがあります。必要な場合は、実際の制約を確認し、すべてのジェネリックパラメータにむやみに Clone を追加するのではなく、手動で実装してください。

派生制約は、フィールドが実際に必要とする制約よりも厳格になる可能性がある

例えば、Arc<T> は T が Clone を実装していなくても clone できます。これは、T 自体ではなく共有所有権をコピーしているためです。Arc<T> を含むジェネリックラッパーに直接 derive Clone を付けると、生成された実装で T: Clone が要求される可能性があります。手動実装により、より正確な制約を表現できます。

use std::sync::Arc;
struct Shared<T>(Arc<T>);
impl<T> Clone for Shared<T> {
    fn clone(&self) -> Self { Self(Arc::clone(&self.0)) }
}
struct NotClone { value: u32 }
fn main() {
    let a = Shared(Arc::new(NotClone { value: 7 }));
    let b = a.clone();
    assert!(Arc::ptr_eq(&a.0, &b.0));
    assert_eq!(b.0.value, 7);
    assert_eq!(Arc::strong_count(&a.0), 2);
}

ここでは NotClone に Clone を追加しておらず、基底オブジェクトもコピーされていません。共有するか、独立してコピーするか、コピーを禁止するかは、リソースのセマンティクスに依存し、「derive で生成できるから」という理由でインターフェースを選択してはいけません。Clone 派生

Eq と Hash はメソッド間の共通契約である

a == b の場合、両者は同じハッシュ値を持たなければなりません。逆は成り立ちません。ハッシュ衝突は許容されるためです。user_id のみで等価性を判断し、変更可能な display_name も一緒にハッシュ化すると、等価と判断されたキーが異なる検索パスに属する可能性があり、HashMap/HashSet の使用仮定を壊します。

期待されるセマンティクス実装の方向性
すべてのフィールドが値の等価性に参加条件を満たすフィールドに対して PartialEq/Eq/Hash を一様に derive する
安定した ID のみで等価比較とハッシュに同じアイデンティティフィールドのセットを使用する
大文字小文字を区別しない識別子構築時に正規化する、または一貫した比較/ハッシュルールを使用する
浮動小数点の特殊値を許可NaN、±0 などのセマンティクスをまず選択し、その後ラッパーと実装を選択する

キーを集合に挿入した後、Eq/Hash を決定する内容を内部可変性によって変更してはいけません。標準ライブラリでは、このような契約違反を論理エラーとして扱います。一般的な safe トレイトの契約違反は、unsafe コードによって発生しないものとして仮定され、それに基づいてメモリの安全性を損なうべきではありません。Hash 契約、PartialEq 契約

等しいキーでも検索に失敗する理由

trait の契約は複数の実装にまたがることがあります。大小文字を無視する Eq と区別する Hash では、等しいキーが異なる候補に送られ得ます。意図的に誤った組み合わせも選べるようにし、型検査と動作の契約を区別します。ハッシュ衝突は許され、必要なのは等値から同じハッシュへの含意です。

図を準備しています
等しいキーでも検索に失敗する理由

Eq で等しいキーは同じハッシュが必要です。違うと検索先のバケットが変わり得ます。

孤児則と実装の重複

トレイトの実装は、孤児則と競合の回避の両方を満たす必要があります。大まかに言えば、トレイトがローカルで定義されているか、ルールに適合するローカル型が含まれている必要があります。ジェネリック実装には、オーバーラップとパラメータの順序に関する要件もあります。チェックは、リンク時にランダムに実装を選ぶのではなく、コンパイル時のトレイト整合性ルールの中で行われます。実装の整合性

実装の形態現在の crate での典型的な結果
外部トレイト for ローカル MyType可能だが、重複を回避する必要がある
ローカル MyTrait for 外部 String可能だが、重複を回避する必要がある
外部 Display for 外部 String許可されない
外部 Display for Vec<MyType>許可されない;Vec は内部にローカル型を含んでいてもローカル型にはならない

Box などの fundamental 型はルール内で特別な扱いを受け、「任意のジェネリックコンテナがパラメータにローカル型を含んでいればローカル型になる」と一般化することはできません。外部コンテナに対して独自の表示方法を必要とする場合、新しい型が明確です。

use std::fmt;
struct Numbers(Vec<i32>);
impl fmt::Display for Numbers {
    fn fmt(&self, output: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(output, "{:?}", self.0)
    }
}
fn main() {
    assert_eq!(Numbers(vec![1, 2]).to_string(), "[1, 2]");
}

トレイトがローカルであっても、impl<T> MyTrait for T とより具体的な実装が重複する可能性があります。孤児則は、合法な実装をむやみにオーバーライドできることを意味するものではありません。

制約は、ジェネリック呼び出し側に実装能力を委譲する

T: Summary + Clone は、2つの能力を同時に要求することを意味し、where 句は複雑な関係の記述により適した書き方です。親トレイトは前置条件能力を表しており、継承構造のフィールドではありません。関連型の制約と dyn オブジェクトの制限については、それぞれ 関連型と GAT および 動的ディスパッチ を参照してください。

公開トレイトの設計:実装可能な空間も API として扱う

トレイトを定義することは、呼び出し側がそれに依存することを許可し、かつ下流のユーザーが独自の型に対して実装することを許可する可能性があります。デフォルト実装のない新しいメソッドを追加すると、下流の実装が不完全になります。デフォルトメソッドを追加すると、名前衝突、動作の変化、または dyn 互換性の変化をもたらす可能性があり、「デフォルト実装があればリスクがない」と単純に考えることはできません。

impl<T: Display> MyTrait for T のようなオーバーライド実装は、一大類の型に能力を提供する一方で、これらの型が特定の MyTrait 実装を記述するスペースも占有します。これはランタイムのプラグイン優先度ルールではなく、より具体的な impl がジェネリック impl を自動的にオーバーライドすると期待することはできません。安定した Rust の整合性チェックは、該当する重複を拒否します。

ライブラリ自身のみが実装を許可する場合、意図的に sealed trait 設計を採用できます。外部拡張を希望する場合、必須メソッドを最小限に安定させ、組み合わせ可能な便利メソッドを適切な位置に配置するように努めます。閉鎖と開放にはそれぞれ用途があり、どの能力が約束であり、どの拡張ポイントであるかをドキュメントで説明する必要があります。Cargo SemVer とトレイト変更

struct と trait の組み合わせが最も価値のある点は、データ所有権、不変条件、振る舞いの契約を分離して定義し、制御された impl インターフェースを介して結びつけることです。インターフェースが型チェックを通過しても、一貫した等価性/ハッシュ、エラー後の状態、進化の約束がある場合に限り、呼び出し側はそれを信頼できます。