4 ポイント 投稿者 GN⁺ 2023-10-10 | 1件のコメント | WhatsAppで共有
  • 2023年に入ってからCコードの書き方が大きく変わり、短い型名、ヌル終端文字列の排除、構造体の返却、単一翻訳単位でのコンパイルを中心にスタイルを再整理した
  • u8, i32, size, s8 のような短い別名と、conststruct の除去は、繰り返し現れる宣言の視覚的ノイズと認知負荷を減らすための選択である
  • 文字列はヌル終端方式ではなく、datalen を持つ s8 fat pointer として扱い、Win32・UTF-16 環境には c16s16 も併用する
  • 関数設計では out パラメータより 構造体の返却 を好み、0 初期化した返り値に成功時のみ ok を設定するパターンを使う
  • マクロ、assert、Win32 宣言、インラインアセンブリに至るまで、読みやすいローカルルールを重視する一方、他のプロジェクトに貢献するときはそのプロジェクトのスタイルに従う

短い型名で意図を示す

  • 基本整数・文字・ポインタサイズ型には短い別名を使う
    • 例: u8, c16, b32, i32, u32, u64, f32, f64, uptr, byte, size, usize
  • こうした名前はプログラム全体で頻繁に登場するため、簡潔さは読みやすさやレビューに直接的な利点がある
  • _t 接尾辞はもう使っておらず、今では視覚的に散漫な要素に感じられる
  • signed 型の接頭辞には s より i を好む
    • s は文字列型名に使うために残している
  • サイズ型には isize ではなく size を使う
    • signed size をより重要なデフォルトと見ているためである
    • usize は外部インターフェースとやり取りするときに主に使う限定的な用途である
  • b32 は「32ビット boolean」という意図を示す
    • _Bool の代わりに自然なワードサイズを使う
    • 実際にはレジスタ上にあるか、構造体のパディングに入ることが多いと見ている
    • メモリが本当に重要なときは boolean を flags 変数に圧縮する
  • c16 は Win32 で必要な UTF-16 文字用の型である
    • char16_t ベースなら GDB のようなデバッガが文字データとして表示しやすくなる
    • Win32 の公式な型名は wchar_t だが、UTF-16 を明示するほうを好む
  • u8 は octet や主に UTF-8 データに使い、byte は生メモリや特殊な aliasing 型 として区別する
  • 固定幅型をサポートしないシステムへの心配は実用性が低いと見ている
    • int_fast32_t のような長い型名も不要な無駄だと考える
  • コード断片だけを単独で見せるときは、これらの別名を単独では使わない
    • 読者が文脈を理解するには typedef も一緒に必要だからである

マクロと assert のルール

  • 関数形式マクロは小文字を使う
    • 例: countof(a), lengthof(s), new(a, t, n)
  • 定数には今でも ALL_CAPS を好むが、関数形式マクロは小文字のほうが読みやすいと考える
  • 関数形式マクロは通常のマクロより名前空間の問題が少ない
    • new() マクロと new 変数・フィールドを同時に置ける
    • 関数呼び出しの形でなければマクロ展開されないためである
  • GCC と Clang 向けの assert マクロは while (!(c)) __builtin_unreachable() という形を使う
  • この assert 方式ではビルド設定を別に分ける必要がない
    • デバッグ用とリリース用の定義を別々に持つ必要がない
    • 動作するかどうかは Undefined Behavior Sanitizer、すなわち UBSan の有無で制御される
    • libubsan がファイル名と行番号を含む診断出力を提供する
    • リリースビルドでは実用的な最適化ヒントになる
  • リリースビルドで assertion を有効にするには、-fsanitize-trap で UBSan を trap モードにし、少なくとも -fsanitize=unreachable を有効にする
  • -funreachable-traps でも理論上は可能だが、執筆時点では最近のいくつかの GCC リリースで壊れている

