static、extern、inline 说明符和链接属性

发布时间 2023-11-29 21:27:21作者: ClickForWhat

概述 - Overview

在我初学 C++ 时,staticinlineextern 可能是最令我迷惑的 C++ 说明符,原因是它们在不同的语境下会发挥不同的作用,而且某些说明符的含义已经和以前不同,这加剧了我在查询资料时的困扰。所以今天决定好好总结一下。

首先要介绍 C++ 的两个概念:存储期链接

存储期 - Storage duration

C++ 程序中,任何对象都有一个存储期,它是下列四个之一:

  • 自动存储期:对象在代码块开始时分配,代码块结束时解分配。
  • 静态存储期:对象在整个程序开始时分配,程序结束时解分配。
  • 线程存储期:对象在某个线程开始时分配,线程结束时解分配。
  • 动态存储期:对象使用某些特定的表达式来进行分配和解分配。

存储期决定了一个对象在给定时刻是否有效。比如,具有静态存储期的对象,在程序开始和结束之间的任意时刻有效;具有动态存储期的对象,在分配和解分配之间的任意时刻有效。(关于不同存储期的对象是如何进行初始化的,那又是另外的话题了)

链接 - Linkage

在本文中,术语“链接” \(≠\) 程序构建时所需要的名为“链接”的步骤,它只是 C++ 标准中定义的一种属性。如果程序中一个名字指代对象、引用、函数、类型、模板、命名空间或值,那么这个名字就可以具有链接属性(不是一定具有哦,只是可以具有)。

如果一个名字具有链接属性,那么它指代的实体,和另一个作用域中相同名字所指代的实体,是同一个实体。简而言之,就是允许一个名字在多个作用域中出现且它们都代表同一个实体。换句话说,我们可以在声明该名字的作用域以外的地方使用它。

链接属性还有两种不同的“等级”:

  • 内部链接:名字可在当前翻译单元中的所有作用域中使用。
  • 外部链接:名字可在其他翻译单元中的作用域中使用。

怎么让一个名字具有链接属性并指定它是内部或外部链接?简而言之,可以使用 staticextern 说明符来控制(好吧,这里很不准确,因为链接属性的详细规则比较复杂、琐碎,它不仅和 staticextern 有关,还和其他事情有关,在这里我只关注部分情形)。

声明说明符 - specifiers

回到本文的标题上来,staticexterninline 都是声明说明符,在声明时使用(当然不是任何声明都能用),并赋予某种性质。

如果硬要说它们有什么共同点,那就是它们以不同程度影响我们在翻译单元中使用一个名字的方式。

staticextern 说明符影响前面介绍的存储期和链接属性;inline 说明符不影响存储期,但以一种隐秘的方式影响链接,并且它还影响另一种重要的规则。下面就来依次说明:

static

static 说明符主要在三种地方使用:

  • 在命名空间作用域中,声明具有静态存储期和内部链接的成员(当然,函数不是对象,所以没有存储期一说,这里只是为了书写上的方便,下面不再额外说明)。
// main.cpp
namespace A {
	static int a; // 在命名空间 A 中
	static void b() { } // 在命名空间 A 中
}
static int c; // 在全局命名空间中
int main() { /*...*/ }
  • 在块作用域中,定义具有静态存储期且只会初始化一次的变量。在块作用域中,有没有 static 说明符不影响链接属性。
// main.cpp
void foo() {
	static int a; // 在块作用域中
}
  • 在类作用域中,声明具有静态存储期的类静态成员。如果类自身具有外部链接,那么类的静态数据成员也有外部链接。
// main.cpp
struct A {
	static int a;
	static void b() { }
}
int main() { /*...*/ }

简单而言,用于声明类成员时,它声明一个静态成员。当用于声明对象时,它指定静态存储期。在命名空间作用域内声明时,它指定内部链接。

extern

extern 说明符的用途并不复杂:在命名空间作用域中,声明具有静态存储期外部链接的成员。它只能用于修饰(类成员或函数形参之外的)变量和函数声明。

// main.cpp
extern int i; // 变量 i 具有静态存储期和外部链接
extern void foo() { }// 函数 foo 具有外部链接

Tips: 在命名空间作用域中声明的对象,即使不带 staticextern 说明符,也自动拥有静态存储期。在命名空间作用域中声明的函数或非 const 变量(且没有被 static 修饰),即使不带 extern 说明符,也自动具有外部链接。

这使得我们可以在不同的翻译单元分享同一个变量或函数,而不必包含头文件:

// foo.cpp
int factor = 1; // 默认具有静态存储期和外部链接
int foo(int a, int b) { // 默认具有外部链接
	return (a + b) * factor;
}

