どう対応すべきか

Cargo.toml

まず、 Cargo.toml で feature を宣言する。

# Cargo.toml 抜粋。

[features]
default = ["std"]

alloc = []
std = ["alloc"]

大半の環境では std が使えるため、 std feature はデフォルトで有効にするのが慣習である。 std を使いたくない場合のみ、 cargo の --no-default-feature オプションや Cargo.toml での default-features = false 指定などでオプトアウトする。

std が使える環境では当然 alloc も使えるので、 std = ["alloc"] の指定で std から alloc への依存を設定する。 これにより、「std または alloc が使える場合」という判定を単に「alloc が使える場合」で済ますことができる。

serde クレートにも対応する場合に feature flag をどうすべきかは微妙である。 たぶん2020年時点で定石はない。 たとえば alloc = ["serde/alloc"] などとしてしまうと serde feature を有効化していないのに allocstd 環境で勝手に serde への依存が発生するなどという悲しいことになる。 ひとつの解決策は、諦めてバラバラのフラグにすることである。

# serde に雑に対応した Cargo.toml 抜粋。

[features]
default = ["std"]

alloc = []
std = ["alloc"]

serde-alloc = ["serde/alloc"]
serde-std = ["serde/std"]

[dependencies]

[dependencies.serde]
version = "1.0.118"
optional = true
default-features = false
features = ["derive"]

おそらく現状 (Rust 1.49.0) ではこれがいちばん無難と思われる。 根本的な問題は「特定の複数の feature が有効化されていたときのみ、別の特定の feature を有効化する」という指定の仕様が存在しないところであり、 cargo の仕様を変更せず知恵だけで綺麗に解決できるという類のものではなさそうである。 当面は workaround で誤魔化してやっていくしかない。

lib.rs

べつに main.rs でも良いのだが、ここまで面倒なことをするなら普通はライブラリにするだろうから、以後 lib.rs の前提でいく。

// lib.rs 冒頭付近抜粋。

#![cfg_attr(not(feature = "alloc"), no_std)]

#[cfg(feature = "alloc")]
extern crate alloc;

ここでする必要があることは2つで、必要に応じてクレート全体で no_std attribute を有効にすることと、必要に応じて extern crate alloc; することである。

#![cfg_attr(not(feature = "std"), no_std)]

まず、 std feature が有効化されていないとき、コンパイラに std ライブラリを使わないよう伝える。 これはクレート全体に反映されるべき設定なので #![] で書く。


#![allow(unused)]
fn main() {
#[cfg(feature = "alloc")]
extern crate alloc;
}

そして、 alloc feature が有効化されているとき、 alloc クレートが使えるようにする。 ここで cfg(feature = "alloc") という条件は次のアイテム extern crate alloc; にだけ指定したいものなので、 #[] で書く。 これらを間違うと、たとえば alloc feature が無効なときクレートの全ての内容がコンパイル結果から消え去ったりするので、タイプミスに注意。

std 環境では use なしに StringVec 等が使えているが、これは std::prelude::v1 内のアイテムが自動で探索されることになっているからである。 しかし no_std な alloc 環境では、 prelude が使えない1。 このままではj, alloc と std でできることはほとんど同じなのに std から use するか alloc から use するか書き分けが必要になってしまい、不便である。

これを解決するのが #[cfg(feature = "alloc")] extern crate alloc; である。 「alloc feature が有効化されているとき」というのが std が有効化されている場合も含むのがミソで、つまり std 環境でも同じ型がたとえば String (これは prelude 経由でアクセスできる std::string::String である) と alloc::string::String の2種類の名前で使えるようになるのである。 2種類のうち alloc 環境で使える方を std 環境でも常に使ってやることにすれば、 (Error トレイト以外では) 書き分けの必要がなくなる。

アイテムの参照

alloc と std に楽に対応する準備ができたわけだが、まずは core 環境で使えるものの書き方から確認していこう。

とはいっても、基本的に core 環境ではそこまで深く考えることはない。 std で参照していたアイテムパスを全て core から参照するだけである。 たとえば use std::fmt; の代わりに use core::fmt; と書くとか、その程度のことである。

alloc と std の方は注意が必要で、やり方が大まかに2種類ある。 ひとつはファイル先頭で std の prelude 相当のものを alloc から事前に use する方法。


#![allow(unused)]
fn main() {
// 各 *.rs ファイル冒頭付近抜粋。

#[cfg(feature = "alloc")]
use alloc::{borrow::ToOwned, string::String};
}

この方法は、規模や使い方によっては手間がかかることがある。 というのも、何かが必要になってからいちいちファイル先頭に戻って編集する必要があったり、ファイルを複数に分割したとき unused import 警告が大量発生する場合があったり、トレイト実装を別ファイルに異動したとき use の書き直しが必要だったりと、管理が面倒だからである。

もうひとつの方法は、アイテムを参照するときに毎回 alloc:: から始まるパスで参照すること。


#![allow(unused)]
fn main() {
// `ToOwned` の実装例。

#[repr(transparent)]
struct AsciiStr(str);
impl AsciiStr {
    fn as_str(&self) -> &str { unimplemented!() }
}

struct AsciiString(String);
impl AsciiString {
    unsafe fn new_unchecked(s: String) -> Self { unimplemented!() }
}

#[cfg(feature = "alloc")]
impl alloc::borrow::ToOwned for AsciiStr {
    type Owned = AsciiString;

    fn to_owned(&self) -> Self::Owned {
        let s = self.as_str();
        unsafe {
            // SAFETY: Valid `AsciiStr` string is also valid as `AsciiString`.
            AsciiString::new_unchecked(s.to_owned())
        }
    }
}
}

要するに、いちいち alloc::borrow::ToOwned などのようにフルパスで指定してやれば、 std 用の prelude が利用可能か否かに関係なくアイテムを参照できるということである。

モジュール分割

alloc feature 有効時にしか有効化されるべきでない型定義やトレイト実装がおそらく多数あるわけだが、それらに毎度 #[cfg(feature = "alloc")] と付けていくのは面倒すぎる。 そこで、適当な子モジュールに吐き出してしまうと楽になる。


#![allow(unused)]
fn main() {
// モジュール分割の一例。

//
// ここに core 用の定義
//

#[cfg(feature = "alloc")]
mod owned {
    //
    // ここに alloc / std 用の定義
    //
    // `use alloc::string::String;` などしてもよい
}
}

このように条件付きでコンパイルされるコード群を別モジュールに吐き出すことで、 #[cfg(feature = "alloc")] を何度も書く必要がなくなり、可読性と保守性の向上が期待できる。

上の例ではインラインでモジュールを定義したが、もちろん別ファイルにしてもよい。


#![allow(unused)]
fn main() {
// モジュール分割の一例。

#[cfg(feature = "alloc")]
mod owned; // owned.rs 内に alloc / std 用の定義

//
// ここに core 用の定義
//
}

この辺りは規模と好みの問題だが、迷ったなら無難にファイルを分割するのが良いだろう。 mod owned { ... } のようにインラインにすると、中のアイテムが全て一段階インデントされた状態になってしまううえ、 tests のようなテスト用モジュールをインラインで定義すると更にネストが深くなってしまう。


1

experimental な alloc::prelude::v1 が存在してるようだが、たぶん普通に Rust 1.49.0 を使っていてもこれが prelude として使われないのだと思われる