Skip to content

C && C++

一、基础

1.1 编译

c/c++ 源代码一般会通过以下步骤,被编译、链接成可执行文件:

  1. 预处理(Preprocess) : gcc -E test.cpp -o test.i
  2. 编译为汇编(Compilation): gcc -S test.i -o test.s
  3. 汇编(Assembly): gcc -c test.s -o test.o
  4. 连接(Linking): gcc test.o -o test

当然,上述步骤可以直接通过 gcc define.cpp test.cpp -o test 一步到位.

1.2 标准库

c && c++ 标准定义了语法、语言特性 以及 C++ 内置库(也就是C++标准库)的实现规范, 但不同的编译器对标准库的实现方法各不一致

SubjectContentStatus
语言特性C++ 11/14/17 标准、多态、模版
异常标准异常、VEH、SEH、TopLevelEH...
调优内存、性能
编译运行编译、运行期的 action 和 mechanism
Designis-a(Inheritance) 和 has-a(Composition)
平台特性window、linux
技巧Tricks and Traps

1.3 Feature

VersionNameSampleNote
C++11rvalue referenceint &&ref_a_left = 6; 右值引用 和 移动语义
C++11move semanticsstd::movemove and move contructor
C++11perfect forwardingstd::forward移动语义 和 完美转发
C++11decltypeint n = 3; decltype(n) t = n + 4;类型推导, 以普通表达式作为参数返回该表达式的类型, 注意,decltype 不会对表达式进行求值
C++11autoauto a = 4 + 6;类型推导, 从变量声明的初始化表达式获得变量的类型
C++14"auto" return type deductiontemplate <typename A, typename B>
auto do_something(const A& a, const B& b)
{ return a.do_something(b); }
C++11trailing-return typeauto foo(int p) -> int { return p * 42; }auto, decltype, trailing-return type
C++11lambdaauto aLambda = [](auto a, auto b) { return a < b; };
C++11constexprconstexpr int x = 5;
C++11strongly typed enumsenum class : char { };
C++11static assertstatic_assert(sizeof(int) == 4);编译期的断言
C++11range based forfor (auto x : vec) { /* TODO */ }
C++11type aliasusing func = void (*) (int, int);using、typedef、typename
using 可读性更高,且可以用于模板别名The "typename" keyword
C++11unicode stringsconst char16_t* utf16 = u"foo";
const char32_t* utf32 = U"foo";
C++11overridestruct A { virtual void foo() {}; };
struct B : public A { void foo() override {}; };
C++17constexpr ifif constexpr (sizeof(int) == 1) return 0;
C++17template autotemplate <auto Value>
struct constant { static constexpr auto value = Value;};
type and non-type template parameters
C++17nested namespace definitionnamespace A::B::C { class X {}; }
C++17inline variableclass X { static inline int field = 2; };
C++17structured bindingint arr[3] = {3,4,5};
auto [a, b, c] = arr;
auto [x, y, z] = std::make_tuple("str", 0.4, 1);
C++20designated initializerstruct A { int x; int y; int z; };
A a { .x = 1, .z = 2 };
C++20modulesexport module A; import A; import std.core;
C++20constevalstatic consteval int square(int n) { return n * n; }
C++20constinitconstinit auto sqrOf5 = square(5);
C++20concepttemplate<typename T>
concept Integral = std::is_integral<T>::value;
C++11attribute specifier sequence[[gnu::const]] [[nodiscard]]
inline int f();
C++enumerators with attributesenum class myEnum{ value1 [[anAttribute]], }; ??
C++11std::integral_constant包装特定类型的静态常量。它是 C++ 类型特征的基类。
C++11constexpr length for const stringtemplate<class T>
constexpr size_t const_str_len(const T* str) { return (*str == 0) ? 0 : const_str_len(str + 1) + 1; }
C++17constexpr length for const stringconstexpr auto l = std::char_traits<char>::length("123");

Thriving in a Crowded and Changing World: C++ 2006–2020

这是 C++ 之父 Bjarne Stroustrup 的 HOPL4 论文的标题。

HOPL(History of Programming Languages,编程语言历史)ACM(Association of Computing Machines,国际计算机协会)旗下的一个会议,约每十五年举办一次。

Bjarne 的这篇论文是他为 2021 年 HOPL IV 会议准备的论文,它涵盖了 C++98 之后的所有 C++ 版本,从 C++11 直到 C++20。

