需要预先了解:


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
}

要点:

  1. 静态成员变量不占用对象大小(sizeof(Bank) 不含它),存放在静态存储区
  2. 类内只是声明,必须在类外定义一次;C++17 起可以用 inline static 在类内直接定义,免去类外定义

静态成员函数:

  • 没有 this 指针,不依赖任何对象,可以直接用 类名::函数名() 调用
  • 因此不能访问非静态成员——没有对象,就不知道访问谁的成员

从底层看,普通成员函数调用时要把对象地址作为 this 传入;静态成员函数没有这一步,等价于一个普通的内部函数,只是名字属于类。


2.4 合理性分析

static 的设计动机可以归结为三点:

  1. 隐藏实现:模块内部的工具函数、全局数据不想暴露给外部,内部链接避免了跨文件命名冲突
  2. 共享状态:类级别的共享数据用静态成员,比裸全局变量更可控,访问权限可以交给 public/private
  3. 状态保持:函数内的 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)。