0%

C++ 中的 inline ——由 LNK2001 和 LNK2019 引发的思考

起因

最近在填坑(CTBX)的时候发现以前 Log 是这样写的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Logging.h
// Declaration

namespace logging{
void debug(const std::string&, const std::string&);
void warning(const std::string&, const std::string&);
// and more
}

// Logging.cpp
// Implementation

namespace logging{
void debug(const std::string& tag, const std::string& msg){ /* one-line implementation */ }
void warning(const std::string& tag, const std::string& msg){ /* one-line implementation */ }
// and more
}

我一看,这实现不就一行嘛,遂改成了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Logging.h
// Declaration

namespace logging{
inline void debug(const std::string&, const std::string&);
inline void warning(const std::string&, const std::string&);
// and more
}

// Logging.cpp
// Implementation

namespace logging{
inline void debug(const std::string& tag, const std::string& msg){ /* one-line implementation */ }
inline void warning(const std::string& tag, const std::string& msg){ /* one-line implementation */ }
// and more
}

然而这个简单的“优化”导致了大概 40+ 个 LNK2001 和 LNK2019 错误,链接器报的错误意思是找不到 debug 和 warning 等函数的定义。

这就让我完全懵逼了,我用 dumpbin /symbols 看了下生成的 obj,的确没有相应的导出符号。最后经过一番学习才知道 C++ 中 inline 真正的含义。

解决

首先根据链接器的错误代码,我第一反应是找 MSDN 的文档看有没有解决办法。

LNK2001 的说明页面果然提到了 inline 可能导致问题,并且引用了一篇文章,在这篇文章开头就给出了使用 inline 的正确姿势

If you are using function inlining, you must:

  • Have the inline functions implemented in the header file you include.

  • Have inlining turned ON in the header file.

也就是说我上面的写法违反了这里的这一条,所以我把代码改成了

1
2
3
4
5
6
7
8
9
10
// Logging.h
// Declaration

namespace logging{
inline void debug(const std::string&, const std::string&){ /* implementation */}
inline void warning(const std::string&, const std::string&){ /* implementation */}
// and more
}

// Logging.cpp is deleted

顺利通过编译。

反思

问题解决是解决了,但是为什么必须写在头文件才能编译通过呢?我发现我对 inline 可能有一定误解。

在 StackOverflow 上有这么一句评论我觉得很精髓

inline does not mean "generate inline assembly". inline means "the definition for this function is in the header". There is no keyword needed for "generating inline assembly" because the compiler does that automatically.

所以 inline 到底是什么意思呢?翻看 CPPReference 有了答案。

The original intent of the inline keyword was to serve as an indicator to the optimizer that inline substitution of a function is preferred over function call, that is, instead of executing the function call CPU instruction to transfer control to the function body, a copy of the function body is executed without generating the call. This avoids overhead created by the function call (copying the arguments and retrieving the result) but it may result in a larger executable as the code for the function has to be repeated multiple times.

Since this meaning of the keyword inline is non-binding, compilers are free to use inline substitution for any function that's not marked inline, and are free to generate function calls to any function marked inline. Those optimization choices do not change the rules regarding multiple definitions and shared statics listed above.

Because the meaning of the keyword inline for functions came to mean "multiple definitions are permitted" rather than "inlining is preferred", that meaning was extended to variables.

从上述描述来看,inline 的意思并不是简简单单的表示函数可以内联展开不生成 call 跳转那么简单。

由于编译器有权利决定是否展开任意一个函数(即使声明了 inline),所以 inline 更加重要的一个含义是告诉编译器允许函数重复定义。

当然可能会问,允许重复定义的话那我定义两个签名相同实现不同的 inline 函数怎么办?C++ 贴心的考虑到了这一点给了一点 Note 来“解决”这个潜在的问题。

If an inline function or variable (since C++17) with external linkage is defined differently in different translation units, the behavior is undefined.

也就是说如果函数被 inline 修饰,那么所有相同签名的函数应该是完全一样的。

同时这里强调了一点 external linkage 因为如果不是 external linkage(比如在函数前加个 static 修饰符)那么语义上是没有歧义的。

理解了 multiple definitions are permitted 的具体含义,那么回过头来看 MSDN 给出的两点要求就很自然了,因为头文件经常会被包含而且 inline 表示函数可以被重复定义,所以这样才能保证调用的时候能解析到要 inline 的函数。

比如现在有这样三个文件如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// file: A.h

inline void foo(){}

// file: B.cpp
#include "A.h"

void bar();

int main(){
foo();
bar();
}

// file: C.cpp
#include "A.h"

void bar(){
foo();
}

当两个 cpp 文件交给编译器去编译的时候实际上已经被预处理为了大概这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// file: B.cpp
inline void foo(){}

void bar();

int main(){
foo();
bar();
}

// file: C.cpp
inline void foo(){}

void bar(){
foo();
}

虽然 foo 被重定义了,但是两个 foo 内容是完全一样的,这样就保证了能正确链接到相应的函数。

思考

那么问题又来了,为什么最初我同时在 .h 和 .cpp 加上 inline 的方法不行呢?

首先构造一段最小复现代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// file: Foo.h

inline void foo();

// file: Main.cpp
#include "Foo.h"

int main(){
foo();
}

// file: Foo.cpp
#include "Foo.h"

inline void foo(){}

尝试生成,果然报了 LNK2019 错误

这时候如果 dumpbin /symbols foo.obj 可以看到

的确是没有 foo 的导出符号,但是如果我们在 Foo.cpp 的 foo 函数前加上 _declspec(dllexport) 并且删掉 include 的话则轻松编译成功。

这时候再去 dumpbin 可以看到有了 foo 的导出符号

所以我一开始遇到的问题其实本质就是编译器默认没有为 inline 函数导出符号(摊手)。

小结

总之,对于 inline 应该了解的几点是

  • inline 已经不再是“内联展开”那么简单,编译器可以自由决定是否展开
  • inline 更关键的一层含义是“允许重复的定义”
  • inline 修饰的所有函数签名相同的函数应该具有完全相同的实现,这也是编译器处理的前提,否则会产生 UB
  • inline 函数在 MSVC 上可以被正常导出导入
  • inline 函数的正确姿势应该是直接在头文件内实现
  • 瞎 inline 什么呀,编译器比你聪明多了

参考资料