宣言は名前を導入し、その型を記述します。定義は、プログラムが使用する実体を提供します。リンケージは、異なるスコープまたは翻訳単位にある宣言が同じ実体を指すかどうかを決定し、記憶域期間はオブジェクトが存在する期間を決定します。この4つの問いを分離すると、複雑なCおよびC++の宣言を容易に推論できます。
名前から宣言子を読む #
宣言は、宣言指定子の後に1つ以上の宣言子が続く構成です。宣言子内の演算子は、文法に従って結合します。
- 後置の
()と[]は、前置の*、&、&&より先に結合します。 - 括弧はその結合順序を上書きします。
- 各コンマは独立した宣言子を開始するため、
*は基本型には属しません。
宣言された識別子から始め、隣接する演算子を適用してから、外側へ向かって読み進めます。
int *pointer;
int value;
const int *pointer_to_const = nullptr;
int *const const_pointer = nullptr;
int (*pointer_to_array)[42] = nullptr;
int *array_of_pointers[42] = {};
int (*binary_operation)(int, int) = nullptr;各宣言の意味は次のとおりです。
| 名前 | 型 |
|---|---|
pointer_to_const | const int へのポインター |
const_pointer | int へのconstポインター |
pointer_to_array | 42個の int オブジェクトからなる配列へのポインター |
array_of_pointers | int へのポインターを42個格納する配列 |
binary_operation | 2つの int 値を受け取り、int を返す関数へのポインター |
基本型の前後に記述した const は同じ意味です。const int と int const は等価です。一方、* の後にあるcv修飾子はポインターを修飾します。生の宣言子では重要な違いが隠れる場合、エイリアスによって公開インターフェースを明確にできます。
using Row = int[42];
using BinaryOperation = int(int, int);
Row *row = nullptr;
BinaryOperation *operation = nullptr;コンパイル時に列数が決まる動的な2次元C配列では、行の型を保持します。
enum { columns = 10 };
int (*rows)[columns] = malloc(row_count * sizeof *rows);sizeof *rows は行の型に応じて変化するため、確保時にその型を手動で繰り返す必要がありません。所有権と境界を抽象化に含めるC++コードでは、std::vector、std::array、または行列型を優先してください。
結果として得られる型を言語が構成できないため、不正な宣言子もあります。記述した形での参照の配列、参照へのポインター、参照への参照は存在しません。C++の参照折りたたみは、エイリアスやテンプレートから型を形成する際に適用される別の規則です。関数パラメーターと配列パラメーターもポインターに調整されます。
void run(void callback(void));
void run(void (*callback)(void)); /* same parameter type */
void consume(size_t count, int values[count]);
void consume(size_t count, int *values); /* compatible adjusted form */パラメーター内の配列境界は、契約を文書化したり、C固有の規則に関与したりできます。しかし、関数が値として配列を受け取るわけではありません。
宣言と定義を区別する #
すべての定義は宣言ですが、すべての宣言が定義であるとは限りません。
extern int request_count; // declaration
int request_count = 0; // definition
int parse(const char *text); // function declaration
int parse(const char *text) { // function definition
return text ? 1 : 0;
}
struct Session; // class declaration
struct Session { int id; }; // class definition
通常、extern と初期化子を持たない宣言はオブジェクトを定義しません。初期化子を追加すると、extern があっても定義になります。関数本体、クラス指定子、初期化された名前空間スコープ変数、extern を持たないほとんどの宣言は定義です。重要な例外には、クラス内で宣言された非インラインの静的データメンバーや、型エイリアスだけを含む宣言があります。
テンプレートとインラインエンティティは、インスタンス化またはodr-useされる場所で定義が必要です。そのため、定義は一般にヘッダーに置かれます。通常の外部リンケージを持つ関数またはオブジェクトは、通常、1つの実装ファイルに1つだけ定義を置き、ヘッダーには宣言を置きます。
One Definition Rule(ODR)は2つのレベルで機能します。
- 1つの翻訳単位には、定義可能な項目ごとに定義を1つだけ置けます。
- プログラム全体には、odr-useされる非インライン関数または変数の定義を1つだけ置けます。ただし、インラインエンティティ、テンプレート、およびODRを満たす許可された定義を持つ型には特定の例外があります。
コンパイラーは翻訳単位を独立してコンパイルできるため、プログラム全体に関わるODR違反をすべて診断する必要はありません。したがって、リンクが成功しても定義が一致していることの証明にはなりません。
記憶域期間とリンケージを分離する #
記憶域期間は、オブジェクトの最小存続期間を表します。
| 記憶域期間 | 典型的な宣言 | 存続期間 |
|---|---|---|
| 自動 | static のないブロックローカル変数 | 初期化からブロック終了まで |
| 静的 | 名前空間変数またはブロックローカルの static | プログラムの期間 |
| スレッド | thread_local オブジェクト | そのスレッドの存続期間 |
| 動的 | new またはアロケーターで作成されたオブジェクト | 明示的に解放されるまで |
サブオブジェクトと参照メンバーは、完全オブジェクトから記憶域期間に関する影響を引き継ぎます。参照には独自の存続期間がありますが、間接的に再束縛できるすべてのオブジェクトの存続期間を延長するわけではありません。自動記憶域期間のローカルオブジェクトへの参照を返すと、依然としてダングリングします。
記憶域クラス指定子は異なるプロパティーに影響するため、1つの概念として扱ってはいけません。ブロックスコープでは、static は記憶域期間を変更します。名前空間スコープでは、static は非テンプレート名に内部リンケージを与えます。extern は通常、別の場所で定義されたエンティティを宣言し、外部リンケージを確立できます。thread_local はスレッド記憶域期間を選択し、文法が許す場合は static または extern と組み合わせられます。
C++のリンケージを理解する #
リンケージは、別のスコープにある同じ名前が同じ実体を指すかどうかに答えます。
- リンケージなし: 明示的にリンケージを与えられていないブロックローカル名、ローカルクラス、その他のほとんどのローカル宣言。
- 内部リンケージ: デフォルトでは名前空間スコープの非テンプレート
constオブジェクト、明示的にstaticと宣言された名前、無名名前空間内の名前。 - 外部リンケージ: 通常の非const名前空間スコープ変数、関数、名前付きクラス、明示的に外部にされた名前。
- モジュールリンケージ: C++20以降、名前付きモジュールに属し、内部リンケージを持たない非エクスポートの名前空間スコープ名は、モジュールリンケージを持つことがあります。
内部リンケージのエンティティは、各翻訳単位で別個のものです。外部リンケージのエンティティは、他の翻訳単位から名前で参照できます。モジュールリンケージのエンティティは同じ名前付きモジュールの翻訳単位間で共有できますが、そのモジュールの外部では共有できません。
ヘッダー内の定数は、リンケージが重要である理由を示します。
// C++14-compatible header: one object per translation unit
constexpr int buffer_size = 4096;
// C++17 header: one program-wide entity
inline constexpr int protocol_version = 3;前者のオブジェクトは内部リンケージを持ちます。非テンプレート、非volatileの名前空間スコープ const オブジェクトは、別の規則によって変更されない限り内部リンケージを持つためです。インライン変数は外部リンケージを持ち、プログラム全体で1つのアドレスを持ちます。
言語リンケージは名前リンケージとは異なります。extern "C" のような言語リンケージ指定は、関数型および外部リンケージを持つ関数名に言語リンケージを適用します。
extern "C" int library_open(const char *);
extern "C" {
int library_read(void *, unsigned char *, unsigned long);
void library_close(void *);
}extern "C" は「Cで定義されている」という意味ではなく、型チェックを無効にせず、それだけで安定したABIを定義するものでもありません。実装にCの言語リンケージを使用するよう指示します。これには通常、C互換のシンボル名や呼び出し規約が含まれます。Cと共有する宣言は、慣例的な #ifdef __cplusplus ガードの内側に置き、パラメーター型と戻り値型にもC互換の表現を使用してください。
生成される動作を = default で明示する
#
C++は、デフォルトコンストラクター、デストラクター、コピーコンストラクター、コピー代入演算子、ムーブコンストラクター、ムーブ代入演算子という6つの特殊メンバー関数を暗黙的に宣言できます。各宣言が現れるか、削除されるか、トリビアルになるかは、クラスと、プログラムが他の特殊メンバーを宣言しているかどうかによって決まります。
= default は、暗黙的に生成される定義を要求しながら、インターフェースを明示します。
struct Record {
Record() = default;
~Record() = default;
Record(const Record &) = default;
Record &operator=(const Record &) = default;
Record(Record &&) = default;
Record &operator=(Record &&) = default;
};明示的にデフォルト化した関数でも、削除されたものとして定義されることがあります。たとえば、デフォルトメンバー初期化子を持たない参照メンバーは、生成されたデフォルトコンストラクターでは初期化できません。
struct View {
int &value;
View() = default; // defined as deleted
};メンバーと基底クラスのプロパティーは伝播します。メンバーをコピーできない場合、コピーコンストラクターは削除されます。サブオブジェクトが必要なムーブを実行できない場合、デフォルト化されたムーブ操作は削除されることがあります。ムーブコンストラクターまたはムーブ代入演算子を宣言すると、暗黙的に宣言されたコピー操作は削除されたものとして定義されます。一方、ユーザー宣言されたコピー操作またはデストラクターは、ムーブ操作の暗黙的な宣言を妨げます。このような相互作用があるため、Rule of Zeroを優先するのが適切です。
最初の宣言でデフォルト化すると、ユーザー提供ではない状態を維持でき、他の要件も満たせばトリビアリティーも維持できます。
struct TrivialValue {
int value;
TrivialValue() = default;
};
struct NonTrivialValue {
NonTrivialValue();
int value;
};
NonTrivialValue::NonTrivialValue() = default; // user-provided
トリビアリティーは、トリビアルコピー可能性、暗黙的ライフタイム、ABIの選択などのプロパティーに影響します。見た目の一貫性だけを理由に、クラス内の = default をクラス外へ移動しないでください。これにより、そうしたプロパティーが変わる可能性があります。一方、std::unique_ptr<Incomplete> のようなメンバーが、生成されたデストラクターのインスタンス化前に不完全型を完全型にする必要がある場合、クラス外でのデフォルト化は有用です。
オーバーロード解決中の呼び出しを = delete で拒否する
#
削除された関数はオーバーロード解決に参加しますが、それが選択されたプログラムは不正です。そのため、診断は拒否された操作を指し示します。
class Socket {
public:
Socket() = default;
Socket(const Socket &) = delete;
Socket &operator=(const Socket &) = delete;
Socket(Socket &&) noexcept = default;
Socket &operator=(Socket &&) noexcept = default;
};
void parse(int);
void parse(bool) = delete;parse(true) は、bool を int に変換するのではなく、完全一致する削除済みオーバーロードを選択します。削除済みオーバーロードは、不要な変換、コピー、アロケーション関数、テンプレートケースを拒否できます。フレンド関数やメンバー関数からも呼び出せないため、関数をprivateにして未定義のままにするより、削除の方が強く明確です。
生成された操作が不正になる場合、コンパイラーは特殊メンバーを暗黙的に削除することもあります。ジェネリックコードがそのインターフェースに依存する場合は、トレイトやコンセプトで結果を検査してください。ただし、所有権の意味論を設計する代わりにトレイトを使用してはいけません。
最適化指示ではなくODRのためにインライン定義を使用する #
C++では、inline によりエンティティを複数の翻訳単位で定義できます。エンティティが使用される場所からすべての定義に到達できなければならず、複数の定義はODRを満たす必要があります。外部リンケージを持つインライン関数または変数では、実装は1つのアドレスを持つ1つのエンティティを提供します。
// metrics.hpp
#pragma once
#include <atomic>
inline int clamp_nonnegative(int value) {
return value < 0 ? 0 : value;
}
struct Metrics {
static inline std::atomic<unsigned> requests{0};
};各翻訳単位はこのヘッダーをインクルードできます。ODR安全な定義では同じトークン列を使用し、標準の詳細な例外に従って依存名が一貫して解決されなければなりません。翻訳単位ごとに異なる関数本体を生成する構成マクロは、リンカーが黙って一方の本体を選択する場合でもODR違反を引き起こします。
クラス定義内で定義された関数は、C++20のモジュール規則に基づいて名前付きモジュールに属する場合を除き、暗黙的にインラインです。constexpr または consteval 関数も暗黙的にインラインです。C++17以降、constexpr と宣言された静的データメンバーは暗黙的にインライン変数です。名前空間スコープ変数がインラインになるのは、inline と宣言された場合だけです。
オプティマイザーは、呼び出しを呼び出し箇所で展開するかどうかを独立して決定します。リンク時最適化は inline 指定子を持たない関数をインライン化できます。一方、再帰、コードサイズ、最適化設定によって、inline 関数が呼び出しのままになることもあります。
ヘッダーで1つの共有エンティティを定義する必要がある場合は、インライン定義を使用します。各翻訳単位が意図的に異なる内部リンケージのエンティティを必要とする場合は、無名名前空間または名前空間スコープの static を使用します。翻訳単位ごとの状態を誤って作成することは、よくあるエラーです。
// Distinct counter in every translation unit:
static unsigned local_counter;
// One counter shared by the program:
inline unsigned shared_counter;CにC++のインラインモデルを適用しない #
ISO Cのインライン意味論はC++と異なります。各C翻訳単位に対してprivateのままにするヘッダー専用ヘルパーには、static inline を使用できます。
/* checksum.h */
static inline unsigned fold_byte(unsigned state, unsigned char byte)
{
return (state << 5) ^ (state >> 2) ^ byte;
}外部リンケージを持つ関数では、Cのインライン定義は、プログラムが必要とする1つの外部定義を自動的に提供するわけではありません。堅牢で移植性のある構成は、ヘッダーに通常の宣言を置き、1つの外部定義を .c ファイルに置く方法です。
/* codec.h */
int codec_reset(void);
/* codec.c */
int codec_reset(void)
{
return 0;
}C99以降のISO意味論、歴史的なGNUインライン意味論、コンパイラーの言語モードでは、通常の inline と extern inline が外部定義を提供する方法が異なります。したがって、GCCの -std=gnu89 と gnu_inline の動作をISO Cの例から推測することはできません。プロジェクトの言語モードを選択し、ビルド設定に記録し、インライン定義と外部リンケージを組み合わせる前にコンパイラーの文書を確認してください。
宣言を契約としてレビューする #
重要な宣言ごとに、次の問いに順番に答えてください。
- 完全な宣言子を適用した後の宣言型は何か。
- その宣言は定義でもあるか。
- オブジェクトを宣言している場合、オブジェクトの存続期間を管理する記憶域期間は何か。
- 名前を他の場所の宣言に結び付けるリンケージはあるか。
- プログラムが必要とするのは、1つの定義、翻訳単位ごとに1つの定義、またはODRと等価なインライン定義のどれか。
- クラスについて、どの特殊メンバーが暗黙的、デフォルト化、削除、トリビアル、またはユーザー提供か。
これらの答えにより、プロパティーがリンカーエラーや実行時の欠陥になる前に、所有権、存続期間、バイナリ境界、ODR上の義務を特定できます。
コメント
GitHub で閲覧・コメントする(投稿にはログインが必要です)