[Rust] モジュールのベストプラクティス

44165 ワード

Rust のモジュールシステムは私の知る中でもトップクラスによくできた仕組みだと思います。特にリファクタリングによってモジュールを再構成するときのやりやすさは他の言語では経験できないものです。例えばそれなりの規模の Python プロジェクトを回帰バグを導入せずにモジュール構造のリファクタリングするのは不可能に近いですが、 Rust ではそのような不安を覚えたためしがありません。

Rust のモジュールシステムがどういうものかは、 The book にも書かれていますし、すでに大量のガイドが書かれていると思います。しかし、どのように使うべきかについては意外なほど情報が少なく感じます。

ベストプラクティスというのもおこがましいですが、数年使ってきて Rust のモジュールシステムを使う上でスムーズに感じる方法をまとめておきたいと思います。

Rust のモジュールシステム

本稿の主題はモジュールシステムが何なのかではなく、どのように使うべきかということですが、少しおさらいしておこうと思います。

Rust の翻訳単位 (compilation unit) は crate です。翻訳単位とは、 C や C++ でいうソースファイルのことで、ソース(テキスト)から中間ファイルへ翻訳する最小単位です。 Rust はこの翻訳単位を crate という大きなくくりにしており、その中をモジュールで構造化できるようにしてあります[1]

実行可能形式の出力なら src/main.rs、ライブラリなら src/lib.rs が crate の入り口になります。

そして特に Rust に特徴的だといえるのが、モジュールとファイル構造が一致しているとは限らないというところです。これは C++ の名前空間に少し似ていますが、そこまで自由に構成できるわけではなく、ファイルに分けるとしたらモジュール構造に合わせる必要があります。

例えば、一つのソースファイルに以下のように sub サブモジュールを含めることができます。このとき、サブモジュール内のアイテムは pub, pub(super), pub(crate) のいずれかの公開宣言がされている必要があります。

main.rs
fn main() {
  sub::greet();
}

mod sub {
  pub fn greet() {
    println!("Hello");
  }
}

同じことは sub.rs というファイルにサブモジュールの内容を移しても実現できます。このとき、元となるソースファイルに mod sub; という宣言を含めておく必要があります。これによってコンパイラは sub.rs というファイルのモジュール sub の内容があることを認識します。

main.rs
mod sub;

fn main() {
  sub::greet();
}
sub.rs
pub fn greet() {
  println!("Hello");
}

さらに孫モジュールを定義することもできます。

sub.rs
pub fn greet() {
  println!(subsub::GREET_TEXT);
}

mod subsub {
  pub const GREET_TEXT: &str = "Hello";
}

さて、孫モジュールをファイルに分けるときの方法が少し特殊です。

方法は2つあります。

  1. 子モジュール名と同じ名前のフォルダを作り、その中に mod.rs というファイルを置く。 2015 edition で使用可能。
  2. 子モジュール名と同じ名前のフォルダを作り、それとは別に <子モジュール名>.rsというファイルを置く。 2018 edition で使用可能。

基本的には、 2018 edition が使えるのであれば、2番目の方法が推奨されます。これはエディタが mod.rs という名前のバッファだらけになるのを防ぐためですが、別に1番目の方法でも問題なく動きます。

ここでは2番目の方法の例を示します。ちょっとややこしくなってきたのでフォルダ階層も示します。

main.rs
sub.rs
sub/
    subsub.rs
main.rs
mod sub;

fn main() {
  sub::greet();
}
sub.rs
mod subsub;

pub fn greet() {
  println!(subsub::GREET_TEXT);
}
sub/subsub.rs
pub const GREET_TEXT: &str = "Hello";

公開設定

Rust のアクセス権限については、次の2つが基本となるルールです。

  • 親モジュールのアイテムは子モジュールからアクセスできる
  • 子モジュールのアイテムは pub 宣言があるときだけ親モジュールや外部クレートからアクセスできる

pub 宣言にもいろいろなアクセスレベルがあります。

  • なし: 現在のモジュールのプライベートアイテム
  • pub: 外部クレートへ公開
  • pub(super): 親モジュールへ公開
  • pub(super::super): 親の親モジュールへ公開
  • pub(crate): 現在のクレート内の全てへ公開

