- C23まで標準Cは改善が続いてきたが、Walter Brightは定数式評価、前方参照、ヘッダー依存といった古くからの不便さが依然として開発フローを妨げていると見る
- Dコンパイラに組み込まれたCコンパイラ ImportC は、新しい実装という利点を生かし、
constant-expression の位置で条件を満たす関数をコンパイル時に実行できる
- CTFE が可能になれば、
_Static_assert(sum(3, 4) == 7,...) のようなユニットテストを別の実行ファイルなしでコンパイルのたびに実行できる
- 標準Cの宣言順序の制約は前方宣言の繰り返しや逆順のコード配置を生むが、ImportCはグローバル宣言順序にあまり縛られない方式を採用している
__import dex; のように .c ファイルの宣言を直接取り込めば、別個の .h ファイルを減らせ、ヘッダーと実装の不一致によって生じるデバッグ負担も軽くなる
標準Cに残っている不便さ
- 標準CはC23まで定期的に改善されてきたが、Walter Brightはなお修正されていない点が残っていると見る
- DlangコミュニティはDプログラミング言語コンパイラの中にCコンパイラを組み込み、このコンパイラをImportCと呼んでいる
- ImportCは最初から新規に作られており、現代的なコンパイラ技術でCの不足している部分に対処する余地があった
- 記事で扱う項目は4つである
constant-expression の評価
- コンパイル時点のユニットテスト
- 宣言の前方参照
- 宣言の取り込み
コンパイル時の関数実行と定数式評価
- Cは単純な式を定数畳み込み(constant folding) によってコンパイル時に計算できるが、標準Cでは関数をコンパイル時に実行することはできない
- 例のコード
enum E { A = 3, B = 4, C = sum(5, 6) }; をgccでコンパイルすると、C の列挙子の値が整数定数ではないというエラーになる
- ImportCは同じコードをコンパイルできる
- Cの文法で
constant-expression が現れるすべての場所で、コンパイラが関数をコンパイル時に実行できるべきだという方向性である
- ただし、その関数はI/O、変更可能なグローバル変数へのアクセス、システムコールのような処理を行ってはならない
コンパイルのたびに実行されるユニットテスト
- コンパイル時関数評価であるCTFEが可能になれば、ユニットテストのやり方も変わり得る
- Cコードでユニットテストが少ない理由は、別のビルドターゲットと実行ファイルを作って実行しなければならない煩雑さにある
- 例のコード
_Static_assert(sum(3, 4) == 7, "test #1"); をgccでコンパイルすると、静的アサーション内の式が定数ではないというエラーが発生する
- ImportCはこのコードをコンパイルできる
- この方式は、コンパイル時に実行可能な関数のユニットテストを簡単にする
- 別ビルドが不要
- 追加作業が不要
- コードをコンパイルするたびにテストが実行される
- ImportCのテストスイートでもこの方式が広く使われている
前方参照が生む宣言の重複
- 標準Cではコンパイラが字句上それ以前に出た宣言しか知らないため、前方参照は許されない
- 例のコードで
floo() が後で定義される dex() を先に呼び出すと、gccは dex の暗黙の宣言と実際の定義の型が衝突するとしてエラーを出す
floo と dex の順序を入れ替えると、同じコードは正常にコンパイルされる
- ImportCはグローバル宣言がどの順序で現れても受け入れられる
- 前方参照ができないと、各前方定義ごとに別の宣言を追加しなければならない
- 先に
char dex(char *s, int i); のような宣言を書き
- その後に関数定義をもう一度書く必要がある
- これは不要な繰り返し作業であり、プログラマにleaf関数は上、グローバルインターフェース関数は下に置くという逆順配置を強いる
- この配置は新聞記事を下から上に読むようなもので、理にかなっていないと批判している
ヘッダーファイルなしで宣言を取り込む
- Cで外部モジュールを使うには、通常
.h ファイルに宣言を作り、.c ファイル側でそれをincludeする必要がある
- 例の構成は次のとおり
floo.c は dex.h をincludeして dex() を呼び出す
dex.h は char dex(char *s, int i); を宣言する
dex.c は dex.h をincludeして dex() を定義する
- 外部モジュールごとに
.h ファイルを作ることは繰り返し作業を増やす
.h ファイルが .c ファイルと正確に一致していないと、何が間違っているのかを突き止めるのに多くの時間がかかることがある
- 提案されている方式は
dex.c を直接取り込むことだ
floo.c では __import dex; を使う
dex.c には char dexx(char *s, int i) { return s[i]; } のような定義だけを置く
- この方式なら
.h ファイルをまったく書く必要がない
- ImportCはこの方式もサポートしている
関連文書
1件のコメント
Hacker Newsの意見
C以外の言語を使うときに最も恋しくなるものの一つが ヘッダーファイル
公開/非公開、インターフェース/実装が非常に明確に分かれている点が、Cコードでは特に気に入っている。ライブラリの
.hファイルだけをざっと見れば使い方が分かるのが良く、通常.hには利用者向けドキュメントが入り、.cと重複しない。ドキュメントを.cに入れることもできるが、使う側としてはインターフェースを読む快適さがかなり落ちるたとえば Rust では、ライブラリのインターフェースと使い方を見るには
cargo doc --openで済む。公開 API 全体が検索可能な形で自動生成され、ヘッダーとソースの間でコードを重複させたり手動で管理したりする必要がない慣習を守るときにだけ正しく機能し、Ada のようにインターフェースと実装仕様がきちんと設計されていて繰り返しパースが不要な言語と比べると見劣りする。Cを使うのは好きだが、この部分はもっと良く設計されるべきだった
Cのヘッダーファイルはテキストをそのまま差し込む方式なので、「何となく動いているように見える」程度のものだ。一方 Ada にはパッケージとパッケージ本体の概念があり、パッケージがヘッダーファイルに、パッケージ本体が実装に相当していた。昔 Ada を使っていたときは、パッケージ本体の実装がまだできていなくても、全員がパッケージを基準にコンパイルできたので、実装前の段階でもインターフェースをすり合わせられた。別の方向では、Python の
importはファイルシステムに自然に対応していて、Cの include の意味論を扱わずに済むので、「ヘッダーファイル」の役割として気に入っているヘッダーファイルがあると、インターフェースを実装と 別物 として考えるようになる。だから、このやり方に慣れた人は、インターフェースがツールで生成されるという発想に居心地の悪さを覚える。インターフェースは実装の副産物ではなく、意図的に別途設計するものであり、人によっては実装より重要ですらある。逆に、自動生成ドキュメントに慣れた人は、実装ソースという単一の真実の源泉からインターフェースが生成されないことに違和感を覚える。別個のインターフェースファイルがある言語とない言語を長く使っていると、それぞれの陣営に固まり、反対側の考え方をしていた感覚を忘れてしまう
Modula-2、Modula-3、Ada、Standard ML、Caml Light、OCaml、F#、Dのように別個に持つこともできるし、Object Pascal、D、Haskell、Java、C#、F#、Swift、Go、Rustのようにテキストまたはグラフィカルツールから生成することもできる。どれもより強い型、より高速なコンパイル、まともな名前空間を備えている。Rust と Swift のツールチェーンにはまだ改善の余地があるものの、Cのツールは Bell Labs の外で起きていたことと比べると、常に原始的だった。AT&T が商業的に活用できていたなら歴史は違っていたかもしれないが、結果として、よく熟したオレンジの代わりに無料のレモンを受け取ったようなものだ。それでも C向けの TypeScript のようなものは作られており、最近では境界検査付きコレクション型や、まともなモジュールもサポートしている
この分野では筆者の方がはるかに豊富な知識と経験を持っているので、次の問題をどう解くのか気になる
定数式評価 は翻訳単位の中だけで行うなら単純になるが、コードの繰り返しなしでできることが大きく制限される。コンパイル時ユニットテスト はテストをマクロで表現できるならある程度は可能で、前の項目が加わればさらにやりやすくなる。宣言の前方参照 は反発を招くかもしれない。コンパイラが1パスから2パスに変わり、性能への影響が出るからだ。巨大なコードベースをコンパイルし、翻訳単位の並列化まで行っている人々には受け入れがたいかもしれない。宣言の取り込み は互換性を壊す変更だ。Cでは、変数を定義した後で
.cファイルを import し、変数を変えてから同じ.cを再度 import する、という形でテンプレートのようなものを実装したことがある。SQLite C Amalgamation の前にいくつか定義を置き、内部関数を公開するために関数を追加したこともあるが、こうした用例はすべて壊れそうだ。こうした問題への解決策があるのか知りたいCでは
#includeでできるし、Dでは必要なコードを含むモジュールを import して処理する。テストをマクロで表現するやり方は、関数をテストしたい場合には適していない。例は理解を助けるために単純化したもので、実際の利用ははるかに複雑になりうる。性能面では、DはCコンパイラより速くコンパイルできるが、その主な理由はCプリプロセッサが複数パスを必要とする化石のような仕組みであり、Dは#includeの代わりに import を使うので.hファイルを繰り返しコンパイルしなくて済むからだ。Dの戦略はパースと意味解析を分離することで、やや遅くなる可能性はあっても、重複した宣言を再コンパイルして一つに畳み込むコストがない。コンパイル時関数実行は多用するとボトルネックになりうるが、軽く使う分には性能は悪くない。Cでテンプレートをハックして実装しているなら、すでに言語の限界を超えており、より強力な言語が必要だ。Dは最高水準のメタプログラミングを備えており、他のテンプレート言語もしばしばDの後を追っている依存グラフがトポロジカルソート順に並んでいると、もはや信じられなくなる
現代のCコンパイラが実際に今でも単一パスなのかどうかも気になる
記事中の定数式評価の例はかなり単純だが、より複雑なケースではコンパイラの速度やメモリ使用量が大きく悪化し、これを活用するには仮想マシンが必要になりそうだと思う。
そのため、標準に入れるには「複雑すぎる」と見なされた可能性は理解できる。C++20で定義された奇妙な混合物の代わりに、C++やCが宣言のインポートの方向に進んでほしかった。たとえば
#import "string.c" as strのようにモジュールをあるシンボルでインポートし、ファイル内のすべての非静的シンボルにstr.trim(" Hello World ");のようにアクセスできる方式だ。別件だが、__import dex;のようにファイルパスが明示されないのは気に入らない。この場合、dex.dをインポートするのかdex.cをインポートするのかわからない多用すれば当然コンパイル時間とメモリを消費する。仮想マシンについて言えば、定数畳み込み自体がすでに仮想マシンであり、そこに関数呼び出し処理を拡張することになる。Cの意味論は単純なので、そこまで悪くない。提案された import 方式は D の import がやっていることとほぼ同じだ: https://dlang.org/spec/module.html#import-declaration。
dex.dかdex.cかという問題は実際に起こり、その答えは import パスの設定だ。Cコンパイラの include パスに似ているCにもすでに定数式がある。より大きな障害は、コンパイラが関数のソースコードにアクセスする必要がある点で、たぶん同じ翻訳単位内の関数に制限される可能性が高い。そして、複数のコンパイラ代表が集まる委員会が、そのような定数評価の意味論に合意しなければならないという人間的な問題のほうが、はるかに大きいかもしれない
constexpr機能とほぼ1対1に見える。ほとんどすべてのCコンパイラはすでにC++コンパイラでもあるので、Cで
constexpr関数と評価をサポートすることが、そこまで悪いことなのか疑問だCコードには常に単体テストを書く。
良いビルドシステムを使い、少しのボイラープレートを受け入れれば難しくない。
npyライブラリのテストでは、test_load_uint8()でnpy_load("tests/npy/uint8.npy")を呼び出し、次元・サイズ・型をassertしたあとnpy_freeし、mainでPRINT_RUN(test_load_uint8);のように実行する。プリプロセッサでテストの一部を生成することもできるだろうが、シンプルに保つほうを好むImportC コンパイラの単体テスト例は、
_Static_assert(sizeof(struct S22079){1,2,3} == sizeof(int)*3, "ok");や_Static_assert(sizeof(struct S22079){1,2,3}.a == sizeof(int), "ok");のような形だ。意味論をコンパイル時に検査するので、リンクして実行する必要がない。テストが多いほどこの方式はかなり速くなり、テストスイートが速く回るほど生産性が上がる以前は派手な単体テストフレームワークを深掘りしたこともあったが、大した利点はないと気づき、ほぼ同じ形に落ち着いた。他の人には良く思われないかもしれない点として、静的関数を呼ぶためにテスト対象の
.cファイルを平気で#includeしている。include する.cファイルは1つだけだ。また、#includeの前にちょっとしたプリプロセッサ処理を入れて、誰かが「リリースモード」でビルドしてassertが無効化される事態を防ぐため、NDEBUGが定義されないようにしている似たようなものを使っている: https://github.com/ensisoft/detonator/blob/master/base/test_...。
boost.test.minimalから多くを借りていて、もともとは単一ヘッダだったが、年月が経つうちに単一の翻訳単位を1つ追加する必要が出てきた。コードベースを常にテストが通る状態に保てば、テストツールのエラー報告や不具合許容といった複雑さは、ずっと少なくて済むという結論だコンパイル時単体テストは、「未使用の import / 変数 / 結果」を警告ではなくエラーにするのと同じくらい、あまり良い発想ではない。
開発者から統制権を奪い、結局仕事を終わらせるために官僚的な手続きを踏ませる「おせっかい機能」だ。こうしたビルド失敗テストは「もう終わったはずだ」というビルドには向いているが、ビルドの99%を占める「まだ作業中」のビルドには合わない。「ドリルを片づけるまでテーブルソーは使えない」と言うようなものだ
ある考えを表現しようとして、コンパイル時テストがそれは間違っていると知らせてくれるなら、実際にはその考えがまだ不完全だったり、その表現のあらゆる結果を十分に考えられていなかったりするのかもしれない。Haskell の型検査に似ている。型検査を通らないプログラムはコンパイルできず、プログラマに常に完全な考えだけを表現するよう強いる。理論上は、よりよく練られたプログラムにつながりうる。ただし、プログラマが「無効なのは分かっているが気にしない」部分まで見つけることを強制するため、書くのは難しくなる
static_assertにより近いものかもしれず、ライブラリ関数の互換性のない使い方を検出するのに価値がある。かなり良いアイデアだと思う
「leaf 関数が先で、グローバルなインターフェース関数が最後に来る」というのは、私には逆に感じられる。
いくつかの理由から、コードはトポロジカルソート順で書くのを好む。関数の中でコードを書くやり方に似ているし、モジュール内で関数をどこに置くべきかが明確になる。そして何より、モジュール内部のコード片どうしの循環依存が非常にはっきり見えるようになる。循環依存はコードベースをより複雑に絡み合わせ、モジュールを独立した単位として理解しにくくするので、あまり好まない。Python では実行されるまで表に出ない問題を生むこともあるし[0]、循環 import があまりに一般的なので、現在の型チェッカーはその診断をデフォルトで無効にしているようだ[1]。前方参照をサポートしない C、OCaml、SML のような言語は、循環依存に「最小驚きの原則」を適用しやすくしてくれる。OCaml では
let rec fn1 = .. and fn2 = ..のように宣言しない限り、関数間の再帰的依存も禁止されるが、書いている最中はやや面倒でも、読むときには重要な情報になる。[0]: https://gist.github.com/Mark24Code/2073470277437f2241033c200...
[1]: https://microsoft.github.io/pyright/#/configuration?id=type-... (
reportImportCyclesを参照)コンパイラが字句的に前にあるものしか知らないため、leaf 関数が先でグローバルなインターフェース関数が最後に来る「下から上へ」の順序になる、という説明があるが、Python のように前方参照を許す言語でもこうした順序はよく見られる[0]。
前方参照を許さない言語の名残なのか、それとも特定の種類のコードでは実際にそのほうが理にかなっているのかが気になる。
[0] https://stackoverflow.com/a/73131538
ただ、関数本体内の識別子は関数自体が実行されるまで解決されず、その時点ではモジュールスコープ内のすべてが定義済みになっているだけだ。関数本体に適当な名前を入れてモジュールをロードしてみれば、自分で確認できる。
厳密なルールがあるわけではないが、そのほうが読むうえでよりすっきりしていて筋が通っているように思える。特に実装が大きい場合、それを全部スクロールしてからでないと基本的に何をするものか分からない、という状態にはしたくない。他の人がどうしているのか気になる。
C にやってほしい「当然のこと」としては、ポインタと長さを保持するスライスタイプのサポート、グローバル状態を使う API の再入可能で、できればスレッドセーフな版、Go や Zig の
deferや GCC のcleanup属性のようなものの標準化、そして Unicode と UTF-8 の移植可能なサポートあたりがある。C の文法で定数式が現れるあらゆる場所において、関数が I/O、可変グローバル変数へのアクセス、システムコールなどを行わない限り、コンパイラはその関数をコンパイル時に実行できるべきだ、という考えは簡単に破綻しうる。
とても長く実行される純粋関数を選べばよい。たとえば
int busybeaver(int n) {...}が n 状態ビジービーバー機械の最大寿命を返す純粋関数だとすると、int x = busybeaver(99);のようなコードが問題になる。C23 には
constexprがあるが、まだ関数には付けられない。ただし提案はある: https://www.open-std.org/jtc1/sc22/wg14/www/docs/n2976.pdf