文章目录

深入理解 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、全局变量、头文件声明、重复定义、链接错误以及不同存储期之间的关系就会变得清晰。

更多推荐