1 ポイント 投稿者 GN⁺ 19 시간 전 | 1件のコメント | WhatsAppで共有
  • C言語の規則では、ポインタ比較、エイリアシング、ヌルポインタ、未初期化値のように単純に見えるコードでも未定義動作になりうる
  • 整数定数とsizeof、文字定数、uint8_tの算術は、型選択と整数昇格のため、プラットフォーム・表記・中間代入の位置によって結果が変わりうる
  • 関数宣言のfoo()foo(void)、プロトタイプ不在、デフォルト引数昇格、戻り値のない関数は、**CとC++**で合法性や動作が異なる
  • 配列はポインタではなく、配列引数はポインタに調整され、a&a&a[0]は同じアドレスでも型が異なるため、交換して使うことはできない
  • 演算子優先順位と評価順序は別物であり、switch本文の構造や一時オブジェクトの寿命まで含めて、標準の文言が実際の実行結果を左右する

未定義動作とポインタ規則

  • ポインタ比較と厳格エイリアシング規則

    • 同じ型のポインタpqが同じアドレスを指していても、互いに異なるオブジェクトに由来し、かつ同じ aggregate または union オブジェクトの一部でないなら、p == qの比較は未定義動作になりうる
    • ポインタが単なる数値アドレスよりもずっと抽象的であるという点は、関連記事で続けて説明されている
    • intオブジェクトにshort lvalueとしてアクセスすると、strict aliasing規則により未定義動作となる
    • unsigned charポインタは例外的に任意のオブジェクトをエイリアスできるため、intオブジェクトにunsigned char lvalueとしてアクセスするのは合法である
    • unsigned charにはパディングビットと trap representation がないことが保証されており、C11以降はsigned charにもパディングビットがないことが保証される
    • 型ベースのエイリアス解析は関連記事で扱われている
  • ヌルポインタとポインタ表現

    • ヌルポインタのビット表現は、必ずしも全ビット0である必要はない
    • C標準はnull pointer constantを定義しているが、実行時のヌルポインタ表現や一般のポインタ表現は定義していない
    • Symbolics Lisp Machine 3600は数値ポインタの代わりに<array-object, index>形式のタプルを使い、ヌルポインタ表現は<nil, 0>である
    • 追加の例はclc FAQ 5.17にある
    • 定数0は文脈によって整数またはヌルポインタになり、(void *)0はヌルポインタとして評価される
    • e0に評価されるからといって、(void *)eがヌルポインタになる保証はない
    • null pointer constantがポインタ型に変換される場合にのみ、ヌルポインタに等しいことが保証される
    • ヌルポインタに対する算術は未定義動作なので、eがヌルポインタでもe + 0がヌルポインタである保証はない
  • 未初期化値

    • 未初期化の自動記憶域期間オブジェクトを読み取る際、そのオブジェクトがregister記憶クラスでありうるうえ、アドレスが一度も取得されていないなら、C11 § 6.3.2.1 ¶ 2により未定義動作となる
    • この規則は、DR338で扱われている Intel Itanium アーキテクチャと関係している
    • Itaniumの汎用整数レジスタは64ビットと1つの trap bit を持ち、この trap bit はレジスタが初期化済みかどうかを示すNaT(not-a-thing)である
    • 変数のアドレスを取得するとこの条件はなくなるが、値は indeterminate であり、trap representation または unspecified value になりうる
    • trap representation を読み取ると、C11 § 6.2.6.1 ¶ 5により未定義動作となる
    • unspecified value の場合、x != xの結果もtrueまたはfalseになりうえ、int xが unspecified ならx *= 0の後でもxが0である保証はない
    • indeterminate と unspecified value は、DR260DR451N1793N1818N2012N2013N2221で議論されている
  • unsigned charmemcpy

    • unsigned char型には C11 § 6.2.6.1 ¶ 3により trap representation がないため、初期値は unspecified である
    • StackOverflowのC委員会メンバーによる回答では、標準ライブラリ関数memcpyの呼び出し後にはxの値は specified であるべきであり、この解釈ではx != xfalseになるとされる
    • C標準でこれを裏付ける根拠は明確ではなく、DR451に対する委員会回答では、indeterminate value にライブラリ関数を使うと未定義動作になるとしており、この解釈と衝突する
    • この問題は未解決のままであり、追加の議論はUninitialized Readsにある