鉴于这篇论文可以帮助 C++ 从业者对 C++ 的设计原则和历史有一个系统的了解,全球 C++ 及系统软件技术大会的主办方 Boolan 组织了一群译者,把这篇重要论文翻译成了中文: 在拥挤和变化的世界中茁壮成长:C++ 2006–2020

二、多态

2.1 虚函数表内存模型

虚函数表是 C++ 等语言实现动态分派的核心机制(Java 所有的实例方法也默认使用虚函数表实现)。

  • 基本原理:编译器会为每个包含虚函数的类创建单独的虚函数表,并在对象的内存模型中生成包含虚函数表指针(vptr)的代码。
  • 复杂继承场景
    • 多继承和指针修正 (thunks)
    • 菱形继承与虚基类

2.2 方法派发 (Dispatch) 机制

派发机制是指选择正确方法调用的过程。按照选择正确方法的时期(编译期或运行期),主要分为以下两种:

静态派发 (Static Dispatch)

在编译期就完全确定了调用方法的分派方式。也就是说,编译期直接决定了函数地址(方法指针),运行时可以直接通过函数地址调用方法。

延伸:内联 (Inline) 静态派发的一种进一步优化方式。编译器将方法的实现直接平铺在调用方的可执行文件内,从而节省了函数调用的消耗。

  • 与宏展开的区别:内联发生在编译期,且不会改变源文件;宏展开在预编译期完成,直接替换源码本身后再进行编译。
  • 建议性而非强制性inline 关键字只是一个 desire 声明而非 require。它只能告诉编译器倾向使用内联方式,最终由编译器定夺。
  • 适用场景:如果一个方法被内联 10 次,会出现 10 份方法的副本。因此内联适用于频繁调用的短小方法;如果方法特别大,即使加了 inline 修饰,编译器也可能拒绝内联。

动态派发 (Dynamic Dispatch)

在运行期根据对象的实际类型,选择调用方法实现的流程(通常就是通过上述的虚函数表来完成)。

对比:迟绑定 (Late Binding) Late Binding(也叫 Dynamic Binding 或 Dynamic Linkage)是指在运行时通过对象调用方法或函数名的一套机制(简单的说,就是编译期并不解读足够的信息去确定方法是否存在)。

  • 优势:在组件对象模型(COM)编程中,最大优势在于不要求编译器在编译期间去引用包含对象的库,这能有效避免类的虚函数表突然更改带来的冲突。
  • 适用场景:大部分的动态类型语言(如 Python、Ruby 等)都可以在运行时去修改对象的方法列表,因此它们深度依赖 Late Binding;而 C++ 更多使用基于虚函数表的动态派发。

三、模版

模板分为函数模板(function template)和类模板(class template), 一般在头文件中定义, 可能被包含多次,编译和链接时会消除等价模板实例;

C++ 模板有以下特点:

  • 图灵完备
  • 函数式编程

所谓元编程,就是编写直接生成或操纵程序的程序,而 C++ 模板赋予了 C++ 语言提元编程的能力。

图灵完备

C++ 模板最初是为实现泛型编程设计的,但人们发现模板的能力远远不止于最初的设计初衷。一个重要理论结论就是:C++ 模板是图灵完备的(Turing-complete)。也就是说,C++ 模板元编程是"意外"功能,而不是设计的功能,这也是 C++ 模板元编程语法丑陋的根源。

C++ 模板是图灵完备的,主要体现在具备以下能力:

  • 编译期数值计算
  • 类型计算
  • 代码计算(如循环展开)

从编程形式来看,模板的“<>”中的模板参数相当于函数调用的输入参数,模板中的 typedef 或 static const 或 enum 定义函数返回值(类型或数值,数值仅支持整型,如果需要可以通过编码计算浮点数)

依赖以下两个能力,C++ 模板具有了和普通语言一样通用的能力(图灵完备性):

  • 依赖 C++ 模板的特例化, 模板拥有了条件判断能力
  • 依赖 C++ 模板的递归嵌套, 模板拥有了循环的能力

所以,可以确定,能够用模板实现类似普通程序中的 if 和 while 语句。

理论上说 C++ 模板可以执行任何计算任务,但实际上因为模板是编译期计算,其能力受到具体编译器实现的限制(如递归嵌套深度,C++11 要求至少 1024,C++98 要求至少 17)。

数值计算的实际意义不太大,但可以很好证明 C++ 模板的能力,而类型计算和代码计算可以使得代码更加通用,更加易用,性能更好(也更难阅读,更难调试,有时也会有代码膨胀问题)