基本的には、公開レベルは必要最小限にとどめておくのが良いです。特に、ロジックの一部を括りだして関数にしたような時は、他のコードからの再利用を考えてない場合が多いですのので、プライベートにするか、サブモジュールを作ってそこに入れて pub(super) にするような方法がいいでしょう。

必要以上に公開レベルを上げないことで、そのコードが再利用性を考えていないことコンパイラと他のプログラマに伝えることができます。また、逆に公開レベルを上げることで、再利用性のある、テストされたコードを際立たせることができます。

pub mod

モジュールも一種のアイテムなので、公開設定が宣言できます。よく見るのは pub mod some_module といったもので、これはサブモジュール some_module を宣言すると同時に、それを公開設定しています。

前節と同じように、 pub(super) modpub(crate) mod といった宣言もできます。

テストでの用法

ファイル内でインラインでモジュールを定義する使い方としては、これが最も多いのではないでしょうか。公開されているリポジトリでもよく見かける書き方です。
ソースファイルの中に mod test を定義し、その中にテストコードとそれに依存する関数なり定数なりを置きます。
単体テストを関数のすぐ近くに記述できるため、たくさんの単体テストを書いても迷子になりにくいです。

fn byte_length(s: &str) -> usize {
  str.as_bytes().len()
}

#[cfg(test)]
mod test {
    use super::*;

    const TEST: &str = "Hello";

    #[test]
    fn test_hello() {
        assert_eq!(bytes_length(TEST), 5);
    }
}

ポイントは #[cfg(test)] によって条件付きコンパイルにしていることです。これによってテスト時以外には使われていないアイテムがあるなどの警告を避けることができます。また、間違って本体のロジックにテスト用のアイテムを使ってしまうことや、コンパイル時間を無駄に増やすこともないです。

また、 mod test 内の use super::* にも注目です。これは親モジュールのアイテムをすべて test スコープ内に導入するということですが、これによって当モジュールのテストしたい対象を直接 bytes_length などの名前でアクセスできるようになります。(ちなみに、この use 宣言をしなくても super::bytes_length などの相対パスでアイテムにアクセスすることもできます。)

テスト用コードと本体のロジックが混じらないように、しかし遠ざかりすぎないように書くことができます。

さて、テスト用コートが長くなってくると、本体のロジックから切り離したくなってくることもあると思います。その場合も簡単です。

main.rs
fn byte_length(s: &str) -> usize {
    s.as_bytes().len()
}

#[cfg(test)]
mod test;
test.rs
use super::*;

const TEST: &str = "Hello";
const TEST2: &str = "Hello World!";

#[test]
fn test_hello1() {
    assert_eq!(byte_length(TEST), 5);
}

#[test]
fn test_hello2() {
    assert_eq!(byte_length(TEST2), 12);
}

基本的に mod test ブロックの中を test.rs というファイルに括りだしただけです。

メソッドの細分化

多くのオブジェクト指向言語では、一つの中心的なクラス定義がぶくぶくと太り"何でも屋"になる一方、他のクラスは細分化していくという傾向があります。この理由については、経験的には「データのモジュール境界とロジックのモジュール境界は必ずしも一致していない」ということに起因しているように思います[2]

例えば、次のような struct State のケースを想像してください。 Statecompute という何らかの非常に込み入ったメソッドを持っているとします。リファクタリングのためにロジックを precompute_input1, precompute_input2, combine_results の3つのサブモジュールに分けたいとします。さらに、 precompute_input1 は何らかのサブロジックを calculate メソッドとして定義したいとします。このような場合、次のように書けます。

lib.rs
mod precompute1;
mod precompute2;
mod combine;

pub struct State {
    // ...
}

impl State {
    pub fn compute(&mut self, input1: f64, input2: f64) -> f64 {
        let precomputed = self.precompute_input1(input1);

        let precomputed2 = self.precompute_input2(input2);

        self.combine_results(precomputed, precomputed2)
    }
}
precompute1.rs
mod sublogic;

use super::State;

impl State {
    pub(super) fn precompute_input1(&mut self, input1: f64) -> f64 {
        let calculated = self.calculate(input1);
        // ...
    }
}
precompute1/sublogic.rs
use super::super::State;

impl State {
    pub(super) fn calculate(&mut self, input1: f64) -> f64 {
        // ...
    }
}
precompute2.rs
use super::State;

impl State {
    pub(super) fn precompute_input2(&mut self, input2: f64) -> f64 {
        // ...
    }
}
combine.rs
use super::State;

