深入理解 C/C++ 中的 `extern`:声明、定义、作用域、链接属性、存储期与生命周期
文章目录
- 深入理解 C/C++ 中的 `extern`:声明、定义、作用域、链接属性、存储期与生命周期
- 一、验证环境
- 二、`extern` 的核心作用
- 三、声明和定义的区别
- 四、完整示例:使用 `extern` 跨源文件共享变量
- 五、编译器和链接器分别做了什么
- 六、为什么必须区分作用域、链接属性、存储期和生命周期
- 七、自动存储期与静态存储期完整演示
- 八、四种存储期
- 九、`thread_local` 完整演示
- 十、动态存储期完整演示
- 十一、C 语言中的 `extern` 完整示例
- 十二、`extern` 不会延长局部变量生命周期
- 十三、函数内也可以出现 `extern`
- 十四、链接属性详解
- 十五、`static` 和 `extern` 的区别
- 十六、C 与 C++ 中全局 `const` 的重要区别
- 十七、常见错误一:只有声明,没有定义
- 十八、常见错误二:头文件中定义普通全局变量
- 十九、头文件保护不能解决重复定义问题
- 二十、C++17 的 `inline` 变量
- 二十一、函数为什么通常不需要显式写 `extern`
- 二十二、`extern` 数组
- 二十三、`extern thread_local`
- 二十四、`extern` 与 `extern "C"` 不是一回事
- 二十五、局部变量遮蔽全局变量
- 二十六、静态对象的初始化与销毁顺序
- 二十七、变量分类总表
- 二十八、如何判断一条变量声明
- 二十九、工程中的推荐写法
- 三十、最终总结
深入理解 C/C++ 中的 extern:声明、定义、作用域、链接属性、存储期与生命周期
前言
学习 C/C++ 变量时,经常会遇到下面这些关键字和术语:
extern
static
const
thread_local
inline
以及:
声明
定义
作用域
链接属性
存储期
生命周期
这些概念经常被混在一起。例如:
extern是不是用来定义全局变量的?extern会不会延长变量的生命周期?- 全局变量为什么可以跨文件访问?
static局部变量为什么离开函数后不会消失?- 为什么头文件中写全局变量会发生重复定义?
- 为什么 C 和 C++ 中的全局
const行为不同? - 编译器为什么能编译通过,最后却在链接阶段报错?
要彻底理解这些问题,必须先建立一个准确的分析模型:
作用域:这个名字在哪里可见?
链接属性:不同位置的同名声明是否指向同一个实体?
存储期:对象占用的存储空间存在多久?
生命周期:对象从构造完成到析构结束持续多久?
本文会通过完整、可编译的 C 和 C++ 示例,把这些概念串联起来。
一、验证环境
本文代码在以下环境中实际编译运行:
gcc (Debian 14.2.0-19) 14.2.0
g++ (Debian 14.2.0-19) 14.2.0
CMake 3.31.6
C++ 示例使用:
-std=c++17 -Wall -Wextra -pedantic
C 示例使用:
-std=c17 -Wall -Wextra -pedantic
二、extern 的核心作用
最常见的 extern 声明如下:
extern int g_value;
它表达的意思是:
程序中存在一个名为
g_value的int对象,它的定义可能位于当前文件的其他位置,也可能位于其他源文件中。
这里的关键点是:
extern 声明通常不会创建对象
extern 声明通常不会分配对象的存储空间
extern 声明让当前翻译单元可以引用外部实体
例如:
extern int g_value;
通常只是声明。
而下面这句:
int g_value = 100;
才是定义,它会真正创建对象并分配存储空间。
可以简单理解为:
声明:告诉编译器“这个东西存在”
定义:真正把这个东西创建出来
三、声明和定义的区别
3.1 声明
声明向编译器提供名称和类型信息:
extern int g_value;
编译器看到这个声明后,就知道下面的代码是否合法:
g_value = 100;
std::cout << g_value;
但当前编译阶段不要求立刻找到 g_value 的存储空间。
3.2 定义
定义会真正创建对象:
int g_value = 100;
对于普通全局变量,这句代码通常完成:
确定变量类型
分配存储空间
初始化变量
生成可供链接器查找的符号
3.3 带初始化器的 extern
下面这句不是单纯声明:
extern int g_value = 100;
它带有初始化器,因此属于定义。
也就是说:
extern int g_value; // 声明
extern int g_value = 100; // 定义
因此,一般不要在头文件中写:
extern int g_value = 100;
因为头文件可能被多个源文件包含,从而产生多个定义。
四、完整示例:使用 extern 跨源文件共享变量
项目结构如下:
01_cpp_extern/
├── global.h
├── global.cpp
└── main.cpp
4.1 头文件:global.h
#pragma once
// 声明全局变量。
// 这里不创建 g_counter 对象。
extern int g_counter;
// const 全局对象也可以通过 extern 声明为外部对象。
extern const int g_limit;
// 普通函数声明默认就具有外部链接。
void increment_counter();
// 返回 global.cpp 中看到的 g_counter 地址,
// 用于验证不同翻译单元访问的是同一个对象。
const int* counter_address_from_global_cpp();
4.2 定义文件:global.cpp
#include "global.h"
// 真正定义 g_counter,并进行零初始化。
int g_counter = 0;
// global.h 中已经存在 extern const 声明,
// 因此这里定义的是具有外部链接的 const 对象。
const int g_limit = 3;
void increment_counter() {
if (g_counter < g_limit) {
++g_counter;
}
}
const int* counter_address_from_global_cpp() {
return &g_counter;
}
4.3 使用文件:main.cpp
#include "global.h"
#include <iostream>
int main() {
std::cout << std::boolalpha;
std::cout
<< "initial g_counter = "
<< g_counter
<< '\n';
increment_counter();
increment_counter();
std::cout
<< "after two increments = "
<< g_counter
<< '\n';
std::cout
<< "g_limit = "
<< g_limit
<< '\n';
// 比较 main.cpp 和 global.cpp 中取得的地址,
// 验证两个翻译单元访问的是同一个对象。
std::cout
<< "same object = "
<< (&g_counter == counter_address_from_global_cpp())
<< '\n';
}
4.4 编译命令
g++ \
-std=c++17 \
-Wall \
-Wextra \
-pedantic \
global.cpp \
main.cpp \
-o app
运行:
./app
4.5 实际运行结果
initial g_counter = 0
after two increments = 2
g_limit = 3
same object = true
最后一行输出:
same object = true
说明:
main.cpp 中的 g_counter
和
global.cpp 中的 g_counter
确实是同一个对象,而不是两个名字相同的独立变量。
五、编译器和链接器分别做了什么
对两个源文件分别编译:
g++ -std=c++17 -c global.cpp -o global.o
g++ -std=c++17 -c main.cpp -o main.o
然后使用 nm 查看符号:
nm -C global.o
nm -C main.o
筛选相关符号:
nm -C global.o |
grep -E 'g_counter|g_limit|increment_counter|counter_address'
nm -C main.o |
grep -E 'g_counter|g_limit|increment_counter|counter_address'
实际结果如下。
5.1 global.o
0000000000000000 T increment_counter()
0000000000000021 T counter_address_from_global_cpp()
0000000000000000 B g_counter
0000000000000000 R g_limit
这里:
T:当前目标文件中已经定义的代码符号
B:位于 BSS 段中的全局对象
R:位于只读数据段中的全局对象
说明 global.o 真正定义了:
g_counter
g_limit
increment_counter()
counter_address_from_global_cpp()
5.2 main.o
U increment_counter()
U counter_address_from_global_cpp()
U g_counter
U g_limit
U 表示:
Undefined
当前目标文件引用了这个符号,但没有在当前目标文件中定义它
注意,这里的“undefined”不是说程序一定错误,而是:
main.o自己没有这些定义,需要链接器到其他目标文件中查找。
链接时:
g++ global.o main.o -o app
链接器会在 global.o 中找到对应定义:
main.o 中的 U g_counter
│
▼
global.o 中的 B g_counter
最终生成可执行文件。
完整流程如下:
global.cpp
│ 编译
▼
global.o
包含 g_counter 的定义
包含 g_limit 的定义
包含函数定义
│
├──────────────┐
│ │
▼ ▼
链接器
▲ ▲
│ │
main.cpp │
│ 编译 │
▼ │
main.o ────────────┘
包含对 g_counter 等符号的外部引用
六、为什么必须区分作用域、链接属性、存储期和生命周期
这四个概念分别回答不同问题。
6.1 作用域:名字在哪里可见
作用域关注的是名字。
void function() {
int value = 10;
std::cout << value << '\n';
}
// 这里不能使用 value
value 的名字只在函数块中可见。
作用域回答:
在源代码的哪些位置,可以通过名称
value访问这个对象?
6.2 链接属性:不同声明是否表示同一个实体
链接属性关注的是:
出现在不同作用域、不同源文件中的同名声明,是否表示同一个对象或函数?
常见链接属性包括:
无链接
内部链接
外部链接
例如:
int g_value = 10;
命名空间作用域的普通非 const 变量通常具有外部链接。
其他源文件可以通过:
extern int g_value;
访问同一个对象。
6.3 存储期:存储空间存在多久
存储期关注的是对象所占用的存储空间。
C/C++ 中常见四种存储期:
自动存储期
静态存储期
线程存储期
动态存储期
6.4 生命周期:对象什么时候真正有效
生命周期关注:
对象什么时候构造完成
对象什么时候可以被合法使用
对象什么时候析构
对象什么时候不再存在
对于简单变量,生命周期和存储期经常重合。
但它们不是严格相同的概念。
例如,定位 new 可以在已经存在的存储空间中开始一个新对象的生命周期:
#include <new>
struct Object {
Object() = default;
~Object() = default;
};
alignas(Object) unsigned char buffer[sizeof(Object)];
int main() {
// buffer 的存储空间早已存在。
Object* object = new (buffer) Object;
// Object 对象生命周期结束。
object->~Object();
// buffer 的存储空间仍然存在。
}
因此:
存储期:内存空间存在多久
生命周期:内存中的对象存在多久
七、自动存储期与静态存储期完整演示
下面通过构造函数和析构函数,观察不同对象的生命周期。
完整代码:
#include <iostream>
#include <string>
#include <utility>
class Tracer {
public:
explicit Tracer(std::string name)
: name_(std::move(name)) {
std::cout
<< "construct: "
<< name_
<< '\n';
}
~Tracer() {
std::cout
<< "destroy: "
<< name_
<< '\n';
}
private:
std::string name_;
};
// 命名空间作用域对象。
// 具有静态存储期。
Tracer global_object("global object");
void automatic_demo() {
// 普通局部变量。
// 具有自动存储期。
Tracer automatic_object("automatic local object");
std::cout << "inside automatic_demo\n";
}
void static_demo() {
// 名字作用域仍然只在 static_demo 内部,
// 但对象具有静态存储期。
static Tracer static_local_object("static local object");
std::cout << "inside static_demo\n";
}
int main() {
std::cout << "enter main\n";
automatic_demo();
automatic_demo();
static_demo();
static_demo();
std::cout << "leave main\n";
}
7.1 编译命令
g++ \
-std=c++17 \
-Wall \
-Wextra \
-pedantic \
main.cpp \
-o app
7.2 实际运行结果
construct: global object
enter main
construct: automatic local object
inside automatic_demo
destroy: automatic local object
construct: automatic local object
inside automatic_demo
destroy: automatic local object
construct: static local object
inside static_demo
inside static_demo
leave main
destroy: static local object
destroy: global object
7.3 自动局部变量分析
每次调用:
automatic_demo();
都会创建一个新的:
Tracer automatic_object("automatic local object");
调用流程:
进入 automatic_demo
│
▼
构造 automatic_object
│
▼
执行函数体
│
▼
离开 automatic_demo
│
▼
析构 automatic_object
因此调用两次,就会构造和析构两次。
自动局部变量的特征:
作用域:当前代码块
链接属性:无链接
存储期:自动存储期
生命周期:执行到定义处开始,离开作用域时结束
7.4 静态局部变量分析
static Tracer static_local_object("static local object");
第一次调用 static_demo() 时:
构造 static_local_object
第二次调用时不会再次构造。
实际输出中只有一次:
construct: static local object
但有两次:
inside static_demo
inside static_demo
说明对象只初始化一次,后续调用继续使用同一个对象。
静态局部变量的特征:
作用域:当前代码块
链接属性:通常无链接
存储期:静态存储期
生命周期:首次初始化完成后,一直持续到程序结束
特别需要注意:
static局部变量的作用域没有扩大。
下面是错误的:
void function() {
static int value = 10;
}
int main() {
// 错误:value 的名字不在这里可见
std::cout << value;
}
static 延长的是对象的存储时间,不是名称的可见范围。
八、四种存储期
8.1 自动存储期
普通局部变量和函数参数通常具有自动存储期:
void function(int parameter) {
int local_value = 10;
}
通常在执行进入相应代码块后创建,离开代码块时销毁。
8.2 静态存储期
以下对象通常具有静态存储期:
int global_value = 10;
static int file_value = 20;
void function() {
static int local_value = 30;
}
它们占用的存储空间通常贯穿整个程序运行阶段。
但是初始化时机可能不同:
全局对象:通常在 main 执行前完成初始化
局部 static:第一次执行到声明处时初始化
8.3 线程存储期
使用:
thread_local
声明的对象具有线程存储期。
每个线程拥有独立实例。
8.4 动态存储期
通过动态分配创建的对象具有动态存储期。
C:
int* pointer = malloc(sizeof(int));
free(pointer);
C++:
int* pointer = new int(10);
delete pointer;
现代 C++ 推荐:
auto pointer = std::make_unique<int>(10);
九、thread_local 完整演示
完整代码:
#include <iostream>
#include <string>
#include <thread>
#include <utility>
class ThreadTracer {
public:
explicit ThreadTracer(std::string name)
: name_(std::move(name)) {
std::cout
<< "construct: "
<< name_
<< '\n';
}
~ThreadTracer() {
std::cout
<< "destroy: "
<< name_
<< '\n';
}
private:
std::string name_;
};
// 每个线程都有自己独立的 tls_counter。
thread_local int tls_counter = 0;
// 每个真正使用该对象的线程,
// 都会拥有自己独立的 tls_object。
thread_local ThreadTracer tls_object("thread-local object");
void worker(int id) {
// 强制当前线程使用 tls_object,
// 触发当前线程中的初始化。
(void)tls_object;
++tls_counter;
++tls_counter;
std::cout
<< "worker "
<< id
<< ": tls_counter = "
<< tls_counter
<< '\n';
}
int main() {
// 为了使输出顺序稳定,先启动并等待第一个线程。
std::thread first(worker, 1);
first.join();
// 第一个线程结束后,再启动第二个线程。
std::thread second(worker, 2);
second.join();
std::cout
<< "main thread: tls_counter = "
<< tls_counter
<< '\n';
}
9.1 编译命令
g++ \
-std=c++17 \
-Wall \
-Wextra \
-pedantic \
-pthread \
main.cpp \
-o app
9.2 实际运行结果
construct: thread-local object
worker 1: tls_counter = 2
destroy: thread-local object
construct: thread-local object
worker 2: tls_counter = 2
destroy: thread-local object
main thread: tls_counter = 0
9.3 结果分析
线程 1 执行:
++tls_counter;
++tls_counter;
得到:
worker 1: tls_counter = 2
线程 1 结束后,它自己的线程局部对象被销毁:
destroy: thread-local object
线程 2 又得到一份全新的实例:
construct: thread-local object
worker 2: tls_counter = 2
主线程中的 tls_counter 从未被两个工作线程修改,因此仍然是:
main thread: tls_counter = 0
逻辑结构如下:
主线程:
tls_counter = 0
线程 1:
tls_counter = 0
↓
tls_counter = 2
↓
线程结束,实例销毁
线程 2:
tls_counter = 0
↓
tls_counter = 2
↓
线程结束,实例销毁
十、动态存储期完整演示
动态对象的生命周期不取决于创建它的函数是否已经返回。
完整代码:
#include <iostream>
#include <memory>
#include <string>
#include <utility>
class Tracer {
public:
explicit Tracer(std::string name)
: name_(std::move(name)) {
std::cout
<< "construct: "
<< name_
<< '\n';
}
~Tracer() {
std::cout
<< "destroy: "
<< name_
<< '\n';
}
private:
std::string name_;
};
std::unique_ptr<Tracer> create_object() {
// pointer 是局部变量,具有自动存储期。
//
// make_unique 创建的 Tracer 对象,
// 具有动态存储期。
auto pointer =
std::make_unique<Tracer>("dynamic object");
std::cout << "return from create_object\n";
// 返回 unique_ptr,把动态对象的所有权交给调用者。
return pointer;
}
int main() {
std::cout << "before create_object\n";
auto object = create_object();
std::cout << "after create_object\n";
// 主动释放动态对象。
object.reset();
std::cout << "after reset\n";
}
10.1 编译命令
g++ \
-std=c++17 \
-Wall \
-Wextra \
-pedantic \
main.cpp \
-o app
10.2 实际运行结果
before create_object
construct: dynamic object
return from create_object
after create_object
destroy: dynamic object
after reset
10.3 结果分析
当 create_object() 返回时,输出是:
return from create_object
after create_object
中间没有出现:
destroy: dynamic object
说明函数返回后,动态对象仍然存在。
直到调用:
object.reset();
才输出:
destroy: dynamic object
这里同时存在两个不同对象:
pointer / object:
std::unique_ptr 类型的局部变量
具有自动存储期
dynamic object:
由 make_unique 创建的 Tracer 对象
具有动态存储期
可以表示为:
create_object 中的 pointer
│
│ 管理
▼
堆上的 Tracer 对象
│
│ 所有权转移
▼
main 中的 object
│
│ reset
▼
Tracer 对象析构并释放存储空间
十一、C 语言中的 extern 完整示例
C 语言中跨文件共享全局变量的基本方式与 C++ 类似。
项目结构:
06_c_extern/
├── global.h
├── global.c
└── main.c
11.1 global.h
#ifndef GLOBAL_H
#define GLOBAL_H
extern int g_value;
void add_value(int delta);
#endif
11.2 global.c
#include "global.h"
int g_value = 10;
void add_value(int delta) {
g_value += delta;
}
11.3 main.c
#include "global.h"
#include <stdio.h>
int main(void) {
printf(
"initial g_value = %d\n",
g_value
);
add_value(5);
printf(
"after add_value = %d\n",
g_value
);
return 0;
}
11.4 编译命令
gcc \
-std=c17 \
-Wall \
-Wextra \
-pedantic \
global.c \
main.c \
-o app
11.5 实际运行结果
initial g_value = 10
after add_value = 15
说明:
global.c 定义 g_value
main.c 通过 extern 声明访问同一个 g_value
十二、extern 不会延长局部变量生命周期
下面的代码是错误的:
int& get_value() {
int value = 10;
return value;
}
value 是自动局部变量。
函数返回时:
value 的生命周期结束
value 所占用的自动存储空间失效
返回引用会形成悬空引用。
不能通过在其他地方写:
extern int value;
让这个局部变量继续存在。
原因是:
extern 负责声明和链接
extern 不改变局部对象的存储期
extern 不延长局部对象的生命周期
如果确实需要函数返回后继续使用对象,可以选择:
int get_value() {
int value = 10;
return value;
}
或者:
const int& get_value() {
static const int value = 10;
return value;
}
或者动态管理:
std::unique_ptr<int> get_value() {
return std::make_unique<int>(10);
}
十三、函数内也可以出现 extern
完整示例:
#include <iostream>
int global_value = 100;
void increase() {
// 这里没有创建局部变量。
// 它声明的是外部的 global_value。
extern int global_value;
global_value += 10;
}
int main() {
increase();
std::cout
<< global_value
<< '\n';
}
输出:
110
函数内部的:
extern int global_value;
具有以下特点:
名字的作用域:从声明位置到当前代码块结束
链接属性:外部链接
对象存储期:由真正定义决定
不过工程中通常不推荐在函数内部临时声明全局变量。
更规范的方式是:
// global.h
extern int global_value;
然后需要使用的源文件统一包含:
#include "global.h"
这样编译器可以帮助检查声明和定义的类型是否一致。
十四、链接属性详解
14.1 无链接
普通局部变量通常没有链接属性:
void first() {
int value = 10;
}
void second() {
int value = 20;
}
两个 value 是完全不同的对象。
它们之间没有任何链接关系。
14.2 内部链接
内部链接表示:
这个名字只在当前翻译单元中表示对应实体。
例如:
static int internal_value = 10;
其他源文件不能通过:
extern int internal_value;
访问它。
因为原对象具有内部链接。
C++ 中还可以使用匿名命名空间:
namespace {
int internal_value = 10;
void internal_function() {
}
}
匿名命名空间中的名字通常具有翻译单元内部可见的链接效果。
14.3 外部链接
外部链接表示:
不同翻译单元中的声明可以表示同一个实体。
例如:
// global.cpp
int value = 100;
其他源文件:
// main.cpp
extern int value;
两者可以在链接阶段关联为同一个对象。
十五、static 和 extern 的区别
15.1 命名空间或文件作用域
int first = 1;
extern int second;
static int third = 3;
对应关系:
| 写法 | 是否定义 | 链接属性 | 存储期 |
|---|---|---|---|
int first = 1; | 是 | 通常外部链接 | 静态存储期 |
extern int second; | 通常否 | 外部链接 | 由定义决定 |
static int third = 3; | 是 | 内部链接 | 静态存储期 |
这里的 static 主要改变链接属性。
因为文件作用域变量本来就具有静态存储期。
15.2 函数内部
void function() {
int first = 1;
static int second = 2;
extern int third;
}
分别表示:
first:
普通局部对象
自动存储期
second:
名字只在函数中可见
对象具有静态存储期
third:
外部对象的局部声明
没有创建新的局部对象
十六、C 与 C++ 中全局 const 的重要区别
这是 C 和 C++ 中非常容易出错的地方。
16.1 C++:命名空间作用域 const 默认具有内部链接
头文件:
// config.h
#pragma once
const int kValue = 42;
两个源文件分别包含它。
first.cpp
#include "config.h"
const int* address_from_first() {
return &kValue;
}
second.cpp
#include "config.h"
const int* address_from_second() {
return &kValue;
}
main.cpp
#include <iostream>
const int* address_from_first();
const int* address_from_second();
int main() {
std::cout << std::boolalpha;
std::cout
<< "value from first = "
<< *address_from_first()
<< '\n';
std::cout
<< "value from second = "
<< *address_from_second()
<< '\n';
std::cout
<< "same object = "
<< (
address_from_first()
==
address_from_second()
)
<< '\n';
}
编译:
g++ \
-std=c++17 \
-Wall \
-Wextra \
-pedantic \
first.cpp \
second.cpp \
main.cpp \
-o app
实际运行结果:
value from first = 42
value from second = 42
same object = false
说明:
first.cpp 中有一份 kValue
second.cpp 中也有一份 kValue
它们的值相同,但不是同一个对象。
查看符号:
nm -C first.o | grep kValue
nm -C second.o | grep kValue
实际结果:
0000000000000000 r kValue
小写 r 表示局部只读符号。
因此 C++ 中:
const int kValue = 42;
放在头文件中通常不会发生普通的重复外部定义,因为每个翻译单元得到独立的内部链接对象。
16.2 让 C++ 的 const 对象跨文件共享
头文件:
#pragma once
extern const int kValue;
源文件:
#include "config.h"
const int kValue = 42;
使用方:
#include "config.h"
#include <iostream>
int main() {
std::cout << kValue << '\n';
}
这里所有翻译单元访问的是同一个 kValue 对象。
16.3 C:文件作用域 const 默认通常具有外部链接
在 C 语言头文件中直接写:
#ifndef CONFIG_H
#define CONFIG_H
const int kValue = 42;
#endif
然后两个 .c 文件分别包含该头文件:
first.c
#include "config.h"
int first(void) {
return kValue;
}
second.c
#include "config.h"
int second(void) {
return kValue;
}
main.c
int first(void);
int second(void);
int main(void) {
return first() + second();
}
编译:
gcc \
-std=c17 \
-Wall \
-Wextra \
-pedantic \
first.c \
second.c \
main.c \
-o app
实际链接失败:
/usr/bin/ld: ...:(.rodata+0x0): multiple definition of `kValue';
...:(.rodata+0x0): first defined here
collect2: error: ld returned 1 exit status
原因是:
C 中的文件作用域 const 默认并不会像 C++ 那样自动获得内部链接
因此不能直接在 C 头文件中定义这种对象。
正确方式:
// config.h
#ifndef CONFIG_H
#define CONFIG_H
extern const int kValue;
#endif
// config.c
#include "config.h"
const int kValue = 42;
十七、常见错误一:只有声明,没有定义
代码:
#include <iostream>
extern int g_missing;
int main() {
std::cout
<< g_missing
<< '\n';
}
编译命令:
g++ \
-std=c++17 \
-Wall \
-Wextra \
-pedantic \
main.cpp \
-o app
实际结果:
/usr/bin/ld: ...: undefined reference to `g_missing'
collect2: error: ld returned 1 exit status
注意,这通常不是 C++ 语法编译错误。
编译器看到:
extern int g_missing;
后认为:
g_missing 是一个合法的外部 int 对象
定义可以稍后由链接器查找
因此源文件可以正常编译为目标文件。
但链接器检查完整程序时发现:
没有任何目标文件真正定义 g_missing
所以报:
undefined reference
解决方式是在某个源文件中提供定义:
int g_missing = 0;
十八、常见错误二:头文件中定义普通全局变量
错误头文件:
// global.h
#pragma once
int g_value = 10;
first.cpp
#include "global.h"
int first() {
return g_value;
}
second.cpp
#include "global.h"
int second() {
return g_value;
}
main.cpp
int first();
int second();
int main() {
return first() + second();
}
编译:
g++ \
-std=c++17 \
-Wall \
-Wextra \
-pedantic \
first.cpp \
second.cpp \
main.cpp \
-o app
实际结果:
/usr/bin/ld: ...:(.data+0x0): multiple definition of `g_value';
...:(.data+0x0): first defined here
collect2: error: ld returned 1 exit status
原因是预处理后,相当于:
// first.cpp
int g_value = 10;
// second.cpp
int g_value = 10;
程序中出现了两个具有外部链接的 g_value 定义。
正确写法:
// global.h
#pragma once
extern int g_value;
// global.cpp
#include "global.h"
int g_value = 10;
其他源文件只包含头文件:
#include "global.h"
十九、头文件保护不能解决重复定义问题
有人可能认为:
#pragma once
或者:
#ifndef GLOBAL_H
#define GLOBAL_H
int g_value = 10;
#endif
可以避免重复定义。
这是错误理解。
头文件保护只能防止:
同一个头文件在同一个翻译单元中被重复展开。
它不能防止:
first.cpp 展开一份 global.h
second.cpp 又展开一份 global.h
因为不同源文件分别形成不同翻译单元。
例如:
first.cpp + global.h
↓
第一个翻译单元
second.cpp + global.h
↓
第二个翻译单元
两个翻译单元中仍然会各自出现:
int g_value = 10;
最终产生重复定义。
二十、C++17 的 inline 变量
C++17 允许在头文件中定义内联变量:
// config.h
#pragma once
inline int g_value = 10;
它可以被多个翻译单元包含,而不会像普通全局变量那样产生重复定义错误。
多个定义会被视为同一个内联变量实体。
例如:
namespace config {
inline int port = 8080;
inline constexpr int max_connections = 1024;
}
使用:
#include "config.h"
#include <iostream>
int main() {
std::cout
<< config::port
<< '\n';
std::cout
<< config::max_connections
<< '\n';
}
传统方式与 C++17 方式对比:
传统 extern
// config.h
extern int port;
// config.cpp
int port = 8080;
C++17 inline
// config.h
inline int port = 8080;
对于大型对象、需要隐藏实现的对象或需要严格控制初始化位置的对象,传统 extern 方式仍然很常见。
二十一、函数为什么通常不需要显式写 extern
普通非成员函数声明默认具有外部链接。
下面两种写法基本等价:
int add(int left, int right);
extern int add(int left, int right);
因此工程中通常直接写:
// math.h
int add(int left, int right);
定义:
// math.cpp
int add(int left, int right) {
return left + right;
}
没有必要显式写:
extern int add(int left, int right);
二十二、extern 数组
头文件中可以声明外部数组:
#pragma once
extern int numbers[];
源文件中定义:
#include "data.h"
int numbers[] = {
10,
20,
30,
40
};
其他文件可以访问:
#include "data.h"
void change_first() {
numbers[0] = 100;
}
但只有:
extern int numbers[];
时,当前翻译单元并不知道数组长度。
因此不能可靠地在使用方执行:
sizeof(numbers);
更完整的设计是同时导出元素数量:
// data.h
#pragma once
#include <cstddef>
extern int numbers[];
extern const std::size_t numbers_count;
// data.cpp
#include "data.h"
int numbers[] = {
10,
20,
30,
40
};
const std::size_t numbers_count =
sizeof(numbers) / sizeof(numbers[0]);
二十三、extern thread_local
如果一个线程局部变量定义在其他源文件中,可以这样声明。
头文件:
#pragma once
extern thread_local int current_task_id;
定义文件:
#include "context.h"
thread_local int current_task_id = -1;
使用文件:
#include "context.h"
void set_current_task(int id) {
current_task_id = id;
}
这里两个关键字分别负责不同事情:
extern:
声明定义位于其他位置
thread_local:
每个线程拥有独立对象
二十四、extern 与 extern "C" 不是一回事
普通 extern:
extern int g_value;
主要用于声明外部对象。
而:
extern "C" int add(int left, int right);
表示使用 C 语言链接规范。
它主要用于:
C 与 C++ 互操作
C++ 与 Rust 互操作
动态库导出 C ABI
避免 C++ 函数名称修饰
例如 C++ 函数:
extern "C" int add(int left, int right) {
return left + right;
}
其导出符号通常是:
add
而不是经过 C++ 名称修饰后的符号。
两者区别:
extern int value;
↓
外部对象声明
extern "C" int function();
↓
指定 C 语言链接规范
二十五、局部变量遮蔽全局变量
代码:
#include <iostream>
int value = 100;
void function() {
int value = 200;
std::cout
<< value
<< '\n';
std::cout
<< ::value
<< '\n';
}
int main() {
function();
}
输出:
200
100
函数中的:
int value = 200;
遮蔽了全局:
int value = 100;
C++ 可以通过:
::value
明确访问全局命名空间中的变量。
这里不需要重新写:
extern int value;
因为全局变量已经在当前翻译单元中声明。
二十六、静态对象的初始化与销毁顺序
同一翻译单元中,命名空间作用域对象通常按定义顺序初始化,并按相反顺序销毁。
Tracer first("first");
Tracer second("second");
Tracer third("third");
构造顺序:
first
second
third
析构顺序:
third
second
first
但是不同翻译单元中的非局部静态对象,不应该随意依赖彼此的动态初始化顺序。
例如:
// first.cpp
Config global_config = create_config();
// second.cpp
Logger global_logger(global_config);
如果 global_logger 初始化时依赖 global_config 已经完成动态初始化,就可能出现静态初始化顺序问题。
常见解决方式是函数局部静态对象:
Config& get_config() {
static Config config = create_config();
return config;
}
使用:
Logger& get_logger() {
static Logger logger(get_config());
return logger;
}
对象在第一次真正调用函数时初始化。
这通常称为:
Construct on First Use
首次使用时构造
从 C++11 开始,函数局部静态对象的初始化由语言保证只完成一次,即使多个线程同时首次进入。
但初始化完成后的普通读写操作,仍然需要程序自己保证线程安全。
二十七、变量分类总表
| 声明或定义方式 | 名字作用域 | 链接属性 | 存储期 |
|---|---|---|---|
函数内 int value; | 块作用域 | 无链接 | 自动存储期 |
函数参数 int value | 函数参数作用域 | 无链接 | 通常自动存储期 |
函数内 static int value; | 块作用域 | 通常无链接 | 静态存储期 |
函数内 extern int value; | 块作用域 | 外部链接 | 由定义决定 |
命名空间作用域 int value; | 命名空间作用域 | 通常外部链接 | 静态存储期 |
文件作用域 static int value; | 文件或命名空间作用域 | 内部链接 | 静态存储期 |
文件作用域 extern int value; | 文件或命名空间作用域 | 外部链接 | 本身通常只是声明 |
thread_local int value; | 取决于定义位置 | 取决于声明形式 | 线程存储期 |
extern thread_local int value; | 取决于声明位置 | 外部链接 | 线程存储期 |
new Object 创建的对象 | 通过表达式或指针访问 | 对象名称通常不适用 | 动态存储期 |
C++ 命名空间作用域 const int value | 命名空间作用域 | 默认内部链接 | 静态存储期 |
C++ extern const int value | 命名空间作用域 | 外部链接 | 由定义决定 |
C++17 inline int value | 命名空间作用域 | 通常外部链接 | 静态存储期 |
二十八、如何判断一条变量声明
看到一条声明时,可以按照以下顺序分析。
例如:
extern thread_local int current_id;
第一步:它是声明还是定义
没有初始化器:
extern thread_local int current_id;
通常只是声明。
第二步:名称在哪里可见
如果它写在头文件的命名空间作用域:
从声明位置开始,在当前命名空间作用域内可见
第三步:它是否具有链接属性
extern 表明它对应一个具有外部链接的实体。
不同翻译单元中的声明可以指向同一个变量定义。
第四步:对象存储多久
thread_local 表示:
每个线程拥有自己的对象实例
对象具有线程存储期
第五步:在哪里提供定义
某个源文件需要提供:
thread_local int current_id = -1;
二十九、工程中的推荐写法
普通共享全局变量
头文件:
#pragma once
extern int global_value;
源文件:
#include "global.h"
int global_value = 0;
共享只读对象
头文件:
#pragma once
extern const int global_limit;
源文件:
#include "config.h"
const int global_limit = 100;
C++17 编译期常量
#pragma once
namespace config {
inline constexpr int default_port = 8080;
inline constexpr int max_connections = 1024;
}
翻译单元私有对象
namespace {
int internal_counter = 0;
}
或者:
static int internal_counter = 0;
复杂全局对象
优先考虑函数局部静态对象:
Config& get_config() {
static Config config;
return config;
}
而不是直接暴露可变全局对象。
三十、最终总结
extern 的核心作用是:
声明一个通常具有外部链接的对象或函数,使当前翻译单元可以引用定义在其他位置的同一个实体。
最常见的模式:
// global.h
extern int global_value;
// global.cpp
int global_value = 0;
// main.cpp
#include "global.h"
int main() {
global_value = 100;
}
必须牢记:
extern 不等于定义
extern 不负责分配对象存储空间
extern 不会延长局部变量生命周期
extern 不会扩大局部变量作用域
extern 不等于 extern "C"
变量需要从四个维度分析:
作用域
名字在哪里可见
链接属性
不同位置的声明是否指向同一个实体
存储期
对象占用的存储空间存在多久
生命周期
对象从构造完成到析构结束持续多久
最后形成完整心智模型:
源代码中写下声明
│
▼
编译器检查名称和类型
│
▼
生成目标文件
│
▼
extern 引用暂时表现为未解析符号
│
▼
链接器在其他目标文件或库中查找定义
│
├── 找到唯一匹配定义
│ ↓
│ 生成可执行文件
│
├── 没找到
│ ↓
│ undefined reference
│
└── 找到多个
↓
multiple definition
理解了这条完整链路,extern、全局变量、头文件声明、重复定义、链接错误以及不同存储期之间的关系就会变得清晰。
更多推荐


所有评论(0)