代码计算是通过类型计算进而选择类型的函数实现的(C++ 属于静态类型语言,编译器对类型的操控能力很强)。

类型计算, 比如类型萃取, 依赖模板的特化来实现

模板元编程概览框图

模板特例化(template specialization)

模板的特例化分完全特例化(full specialization)和部分特例化(partial specialization)。实例化将会匹配参数集合最小的特例。

模板特化时,使用类型等价判断规则确定使用(模板的多个实例中的)哪一个模板:

  • 同一个模板(模板名及其参数类型列表构成的模板签名 template signature 相同)且指定的模板实参等价(类型参数是等价类型,非类型参数值相同)
  • 函数模板可以重载,类模板不存在重载

可变模版参数(variadic templates),C++11新增的最强大的特性之一。可以用递归实现伪变长参数模板,C++11 变长参数模板背后的原理也是模板递归;

函数式编程

从编程范型(programming paradigm)上来说,C++ 模板是函数式编程(functional programming),它的主要特点是:函数调用不产生任何副作用(没有可变的存储,都是编译期常量),用递归形式实现循环结构的功能。

函数式编程看上去似乎效率低下(因为它和数学接近,而不是和硬件工作方式接近),但有自己的优势:描述问题更加简洁清晰(前提是熟悉这种方式),没有可变的变量就没有数据依赖,方便进行并行化。

关键字:template、typename、this

template、typename、this 关键字用来消除歧义,避免编译错误或产生不符预期的结果;

C++标准规定,如果解析器在一个模板中遇到一个嵌套依赖名字,它假定那个名字不是一个类型,除非显式用 typename 关键字前置修饰该名字;

和上一条 typename 用法类似,template 用于指明嵌套类型或函数为模板;

this 用于指定查找基类中的成员(当基类是依赖模板参数的类模板实例时,由于实例化总是推迟,这时不依赖模板参数的名字不在基类中查找)。

特性 (Traits)、策略 (Policy)、标签 (Tag)

特性、策略、标签是模板编程的常用技巧,它们可以让模板变得更加通用。

  • 特性 (Traits):一般是对类型的信息(如 value_typereference)进行包装,使得上层代码可以以统一的接口访问这些信息。

    cpp
    // Traits 示例:提取类型的 value_type
    template <typename IterT>
    struct iterator_traits {
        typedef typename IterT::value_type value_type;
    };

    说明:如果这些类型的信息的访问方式不一致(如迭代器和指针),通过加一层 Traits 可以统一接口,避免大量重复代码。

  • 策略 (Policy):一般是一个类模板,将模板中经常变化的那一部分子功能块集中起来作为模板参数。

    cpp
    // Policy 示例:STL 容器的分配器
    template<class T, class Alloc = allocator<T>> 
    class vector;

    说明allocator<T> 就是一种内存分配策略,带有默认参数。

  • 标签 (Tag):一般是一个空类,作为独一无二的类型名字用于标记某些属性,方便在编译期进行函数重载决议。

    cpp
    // Tag 示例:STL 迭代器类型标签
    struct input_iterator_tag {};
    struct forward_iterator_tag : public input_iterator_tag {};

缺点

C++ 模板编程主要面临以下两个挑战:

  • 难以调试:如果出错,会产生冗长且难以阅读的编译错误信息。
    • 缓解方案:可以使用 static_assert 或 C++20 的 concept 增加前置检查代码,让编译器尽早且清晰地报错。
  • 代码膨胀:包括源代码膨胀和二进制对象文件膨胀。
    • 缓解方案:使用特性、策略等让模板更通用,或者将非依赖模板参数的逻辑抽离到基类中,以合并模板实例。

Reference

四、导入 (Import) 导出 (Export)

4.1 对象类型

支持导出的实体包括:函数、接口、类、成员函数、成员变量。

4.2 导出配置

在构建动态链接库时,需要明确声明哪些符号是对外可见的:

  • Windows / MSVC 体系
    • 使用模块定义文件 (.def)
    • 使用声明修饰符:__declspec(dllexport)__declspec(dllimport)
  • Linux / GCC 体系
    • 使用可见性修饰符:__attribute__((visibility("default")))__attribute__((visibility("hidden")))
    • 编译器链接选项:-fvisibility=default(GCC 默认全部可见)或 -fvisibility=hidden

4.3 动态库加载方式

显式加载 (Explicit Linking)