impl State {
    pub(super) fn combine_results(&mut self, input1: f64, input2: f64) -> f64 {
        // ...
    }
}

すべて State のメソッドではありますが、ロジックの公開境界は必要最小限にとどめられていることに注目してください。特に、サブモジュール内で定義されているメソッドの pub(super) がポイントです。これらのメソッドはクレート外部からも、同じクレート内の親クレートからも参照できません。これは公開メソッドの数を最小限に抑え、モジュール間のインターフェースを必要以上に増やさないために重要です。

具体的には、もしこれらのサブモジュール内のメソッドの名前や構成を変えたら、もしかしたらそれに依存しているかもしれない外部コードや同じ crate 内の他のモジュールを壊す心配がないということです。

例えば、 C++ や Java ではこの公開レベルは public, protected private の3つしかなく、それがクラス単位で適用されるので、上に述べたような孫モジュールに分けるようなことはできません。また、 C++ のテンプレートを使った header only library などでは、メソッドが可視である必要があったりするので、 detail という名前空間に実装の詳細 (implementation details) を入れるなどということが行われますが、これはユーザーが誤って使ってしまうことを完全に防ぐものではありません。 Rust のモジュール公開性はこのような使い方も言語レベルでサポートしています。

これを可能にしているのは、 Rust での impl ブロックは複数に分けることができることです。 C++ や Java では、クラス定義は一つのブロックで完結している必要があるので、 impl ブロックが分かれていることに違和感を覚える人もいますが、その理由はこういったモジュール化を可能にするためです (他にもトレイト実装のためという理由もあります)。

定数テーブル

あまりよくやることではないと思いますが、モジュールは定数のグループをまとめるのにも使えます。 FFI で外部の定数が整数型で定義されているような場合、 enum 型にできないので使えることがあるかもしれません。

mod state {
     pub const SUCCESS: usize = 0;
     pub const FAILURE: usize = 1;
     pub const CANCEL: usize = 2;
}

enum State {
    Success,
    Failure,
    Cancel,
}

impl From<usize> for State {
    fn from(v: usize) -> Self {
        match v {
	    state::SUCCESS => Self::Success,
	    state::FAILURE => Self::Failure,
	    state::CANCEL => Self::Cancel,
	    _ => panic!(),
	}
    }
}

use 宣言のベストプラクティス

use 宣言はプロジェクトが成長してくると複雑になりがちです。ここではどのように組織化できるかを考えてみます。

絶対パス、相対パス、 crate root からのパス

モジュール間のアイテムの参照には次のようにいくつかの方法があります。名前の衝突を避けるために、 use some::thing; のような書き方よりも以下のように絶対パス、相対パス、 crate root からのパスを明確化した方が安全です。

use ::some_crate::some_item;
use self::submodule::some_item;
use super::sibling_module::some_item;
use crate::root_module::some_item;

まず ::some_crate::some_itemCargo.tomldependencies に挙げたクレート some_crate のメンバを参照します。
次に、 self::submodule::some_item は、現在のモジュールから見てサブモジュールのアイテムを参照します。
さらに、 super::sibling_module::some_item は兄弟モジュール(同じ親を持つモジュール)の参照です。
最後に、 crate::root_module::some_item はクレートのトップレベルからのモジュールのパスを参照します。

おまけで、 super::super::some_item で親の親にもアクセスできます。

適切なパスの指定方法を使うことで、モジュールをネストしたりするようなリファクタリングの際に修正する量を減らすことができます。

グループ化

これは好みの範囲かもしれませんが、use 宣言で共通するパスをまとめて書く方法と、アイテムごとに別々に書く方法があります。

use std::{
    sync::{Arc, Mutex},
    collections::{HashMap, HashSet},
};
use crate::{prelude::*, utils::calculate};
use std::sync::Arc;
use std::sync::Mutex;
use std::collections::HashMap;
use std::collections::HashSet;
use crate::prelude::*;
use crate::utils::calculate;

どちらが読みやすいかは人によるかもしれませんが、私は前者の書き方を心がけています。多くのパスを use した時に行ごとにばらばらに書くと順番がごっちゃになりがちだからです[3]。しかし後者の方法でも rust fmt を使っていれば文字列順に並び変えてくれるので迷うことはないでしょう。いずれにしてもクレートの中ではどちらかに統一しておくことをお勧めします。

