Rustのモジュールを詳細に理解する(7) 名前解決とマクロ

概要: 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の細かい仕様や実装を調べるためのものであり、実際に有用なプログラミングパターンであるとは限りません。また、実質的にバグに近い挙動を説明しているものもあるため、将来にわたって同じ動作であることはあまり保証できません。


衛生性とは

衛生性 (hygiene) とは、マクロ内外の変数をマクロ展開前の構造にもとづいてレキシカルスコープで解決できるようにする仕組みです。

macro_rules! foo {
    ($e:expr) => {
        let mut x = 42;
        $e;
        eprintln!("x in foo = {}", x); // 42
    }
}

fn main() {
    let mut x = 53;
    foo!(x = 64);
    eprintln!("x in main = {}", x); // 64
}

上のコードでは、 main が導入した束縛 xfoo が導入した束縛 x は区別されます。

マクロ展開を追跡するために、コンパイラはマクロ展開ひとつひとつに Mark という番号を割り振ります。たとえば上の例では foo! が1回、 eprintln! が2回展開されているので少なくとも3つの Mark が割り振られることになります。そして、衛生性を追跡するために、マクロ展開境界をまたいだ識別子には SyntaxContext という情報が割り当てられます。これは実質的には Mark の列 (より正確には Mark と透明度の対の列) で、今ある位置に至るまでにまたいだマクロ展開境界の一覧が表現されます。RustのASTの識別子やキーワードには、この SyntaxContext が付随しています。

上の例では、 foo!(x = 64) を展開するときに foo 内の識別子にだけマークが付与されて以下のようになります。

macro_rules! foo {
    ($e:expr) => {
        let mut x = 42;
        $e;
        eprintln!("x in foo = {}", x); // 42
    }
}

fn main() {
    let mut x = 53;
    // macro[1] { // of foo!()
    let mut x /* [1] */ = 42;
    x = 64;
    eprintln!("x in foo = {}", x /* [1] */); // 42
    // }
    eprintln!("x in main = {}", x); // 64
}

正確には let などのキーワードにもマークが付与されていますが、これはパースの際に無視されます。

レガシーマクロとモダンマクロ

macro_rules! によるマクロはグローバル定義に対する衛生性を持ちません。

mod m1 {
    const X: i32 = 42;
    #[macro_export]
    macro_rules! foo {
        () => { X }
    }
}

const X: i32 = 53;

fn main() {
    dbg!(foo!()); // 53
}

一方、RFC1584の定める宣言マクロ2.0ではグローバル定義に対する衛生性も導入されます。

#![feature(decl_macro)]

mod m1 {
    const X: i32 = 42;
    pub macro foo() {
        X
    }
}

const X: i32 = 53;

fn main() {
    dbg!(m1::foo!()); // 42
}

これらを区別するために、Rustのマクロ衛生性には透明度という概念があります。

  • Transparent (透明): このマークを衛生性のために使わない。
    • #[rustc_transparent_macro] のついた宣言マクロ2.0や、手続きマクロの衛生性オプトアウトで使われる。
  • SemiTransparent (半透明): ローカル束縛を探すときは考慮するが、グローバル定義を探すときは考慮しない。
    • macro_rules! で使われる。
  • Opaque (不透明): このマークを衛生性のために使う。
    • 宣言マクロ2.0や手続きマクロのデフォルト挙動。

少し前の段落で SyntaxContextMark の列だと書きましたが、 (Mark, Transparency) の列であるとみなすほうが正確です。そして SyntaxContext には以下の2つのメソッドが備わっています。

  • modern: Opaqueなマークだけを取り出した SyntaxContext を返す。
  • modern_and_legacy: OpaqueまたはSemiTransparentなマークだけを取り出した SyntaxContext を返す。

名前解決時は、識別子を比較する前に modern/modern_and_legacy のいずれかを呼んで正規化をしています。基本的には modern が使われます。modern_and_legacyが呼ばれる場面として重要なのが「値名前空間でレキシカルスコープ解決をするとき」です。これはまさに、旧来の macro_rules! で衛生性が有効だった場面に他なりません。

さて、modern形式のマクロ(手続きマクロ、宣言マクロ2.0)では、マクロ内の識別子がマクロ呼び出しの外側 (caller-site) のスコープで解決されることはありません。 (Markがずれるため)

かわりに、通常のスコープ内探索が終わったあと、マクロ定義の外側 (def-site) のスコープで探索を再開する仕組みになっています。(前の記事でも少し言及していました) これにより、「マクロ展開前の構造に基づいてレキシカルスコープで解決する」という衛生性の目標をより広く達成できるようになっています。

インポート解決とマクロ

Rustのマクロは元々、構文解析の後・名前解決の前に展開されていました。その名残りもあって、 #[macro_export] のない macro_rules! は今でも後方定義を許容しません。

foo!(); //~ERROR cannot find macro

macro_rules! foo {
    () => {
        fn f() {}
    }
}

現在は、マクロ解決は部分的に名前解決に統合されています。そのおかげで外部マクロを use したり、:: で参照したりできています。

use log::debug;