整数定数、整数昇格、sizeof

  • 整数定数の表記と型

    • 接尾辞のない10進整数定数は常に signed 型の一覧から選ばれるが、8進・16進定数は signed または unsigned 型になりうる
    • C17 § 6.4.4.1 に従い、整数定数の型はその値を表現できる一覧の最初の型に決まる
    • 接尾辞がない場合、10進定数は intlong intlong long int の順で、8進・16進定数は intunsigned intlong intunsigned long intlong long intunsigned long long int の順となる
    • INT_MAX+1 から UINT_MAX の間の定数は、10進か16進かによって型が変わることがあり、可変長引数関数の呼び出しのような ABI に敏感なコードで違いを生む可能性がある
    • Arm 32-bit architecture ABI では、intlong は 32 ビットでレジスタ 1 個に渡され、long long は 64 ビットでレジスタ 2 個に渡される
    • int が 32 ビットのプラットフォームでは -1 < 0x8000true になり、int が 16 ビットのプラットフォームでは false になって、移植性の問題が生じる可能性がある
    • generic selection、C++ のオーバーロード関数、sizeof(0x80000000) == sizeof(2147483648) のような式でも、定数の型の違いが結果を変えることがある
  • sizeof(int) > -1

    • sizeof 演算子は size_t 型の unsigned integer を返す
    • C11 § 6.3.1.8 の usual arithmetic conversions に従い、signed オペランドが unsigned オペランドより低い rank を持つ場合、同じ rank の unsigned 型に変換される
    • -1 に対応する signed integer は、unsigned に変換されるとその rank の最大 unsigned integer になる
    • したがって sizeof(int) > -1 は常に false と評価される
  • 文字定数の型

    • C では文字定数は C11 § 6.4.4.4 ¶ 10 に従って int 型である
    • したがって sizeof(char) == sizeof('x') が常に true である保証はなく、sizeof(int) == sizeof('x') のみが保証される
    • integer character constant は 1 個以上の multibyte character シーケンスでありうるため、'abc' も有効で、その表現は実装定義である
    • 単一文字を含む integer character constant の値は、同じ単一文字を表す char 型オブジェクトの整数表現と等しい
  • uint8_t の算術と除算

    • abc が読み取り前に初期化されていても、整数昇格と中間代入の位置のために xz の値が異なることがある
    • 各変数の値は int の大きさに昇格された後で加算と除算が行われ、各代入結果は対応する変数型に truncate されて格納される
    • たとえば a=255b=1c=2 なら、x((255 + 1) / 2) % 256 = 128 になる
    • 中間変数 y(255 + 1) % 256 = 0 となり、その後 z(0 / 2) % 256 = 0 となって、128 != 0 である
    • unsigned integer overflow は定義済みの動作である
    • モジュロ演算は加算に対して分配されるため、除算を加算に置き換えると xz は常に等しくなる
    • 最初の代入を uint8_t x = ((uint8_t)(a + b)) / c; に変えても、xz は常に等しくなる
  • const 変数と variable length array

    • const 修飾された変数 nm を配列サイズに使っても、これらは C の integer constant expression ではない
    • C11 § 6.6 ¶ 6 では、integer constant expression は integer constant、enumeration constant、character constant、結果が integer constant である sizeof_Alignof、cast の直接のオペランドである floating constant などに限定される
    • 配列サイズ式が integer constant expression でなければ、C11 § 6.7.6.2 ¶ 4 に従って variable length array になる
    • variable length array は file scope では許可されないため、グローバル配列 x がある compilation unit はコンパイルされない
    • block scope では variable length array が許可されるため、ローカル配列 y がある compilation unit はコンパイルできる
    • variable length array は実装がサポートしなくてもよい conditional feature なので、これをサポートしないコンパイラでは block scope の例もコンパイルされない可能性がある
    • C++ では両方の compilation unit がコンパイルされ、C++ には variable length array の概念がないため、y は 42 要素を持つ通常の配列としてコンパイルされる

