extern、static 与 inline 关键字总结
需要预先了解:
1. extern、static、inline 修饰变量时的区别
三个关键字修饰变量时,本质都在操纵声明与定义的关系和链接属性(linkage)。要理解它们的区别,先要弄清楚链接是怎么回事。
1.1 翻译单元与链接
一个 C++ 程序通常由多个 .cpp 文件组成。每个 .cpp 文件连同它 #include 的所有头文件,构成一个翻译单元(translation unit)。编译器逐个翻译单元独立编译,产出目标文件(.o),链接器(linker)再把所有目标文件合并成可执行文件。
每个翻译单元中定义的全局变量和函数,都会在目标文件里生成一个符号(symbol)——它是链接器用来识别"哪个名字对应哪块地址"的索引。链接器需要回答一个关键问题:a.cpp 里用到了 global,该去哪里找它的定义?答案取决于符号的链接属性:
- 外部链接(external linkage):符号对其他翻译单元可见,链接器可以在整个程序范围内解析它
- 内部链接(internal linkage):符号只在本翻译单元内可见,链接器不会把它暴露给外部
extern、static、inline 修饰变量时的作用,分别对应链接过程中的三种不同安排。
1.2 extern 修饰变量
extern 的作用是声明一个变量而不定义它,告诉编译器"这个名字的变量定义在别处":
// a.cpp
int global = 42; // 定义:分配存储空间
// b.cpp
extern int global; // 声明:不分配存储空间
int main()
{
std::cout << global << std::endl; // 链接时解析到 a.cpp 中的 global
}
从内存与链接的角度看:
- 定义处分配存储:
global是静态存储期,存放在.data段 extern声明本身不分配任何内存,编译器只在符号表中记录一个"未解析的引用"(unresolved reference),链接器在链接阶段把它解析为a.cpp中global的地址
一个容易混淆的点:extern 声明一旦带上初始化器,它就变成了定义:
extern int x = 10; // 这是定义,等价于 int x = 10;
1.3 static 修饰变量(文件作用域)
static 修饰文件作用域的变量(或函数)时,把链接属性从外部链接改成内部链接:
// a.cpp
static int hidden = 1; // 内部链接:只有本翻译单元可见
int visible = 2; // 外部链接:其他文件可以用 extern 引用
从内存与链接的角度看:
- 存储期不变,仍然是静态存储期,存放在
.data段 - 区别在符号表:
hidden被标记为 local 符号,链接器不会让它参与跨翻译单元的符号解析
如果另一个文件里也定义了一个 static int hidden,两者互不干扰、各自独立存在。这正是 static 的经典用途:避免多文件协作时的符号冲突,隐藏实现细节。
1.4 inline 修饰变量(C++17)
C++17 引入了inline 变量,允许同一个变量在多个翻译单元中各自定义一次,链接器会把它们合并为同一个实体:
// config.h
inline int version = 3; // 可以被多个 .cpp 包含而不报错
// a.cpp
#include "config.h" // 翻译单元 A:定义 version
// b.cpp
#include "config.h" // 翻译单元 B:也定义 version
如果不加 inline,直接在头文件里写 int version = 3;,多个翻译单元各自包含后,链接器会报告 multiple definition 错误——这违反了ODR(One Definition Rule,单一定义规则):同一实体在整个程序中只允许有一个定义。
从底层看,inline 变量对应弱符号(weak symbol)语义:目标文件中的符号被标记为 weak,链接器遇到多个 weak 定义时选择其中一个保留,其余丢弃,最终所有引用都指向同一块存储。
注:C++17 之前,头文件里定义"共享全局变量"只能靠
extern 声明 + 某个 .cpp 中定义的组合,inline 变量让头文件可以独立承担定义,不必再维护"定义在哪个 .cpp"的约定。
1.5 三者对比
| 关键字 | 作用 | 是否分配存储 | 链接属性 | 底层机制 |
|---|---|---|---|---|
extern |
声明外部变量 | 否(仅声明时) | 外部链接 | 符号引用,链接时解析 |
static(文件作用域) |
限制为本翻译单元可见 | 是(定义时) | 内部链接 | 符号标记为 local |
inline(C++17) |
允许多翻译单元定义同一变量 | 是(定义时) | 外部链接、允许多定义 | 弱符号,链接器合并 |
2. static 关键字总结与深入分析
第一部分从"修饰变量"的角度讲了文件作用域的 static,这里把 static 的全部用法系统过一遍,重点看它涉及的存储与底层机制。
2.1 static 修饰局部变量
static 局部变量:存储期变为静态,贯穿整个程序运行期,但作用域仍然只在函数内:
#include <iostream>
void counter()
{
static int n = 0; // 只初始化一次,地址固定
++n;
std::cout << n << std::endl;
}
int main()
{
counter(); // 输出 1
counter(); // 输出 2
counter(); // 输出 3
}
普通局部变量每次调用都在栈上重新创建、函数返回即销毁;static 局部变量则:
- 存放在
.data段(有初始值)或.bss段(零初始化),地址固定 - 编译器生成一个隐藏的标志位(guard variable)记录"是否已初始化",整个程序运行期间只初始化一次
- C++11 起,这个初始化是线程安全的:多个线程同时首次进入时,只有一个线程执行初始化,其余线程等待
#include <iostream>
void f(int depth)
{
int local = 1; // 栈上分配,每次递归调用地址不同
static int s = 1; // .data/.bss 段,地址固定
std::cout << "depth " << depth << ": local=" << &local
<< " s=" << &s << std::endl;
if (depth > 0)
f(depth - 1); // 递归让多个栈帧并存
}
int main()
{
f(2);
}
编译并运行代码后,看到这样的输出:
depth 2: local=0x10c7fff7ec s=0x7ff68dbcf000
depth 1: local=0x10c7fff7ac s=0x7ff68dbcf000
depth 0: local=0x10c7fff76c s=0x7ff68dbcf000
每次递归调用都新建一个栈帧,local 的地址随深度变化;s 只有一份,地址始终相同。
注:static 局部变量与全局变量共享同一块静态存储区,只是作用域被限制在函数内——"生命周期"和"可见范围"是两回事。
2.2 static 修饰全局变量与函数
static 修饰全局变量和函数的效果一致:链接属性变为内部链接,只有本翻译单元可见(第一部分已详述底层机制)。
C++ 中更推荐用匿名命名空间(unnamed namespace)达到同样效果,语义更清晰:
namespace
{
int hidden = 1; // 等价于 static int hidden = 1;
void helper() {} // 等价于 static void helper() {}
}
2.3 static 修饰类成员
静态成员变量属于类而不属于某个对象,所有对象共享同一份:
#include <iostream>
class Bank
{
public:
static int total; // 类内声明
static void add(int x) { total += x; }
};
int Bank::total = 0; // 类外定义(必须)
int main()
{
Bank::total = 100;
Bank::add(50);
std::cout << Bank::total << std::endl; // 输出 150
}
要点:
- 静态成员变量不占用对象大小(
sizeof(Bank)不含它),存放在静态存储区 - 类内只是声明,必须在类外定义一次;C++17 起可以用
inline static在类内直接定义,免去类外定义
静态成员函数:
- 没有
this指针,不依赖任何对象,可以直接用类名::函数名()调用 - 因此不能访问非静态成员——没有对象,就不知道访问谁的成员
从底层看,普通成员函数调用时要把对象地址作为 this 传入;静态成员函数没有这一步,等价于一个普通的内部函数,只是名字属于类。
2.4 合理性分析
static 的设计动机可以归结为三点:
- 隐藏实现:模块内部的工具函数、全局数据不想暴露给外部,内部链接避免了跨文件命名冲突
- 共享状态:类级别的共享数据用静态成员,比裸全局变量更可控,访问权限可以交给
public/private - 状态保持:函数内的 static 局部变量让函数能"记住"上一次的状态,又不会污染全局命名空间
代价:静态存储期的变量在程序运行期间一直占用内存,如果只是短暂使用,会造成浪费;另外静态成员的初始化顺序在不同翻译单元之间没有保证,跨文件的静态初始化依赖需要谨慎处理。
3. inline 关键字总结与深入分析
3.1 inline 修饰函数
inline 函数的历史含义是"建议编译器在调用点展开函数体",但在现代 C++ 中,它的主要作用是解决 ODR:允许函数定义出现在头文件中、被多个翻译单元包含而不冲突。
// math.h
inline int square(int x) { return x * x; } // 头文件中定义,多文件包含不报错
对比普通函数:
- 普通函数定义放头文件:多个翻译单元各自包含后,链接器报 multiple definition
- 加上
inline后,函数符号变为弱符号,链接器遇到多个定义时合并为一个
因此头文件库(header-only library)能成立,靠的就是 inline(以及模板的隐式内联语义)。
3.2 内联的底层:编译器如何展开
看一段代码在 -O2 优化下的汇编:
inline int square(int x) { return x * x; }
int main()
{
return square(5);
}
用 g++ -O2 -S -masm=intel 编译,main 里没有 call 指令,函数体被直接展开:
main:
mov eax, 25
ret
内联省去了 call/ret 指令、参数传递和栈帧建立的开销,代价是函数体被复制到每个调用点,产物体积增大。
inline 只是建议,编译器可以拒绝内联:
- 函数体过大,展开的代码膨胀代价超过调用开销
- 通过函数指针调用(调用目标在运行期才确定)
- 递归函数(无法有限展开)
- 虚函数(需要查虚表决定目标)
注:即使编译器没有内联,
inline的 ODR 豁免依然生效——内联与否是优化决策,允许重复定义是语言规则,两者相互独立。
3.3 inline 与宏的对比
宏是预处理器阶段的文本替换,inline 是编译阶段的真实函数。文本替换没有类型检查,且参数会被多次求值:
#define SQUARE(x) ((x) * (x)) // 宏:文本替换
inline int square(int x) { return x * x; } // 内联函数:真实函数
int bump(int& v) { return v++; } // 返回当前值并把 v 自增 1
int a = 5;
int b = SQUARE(bump(a)); // 宏:bump 被调用两次,a: 5 -> 6 -> 7,结果是 5*6=30
int c = 5;
int d = square(bump(c)); // inline:bump 只调用一次,c: 5 -> 6,结果是 5*5=25
编译并运行代码后,看到这样的输出:
30 7
25 6
宏把 bump(a) 原样复制了两份,参数被求值两次;inline 函数是真正的函数,参数只求值一次。
inline 函数还有宏没有的类型检查、作用域和调试信息,因此 C++ 中应优先用 inline 函数而非宏。
3.4 合理性分析
- extern 把"声明"与"定义"分离,是多文件协作的基础机制:接口在头文件里,实现散落在各
.cpp,编译单元之间通过符号引用耦合,链接器负责最终拼装 - inline 让"头文件即接口即实现"成为可能,是模板与头文件库能够广泛使用的前提
- 代价:内联展开会增加编译产物体积;inline 定义一旦修改,所有包含它的翻译单元都要重新编译,大型项目中会影响增量编译速度
三者组合起来看,正好覆盖了链接的三种需求:引用别处的定义(extern)、隐藏本地的定义(static)、共享同一份定义(inline)。