在运行时通过系统 API(如 Windows 的 LoadLibrary/GetProcAddress,Linux 的 dlopen/dlsym)从导出表获取函数地址,然后通过函数指针进行调用。

隐式加载 (Implicit Linking)

编译期间依赖于 DLL 生成的导入库(.lib 文件),并在代码中直接引用头文件。.lib 文件包含了导出的符号信息,在模块被加载时会主动寻找 .dll/.so 文件并关联对应的符号。

关于 __declspec(dllimport) 的深度解析

典型的宏定义模式(同一个头文件兼顾导出与导入):

cpp
#ifdef _EXPORTING
  #define API_DECLSPEC __declspec(dllexport)
#else
  #define API_DECLSPEC __declspec(dllimport)
#endif

根据 MSDN

  • 对于函数而言,使用 dllimport 是可选的(没有它也能链接成功),但使用了它会让编译器生成更高效的代码。
  • 对于公共数据符号和对象(全局变量/类的静态成员等),你必须使用 dllimport 才能正确访问。

底层效率差异示例(假设调用 DLL 函数 func()):

  • 没有 dllimport:编译器产生常规的 call func,链接器会生成一个中间跳转桩(Thunk): 0x40000001: jmp DWORD PTR __imp_func。程序先跳到 Thunk,再通过导入表跳转。
  • dllimport:链接器不会产生 Thunk,而是直接生成间接调用: call DWORD PTR __imp_func。有效减少了目标代码大小并提升了执行效率。

注意事项:在导出类、成员函数、成员变量时,需要注意它们在不同模块间的内存归属及释放问题(避免跨模块的堆内存分配与释放)。

五、名称修饰 (Name Mangling)

名称修饰是编译器为了支持函数重载、命名空间等特性,在编译阶段将函数签名重新编码为唯一符号串的过程。不同的编译器有不同的修饰规则。

5.1 查看工具

  • Linux:使用 GCC 编译后,用 nm 命令查看 .o.so 中的符号。
  • Windows:使用 cl 编译(参数 -c)后,用 dumpbin /SYMBOLS 命令查看符号。

5.2 注意事项

  • 使用模块定义文件 (.def) 导出的 C 函数,不会使用名称修饰,保持原名。
  • Microsoft 提供了 UnDecorateSymbolName() API,可将修饰后的名称还原(Demangle)为函数签名。
  • VC 可使用 #pragma 修改导出名称:#pragma comment(linker,"/EXPORT:MyExportFunction=_MyExportFunction@4")

5.3 编译器名称修饰规则对比表

语言编译器架构调用约定修饰前示例修饰后格式 / 示例
CMSVCx86__stdcallfunc(int, int)_func@8 (下划线前缀 + @ + 参数字节数)
CMSVCx86__fastcallfunc(int, int)@func@8 (@前缀 + @ + 参数字节数)
CMSVCx64任何约定funcfunc (保持原名不变)
CGCCx86__cdeclfunc_func (仅加下划线前缀)
CGCCx64__cdeclfuncfunc (保持原名不变)
C++MSVCAnyAnyint C2::func(int)?func@C2@C@@AAEHH@Z (以?开头,包含类名、访问控制、参数返回类型等,以Z结尾)
C++GCCAnyAnyN::C::func(int)_ZN1N1C4funcEi (以_Z开头,N表示嵌套,长度+名称拼合,结尾接参数类型如i代表int)

六、特性

6.1 const 关键字

  • 修饰指针:通过判断 const* 的左右侧来确定语义:
    • const int* p (或 int const* p):底层 const,指针指向的值不能通过 p 修改。
    • int* const p:顶层 const,指针本身的地址不能被修改。
  • 修饰成员变量:必须在构造函数的初始化列表中进行初始化。
  • 修饰成员函数
    • void func() const; 表示该函数承诺不修改其所在对象的任何非静态成员变量。
    • const 成员函数内部只能调用其他 const 成员函数。
  • 修饰对象const ClassName obj;
    • 该对象的任何非 const 成员函数都不能被调用,因为非 const 成员函数有修改成员变量的企图。
  • 类型转换:使用 const_cast<type_id>(expression) 可以移除变量的 const 属性(通常用于适配非 const 签名的遗留 API)。

6.2 内存对齐 (Attribute & Pack)

在处理跨平台网络协议或内存映射时,常需要精细控制结构体的内存对齐:

cpp
#if defined(__GNUC__)
  /* __attribute__ 是 GCC 特有机制 */
  typedef struct {
      char a;
      int b;
  } __attribute__((packed)) position_t; // 取消优化对齐,紧凑排列