関数宣言、戻り値、linkage

  • foo()foo(void)

    • foo() 形式の関数宣言は、引数の個数と型が不明な関数を宣言し、foo(void) は引数のない nullary function を宣言する
    • この違いは 関数宣言・定義・プロトタイプに関する記事 で扱われている
    • 引数リストのない宣言は、関数名だけを導入し、引数の数と型を定義しないため、後続の関数定義と結び付いて適法になり得る
    • プロトタイプなしで関数が呼び出されると、default argument promotions が適用され、floatdouble に昇格する
    • 昇格後の関数型が実際の関数定義の型と互換でなければ、宣言と定義の組み合わせは有効ではない
    • 宣言のない関数呼び出しは、C では暗黙の関数宣言が許されるためコンパイルできる場合があるが、C++ ではコンパイルエラーになる
    • 宣言なしで bar(42) のように呼び出すと、整数引数の昇格が適用されて 42int として表現されるため、bar がある戻り型 T について T (*)(int) と互換でなければ未定義動作になる
  • 値を返さない value-returning 関数

    • 戻り型が int の関数が値を返さなくても、C では呼び出し結果の値を使用しない限り適法であり得る
    • K&R C には void 型がなく、型を省略するとデフォルトで int が仮定されていたため、値を返さない関数と暗黙の int 規則は歴史的につながっている
    • 暗黙の int 規則は C99 で廃止されており、関連する議論は N661C99 rationale にある
    • C17 § 6.9.1 ¶ 12 は、関数末尾の } に到達し、呼び出し側が関数呼び出しの値を使用すると 未定義動作 になると規定している
    • C++98 § 6.6.3 ¶ 2 では、value-returning 関数の末尾まで流れ出ること自体が値なしの return と同じであり、value-returning 関数では未定義動作になる
    • C++ コンパイラは、どの分岐で abort_program() が終了するかを一般に証明できないため、このような場合はエラーではなく診断しか出せない
  • linkage と extern

    • 以前の宣言が見えているスコープで、同じ識別子を extern でもう一度宣言すると、後の宣言の linkage は以前の宣言の linkage と同じになる
    • C17 § 6.2.2 ¶ 4 は、以前の宣言が internal または external linkage を指定していた場合、後続の extern 宣言も同じ linkage を持つと規定している
    • 以前の宣言が見えていないか、以前の宣言に linkage がない場合、extern 識別子は external linkage を持つ
    • 逆順の宣言の組み合わせは未定義動作になり得て、GCC と Clang はこれを検出する

修飾子と不完全型

  • 関数パラメータの const

    • 関数宣言でパラメータ xconst 修飾され、関数定義ではそうでなく、関数本体で x に値を書き込んでも適法である
    • C11 § 6.7.6.3 ¶ 15 によれば、関数パラメータ型の互換性と composite type を判断する際、qualified type として宣言された各パラメータは unqualified version として扱われる
    • 同じテーマは DR040 でも扱われている
  • 関数戻り型の const

    • 関数定義の戻り型だけが const 修飾され、宣言はそうでない場合の正解は、単純に正しいとも間違いとも言い切りにくい
    • 全体としての合意は、rvalue の修飾子は無視されるべきだというものだが、C11 までの標準文言ではこれを明示的に扱っていなかった
    • C17 では、cast、lvalue conversion、function declarator で rvalue 修飾子を無視すべきことが明確になった
    • C17 § 6.7.6.3 ¶ 5 には、関数が返す型は Tunqualified version であると明記されており、この文言は C17 で追加された
    • 戻り型の const 修飾が異なっていても、関数型の代入が適法になることがある
    • 追加の議論は DR423DR481 にある
  • 不完全な構造体とグローバル変数

    • グローバル変数の宣言時点で struct foo が不完全型でサイズが不明でも、その後同じ translation unit で型が完全になる場合、特定の状況では許容される
    • グローバル変数や不完全型配列にも同様の論理が適用される
    • この内容は DR016 でも扱われている
  • void 型の external object

    • internal linkage を持つ void 型変数の宣言は適法ではないが、external linkage を持つ void 型変数の宣言は文法上は適法で、C11 標準のどこにも明示的な禁止はない
    • C11 § 6.2.5 ¶ 19 によれば、void 型は値の空集合から構成される 完成できない不完全オブジェクト型 である
    • C11 § 6.3.2.1 ¶ 1 は lvalue を void 以外のオブジェクト型の式として定義しているため、void 型オブジェクト名 foo は有効な lvalue ではない
    • C11 を基準にすると、external void オブジェクトに対して意味があり conforming な演算は思い付きにくい
    • DR012 は、型を const void に変えるとオブジェクト foo のアドレスを取ることが適法だと扱っており、これは意図された機能というより oversight のように見える
  • ポインタ-const 変換

