Fork me on GitHub

为什么标准库的模板变量都是inline的

最近在看标准库里的type_traits的时候发现了个有趣的地方,几乎所有在标准库里的变量模板都是inline的!

不仅常见的实现上(libstdc++、libc++、ms stl)都是inline的,标准里给的形式定义也是inline的。

比如微软开源的stl实现:https://github.com/microsoft/STL/blob/main/stl/inc/type_traits#L73

_EXPORT_STD template <class _Trait>
_INLINE_VAR constexpr bool negation_v = negation<_Trait>::value;

_EXPORT_STD template <class _Ty>
_INLINE_VAR constexpr bool is_void_v = is_same_v<remove_cv_t<_Ty>, void>;

其中_INLINE_VAR这个宏的实现在这里:

// P0607R0 Inline Variables For The STL
#if _HAS_CXX17
#define _INLINE_VAR inline
#else // _HAS_CXX17
#define _INLINE_VAR
#endif // _HAS_CXX17

可以看到如果编译器支持c++17的话这些模板变量就是inline的。

为什么要这样做呢?如果不使用inline又会要什么后果呢?带着这些疑问我们接着往下看。

c++的linkage

首先复习下c++的linkage,国内一般会翻译成“链接性”。因为篇幅有限,所以我们不关注“无链接”、“语言链接”和“模块链接”,只关注内部链接外部链接这两个。

内部链接(internal linkage):符号(粗暴得理解成变量,函数,类等等有名字的东西)仅仅在当前编译单元内部可见,不同编译单元之间可以存在同名的符号,他们是不同实体。

看个例子:

// value.h
static std::size_t a = 1;

// a.cpp
#include "value.h"

void f() {
    std::cout << "f() address of a: " << &a << "\n";
}

// b.cpp
#include "value.h"

void g() {
    std::cout << "g() address of a: " << &a << "\n";
}

// main.cpp
void f();
void g();

int main() {
    f();
    g();
}

注意,不要像上面那样写代码,尤其是把具有内部链接的非常量变量写在头文件里。编译并运行:

$ g++ -Wall -Wextra a.cpp b.cpp main.cpp
$ ./a.out

f() address of a: 0x564b7892e004
g() address of a: 0x564b7892e01c

可以看到确实有两个不同的实体存在。内部链接最大的好处在于可以实现一定程度上的隔离,但缺点是要付出生成文件体积和运行时内存上的代价,且不如命名空间和模块好使。

这个例子可能看不出,因为只有两个编译单元用了这个模板变量,所以只浪费了一个size_t的内存,在我的机器上是8字节。但项目里往往有成百上千甚至上万个编译单元,而且使用的变量不止一个,那么浪费的资源就很可观了。

外部链接(external linkage):符号可以被所以编译单元看见,且只能被定义一次。

例子:

// value.h
// extern std::size_t a = 1; 这么写是声明的同时定义了a,在头文件里这么干会导致a重复定义
extern int a;

// a.cpp
#include "value.h"

std::size_t a = 1; // 随便在哪定义都行,但只能定义一次

void f() {
    std::cout << "f() address of a: " << &a << "\n";
}

// b.cpp
#include "value.h"

void g() {
    std::cout << "g() address of a: " << &a << "\n";
}

// main.cpp
void f();
void g();

int main() {
    f();
    g();
}

编译并运行:

$ g++ -Wall -Wextra a.cpp b.cpp main.cpp
$ ./a.out

f() address of a: 0x55f5825f8040
g() address of a: 0x55f5825f8040

可以看到这时候就只有一个实体了。

那么什么样的东西会有内部链接,什么又有外部链接呢?

内部链接:所有匿名命名空间里的东西(哪怕声明成extern) + 标记成static的变量、变量模板、函数、函数模板 + 不是模板不是inline没有volatile或extern修饰的常量(const和constexpr)。

外部链接:非static函数、枚举和类天生有外部链接,除非在匿名命名空间里 + 排除内部链接规定的之后剩下的所有模板

说了半天,这和标准库用inline变量有什么关系吗?

还真有,因为内部链接最后一条规则那里的“非模板和非内联”是c++17才加入的,而模板变量c++14就有了,所以一个很麻烦的问题出现了:

template <typename T>
constexpr bool is_void_t = is_void<T>::value;

在这里is_void_t按照c++14的规则,可以是内部链接的。这样有什么问题?一般来说问题不大,编译器会尽可能把常量全部优化掉,但在这个常量被ODR-used(比如取地址或者绑定给函数的引用参数),这个常量就没法直接优化掉了,编译器只能乖乖地生产两个is_void_t的实例。而且这个is_void_t必须是常量,否则可以任意修改它的值,以及不是编译期常量的话没法在其他的模板里使用。

另一个问题在于,c++14忘记更新ODR原则的定义,漏了变量模板,虽然g++上变量模板和其他模板一样可以存在多次定义,但因为标准里没给出具体说法所以存在很大的风险。

c++社区最喜欢的一句格言是:“Don't pay for what you don't use.”

所以c++17的一个提案在增加了inline变量之后建议标准库里把模板变量和static constexpr都改为inline constexprhttps://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/p0607r0.html

inline变量

为什么提案里加上inline就能解决问题了呢?这就要了解下inline变量会带来什么了。

inline在c++里的意义比起内联,更确切的是允许某个object(这里不是面向对象那个object)被定义多次。但前提是每个定义都必须是相同的,且在需要这个object的地方必须要能看到它的完整定义,否则是未定义行为

