整理一下关于C++中的几个关键词(staticexterninline)的笔记。

static

static这个关键词的语义非常复杂,在C语言中本来就有多重语义,C++又添加了额外的语义。

静态函数

默认情况下,函数可以被其它编译单元使用,具有外部链接性,可以使用static将其改为具有内部链接性的静态函数,只有在当前文件中才可以使用此函数。 例如

1
2
3
static void func();

void func(){}

静态全局变量

与静态函数类似,全局变量在默认情况下可以被其它编译单元所使用,具有外部链接性, 可以使用static将其改为静态全局变量,只有在当前文件中才可以使用。 例如

1
static int s = 100;

静态局部变量

在函数体内部使用static修饰的局部变量会变成静态局部变量,此时静态的语义不再是对外部不可见,而是延长生命周期,此时变量的存储位置从栈区转移到了数据区,不会因为函数调用的结束而销毁,下次进入函数体时会自动忽略定义和初始化语句,例如

1
2
3
4
5
int func(){
static int s = 0;
s++;
return s;
}

滥用静态局部变量是不推荐的,因为函数体内的静态局部变量相对于给函数加入了内部状态,对函数在相同输入下可能导致不同的输出,这会降低代码的逻辑性和可读性。

类的静态成员

C++的static不仅继承了C语言中的全部语义,还将其扩展到面向对象的语法中,用于声明(或直接在类的内部定义)类的静态成员。 与非静态成员相比,静态成员在使用中有如下明显的区别:(这里假设具有访问权限)

  • 通过对象可以访问所有的成员,无论是静态还是非静态;
  • 通过类名只能访问类的静态成员,无法访问非静态成员;
  • 通过非静态的成员函数可以访问所有的成员,无论是静态还是非静态;
  • 通过静态的成员函数只能访问类的静态成员,无法访问非静态成员。

下面分别讨论静态成员变量和静态成员函数的语义。

静态成员变量

从内存的角度分析静态成员变量和普通成员变量的区别:

  • 每一个对象都有单独的非静态成员变量,生命周期与对象一致;
  • 但是同一个类只有一份静态成员变量,生命周期是整个程序运行期,实质就是一个全局变量,只不过从属于一个类,接受对类成员的权限控制。

正因为静态成员变量是一个特殊的全局变量,我们通常需要在类的外部(最好在main函数之前)进行额外的初始化:

  • 通常需要在类的实现文件中对静态成员变量进行单独的初始化,这不受到访问权限的影响,并且初始化显然不能放在头文件中
  • 对于const的静态成员变量允许在类的内部直接初始化
  • 在C++17之后,加上inline关键词将其标记为内联变量,可以允许在类的内部直接进行初始化。

下面的例子可以展示静态成员变量和非静态成员变量的区别:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <iostream>

class Counter {
public:
int count = 0;
static int count_static;
};

int Counter::count_static = 0; // 静态成员变量初始化

int main() {
Counter a;
a.count = 10;
a.count_static = 10;

std::cout << a.count << std::endl; // 10
std::cout << a.count_static << std::endl; // 20

Counter b;
b.count = 20;
b.count_static = 20;

std::cout << a.count << std::endl; // 10
std::cout << a.count_static << std::endl; // 20
std::cout << b.count << std::endl; // 20
std::cout << b.count_static << std::endl; // 20

return 0;
}

静态成员函数

从实现的角度分析静态成员函数和普通成员函数的区别:

  • 在调用非静态的成员函数时,总是自动传递了一个指向类的对象本身的this指针,这个特殊的参数通常不会在函数列表中出现,也不需要在调用时显式传递。
  • 而静态的成员函数则几乎和普通的全局函数是一样的,只不过从属于一个类,接受对类成员的权限控制。

下面的例子可以展示静态成员函数和非静态成员函数的主要区别:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <iostream>

class Point {
public:
double x;
double y;

void show() const;
static void show_static(const Point *const p);
};

void show_point(const Point *const p) {
std::cout << "(" << p->x << ", " << p->y << ")" << std::endl;
}

void Point::show() const { show_point(this); }

void Point::show_static(const Point *const p) { show_point(p); }

int main() {
Point s(1.0, 2.0);
s.show();
Point::show_static(&s);

return 0;
}

我们通过调用静态成员函数和非静态成员函数,达到了完全一样的效果(调用show_point), 对于非静态成员函数的调用,编译器帮我们自动完成了对象指针的获取和传递。

