🐈

Rust 小ネタ: featureの不整合をコンパイルエラーにする方法(用法注意)

に公開

こんにちは、Fairy Devicesの吉川(@emergent: X, GitHub)です。

Rustでライブラリクレートを作っていると、ついついいろんなfeatureを足してしまいがちです。featureで有効になる機能を追加していると、#[cfg(feature = "...")]のような条件付きコンパイルを使うことが増えます。

feature同士の機能がお互い独立していればよいのですが、場合によっては「2つのうちのどちらか片方だけを有効にできる」ような実装をしたいときもあるでしょう。たとえば、ライブラリクレート内で依存するHTTPクライアントや非同期ランタイムを選択できるようにする場合が考えられます。

同種のfeatureが同時に有効になっている状況下で、コードだけで制御するのは困難なケースもありえます。本記事では、望ましくないfeatureの組み合わせを、compile_error!マクロを使ってコンパイルエラーにする方法を紹介します。ただし、後述の注意点がありますので、使用時は十分注意したほうがよいでしょう。

準備(compile_error!を使う前の状態)

まず、Rustのプロジェクトを作成し、Cargo.tomlsrc/main.rs を以下のように設定します。

# Cargo.toml
[package]
name = "feature-conflict"
version = "0.1.0"
edition = "2024"

[features]
default = []
feature_a = []
feature_b = []
// src/main.rs
#[cfg(feature = "feature_a")]
const FEATURE_A: &str = "Feature A が有効です。";

#[cfg(feature = "feature_b")]
const FEATURE_B: &str = "Feature B が有効です。";

fn main() {
    #[cfg(feature = "feature_a")]
    println!("{FEATURE_A}");

    #[cfg(feature = "feature_b")]
    println!("{FEATURE_B}");

    println!("おわり")
}

これを各featureの有無を切り替えながら実行してみます。1行目がコマンド、2行目以降が標準出力です。

> cargo run
おわり
> cargo run --features feature_a
Feature A が有効です。
おわり
> cargo run --features feature_b
Feature B が有効です。
おわり
> cargo run --features feature_a,feature_b
Feature A が有効です。
Feature B が有効です。
おわり

簡易な実装例のため、feature_afeature_b の両方を有効にしても問題ないですが、実際のプロジェクトでは、冒頭に記載のとおり「2つのうちのどちらか片方だけを有効にできる」ような実装をしたい場合もあるでしょう。では次に条件を追加して、特定のfeatureの組み合わせではコンパイルエラーを起こせるようにします。

compile_error! 実装例

ここでは、両方のfeatureが有効になっている場合とどちらも有効になっていない場合にコンパイルエラーになるようにします。

feature_a feature_b 結果
コンパイルエラー
× OK
× OK
× × コンパイルエラー

先ほどの src/main.rsmain 関数の上あたりに以下のコードを追加します。

#[cfg(all(feature = "feature_a", feature = "feature_b"))]
compile_error!("feature_a と feature_b の両方を同時に有効にすることはできません。");

#[cfg(not(any(feature = "feature_a", feature = "feature_b")))]
compile_error!("feature_a か feature_b のいずれかを有効にしてください。");

上段が両方のfeatureが有効になっている場合のコンパイルエラー、下段がどちらも有効になっていない場合のコンパイルエラーです。前述の実行例のうち1番目と4番目が以下のように変わります。きちんとコンパイルエラーが出ることを確認できましたね。

> cargo run
   Compiling feature-conflict v0.1.0 (/path/to/feature-conflict)
error: feature_a feature_b のいずれかを有効にしてください。
  --> src/main.rs:11:1
   |
11 | compile_error!("feature_a か feature_b のいずれかを有効にしてください。");
   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

error: could not compile `feature-conflict` (bin "feature-conflict") due to 1 previous error
 cargo run --features feature_a,feature_b
   Compiling feature-conflict v0.1.0 (/path/to/feature-conflict)
error: feature_a feature_b の両方を同時に有効にすることはできません。
 --> src/main.rs:8:1
  |
8 | compile_error!("feature_a と feature_b の両方を同時に有効にすることはできません。");
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

error: could not compile `feature-conflict` (bin "feature-conflict") due to 1 previous error

compile_error! マクロ使用時の注意点

一方で、compile_error! マクロを使うことで問題となるケースもあります[1]

上記のような形でライブラリ(クレートXとします)実装をしていて、加えて feature_afeature_b にそれぞれ依存しているクレートA, Bがあるとします。さらに別のクレートCがクレートA, Bに依存している状況のとき(ダイヤモンド型の依存関係のとき)、feature_afeature_b を同時に有効にしたときのコンパイルエラーが発生します(興味がある方は試してみてください)。この問題が起きないように、featureの分岐で compile_error! マクロをむやみに使うのは避け、できるだけ複数のfeatureを同時に指定できるよう設計をするのがよさそうです。

依存関係のイメージ
    X
   / \
  A   B
   \ /
    C

feature以外での #[cfg(...)] での条件付きコンパイル、たとえば特定のOSやアーキテクチャを非サポートにするケース(target_ostarget_archを使う)などでこのマクロを使うのは問題ないと考えます。

おまけ

本記事を書くにあたり compile_error! マクロの他の使い道を検索したりLLMに聞いたりしたのですが、とあるLLMが出力してきた以下のような例に興味を持ちました。構造体のサイズをコンパイル時にチェックするというものです。結論からいうと、このコードは意図通りには動作しません。 MyData 構造体のサイズにかかわらずコンパイルエラーとなるためです。

use std::mem::size_of;

struct MyData {
    // ... フィールド定義
    id: u64,
    value: u32,
    flag: bool,
}

const _: () = {
    if size_of::<MyData>() > 16 {
        compile_error!("MyData構造体のサイズは16バイトを超えてはいけません。");
    }
};

fn main() {}

上記の例で size_of::<MyData> は(筆者のmacOS環境では)16になる[2]ので条件式は false なのですが、条件式の評価より先に compile_error! マクロが展開されてコンパイルエラーとなります。compile_error! マクロは、 #[cfg(...)] アトリビュートとの組み合わせでだけ使うようにしましょう。

コンパイラの評価順序については Rust Compiler Development Guide の「Macro expansion」や「Constant Evaluation」あたりが参考になるかと思います。筆者もすべて目を通したわけではないですが、マクロの展開はASTを作成する段階、定数の評価はMIRという中間表現になったあとに行われるということがわかります。

おわりに

連休中は普段よりコードと向き合える時間が増えて、技術ブログのネタも作れたのでよかったです。

脚注
  1. 参考: https://users.rust-lang.org/t/how-to-design-conflict-features/38536/3 ↩︎

  2. 構造体のメモリレイアウトやアラインメントの話は割愛します。 ↩︎

フェアリーデバイセズ公式

Discussion