[C++]UBとEB

この記事はC++アドベントカレンダー2023 11日目の記事です。

C++26に対して現在、一部のUBを置き換える概念としてEBというものが議論されています。

EBとは

EBはErroneous Behaviourの略称であり、EBはUB同様に規格に準拠したC++プログラムの動作状態を指定するもので、未定義動作(UB)に対して誤った動作という言葉通りの意味です。

EBは誤った動作ではあるものの、その動作は未定義ではなく定義されたものであり、EBを含むプログラムはUBを含むものと異なり何が起こるか分からないという状態にはありません。

例えば、いわゆる自動変数というカテゴリにある非クラス型の変数の初期化子を省略するとその変数は初期化されずその値は不定となります。それそのものは問題ないのですが、その値を読み取ろうとするとUBとなります。

void f(int);

int main() {
  // 未初期化変数、値は不定
  int x; // ✅

  // 未初期化変数の読み取り、UB
  f(x); // 💀

  // 初期化してからならUBではない
  x = 0;
  f(x); // ✅
}

この場合のUBは未初期化の変数を読み取ることですが、これをEBとして規定しなおし特定の値(例えば0)を読み取るようにすることができます。

void f(int);

int main() {
  // 未初期化変数、0に初期化される
  int x; // ✅

  // EB、0を読み取る
  f(x); // ✅
}

この場合、依然として未初期化変数を読み取るコードは間違っている(erroneous)コードではありますが、その動作として何が起こるか分からない(UB)ではなく特定の値(この例では0)を読み取るように規定するのがEBです。また、安全な動作をするようになるとはいえ間違っているコードであり続けているため、実装は依然としてこれに警告を発するでしょう。

このように、EBは一部のUBを置き換えることを意図して導入されようとしています。この例の未初期化変数読み取りがその急先鋒にあり、順調にいけばC++26で未初期化変数読み取りをしているコードはUBではなくなります。そして同様に、UBがEBに置き換えられることによって、現在UBのコードは何もしなくても言語バージョンをアップデートするだけで安全なコードになります。

規定

EBはまだ標準に導入されていませんが、その議論の最前線であるP2795R3(未初期化変数読み取りのEB化)には現時点の標準文言案が記されており、EBは次のように規定されています

erroneous behaviour

実装が診断することが推奨されるwell-definedな動作(実装定義・未規定の動作を含む)

[Note: Erroneous behaviourは常に正しくないプログラムコードの結果として生じる。実装はそれを診断することが許可されているが、必須ではない。定数式の評価においては明示的にerroneousと指定された動作を示すことはない。]

また、C++の実装に対する要件としてUBと並べられて次のように指定されています

この文書(規格書のこと)は、未定義の動作をする構成要素を評価するプログラムに関する実装を要件としていない
erroneous behaviourをする構成要素を評価した後の動作は実装定義

推奨プラクティス : 実装はerroneous behaviourの実行を、診断無しで実行するか、診断を発行して継続するか、診断を発行して終了、のいずれかで行う必要がある

[Note: 実装は、プログラムの動作に関する実装固有の仮定の下でerroneous behaviourに到達可能であると判断する場合、診断を発行することができる(これにより、誤検知が発生する可能性がある)。]

UBプログラムに対する要件が無いというのは以前から同じような規定が存在しており、UBが鼻から悪魔と呼ばれる根拠の一部でもあります。それと比較すると、EBの動作は(実装定義を介するとはいえ)しっかりと規定されており、UBのように最適化に利用することは明確に禁止されています。

注意点として、EBに関して現れる実装定義(または未規定)の動作とはEBそのものではなくEBに対してどういう動作をするかについてのもので、その詳細は個別のEBによって変化するものです。例えば未初期化変数読み取りでは、未初期化変数読み取りそのものがEBであり、どういう値を読み取るかが実装定義となり、それはコンパイラオプションによって指定することを意図するものです。

すなわち、EBが内包する実装定義の意図としてはコンパイラオプション等によって動作を調整するもので、実装が好き勝手な動作をできるというような意味ではありません。

例えば、未初期化変数読み取りに関してGCC/Clangは-ftrivial-auto-var-init=...というコンパイラオプションを(GCCは12から、clangは16から)利用可能であり、...zeroを指定すると未初期化変数は0に初期化され、patternを指定すると特定パターンで初期化され、オプションそのものを指定しないあるいはuninitializedを指定すると今まで通りの動作(未初期化のまま)となります。

これはEBの実装経験の1つであり、EBはこの動作を追認するとともにそれに明確な規格上の意味を与えるものです。

現在のC++プログラムでill-formedではないものは、well-definedかつUBを含まずコードの記述通りに正しく動作するものと、UBを含むために動作が規格の範囲外にあるものの2つに大別されます。誤った動作をしながら標準仕様によってその動作が制限される、というものは存在していません。

EBはそのギャップを埋めるもので、well-definedかつ間違っている(erroneousである)ものの、間違っていることが認識されているため実装はその診断を提供でき、同時にその動作について仕様の制約を与えるものです。