小结

static可能是C/C++中语义最复杂的一个关键字,为了节省关键词,C/C++为其赋予了四种不同的语义:

  1. 修饰函数或全局变量,改变其链接属性,只允许在当前文件中使用;
  2. 修饰局部变量,改变存储位置以延长生命周期;
  3. 修饰类的静态成员变量,解除成员变量与对象的自动关联,转而与类本身进行关联,同时改变存储位置以延长生命周期;
  4. 修饰类的静态成员函数,解除成员函数与对象指针this的自动关联,转而与类本身进行关联;

对应的中文翻译始终采用了“静态”这个词,让人很难理解。

extern

外部符号声明

extern最主要的语义是声明一个可能在其它编译单元实现的标识符,在链接过程中会对跨不同编译单元的符号使用进行检查。 例如

1
2
extern int s;
extern void func();

其中:

  • 对于全局变量来说,extern出现在声明中是必要的,否则在当前编译单元会将其自动转换为变量定义,无论是否提供了初始值;
  • 对于函数来说,extern可以省略,因为声明缺少函数体,编译器不会将其转换为函数定义。

extern "C"

如果我们直接尝试将C和C++混编,在链接阶段会遇到找不到对方正确的函数标识符的问题(细节见下文),这会导致链接失败。 C++为了解决这个问题,引入了extern "C"这个特殊语法。(注意这个语句只有C++编译器支持,C编译器不能识别)

一个考虑头文件保护和C/C++混编的头文件通常具有如下结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// XXX.h
#ifndef XXX_H
#define XXX_H

#ifdef __cplusplus
extern "C" {
#endif

/*...*/

#ifdef __cplusplus
}
#endif

#endif // XXX_H

这里利用了编译C++时的宏__cplusplus进行区分:同样的一份头文件,对于C编译器是如下的内容

1
2
3
4
5
6
#ifndef XXX_H
#define XXX_H

/*...*/

#endif // XXX_H

对于C++编译器则是如下内容

1
2
3
4
5
6
7
8
9
10
11
// XXX.h
#ifndef XXX_H
#define XXX_H

extern "C" {

/*...*/

}

#endif // XXX_H

extern "C"的具体含义是让C++编译器对包裹在其中的内容采用C的方式处理,重点就是函数标识符的处理,包裹在其中的内容只允许使用C/C++的公共语法(不支持C++的引用,函数重载等)。

下面我们用两个例子进行说明,两个例子使用了同样的头文件。

第一个例子是C++调用C编译的结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// head.h
#ifndef HEAD_H
#define HEAD_H

#ifdef __cplusplus
extern "C" {
#endif

void hello();

#ifdef __cplusplus
}
#endif

#endif // HEAD_H

// func.c
#include "head.h"
#include <stdio.h>

void hello() { printf("hello,world\n"); }

// main.cpp
#include "head.h"

int main() {
hello();
return 0;
}

首先通过C编译器编译func.c,然后用C++编译器编译main.cpp并链接

1
2
gcc -c func.c -o func.o
g++ main.cpp func.o -o test1

可以成功编译链接,程序也可以正常执行。

第二个例子是C调用C++编译的结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// head.h
#ifndef HEAD_H
#define HEAD_H

#ifdef __cplusplus
extern "C" {
#endif

void hello();

#ifdef __cplusplus
}
#endif

#endif // HEAD_H

// func.cpp
#include "head.h"
#include <iostream>

void hello() { std::cout << "hello,world\n"; }

// main.c
#include "head.h"

int main() {
hello();
return 0;
}

首先通过C++编译器编译func.cpp,然后用C编译器编译main.c并链接(注意需要额外链接C++标准库)

1
2
g++ -c func.cpp -o func.o
gcc main.c func.o -lstdc++ -o test2

可以成功编译链接,程序也可以正常执行。

如果在头文件中移除extern "C"得到下面的简单版本

1
2
3
4
5
6
7
// head.h (error)
#ifndef HEAD_H
#define HEAD_H

void hello();

#endif // HEAD_H

那么上面的两个例子都会链接报错:使用者找不到hello函数,原因就是hello()的提供者和使用者对函数标识符采用了不同的处理。

inline

内联函数

静态函数的主要含义是“局部可见性”,它们的定义是允许出现在头文件中的,编译时不会报错符号重定义,因为此时相当于每一个导入该头文件的源文件都有一份仅自己可见的独立定义,对当前源文件以外是不可见的。

