宣告會引入名稱並描述其型別。定義會提供程式所使用的實體。連結決定不同作用域或轉譯單元中的宣告是否表示相同實體,而儲存期決定物件存在多久。將這四個問題分開,可讓複雜的 C 與 C++ 宣告更容易推理。
從名稱讀取宣告子 #
宣告由宣告指定子後接一個或多個宣告子組成。宣告子中的運算子依照語法規則繫結:
- 後置的
()和[]會在前置的*、&和&&之前繫結; - 括號會覆寫這種繫結;
- 每個逗號都會開始一個獨立的宣告子,因此
*不屬於基礎型別。
從宣告的識別字開始,套用相鄰的運算子,然後向外讀取:
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 | 包含 42 個指向 int 之指標的陣列 |
binary_operation | 指向函式的指標;該函式接受兩個 int 值並回傳 int |
寫在基礎型別前後的 const 具有相同意義:const int 與 int const 等價。* 後面的 cv 限定詞則限定指標本身。當原始宣告子會隱藏重要差異時,別名可以讓公開介面更清楚:
using Row = int[42];
using BinaryOperation = int(int, int);
Row *row = nullptr;
BinaryOperation *operation = nullptr;對於欄數在編譯期確定的動態二維 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 的位置有定義,因此其定義通常放在標頭檔中。一般具有外部連結的函式或物件通常應在一個實作檔中提供一個定義,並在標頭檔中提供宣告。
單一定義規則(ODR)在兩個層次運作:
- 一個轉譯單元中,每個可定義項目只能有一個定義。
- 整個程式中,被 odr-use 的非內嵌函式或變數只能有一個定義;內嵌實體、樣板,以及其允許的定義符合 ODR 的型別具有特定例外。
編譯器不必診斷每個涉及整個程式的 ODR 違規,因為它可以獨立編譯轉譯單元。因此,連結成功並不能證明各個定義一致。
將儲存期與連結分開 #
儲存期描述物件的最短生命週期:
| 儲存期 | 典型宣告 | 生命週期 |
|---|---|---|
| 自動 | 不帶 static 的區塊區域變數 | 從初始化到區塊結束 |
| 靜態 | 命名空間變數或區塊區域 static | 整個程式期間 |
| 執行緒 | thread_local 物件 | 該執行緒的生命週期 |
| 動態 | 由 new 或配置器建立的物件 | 直到明確釋放 |
子物件與參照成員會從完整物件繼承儲存期上的影響。參照具有自己的生命週期,但不會間接延長它可能重新繫結到的每個物件的生命週期;回傳自動儲存期區域物件的參照仍會懸空。
儲存類別指定子會影響不同屬性,不得將其視為同一個概念。在區塊作用域中,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 物件,除非其他規則改變其屬性,否則具有內部連結。內嵌變數具有外部連結,並在整個程式中共用一個位址。
語言連結不同於名稱連結。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++ 可以隱式宣告六個特殊成員函式:預設建構函式、解構函式、複製建構函式、複製指定運算子、移動建構函式與移動指定運算子。每個宣告是否出現、被刪除或為平凡,取決於類別以及程式宣告了哪些其他特殊成員。
= 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 並保持未定義更嚴格且清楚。
當產生的特殊成員操作無效時,編譯器也會隱式刪除它們。如果泛型程式碼依賴產生的介面,請使用 traits 或 concepts 測試結果,但不要用 trait 取代所有權語意的設計。
使用內嵌定義處理 ODR,而不是將其視為最佳化命令 #
在 C++ 中,inline 允許實體在多個轉譯單元中定義。每個定義都必須在實體被使用的位置可達,且多個定義必須符合 ODR。對於具有外部連結的內嵌函式或變數,實作會提供一個具有單一位址的實體:
// 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 的定義會使用相同的 token 序列,並且必須依照標準的詳細例外一致解析相依名稱。讓不同轉譯單元產生不同函式本體的組態巨集會造成 ODR 違規,即使連結器靜默選擇其中一個本體。
在類別定義內定義的函式會隱式成為內嵌函式,除非根據 C++20 模組規則附屬於具名模組。constexpr 或 consteval 函式也會隱式成為內嵌函式。自 C++17 起,宣告為 constexpr 的靜態資料成員會隱式成為內嵌變數;命名空間作用域變數只有在宣告為 inline 時才是內嵌變數。
最佳化器會獨立決定是否在呼叫點展開呼叫。連結時最佳化可以將沒有 inline 指定子的函式內嵌,而遞迴、程式碼大小或最佳化設定可能使 inline 函式保留為呼叫。
當標頭檔必須定義一個共用實體時,請使用內嵌定義。當每個轉譯單元有意需要一個不同的內部連結實體時,請使用未命名命名空間或命名空間作用域的 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 轉譯單元保持私有,可以使用 static inline:
/* checksum.h */
static inline unsigned fold_byte(unsigned state, unsigned char byte)
{
return (state << 5) ^ (state >> 2) ^ byte;
}對於具有外部連結的函式,C 的內嵌定義不會自動成為程式所需的唯一外部定義。穩健且可攜的安排是在標頭檔中提供一般宣告,並在 .c 檔中提供一個外部定義:
/* codec.h */
int codec_reset(void);
/* codec.c */
int codec_reset(void)
{
return 0;
}C99 及後續 ISO 語意、歷史上的 GNU 內嵌語意,以及編譯器語言模式,在普通 inline 和 extern inline 如何提供外部定義方面有所不同。因此,不能從 ISO C 範例推斷 GCC 的 -std=gnu89 和 gnu_inline 行為。請選擇專案語言模式,在建置設定中記錄該模式,並在混合使用內嵌定義與外部連結前查閱編譯器文件。
將宣告視為契約進行檢視 #
對於每個非平凡宣告,請依序回答以下問題:
- 套用完整宣告子後,宣告的型別是什麼?
- 該宣告是否同時也是定義?
- 如果它宣告了一個物件,什麼儲存期支配該物件的生命週期?
- 哪種連結(如果有)將該名稱與其他位置的宣告連接起來?
- 程式需要的是一個定義、每個轉譯單元一個定義,還是與 ODR 等價的內嵌定義?
- 對類別而言,哪些特殊成員是隱式的、預設的、刪除的、平凡的或使用者提供的?
這些答案會在相關屬性變成連結器錯誤或執行時缺陷前,識別所有權、生命週期、二進位邊界與 ODR 義務。
評論
在 GitHub 上閱讀或評論(發表評論需要登入)