宣言で減らすもの

  • パラメータに const を使わない
    • 最適化において実用的な役割がないと考えている
    • ミスを検出した、あるいは検出できたはずの事例を思い出せなかったとしている
    • 良いパラメータ名があれば、プロトタイプの文書化としては十分だと考える
  • const の除去は認知負荷と視覚的ノイズを減らし、生産性を高めた変化である
  • 小さな例外として、静的テーブルをコード近くの読み取り専用メモリに置くためのヒントとしては、引き続き const を好む
    • 必要ならキャストで const を外す
  • ヌルポインタにはリテラル 0 を使う
    • 約7年間使い続けているスタイルである
    • 理論的な欠陥の可能性はあるが、数十万行のコードで実際に起きた例を見たことがない
  • restrict は必要なときだけ使う
    • out パラメータをループで使わないようにしたり、out パラメータ自体を避けたりする形でコードを構成する
  • inline は使わない
    • すべてを一つの翻訳単位としてコンパイルするためである
  • すべての構造体は typedef する
    • struct キーワードをなくすとコードが読みやすくなる
    • 再帰構造体では直前に forward declaration を置き、フィールドには短い名前を使う
  • エントリポイントを除くすべての関数は static で宣言する
    • 単一翻訳単位でのコンパイルを前提としているためである
  • 短い型名、const の除去、struct の除去のおかげで、関数の返り値型と関数名を同じ行に無理なく置ける
  • 型名を大文字で書いていた時期もあったが、最終的にはやめた

文字列はヌル終端ではなく s8

  • 最も生産性の高かった変化の一つは、ヌル終端文字列を完全に退け、datalen を持つ s8 文字列型 を使うようになったことである
  • s8 構造:
    • u8 *data
    • size len
  • s8(s) マクロは C 文字列リテラルを s8 文字列で包む
  • s8 は fat pointer のように値で渡し、値で返す
  • s8 は関数接頭辞としても使いやすい
    • str 系の名前は予約されているためである
    • 例: s8span, s8equals, s8compare, s8hash, s8trim, s8clone
  • リテラル比較には s8equals(tagname, s8("body")) のような形を使う
  • flexible array member でサイズと配列を1回の allocation に束ねる方式も試したが、柔軟性の不足が利点を上回ると考えている
  • 単純なプログラムでは文字列型は不要だと思ったこともあったが、たいていは誤った判断だったと見ている
  • UTF-16 対応型として s16 も使う
    • c16 *datasize len を持つ
    • マクロでリテラルに u を付ける方式には、まだ完全には確信が持てていない

構造体の返却と初期化方式

  • out パラメータより 構造体の返却 を好む
    • 実質的には複数の値を返す方法だが、destructuring はない
  • 例の i32parse(s8) は、パース結果の value と状態の ok を一緒に返す
  • 追加のコピーコストは実務では大きな問題ではないと考えている
    • 呼び出し規約がこれを隠れた restrict out パラメータに変換したり
    • インライン化されれば返り値のオーバーヘッドは意味を失うためである
  • この方式は、特別な null 返却のような in-band シグナルでエラーを示したくなる誘惑を減らす
  • 関数の先頭で 0 初期化した返り値を作り、すべての return でそれを使うパターンを好む
    • エラー時はただちに 0 初期化状態のまま返す
    • 成功経路では返す直前に ok を true に設定する
  • 静的データと s8s16 マクロを除けば、initializer の使用も減らしている
    • designated initializer も避け、代入文で初期化する
  • 代入文による初期化は読みやすく、各代入の間に sequence point があるため、明示的な順序を与える
  • 乱数生成関数のように呼び出し順が結果に影響しうる初期化では、取りうる値のケースを考えなくて済む

Win32 宣言とインラインアセンブリ

  • __attribute__ より __attribute を好む
    • 後ろ側の __ 接尾辞は大げさで不要だと考える
  • Win32 のシステムプログラミングでは windows.h をインクルードせず、必要なプロトタイプを自分で書く
    • 普通は必要な宣言や定義の数がそれほど多くないためである
    • ビルド時間を短縮し、名前空間もあまり汚さない
    • DWORD, BOOL, ULONG_PTR より u32, b32, uptr のようなカスタム型のほうがすっきり噛み合う
  • Win32 宣言の例では W32(r) __declspec(dllimport) r __stdcall マクロを使う
    • ExitProcess, GetStdHandle, VirtualAlloc, WriteConsoleA, WriteConsoleW のような関数を直接宣言する
  • インラインアセンブリでは外側の括弧を波括弧のように扱う
    • if のように開き括弧の前に空白を置く
    • 各 constraint 行はコロンで始める
  • 述べたスタイルを小さなプログラムで確認できる例として wordhist.c がある
  • もう少し大きな例として、ミニプログラミング言語実装の asmint.c がある