fn main() {
    debug!("foo");
    log::info!("bar");
}

これはつまり、インポート解決をしなければマクロが展開できない場合があるということです。一方で、マクロは任意のアイテムに展開できるので、マクロの展開により新たなインポート解決が必要になる可能性もあります。

インポート解決とマクロ: コンパイラアーキテクチャ

Rustコンパイラsyntaxrustc という2大クレートと、その周辺クレートから成り立っています。 rustcsyntax に依存し、 rustc_*rustc に依存し、 rustc_driverrustc_* に依存するという形でコードベースが分割されています。

ここで、マクロの展開は syntax の責務であるのに対し、インポート解決は rustc の責務です。この部分を繋ぐために、 syntax 側にDIのようなインターフェースが用意されています。

pub trait Resolver {
    fn next_node_id(&mut self) -> ast::NodeId;
    fn get_module_scope(&mut self, id: ast::NodeId) -> Mark;

    fn resolve_dollar_crates(&mut self, fragment: &AstFragment);
    fn visit_ast_fragment_with_placeholders(&mut self, mark: Mark, fragment: &AstFragment,
                                            derives: &[Mark]);
    fn add_builtin(&mut self, ident: ast::Ident, ext: Lrc<SyntaxExtension>);

    fn resolve_imports(&mut self);

    fn resolve_macro_invocation(&mut self, invoc: &Invocation, invoc_id: Mark, force: bool)
                                -> Result<Option<Lrc<SyntaxExtension>>, Determinacy>;
    fn resolve_macro_path(&mut self, path: &ast::Path, kind: MacroKind, invoc_id: Mark,
                          derives_in_scope: Vec<ast::Path>, force: bool)
                          -> Result<Lrc<SyntaxExtension>, Determinacy>;

    fn check_unused_macros(&self);
}

syntax はこのような「パスを渡すとマクロ定義への解決を試みてくれるオラクル」を受け取り、これを使いながらマクロを展開していきます。

依存性の実際の注入は rustc_driver で行われています。名前解決器をあらかじめ生成した上で、

  • 名前解決器にビルトインマクロを登録する。
  • 名前解決器を使って、マクロを展開する。
  • 名前解決器側だけでできる処理 (インポート解決のファイナライズ + パス解決) をする。

という3段階の処理をしています。

インポート解決とマクロ: シャドウイングの処理

未展開のマクロは名前解決にとってはいくつか困った問題があります。

  • 未展開マクロは任意の名前を追加で定義しうる。
  • 未展開マクロは extern crate を生成しうる。
  • 未展開マクロは #[macro_export] macro_rules!{} を生成しうる。

未展開マクロは任意の名前を追加で定義しうる

このため、未展開マクロの残っているモジュールでは、ある識別子が存在しないことを結論づけることはできないという追加規則が必要です。

ただし、シャドウイングに関してはRFC1560に追加の規則が定義されています。

Caveat: an explicit name which is defined by the expansion of a macro does not shadow implicit names.

これにより、globインポートのシャドウイングを検討するときにマクロを気にする必要はなくなっています。

未展開マクロは extern crate を生成しうる

extern crate は名前解決に対するグローバルな影響があります。クレートルートで extern crate serde as serde2; とすることにより serde2::Serialize というパスが解決可能になってしまうからです。

そのため、ExternPreludeの解決時はクレートルートに未解決のマクロがある場合、ExternPreludeの非存在を結論づけないという追加規則が必要です。

未展開マクロは #[macro_export] macro_rules!{} を生成しうる

#[macro_export] macro_rules!{} を使うと、任意の位置からクレートルートに名前を挿入できてしまいます。

このせいで名前解決が進まないのは本望ではないので、マクロ内定義マクロのパスによる参照を禁止するという方向で対策されています。これにより、マクロ解決時に未展開マクロを気にする必要はほぼなくなっています。

まとめ

Rustはマクロに衛生性を導入しており、そのせいで識別子に対する処理が複雑になっています。さらに歴史的な事情もあって衛生性には3段階のレベルがあるので、このことをきちんと理解しつつ名前解決の挙動を追うのは大変です。また、インポート解決とマクロ展開が相互作用するのも、この部分を複雑にしている一因となっています。

とはいえ、こういった細かい配慮によって全体としてはそれなりに一貫した体感のモジュールシステムが提供されており、一般的なRustプログラマーはここまで複雑な問題を気にする必要がないようになっていると思います。

ここまで7日間かけて名前解決についてまとめましたが、2年前に同じ部分を調べたときに比べて全容がより複雑になっていると感じます。その一方で、当時からあった細かい仕様や実装についてもより深く理解できるようになったので、この記事をまとめた甲斐があると思います。

この記事を通しで読む人がどれくらいいるかはわかりませんが、きちんと読めば(すぐには役立たなくても)Rustでのプログラミングを底から支える力になると思います。また、何か細かい仕様で困った人にとっての辞書としても使える記事に仕上がったと思います。

関連RFC

Rust2018以前の仕様に関連したRFC

Rust2018に関連した(または2018年頃に実装された)RFC

実装途上のRFC