Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

モジュールシステム

1994年の C++ の設計に関する講演で1、この言語の発明者である Bjarne Stroustrup は次のように述べました。

私は、Simula2 が提供していたような、プログラム編成のための支援を提供するツールが欲しかったのです。 Simula が提供していた、思考の助けと設計の助けです。 一方で、必要なときには BCPL3 や C のように本当に高速に動作するものが欲しかったのです。

「プログラム編成」とは、Stroustrup 博士が C++ のオブジェクト指向クラスのサポートを指していたものです。 編成のサポートは C++ の勝利の方程式の半分であり、もう半分はパフォーマンスでした。 保守性とドメイン抽象化の双方に適した方法でコードを効果的に編成することは非常に重要な問題であり、高性能アプリケーション向けにそれを解決したことで、C++ は数十年にわたって支配的な地位を占めることができました。

  • Rust におけるクラスの代替: 高レベルデータに関するこれまでの議論では、トレイトと構造体がどのように相互作用し、継承ではなく合成によって振る舞いを定義するかを見ました。Rust の構造体と C++ のクラスは、プログラム編成の基本的な第一段階だと考えることができます。

では、なぜモジュールに関するセクションでこの話題を持ち出すのでしょうか4。特に、C++ は C++20 標準までモジュールシステムさえ持っていなかったにもかかわらずです(誕生からおよそ40年後のことです)。 それは、モジュールが、プログラム編成という時代を超えた問題に対する拡張された解決策だからです。

同じ90年代のインタビューで、Stroustrup は非常に実用主義的な洞察を示しています。

言語に関する私の主要な考え方は、言語とは、ある時点における一連の問題に対する誰かの応答だというものです。 つまり、言語は、それ自体が興味深い対象であるというよりも、問題を解決するために存在します。 私たちの問題、そしてそれらの問題に対する理解は、時間とともに自然に変化します。

そして、ある言語が、実際のコードに取り組む実際のプログラマーが直面する問題に対する優れた解決策であり続ける限り、その言語は生き続け、プログラマーのニーズを満たすために成長していくでしょう。

私たちはすでに、Rust の主要な価値が、予測可能なパフォーマンスを犠牲にすることなく、古くからあるメモリ安全性の問題を解決することだと確認しました(安全な並行性は「パフォーマンス」に含めて考えましょう)。 デバッグすべき未定義動作(UB)が少なくなることで、私たちはより野心的な高性能システムを迅速に構築できます。

構築する価値のあるほぼすべてのシステムは、最終的には規模と複雑さを増していきます。 顧客は新機能を要求し、開発チームは需要に応えるために新しいエンジニアを受け入れ、コードベースは拡大し始めます。 コード編成は「実際のコードに取り組む実際のプログラマー」にとって非常に根本的な問題であるため、それに対処する手段がなければ Rust は実用的ではなかったでしょう。

大規模なプロジェクトを整理され、まとまりのある状態に保つために、Rust はどのようなツールを提供してくれるのでしょうか。 大まかには、構成要素を次のように分解できます。


複雑なシステムを形成するために組み合わされる、基本的な構成要素。

  • アイテムは、ソースコード内のエクスポート可能な要素です。構造体、関数、定数などが含まれます。Rust におけるクラス風の抽象化である構造体は、間違いなく私たちの最も基本的な編成ツールです。プログラム編成階層の頂点です。

    • アイテムと見なされる言語構成要素の完全な一覧が利用できます5。技術的には、モジュールもアイテムです。しかし、現在のコード編成に関する議論の目的では、それらを分類上は別個のものとして扱います。
  • モジュールは、関連するアイテムをまとまりのある単位にグループ化します。名前空間とよく似た形で、プロジェクト内のコードを整理するのに役立ちます。

    • 一部のプログラマーは、「1つのソースファイルにつき1つのモジュール」という慣習に従うことを好みます。しかし、その1対1の対応付けは完全に任意です。モジュールは論理的で階層的なグループ化です。ファイルシステムのレイアウトによって決まるものではありません。
  • クレートは、関連する1つ以上のモジュールをライブラリまたはバイナリとしてグループ化します。プロジェクト間でコードを整理するのに役立ちます。ライブラリでは、可視性修飾子によって、モジュールがどのアイテムをエクスポートするかが決まります(例: クレートの公開 API)。

    • クレートは依存関係を持つこともでき、それら自体もクレートです(例: 内部で使用されるサードパーティライブラリ)。第2章の rcli ツールは、rc4clap という2つのライブラリクレート依存関係を持つバイナリクレートでした。
  • システムは、相互接続されたコンポーネントから成る大規模なソフトウェアを指す一般的な用語です。これは、複数の Rust クレート、CFFI6 を介して相互運用する他のプログラミング言語で書かれたライブラリ、あるいは REST7 や gRPC8 のような構造化フォーマットを使用して通信するネットワーク化されたサブサービスでさえあり得ます。

