2 ポイント 投稿者 GN⁺ 2025-01-13 | 1件のコメント | WhatsAppで共有
  • 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 の暗黙の宣言と実際の定義の型が衝突するとしてエラーを出す
  • floodex の順序を入れ替えると、同じコードは正常にコンパイルされる
  • ImportCはグローバル宣言がどの順序で現れても受け入れられる
  • 前方参照ができないと、各前方定義ごとに別の宣言を追加しなければならない
    • 先に char dex(char *s, int i); のような宣言を書き
    • その後に関数定義をもう一度書く必要がある
  • これは不要な繰り返し作業であり、プログラマにleaf関数は上、グローバルインターフェース関数は下に置くという逆順配置を強いる
  • この配置は新聞記事を下から上に読むようなもので、理にかなっていないと批判している

ヘッダーファイルなしで宣言を取り込む

  • Cで外部モジュールを使うには、通常 .h ファイルに宣言を作り、.c ファイル側でそれをincludeする必要がある
  • 例の構成は次のとおり
    • floo.cdex.h をincludeして dex() を呼び出す
    • dex.hchar dex(char *s, int i); を宣言する
    • dex.cdex.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件のコメント

 
GN⁺ 2025-01-13
Hacker Newsの意見
  • C以外の言語を使うときに最も恋しくなるものの一つが ヘッダーファイル
    公開/非公開、インターフェース/実装が非常に明確に分かれている点が、Cコードでは特に気に入っている。ライブラリの .h ファイルだけをざっと見れば使い方が分かるのが良く、通常 .h には利用者向けドキュメントが入り、.c と重複しない。ドキュメントを .c に入れることもできるが、使う側としてはインターフェースを読む快適さがかなり落ちる

    • この主張はいつも不思議に感じていて、他の言語はこの問題を ツール で解決しており、その方が優れていると思う
      たとえば Rust では、ライブラリのインターフェースと使い方を見るには cargo doc --open で済む。公開 API 全体が検索可能な形で自動生成され、ヘッダーとソースの間でコードを重複させたり手動で管理したりする必要がない
    • ヘッダーファイルは、70年代の 資源制約の厳しいプラットフォーム を扱うための弱いハックに近いと思う
      慣習を守るときにだけ正しく機能し、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 をインポートするのかわからない

    • 他の人気言語でもできるし、Dではコンパイル時関数実行が非常に人気で有用な機能だ。
      多用すれば当然コンパイル時間とメモリを消費する。仮想マシンについて言えば、定数畳み込み自体がすでに仮想マシンであり、そこに関数呼び出し処理を拡張することになる。Cの意味論は単純なので、そこまで悪くない。提案された import 方式は D の import がやっていることとほぼ同じだ: https://dlang.org/spec/module.html#import-declarationdex.ddex.c かという問題は実際に起こり、その答えは import パスの設定だ。Cコンパイラの include パスに似ている
    • たいていの実用的なCコンパイラは、最適化のためにすでにある程度のコンパイル時評価を行っているはずだ。
      Cにもすでに定数式がある。より大きな障害は、コンパイラが関数のソースコードにアクセスする必要がある点で、たぶん同じ翻訳単位内の関数に制限される可能性が高い。そして、複数のコンパイラ代表が集まる委員会が、そのような定数評価の意味論に合意しなければならないという人間的な問題のほうが、はるかに大きいかもしれない
    • これは実際、C++の constexpr 機能とほぼ1対1に見える。
      ほとんどすべてのCコンパイラはすでにC++コンパイラでもあるので、Cで constexpr 関数と評価をサポートすることが、そこまで悪いことなのか疑問だ
  • Cコードには常に単体テストを書く。
    良いビルドシステムを使い、少しのボイラープレートを受け入れれば難しくない。npy ライブラリのテストでは、test_load_uint8()npy_load("tests/npy/uint8.npy") を呼び出し、次元・サイズ・型を assert したあと npy_free し、mainPRINT_RUN(test_load_uint8); のように実行する。プリプロセッサでテストの一部を生成することもできるだろうが、シンプルに保つほうを好む

    • その関数は I/O を行うように見えるので、コンパイル時テストとしては動かない。
      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

    • Python には前方参照はない。
      ただ、関数本体内の識別子は関数自体が実行されるまで解決されず、その時点ではモジュールスコープ内のすべてが定義済みになっているだけだ。関数本体に適当な名前を入れてモジュールをロードしてみれば、自分で確認できる。
    • 私のコードでは、公開インターフェースは常に上に来て、実装の詳細はファイルの末尾へ行く傾向がある。
      厳密なルールがあるわけではないが、そのほうが読むうえでよりすっきりしていて筋が通っているように思える。特に実装が大きい場合、それを全部スクロールしてからでないと基本的に何をするものか分からない、という状態にはしたくない。他の人がどうしているのか気になる。
  • 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