// main.cpp
int factor; // 错误! 违反单一定义原则,因为这样做是定义而非单纯声明
extern int factor; // 正确! 应使用 extern 声明
int foo(int a, int b); // 正确!具有外部链接,且未违反单一定义原则

int main() {
	factor = 2;
	foo(1, 2);
}

除此之外,extern 说明符还有其他作用(控制语言链接,显式实例化模板),但与本文的关注点关系不大,所以不加讨论。(话说回来,我感觉似乎没有在块作用域中使用 extern 修饰变量的需求?绝大多数时间都在命名空间作用域中使用它)

inline

inline 说明符实际上既不影响存储期,也(几乎)不影响链接属性。inline 说明符的用处相当直接,就是将函数或变量声明为内联*。至于内联的具体作用将在下面解释。用法简单粗暴,直接在声明处加上 inline 说明符即可。有一点需要注意:具有静态存储期的变量(静态类成员或命名空间作用域变量)才能声明为内联变量。

Tips: 下列情形会隐式将函数或变量内联:

  • 如果一个函数的定义在 class/struct/union 内部,那么它是内联函数。
  • 如果一个函数声明有 constexpr,那么它是内联函数。
  • 如果一个类的静态成员变量声明有 constexpr,那么它是内联变量。

内联函数和内联变量有一个必须满足的条件:它们的定义必须在访问它的翻译单元中可达。

这个条件看起来微不足道。不过若是能进一步满足"具有外部链接"这个看起来同样微不足道(但实际上隐藏了诸多细节)的条件,我们将会获得重量级的好处!

这样一来,内联函数和变量就可以在程序中多次定义!只要它们每个定义都出现在不同的翻译单元,且它们均等同。这对喜欢只用头文件来分发库代码的人来说是莫大的福音:

// lib.h
inline int add(int a, int b) {
	return a + b;
}

// source1.cpp
#include "lib.h"
int foo1() {
	return add(1, 2);
}

// source2.cpp
#include "lib.h"
int foo2() {
	return add(3, 4);
}

不需要额外的步骤,只需要包含头文件,就可以方便地使用其他人编写的功能函数或变量。

有的人可能会说,即使不用 inline 说明符,使用 static 也能达到类似的效果:

// lib.h
static int add(int a, int b) {
	return a + b;
}

// source1.cpp
#include "lib.h"
int foo1() {
	return add(1, 2); 
}

// source2.cpp
#include "lib.h"
int foo2() {
	return add(3, 4); 
}

某种程度上的确如此。然而,现在应该清楚地认识到,两者使用的是不同的语言机制:

对于 static 说明符:通过包含头文件,source1.cppsource2.cpp 在各自的翻译单元内都能访问到名字 add。在这里,我们并没有多次定义一个 add 函数,相反,我们在 source1.cppsource2.cpp 中各自定义了不同add 函数,尽管它们看起来一模一样。换言之,代码中的 add(1, 2)add(3, 4),它们实际引用了不同的函数。而正是多亏了 static 说明符赋予的内部链接属性,它们各自在外部不可见,因此不会造成重定义。

对于 inline 说明符:通过包含头文件,source1.cppsource2.cpp 在各自的翻译单元中也能访问到名字 add,而且该名字具有外部链接。因此在这里,我们确实多次定义了同一个实体—— add 函数。而多亏了 inline 说明符,这种行为被允许,所以也不会造成重定义。

这两种情况的微妙差别,在执行编译、链接后的二进制文件中也有所体现。

假设我们有以下文件,这是使用 static 说明符的情形:

// Lib.h
static int foo() { return 114514; }

// Src1.cpp
#include "Lib.h"
int main() { return foo(); }

// Src2.cpp
#include "Lib.h"
int fn() { return foo(); }

使用 Visual Studio 构建(未开启优化),并对构建出来的可执行文件进行反汇编,可以看到:

image

image

它们调用的 foo 函数,其地址不同。而二进制文件里确实存在两个长得“一样”的 foo 函数:

image

image

再来看看使用 inline 说明符的情形:

// Lib.h
inline int foo() { return 114514; }

// Src1.cpp
#include "Lib.h"
int main() { return foo(); }

// Src2.cpp
#include "Lib.h"
int fn() { return foo(); }

除了改用 inline,和之前没什么区别。让我们再用 Visual Studio 构建并执行反汇编。我们可以看到:

image

image

它们指向相同的地址。而二进制文件中,也只有一处 foo 函数的实现。

image

image

好了,这就是这篇文章的全部内容,如果出现任何错误,请务必让我知道!