assertは必ず修正すべき
(kristoff.it)- assert は、事前条件・事後条件・不変条件をコードで明示するための仕組みであり、型システムで強制できる制約は言語機能で表現するほうが望ましい
- Zig の
std.debug.assertはマクロではなく 通常の関数 で、unreachableによって到達不能な経路を示し、最適化にも利用される - Debug・ReleaseSafe では assert の失敗は panic によるクラッシュになるが、ReleaseFast・ReleaseSmall では unchecked illegal behavior となり、誤動作する可能性がある
- 本番環境で assert を無効化すると、誤った前提を早期に発見する機会を失い、その後のコードが 誤った assert に依存して脆弱性につながることがある
- ReleaseSafe と ReleaseFast のどちらを選ぶかはプログラムの優先順位次第だが、重要なのは assert を上書きして無効にするのではなく、誤った assert を修正すべき だという点にある
assert の役割と Zig の基本動作
- assert は「この引数は null ではありえない」「この整数は偶数ではありえない」といった条件が常に真でなければならないことをコードで表現する仕組みである
- 例:
assert(my_arg != null);,assert(my_num % 2 != 0); - 型システムで制約を強制できるなら、assert より言語機能を使うほうがよい
- Zig では通常のポインタ
*Fooは null になれず、オプショナルポインタ?*Fooは null を取りうるが、値へアクセスする前に確認が強制される
- 例:
- assert は 事前条件、事後条件、不変条件 を明示するのに適している
- 良い assert は、プログラミングミスを捕まえるうえで単体テストより強力なことがある
- ファジングと組み合わせると assert の効果はさらに高まる
Zig の unreachable と assert
- Zig の assert は、不正なコード経路を示す言語機能である
unreachableを土台としているswitchでは、到達不能な分岐を.a => unreachableのように表せるunreachableは文としても使え、任意の型の式が必要な位置でも使える- 到達不能な場合のために無理にダミー値を作る必要がない
- Zig 標準ライブラリの
std.debug.assertは次のように実装されているpub fn assert(ok: bool) void { if (!ok) unreachable; // assertion failure } unreachableの情報は 最適化 に利用できる- コンパイラは到達不能な経路を削除でき、その情報が伝播することで非局所的な最適化も可能になる
- すべての assert が性能向上につながるわけではないが、プログラマが容易に予想できない最適化が行われることもある
ビルドモードとランタイム安全性
- Zig には Debug、ReleaseSafe、ReleaseFast、ReleaseSmall のビルドモードがある
- この設定は必ずしもプログラム全体にグローバルにしか適用できないわけではない
- 各依存関係を異なるモードでビルドでき、
@setRuntimeSafetyを使えば関数内のブロック単位でもランタイム安全性を調整できる
- assert の失敗は Zig では “illegal behavior” になる
- Checked モードである Debug、ReleaseSafe、
@setRuntimeSafety(true)では panic によりプログラムがクラッシュする - Unchecked モードである ReleaseFast、ReleaseSmall、
@setRuntimeSafety(false)では “unchecked illegal behavior” が発生し、プログラムは誤動作する
- Checked モードである Debug、ReleaseSafe、
- unchecked illegal behavior の結果は保証されない
- 例の
switchでは、現在生成されるマシンコードの性質上、別の分岐へ飛んだように見えることがある - 別のコンパイラバージョンでは、まったく異なる誤動作になる可能性がある
- 関連する挙動は godbolt の例 で確認できる
- 例の
- assert とその後の
switchが ReleaseSafe と ReleaseFast でどう変わるかは、別の godbolt の例 でも確認できる- ReleaseFast では、関数がすべての比較を飛ばして
trueを返す形になる - この種の最適化は、ビデオゲームやその他のリアルタイムメディアアプリケーションが大きく依存している種類のものだ
- ReleaseFast では、関数がすべての比較を飛ばして
Zig の assert はマクロではない
- Zig の
std.debug.assertは マクロではなく通常の関数 である- Zig にはマクロがない
- C/C++ 開発者が Zig に触れるとき、とくに驚く点のひとつである
- C/C++ では assert を無効化すると、assert 呼び出し全体と渡された式がコメントアウトされたかのように振る舞うことが多い
- そのため C/C++ では、副作用のある式を assert に入れてはいけない
- assert が無効化されると、その演算自体が消える可能性があるからだ
- Zig では関数呼び出し規則に従って、引数は関数呼び出し前に評価される
std.debug.assertの内部ロジックとは無関係に、引数式は評価される- したがって、次のように副作用のある式も assert に入れられる
// assert that the remove operation is not a noop: assert(my_map.remove("expected-to-exist")); - 逆に、assert 条件を計算するために複雑な処理が必要な場合、unchecked モードでもその計算が必ず除去されるとは限らない
- そのような場合は
comptime ifでコードをガードすべきである
const builtin = @import("builtin"); if (builtin.mode == .Debug) { var condition = ...; // whatever bookkeeping is necessary // to compute the condition assert(condition == .ok); } - そのような場合は
- C/C++ の意味論に慣れていると違和感があるかもしれないが、Zig では assert を一般に無効化しないという前提が置かれている
本番環境で assert を無効化する問題
- assert には大きく分けて 3 つの選択肢がある
- ランタイムチェックとして維持し、失敗時には panic によってプロセスをクラッシュさせる
- assert を性能最適化に使うが、assert が誤っていた場合のプログラム誤動作を受け入れる
- assert を完全に無効化する
std.debug.assertは assert の完全無効化をデフォルトではサポートしていない- ビルド時フラグを内部で確認する独自 assert を実装すれば、C/C++ 方式に近い挙動を作ることはできる
- assert を無効化したくなる理由は、通常 2 つの要因が組み合わさった結果である
- 性能コストやアプリケーションのクラッシュが嫌で、ランタイムチェックを維持したくない
- assert が常に正しいと信じきれず、最適化に使われたときの誤動作を恐れている
- matklad が関連議論で指摘しているように、クラッシュを避けるべき正当なエンジニアリング上の理由がある状況は存在する
- しかし一般的なソフトウェアにおいて、クラッシュ回避をデフォルトにするのは悪い選択だと評価される
- assert を無効化すると、不可能だと想定していた条件が実際に発生してもプログラムは動き続ける
- プログラムは誤った前提のもとで実行を続け、これは unchecked illegal behavior でなくても一種の誤動作である
- unchecked illegal behavior や C の undefined behavior が危険なのは、プログラムを weird machine に変えてしまう経路になりうるからである
- 十分に複雑なソフトウェアでは、UIB がなくてもプログラムは意図しない形にねじ曲がりうる
- 実行時に assert が偽になることは仕様からの逸脱であり、それ自体が意図しない処理を引き起こしうる
- SQL injection は、UIB がなくても weird-machine 級の誤動作を引き起こす具体的かつ広く知られた例である
- プログラム誤動作のコストが高すぎるなら、assert を有効にしておくべきである
- 性能が極めて重要で、誤動作のリスクを受け入れられるなら、assert を最適化の機会として使うほうがよい
- assert を無効化すると、性能向上を逃す一方で、実際より安全だと錯覚しやすい
誤った assert がコードベースを欺く仕組み
- 中核となる危険は、誤った assert がテストでは露見せず、本番でのみ失敗しうることにある
- すべての assert が常に真だと保証できるなら、assert を最適化に使うことは議論にならない
- テストがすべての誤った assert を捕捉できると保証できるなら、本番での最適化も安全になる
- 現実には誤った assert を書いてしまうことがあり、テストが必ず見つけてくれるわけでもない
- 本番で assert を無効化すると、誤った assert をできるだけ早く発見する機会を失う
- より深刻なのは、その後のコードがその誤った assert に依存して書かれ続けることだ
- 例のコードでは、
processThingは開始済みのthingに対してのみ呼ばれるべきだという前提を assert で置いているfn processThing(thing: Thing) void { // this function must always be invoked on // a thing that has already been started assert(thing.is_started); // ... } - この assert がテストでは失敗せず、本番では無効化されているため実際には偽になりうることを見落とすかもしれない
- ユーザーから見える誤動作がなければ、問題がないかのように見えて開発が続いてしまう
- その後、誰かが
thingはすでに開始済みなのだから追加準備なしでbazを呼べると考え、コードを追加するかもしれないfn processThing(thing: Thing) void { // this function must always be invoked on // a thing that has already been started assert(thing.is_started); // ... // Since thing is already started, we don't // need to foo the bar before bazzing the qux. // It would be really bad to baz the qux otherwise, // so we add an assert for good measure. assert(thing.is_fooed); thing.baz(qux); } - 2 つ目の assert 自体の論理は正しくても、1 つ目の assert が実際には偽になりうるなら危険が生じる
- テストでは 1 つ目の assert が失敗しないため、2 つ目の assert も失敗しない
- 本番では assert が無効化されているため、脆弱性がコードベースに入り込んだ瞬間に気づけない可能性がある
- コード中の assert が開発者を欺く状態になると、正しいコードを書くことが不当に難しくなる
選択肢はプログラムの優先順位によって変わる
- プログラムごとに優先順位は異なり、誤動作リスクの最小化より性能を優先するのが正当化されるものもある
- その場合、assert を最適化の機会に変える選択は自然である
- 本番で assert を惰性的に無効化するのは、assert を有効にしておくよりも、また性能最適化を積極的に活用するよりも劣った選択だと評価される
- Zine は静的サイトジェネレータで、現在は主に個人ブログのビルドに使われている
- 脅威モデルは定義されておらず、それが最優先事項でもない
- Hugo より一桁高速に動作する点を重視して、ReleaseFast ビルドを配布している
- Awebo は pre-alpha 段階のセルフホスト可能な Discord 代替である
- 個人情報を扱い、インターネットに公開されるソフトウェアであることはすでに明らかである
- 配布時には ReleaseSafe ビルドを提供する予定である
- ただし、FFmpeg、Xiph Opus、SQLite のような中核依存の一部は ReleaseFast でビルドする予定だ
- それらの依存関係では、性能向上のほうがプログラム誤動作リスクをさらに下げることより明確に重要だと判断している
実際のプロジェクトの選択とセキュリティ事例
- TigerBeetle は金融データベースであり、assert を常に有効にしている
- Ghostty はターミナルエミュレータで、macOS 向けに ReleaseFast ビルドを配布している
- 下流の利用者、たとえば Linux ディストリビューションのメンテナにも 同じ方針を推奨している
- Ghostty で公開された比較的深刻な CVE 2 件はいずれも、メモリ破壊なしで 任意コマンド実行が可能だった事例である
- メモリ破壊や UIB だけが危険のすべてではない
Zig で完全には消えない暗黙の assert
- 独自 assert は無効化できても、Zig 言語自体がコードに暗黙的に追加する assert は無効化できない
- 整数オーバーフロー、0 除算、配列範囲外アクセスなどがこれに当たる
- これらの条件はランタイム panic を引き起こすか、最適化目的に利用される
- 本番で assert を無効化する慣行は、誤った assert がコードベースの中で腐り、増殖していく原因になりうる
- その結果、UIB に対する被害妄想的な警戒が強まり、開発者が assert を再び有効にして結果と向き合うことを無意識に恐れるようになるかもしれない
- 避けられない結論は、assert を無効化して覆い隠すのではなく、誤った assert を修正すべき だということだ
- プログラムの正しさは、一部の部分集合ではなく全体を対象に追求すべきである
1件のコメント
Lobste.rs の意見
assertで単にクラッシュさせること、あるいは Rust のパニックのように処理だけをクラッシュさせる挙動が、おおむね最善だという点には同意する。だが、assertを最適化ヒントとして使うことが、単に取り除くより常に良いという点には同意しがたい第一に、任意の
assertは最適化にあまり役立たないことが多く、最適化器がすぐ活用できない条件も多い。「この分岐は絶対に通らない」のような直接的な仮定を入れるのでなければ、コードのあちこちに無作為に仮定をばらまいて得られる性能向上は大きくない可能性が高い第二に、
assertを仮定に変えると、ミスの 被害範囲 が大きく広がる。たとえばプロジェクトやユーザーごとに分離されたデータを処理するシステムで、本来ありえない状態を捕捉するassertが計算関数の途中にあるとする。リリースビルドでコストが高いので無効にする場合、単なる無効化なら被害は 1 つのプロジェクトやユーザーに限定され、その後の検査で検出されるかもしれない。一方、これを 未定義動作 にしてしまうと、計算が見当違いのコードへ飛んでメモリを任意に破壊し、すべてのプロジェクトのデータを損なう可能性がある結局、リリースビルドのデフォルトとして安全でない
assertを選ぶのは、問題発生時に被害を局所化できる可能性を下げる代償として、コード中の任意の地点を性急に最適化するようなものだ。Rust はassert!()は常にパニック、debug_assert!()はデバッグモードでのみパニック、assert_unchecked()はデバッグではパニックしリリースでは最適化ヒントになる、という形でうまく設計されていると思うReleaseFastではなくReleaseSafeを使うべきだassertを無効にすることに反対しているのではなく、一般的な推奨慣行のように 一括で無効化してしまうこと に反対している性能への影響が大きすぎてリリースビルドに残せないという判断は完全に合理的だ。しかも計算コストの高い
assertは、先に述べたように性能改善につながる可能性もほとんどないZine にもそうした例がいくつかある:
https://github.com/kristoff-it/zine/…
https://github.com/kristoff-it/zine/…
Zig には「デフォルトのリリースモード」はない。
assertをどう扱うかは常に自分で選ぶ必要があり、全体適用の選択肢はクラッシュまたは最適化で、どちらもより標準的なデフォルトとは言えないGhostty でこれまで公開された比較的深刻な CVE 2 件が、どちらもメモリ破壊なしに任意コマンド実行へつながったという事実は、とても奇妙に感じられる。ReleaseFast で配布されていたのにそうだったという点は、世界の成り立ちについての自分の理解と真っ向から食い違う
ターミナルエミュレータを扱ったことのある立場からすると、これらの脆弱性はまさに予想できる種類の厄介な問題だ。開発者や研究者をおとしめる意図はないが、こうした妙な場所での コマンドインジェクション はこの分野ではほぼ付きものの問題で、他の分野で別種のインジェクション脆弱性が付きまとうのと似ている
プロダクションで「性能のために」
assertや境界検査を無効にしようという主張を、もうほぼ 40 年 聞き続けているのが面白い。この間にコンピュータは何桁も高速化し、ソフトウェアは誰の生活にもはるかに深く入り込んだのだから、ランタイムの正確性はかつてないほど重要になっているもっと生産的な話をすると、昔の Microsoft には、一般的な
assertやcheckのほかに、他ではあまり見かけない 報告用 assert があった。自分が完全には制御できない条件があり、真だと仮定しつつ、偽だった場合も防御的に処理し、実運用で本当に偽になるのかをログやテレメトリで知りたいときに使う。たとえば、ユーザーはあるリストに 1000 個を超えて追加しないと見込んで二次アルゴリズムを使う場合や、ネットワーク遅延は 200ms 未満だと見て往復回数の多いプロトコルを使う場合などだcheckと何が違うのか?ここでリンクされている当人の一人として言うと、これは
assertに対する自分の考えをばかげた 偽の二者択一 と戯画にしたものだ。別のコメント でも書いたように、未定義動作へ切り替えるかどうかはassertごとに決めるのが望ましいと思っている。ReleaseFast への私の批判は、その選択を特定範囲のすべてのassertだけでなく、すべての安全性検査と一緒くたにしてしまう点にある未修正の
assertがクラッシュを起こすからといって無効化してしまうのは愚かだ、という kristoff の意見には同意する。ただし、「クラッシュか未定義動作か」だけが合理的な代替案だという点には同意しない。兄弟コメントの goldstein 側の考えのほうが、自分の考えに近いassert_unchecked()の動作をグローバルなデフォルトにするのは擁護しにくいが、性能最適化の手法としては合理的かもしれない。すべてのassertを仮定に変えたときにプロダクションビルドが大きく速くなるなら、性能向上の大半を生み出している 少数の仮定、できれば 1 つの仮定があるかもしれず、二分探索のような方法で見つけられるだろうReleaseFast/ReleaseSmallのどちらかを選ぶ仕組みだプログラム解析の文献では、コード内の主張または
assertを二つの形に分ける双対性がある。一つはコードを取り巻く文脈、関数であれば呼び出し側が満たすべき条件であり、もう一つはコード自身、関数であれば関数が満たすべき条件であるこの区別は、契約や漸進的型付けの文献で標準的な学術概念である「責任(blame)」として見ると明確になる。文脈に対する主張が失敗した場合、それは我々の誤りではなく文脈や呼び出し側に責任があるが、呼び出し側が正しく主張自体がバグである可能性もある。コード自身に対する主張が失敗した場合、それは我々の責任だが、コードが正しく主張自体がバグである可能性もある
関数レベルでは、事前条件は文脈に対する主張であり、事後条件はコードに対する主張である。ただし、どちらもコードの途中に入れられる。ある種の検証フレームワークでは、
assertをコードに対する主張に、assumeを文脈に対する主張に使う。一部のテストフレームワーク、特にランダムテストのフレームワークがこれをどう解釈するかとも関係している。assertが失敗するとテスト失敗として記録され、assumeが失敗するとテストはスキップされるこれは Bun を示唆しているようなので、つながりをもう少し明示的にしておきたい。Bun の作者である Jarred Sumner が 2024 年に、
unreachableは ReleaseFast でパニックすべきだと提案した Zig の issue がある。そのスレッドにある Andrew Kelley と Matthew Lugg のコメントはこの議論に関係している=> https://github.com/ziglang/zig/issues/19664
Bun は独自の
assert関数群を使っており、リリースモードではパニックするか取り除かれるが、未定義動作は導入しない。ただし、Loris の脚注も覚えておくべきだ。「言語としての Zig は、無効化できない多くのassertをコードに暗黙のうちに追加する」という点であるBun の話を長くしすぎたくはない。小規模チームによる単一プロジェクトなのだから。要点は、少しでも不安があるなら ReleaseSafe を使うべきだということだ。ReleaseSafe は遅いという評判があるが、自分の小規模な Zig プロジェクトでは ReleaseSafe と ReleaseFast のベンチマーク差を測れなかった。それでもなお、多くの他言語より速い可能性は高い
あるいは文脈上妥当なら、ReleaseFast の実行ファイルを配布しておき、未定義動作に起因する非決定的なバグ報告が届き始めたら ReleaseSafe に戻すこともできる。そうすれば、どの
assertが失敗したのか、範囲外アクセスやオーバーフローなども含めて実行可能なバグ報告を集め、コードを修正できる。そもそも ReleaseFast で配布すべきでない文脈でその判断をしてしまった場合でも、このやり方を勧めたいくらいだ :^)依存関係を調整し、
@setRuntimeSafetyを使ってプロジェクトの一部にだけ同じ方式を適用することもできる。結局のところ、賢くやろうという意思さえあれば必要な道具は一通り揃っているassert呼び出しの中に副作用のある式を入れてよい、という書き方はすべきではない。悪い慣行である。エラーチェックにassertを使うことも避けるべきだ。公平に言えば、筆者がそう主張しているようには見えない逆に、
assertが複雑な計算に依存するなら、unchecked モードでもその計算が必ずしも除去されないため、comptime ifで保護すべきだという説明も出てくる「マクロが残したトラウマを捨てて単純さを受け入れるよい機会」という言葉のアイロニーを、筆者が見落としていないことを願う。要するに「プログラムのビルドモードを考慮し、防御的な
comptime ifをあちこちにばらまかなければならない単純さ」を受け入れろという話なのだからC# で数値計算コードを少し書いており、リリースでは無効になる
assertを多用している。密なループごとに実行するにはコストが高すぎるが、ユニットテストではルーチンが最初の NaN 入力を見た瞬間に即座に落ちるのが有用だこうした NaN は、ユーザー入力から来るというより、最適化器が行ってはいけない場所へ進んでしまうなどのコードバグによって生じることが多く、より良い境界制約などが必要になる。もちろん、ユーザー入力に正規化が必要な場合もあるが、それはアルゴリズムの深部ではなく最も外側の境界で行うべきだ。ユーザー入力の正規化の結果として、アルゴリズム内部の不変条件を
assertなしで証明できる証明システムがあればよいのだが、これはサイドプロジェクトであり、落ちても誰も死なないassertをめぐる意見の不一致の 90% は、その語の定義が曖昧で複数あるために生じており、そのせいで思考とコミュニケーションが濁っている。だから概念を次の三つの名前に分け、厳密に使うべきであるassert(bool)、あるいは Rust ならassert_unchecked()は、プログラマが常に真だと信じ、コンパイラも常に真だと仮定して最適化に使うものだ。旧来の言語における検査型 assert との連想を避けるには、assume()と呼ぶ方がよいかもしれないcheck(bool)は、条件が偽ならパニックし、真なら継続し、常にそのように動作するdebug_check(bool)は、デバッグモードではcheck()と同じで、リリースモードでは常に継続する。実際には、デバッグモードでデフォルト有効の--debug_checksフラグで制御されるさらに、
assert()をcheck()に置き換えるコンパイラフラグ--check_assertsも必要だ。自分のassertが疑わしく検証したいときに使い、デバッグモードではデフォルトで有効にする。「assert」と言うときに何を意味するのかが十分に明確でなければ、成熟した議論は不可能で、言葉を浪費するだけになる