对于这样允许多次定义的东西,链接器最后会选择其中一个定义生成真正的实体变量/类/函数。这就是为什么所以定义都必须一样的原因。

看例子:

// value.h
// 例子,Size返回sizeof的值
template <typename T>
struct Size {
    static_assert(!std::is_same_v<T, void>, "can not be void");
    static constexpr std::size_t value = sizeof(T);
};

// 注意这里
template <typename T>
inline constexpr std::size_t size_v = Size<T>::value;

// a.cpp
#include "value.h"

void f() {
    std::cout << "f() address of size_v: " << &size_v<int> << "\n";
}

// b.cpp
#include "value.h"

void g() {
    std::cout << "g() address of a: " << &size_v<int> << "\n";
}

// main.cpp
void f();
void g();

int main() {
    f();
    g();
}

编译并运行:

$ g++ -Wall -Wextra a.cpp b.cpp main.cpp
$ ./a.out

f() address of a: 0x5615acde601c
g() address of a: 0x5615acde601c

只存在一个实体,看符号表的话也只有一个size_v。

这样其实就上一节说到的所有问题:

  1. c++17新加了通常情况下模板变量和inline变量是外部链接的规定,因此加上inline解决了模板变量常量链接性上的问题
  2. inline变量允许被多次定义,因此就算ODR规则忘记更新或者重新考虑后改变了规则也没问题(当然现在已经明确模板变量可以多次定义了)
  3. 比起加static,使用inline不会生成多余的东西

当然这些只是inline变量带来的附加优点,真正让c++加入这一特性的原因因为篇幅这里就不详细展开了,有兴趣可以深入了解哦。

constexpr不是隐式inline的吗

这话只对了一半。

因为constexpr只对函数静态成员变量产生隐式的inline。

如果你给一个正常的有namespace scope(在文件作用域或者namespace里)变量加上constexpr,它只有const和编译期计算两个效果。

所以只加constexpr是没用的。

我不写inline会有什么问题吗

既然新标准补全了ODR规则,那我可以不再给模板变量加上inline吗?

我们把上上节的例子里的inline去掉:

// value.h
// 例子,Size返回sizeof的值
template <typename T>
struct Size {
    static_assert(!std::is_same_v<T, void>, "can not be void");
    static constexpr std::size_t value = sizeof(T);
};

// 注意这里
template <typename T>
constexpr std::size_t size_v = Size<T>::value;

// a.cpp
#include "value.h"

void f() {
    std::cout << "f() address of size_v: " << &size_v<int> << "\n";
}

// b.cpp
#include "value.h"

void g() {
    std::cout << "g() address of a: " << &size_v<int> << "\n";
}

// main.cpp
void f();
void g();

int main() {
    f();
    g();
}

编译并运行:

$ g++ -Wall -Wextra -std=c++20 a.cpp b.cpp main.cpp
$ ./a.out

f() address of a: 0x55fb0cfeb020
g() address of a: 0x55fb0cfeb010

这时候结果很有意思,g++12.2.0在生成的二进制上仍然表现的像是产生了内部链接,而clang14.0.5则和标准描述的一致,产生的结果是正常的:

$ clang++ -Wall -Wextra -std=c++20 a.cpp b.cpp main.cpp
$ ./a.out

f() address of a: 0x56184ee30008
g() address of a: 0x56184ee30008

更有意思的在于,如果我把size_v的constexpr去掉,那么size_v就会表现成正常的外部链接:

// 注意这里
template <typename T>
std::size_t size_v = Size<T>::value;
$ g++ -Wall -Wextra -std=c++20 a.cpp b.cpp main.cpp
$ ./a.out

f() address of a: 0x5586c90ef038
g() address of a: 0x5586c90ef038

所以看上去g++在判断是否是内部链接的规则上没有遵照c++17标准(还记得老版的标准吗,非inline的constexpr模板变量会被认为具有内部链接),暂时没有进一步去查证,所以没法确定这是g++自己的特性还是单纯只是bug。

如果指定inline,两者的结果是一致的。

所以我不加inline会有什么后果:

  1. 如果你在用c++20或者更新的版本,那么语法上没有任何问题;否则在语法上也处于灰色地带,在参考资料中的第三个链接里就描述了这个原因引起的符号冲突问题
  2. 各个编译器处理生成代码的结果不一样且不可控,可能会生成和标准描述的不一致的行为

所以结论显而易见,有条件的话最好始终给模板变量加上inline。这样不管你在用c++17还是c++20,编译器是GCC还是clang,程序的行为都是符合标准的可预期的。

总结

c++是一门很麻烦的语言,为了弄清楚别人为什么要用某个关键字就得大费周折,还需要许多前置知识作为铺垫。

换回这次的话题,标准库的实现和标准定义里给模板变量加inline最大的原因是因为几个历史遗留问题和标准自己的疏漏,当然加上去之后也没什么坏处。

这也更说明了在c++里真的没有什么银弹,某个特性需不需要用得结合自己的知识、经验还有实际情况来决定,别人的例子最多也只能作为一种参考,也许对于他来说合适的对你来说就是不切实际的,依葫芦画瓢前得三思。

这也算是c++的黑暗面之一吧。。。

参考资料

https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/p0607r0.html

https://stackoverflow.com/questions/70801992/are-variable-templates-declared-in-a-header-an-odr-violation

https://stackoverflow.com/questions/65521040/global-variables-and-constexpr-inline-or-not

posted @ 2022-11-28 09:49  apocelipes  阅读(2127)  评论(1编辑  收藏  举报