前言
static是 C++ 里语义最多的关键字之一。同一个词,放在不同的位置,分别表示“内部链接”“静态存储期”“类的静态成员”三件并不相同的事;到了 C++11 之后,函数内静态局部变量的初始化又多了线程安全的保证。很多教程把它们混在一起讲,读者记住了一串结论,却不知道某条结论对应哪种语境。
典型误解包括:以为函数里的static变量是“每个线程一份”(它是全程序共享的);以为类里的static成员函数可以写成const(它没有this,不能有 cv 限定);以为在类内写了static int count = 0;就不再需要在类外定义(C++17 之前,被 odr-use 时仍然需要)。这些误解在单文件小程序里可能不暴露,一到多文件工程或并发场景就会变成链接错误或数据竞争。
本文按三个维度梳理:命名空间作用域的static、函数内的static、类中的static,再讲初始化顺序和线程安全,最后给出一个可运行的完整示例。文中以 C++17 为基准,涉及 C++17 之前的行为会明确标注。
一、三个维度:存储期、作用域、链接性
理解static先要分开三个概念:
- 存储期(storage duration):对象在内存中活多久。静态存储期的对象从程序开始到程序结束一直存在。
- 作用域(scope):名字在源码的哪个范围可见。
- 链接性(linkage):不同翻译单元(translation unit,即一个源文件加上它包含的头文件)中的同名符号是否指向同一个实体。外部链接可以跨文件,内部链接只在当前翻译单元有效。
static在不同位置影响其中不同的维度:
| 位置 | 主要影响 | 存储期 | 链接性 | 可见范围 |
|---|
| 命名空间作用域的变量/函数 | 链接性 | 静态 | 内部 | 当前翻译单元 |
| 函数内的局部变量 | 存储期 | 静态 | 无 | 所在函数块 |
| 类的数据成员 | 归属 | 静态 | 外部(C++17 可用 inline) | 类作用域 |
| 类的成员函数 | 归属 | 无 | 外部 | 类作用域 |
命名空间作用域的static变量或函数具有内部链接,其他源文件看不到。它在头文件里定义时,每个包含该头文件的翻译单元都会得到自己的一份副本,地址不同、状态独立。若本意是“全程序一份”,应该用inline(C++17 起)或把定义放到一个源文件里。
匿名命名空间也能实现内部链接,而且对类型同样适用:
// 适用于 C++17
namespace
{
int internal_helper(int x) { return x * 2; }
}在现代 C++ 里,函数和变量更推荐匿名命名空间或inline,static仍然合法,但表达“仅本文件可见”时,匿名命名空间对类、模板等更一致。
二、函数内的 static:只初始化一次,但全程序共享
函数内的静态局部变量在第一次执行到声明处时初始化,之后即使函数返回、再次调用,也不会重新初始化。它存放在静态存储区,生命周期到程序结束,析构按构造的逆序在程序退出时进行。
// 适用于 C++17
int next_id()
{
static int counter = 0; // 第一次调用时初始化为 0
return ++counter;
}这段代码常被用来生成递增编号。需要强调的是:counter是全程序共享的,不是每个调用者一份,也不是每个线程一份。多个线程同时调用next_id,对counter的自增没有同步,构成数据竞争(data race),属于未定义行为。要在线程间安全地计数,应使用std::atomic<int>或互斥量;要每个线程独立计数,应使用thread_local。
C++11 起,静态局部变量的初始化本身是线程安全的:如果多个线程同时第一次到达声明处,只有一个线程执行初始化,其他线程等待。这条保证常被称为“magic statics”。但它只保护初始化,不保护之后的读写。下面这种写法仍然是数据竞争:
// 适用于 C++17
#include <vector>
const std::vector<int>& cached_table()
{
static const std::vector<int> table = {2, 3, 5, 7, 11};
return table; // 初始化线程安全,之后只读也安全
}这里返回的是const引用,初始化完成后没有任何写操作,因此多线程调用是安全的。相反,如果返回非 const 引用并允许外部修改,调用方就要自己同步。static不会给对象附加任何锁。
如果静态局部变量的初始化抛出异常,初始化被视为未完成,下次执行到声明处会再次尝试。这一点在写懒加载单例时要意识到:构造函数抛异常不会留下一个半初始化对象,但会向上传播异常。
三、类的 static 成员:属于类,不属于对象
类的静态数据成员被所有对象共享,不占用对象的大小。它必须在类外定义(C++17 之前),因为类内的声明不是定义:
// C++17 之前的写法
class Widget
{
public:
static int alive;
};
int Widget::alive = 0; // 必须放在某一个 .cpp 里如果只在头文件里写类内声明,又在多个源文件里使用Widget::alive的地址或引用,链接器会报未定义符号。C++17 引入内联变量后,可以直接在类内写:
// 适用于 C++17
class Widget
{
public:
static inline int alive = 0;
};inline静态数据成员可以在多个翻译单元中出现同一个定义,链接器负责合并。这是现代写法,头文件友好。
静态成员函数没有this指针,因此:
- 不能声明为
const、volatile; - 不能是
virtual; - 不能直接访问非静态成员,除非通过对象、指针或引用;
- 可以用
类名::函数名()调用,也可以用对象调用,但后者只是语法糖。
// 适用于 C++17
class Math
{
public:
static int square(int x) { return x * x; }
static constexpr double pi = 3.14159265358979323846;
};
int r = Math::square(5);static constexpr数据成员在 C++17 起隐含inline,类内初始化即可,不需要类外定义。C++17 之前,static constexpr成员被 odr-use 时仍可能需要类外定义,这是老代码常见的链接错误来源。
类模板的静态成员对每个实例化类型各有一份:Counter<int>::value和Counter<double>::value是两个不同的变量,不要假设它们共享。
四、完整示例:对象计数与懒加载单例
下面的例子把三类用法放在一起:静态成员计数、静态成员函数工厂、函数内静态变量实现懒加载单例。
// 适用于 C++17
#include <iostream>
#include <string>
#include <utility>
class Widget
{
public:
explicit Widget(std::string name) : name_(std::move(name))
{
++alive_;
}
~Widget()
{
--alive_;
}
Widget(const Widget&) = delete;
Widget& operator=(const Widget&) = delete;
static int alive() { return alive_; }
static Widget make(const std::string& name)
{
return Widget(name); // C++17 保证复制消除,无需拷贝/移动构造
}
const std::string& name() const { return name_; }
private:
std::string name_;
static inline int alive_ = 0; // C++17 内联静态成员
};
class Config
{
public:
static Config& instance()
{
static Config inst; // 首次调用时构造,C++11 起线程安全
return inst;
}
int value() const { return value_; }
private:
Config() = default;
int value_ = 42;
};
int main()
{
{
Widget a = Widget::make("alpha");
Widget b = Widget::make("beta");
std::cout << Widget::alive() << '\n'; // 2
}
std::cout << Widget::alive() << '\n'; // 0
std::cout << Config::instance().value() << '\n';
return 0;
}Widget删除了拷贝构造和拷贝赋值,防止复制对象导致计数失衡。make返回纯右值,C++17 的强制复制消除保证这里不需要可访问的拷贝/移动构造函数;如果把标准降到 C++14,这段代码就需要保留或实现移动构造。
Config::instance()是迈耶斯单例(Meyers singleton)的写法:把静态局部变量放在访问函数里,第一次调用时构造,之后返回同一引用。它避开了跨翻译单元的静态初始化顺序问题,因为构造发生在第一次函数调用时,而不是程序启动阶段。代价是每次调用都要检查一次初始化标志(编译器通常用线程安全的守卫变量实现),以及析构发生在程序退出阶段,此时其他静态对象可能已经销毁,若单例析构依赖它们,仍有顺序风险。
常见坑点
- ❌ 在头文件里写
static int counter = 0;,多个源文件包含后各自得到一份,计数互不相干。
✅ 需要全程序一份时,用inline变量(C++17)或在一个.cpp中定义、头文件只做extern声明。
- ❌ C++17 之前只在类内写
static int alive;,没有类外定义,使用时链接失败。
✅ 在某个源文件写int Widget::alive = 0;;或升级到 C++17 使用static inline。
- ❌ 把静态成员函数声明为
static int f() const;。
✅ 静态成员函数没有this,不能有const限定;去掉const,把不修改状态的行为体现在参数和返回值上。
- ❌ 认为函数内
static变量是线程局部的,多线程直接自增。
✅ 它是全程序共享的,读写需要std::atomic、互斥量等同步;每线程独立状态用thread_local。
- ❌ 依赖两个源文件中的全局对象初始化顺序,用一个对象去初始化另一个。
✅ 改用函数内静态变量(首次调用时初始化),或把初始化推迟到明确的启动函数中。
- ❌ 在头文件的类外写
int Widget::alive = 0;,被多个源文件包含后重复定义。
✅ 定义只放在一个源文件;C++17 起优先用inline静态成员,把定义留在类内。
- ❌ 把
static当成const使用,期待静态成员不可修改。
✅static只决定归属和存储期,不决定可变性;需要只读时加const或constexpr。
- ❌ 通过对象访问静态成员来“绑定”状态,以为每个对象有自己的副本。
✅ 静态成员属于类,所有对象共享;需要每对象一份时用普通非静态成员。
总结
| 用法 | 含义 | 生命周期 | 关键注意点 |
|---|
命名空间作用域static | 内部链接 | 程序全程 | 头文件中使用会产生多份副本 |
函数内static | 静态存储期,一次初始化 | 程序全程 | C++11 起初始化线程安全,读写不保证 |
| 类静态数据成员 | 全类共享 | 程序全程 | C++17 前需类外定义 |
| 类静态成员函数 | 无this | 不适用 | 不能 const/virtual |
static inline成员 | 头文件友好的单一定义 | 程序全程 | C++17 起可用 |
thread_local | 每线程一份 | 线程生命周期 | 与static语义不同 |
static的三种含义可以这样记:在名字空间里它管“别人能不能看见”,在函数里它管“活多久”,在类里它管“属于谁”。真正容易出错的是后两者叠加并发与初始化顺序:共享的静态变量不是线程安全的,跨文件的动态初始化顺序是不确定的。把状态封装进函数内静态变量、把只读数据声明为const/constexpr、把需要并发访问的计数器换成原子类型,大多数坑都可以提前避开。