第25.3章:存储期、链接与命名空间
1. 三个不同的问题
“这个名字住在哪里?”实际上是三个问题,混淆它们是这个领域大多数 bug 的来源:
| 问题 | 术语 | 答案 |
|---|---|---|
| 名字在源码的哪里可用? | 作用域 | 块、类、命名空间、函数参数等 |
| 对象活多久? | 存储期 | 自动、静态、线程或动态 |
| 另一个翻译单元能引用它吗? | 链接 | 无链接、内部链接或外部链接 |
变量可以有块作用域和静态存储期(局部 static)。可以有命名空间作用域和内部链接
(匿名命名空间)。可以有命名空间作用域和外部链接(普通全局变量)。这三个属性互相独立。
2. 局部 static:一个对象,只初始化一次
int next_id()
{
static int counter{};
return ++counter;
}
counter 是块作用域变量,具有静态存储期。每个程序一个对象,不是每次调用一个。
控制第一次到达声明时初始化,程序退出时销毁。
从 C++11 起,初始化是线程安全的:如果两个线程同时第一次调用 next_id,一个会等待另一个,
对象只构造一次。这个保证是关于初始化的,不是关于之后的访问。++counter
本身在两个线程并发调用时是数据竞争。如果这就是你的意思,用 std::atomic<int>。
#include <atomic>
int next_id()
{
static std::atomic<int> counter{};
return ++counter;
}
局部 static 适合惰性单例、缓存和一次性初始化。它不适合隐藏的全局状态:函数签名不再告诉读者 调用之间共享数据。如果测试需要重置状态,局部 static 让你无法做到,除非额外提供重置钩子。
3. thread_local:每个线程一个对象
thread_local int per_thread_counter{};
void bump()
{
++per_thread_counter;
}
每个线程有自己的 per_thread_counter。线程第一次到达声明时创建对象,线程退出时销毁。
它是线程局部缓存的存储期对应物:没有锁、没有共享、没有伪共享。
在头文件里用 inline thread_local,让每个翻译单元引用同一个实体:
inline thread_local int per_thread_counter{};
thread_local 有成本。动态初始化器每个线程运行一次,对象可能需要 TLS 槽,
析构函数在线程退出时运行。在独立目标上可能根本没有线程局部存储。不要把它用于逻辑上全局的值;
当值确实是每线程的时候才用它。
thread_local 变量不会自动与其他线程同步。它是私有状态,不是通信通道。如果两个线程
必须看到同一个值,用 atomic 或受 mutex 保护的对象,而不是 thread_local。
4. inline 变量
C++17 之前,头文件不能定义全局变量:每个包含头文件的翻译单元都会定义它,链接器会报重复定义。
变通办法是每个翻译单元一个 static 变量(各有一份),或者 extern 声明
加一个 .cc 文件里的定义。
C++17 增加了 inline 变量。inline 变量有外部链接,在所有翻译单元中只有一个定义,
即使定义出现在头文件里:
inline constexpr int protocol_version = 3;
inline std::string default_name{"fast_io"};
这里的 inline 是关于链接和 ODR 的,不是关于内联变量的地址或避免内存加载。
编译器仍然可以把对象放在一个位置,并让每个翻译单元引用它。
头文件定义的常量用 inline constexpr,需要唯一身份的全局对象用
inline。非平凡 inline 变量有动态初始化;它相对于其他翻译单元的初始化顺序仍然是问题。
顺序重要时,优先常量初始化或函数局部 static。
5. 命名空间作用域的 const 很特殊
在 C++ 中,命名空间作用域的 const 变量默认具有内部链接:
const int limit = 100; // internal linkage
extern const int shared = 100; // external linkage, defined here
inline constexpr int version = 3; // one entity across TUs
这就是为什么头文件里的 const int limit = 100; 不会导致链接错误:每个翻译单元得到自己的
副本。这也是为什么在两个翻译单元里取 limit 的地址可能得到两个不同地址。如果你需要
一个身份,写 inline constexpr,或者 extern const 加一个定义。
这条规则是 C++ 从 C 的 const 语义继承来的。它会让那些以为每个命名空间作用域定义都是
外部链接的人吃惊。依赖地址身份之前,先检查链接。
6. 匿名命名空间
namespace
{
int implementation_counter;
void helper();
}
无名命名空间里的名字具有内部链接。每个翻译单元得到自己的副本,名字不会与另一个翻译单元里的同名名字冲突。
它是命名空间作用域 static 的现代替代品。
匿名命名空间适合 .cc 文件里的实现细节。它不适合头文件里的共享状态:每个包含头文件的
翻译单元得到不同的对象,所以那个“全局”不是全局的。如果你想要一个对象,用 inline 变量。
匿名命名空间也不是安全边界。它改变链接,不改变调试器、二进制或另一个拥有自己副本的翻译单元对它的可见性。 不要把秘密放进去。
7. 内联命名空间
内联命名空间的成员在外层命名空间里也可见:
namespace lib
{
inline namespace v2
{
struct widget
{
int id;
};
}
}
lib::widget w{1}; // OK, as if widget were directly in lib
lib::v2::widget x{2}; // also OK
这是标准库的版本化工具。std::literals 是一个内联命名空间,这就是为什么
using namespace std::literals; 能让字面量可见而不需要命名版本。库可以增加新的内联命名空间
v3,同时保留旧的以兼容 ABI。
内联命名空间影响名字查找和名字修饰。它们不是运行时机制,也不创建新对象。当你需要透明版本化时用它, 不要把它当通用分组工具。
8. extern:声明与定义
extern int global; // declaration: no storage here
int global = 42; // definition: storage here
extern "C" int c_api(); // C language linkage
没有初始化器的 extern 是声明。它告诉编译器名字和类型;链接器在别处找定义。
定义分配存储(或提供函数体)。这个区别在头文件里很重要:声明放头文件,定义放一个 .cc
文件,或者对变量用 inline。
extern "C" 是语言链接,不是存储。它关闭 C++ 名字修饰,让 C 程序或 C ABI 能找到符号。
它不改变类型系统,也不让 C++ 类布局与 C 兼容。
要点
- 作用域、存储期和链接是互相独立的属性。
- 局部
static每个程序一个对象,只初始化一次,首次使用线程安全。 thread_local每线程一个对象;它是私有状态,不是同步。inline变量在头文件里跨翻译单元提供一个实体。- 命名空间作用域的
const默认内部链接;需要一个身份时用inline constexpr。 - 匿名命名空间用于每翻译单元的实现细节,不是共享全局。
- 内联命名空间是透明版本化,不是运行时特性。
extern声明;定义提供存储。
最后一页讲那些含义容易被忽略的声明:条件 explicit、空基类优化、对齐、
offsetof 和 volatile。