除此之外,我们还可以选择另一个策略来避免符号重定义:对函数加上inline关键词将其设置为内联函数,编译器不会针对内联函数报错符号重定义,在遇到重复定义时会直接任意挑选一个版本。(必须保证这些版本的实现都是一样的,并且每一个版本都被标记为内联函数,例如全都来自于同一个头文件,否则具体行为是未定义的)

例如

1
inline add(int x,int y){ return x+y; }

注意:

  • inline函数不需要单独进行声明,直接提供函数定义即可,并且函数定义可以放在头文件中,如果只是在头文件中提供inline函数的声明,则会编译报错;
  • 在类的声明中直接定义的成员函数都是隐式内联的,不需要显式地加上inline关键词。

inline的另一个语义是表明当前的函数体非常短小简单,建议编译器将inline函数在调用位置直接展开(内联),这可以优化程序的执行效率。 这个语义意味着我们不允许在内联函数的主体中定义静态局部变量。 注意内联只是建议性的而非强制性的,编译器有自己单独的标准去判断是否将一个函数调用直接展开,这也取决于当前的优化等级。

inline在早期的主要语义是“优先内联”,但是目前的主要语义已经变成了“容许多次定义”,内联已经弱化成了一个建议,并且这个概念也从函数扩展到了变量中。

内联变量

对于具有全局生命周期的变量(例如全局变量或静态变量),可以使用inline将其设置为内联变量。 与内联函数类似,我们也可以将内联变量的定义放在头文件中,编译器不会报错符号重定义。

1
inline int a = 10;

补充

函数标识符

在 C++ 中,一个函数在编译器眼中的标识符并不等于函数的名称,例如下面这几个函数

1
2
3
void func(int, int);
void func(int, double);
void func(double, double);

g++为这些函数实际生成的函数标识符如下:

1
2
3
func(int, int) -> _Z4funcii
func(int, double) -> _Z4funcid
func(double, double) -> _Z4funcdd

而MSVC为这些函数实际生成的函数标识符如下

1
2
3
func(int, int) -> ?func@@YAXHH@Z
func(int, double) -> ?func@@YAXHD@Z
func(double, double) -> ?func@@YAXDD@Z

这些标识符的生成规则与C语言是完全不同的,C语言的函数标识符就只是函数名称。 C++的标识符生成规则更加复杂: 在函数名称的基础上,通过特定的前缀和后缀加入额外信息,分别对应参数数量、参数类型、参数顺序以及函数名称的长度等信息。

注意:

  • 函数标识符的信息是不包括参数默认值的,编译器不会将参数默认值视为函数签名的一部分。
  • 在C++中设计复杂的函数标识符生成规则的主要目的是支持函数重载,重载的函数虽然函数名称一样,但是函数标识符是不一样的,编译器就可以正确区分。
  • 正如上面的例子,GCC/LLVM和MSVC编译器虽然都考虑到了要把参数信息用于标识符的生成,但是编译器具体使用的生成规则是不一样的,这导致了MSVC得到的库通常无法和另外两个混用!对于C++的库,考虑到兼容性,最稳妥的办法就是使用extern "C"包裹以导出C语言版本的函数接口,这会让C++编译器采用C语言的规则来生成函数标识符。(此时自然就不再支持函数重载,以及其它的部分C++语法了)

gcc和g++的区别

gcc/g++ 支持C语言和C++两种对源文件的处理模式,这两种模式的区别就包括上面的对函数标识符的处理。 提供-x选项使我们可以手动指定对源文件的处理方式:-x c强制按照C语言处理,-x c++强制按照C++处理。

  • gcc:
    • 使用-x c选项按照C语言处理
    • 使用-x c++选项按照C++处理,但是不会自动链接C++标准库
  • g++:
    • 使用-x c选项按照C语言处理
    • 使用-x c++选项按照C++处理,自动链接C++标准库

通常我们很少使用-x选项,而是遵循 gcc/g++ 的默认行为:

  • gcc 默认对.c.cpp后缀分别按照C和C++的模式进行处理,但是始终不会自动链接C++标准库,C++模式下需要手动加上-lstdc++
  • g++ 默认对.c.cpp后缀统一按照C++的模式处理,自动链接C++标准库

gcc/g++除了这里的区别之外,还有很多细节上的区别,例如预定义宏等。