やってみた 2026年10月14日

Rust 1.99.0を1.98.1と並べて試したら、半角カナのDebug出力が変わり、1.98.1で通していたテストが落ちた

XECIN RustコンパイラCargoCI

GitHubのトレンドを眺めていたら、rust-lang/rust そのものが上がっていました。ライブラリやツールではなく、コンパイラと標準ライブラリの本体です。確認した時点でスターは 119,985。10月1日に 1.99.0 が出たばかりでした。

私は業務の小さなサービスで Rust を使っていますが、ツールチェーンを上げるときは、リリースノートを読んで「うちには関係なさそうだな」で終わらせてしまうことが多いんですよね。以前、別の言語の処理系を上げたときに、ログの文字列を比べていたテストが一斉に落ちて、原因を探すのに思った以上に時間を取られた経験があります。リリースノートには書いてあったのに、自分のコードに当てはめて読めていなかったんです。

そこで今回は、1.99.0 のリリースノート(リポジトリの RELEASES.md)から、手元のコードに効きそうな項目を拾い、ひとつ前の 1.98.1 と同じコードで並べて確かめてみました。

試した環境はこんな感じです。

  • 使い捨ての Docker コンテナ(debian:bookworm-slim、x86_64)。メモリ 4GB・CPU 2 に制限し、ホストのフォルダはマウントしない
  • rustup 1.29.1 で、1.99.0(rustc b940084d7、LLVM 23.1.1)と 1.98.1(rustc 48a229cea、LLVM 22.1.8)を入れて切り替え
  • 単一ファイルの確認は rustc --edition 2024、Cargo の確認は cargo new した新規プロジェクトで、1ケースずつ別のプロセスで実行

入れるところから(ソースからはビルドしない)

最初に考えたのは、リポジトリを clone してコンパイラをビルドする方法でした。ただ、README の Quick Start は The Book の「Installation」を読むよう案内していて、ソースからのインストールは「not recommended」とはっきり書かれています。CPU 2・メモリ 4GB のコンテナで本体をビルドするのは現実的ではないので、今回は rustup の公式インストーラ(sh.rustup.rs)で配布版を入れました。

もう一つの選択肢として、手元にキャッシュされていた rust:1-bookworm イメージ(2.21GB)を使う手もありました。こちらを選ばなかったのは、2つの版を同じ rustup で並べたかったからです。版の違い以外の条件は、できるだけ揃えておきたいところです。

# リンカ用の gcc などを入れる(13.9秒)
$ apt-get install -y -qq curl ca-certificates gcc libc6-dev file

# rustup の公式インストーラで 1.99.0 を入れる(minimal プロファイル。所要 7,983ms)
$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.99.0

# 比較用に 1.98.1 も入れる(所要 7,321ms)
$ rustup toolchain install 1.98.1 --profile minimal
  1.98.1-x86_64-unknown-linux-gnu installed - rustc 1.98.1 (48a229cea 2026-09-01)

$ rustc +1.99.0 -Vv   # 抜粋
rustc 1.99.0 (b940084d7 2026-09-28)
release: 1.99.0
LLVM version: 23.1.1