経緯

EBという動作状態の発明は、ここまで例示に使用してきた未初期化変数読み取りのUBをUBではなくそうとする議論に端を発しています。

2022年11月に公開されたP2723R0は、C++プログラムの安全性向上のために未初期化変数を強制的にゼロ初期化するように変更することを提案するものでした。これはそこそこの反響があり、概ね好意的に受け止められていたように思えます。

この提案はSG23のレビューでも大きな反対はなくEWGに転送されました。

この提案に対してはいくつものフィードバックやアイデアが寄せられたようで、P2754R0でそれらをまとめて代替ソリューションとの比較検討がなされました

そこでは、7つの解決策候補が提示されました

  1. 常にゼロ初期化する(P2723の提案)
    • 非クラス型の自動変数が初期化されない場合、常にゼロ初期化される
  2. ゼロ初期化もしくは診断する
    • 無条件に不定値を読む場合は診断(コンパイルエラー)
    • 条件次第で不定値を読む可能性がある場合はゼロ初期化
  3. ソースでの初期化を強制する
    • 非クラス型の未初期化変数はill-formed
  4. 後から初期化されることを考慮しつっつ、ソースでの初期化を強制する
    • 注釈なしの非クラス型の未初期化変数はill-formed
    • 未初期化変数は明示する
  5. 実装定義の値で初期化するものの、書き込み前の読み取りは未定義動作
  6. 実装定義の値で初期化するものの、書き込み前の読み取りは誤った動作
    • 書き込み前の値の読み取りは誤っているものの、UBではない
    • コンパイラフラグなどによって、テストのために検出しやすい値で初期化したり、実運用のために安全な値で初期化したりする
    • あるいは、誤った動作を未定義動作として扱うこともできる
  7. 値初期化に一本化
    • 仕様からデフォルト初期化を削除する
    • これによって初期化は常に値初期化となり、仕様が単純化され、未初期化を含む初期化周りの問題が解決される

そして、これらの解決策候補を実現可能性、下位互換性、表現可能性の3つの観点から比較します

  • 実現可能性 : その解決策が既存のC++標準に対して一貫しているかどうか。つまりは、C++標準に適用可能であるかどうか
    • 実現可能
    • 実現不可能
    • 不透明 : 現時点では判断できない
  • 下位互換性 : その解決策が採用された場合に、既存のコードを壊すことが無いかどうか
    • 互換性がある : 以前にコンパイル可能なコードは引き続きコンパイル可能であり、UBの場合のみ動作が変更される
    • 正しいコードと互換性がある : 以前にコンパイル可能でUBを含まないものは引き続きコンパイル可能だが、UBを含むコードはコンパイルエラーとなる場合がある
    • 互換性がない : 以前に正しいコードもコンパイルが通らなくなる
    • 不透明 : 現時点では判断できない
  • 表現可能性 : その解決策が採用された場合に、既存コードの意味が変更されるかどうか
    • 良い : 初期化を遅らせる意図を明示、あるいはロジックエラー(初期化忘れ)を修正するためにコードを更新する必要がある
    • 悪い : 意図的な初期化遅延とロジックエラー以外の可能性が発生することで、現在よりも状況が悪くなる
    • 変わらない : 意図的な初期化遅延もしくはロジックエラーを含むような(未初期化変数を含む)既存コードが曖昧ではなくなる
    • 不透明 : 現時点では判断できない

比較結果は次のようになりました

解決策 実現可能性 下位互換性 表現可能性
1. 常にゼロ初期化 実現可能 互換性がある 悪い
2. ゼロ初期化/診断 不透明 正しいコードと互換性がある 変わらない
3. 初期化の強制 実現可能 互換性がない 良い
4. 遅延初期化を考慮した初期化の強制 実現可能 互換性がない 良い
5. 実装定義の値で初期化+その読み取りは未定義動作 実現不可能 互換性がある 変わらない
6. 実装定義の値で初期化+その読み取りは誤った動作 実現可能 互換性がある 変わらない
7. 値初期化に一本化 不透明 不透明 不透明

3つの評価軸から最適と思われる組み合わせは、実現可能であり下位互換性があり表現可能性が良いものです。しかし、それに該当する解決策は存在しないようです。評価軸の最初の2つについては自由度がほぼありませんが、表現可能性については、そのうえで最悪変化が無ければ良い解決策であるといえます。そして、それに該当するのは6の解決策だけです。

1の解決策(P2723の提案)は表現可能性が悪化すると評価されています。これは、その解決策の適用後に初期化子が無い変数宣言がバグなのかどうかわからなくなるためです。

void f(int);

// P2723が適用されているとすると
int main() {
  // この宣言はint x = 0;のつもりで書いたのか
  // 本当にint x;と書いてしまったのかわからない
  int x;

  f(x);
}