#endif

/* 微软 MSVC 及跨平台的 #pragma pack 方式 */
#if (_MSC_VER >= 800 && !defined(_M_I86)) || defined(_PUSHPOP_SUPPORTED)
  #pragma pack(push, 1) // 保存当前对齐状态,并设为 1 字节对齐
  struct test {
      char a;
      int b;
  };
  #pragma pack(pop)     // 恢复之前的对齐状态
#else
  #pragma pack(1)
#endif

6.3 继承体系下的对象模型

单一继承

  • 形式class D : public B {}
  • 特点:派生类对象内存布局中,基类子对象位于最上方。若包含虚函数并发生了 Override,派生类会拥有自己的虚函数表,重写的方法会覆盖虚表中的基类槽位。

多重继承

  • 形式class D : public B1, public B2, public B3 {}
  • 特点
    • 派生类会和第一个基类公用 vptr(虚表指针)和虚表。
    • 内存调整:派生类转换为第二、第三个基类指针时,编译器会自动进行偏移调整。
    • 新实体引入:为了支持虚函数调用和指针调整,虚表中会引入 offset_to_top(记录当前子对象到对象首地址的偏移)以及 non-virtual thunk(一段简短的汇编代码,用于在调用方法前调整 this 指针的偏移量)。

菱形继承 (虚拟继承)

  • 形式class D : public B1, public B2 {}; class B1 : virtual public BB {}; class B2 : virtual public BB {};
  • 特点:用于解决菱形继承导致的最顶层基类(BB)数据冗余和二义性问题。对象模型中会引入虚基类表(vbtable)指针,所有继承路径共享同一个虚基类实例。

注意事项与辅助工具

  • 派生类子对象与基类子对象之间也会遵循内存对齐 (Alignment) 规则。
  • 对象模型查看利器

七、Traps

1. Enum

1.1 疑问

class Test {
public:
    enum Result {
        Result_OK = 0x1000,
    };

    Test(enum Result emRst) {
        m_nRst = emRst;
    }

    static int m_nRst;
};

int Test::m_nRst = 0; /* OK */
// Test::m_nRst = 0; /* ERROR, 此声明没有存储类或类型说明符 */

int main() { 
    Test(Test::Result(Test::Result_OK)); /* OK */
    Test((Test::Result)Test::Result_OK); /* OK */
    // Test(Test::Result_OK); /* ERROR, 当前范围内无法定义 constant "Test::Result_OK" */
}

为什么上述代码在去掉注释"//"后,vs2019编译器会提示错误?

1.2 背景

我们需要明白,enum 关键字会定义一个类型,同时,其内定义的项目会被展开为int型常量到定义 enum 的作用域。

这会造成,在全局位置定义的 enum 类型中的项目名需要是全局唯一的, 如下述定义会编译出错(error C2365: “a”: 重定义):

enum X { a };
enum Y { a };

由此,为了解决作用域 以及 强类型限定问题,C++11 中引入了 enum class 或 enum struct

1.3 解答

  1. 全局位置的 ERROR, 此声明没有存储类或类型说明符
    • c/c++ 没有全局代码块,在全局位置的是 声明或定义
  2. 传递枚举值到枚举类型参数时 ERROR, 当前范围内无法定义 constant
    • vs2019 会报错,但,g++ 在 linux 下编译正常
      • 可能是:强类型限定造成现在拒绝枚举量与int之间的隐式转换

2. Array & Pointer

2.1 疑问

  // define.c
  int arr[] = { 1,2,3 };
  int* arm = &arr[0];

  // test.c
  int art[] = { 5,6,7,8,9 };
  
  extern int* arr;
  
  void test() {
      int* x = arr + 1;
      int* m = art + 1;
  }

请问 test 函数中的 x 和 m 分别是多少?(int)x = 5, *m = 6

同我们的认知:数组可以当成指针使用 不一样,对吧...

2.2 解答

将上述代码编译成汇编后的代码如下:

  • define.cpp
        .file   "define.cpp"
        .text
        .globl  arr
        .data
        .align 8
        .type   arr, @object
        .size   arr, 12
arr:
        .long   1
        .long   2
        .long   3
        .globl  arm
        .section        .data.rel.local,"aw"
        .align 8
        .type   arm, @object
        .size   arm, 8
