Cプログラミング言語クイズ
(stefansf.de)- C言語の規則では、ポインタ比較、エイリアシング、ヌルポインタ、未初期化値のように単純に見えるコードでも未定義動作になりうる
- 整数定数と
sizeof、文字定数、uint8_tの算術は、型選択と整数昇格のため、プラットフォーム・表記・中間代入の位置によって結果が変わりうる - 関数宣言の
foo()とfoo(void)、プロトタイプ不在、デフォルト引数昇格、戻り値のない関数は、**CとC++**で合法性や動作が異なる - 配列はポインタではなく、配列引数はポインタに調整され、
a、&a、&a[0]は同じアドレスでも型が異なるため、交換して使うことはできない - 演算子優先順位と評価順序は別物であり、
switch本文の構造や一時オブジェクトの寿命まで含めて、標準の文言が実際の実行結果を左右する
未定義動作とポインタ規則
-
ポインタ比較と厳格エイリアシング規則
- 同じ型のポインタ
pとqが同じアドレスを指していても、互いに異なるオブジェクトに由来し、かつ同じ aggregate または union オブジェクトの一部でないなら、p == qの比較は未定義動作になりうる - ポインタが単なる数値アドレスよりもずっと抽象的であるという点は、関連記事で続けて説明されている
intオブジェクトにshortlvalueとしてアクセスすると、strict aliasing規則により未定義動作となるunsigned charポインタは例外的に任意のオブジェクトをエイリアスできるため、intオブジェクトにunsigned charlvalueとしてアクセスするのは合法である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はヌルポインタとして評価される - 式
eが0に評価されるからといって、(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 は、DR260、DR451、N1793、N1818、N2012、N2013、N2221で議論されている
- 未初期化の自動記憶域期間オブジェクトを読み取る際、そのオブジェクトが
-
unsigned charとmemcpyunsigned char型には C11 § 6.2.6.1 ¶ 3により trap representation がないため、初期値は unspecified である- StackOverflowのC委員会メンバーによる回答では、標準ライブラリ関数
memcpyの呼び出し後にはxの値は specified であるべきであり、この解釈ではx != xはfalseになるとされる - C標準でこれを裏付ける根拠は明確ではなく、DR451に対する委員会回答では、indeterminate value にライブラリ関数を使うと未定義動作になるとしており、この解釈と衝突する
- この問題は未解決のままであり、追加の議論はUninitialized Readsにある
整数定数、整数昇格、sizeof
-
整数定数の表記と型
- 接尾辞のない10進整数定数は常に signed 型の一覧から選ばれるが、8進・16進定数は signed または unsigned 型になりうる
- C17 § 6.4.4.1 に従い、整数定数の型はその値を表現できる一覧の最初の型に決まる
- 接尾辞がない場合、10進定数は
int、long int、long long intの順で、8進・16進定数はint、unsigned int、long int、unsigned long int、long long int、unsigned long long intの順となる INT_MAX+1からUINT_MAXの間の定数は、10進か16進かによって型が変わることがあり、可変長引数関数の呼び出しのような ABI に敏感なコードで違いを生む可能性がある- Arm 32-bit architecture ABI では、
intとlongは 32 ビットでレジスタ 1 個に渡され、long longは 64 ビットでレジスタ 2 個に渡される intが 32 ビットのプラットフォームでは-1 < 0x8000がtrueになり、intが 16 ビットのプラットフォームではfalseになって、移植性の問題が生じる可能性がある- generic selection、C++ のオーバーロード関数、
sizeof(0x80000000) == sizeof(2147483648)のような式でも、定数の型の違いが結果を変えることがある
-
sizeof(int) > -1sizeof演算子は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型オブジェクトの整数表現と等しい
- C では文字定数は C11 § 6.4.4.4 ¶ 10 に従って
-
uint8_tの算術と除算a、b、cが読み取り前に初期化されていても、整数昇格と中間代入の位置のためにxとzの値が異なることがある- 各変数の値は
intの大きさに昇格された後で加算と除算が行われ、各代入結果は対応する変数型に truncate されて格納される - たとえば
a=255、b=1、c=2なら、xは((255 + 1) / 2) % 256 = 128になる - 中間変数
yは(255 + 1) % 256 = 0となり、その後zは(0 / 2) % 256 = 0となって、128 != 0である - unsigned integer overflow は定義済みの動作である
- モジュロ演算は加算に対して分配されるため、除算を加算に置き換えると
xとzは常に等しくなる - 最初の代入を
uint8_t x = ((uint8_t)(a + b)) / c;に変えても、xとzは常に等しくなる
-
const変数と variable length arrayconst修飾された変数nとmを配列サイズに使っても、これらは 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 が適用され、
floatはdoubleに昇格する - 昇格後の関数型が実際の関数定義の型と互換でなければ、宣言と定義の組み合わせは有効ではない
- 宣言のない関数呼び出しは、C では暗黙の関数宣言が許されるためコンパイルできる場合があるが、C++ ではコンパイルエラーになる
- 宣言なしで
bar(42)のように呼び出すと、整数引数の昇格が適用されて42はintとして表現されるため、barがある戻り型TについてT (*)(int)と互換でなければ未定義動作になる
-
値を返さない value-returning 関数
- 戻り型が
intの関数が値を返さなくても、C では呼び出し結果の値を使用しない限り適法であり得る - K&R C には
void型がなく、型を省略するとデフォルトでintが仮定されていたため、値を返さない関数と暗黙のint規則は歴史的につながっている - 暗黙の
int規則は C99 で廃止されており、関連する議論は N661 と C99 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- 関数宣言でパラメータ
xがconst修飾され、関数定義ではそうでなく、関数本体で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 には、関数が返す型は
Tの unqualified version であると明記されており、この文言は C17 で追加された - 戻り型の
const修飾が異なっていても、関数型の代入が適法になることがある - 追加の議論は DR423 と DR481 にある
- 関数定義の戻り型だけが
-
不完全な構造体とグローバル変数
- グローバル変数の宣言時点で
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 のように見える
- internal linkage を持つ
-
ポインタ-
const変換Tが派生オブジェクト型であるとき、cpへの代入は適法だが、cppへの代入が適法かどうかには短い答えがない- このテーマは implicit pointer to const conversion に関する記事 で扱われている
配列、文字列リテラル、ポインタ調整
-
配列はポインタではない
- 配列の初期化とポインタの初期化は等価ではない
- 1つ目の形式は、自動または静的記憶域期間を持つ 変更可能な配列 を初期化する
- 2つ目の形式は、静的記憶域期間を持つ配列を指すポインタを初期化するもので、その配列が必ずしも変更可能とは限らない
- 配列はポインタではなく、詳しくは 関連記事 で扱われている
-
a、&a、&a[0]int a[42];において、a、&a、&a[0]はいずれも配列の最初の要素のアドレスとして評価される- しかし、3つの式の 型は互いに異なるため、相互に置き換えて使うことはできない
- 詳しくは 関連記事 で扱われている
-
配列パラメータとローカル配列
- 関数パラメータ型が「
Tの配列」であれば、「Tを指すポインタ」に調整される - パラメータ
xがint[42]のように見えても、実際にはint *として扱われる - ローカル変数
yがint[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を代入する
- Cでは C++ のように新しい演算子を定義できないため、
-
算術オペランドの評価順序
- 演算子の優先順位は明確に定義されているが、算術オペランドの 評価順序 は定義されていない
(x=1) + (x=2)は 2 つの代入の順序が定義されておらず、xの最終値が1か2か定まらないため、未定義動作である-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が混在した構造も合法になりうる- 制御式が常に
falseのif文の内側にあるtruebranch でも、caselabel があればその文は live となり、printf("1");は dead code ではない case 2にジャンプすると loop の clause-1 と制御式が実行されない可能性があるため、変数iはあらかじめ初期化されていなければならないcase 1にbreakがなく fall through が起きても、case 1がifのtruebranch にあり、case 2がfalsebranch にある場合は、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 は単純だからサイドプロジェクトに使ってみてもよさそうだという残っていた感覚が消えた
clangでは-std=<language-standard>-pedantic -Wall -Wextraを使い、警告が出るたびに実際に修正し、ポインタキャストとポインタ操作をできるだけ避ければ、大きな落とし穴はなさそう最近の GCC/
clangの警告はかなり優秀で、<language-standard> には c89、c99、c11、c23 が使えるtcc のような、妙な最適化をしないコンパイラを使えば、奇妙な驚きに遭うことは減る
ただ「この中でいちばんありえない挙動はどれだろう?」という基準で選んで、32 問中 21 問正解した
間違えたもののほとんどは、そのありえなさの度合いを十分深く考えなかったせいだった
C は 15 年以上前に少し触った程度だが、こういうクイズを見るとまたやってみたくはならない
C23 基準では 設問 4 の答えは有効ではない
興味深いことに、しばらく C を使っていなかったのに 32 問中 27 問正解した
こういうことがあるから静的チェッカーやリンターに頼ってきた
設問 1 の時点でもう嫌な感じがした
そのポインタがどこから来るのかは考慮されておらず、そこで述べられているケースが成り立つには非常に特殊な条件が必要だ
たいていの場合、そういうポインタを作ろうとすること自体が未定義動作だが、それでも公平だとは言えるかもしれない
設問 3 は本当に驚いたし、またひとつの C の落とし穴だった
そもそも C の整数リテラルに確定した型があるというのがものすごく煩わしい
整数昇格の規則がある程度は帳尻を合わせてくれるが、同時にエラーの原因でもある
現代の言語はたいてい、あるいはすべての 暗黙の数値キャストを禁止し、可能なら文脈からリテラルの型を推論し、不可能なら明示的キャストを要求すべきだ
設問 6 以降は、テスト自体を信用できなくなってやめた
最初は設問 5 の答えが事実上、設問 6 を間違えさせるように設計されているからだと思ったが、見直すと設問 6 自体が間違っているようだ
解説では関数呼び出しが未定義動作だとしているが、問題は関数定義が合法かを尋ねており、おそらく合法だった可能性が高い
そしてそれは、そこまで珍しいケースでもない気がする
switch()の問題は本当によかった厄介だが、頭の中で解いていく過程がとても楽しかった