漫谈C++中的重定义:ODR,inline,static,extern

漫谈C++中的重定义:ODR,inline,static,extern

首先加了inline不会一定内敛,甚至inline原本的建议内联的作用已经没什么作用了,现在的编译器已经足够智能,能够自己决定是否内联。

先了解ODR 和 extern/static

这一节主要理解一下几个概念

  • 什么是ODR
  • 什么是一个翻译单元
  • 定义和声明的区别
  • extern关键字帮助我们把definition变为declaration
  • 函数声明默认带extern
  • static关键字的local definition

https://en.cppreference.com/w/cpp/language/definition

​ One Definition Rule意味着只能被定义一次,即任何变量、函数、类类型、枚举类型、概念 (C++20 起)或模板,在每个翻译单元中都只允许有一个定义(其中部分可以有多个声明,但只允许有一个定义)。

​ 在C++中, 一个翻译单元由一个实现文件及其直接或间接包含的所有标头组成。 实现文件通常具有文件扩展名 .cpp.cxx。 头文件通常具有扩展名 .h.hpp。 每个翻译单元由编译器独立编译。 编译完成后,链接器会将编译后的翻译单元合并到单个程序中

According to standard C++ (wayback machine link) : A translation unit is the basic unit of compilation in C++. It consists of the contents of a single source file, plus the contents of any header files directly or indirectly included by it, minus those lines that were ignored using conditional preprocessing statements.

A single translation unit can be compiled into an object file, library, or executable program.

The notion of a translation unit is most often mentioned in the contexts of the One Definition Rule, and templates.

首先要区分定义和声明

1
2
3
4
5
6
7
8
9
10
11
12
int f(int); // 声明但不定义 f
extern const int a; // 声明但不定义 a
extern const int b = 1; // 定义 b

struct S
{
int n; // 定义 S::n
static int i; // 声明但不定义 S::i
inline static int x; // 定义 S::x
}; // 定义 S

int S::i; // 定义 S::i

考虑这种情况

1
2
3
4
5
6
7
8
9
//2.cpp
int i = 3;//definition

//main.cpp
int i;//definition
int main()
{

}

会出现问题,因为不符合ODR原则,两个对i的definition。

我们也许会疑惑 这不是两个cpp两个翻译单元吗? 每个翻译单元有各自的i这有什么问题呢?

我们对翻译单元的理解有误

看一下cmake

1
2
3
4
5
6
7
8
9
10
cmake_minimum_required(VERSION 3.22)
project(
untitled1
LANGUAGES CXX C
)

set(CMAKE_CXX_STANDARD 20)

add_executable(MAIN main.cpp b.h 2.cpp)
TARGET_COMPILE_FEATURES(MAIN PRIVATE cxx_std_17)

这里最后把main.cpp 和2.cpp扔到一起合并到单个程序中,所以这是一个翻译单元哦

那怎么办?用extern把main里的i变成declaration即可,这下就是一个definition了

1
2
3
4
5
6
7
8
9
//1.cpp
int i = 3;//definition

//main.cpp
extern int i;//declaration
int main()
{

}

这就一个definition了,可以通过编译了,反过来也是可以的 main.cpp里是int i = 3;, 1.cpp里是extern int i;,不论怎样我们需要保证只有一个定义。

所以总结下extern:

1
2
3
4
5
int i; //definition
extern int i;//declaration

void foo();//declration, it's equal to extern void foo(),使得如果没有函数定义会自动给函数加上extern使他成为一个声明。
void foo(){};//definition

没有函数定义会自动给函数加上extern使他成为一个声明。

我们上面都在说的都是全局的definition,那么static这个关键字是一个局部的definition,只在自己的这个.cpp里有用。这样也不会造成重定义。

1
2
3
4
5
6
7
8
9
//1.cpp
int i = 3;//definition

//main.cpp
static int i = 5;//local definition
int main()
{
std::cout<<i<<std::endl; // output 5
}

inline变量

根据上文的知识,在C++17的inline 变量出现前,要想在多个cpp中用同一个变量,我们需要extern 声明变量+只有一个定义:

1
2
3
4
5
6
//1.cpp
extern int i;//declaration
//2.cpp
extern int i;//declaration
//main.cpp
int i = 3;//defination

​ 由于inline现在的作用其实和内敛没什么关系了,他是为了让多个翻译单元可以共用一个变量,c++17后对inline的解释是“允许重复定义”。而内敛与否完全是编译器的优化行为了。

​ 如果有多个编译单元拒绝了内联,就会生成多份函数/变量定义,为了在链接时不报错,由inline修饰的函数会生成弱符号,所以inline帮助我们在多个翻译单元中可以重复定义,帮助我们突破了ODR规则。

​ 因此我们可以这样:

1
2
3
4
5
6
//1.cpp
inline int i;
//2.cpp
inline int i;
//main.cpp
inline int i = 3;

但是请注意它带来的一些UB行为,常常多个inline不同的初始值会导致

1
2
3
4
5
6
7
8
9
//1.cpp
inline int i = 2;//definition

//main.cpp
inline int i = 5;//definition
int main()
{
std::cout<<i<<std::endl; // output 2 or 5, it's ub
}

总结一下:

  • 突破了ODR规则,不再用多个extern+一个定义来实现,减少了思维负担。

  • 注意inline变量的不同初始化会带来的UB行为

  • constexpr函数和constexpr static变量默认隐含了inline。 ([dcl.constexpr]/1).

inline函数

  • 加了inline关键字这个函数不一定是内敛函数,但是可以防止odr
  • 类成员函数默认是inline的
1
2
3
4
5
6
7
8
9
10
11
12
//dog.h
class Dog
{
void say(){cout<<"wof"<<endl;}
};

//middle.h
#include "dog.h"

//main.cpp
#include "dog.h"
#include "middle.h"

内敛函数(指编译器优化行为)

注意下面说的是如果一个函数被内敛了 ,就会有以下的故事,而不是一个函数加了inline关键字就会有以下的故事。

内联函数语法

inline要起作用,必须要与函数定义放在一起,而不是函数的声明

1
inline int Foo(int x,int y) // 函数定义{    return x+y;}

内联函数的作用

​ 当编译器处理调用内联函数的语句时,不会将该语句编译成函数调用的指令,而是直接将整个函数体的代码插人调用语句处,就像整个函数体在调用处被重写了一遍一样,在执行时是顺序执行,而不会进行跳转

内联函数的优缺点

优点:内联函数没有执行函数调用的开销,加快了程序运行时间;

缺点:内联函数是将整个函数体的代码插入到调用语句处,会增大代码的大小,增大内存的占用。不过这个缺点几乎可以忽略,代码膨胀怪不到内敛上。

类与内联函数

  1. 类内定义的函数都是内联函数,无论是否有inline修饰符(虚函数单独考虑)
  2. 类外定义的没有inline修饰的函数不是内联函数

内联函数和宏的区别

  1. 宏是由预处理器对宏进行替换的,而内联函数是通过编译器控制实现的。
  2. 宏调用并不执行类型检查甚至连正常参数也不检查,但是函数调用却要检查。
  3. C语言的宏使用的是文本替换,可能导致无法预料的后果
  4. 在宏中的编译错误很难发现,因为它们引用的是扩展的代码,而不是程序员键入的

虚函数可以标为inline吗

虚函数可以是内联函数,内联是可以修饰虚函数的,但是当虚函数表现多态性的时候不能内联(因为无法知道具体将哪一部分代码插入到调用位置),具体内联与否由编译器决定。