- 2023年に入ってからCコードの書き方が大きく変わり、短い型名、ヌル終端文字列の排除、構造体の返却、単一翻訳単位でのコンパイルを中心にスタイルを再整理した
u8, i32, size, s8 のような短い別名と、const・struct の除去は、繰り返し現れる宣言の視覚的ノイズと認知負荷を減らすための選択である
- 文字列はヌル終端方式ではなく、
data と len を持つ s8 fat pointer として扱い、Win32・UTF-16 環境には c16 と s16 も併用する
- 関数設計では out パラメータより 構造体の返却 を好み、0 初期化した返り値に成功時のみ
ok を設定するパターンを使う
- マクロ、assert、Win32 宣言、インラインアセンブリに至るまで、読みやすいローカルルールを重視する一方、他のプロジェクトに貢献するときはそのプロジェクトのスタイルに従う
短い型名で意図を示す
- 基本整数・文字・ポインタサイズ型には短い別名を使う
- 例:
u8, c16, b32, i32, u32, u64, f32, f64, uptr, byte, size, usize
- こうした名前はプログラム全体で頻繁に登場するため、簡潔さは読みやすさやレビューに直接的な利点がある
_t 接尾辞はもう使っておらず、今では視覚的に散漫な要素に感じられる
- signed 型の接頭辞には
s より i を好む
- サイズ型には
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 を好む
- ヌルポインタにはリテラル
0 を使う
- 約7年間使い続けているスタイルである
- 理論的な欠陥の可能性はあるが、数十万行のコードで実際に起きた例を見たことがない
restrict は必要なときだけ使う
- out パラメータをループで使わないようにしたり、out パラメータ自体を避けたりする形でコードを構成する
inline は使わない
- すべてを一つの翻訳単位としてコンパイルするためである
- すべての構造体は
typedef する
struct キーワードをなくすとコードが読みやすくなる
- 再帰構造体では直前に forward declaration を置き、フィールドには短い名前を使う
- エントリポイントを除くすべての関数は
static で宣言する
- 単一翻訳単位でのコンパイルを前提としているためである
- 短い型名、
const の除去、struct の除去のおかげで、関数の返り値型と関数名を同じ行に無理なく置ける
- 型名を大文字で書いていた時期もあったが、最終的にはやめた
文字列はヌル終端ではなく s8
- 最も生産性の高かった変化の一つは、ヌル終端文字列を完全に退け、
data と len を持つ s8 文字列型 を使うようになったことである
s8 構造:
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 *data と size len を持つ
- マクロでリテラルに
u を付ける方式には、まだ完全には確信が持てていない
構造体の返却と初期化方式
- out パラメータより 構造体の返却 を好む
- 実質的には複数の値を返す方法だが、destructuring はない
- 例の
i32parse(s8) は、パース結果の value と状態の ok を一緒に返す
- 追加のコピーコストは実務では大きな問題ではないと考えている
- 呼び出し規約がこれを隠れた
restrict out パラメータに変換したり
- インライン化されれば返り値のオーバーヘッドは意味を失うためである
- この方式は、特別な null 返却のような in-band シグナルでエラーを示したくなる誘惑を減らす
- 関数の先頭で 0 初期化した返り値を作り、すべての
return でそれを使うパターンを好む
- エラー時はただちに 0 初期化状態のまま返す
- 成功経路では返す直前に
ok を true に設定する
- 静的データと
s8・s16 マクロを除けば、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件のコメント
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 の型に慣れている人でも、ひとつのプログラムを理解するには別の独特な体系を学ばなければならない。サイズを明示するのは妥当なので、
uintよりuint32_tを使うような考え方は理解できるこうした型は適切なヘッダーに定義されているべきで、C を長く使っていないので間違っているかもしれない
intは32ビットである16ビットターゲットでは違うが、5MB のプログラムを本当に 16ビットへ移植するつもりなのか? そうした心配にはたいてい価値がない
問題は
longだ。あるマシンでは 32ビット、別のマシンでは 64ビットなので混乱する。幸いlong longは常に 64ビットなので、longは捨てればよいchar8ビット、short16ビット、int32ビット、long long64ビットで終わりだ。C ではintのサイズについて延々と時間を無駄にしてきたC をよく使う人にとって、ここにある略称はなじみがあり、カスタム型体系としてはかなり上品だ。Rust を思い出す
stdint.hにある複数のプロジェクトがこのファイルを苦労して作り直しているのを見ると、いつも驚く
標準型を自分たちの名前に翻訳し直して使うのは、読む側には面倒だ。以前、C++ プロジェクトでコレクション、参照、複合オブジェクトに
typedefを大量に使っている理由を聞いたところ、理解しやすくなるからだと答えられた後で、その人のモニター横にtypedef チートシートが貼ってあるのを見た
dim_tのような型をよく見かけるが、用途に応じて 32ビットにも 64ビットにもなる。64ビットプラットフォームでも、ポインター圧縮構造では 32ビット整数をよく使う例えば自前でヒープを割り当て、32ビットオフセットだけを保存すれば、4GB 未満のワークロードではメモリ使用量が半分になり、キャッシュ局所性も良くなって性能が向上する
個人的な好みのために、Cで確立された慣例を捨てるのは少しやりすぎに見える
uint8_tやint32_tの代わりにu8、i32を使えば数文字は減るだろうが、他の人がコードを読むときに混乱するかもしれないヌル終端文字列の代わりにカスタム文字列型を使うのも、Cがそうした文字列を中心に作られていることを考えると、協業の難易度を上げる感じがする
windows.hをインクルードせずに Win32 APIプロトタイプ を直接書くのは、コンパイル時間は短縮できても、整備された高速道路があるのに森の小道を進むようなものに思える。かなりの部分が、誰にとっても扱いやすいCコードというより個人の好みに近く見えるu8やi32はタイプ数を節約するためではなく、読むときの 感覚的な負担 を減らすためのもの冗長さ対簡潔さの議論でいつも出てくる「キー入力数」という主張には大きな欠陥がある。簡潔さは速くタイプすることにしか利点がなく、冗長さは読むうえで常に良いという信念は間違っている
冗長さにも読解上の利点はあるが、簡潔さにも利点があり、どちらも明白な勝者ではない。異なるトレードオフにすぎない
u16のような名前はよく使われており、プログラマーを混乱させる可能性は低い本当に破綻するのは、別々の2つのプログラムがそれぞれ
u16を定義してヘッダーファイルに露出し、3つ目のプログラムがその2つのヘッダーを同時にインクルードするとき名前空間付きのライブラリ型は
libname_u32のような形になり、そのあたりまで来るとlibname_接頭辞の代わりに、単にuint32_tを使いたくなるu8やi32を見て混乱する可能性は、せいぜい理論上のもので、やや藁人形論法のように見えるいら立つことはあるかもしれないが、混乱ではないはずだ。Rich Hickeyが言ったように、読み方を学ぶ前は、あらゆるものが読みにくい
32ビットのブール値を使うのは初心者にはメモリの無駄に見えるかもしれないというが、それなら自分も初心者なのだろう
8ビットboolより悪くない場合はいくつか聞いたが、実際により良い場合は見当たらない。構造体に隣接したブール値があったり、関数のブール変数がレジスタから押し出されてスタックに置かれたりすれば、やはりメモリを無駄にする
たとえ数バイトでも、なぜわざわざ悲観化するのか分からない。より大きなサイズを使って何が得られるのか?
周期ごとの構造体の先頭に条件値があり、その後ろに512、1024、2048個のサンプル値が続く形だったが、ジュニアが領域を節約しようとして構造体をパックし、条件値を8ビット1バイトにした
その改善後のコードはIntelチップでスループットを約10倍低下させ、SPARC RISCアーキテクチャではBUS ERRORを出した
構造体ヘッダーをパックしたことでデータ配列がアラインされなくなり、Intelは黙って2つの32ビットワードを取って組み合わせる必要があり、SPARCは非アラインデータに正しく怒った
長期のファイル保存ではなく、スループットが重要なパイプライン計算なら、データを「省スペース」に合わせてパックするより、アーキテクチャのアラインメント に合わせるほうがよい場合がある
ほぼすべてのコンパイラがこれをやってくれるので、「構造体 アラインメント/パディング」を調べればよい。コンパイラがどうせ空き領域を置くなら、そのメモリを自分で使ったほうがよく、そうでなければ性能を逃すことがある
より正確には、各フィールドは自分のサイズまたはワードラインサイズで割り切れるアドレスになければならず、構造体全体も最大フィールドサイズの倍数にパディングされる必要がある。実際には普通、32ビットアラインメント を意味する
参考: http://www.catb.org/esr/structure-packing/
bool型を使えば、値がfalseである0やtrueである1でないときに サニタイザー が警告してくれる構造体の返却と出力パラメーターに関する主張には同意しない
エラーを返しうる関数を組み合わせるのがはるかに難しくなり、あちこちで型が増える。実際にはほぼすべての関数が失敗しうるので、特にメモリ不足まで扱うなら、予測可能なエラー返却スタイル のほうが重要だ
非常に難しく、得られるものもほとんどない。その時点では、プログラミングスタイルの選択よりはるかに別の問題が生じる
errnoと出力パラメーターのセマンティクスを得られただろうが、CにはそれがないそれでもCでオプション値を組み合わせるのは、いつも少し苦痛だった。例外を使わないなら、言語ごとに例外とモナドが二大選択肢のように見えるが、どちらもCには合わず、たいていのCプログラマーの哲学にも合わない
単純な一対一の呼び出しにはマクロを試せるかもしれないが、限界がある。C++がひどいとしても、C++ optional は
if(foo(x,y, out1, out2) != WHATEVER_LIBRARY_OK) { ... }より使っていて楽しいすべての関数呼び出しごとに
if (thing(...)) goto failを付けるパターンもあまり素晴らしくは見えないが、Go側は好んでいるようだあるいは
thread_local mylibrary_errnoがあり、ライブラリ内部では実際に正しい方法かもしれず、境界では列挙型の戻り値に変換すればよい「signed sizes are the way」のところで、もうここまで読めば十分だったと言いたい
signed sizeは非常に驚くべき抽象化の漏れで、災いを招くやり方だ
constには実用的な役割がなく、ミスを見つけてくれたこともない、という話も納得しにくい。人は入力バッファと出力バッファをよく取り違えるし、constはそれを即座に明らかにしてくれるすべての関数をエントリポイント以外は
staticにしておけというのも、デバッグ時に変数や関数が見つからず、作者を呪うことになりかねない構造体を返すことを好むのは、誤ってスタックポインタを返して大きなセキュリティホールを開けやすい。出力バッファを渡せば所有権セマンティクスが明確になる
この助言は主に64ビットのシステムコードを書く人にはそれなりに合うかもしれないが、32ビット組み込みの領域ではすぐに厄介なことになり得る
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_tとstruct statの違いだtypedefするな、という点には同意するSDL_netのようなものがまさにそれをやっているので嫌いだ。実際にはポインタなのに、値型のように
typedefしている意図は分かるが、かなり気持ち悪いやり方だ
この記事の多くには納得できる
Arm64向けのベアメタルOSを書き始めたところで、まだ初期段階だが似たようなことをしている。Pascal文字列を使い、型名も変えた。ただし
i8ではなくint8スタイルだ実際のソフトウェアを移植するつもりはないと早い段階で決めたので、標準Cライブラリ関数や慣例に従う必要がない。そのおかげで、より自由に実験できる
Cは、あらゆるバイトが貴重だった時代の重荷が関数名にまで残っているほど古い言語だ。そこから離れられるのは良いし、この記事の内容やいくつもの小さな名前変更は、かなりきれいな整理のように感じる
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
_Float型があるtypedef _Float32 f32;typedef _Float64 f64;https://gcc.gnu.org/onlinedocs/gcc/Floating-Types.html