1件のコメント

 
GN⁺ 2023-10-10
Hacker Newsの意見
  • #define sizeof(x) (size)sizeof(x) には外側の括弧がなくてもよいと思ったようだが、ごく些細な例外がある
    キャストは乗算より優先順位が高いので、sizeof(x) * 3(size)sizeof(x) * 3 として安全に動作する
    ただし (size)sizeof(x)[y] では、配列インデックス指定がキャストより先に適用され、((size)sizeof(x))[y] ではなく (size)(sizeof(x)[y]) になる
    実際のコードで sizeof(x) にインデックス指定することはないだろうが、C は integer[pointer]pointer[integer] と同じ意味として許容するため、このマクロは括弧不足のせいでコンパイルは通るのに誤動作する可能性がある
    より本質的には、signed size のほうがよいという主張にも同意しにくい。筆者は unsigned size が欠陥の源だと言っているが、提示されたコードにも count が負数ならメモリを壊すバグがある
    符号なし整数では負の個数は表現されず、オーバーフローしても非常に大きな正数になって既存のチェックに引っかかる。個人的には符号なし整数を使いつつ、可能な限り範囲チェック付きラッパーでオーバーフロー時に停止させるほうを好む

    • _Bool のセマンティクスはむしろ気に入っている
      if (flags & FLAG_ALLOCATED) でうまく動く式を、_Bool need_free = flags & FLAG_ALLOCATED; のようにブール変数へ切り出せるからだ
      flags & FLAG_ALLOCATED は設定時に 1 ではなく任意の非ゼロ値になりうるが、_Bool はそれを 1 に正規化する。int で受けると if (need_free) は通るが、if (need_free == true) は失敗する可能性がある
      欠点もある。リファクタリング中に _Bool への暗黙変換が有用な仕事をしていた事実を見落とすと、if ((flags & FLAG_ALLOCATED) == true) のような誤ったコードになりうる
      また、ディスクから構造体を読んだり任意のバイトを詰め込んだりする際に、_Bool フィールドが 0 または 1 でないと未定義動作のリスクがある
    • 実は (size)(sizeof(x)[y]) も多くの人には意外だろうが、(size)(sizeof ((x)[y])) と同じである
      sizeof は関数ではなく単項演算子であり、インデックス指定と関数呼び出しは sizeof より優先順位が高い。そのため sizeof の後に空白を置き、必要なときだけオペランドに括弧を付ける書き方を好む
      https://en.cppreference.com/w/c/language/operator_precedence
      マクロを正しく書くなら #define sizeof(x) ((size)(sizeof (x))) となる
    • よい指摘だ。教訓は、マクロ定義が単一トークンにだけ展開されるのでないなら、常に括弧で囲むべきだということ。C の優先順位規則は本当に複雑だ
  • 独自の型を定義するのは一歩行き過ぎのように思う
    すでに C の型に慣れている人でも、ひとつのプログラムを理解するには別の独特な体系を学ばなければならない。サイズを明示するのは妥当なので、uint より uint32_t を使うような考え方は理解できる
    こうした型は適切なヘッダーに定義されているべきで、C を長く使っていないので間違っているかもしれない

    • 現実的には C の int32ビットである
      16ビットターゲットでは違うが、5MB のプログラムを本当に 16ビットへ移植するつもりなのか? そうした心配にはたいてい価値がない
      問題は long だ。あるマシンでは 32ビット、別のマシンでは 64ビットなので混乱する。幸い long long は常に 64ビットなので、long は捨てればよい
      char 8ビット、short 16ビット、int 32ビット、long long 64ビットで終わりだ。C では int のサイズについて延々と時間を無駄にしてきた
    • 筆者は個人的なコーディングスタイルだと限定してはいる。正直、標準型は冗長すぎるので、この人が示した簡潔な一覧が昔採用されていればよかったのにと思う
    • 少し冗談めかして言えば、プログラミングのかなりの部分は他人の型システムを扱うことだ
      C をよく使う人にとって、ここにある略称はなじみがあり、カスタム型体系としてはかなり上品だ。Rust を思い出す
    • そうした型は stdint.h にある
      複数のプロジェクトがこのファイルを苦労して作り直しているのを見ると、いつも驚く
      標準型を自分たちの名前に翻訳し直して使うのは、読む側には面倒だ。以前、C++ プロジェクトでコレクション、参照、複合オブジェクトに typedef を大量に使っている理由を聞いたところ、理解しやすくなるからだと答えられた
      後で、その人のモニター横にtypedef チートシートが貼ってあるのを見た
    • 独自の整数型定義は、リソース制約のあるプラットフォームでは意味がある
      dim_t のような型をよく見かけるが、用途に応じて 32ビットにも 64ビットにもなる。64ビットプラットフォームでも、ポインター圧縮構造では 32ビット整数をよく使う
      例えば自前でヒープを割り当て、32ビットオフセットだけを保存すれば、4GB 未満のワークロードではメモリ使用量が半分になり、キャッシュ局所性も良くなって性能が向上する
  • 個人的な好みのために、Cで確立された慣例を捨てるのは少しやりすぎに見える
    uint8_tint32_t の代わりに u8i32 を使えば数文字は減るだろうが、他の人がコードを読むときに混乱するかもしれない
    ヌル終端文字列の代わりにカスタム文字列型を使うのも、Cがそうした文字列を中心に作られていることを考えると、協業の難易度を上げる感じがする
    windows.h をインクルードせずに Win32 APIプロトタイプ を直接書くのは、コンパイル時間は短縮できても、整備された高速道路があるのに森の小道を進むようなものに思える。かなりの部分が、誰にとっても扱いやすいCコードというより個人の好みに近く見える

    • u8i32 はタイプ数を節約するためではなく、読むときの 感覚的な負担 を減らすためのもの
      冗長さ対簡潔さの議論でいつも出てくる「キー入力数」という主張には大きな欠陥がある。簡潔さは速くタイプすることにしか利点がなく、冗長さは読むうえで常に良いという信念は間違っている
      冗長さにも読解上の利点はあるが、簡潔さにも利点があり、どちらも明白な勝者ではない。異なるトレードオフにすぎない
    • u16 のような名前はよく使われており、プログラマーを混乱させる可能性は低い
      本当に破綻するのは、別々の2つのプログラムがそれぞれ u16 を定義してヘッダーファイルに露出し、3つ目のプログラムがその2つのヘッダーを同時にインクルードするとき
      名前空間付きのライブラリ型は libname_u32 のような形になり、そのあたりまで来ると libname_ 接頭辞の代わりに、単に uint32_t を使いたくなる
    • 有能なCプログラマーが u8i32 を見て混乱する可能性は、せいぜい理論上のもので、やや藁人形論法のように見える
      いら立つことはあるかもしれないが、混乱ではないはずだ。Rich Hickeyが言ったように、読み方を学ぶ前は、あらゆるものが読みにくい
  • 32ビットのブール値を使うのは初心者にはメモリの無駄に見えるかもしれないというが、それなら自分も初心者なのだろう
    8ビットboolより悪くない場合はいくつか聞いたが、実際により良い場合は見当たらない。構造体に隣接したブール値があったり、関数のブール変数がレジスタから押し出されてスタックに置かれたりすれば、やはりメモリを無駄にする
    たとえ数バイトでも、なぜわざわざ悲観化するのか分からない。より大きなサイズを使って何が得られるのか?

    • アーキテクチャとCPUに完全に依存するが、過去の経験で明確な例は 数値処理の作業 だった
      周期ごとの構造体の先頭に条件値があり、その後ろに512、1024、2048個のサンプル値が続く形だったが、ジュニアが領域を節約しようとして構造体をパックし、条件値を8ビット1バイトにした
      その改善後のコードはIntelチップでスループットを約10倍低下させ、SPARC RISCアーキテクチャではBUS ERRORを出した
      構造体ヘッダーをパックしたことでデータ配列がアラインされなくなり、Intelは黙って2つの32ビットワードを取って組み合わせる必要があり、SPARCは非アラインデータに正しく怒った
      長期のファイル保存ではなく、スループットが重要なパイプライン計算なら、データを「省スペース」に合わせてパックするより、アーキテクチャのアラインメント に合わせるほうがよい場合がある
    • たいてい簡単な最適化は、構造体フィールドを32ビット境界に合わせてパディングすること
      ほぼすべてのコンパイラがこれをやってくれるので、「構造体 アラインメント/パディング」を調べればよい。コンパイラがどうせ空き領域を置くなら、そのメモリを自分で使ったほうがよく、そうでなければ性能を逃すことがある
      より正確には、各フィールドは自分のサイズまたはワードラインサイズで割り切れるアドレスになければならず、構造体全体も最大フィールドサイズの倍数にパディングされる必要がある。実際には普通、32ビットアラインメント を意味する
      参考: http://www.catb.org/esr/structure-packing/
    • 実際の bool 型を使えば、値が false である0や true である1でないときに サニタイザー が警告してくれる
    • コンピューターアーキテクチャは、その多くについて 32ビット以上のアラインされたアクセス に最適化されている。得られるものは通常、ただし常にではないが、性能である
    • ブール変数がスタックに押し出され、その3バイトが重要になる関数の例が何なのか気になる
  • 構造体の返却と出力パラメーターに関する主張には同意しない
    エラーを返しうる関数を組み合わせるのがはるかに難しくなり、あちこちで型が増える。実際にはほぼすべての関数が失敗しうるので、特にメモリ不足まで扱うなら、予測可能なエラー返却スタイル のほうが重要だ

    • メモリ不足の処理はほとんど誰もやっていない
      非常に難しく、得られるものもほとんどない。その時点では、プログラミングスタイルの選択よりはるかに別の問題が生じる
    • 意味のある構造体のアンパックができれば、通常の errno と出力パラメーターのセマンティクスを得られただろうが、Cにはそれがない
      それでもCでオプション値を組み合わせるのは、いつも少し苦痛だった。例外を使わないなら、言語ごとに例外とモナドが二大選択肢のように見えるが、どちらもCには合わず、たいていのCプログラマーの哲学にも合わない
      単純な一対一の呼び出しにはマクロを試せるかもしれないが、限界がある。C++がひどいとしても、C++ optionalif(foo(x,y, out1, out2) != WHATEVER_LIBRARY_OK) { ... } より使っていて楽しい
    • optionやsum typeを返すのが正しいが、Cでは書くのが本当に面倒
      すべての関数呼び出しごとに if (thing(...)) goto fail を付けるパターンもあまり素晴らしくは見えないが、Go側は好んでいるようだ
      あるいは thread_local mylibrary_errno があり、ライブラリ内部では実際に正しい方法かもしれず、境界では列挙型の戻り値に変換すればよい
  • 「signed sizes are the way」のところで、もうここまで読めば十分だったと言いたい
    signed sizeは非常に驚くべき抽象化の漏れで、災いを招くやり方だ
    constには実用的な役割がなく、ミスを見つけてくれたこともない、という話も納得しにくい。人は入力バッファと出力バッファをよく取り違えるし、constはそれを即座に明らかにしてくれる
    すべての関数をエントリポイント以外はstaticにしておけというのも、デバッグ時に変数や関数が見つからず、作者を呪うことになりかねない
    構造体を返すことを好むのは、誤ってスタックポインタを返して大きなセキュリティホールを開けやすい。出力バッファを渡せば所有権セマンティクスが明確になる
    この助言は主に64ビットのシステムコードを書く人にはそれなりに合うかもしれないが、32ビット組み込みの領域ではすぐに厄介なことになり得る

    • Bjarne Stroustrupはsigned sizeを擁護する詳しいメモを書いている
      https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p14...
    • constを嫌う人たちは決して満足しないので、必要な場所にconstを書き、必要なだけ伝播させて、その不満は無視すればいい
      彼らが外したら、また入れ直せばいい。いつも先に折れるのは向こうだ。25年こうしてきたし、まだ自分はここにいる
      staticはツールによって変わり得る。15年ほど前にデフォルトでstatic、全面的にsize_tを使う方針に移ったが、まだ問題には遭遇していない
  • 自分の経験は別の方向に進んだという点が興味深い
    https://dlang.org/blog/2023/10/02/crafting-self-evident-code...
    記事はDを中心に書かれているが、原則はCにも適用できる

    • 面白く読んだ
      条件式をdoX()doZ()の内部へ移す部分が興味深かった。常に正しいかは分からず、抽象化をどこに置くか、コードに対するメンタルモデル次第だ
      例えばdeleteRecords();if let x = deadRecords() deleteRecords(x);より良いとは言えない。後者のほうがごちゃついて見えるが、前面で削除ではなく枝刈りであることを示す価値がある
      関数をpruneDeadProjects()のように賢く変えるなら問題ないが、単に条件を関数の中へ移すだけだと、文脈を危うくし、漏れのある抽象化になり得る
  • すべての構造体にtypedefを使うのは簡潔さに役立つので賛成だ
    typedefはたっぷり使ってよいと思う。ただし対象そのものだけをtypedefし、ポインタはしないほうがよい。ポインタが必要ならいつでも(type *)を書けばいい
    特に関数ポインタについては、関数ポインタではなく関数そのものをtypedefすべきだ。そうすれば関数宣言にもそのtypedefを使って引数型の検査を受けられ、関数シグネチャを変えるときに宣言を全部直す必要がない
    ほとんどのCコードベースはこれを間違えて関数ポインタをtypedefしており、相変わらずそのポインタ定義に合わせた関数宣言を手で書かなければならない
    構造体を戻り値の型として使うやり方には、まだ説得されていない。数値のエラーコードを戻り値にして、それ以外の戻り値は出力引数で受け取るほうを好む

    • 不透明構造体にはtypedefを使って、すべてのフィールドが非公開のクラスをまね、単純なデータ構造にはstructを使うほうが好みだ
      クラスは関数でだけアクセスし、構造体は直接アクセスできるべきだ
      これはおおむねC/POSIX標準の慣例に近い。例えばpthread_tstruct statの違いだ
    • ポインタそのものをtypedefするな、という点には同意する
      SDL_netのようなものがまさにそれをやっているので嫌いだ。実際にはポインタなのに、値型のようにtypedefしている
      意図は分かるが、かなり気持ち悪いやり方だ
  • この記事の多くには納得できる
    Arm64向けのベアメタルOSを書き始めたところで、まだ初期段階だが似たようなことをしている。Pascal文字列を使い、型名も変えた。ただしi8ではなくint8スタイルだ
    実際のソフトウェアを移植するつもりはないと早い段階で決めたので、標準Cライブラリ関数や慣例に従う必要がない。そのおかげで、より自由に実験できる
    Cは、あらゆるバイトが貴重だった時代の重荷が関数名にまで残っているほど古い言語だ。そこから離れられるのは良いし、この記事の内容やいくつもの小さな名前変更は、かなりきれいな整理のように感じる

    • 標準Cライブラリ関数や慣例に従わなくていいというのは、単に趣味で作っているもので、GNUのような大きく専門的なものにはならないという意味なのか?
    • シンボルでもバイトを節約するというのは考えたことがなかった
  • typedef float f32;typedef double f64; は、float が32ビットで double が64ビットだと仮定する危険な足場のように見える
    OpenCV は float16_t を定義しており、CUDA は半精度浮動小数点を実装していて、マイクロコントローラはそれぞれ独自に実装できる
    C++23 は固定幅浮動小数点型を導入するが、C でこれを強制する方法は分からない。コンパイル時にデータ損失がないか確認するマクロを用意するほうがよさそう
    全体的には他の人たちが言うように、簡潔ではなくても可読性のために一部はデフォルトのままにしておくほうがよいかもしれない
    [0] https://docs.opencv.org/4.x/df/dc9/classcv_1_1float16__t.htm...
    [1] https://docs.nvidia.com/cuda/cuda-math-api/group__CUDA__MATH...
    [2] https://en.cppreference.com/w/cpp/types/floating-point