有害と見なされる翻訳単位?

構造体 square があるとしましょう の面積を計算します。

struct square { int width; }

もちろんそれも可能です:

int area(square s) { return s.width * s.width; }

しかし、あなたの友人のトニーがもっと関数を使うように言ったので、代わりにそれを実行してください

int area(square s) { return width(s) * width(s); }
int width(square s) { return s.width; }

area あなたが本当に気にかけている関数は最初に定義されます - 結局のところ、コードは上から下に読み込まれます.

; がないことから推測できるように 構造体の閉じ括弧の後、上記のコードは D で書かれています。私の読者層はあまり D に興味がないと思うので、Rust の方がいいかもしれません。 ?

pub fn area(square: Square) -> i32 { return width(s) * width(s) }
pub fn width(square: Square) -> i32 { return s.width }
pub struct Square { width: i32 }

正方形の面積を縮尺で計算することもできます 行く

func Area(s square) int { return width(s) * width(s); }
func width(s square) int { return s.width }
type square struct { width int }

または スウィフト

func area(s: Square) -> Int { return width(s:s) * width(s:s); }
func width(s: Square) -> Int { return s.width }
struct Square { var width:Int = 0; }

でももちろん、あなた オーバーヘッドを心配し、最もパフォーマンスの高い言語が必要になります (それは言葉ではありません)。喜んで感銘を与えたいので、D コードをコピーして、非常に重要なセミコロンを追加させてください。

struct square { int width; };
int area(square s) { return width(s) * width(s); }
int width(square s) { return s.width; }

いいですね。ほとんどの言語が似ているのは興味深いですね。

error: 'width' was not declared in this scope

でも、バカめ、そこにある .私はマニアックなようにグローバル スコープですべてを宣言しましたね。わかりますか?

残念ながら、標準はコンパイラを盲目にします。

名前空間 N のメンバーである関数の定義では、関数の declarator-id23 の後に使用される名前は、それが使用されるブロックまたはそれを囲むブロックの 1 つで使用される前に宣言されなければなりません ([stmt.block] ) または名前空間 N で使用する前に宣言するか、N がネストされた名前空間である場合は、N を囲む名前空間の 1 つで使用する前に宣言する必要があります。

もちろん、これは意味がありません。他の言語で証明されているように、コンパイラは定義とは無関係に宣言を本当に簡単に解析できます。またはご存知のように、C++ クラスです。 (大きな名前空間を、静的メソッドとネストされた型でいっぱいのクラスに置き換えることを想像してみてください)もちろん、それがパフォーマンスの問題である場合を除きます.しかし、あなたは非常に優れたエンジニアであるため、ソースファイルが数百行を超えることはありません.あなたのコードはきっと美しいでしょう、この小さな自己完結型の非常に便利なプログラムのように

#include <iostream>
int main () {
 std::cout << "Hello world\n";
}

私のシステムでは、約 33000 に拡張されます コード行。おかしなこと。しかし、それについては後で詳しく説明します。

振り出しに戻りましょう.C++ の無限の知恵により、関数を前方宣言できるため、次のように記述できます。

struct square { int width; };
int width(const square& s);
int area(const square& s) { return width(s) * width(s); }
int width(const square& s) { return s.width; }

目を細めると、素敵でダンディです。

関数の正確な宣言を完全に正しく取得する必要があることに加えて、これは維持するのが難しく、多くのエンティティ、特に型エイリアス、テンプレート化された型などは前方宣言可能ではありません。関数の前方宣言が必要な場所を考えると、これは奇妙な制限です名前を導入しようとしているだけの型について、正確なシグネチャを知る必要があります。

例外なし

area に気付くでしょう。 つまり、area の部分式はありません。

そうでないことを確認できます。

static_assert(noexcept(area(square{})));

必然的に、それは失敗します。error: static assertion failed .実際、関数がスローできないことをコンパイラに伝えるのを忘れていました。

int width(const square& s) noexcept;
int area(const square& s) noexcept { return width(s) * width(s); }
int width(const square& s) noexcept { return s.width; }

noexcept を追加する必要があることに注意してください 前方宣言を含むすべての宣言で。そして、コンパイラにかなり簡単に嘘をつくことができます.