現在のC++は非クラス型ローカル変数は初期化しなければその値は初期化されないのがデフォルトであり、現在のコードベースで出現するそのような変数宣言は初期化を忘れたか意図的に初期化していないかのどちらかになります。

意図的に初期化をしない場合については属性などのオプトアウトするメカニズムによってそれを明示することができます。しかし、P2723の無条件でゼロ初期化を行う世界では、初期化子を持たない変数宣言は意図的にゼロ初期化をしているのか初期化を忘れたのかの区別がつかなくなります。

つまり、int x = 0;のつもりでint x;と書いてしまっている現在の誤ったコードは、P2723適用後に正しいコードになってしまいます。これは基本的にはいいことに思えますが、たとえばint x = 10;のつもりでint x;と書いてしまっている現在のコードだとどうでしょうか?それはP2723適用後も誤ったコードであり続けますが、コード上からはそれを読み取ることができなくなり、0という分かりやすい値で初期化されていることで実行も上手くいってしまう可能性が高いでしょう。コンパイラ等のツールも、int x = 0;int x;の違いを認識できなくなることで、初期化忘れというバグを警告できなくなります。

6の解決策は、意図しない未初期化変数はバグであるという現在の基本をベースとしており、int x = 0;int x;は異なる意味を持つ宣言です。そして、あくまで未初期化の値を読み取る場合をUBではなく特定の値を読み取るようにすることで問題の解決を図っています。さらに、初期化する値を指定可能(実装定義)とすることで実行時のデバッグに役立てたり、本番環境で安全な値にしておくことができます。意図して未初期化のままにしておくためのオプトアウトメカニズムが用意されれば、残ったすべての未初期化変数宣言はバグであると人間もツールも認識することができ、なおかつその動作を安全なものに変更することができるわけです。

もうお察しの通り、この6番目の解決策がErroneous Behaviourとよばれる動作状態です。

前述のように、このような振る舞いは-ftrivial-auto-var-initとして既に利用可能であったため、あとはC++標準仕様でErroneous Behaviourというものを規定し、未初期化変数読み取りがEBであるというように変更するだけでした。

これらの議論を経て、EBの規定追加と未初期化変数読み取りのEB化のための標準文言を追加するためにP2795が提出され、P2723が解決しようとした問題はEBという解決策によってこちらの提案に引き継がれています。

P2795では、意図的な末初期化を行うためのオプトアウトメカニズムとして、[[indeterminate]]という属性を提案しています。これを使用すると、初期化忘れ・正しい初期化・意図的な未初期化の3つの意図がコード上で明白になります。

void f(int);

int main() {
  // 初期化忘れ、バグ
  int x;

  // 初期化済み
  int y = 0;

  // 意図的な初期化省略
  int z [[indeterminate]];

  f(x); // EB、特定の値が読み取られ、コンパイラは警告を発する
  f(y); // OK
  f(z); // UB、初期化しなければならない
}

P2795はこの記事執筆時点(2023年12月初旬)でEWGのレビューを通過しCWGでレビューされています。全ての自動変数(関数引数や一時オブジェクトを含む)に対してこのEBを適用するように改訂が必要とされてはいますが、今のところ順調に進行しています。

他のEB候補について

P2795R3には、現在UBとなっているものをEBに切り替える際の判断基準となる原則が提供されています。

  1. 既存のコードを破壊するため、現在well-definedな動作は一般的にEBにすべきではない
    • 特定の動作が常に間違っていることが判明した場合、例外的にこれを考慮することができる
  2. 現在未定義とされている動作で、有害なコンパイル結果となる可能性が低いもの(例えばCVEなどによって報告された脆弱性を悪用することで明示されるもの)はUBのままにしておくべき
    • これは規格の複雑さを最小にするため
  3. 現在UBであるもののうち、有害な障害モードを露呈するもので妥当なwell-definedな動作が見つかるものは、EBへの変更を検討すべき
    • 注記 : ある種のUBは、たとえ有害であったとしても全ての場合に合理的に検出できるわけではないため、その動作を定義しようとすることは不可能
    • 注意 : EBはUBよりもプログラマが故意に依存する可能性が若干高いため、その動作を定義することの明確な利点が無い限りUBを保持する判断をすべき

そのうえで、現在標準にあるUBのうちEBに転換できうるものをリストアップしています

  • 符号付整数型のオーバーフロー
  • 表現できない数値変換
  • 不正なビットシフト
  • ゼロ除算
  • void戻り値型の関数終端からの流出(return文忘れ)
  • [[noreturn]]関数からのreturn
  • 抽象クラス型のコンストラクタ/デストラクタからの純粋仮想関数呼び出し

このうち、非void戻り値型の関数終端からの流出のより限定的な状況である、コピー/ムーブ代入演算子定義におけるreturn *this;忘れについて、その場合に暗黙にreturn *this;する動作をEBとしてUBから転換する提案がすでに提出されています。

ただし、これに関してはEBよりもill-formed(コンパイルエラー)とすることを好む向きもあります。

参考文献

この記事のMarkdownソース