Re-export

ライブラリクレートのルートでよく見るのが、以下のような宣言です。

pub use de::{Deserialize, Deserializer};

これは、サブモジュール de のメンバ Deserialize, Deserializer を「再公開」するものです。現在のモジュールのスコープにサブモジュールのアイテムを持ち込むだけではなく、外部のモジュールやクレートに対しても、あたかも現在のモジュールのメンバであるかのように公開するものです。

これはライブラリの作成時に非常に役に立ちます。クレートの構造をモジュール化しつつ、外部へのインターフェースの互換性を保ってバージョン更新できるのです。

さらに、元々のアイテムで宣言されていた公開設定以上に広い範囲には公開できないというのも覚えておく価値があります。例えば、元のアイテムが pub(crate) で宣言されていた場合、 pub use で外部クレートに公開することはできません。 Re-export はあくまでもスコープの移動であって、公開設定の再定義ではないということです。これによって、元のコードが想定していない公開範囲に誤って公開してしまうことを防げます。

この仕組みを使うと、クレートのルートモジュール (main.rslib.rs) は非常にコンパクトになり、 modpub use を並べただけのファイルにもできます。多くのモジュールと公開インターフェースを持つライブラリクレートでは、コードの組織化と見通しをよくすることができるでしょう。

トレイトの use

ほとんどの場合、 use 宣言はアイテムをスコープに導入する「ショートカット」としての役割を果たします。つまり実際に使う場所で毎回フルパスを入力すれば原則的に必要ないものです。しかし一つだけ例外があります。トレイトメソッドです。

例えば、次のようなコードはエラーになります。

struct Cat;

fn main() {
    let cat = Cat;
    println!("Cat cries: {}", cat.cry());
}

mod cryable {    
    pub trait Cryable {
        fn cry(&self) -> String;
    }
}

impl cryable::Cryable for Cat {
    fn cry(&self) -> String {
        "Meow".to_string()
    }
}

エラーの内容は

error[E0599]: no method named `cry` found for struct `Cat` in the current scope

というものです。

つまり、 Cryable トレイトを通して定義したはずの cry メソッドにアクセスできていません。これは前述の メソッドの細分化 で示した例とは対照的です。

さらに、コンパイラはこんなヒントも出します。

   = help: items from traits can only be used if the trait is in scope
help: the following trait is implemented but not in scope; perhaps add a `use` for it:
   |
1  | use cryable::Cryable;
   |

このヒント通り use cryable::Cryable; をルートモジュールに追加するとコンパイルできるようになります。つまり、トレイトのメソッドは、そのトレイトをスコープに導入しないと使えません。

このような設計になっている理由は、コヒーレンスという概念と関わっていて、少々ややこしいので詳細は割愛します。

トレイトメソッドを使いたいけど、トレイト名で現在のモジュールの名前空間を汚したくないという場合、次のような書き方ができます。

use cryable::Cryable as _;

これは、 use したアイテム名を _ にバインドすることで、実際に識別子の名前にバインドすることはないけれど、スコープ内に入れるという書き方です。

脚注
  1. このような設計になっている理由は、近年のコンパイラの最適化技術が進歩してきたため、翻訳単位を大きく取った方が、コンパイラの見通せる範囲が増えて、最適化の機会を増すことができるということです。 C や C++ が開発された当初は、最適化が強力ではなく、CPUの構造も比較的単純だったため、コンパイル時のメモリ使用量を抑えられるように小さな翻訳単位に分けることが想定されていました。リンク時最適化(LTO)という技術もありますが、コンパイル時に見通せる情報を活用した方が高い最適化が期待できます。その代わり、コンパイルにかかる時間とメモリ使用量は増大する傾向があります。大規模な Rust プロジェクトのコンパイル時間を短縮する手法の一つが、 workspace 内で crate を分けるという方法ですが、それはまた別の話。 ↩︎

  2. C++で定義された、 Bjone Stroustrup 氏的な意味でのオブジェクト指向は、データとロジックは関連するものとして一つのオブジェクトにカプセル化しますが、必ずしもそのようなモデルに沿う問題だけがプログラムに出現するわけではないという気がします。 ↩︎

  3. というよりも、気まぐれに use を書いていると順番が変わるたびに無駄な Git の差分が生じるのがうっとうしいのですが、それも cargo fmt で解決できます。 ↩︎