int area(const square& s) noexcept {
 return width(s) * width(s);
}

int width(const square& s) {
 throw 42;
}

上記のコードは std::terminate() になります 、あなたはコンパイラがそれを知っていることを知っています、誰もがそれを知っています.

では、どの関数を noexcept とマークする必要がありますか? ?実際には非常に簡単です。スローできないすべての関数。それは次の関数です:

  • throw を含めないでください 例外
  • noexcept 以外の関数を呼び出さない

二重 (三重?) の否定に注意してください。

noexcept になる可能性のあるすべての関数をマークしようとしている開発者として そのため、呼び出しチェーンが決してスローしないか、実際にスローする可能性があることを確認できるまで、呼び出しツリーを再帰的にたどる必要があります (1 つの呼び出し先がスローするか、C インターフェイスの境界にいるなどの理由で)。制御フローについてより難しい:例外は多かれ少なかれ、毎回プログラム全体の制御フローについて推論することを強制します.noexcept はそれを解決するはずですが、それを noexcept にすると 自信を持ってキーワードを分析する必要があります。間違いを犯す可能性は高いです。ジェネリック コードを記述する場合、シンボルのすべての部分式が noexcept である場合、シンボルが noexcept であることを手動でコンパイラに伝える必要があります。

そして、コンパイラは、関数が実際にスローしないことを信頼できないため、実装者は std::terminate への呼び出しを挿入します あちこちで、関数 noexcept をマークすることによるパフォーマンス上の利点がいくらか否定されています

代わりにラムダを使用してコードを書き直しましょう

auto width = [](const square& s) -> int {
 return s.width;
};
auto area = [](const square& s) -> int {
 return width(s) * width(s);
};

もちろん、ラムダは前方宣言できません。そのため、コードを再編成する必要がありました。

そして今、noexcept がないにも関わらず キーワード、static_assert(noexcept(area(square{})));

何が起きているの?

コンパイラは、どの関数が noexcept であるかを知るのに非常に優れていることがわかりました .ラムダの場合、定義は呼び出しの前に常にコンパイラーに表示されるため、コンパイラーは暗黙的に例外をマークして作業を行うことができます。これは C++20 の一部として許可されています。

noexcept とはどういう意味ですか?

noexcept と言っているわけではありません それには複数の意味があり、人によって使い方が異なるため、理想的な世界では必要ありません。特に、noexcept 意味:

  • この関数の例外処理コードを生成しない
  • この関数はスローしません
  • この関数は決して 投げる

最初のステートメントはコンパイラーへの要求であり、2 番目のステートメントはコンパイラーと人間の読者の両方に対するアサーションですが、最後のステートメントは人間専用です。

だから noexcept 関数が実際に例外をスローしないかどうかをコンパイラが独自に判断できたとしても、人々の間の契約として API 境界で興味深いままです。

transaction_safe

トランザクション メモリ TS は、トランザクション セーフな表現の概念を定義します 次のように:

評価される可能性のある部分式 (3.2[basic.def.odr]) として次のいずれかが含まれている場合、その式はトランザクションセーフではありません:

  • 揮発性の glvalue に適用される左辺値から右辺値への変換 (4.1 [conv.lval])
  • 揮発性の glvalue を介してオブジェクトを変更する式
  • volatile 修飾された型の一時オブジェクトの作成、または volatile 修飾された型のサブオブジェクトを使用した作成
  • postfix-expression が、トランザクションセーフではない非仮想関数を指定する id-expression である関数呼び出し (5.2.2 expr.call)
  • トランザクションセーフではない非仮想関数の暗黙の呼び出し
  • その他の関数の呼び出しで、関数の型が「transaction_safe 関数」ではない

(私のものを強調)

詳細は重要ではありませんが、基本的には transaction_safe 安全な式は、揮発性オブジェクトに触れないものです.そして、同じプロパティを持つ関数のみを呼び出します.それはおそらく関数の99%以上です-互換性の理由から非常にひどいデフォルトが存在すると思われます.重要な部分は、タグ付けする必要があることです.すべての関数またはプロパティが再帰的に true を保持することを望みます (noexcept のように) 、関数 transaction_safe をマークすることで嘘をつくことができます 呼び出し先が transaction_safe でなくても 、UB への扉を開く)。この TS を妨げているように見える問題。

