3 ポイント 投稿者 GN⁺ 2024-07-01 | 1件のコメント | WhatsAppで共有
  • 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件のコメント

 
GN⁺ 2024-07-01
Hacker Newsのコメント
  • 上の例は a の値を出力するが、123で初期化されるわけではないとされていたものの、Cでは実際にそうなる可能性もある
    未初期化変数を使うことは「そのメモリにたまたま残っていた値」を読むという意味ではなく、未定義動作なので、コンパイラは好きなように扱える
    たとえばそのメモリを必ず123で初期化することもできるし、断片全体を未定義動作だとみなして命令をすべて消し、何も出力しないようにもできる。さらに後続の return や前の命令まで最適化で消せるため、未定義動作が「時間をさかのぼって」影響しているように見えることがある

    • Cで未定義動作が実際に時間をさかのぼるわけではない
      以前の命令に影響を与えることはありうるが、コードの再配置や複雑な変換は未定義動作がなくても起こることだ
    • Cで未定義動作になるのは、未初期化の自動記憶域期間オブジェクトにアクセスしたときだけ
      静的オブジェクトは常に初期化されるので、この状況は起きない
      残るのは 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 でも同じように動くのではと思うが、そうするとマクロの目的が少し薄れる
    • これがどう動くのか非常に分かりにくいので、Duff's device風の switch 乱用より、同じ制御フローを goto で書いたほうがよさそう
    • この方法は case ラベルの本体が1行だけか、中括弧で囲まれているときにしか機能しない
      昔、「次の case ラベルの最初の1行だけを飛ばして、それ以降はそのままフォールスルーする」という意味でこの構造を使ったことがある
      case ラベルを文の区切りではなく単なるラベルだと見れば、すべて筋が通る
  • Cでコルーチンを実装するのにこの手法を使える: https://stackoverflow.com/questions/24202890/switch-based-co...

  • case 1 ... 10: が有効なCだと知らなかったのが不思議なくらい
    何年もCを使ってきたのに、これがどの標準に由来するのか気になる

    • 最近標準化されたのでなければ、有効なCではなくGNU拡張
    • GNU C拡張のようだ: https://gcc.gnu.org/onlinedocs/gcc/Case-Ranges.html
      拡張の来歴は見つけられず、標準Cにはないと理解している。clang については確信がない
  • 昔、遊びで10から1までカウントダウンする妙なCコードを書いたことがある
    C、Python、シェル版のどれも && と再帰呼び出しを使ってワンライナーにできる

    • 細かい指摘だが、sys.stdout.write(f"{n}\n")print(n) に置き換えられる
      今のコードは f-string を除けば、print が文だった Python 2 スタイルのように見える。Python 3では print は通常の関数で、None を返して偽と評価されるので、最初の andor に変える必要がある
  • もう一つ驚くのは、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の重要な部分なのではないかと思う

    • Duff's device は、K&R C構文で 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 のような面倒な境界ケースも人ではなくツールが正しく処理してくれる