static、extern、inline 说明符和链接属性
概述 - Overview
在我初学 C++ 时,static
、inline
、extern
可能是最令我迷惑的 C++ 说明符,原因是它们在不同的语境下会发挥不同的作用,而且某些说明符的含义已经和以前不同,这加剧了我在查询资料时的困扰。所以今天决定好好总结一下。
首先要介绍 C++ 的两个概念:存储期和链接。
存储期 - Storage duration
C++ 程序中,任何对象[1]都有一个存储期,它是下列四个之一:
- 自动存储期:对象在代码块开始时分配,代码块结束时解分配。
- 静态存储期:对象在整个程序开始时分配,程序结束时解分配。
- 线程存储期:对象在某个线程开始时分配,线程结束时解分配。
- 动态存储期:对象使用某些特定的表达式来进行分配和解分配。
存储期决定了一个对象在给定时刻是否有效。比如,具有静态存储期的对象,在程序开始和结束之间的任意时刻有效;具有动态存储期的对象,在分配和解分配之间的任意时刻有效。(关于不同存储期的对象是如何进行初始化的,那又是另外的话题了)
链接 - Linkage
在本文中,术语“链接” \(≠\) 程序构建时所需要的名为“链接”的步骤,它只是 C++ 标准中定义的一种属性。如果程序中一个名字指代对象、引用、函数、类型、模板、命名空间或值,那么这个名字就可以具有链接属性(不是一定具有哦,只是可以具有)。
如果一个名字具有链接属性,那么它指代的实体,和另一个作用域中相同名字所指代的实体,是同一个实体。简而言之,就是允许一个名字在多个作用域中出现且它们都代表同一个实体。换句话说,我们可以在声明该名字的作用域以外的地方使用它。
链接属性还有两种不同的“等级”:
- 内部链接:名字可在当前翻译单元中的所有作用域中使用。
- 外部链接:名字可在其他翻译单元中的作用域中使用。
怎么让一个名字具有链接属性并指定它是内部或外部链接?简而言之,可以使用 static
、extern
说明符来控制(好吧,这里很不准确,因为链接属性的详细规则比较复杂、琐碎,它不仅和 static
、extern
有关,还和其他事情有关,在这里我只关注部分情形)。
声明说明符 - specifiers
回到本文的标题上来,static
、extern
、inline
都是声明说明符,在声明时使用(当然不是任何声明都能用),并赋予某种性质。
如果硬要说它们有什么共同点,那就是它们以不同程度影响我们在翻译单元中使用一个名字的方式。
static
和 extern
说明符影响前面介绍的存储期和链接属性;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: 在命名空间作用域中声明的对象,即使不带
static
或extern
说明符,也自动拥有静态存储期。在命名空间作用域中声明的函数或非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
说明符实际上既不影响存储期,也(几乎)不影响链接属性[2]。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.cpp
和 source2.cpp
在各自的翻译单元内都能访问到名字 add
。在这里,我们并没有多次定义一个 add
函数,相反,我们在 source1.cpp
和 source2.cpp
中各自定义了不同的 add
函数,尽管它们看起来一模一样。换言之,代码中的 add(1, 2)
和 add(3, 4)
,它们实际引用了不同的函数。而正是多亏了 static
说明符赋予的内部链接属性,它们各自在外部不可见,因此不会造成重定义。
对于 inline
说明符:通过包含头文件,source1.cpp
和 source2.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 构建(未开启优化),并对构建出来的可执行文件进行反汇编,可以看到:
它们调用的 foo
函数,其地址不同。而二进制文件里确实存在两个长得“一样”的 foo
函数:
再来看看使用 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 构建并执行反汇编。我们可以看到:
它们指向相同的地址。而二进制文件中,也只有一处 foo
函数的实现。
好了,这就是这篇文章的全部内容,如果出现任何错误,请务必让我知道!