モジュールで複雑性に対抗する

モジュールはこのセクションの焦点です。 コードを書くときには大きな「設計の助け」となり、読むときには「思考の助け」となります。 モジュールは、コードの機能的なまとまりを区分し、それらのインターフェースを定義します。 コード編成という時代を超えた必要性に対処する助けとなり、最終的には、複雑性を抑制する助けとなります。

私たちが構築するシステムの規模と機能が増すにつれて、それらは何らかの形の入り組みを少しずつ蓄積する傾向があります。 いったん複合化すると、複雑性はシステムの理解と変更を困難にします。 不要な複雑性は、障害やセキュリティ侵害の可能性を高めます。

蓄積された複雑性は、「技術的負債」と呼ばれることがあります。 金融上の負債と同じように、そこから抜け出すのは困難です。 したがって、単純さと保守性を考慮して設計することは、常に優先事項であるべきです。

さて、本書で構築するデータ構造ライブラリは、合計しても5,000行未満のコードになります。 大局的に見れば、これはごく小さなコードベースです。 しかし、私たちは最初から Rust のモジュールシステムを活用します。規模に依存しない利点が得られるからです。 それでは、モジュールがどのように機能するのか感触をつかみましょう。

ソースコードの編成

ファイルシステムのレイアウトからモジュールを推論する言語もあります。 Rust ではそうではありません。

Rust のモジュールは、個々のソースコードファイル(名前が .rs で終わるファイル)と緩やかな関係を持ちます。 モジュールは論理的なグループ化であるため、3つのモジュールとファイルの対応付け、すなわち多対一、多対多、一対一のいずれかを選択できます。 これらは排他的ではありません。単一のプロジェクトで、必要に応じて戦略を組み合わせることができます。

どの対応付けを選んだとしても、モジュールは常に木のような階層を形成します。 モジュールツリーにはルートが必要であり、通常は次のいずれかです。

  • バイナリクレート(rcli など)の場合は main.rs
  • ライブラリクレート(rc4 など)の場合は lib.rs 他のターゲット(テスト、ベンチマーク、例など)をビルドするクレートには、他のターゲット固有のルートがあります。

バイナリの場合、どの項目がどのモジュールで可視になるかは階層によって決まります。 これはライブラリの場合にも当てはまります。 さらに、ライブラリクレートは特定の項目やモジュールをエクスポートすることを選択できます。これにより公開 API が作成されます。

同じ単一の階層を維持する、3 つのモジュールとファイルの対応関係を見ていきましょう。