配列、文字列リテラル、ポインタ調整

  • 配列はポインタではない

    • 配列の初期化とポインタの初期化は等価ではない
    • 1つ目の形式は、自動または静的記憶域期間を持つ 変更可能な配列 を初期化する
    • 2つ目の形式は、静的記憶域期間を持つ配列を指すポインタを初期化するもので、その配列が必ずしも変更可能とは限らない
    • 配列はポインタではなく、詳しくは 関連記事 で扱われている
  • a&a&a[0]

    • int a[42]; において、a&a&a[0] はいずれも配列の最初の要素のアドレスとして評価される
    • しかし、3つの式の 型は互いに異なるため、相互に置き換えて使うことはできない
    • 詳しくは 関連記事 で扱われている
  • 配列パラメータとローカル配列

    • 関数パラメータ型が「T の配列」であれば、「T を指すポインタ」に調整される
    • パラメータ xint[42] のように見えても、実際には int * として扱われる
    • ローカル変数 yint[42] であれば、sizeof(y)42 * sizeof(int) である
    • 一般にオブジェクトポインタのサイズは整数 42 個分のサイズと等しくないため、sizeof(x) == sizeof(y) は通常 false である
    • 詳しくは 関連記事 で扱われている

演算子、評価順序、制御フロー

  • x+++y

    • Cでは C++ のように新しい演算子を定義できないため、+++ のような新しい演算子は存在しない
    • x+++y は既存の演算子の組み合わせとして解釈され、(x++) + y と等価である
    • --*--p も新しい演算子ではなく、既存の演算子の組み合わせである
    • --*--p--(*(--p)) と等価で、例では -1 に評価され、副作用として x[0]-1 を代入する
  • 算術オペランドの評価順序

    • 演算子の優先順位は明確に定義されているが、算術オペランドの 評価順序 は定義されていない
    • (x=1) + (x=2) は 2 つの代入の順序が定義されておらず、x の最終値が 12 か定まらないため、未定義動作である
    • -std=c11 -O2 オプションでは、GCC 8.2.1 は例の式を 4 に、Clang 7.0.0 は 3 に評価する
  • 論理演算子の評価順序

    • 論理演算子 &&|| では、オペランドの評価順序も明確に定義されている
    • C 標準の表現では、最初のオペランドの評価と 2 番目のオペランドの評価の間に sequence point が存在する
    • 例では、まず x=1 が評価されて true になり、続いて x=2 が評価され、やはり true になるため、式全体は true になる
  • switch の自由な本体構造

    • switch 文の本体は任意の statement にできるため、loop と if が混在した構造も合法になりうる
    • 制御式が常に falseif 文の内側にある true branch でも、case label があればその文は live となり、printf("1"); は dead code ではない
    • case 2 にジャンプすると loop の clause-1 と制御式が実行されない可能性があるため、変数 i はあらかじめ初期化されていなければならない
    • case 1break がなく fall through が起きても、case 1iftrue branch にあり、case 2false branch にある場合は、case 2 を飛ばして case 3 へ継続できる
    • 3 回の呼び出し foo(0); foo(1); foo(2); の後、コンソール出力は 02313223 になる
    • loop と switch を組み合わせた有名な実例は Duff's device である

