Rustのモジュールを詳細に理解する(1) モジュール入門 (この回は簡単です!)

概要: Rust2018以降、Rustのモジュールシステムは入門しやすく、かつマスターしにくくなりました。そこで本記事では、その両方を達成することを目指しました。

  1. モジュール入門
  2. 名前解決の役割と用語
  3. モジュールグラフの構造
  4. インポート解決
  5. 可視性
  6. パス解決
  7. 名前解決とマクロ

本記事は2017年に書いた記事 (https://qnighy.hatenablog.com/entry/2017/03/27/070000, https://qnighy.hatenablog.com/entry/2017/04/24/070000 ) と同じテーマで、Rust2018を考慮して改めてまとめ直したものです。


モジュールとは

モジュールはRustのプログラムを分割する単位のひとつです。ワークスペース・クレート・モジュールという単位になっていると考えてください。

  • ワークスペースは関連するライブラリ(クレート)をひとつにまとめたもので、通常1つのgitリポジトリに対応します。たとえばAWSクライアントライブラリであるrusoto/rusotoには1つのワークスペースがあり、その中にはAWSクライアントのコアライブラリと各サービスに対応するライブラリが存在しています。開発時に依存関係のビルドを集約するためのものなので、 crates.io にアップロードした時点でワークスペースというまとまりに意味はなくなります。
  • クレート (crate)は依存解決の単位です。クレート同士は一方通行の依存関係しか持てません(dev-dependenciesなどの例外あり)
    • 依存の方向がはっきりしている場合(BはAに依存するが、AはBに依存せずに単独でも使える)はクレートを分割するのがよいでしょう。
    • 並列コンパイラは実装途上なので、大きなプロジェクトで並列性を高める場合は依存関係を考慮してクレートを分割するのがセオリーです。たとえばRustコンパイラでは共通インターフェースを rustc 内で定義し、各フェーズのアルゴリズムrustc_resolve, rustc_typeck, rustc_mir などで実装してから rustc_driver で集約するという構造になっています。
  • モジュールはクレート内で機能を小さい単位にわけて整理したり、実装を隠蔽したりするために使います。
    • 1ファイルが1モジュールになるように書くのが一般的です。
    • 基本的にクレート全体がコンパイルされるので、スリム化には使えません。

実際にプログラムを書き始めるときは、1モジュールから始めて、必要に応じてモジュール分割→クレート分割→リポジトリ(ワークスペース)分割という形が一般的かと思います。

Rustのモジュールは(他の多くの言語と同様)ファイルシステムのように木構造になっており、モジュールを指定するときの記法 (foo::bar::baz) はそのものずばりパスと呼びます。

Rust2018で一様パスが導入された

Rust2018にあわせてRFC2126で定められたモジュールシステムの改革が実装され、2015時点でのモジュールシステムの問題点が改善されました。互換性のために古い書き方も残されているので全体の仕組みは複雑になりましたが、イディオマティックなRustコードでのモジュールの動作はかなりわかりやすくなったのではないかと思います。

Rust2018時代のイディオマティックなRustコードでは、

  • extern crate を使いません。
    • 代わりに、extern prelude (1.30.0で安定化) を使います。
  • #[macro_use], #[macro_export] も使いません。
    • 代わりに、マクロの名前解決 (RFC1561; 1.30.0で安定化) を使います。
  • :: で始まるパスは基本的に使いません。
    • :: で始まるパスの意味自体がRust2018で大きく変化しました。
    • 代わりに、 crate:: (1.30.0で安定化) を使います。
  • $crate:: で始まるパスは使いません。
    • 代わりに、 crate:: (1.30.0で安定化) を使います。

特に、これらを守ることで一様パス (1.32.0で安定化) の恩恵を受けることができます。すなわち、 usepub(in) でのパス指定と、それ以外の箇所でのパス指定が一貫するということです。 (playground)

pub enum RealWeekday {
    Sun,
    Fri,
    Sat,
}

pub mod m1 {
    pub enum Weekday {
        Sun,
        Sat,
    }

    // デフォルトパス (use内)
    pub use Weekday::Sun;
    // デフォルトパス (use以外)
    pub const X: Weekday = Weekday::Sun;

    // extern preludeパス (use内)
    pub use chrono::naive::MIN_DATE;
    // extern preludeパス (use以外)
    pub const Y: chrono::NaiveDate = chrono::naive::MIN_DATE;

    // 相対パス (use内)
    pub use self::Weekday::Sat;
    // 相対パス (use以外)
    pub const Z: self::Weekday = self::Weekday::Sat;

    // 絶対パス (use内)
    pub use crate::RealWeekday::Fri;
    // 絶対パス (use以外)
    pub const W: crate::RealWeekday = crate::RealWeekday::Fri;
}

これは1.29以前のRust2015では以下のように書く必要がありました。 (playground)

// 1.29まではextern crateが必須だった
extern crate chrono;

pub enum RealWeekday {
    Sun,
    Fri,
    Sat,
}

pub mod m1 {
    pub enum Weekday {
        Sun,
        Sat,
    }

    // デフォルトパス (use内) → 相対パス
    pub use self::Weekday::Sun;
    // デフォルトパス (use以外)
    pub const X: Weekday = Weekday::Sun;

    // extern preludeパス (use内) → extern crate + デフォルトパス
    pub use chrono::naive::MIN_DATE;
    // extern preludeパス (use以外) → extern crate + 絶対パス(旧)
    pub const Y: ::chrono::NaiveDate = ::chrono::naive::MIN_DATE;

    // 相対パス (use内)
    pub use self::Weekday::Sat;
    // 相対パス (use以外)
    pub const Z: self::Weekday = self::Weekday::Sat;

    // 絶対パス (use内) → 絶対パス(旧)
    pub use ::RealWeekday::Fri;
    // 絶対パス (use以外) → 絶対パス(旧)
    pub const W: ::RealWeekday = ::RealWeekday::Fri;
}

また、extern preludeへの移行により、 use chrono; のような単独useに関する許しがたい挙動 (ルートでは書いてはいけないが、ルート以外では書く必要がある) が解消されたのも非常に好ましい変化だと言えます。

Rust2018時代のパスの解釈

上記のようにパスの解釈が整理されたので、(Rust2018イディオムに限っていえば)シンプルに以下のように説明できます。

  • 通常のパス foo::bar::baz はスコープ内の foo を探索し、なければプレリュードにフォールバックする。
    • stdCargo.toml に指定されている依存関係は、プレリュードの亜種という扱い。
  • self:: または super:: で始まるパスは相対パスで、今いる mod からの相対位置で探索する。
  • crate:: で始まるパスは絶対パスで、クレートのルートから探索する。

これによって、モジュールについて説明しなくても直感的に動くケースが増えたと思います。たとえば

let it = std::iter::once(42);

use std::iter;
let it = iter::once(42);

はどちらも場所に関係なく自然に動作するので、完全修飾名を使うかどうかは単にDRYや可読性や好みの観点で決定すればよくなりました。 (Rust2015では std::iter::once を使うにはルートである or use std; がある or ::std::iter::once と表記する必要があるという複雑な条件を検討しなければならなかった)

モジュールを使いこなすための基本知識3つ

前節で説明した通り、Rust2018ではパスの挙動が比較的シンプルになったので、考えることは少なくなりました。いっぽう、 mod/use の使い方については、知っておいたほうがよいことがもう少しあります。

  • 1クレート = 1ファイル
  • mod はスコープを断ち切る
  • pub useuse の仲間

1クレート = 1ファイル

rustc コマンドは一回の呼び出しで1つの lib.rs を1つの *.rlib に変換します。たとえば以下のような lib.rs が考えられます。

pub mod fmt { ... }
pub mod io { ... }
pub mod sync { ... }
...

しかし、大きなクレートではこのように1ファイルに詰めると巨大化しすぎて破綻します。そのため以下のように分割できるような仕組みになっています。

pub mod fmt; // 中身は `fmt.rs` か `fmt/mod.rs` にある
pub mod io; // 中身は `io.rs` か `io/mod.rs` にある
pub mod sync; // 中身は `sync.rs` か `sync/mod.rs` にある
...

これは構文解析の段階で結合されてしまうので、Rustの仕様上は1ファイルにまとめた場合とほぼ同じ挙動になります。ファイルを置いただけではモジュールにならないのはこういう仕組みだからと考えることもできます。

コンパイラの仕組みとしては以上のようになっています(1クレート = 1ファイル)が、イディオマティックなRustコードではモジュールを最大限ファイルに分割する(1モジュール = 1ファイル)のが一般的です。

mod はスコープを断ち切る

mod は名前解決のスコープを断ち切ります。たとえば、

const X: i32 = 21 + 21;

#[cfg(test)]
mod tests {
    use super::*; // ←
    #[test]
    fn test_everythings_answer() {
        assert_eq!(X, 42);
    }
}

というテストコードは(2015/2018で共通の)頻出パターンです。ここで fn test_everythings_answer から X は(mod 境界をまたいでいるので)そのままでは参照できず、 super::X と書く必要があります。ただ、このようなファイル内テストではファイル内の定義や use をそのまま再利用するほうが自然なので、 use super::* と書いて全てをインポートしています。これによってあたかもスコープが外側まで延長されたかのような感覚でテストコードを書くことができます。

前述のように、テスト以外のイディオマティックなRustコードでは「1モジュール=1ファイル」として書かれるので、この仕組みによってファイル外の use の影響を回避できるようになっていると考えることもできます。

pub useuse の仲間

RFC1560による2016年のモジュールシステム改革以降、 pub use は特別扱いではなく use と一貫した挙動をするようになっています。

#![allow(dead_code, path_statements)]

mod m1 {
    use std::marker::PhantomData;
    pub use std::marker::PhantomPinned;
    
    struct Foo;
    pub struct Bar;
    
    fn foo() {
        PhantomData::<i32>;
        PhantomPinned;
        Foo;
        Bar;
    }
}

fn bar() {
    m1::PhantomPinned;
    m1::Bar;
}

use の働きは単に名前をスコープに入れるというわけではありません。今いるモジュールに定義を「継ぎ木」して、結果として名前がスコープ内に入るという挙動をします。 pub use の場合は、そうして「継ぎ木」した定義が外からも参照できるようになる、という寸法です。

まとめ

Rust2018でモジュールまわりの非直感的な挙動が整理され、比較的直感的に動作するようになりました。

とはいえ、その正確な動作は奥が深いです。次回は名前解決の役割がどこからどこまでなのかを明確にします。