$ du -sh /root/.rustup/toolchains/*
577M	/root/.rustup/toolchains/1.98.1-x86_64-unknown-linux-gnu
593M	/root/.rustup/toolchains/1.99.0-x86_64-unknown-linux-gnu

minimal プロファイルなら、1つの版が8秒前後で入りました。展開後はそれぞれ 600MB 弱です。cargo new hello からの cargo run も問題なく、Hello, world! まで 0.1 秒ほどでした。

存在しないメソッドを呼んだときの提案が変わった

最初に試したのは、Compiler の項にあった「存在しないメソッドを提案するとき、似た名前より doc alias の完全一致を優先する」という変更です。他の言語から来た人が、つい別の名前でメソッドを呼んでしまう場面を想定しています。

count に #[doc(alias = "size")] を付け、あえて sizes という似た名前のメソッドも並べてから、b.size() を呼んでみました。

// 1.99.0: 存在しないメソッド名の提案で、doc alias の完全一致を似た名前より優先する
struct Basket {
    items: Vec<u32>,
}

impl Basket {
    #[doc(alias = "size")]
    fn count(&self) -> usize {
        self.items.len()
    }

    fn sizes(&self) -> Vec<usize> {
        vec![self.items.len()]
    }
}

fn main() {
    let b = Basket { items: vec![1, 2, 3] };
    println!("{}", b.size());
}

// rustc +1.98.1 の help(似た名前の sizes を勧める)
help: there is a method `sizes` with a similar name
   |
19 |     println!("{}", b.sizes());
   |                          +

// rustc +1.99.0 の help(doc alias の count を勧める)
help: there is a method `count` with a similar name
   |
19 -     println!("{}", b.size());
19 +     println!("{}", b.count());
   |

1.98.1 は、名前が似ているだけで型も意味も違う sizes を勧めてきます。言われたとおり直すと、今度は戻り値の型でエラーになるパターンです。1.99.0 では doc alias を書いておいた count が出てきました。

細かいところですが、1.99.0 でも文言は「with a similar name」のままでした。size と count は似ていないので、この文だけ読むと少し戸惑うかもしれません。また、どちらの版でも std::os::unix::fs::MetadataExt が size を持っているという note が一緒に出ます。標準ライブラリの側も気になったので Vec に length() を呼んでみましたが、こちらは両方の版で len を勧めてきて、差はありませんでした。

半角カナの Debug 出力と、落ちたテスト

今回いちばん「やっておいてよかった」と思ったのがこれです。Libraries の項に「escape_debug_ext で U+FF9E と U+FF9F をエスケープしない」という1行があります。コードポイントだけ見るとピンとこないんですが、半角カナの濁点「゙」と半濁点「゚」です。

業務システムでは、古いシステムから来た半角カナのデータを扱うことがまだあります。そこで、{:?} の出力を文字列で固定したテストを、1.98.1 で通る形で書いてから 1.99.0 で回してみました。

// src/lib.rs(1.98.1 で通るように書いたテスト)
pub fn label() -> &'static str {
    "ガギパ"
}

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

    #[test]
    fn debug_output_is_stable() {
        assert_eq!(format!("{:?}", label()), r#""カ\u{ff9e}キ\u{ff9e}ハ\u{ff9f}""#);
    }
}

// 同じ文字列を println!("{:?}", s) した結果(別の小さなプログラムで確認)
1.98.1: "カ\u{ff9e}キ\u{ff9e}ハ\u{ff9f}"
1.99.0: "ガギパ"

// cargo +1.98.1 test
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

// cargo +1.99.0 test(終了コード 101)
assertion `left == right` failed
  left: "\"ガギパ\""
 right: "\"カ\\u{ff9e}キ\\u{ff9e}ハ\\u{ff9f}\""
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

1.98.1 では、濁点と半濁点だけが \u{ff9e} の形にエスケープされていました。「カ」はそのままなので、半角カナ全体ではなく、この2文字だけの扱いです。1.99.0 では見たままの文字で出るようになり、私が書いたテストはきれいに落ちました。

正直なところ、このテスト自体が Debug の出力という「人が読むための表示」に依存していて、書き方として改善の余地があります。最初の書き方は表示の文字列を丸ごと固定するものでしたが、本来は label() が返す値そのものを比べるべきでした。ただ、ログの断片やエラーメッセージを丸ごと文字列で比べるテストは、現場では珍しくないと思っています。半角カナを含むデータを {:?} で出している箇所があれば、上げる前に一度テストを回しておくと安心です。

使い切った範囲の表示と、古い定数の書き方

もう一つ、エッジケースの話です。Libraries の項に「使い切った RangeInclusive(a..=b)の start() と end() は、これまでと違う値を返すことがある」とあります。これまでも保証された挙動ではないので破壊的変更ではない、という扱いです。

1..=3 を by_ref() で最後まで回してから、中身をのぞいてみました。あわせて Compatibility Notes にあった「古い整数モジュールの完全な非推奨化」も、std::i32::MAX と core::u8::MIN を書いて確かめています。

# 使い切った 1..=3 を表示(Debug / start / end / is_empty / next)
1.98.1: debug=3..=3 (exhausted) start=3 end=3 is_empty=true next=None
1.99.0: debug=4..=3 start=4 end=3 is_empty=true next=None
# 使い切った範囲で配列 [10, 20, 30, 40, 50] をスライス
1.98.1: slice_by_exhausted_range=Ok([])
1.99.0: slice_by_exhausted_range=Ok([])

# let a = std::i32::MAX; let b = core::u8::MIN;
1.98.1: 警告なし(compile_rc=0)
1.99.0: warning: use of deprecated constant `std::i32::MAX`: replaced by the `MAX` associated constant on this type
        warning: use of deprecated constant `std::u8::MIN`: replaced by the `MIN` associated constant on this type

start() は 3 から 4 に変わり、Debug 表示からは「(exhausted)」という目印が消えました。is_empty() と next() の結果は同じで、このスライスもどちらの版も空でした。今思えば、使い切った範囲の start() を読むコードは書いたことがありません。ただ、ループを途中で抜けたあとの範囲を、デバッグ用にログへ出しているケースはありそうです。その場合、表示が 4..=3 になるので、読み手が一瞬混乱するかもしれません。

古い定数の方は、1.99.0 で警告が出るようになりました。面白いのは、core::u8::MIN と書いた行の警告が std::u8::MIN という名前で出ていたことです。直し方は警告の文のとおり、u8::MIN のような型の関連定数に置き換えるだけです。

Cargo 側:CI=1 と —profile debug

Cargo の項からは2つ試しました。一つは「環境変数 CI があるとき、incremental compilation を既定で無効にする」という変更です。CI のキャッシュに target/ を載せている人には、地味に効いてきそうです。

新規プロジェクトを条件ごとに1つずつ作り、cargo build 後の target/debug/incremental の中身を数えました。

条件incremental の中身du -sb の値
1.98.1・CI=11件127,692
1.99.0・CI=10件(空のディレクトリだけ残る)4,096
1.99.0・CI なし1件128,003
1.99.0・CI=1 と CARGO_INCREMENTAL=11件127,664

1.99.0 で CI=1 を付けたときだけ、incremental の中身が空になりました。CARGO_INCREMENTAL=1 を明示すると、CI=1 のままでも元に戻ります。なお、hello world 程度では、ビルド時間の差は測れていません。

もう一つは、新しく組み込みになった debug プロファイルです。

$ cargo +1.98.1 build --profile debug
error: profile name `debug` is reserved
To configure the default development profile, use the name `dev` as in [profile.dev]
See https://doc.rust-lang.org/cargo/reference/profiles.html for more on configuring profiles.
rc=101

$ cargo +1.99.0 build --profile debug
   Compiling prof_1_99_0 v0.1.0 (/work/prof_1_99_0)
    Finished `debug` profile [unoptimized + debuginfo] target(s) in 0.08s
rc=0

1.98.1 では予約語として弾かれていた名前が、1.99.0 では通るようになりました。リリースノートによると、今のところ dev との違いはなく、dev を開発中の速い繰り返し向けに寄せていくための準備だそうです。CI の設定に --profile debug を書くと 1.98 系では落ちるので、版が混在するうちは使わない方が無難だと感じました。

期待したのに、差が出なかったもの

全部の項目で差が見えたわけではありません。Compiler の項に「すべての gnu と musl のターゲットで static_position_independent_executables を有効にする」とあったので、RUSTFLAGS='-C target-feature=+crt-static' で hello を静的リンクし、file コマンドで見比べました。

結果は、1.98.1 も 1.99.0 も「static-pie linked」でした(サイズは 5,476,624 バイトと 5,507,496 バイト)。少なくとも今回の x86_64-unknown-linux-gnu では、前の版から static-pie になっていたことになります。どのターゲットで挙動が変わったのかまでは、今回は確かめていません。

最後に、ここまでの差を一覧にしておきます。

試したこと1.98.11.99.0
doc alias 付きのメソッドを別名で呼ぶ似た名前の sizes を提案alias を持つ count を提案
半角カナ「ガギパ」の Debug 出力濁点・半濁点だけ \u{ff9e} などにエスケープそのまま表示(固定したテストは失敗)
使い切った 1..=33..=3 (exhausted)、start()=34..=3、start()=4
std::i32::MAX警告なしdeprecated の警告
String::from_utf8_lossy_ownedE0658(unstable)“a�b” を返す
CI=1 で cargo buildincremental ありincremental なし
cargo build —profile debug予約語エラー(101)成功
crt-static で hello をビルドstatic-piestatic-pie(差なし)

試してみての所感

コンテナの起動から片付けまでは73秒でした。2つの版を入れて同じコードを流すだけなら、思っていたよりずっと手軽です。

自分の現場に持ち帰るなら、まず半角カナを含む値を {:?} で文字列化しているテストやログを洗い出します。次に、CI で target/ をキャッシュしているジョブは、incremental が既定で切れる前提でキャッシュの中身を見直すつもりです。キャッシュする意味が薄れるのか、CARGO_INCREMENTAL=1 で戻すべきなのかは、プロジェクトの大きさ次第だと思います。私はまず実際のビルド時間を測ってから決めます。

doc alias は、これまで rustdoc の検索のためだけに書くものだと思っていました。コンパイラの提案にも効くとなると、社内ライブラリで「よく間違えられる名前」を alias に書いておく価値が出てきます。一方で --profile debug は、チームの版が揃うまで様子見です。リリースノートを読むだけで終わらせず、自分のコードで1行ずつ当ててみる。これだけで、落ちるテストに上げる前に気づけるのは大きいと感じました。