constexpr

constexpr 機能が少し異なります。コンパイラは、どの関数が候補 constexpr であるかを認識しています .ほとんどの場合、実際にそのようにマークされているかどうかに関係なく、定数を評価します.キーワードは、可能な場合にコンパイラが実際に定数評価を行うことを保証するために必要です.関数はソース破壊的変更である可能性があります - (その関数が constexpr の評価中に呼び出された場合 変数)。その性質上、constexpr constexpr を意味します 関数はTUのどこかに定義されています。また、TU で定義されていないものはすべて定数評価できません。C++20 の提案では、場合によってはそれを暗黙的にすることが提案されています

今のところ、次のコードが残っています。適切な修飾子を使用するのはあなた次第です。

constexpr int width(square s) noexcept transaction_safe;
constexpr int area(square s) noexcept transaction_safe { return width(s) * width(s); }
constexpr int width(square s) noexcept transaction_safe { return s.width; }

C++20 以降、constexpr 関数はスローできます。委員会は new にすることも検討しています 式noexcept 23 または 26 までに、関数の 95% 以上が constexpr になる場所にゆっくりと到達しています。 と noexcept 資格があり、手動でマークする必要があります。

もっと良い方法はありますか?

C++ コンパイル モデルに戻ります。

ソース ファイルとそれに含まれるヘッダーが翻訳単位を形成します。複数の翻訳単位がプログラムを形成します。

シンプルに聞こえますよね?実際はもっとシンプルです

ヘッダーとソース ファイルは、私たちが自分自身に言い聞かせているちょっとした嘘です。私が知る限り、「ヘッダー」という用語は、「標準ライブラリ ヘッダー」の名前として標準にのみ表示されます。実際には、ヘッダーは必ずしもそうである必要はありません。実際のファイルである場合、これらはコンパイラがトークンのシーケンスとして理解できるものを識別します。

実際には、プリプロセッサ (1960 年代後半から 70 年代前半に酔っ払ったベル ラボのインターンが LSD で実装した技術) を使用して、完全ではないファイルのコレクションをつなぎ合わせます。 システムのどこから来たのかを確認してください。私たちはそれらをヘッダーとソースファイルと呼んでいますが、実際には .cpp を含めることができます .h のファイル または .js を使用することを選択します ヘッダーの拡張子、.rs もちろん、循環ヘッダー依存関係を作成することもできます。

プリプロセッサは非常に馬鹿げているため、インクルード ガードと呼ばれる最悪のパターンで既にインクルードされているファイルを明示的に指定する必要があります。

結局、#include ディレクティブは cat のように機能します - cat を除く

もちろん、どこでもマクロを定義できるため、「ヘッダー」はコンパイル時にすべてのコードを混沌とした方法で書き換えることができます (ここで混沌とは決定論を意味しますが、人間の認知能力をはるかに超えています)。

このコンテキストでは、参照されたシンボルを宣言したかどうかを確認するために、コンパイラが数万行先を調べない理由を理解するのは簡単です。結果として(私は思う これは実際には任意ではありません)、オーバーロードと名前の検索は、ベスト マッチではなく、最初の良いマッチとして機能します。

constexpr int f(double x) { return x * 2; }
constexpr auto a = f(1);
constexpr int f(int x) { return x * 4; }
constexpr auto b = f(1);

簡単なクイズ:a の値は何ですか と b ?

あなたが間違っていない、または驚いていない場合は、ストックホルム症候群に苦しんでいる可能性があります.治療法はありません。また、宣言の順序がプログラムのセマンティクスに影響を与える可能性があり、マクロはすべてを書き換えることができるため、C++ の治療法もありません。

一般的な知恵は、宣言をヘッダーに、実装をソース ファイルに配置することです。そうすることで、同じ数十万行のヘッダー ファイルをすべて含む非常に小さなソース ファイルをより高速にコンパイルできます。少なくとも、コンパイルの頻度は少なくなります。ほとんどのコードを constexpr にすることができ、constexpr 宣言はすべての翻訳単位から見える必要があります。そのため、常に auto を使用してテンプレート化され、概念化された constexpr 化されたコードを見ると、何をソース ファイルに分割できるのか疑問に思うでしょう。おそらく何もありません。 C++98 に固執しない限り、私は推測します。または、type-erasure を多用します。たとえば、span を使用できます。 、C++20 が提供する最高の型です。

