週末プロジェクト: C言語で楽しむ愉快な挑戦
(lcamtuf.substack.com)- Cは単純で表現力が高い一方、switch文とラベル規則だけでも熟練開発者を戸惑わせるコードを作れる
- IOCCC風の難読化はマクロやフォーマットでコードを隠すが、C自体の文法だけでも読めるのに見慣れないコードは可能
switch (...)は波かっこが必須ではなく、一致したcaseラベルへジャンプするため、一般的なブロック初期化の流れとは異なる動作をしうるcaseラベルはswitchブロックの最上位にある必要がなく、if (0) case...とelse ifチェーンを組み合わせた波かっこなしの switchもコンパイルできる- GNU Cの
&&ラベルアドレス拡張は、自作のswitchやラベルベースのループまで可能にするが、一部の例はGCC専用であり、未定義動作に対して安全ではない可能性がある
C文法が生み出す見慣れないコード
- Cには欠点も多いが、単純な文法と表現力のおかげで、オペレーティングシステムのような大きなソフトウェアを書ける言語として使われている
- 簡潔な文法は、JavaからGoまで多くの主流な後続言語のコード構造にも影響を与えた
- コード難読化の代表例は IOCCC である
- IOCCC受賞作は、プリプロセッサマクロ、異常なフォーマット、役に立たない変数名、難解な算術式で構成されることが多い
- こうしたコードは感嘆に値するが、通常のコードへとリバースエンジニアリングしなければならないため、学習素材としては面白さが少し薄れることもある
switch, case, gotoで見えるCの深い片隅
switch (...)はif (...)やfor (...)と同じように波かっこなしで書けるswitch (i) case 1: puts("i = 1");はコンパイル可能- 波かっこがない場合、
switchに結び付くのは1つの文だけなので、続くcase 2:はもはやswitchの中に存在せずエラーになる
switchは本質的に、一致したcaseラベルへ移動するgotoに近い構造であるswitchブロック内にint a = 123;やputs(...)があっても、default:へジャンプするとその前の初期化コードは実行されない- この場合
aは123に初期化されず、技術的には未定義動作になる - 例は Godbolt で確認できる
caseラベルは、対応するswitchブロックの最上位になければならないわけではないif (0) case 0: puts("i = 0");のような形も動作するswitchがそのcaseへ直接ジャンプするため、手前のif (0)はスキップされる- その
puts(...)実行後の別の出力文は引き続きif (0)条件に阻まれるため、breakがなくても fallthrough を避けられる - 例は Godbolt で確認できる
if ... elseチェーンは文法上1つの最上位文として振る舞うため、これをswitchと組み合わせると波かっこなしの奇妙な switchを作れる- 例には
case 1 ... 10の範囲caseとdefaultまで含まれる - 例は Godbolt で確認できる
- 例には
- GNU拡張の
&&演算子はラベルのアドレスを取得し、そのアドレスへgotoできるようにする- これを使えば
goto *(void*[]){ &&case_0, &&case_1, &&case_2 }[i];のように自作のswitchを実装できる - 例は Godbolt で確認できる
- これを使えば
- 同じGNU拡張を使うと、
for (...)なしでも変数宣言の中でラベルベースのループを実装できる- 例では
i = 0からi = 5までを出力するループを、ラベルとgoto *&&_で構成している - 例は Godbolt で確認できる
- この最後のスニペットはGCC専用であり、未定義動作に対して安全ではない可能性がある
- 例では
- Cでは、意図的にコードを難読化しなくても、完全に見慣れず混乱を招く形を作り出せる
1件のコメント
Hacker Newsのコメント
上の例は
aの値を出力するが、123で初期化されるわけではないとされていたものの、Cでは実際にそうなる可能性もある未初期化変数を使うことは「そのメモリにたまたま残っていた値」を読むという意味ではなく、未定義動作なので、コンパイラは好きなように扱える
たとえばそのメモリを必ず123で初期化することもできるし、断片全体を未定義動作だとみなして命令をすべて消し、何も出力しないようにもできる。さらに後続の
returnや前の命令まで最適化で消せるため、未定義動作が「時間をさかのぼって」影響しているように見えることがある以前の命令に影響を与えることはありうるが、コードの再配置や複雑な変換は未定義動作がなくても起こることだ
静的オブジェクトは常に初期化されるので、この状況は起きない
残るのは
mallocで確保した構造体の未初期化メンバーのような動的オブジェクトだが、未初期化の動的メモリを読むことはCでは未定義動作ではなく、未初期化ビットが意味する値を得ることになる。その型にトラップ表現がなければ失敗は起こりえないこういう構文も可能
switch(k) { if (0) case 0: x = 1; if (0) case 1: x = 2; if (0) default: x = 3; }各節の末尾ごとに
breakを書かなくてよいswitchのようにできるし、#define brkcase if (0) caseのようなマクロも可能だ。コンパイラは制御フローを気に入らないだろうが、たいていはうまく消し去ってくれそう#define brkcase break;caseでも同じように動くのではと思うが、そうするとマクロの目的が少し薄れるgotoで書いたほうがよさそうcaseラベルの本体が1行だけか、中括弧で囲まれているときにしか機能しない昔、「次の
caseラベルの最初の1行だけを飛ばして、それ以降はそのままフォールスルーする」という意味でこの構造を使ったことがあるcaseラベルを文の区切りではなく単なるラベルだと見れば、すべて筋が通るCでコルーチンを実装するのにこの手法を使える: https://stackoverflow.com/questions/24202890/switch-based-co...
case 1 ... 10:が有効なCだと知らなかったのが不思議なくらい何年もCを使ってきたのに、これがどの標準に由来するのか気になる
拡張の来歴は見つけられず、標準Cにはないと理解している。
clangについては確信がない昔、遊びで10から1までカウントダウンする妙なCコードを書いたことがある
C、Python、シェル版のどれも
&&と再帰呼び出しを使ってワンライナーにできるsys.stdout.write(f"{n}\n")はprint(n)に置き換えられる今のコードは
f-stringを除けば、printが文だった Python 2 スタイルのように見える。Python 3ではprintは通常の関数で、Noneを返して偽と評価されるので、最初のandもorに変える必要があるもう一つ驚くのは、
4[arr]がarr[4]と同じだということ*(array_label+offset)であり、ここでは*(offset+array_label)になるつまり
*(arr+4)と*(4+arr)は同じということだarr[i][j]とj[i[arr]]も完全に同じa[x][y]が(a[x])[y]と同じで、a[x]がx[a]と同じだと分かればよいarr[i][j]→(arr[i])[j]→(i[arr])[j]→j[i[arr]]記事の最後の難読化コード片は、別のGCC拡張も示している: https://stackoverflow.com/questions/34559705/ternary-conditi...
このブログ作者のこうしたお遊び的な手法は、先にTwitterで見た
switch文でループも作れる: https://twitter.com/lcamtuf/status/1807129116980007037この
switchの遊びはDuff's deviceの重要な部分なのではないかと思うswitchブロックとループを混在させられること、明示的にbreakしなければcaseがフォールスルーするという仕様、そしてCではループが再びswitchの内側へジャンプできることに依存しているDuffがやろうとしていたのはメモリマップトI/O(MMIO)の最適化で、今のCでもこういうやり方はしないだろう。今ではMMIOはCPU命令速度に近いほど高速ではなく、データが少し多ければDMAが使える
現代の言語ならMMIOを単なるポインタ間接参照として扱うこともないだろうが、Cではこれを維持するために型システムへ迂回策を追加し続けてきた
個人的には、Tom Duff の「Device」を継ぐものは WUFFS のiterate loops機構だと思う。ループのN段階を部分的に展開する方法を指定しつつ、主ループ本体をN回実行したのと同じ結果だがより高速になりうると約束する仕組みだ。ベクトル化で意図を把握しやすくなり、
M % N != 0のような面倒な境界ケースも人ではなくツールが正しく処理してくれる