arm:
        .quad   arr
        .ident  "GCC: (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0"
        .section        .note.GNU-stack,"",@progbits
        .section        .note.gnu.property,"a"
        .align 8
        .long    1f - 0f
        .long    4f - 1f
        .long    5
  • test.cpp
        .file   "test.cpp"
        .text
        .globl  art
        .data
        .align 16
        .type   art, @object
        .size   art, 20
art:
        .long   5
        .long   6
        .long   7
        .long   8
        .long   9
        .text
        .globl  test
        .type   test, @function
test:
.LFB0:
        .cfi_startproc
        endbr64
        pushq   %rbp
        .cfi_def_cfa_offset 16
        .cfi_offset 6, -16
        movq    %rsp, %rbp
        .cfi_def_cfa_register 6
        movq    arr(%rip), %rax
        addq    $4, %rax
        movq    %rax, -16(%rbp)
        leaq    4+art(%rip), %rax
        movq    %rax, -8(%rbp)
        movq    -8(%rbp), %rax
        movl    (%rax), %eax
        movl    %eax, -20(%rbp)
        nop
        popq    %rbp
        .cfi_def_cfa 7, 8
        ret
        .cfi_endproc
.LFE0:
        .size   test, .-test
        .ident  "GCC: (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0"
        .section        .note.GNU-stack,"",@progbits
        .section        .note.gnu.property,"a"
        .align 8
        .long    1f - 0f
        .long    4f - 1f
        .long    5

观察:

  • define.cpp中 arr 和 arm 的定义
  • test.cpp中 arr(%rip) 和 art(%rip) 的操作

可以发现:

  1. 数组可以当成指针使用,的原因是:编译器帮忙做了取地址操作
  2. 因为 extern int* arr 在使用时缺少了编译器帮忙做取地址操作这一动作,直接被把 arr 当成了 指针使用:具体的方式可以参考 arm 的定义:直接把 arm 的值当成了地址使用

3. Single Parameter Constructor

C++中单参数构造函数是可以被隐式调用的,主要有两种情形会隐式调用单参数构造函数:

  • 同类型对象的拷贝构造
    • 即用相同类型的其它对象来初始化当前对象
  • 不同类型对象的隐式转换
    • 即其它类型对象隐式调用单参数拷贝构造函数初始化当前对象。比如 A a=1; 就是隐式转换,而不是显示调用构造函数,即A a(1); 像 A(1) 这种涉及类型转换的单参数构造函数,又被称为转换构造函数(Converting Constructor)

可以通过 explict 关键字避免这种隐式调用。

4. HeapOnly & StackOnly

      #include <iostream>
      class HeapOnly {
      public:  
          HeapOnly() { }
          void destroy() const { delete this; }
      private:  
          ~HeapOnly() { }
      };
      class StackOnly {
      public:
          StackOnly() { }
          ~StackOnly() { }
      private:
          void* operator new(size_t);
      };
      int main() {  
          StackOnly s; // ok
          StackOnly *p = new StackOnly; // wrong
          HeapOnly *p = new HeapOnly; // ok
          p->destroy();
          HeapOnly h;  // wrong
          return 0;
      }

5. 在头文件中定义全局变量

// test.h

// 如果此头文件被多次包含,会报重定义错误
int global_value = 100; 

// 如果此头文件被多次包含,不会报错,,但问题更隐蔽、严重
//    因为:包含了该头文件的所有源文件中都定义了此变量,即该头文件被包含了多少次,此变量就定义了多少次 
//    验证:在多个源文件中包含该头文件,然后在每个源文件中改变变量的值并输出值、以及变量地址。对比输出,如果都不同,则推测得证;否则推测错误
static int global_value = 100;

// 常见的在头文件中定义全局变量的方式(在某个源文件中定义并赋值)。
extern int global_value;

// __declspec(selectany) 是 Microsoft Visual C++ 提供的一个特定关键字,
// 它的主要作用是告诉编译器,如果某个全局变量或静态变量在多个编译单元(通常是多个源文件)中被定义多次,编译器只选用其中一个定义,而忽略其他重复的定义
__declspec(selectany) int global_value = 100; 

// inline 关键字:C++17及以后的标准,作用与 __declspec(selectany) 类似,不同的是 inline 是标准 C++ 的一部分,因此具有更好的可移植性
inline int global_value = 100;

Reference

  • 《深度探索C++对象模型》
  • 《Effective C++》
  • 《More Effecitve C++》
  • 《C专家编程》
  • 《C陷阱与缺陷》
  • 《C++ Templates》
  • gcc.gnu.org/onlinedocs