一時オブジェクトの寿命と C 標準バージョンの違い

  • ある特定のコード片は C11 では未定義動作だが、C99 ではそうでない可能性がある
  • C11 では特定のオブジェクトの寿命が短くなり、関数呼び出しが返したオブジェクトは右辺が評価されている間だけ生存する
  • C99 では同じオブジェクトが enclosing block の終わりまで生存する
  • 寿命が尽きたオブジェクトを参照すると、C11 § 6.2.4 ¶ 2 に従って 未定義動作 となる
  • C99 でも automatic storage duration を持つオブジェクトの寿命は最も近い enclosing block に結び付けられるため、そのブロックの外でオブジェクトを参照すると未定義動作である
  • C11 § 6.2.4 ¶ 8 では、構造体または union 型の non-lvalue expression が array member を含む場合、automatic storage duration と temporary lifetime を持つオブジェクトを参照すると規定している
  • この一時オブジェクトの寿命は式が評価されるときに始まり、それを含む full expression または full declarator の評価が終わると終了する
  • temporary lifetime を持つオブジェクトを変更しようとする試みは未定義動作である
  • この例は N1285 から取られており、追加の議論もそこにある

1件のコメント

 
Lobste.rs の意見
  • 設問 4 は C23 では有効ではないが、それ以前では有効だった
    設問 10 は正解でも不正解でもないので、選択式としては少し引っかかる
    設問 15 は、特に設問 13 との関係で技術的に誤っており、設問 20 は「未規定」なのでやはりどの答えでもない
    設問 30 は読み方によって曖昧
    それでも 31 問中 27 問正解できたし、コンパイラ開発者であることが少しは役に立った

  • 4 問ほど解いたところで、C は単純だからサイドプロジェクトに使ってみてもよさそうだという残っていた感覚が消えた

    • GCC や clang では -std=<language-standard> -pedantic -Wall -Wextra を使い、警告が出るたびに実際に修正し、ポインタキャストとポインタ操作をできるだけ避ければ、大きな落とし穴はなさそう
      最近の GCC/clang の警告はかなり優秀で、<language-standard> には c89、c99、c11、c23 が使える
    • C は単純だが、未定義動作をめぐる曲芸は単純ではない
      tcc のような、妙な最適化をしないコンパイラを使えば、奇妙な驚きに遭うことは減る
  • ただ「この中でいちばんありえない挙動はどれだろう?」という基準で選んで、32 問中 21 問正解した
    間違えたもののほとんどは、そのありえなさの度合いを十分深く考えなかったせいだった
    C は 15 年以上前に少し触った程度だが、こういうクイズを見るとまたやってみたくはならない

    • ちなみに ChatGPT は、各解答の後に出る追加説明を見ていない状態で 32 問中 22 問正解した
  • C23 基準では 設問 4 の答えは有効ではない

  • 興味深いことに、しばらく C を使っていなかったのに 32 問中 27 問正解した
    こういうことがあるから静的チェッカーやリンターに頼ってきた

  • 設問 1 の時点でもう嫌な感じがした
    そのポインタがどこから来るのかは考慮されておらず、そこで述べられているケースが成り立つには非常に特殊な条件が必要だ
    たいていの場合、そういうポインタを作ろうとすること自体が未定義動作だが、それでも公平だとは言えるかもしれない
    設問 3 は本当に驚いたし、またひとつの C の落とし穴だった
    そもそも C の整数リテラルに確定した型があるというのがものすごく煩わしい
    整数昇格の規則がある程度は帳尻を合わせてくれるが、同時にエラーの原因でもある
    現代の言語はたいてい、あるいはすべての 暗黙の数値キャストを禁止し、可能なら文脈からリテラルの型を推論し、不可能なら明示的キャストを要求すべきだ
    設問 6 以降は、テスト自体を信用できなくなってやめた
    最初は設問 5 の答えが事実上、設問 6 を間違えさせるように設計されているからだと思ったが、見直すと設問 6 自体が間違っているようだ
    解説では関数呼び出しが未定義動作だとしているが、問題は関数定義が合法かを尋ねており、おそらく合法だった可能性が高い

    • メモリ上で 2 つの配列が隣接していて、一方の先頭要素と他方の末尾のひとつ先を指していれば、そういう状況になる
      そしてそれは、そこまで珍しいケースでもない気がする
  • switch() の問題は本当によかった
    厄介だが、頭の中で解いていく過程がとても楽しかった