1. 複数のモジュール -> 1 つのソースファイル(m:1

1 つのファイルにはネストされたモジュールを含めることができます。インラインモジュール定義の構文は次のとおりです。

mod my_module {
    // ここにモジュールの内容。ネストされた「サブモジュール」を含む可能性があります
}

この章で継続している OS の例を続けましょう。ただし、実際に起動可能なカーネルを作成するために必要な詳細については扱いません(このトピックは Philipp Oppermann の優れたチュートリアルシリーズ9で詳しく扱われています)。

新しいバイナリクレートを作成し、main.rs に次の内容を追加するとします。

mod kern {
    pub mod sched {
        // ここにスケジューリングのコード。`Proc` 構造体も含みます...

        /// プロセスの優先度を設定する
        pub fn set_priority(pid: usize, priority: usize) -> bool {
            // ここに実装...
        }
    }

    pub mod dma {
        // ここに Direct Memory Access(DMA)に関連するコード...
    }

    pub mod syscall {
        // ここにシステムコールに関連するコード...
    }
}

// サブモジュール経由のテストを示すためのダミー関数
fn private_helper() -> bool {
    true
}

#[cfg(test)]
mod tests {
    // 「ピア」モジュールから公開関数をインポート
    use super::kern::sched::set_priority;

    // 「親」モジュールからプライベート関数をインポート
    use super::private_helper;

    #[test]
    fn test_private_helper() {
        assert!(private_helper());
    }

    #[test]
    fn test_set_priority() {
        // ここにユニットテスト...
    }

    // ここにさらに個別のテスト...
}

どのような設計判断でも、“できる” と “すべき” は別物です。 OS や大規模なプロジェクトを、このように単一ファイル内で構成したいとはおそらく思わないでしょう。

しかし、多対一の対応関係は、モジュールが柔軟な概念であることを示しています。 この柔軟性のうち、実際に活用する可能性が高い側面の 1 つが、上記の末尾にあるような tests モジュールです。 これにより、ユニットテストを、テスト対象のコードの近く(同じファイル内)に置いておくことができます。

これは、プライベート関数をテストする場合に特に便利です。private_helper 関数が pub とマークされていないにもかかわらず、tests モジュールがその関数を使用できる点に注目してください。 その理由を理解するには、この単一ファイルが作成するモジュール階層を理解する必要があります。

暗黙的に、ファイル main.rs はそれ自体がモジュールです。 実際、これは階層のルートです。 つまり、mod tests は階層内のサブモジュールであり、トップレベルの main.rs モジュールの「子」を意味します。 これは、同じファイル内で宣言された「ピア」モジュール(kern)と同じレベルに位置します。

上記のスニペットのモジュール階層。これは次の 2 つの小節でも変わりません。

Rust では、サブモジュールは親のプライベート項目と公開項目の両方にアクセスできます。

  • 例: tests は、use super::private_helper; によって、親からプライベート関数 private_helper をインポートできます。

プライベート項目は、ピア(kern のような階層内の同じレベル)や子(この例では tests には子サブモジュールがありません)からはアクセスできません。

  • 例: tests は、kern からエクスポートされた公開項目にのみアクセスできます。use super::kern::sched::set_priority; によって、公開関数 set_priority をインポートします。

2. 複数のモジュール -> 複数のソースファイル(m:n

mod kern の内容を、次のように kern.rs という名前のファイルへ移動できます。

pub mod sched {
    // ここにスケジューリングのコード。`Proc` 構造体も含みます...

    /// プロセスの優先度を設定する
    pub fn set_priority(pid: usize, priority: usize) -> bool {
        // ここに実装...
    }
}

pub mod dma {
    // ここに Direct Memory Access(DMA)に関連するコード...
}

pub mod syscall {
    // ここにシステムコールに関連するコード...
}

囲むための pub mod kern { ... } が不要になっていることに注意してください。これはファイル名によって暗黙的に示されます。 変更後のディレクトリの内容は次のようになります。

.
├── Cargo.toml
└── src
    ├── kern.rs
    └── main.rs

1 directory, 3 files

見てきたように、kern.rs はサブモジュールに対して、変更されていないインライン定義(例: pub mod sched { ... } など)を使用します。 したがって、上図の階層は維持されます。

main.rsset_priority 関数をインポートするには、次を使用する必要があります。

mod kern;
use kern::sched::set_priority;
  • mod kern; は、kern モジュールの内容が別ファイル、つまり kern.rs または kern/mod.rs に存在することを示します。

    • これらの「前方宣言」は通常、モジュールルートに配置されます。それが階層全体のルート(ここでは main.rs が該当)であっても、単にサブモジュールを含むモジュールであっても同様です。
  • use kern::sched::set_priority; は、前のレイアウトで tests サブモジュールが行ったのと同じように、公開された sched サブモジュールから特定の関数をインポートします。

3. 1 つのモジュール -> 1 つのソースファイル(1:1

より現実的なプロジェクトレイアウトでは、各モジュールを個別のファイルに配置することを選ぶかもしれません。 それでも、同じ階層は維持されます。

scheddmasyscall のインラインモジュール定義の代わりに、各サブモジュールを次のように専用ファイルに配置できます。

.
├── kern
│   ├── dma.rs
│   ├── sched.rs
│   └── syscall.rs
├── kern.rs
└── main.rs

1 directory, 5 files

別ファイルからモジュールをインポートするとき、Rust は module_name.rs または module_name/mod.rs のどちらかを探します。 したがって、次のレイアウトは上記と等価であり、単に好みの問題です。

.
├── kern
│   ├── dma.rs
│   ├── mod.rs
│   ├── sched.rs
│   └── syscall.rs
└── main.rs

1 directory, 5 files

どちらの場合でも、3 つのサブモジュールについては、囲むためのインラインの pub mod mod_name { ... } を再び削除します。これはファイル名によって暗黙的に示されるためです。たとえば、sched.rs には現在、次の内容が含まれます。

// ここにスケジューリングのコード。`Proc` 構造体も含みます...

/// プロセスの優先度を設定する
pub fn set_priority(pid: usize, priority: usize) -> bool {
    // ここに実装...
}

kern.rs または kern/mod.rs(どちらのレイアウトを選ぶかによります)は、階層全体のルートである main.rs の下に位置するモジュールルートです(上図のとおり)。 この 1:1 レイアウトでは、このファイルが、その子サブモジュールを親である main.rs にどのように公開するかを制御できます。

簡単のため、最初の選択肢である kern.rs を採用したと仮定しましょう。 このファイルは、次のようにして sched サブモジュール全体を公開する(例: 再エクスポートする)ことを選択できます。

pub mod sched;

すると、main.rs は以前と同じように(前の m:n の場合と同様に)set_priority 関数をインポートします。

mod kern;
use kern::sched::set_priority;

しかし、モジュールのユーザーから kern の内部の詳細を抽象化する選択肢もあります。

main.rs は引き続き set_priority 関数を使用できるべきかもしれませんが、その関数が内部的にはより大きな sched モジュールの一部であることを意識すべきではありません。 もし kern.rs がモジュール全体ではなく、単一の関数だけを再エクスポートするなら:

mod sched;
pub use sched::set_priority;

すると、main.rs は次のようにして set_priority を使用できるようになります。

mod kern;
use kern::set_priority;

これは小さな違いに見えるかもしれません。つまり、同じ関数を kern::sched::set_priority 経由でインポートしていたのが、kern::set_priority になっただけです。 関数とモジュール階層は変わっておらず、項目の パス を短くしただけです。

しかし、可視性の制御 は、コードベースの複雑さを管理するための重要な手段です。 これにより、大規模なシステムを内部ではある方法で自由に整理しつつ、エンドユーザーにはそのシステムの一部だけを公開できます。しかも、内部構成の詳細を漏らさない形で公開できます。

公開 API(モジュール、関数、データ型、定数などの項目を含む)は、多くの場合、安定性を保証することに注意してください。 公開 API を通じて内部の詳細を公開しているシステムは、「破壊的変更」(下流のコードがコンパイルできなくなる変更)なしにリファクタリングすることが難しくなります。

保守負担が増えることに加えて、大きく詳細な API サーフェスは、複雑さと認知負荷(API 開発者と API ユーザーの双方にとって)を増加させます。

したがって、モジュール設計の主要な目標は、内部インターフェイスの可視性を制御することによって抽象化を提供することです。 モジュールルートでの再エクスポートを制限するだけでなく、Rust がどのような選択肢を提供しているのかを詳しく見ていきましょう。

可視性の制御

Rust の可視性修飾子は、内部および外部の API サーフェスを適切に保つのに役立ちます。 目標は、項目を次の 2 つのカテゴリのいずれかに分類することです。

  • Private - 同じモジュールまたはそのサブモジュール内でのみアクセスできるもの。
  • Public - モジュールによってエクスポートされるもの。

複雑さを抑えることに加えて、可視性を制限すると 不変条件 を維持できます。 たとえば、ある構造体がプライベートフィールドに対するゲッター関数とセッター関数を提供しているとします。 フィールドを public にするのではありません。 セッター関数が不正なパラメータを処理する(おそらくエラーを返す)なら、その構造体が不正な状態(値が不正または範囲外のフィールドを持つなど)にならないことを保証できます。 オブジェクト指向言語では、同様の実践は カプセル化 という概念に含まれます。

Rust では、デフォルトで項目は private です(現在のモジュール内でのみ可視です)。 可視性を高めるには、明示的にオプトインする必要があります。 任意の項目(モジュール、関数、構造体、構造体フィールドなど5)に適用できる public 可視性の修飾子10は 5 つあります。 制限が少ないものから多いものの順に列挙すると、次のようになります。

修飾子項目はどこで可視か?
pub現在のモジュールの外側または内側を問わず、どこでも。
pub(crate)現在のクレート内のどこでも。
pub(super)親モジュールとサブモジュール内のみ。
pub(in some::path::here)指定されたパス上のサブモジュール内のみ。
pub(self)現在のモジュール内のみ(pub をまったく使わない場合と同じ)。

このセクションでは例を示す代わりに、コアプロジェクトを実装する中で、上記の修飾子のいくつかを使用します。

ある項目が 可視 であるからといって、その項目が 利用可能 であるとは限らないことに注意してください。 項目は、それを含むモジュールによってエクスポートされ、その後エンドユーザーによってインポートされる必要があります。 そのエクスポートと対応するインポートがなければ、項目は スコープ内 にはなりません。

まとめ

コードの構成は、重要で時代を問わない問題です。 Rust のモジュールシステムは、大規模プロジェクトのための粒度が細かく構成可能な解決策を提供します。 モジュールシステムを効果的に使うことは、複雑さを抑える鍵です。

Rust のモジュールは、論理的で階層的なグループ化です。 ファイルシステムから直接推論されるわけではありませんが、モジュールをソースファイルに対応付ける方法はいくつかあります。

内部的には、可視性修飾子が、どの項目がどのモジュールで可視になるかを制御します。 外部的には、モジュールは公開利用のために特定の項目を再エクスポートすることを選択できます。 どちらの場合も、可視性は API サーフェスを制御し、不変条件の維持を支援します。

モジュールがどのようにプロジェクトを整理された状態に保つのかを少し見てきました。 次は、同じプロジェクトを長期にわたって健全に保つためのツールに進みましょう。

Rust モジュールを超えて、ソフトウェアシステムを整理するにはどうすればよいでしょうか?

付録の 基礎: コンポーネントベース設計 セクションは、「プログラム構成」に関する議論を拡張した、一般的に適用できる続編です。

このセクションでは Rust のモジュールシステムに焦点を当てましたが、補足の付録セクションでは普遍的な原則を探ります。


  1. C++ の設計。Bjarne Stroustrup(1994)。

  2. シミュレーションの記述向けに設計された 1962 年のオブジェクト指向言語。クラスという考え方を導入した。

  3. もともとはコンパイラ開発を目的とした、1967 年の C の前身。高性能だが型を持たない。

  4. システムをモジュールに分解する際に使用すべき基準について。David L. Parnas(1972)。

  5. 項目。The Rust Reference(2022 年アクセス)。 ↩2

  6. 外部関数インターフェイス。Wikipedia(2022 年アクセス)。

  7. REST API とは何か?。RedHat(2020)。

  8. コア概念、アーキテクチャ、ライフサイクル。Google(2022 年アクセス)。

  9. Rust で OS を書く。Philipp Oppermann(2022 年アクセス)。

  10. 可視性とプライバシー。The Rust Reference(2022 年アクセス)。