そしてもちろん、リンカーはさまざまな翻訳単位を取得し、そこからプログラムを作成します。この時点で、悪名高い One Definition Rule 各シンボルを 1 回だけ定義する必要があります。何百ものヘッダーがさまざまな順序で数十万行のコードに展開され、さまざまなマクロのセットがそのプロジェクトに固有の方法で定義され、システム上で、その日、<してはならない 何かを再定義します。リンカー エラーが発生する最良のシナリオです。おそらく、あなたはUBを取得します。あなたのコードは現在、ある程度 ODR に違反していますか?おそらくそうなりますが、実際には、する not.ODR は、コンパイラがコードベースに存在する名前を認識していないことの直接的な結果です。

Titus Winters が C++ Past vs. Future というすばらしい新しい講演で ODR について詳しく語っていることがわかりました。これを必ず見る必要があります。

しかし、リンカーはかなり優れています

彼らは静的ライブラリを作成できます - 基本的には複数の翻訳単位を含む zip です。そのライブラリを使用するとき、リンカは参照されていない静的オブジェクトを都合よくリンクしないことがあります。

また、動的ライブラリを作成することもできます。私たちが今でも信じている最悪のアイデアです。おそらく、動的ライブラリを作成することで問題を解決できます。おそらく動作します。動作しないかは、実行時にわかります。

いいえ、実際には、リンカーは とても素晴らしいです。

プログラム全体を最適化できる コンパイラとは異なり、リンカはすべてのコードを見ることができるため .したがって、非常に複雑なビルド システムを犠牲にして複数のソース ファイルに細心の注意を払って分割したすべてのコードは、最終的にリンカーによってつなぎ合わされ、そのようにして全体として最適化されます。

もちろん、無数の CPU がすべて <vector> を解析している分散ビルド ファーム全体で、多くのビルドを並行して実行できます。 その裏返しとして、コンパイラ自体は、同時に複数のジョブを実行することを期待しており、その実装にいかなる種類の並行性も実装しないということです.

main() から始まるコール グラフで使用されていないもの 関数またはグローバル コンストラクターは破棄されます。

モジュールはどうですか?

そうですね、C++ モジュールが少し役に立ちます。

あなたが尋ねるかもしれないC++モジュールは何ですか? モジュールとは、標準化されたプリコンパイル済みヘッダーです 事前に消化されたバイナリ形式で「ヘッダー」を取得するため、コンパイルが高速化されます.とにかくすべてを常に再構築する必要はないと仮定します.ヘッダーに大きなサードパーティが実装されている場合、それらは本当に役立つと思います.モジュールの扱い方を理解してください。

モジュール インターフェイスを変更すると、既存の宣言を変更しなくても、すべてのモジュール インターフェイスが推移的に変更されると私は信じていることに注意してください。

重要なことに、モジュールはそうではありません

  • スコープ メカニズム、または名前空間を置き換える方法
//MyFoo.cppm
export module my.foo;
export namespace my::foo {
 constexpr int f() {}
}

//MyBar.cpp
import my.foo;
int main() {
 my::foo::f();
}
  • 宣言前に使用されたシンボルを許可する方法

できると思います なっている。モジュールがクローズされている場合、定義の解析を行う前に同じモジュール内のすべての宣言を検討するのが合理的と思われますが、これにより「モジュールへの移植」が難しくなり、「モジュールへの移植」は TS の重要な部分になります。 em>あなた それについて論文を書きたいですか?!

  • マクロをサンドボックス化する方法

実際には何の作業も行わずに 20 年前のコードベースでモジュールを動作させる強いインセンティブがあります。その結果、現在の提案では、多かれ少なかれ好きな場所でマクロを宣言して使用し、モジュールからマクロをエクスポートすることができます。約。つまり、モジュールのコードベースが実際にどのように効率的に構築されるかはまだ分からないと思います.

  • C++ をモダナイズする方法

