声明引入一个名称并描述其类型。定义提供程序所使用的实体。链接决定不同作用域或翻译单元中的声明是否表示同一个实体,而存储期决定对象存在多长时间。将这四个问题分开,可以更容易地推理复杂的 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 的定义使用相同的令牌序列,并且必须按照标准详细例外的要求一致地解析依赖名称。在不同翻译单元中生成不同函数体的配置宏会造成 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 上阅读或评论(发表评论需要登录)