モジュール コンテキスト内の特定の構造を禁止または修正する提案がいくつかありましたが、人々は将来のコードよりも既存のコードベースに関心があるため、うまくいくとは思いません。

  • モジュール

C++ モジュールは、コンパイルされたヘッダーとして美化されているため、翻訳単位モデルを置き換えようとはしません。モジュールは、インターフェイスとして分割されます (コンパイラーは、そのモジュールのソースを BMI (バイナリ モジュール インターフェイス) に変換できます)。インターフェイスに実装されているもの (オブジェクト ファイル)。実際、次のコードはリンクしません

//m1.cppm
export module m1;
export int f() {
 return 0;
}
//main.cpp
import m1;
int main() {
 f();
}
clang++ -fmodules-ts --precompile m1.cppm -o m1.pcm
clang++ -fmodules-ts -fmodule-file=m1.pcm main.cpp

なぜなら m1 モジュール バイナリ インターフェース f() の定義を考慮しません 、インラインでマークするか、そこから .o を作成しない限り。それにもかかわらず、私のシステムの BMI には関数の定義が確実に含まれています。いずれにせよ、すべての依存関係の再構築につながります.

したがって、モジュールは、他の言語のように自給自足の単位ではありません。幸いなことに、特定のモジュールの実装を単一の翻訳単位で行う必要があります。

一連の定義

人々は自分のコードをまとまりのある全体として考えており、口語的な用語は「プロジェクト」です。コンパイラがコードを認識すればするほど、コードを最適化できるようになります。 constexpr メソッド、テンプレート (および概念)、ラムダ、リフレクション…

しかし、コンパイル モデルは、私たちのツールを無力に盲目にし、私たちの生活を困難にすることを奨励しています.これらの問題の解決策は簡単ではありません.

核となる問題は、プログラムは、それが書かれた言語に関係なく、定義の集まりですが、開発ツールはファイルを操作し、そこにはある種の不一致があるということです.

長い間、C++ コミュニティは、定義と宣言の分離、つまりソース/ヘッダー モデルが優れているという深い信念を持っていました。結局のところ、使いやすく、理由を説明するのがはるかに簡単です。人々のため、ツールのため、コンパイラのため。モジュールとして出荷される将来のライブラリが同様に「モジュール インターフェイスのみ」であっても驚かないでしょう。シングル ヘッダー ライブラリが 1 つのファイルとして出荷されることは問題ではないと思います。重要なのは、それらが 1 つのファイルを含めることで消費できることです。「これは、私のライブラリを構成する宣言のセットです」と表現します。

もちろん、長いコンパイル時間の問題を手放すべきではありません。しかし、ほとんどの FX/3D アーティストが仕事をするために 4000 ドル以上のマシンを必要とすることは広く受け入れられています。スタジオは、ビジネスを行うためのコストとしてそれを理解しています.そしておそらく、C++ のコンパイルには高価なハードウェアも必要です.そして多分それは大丈夫です。ハードウェアは安くても、人はそうではありません。 特に 優れたソフトウェア エンジニア

オブジェクト ファイル、静的ライブラリ、および動的ライブラリを取り除くことができるかどうかはわかりません.非常に特定のライブラリ以外で ABI を気にするのをやめるかどうかはわかりません.

しかし、C++ コミュニティはより優れたツールと依存関係マネージャーを夢見ているため、基礎をより正確に定義することが役立つかもしれません:私たちのプログラムは 定義 のセットです 、そのうちのいくつかは他の人によってツリー外で提供および維持されています。私たちのツールがそのモデルに忠実に準拠すればするほど、長期的にはうまくいくと思います.

したがって、コンパイル モデルについて基本的な質問をし、私たちが持っているいくつかの信念を調べる必要があるかもしれません (たとえば、「コンパイラとビルド システムは分離しておく必要があります。そうですか?どの程度ですか?」)。

技術的な障害、社会的および法的障害 (LGPL、あなたは自分自身を恥じるべきです) は、明らかに計り知れないものがあります。それまでの間、私は何の答えも持っていないことを十分に承知しており、インターネットで叫びます。