记录一些遇到过的大大小小的C++坑,或者是C++比较优秀的惯用技巧、设计模式。这些知识点十分分散却很有用,但他们单独拿出来难以构成一篇完整的文章,这里我以杂记的方式将它们记录下来,以便后续翻阅。
(最后更新于2020.09.06)
前言
C++很久以来被称作是“博大精深”的一门语言,不仅是因为其语言特性较为复杂且多到令人叹为观止的地步;也是因为细节很多,稍不留神就有可能踏入隐藏的深坑。
说到语言特性复杂,C++确实有很多不同方面的语言特性,在cppreference上一眼望去都会眼花缭乱。众所周知,C++被称作一门“多范式”的语言,相比于其他一些“纯粹”的编程语言,它不纯粹,因为它的设计者们认为没有一种设计思想适用于所有的情况:C++不仅支持从C直接继承而来的命令式编程,也支持层次性的面向对象编程、泛型编程、函数式编程、编译期编程等编程范式。这种“不纯粹”的设计观念被一些人反感,但也正是这种多样性,使得C++得以很好的应用在各个行业领域。
除此之外,C++还有许多很好的设计理念与思想:
- “Zero-overhead Principle”:对于不使用的部分,不需要为其付出代价;对于使用到的抽象,自己手写的代码不会更好。
- 既能向上增加抽象层次,丰富语义减轻思维负担;也能向下足够接近硬件,充分利用硬件资源。
- 用户可扩充的静态类型系统:最大发挥编译器的作用,在编译期解决绝大多数的错误。
- 对值类型与引用类型的同等支持。
- 系统性的资源管理思想与机制:RAII。
- 库与语言一样重要:对于用户来说,语言设计与库设计没有区别,是紧密结合的。
- …
C++已经历经的多个版本,并在C++11之后以3年为周期持续演进新的版本;每次演进都旨在解决之前出现的痛点,或是引入新的特性或组件。当然,这样也使得C++的学习成本在不断增加(如果真的要了解所有细节的话)。不过C++的设计初衷并不是让我们用上所有的语言特性,而是选择最适合自己项目的语言特性,毕竟没有什么单一的思想是万能。
对我而言,C++最大想吸引力就是“能上能下”,足够的抽象能力让简单的事情与复杂的事情都能较好地表达;而需要极致性能的时候又可以深入到机器底层开展细致的优化。当然坑的问题仍然存在,它不像其他一些对程序编写有严格要求的语言(如Rust),旨在给以最大的自由,这时就需要我们自己保持良好的程序风格与规范。不过好在既有C++11以来的“现代C++”给我们带来越来越多的安全的抽象能力,也有如CppCoreGuideline这样的从经验中总结的编程规范,让我们离这个目标越来越近。
语法
-
默认实参的问题:
- 多个声明时,(同一作用域下)后面的声明可以获得前面声明的实参;也就是说可以在后面添加默认实参。
- 虚函数调用的默认实参,根据调用时的静态类型来定。
- 运算符重载不能有默认实参。
-
关于取重载函数的指针问题:用
static_cast
进行强制类型装换后再取地址即可。注意在函数指针作为模版参数时,重载函数会导致模版参数推导失败,这个时候可以手动指定模版参数。 -
Inline namespace可用于管理library versioning,将库的不同版本放置于一个特定的namespace中,再对目前使用的namespace前加上inline即可。其好处是在ABI的符号中保留了inline namespace的名称。
-
Anonymous namespace用于定于翻译单元范围内的符号,其作用与
static
类似,不过相比static
来说可以作用在类型定义上。Anonymous namespace只用于.cpp
文件而不用于头文件,因为在头文件中的匿名空间里定义的符号可能会被多个翻译单元编译,导致在链接时发生意想不到的结果(违背了ODR原则)。对于头文件中的“私有”符号,需要采用私有的名字空间(如目前流行的detail
/internal
)。 -
noexcept()
有在不同地方两种含义:- 在函数声明时的
noexcept(x)
表示如果x
为true
,则该函数为no-throw。noexcept(x)
也可以作为操作符,返回表达式x
是否会抛出异常。
所以
void foo() noexcept(noexcept(y));
表示函数foo()
是否抛出异常与表达式y
是否抛出异常相同。 - 在函数声明时的
-
在作为参数传递Functor时,为了保证其传递的值类型不变,需要使用
std::forward
;此外,在最后调用Functor时,也需要先用std::forward
得到原本的值类型,否则会导致Functor的值类型变化,调用到不正确的重载函数版本(如const &&
被调用为const &
的版本)。 -
C++ 11之后支持了构造函数委托(Delegating constructor):支持一个构造函数调用另一个构造函数,可以节省很多冗余构造的代码。这种委托可以形成一个链式调用,如“A->B->C”;当然,循环调用的结果是未定义的。
-
默认初始化 vs 值初始化/聚合初始化
首先区分标量类型和聚合类型:
- 标量类型:基础算术类型、指针、枚举、
std::nullptr_t
- 聚合类型:数组、平凡类(仅有public成员、非静态成员,没有用户提供的构造函数、没有虚/非公有基类、没有虚函数)
值初始化保证了对所有标量类型对象;聚合初始化保证了对所有聚合类型对象的初始化。注意提供了用户自定义构造函数的类不算在聚合类型中,此时成员是否构造由构造函数是否对其初始化决定。值初始化/聚合初始化发生在对象后面跟随
()
,{}
,= {}
初始化器的地方,对于未显式指定初始化值的对象,对其使用零初始化。默认初始化不对标量类型对象和聚合类型对象初始化,也就是其有未初始化值。对于提供了用户自定义构造函数的类,默认初始化相当于调用其无参构造函数。默认初始化发生在对象后没有跟随初始化器的地方。
1
2
3
4int main() {
int foo[3]; // 默认初始化
int bar[3] = {}; // 聚合零初始化
}在C++11之后,值初始化一个没有用户定义构造函数的类,该类中的成员在调用构造函数前会先被零初始化。下面这个例子(from cppreference)中
a.i
的值在第一种值初始化下为0,而在聚合初始化下为未初始化值:1
2
3
4
5
6
7struct A {
int i;
A() {} // user-provided default ctor, does not initialize i
};
struct B { A a; }; // implicitly-defined default ctor
std::cout << B().a.i << '\n'; // 值初始化, B().a.i = 0
std::cout << B{}.a.i << '\n'; // 聚合初始化, B().a.i = ?此外需要注意
=default
只在第一次声明时才表示为默认提供实现(非用户定义)。 - 标量类型:基础算术类型、指针、枚举、
-
平凡类型、标准排布类型、POD类型的关系:
-
平凡类型(Trivial Types):有着编译器提供,或是显式声明为
default
的特殊成员函数的类或结构体。平凡类型可以有不同访问权限的非静态数据成员。平凡类型可以用std::is_trivial
检测。注意到这里要求的是所有的特殊成员函数,如果将限制放宽,不要求有编译器提供,或是显式声明为
default
的默认构造函数,就符合可平凡复制类型(Trivial Copyable Types)。可平凡复制类型可以用std::is_trivially_copyable
检测。 -
对于非静态成员排布(Layout),不同访问权限的成员,编译器对其的排布顺序不定。如果限制使用C++的一些特性,结构体的数据排布规则就能够确定下来,便于与其他语言交互,这样就得到了标准排布类型。
**标准排布类型(Standard Layout Types)**有如下的要求:
- 所有非静态数据成员有相同的访问权限
- 没有虚函数或虚基类
- 没有引用类型的非静态数据成员
- 所有非静态数据成员和基类本身也是标准排布
- 没有多个同一类型的基类
- 没有与首个非静态数据成员类型相同的基类
- 所有的位域和非静态数据成员定义在同一个类中(即非静态数据成员要么全在派生类,要么在某单个基类中)
-
POD类型(Plain Old Data Types):即是平凡类型,又是标准排布类型的类或结构体,其与C语言中的类型兼容,可以与C语言互通。
- …
模版
-
Fold expression: 目前只支持32种C++内置的二元运算符,共4种展开形式(左折叠or右折叠 x 不带初值or带初值)
其中比较特殊的
,
二元运算用法较独特:可以用作依次的无返回值函数调用,如1
2
3
4
5
6template<typename T, typename... Args>
void push_back_vec(std::vector<T>& v, Args&&... args)
{
static_assert((std::is_constructible_v<T, Args&&> && ...));
(v.push_back(std::forward<Args>(args)), ...);
}可见有返回值的函数可以将返回值Reduce收集,而无返回值的可以采用
,
运算符展开。 -
当重载函数/模版函数作为参数传递给一个模版函数时,该模版函数的deduction会失败。这是因为C++目前不允许传递重载集(Overload Sets)。比如以下代码:
1
2
3
4
5
6int main() {
const auto v1 = std::vector<int>{3,15,7,8,1};
const auto v2 = std::vector<int>{5,1,3,7,3};
auto v3 = std::vector<int>{};
std::transform(v1.begin(), v1.end(), v2.begin(), std::back_inserter(v3), std::max);
}由于
std::max
为重载模版函数,其作为参数传递给std::transform
时决议失败。然而此时是可以推导出std::max
应有的重载版本的,解决方案是用lambda将std::max
包起来,如1
2std::transform(v1.begin(), v1.end(), v2.begin(), std::back_inserter(v3),
[](auto l, auto r){return std::max(l, r);});不过这里的参数类型可能发送变化,为了得到与之前完全一样的类型,需要用到完美转发,再让lambda自动推导返回类型。假设函数为
fun
,传入的函数应该写成:1
[](auto&&...args)->decltype(auto){return fun(std::forward<decltype(args)>(args)...);}
用一个宏简化一下,这里的函数用
__VA_ARGS__
表示,以支持更复杂的情况,如静态成员函数模版等;并加上了异常的声明:1
2
3
4
[](auto &&... args) noexcept(noexcept( \
__VA_ARGS__(std::forward<decltype(args)>(args)...))) -> decltype(auto) \
{ return __VA_ARGS__(std::forward<decltype(args)>(args)...); }参考2指出该宏在多个地方均有出现,意味着语言特性的缺失;此外也有一些关于解决此问题的提案。
-
SFINAE(Substitution Failure Is Not An Error):函数模版在重载决议时,当特化模版时失败,该特化就会从重载集中移除,而不是导致编译错误。特化模版时失败指替换后的类型或表达式为语义错误的(ill-formed)。
SFINAE为模版级别的重载开启了可能。在C++ 20的Concepts到来之前,SFINAE还经常用于模拟Concept,为模版参数增加限制。
SFINAE可以分为两种:类型SFINAE与表达式SFINAE。STL中的
std::enable_if
与std::void_t
对应着这两种SFINAE的实现。 -
MDFINAE(Method Definition Failure Is Not An Error):指模版成员函数在没有调用时,其正确性不会被检查。
这个小特性值得不同的接口可以被同时考虑进来,而不会引发编译错误(只要不被使用)。
-
…
STL
-
emplace_back(Args&&... args)
通过std::allocator_traits<A>::construct(allocator, p, args)
构造元素,args
是传递给元素T
构造函数的参数。::construct()
函数内调用::new ((void*)_Ptr) T(std::forward<Args>(args)...)
进行构造,因此这里没有使用统一初始化(Uniform Initialization)。如果有一个POD
struct X{int a; int b;};
没有定义任何构造函数,对其容器std::vector<X>
进行emplace_back(1, 2)
就会编译错误,因为找不到对应的构造函数。尽管聚合体struct X
能被initializer_list初始化,此时也不能用emplace_back({1, 2})
,因为**大括号initializer list(brace-enclosed initializer list)**不能被std::forward
传递。解决:这时要么给
struct X
定义构造函数,要么改用显式初始化的emplace_back(X{1, 2})
/emplace_back<X>({1, 2})
,或隐式初始化的push_back({1, 2})
。 -
对于
std::make_shared()
,要求相应对象的构造函数必须为public的。然而,即使调用std::make_shared()
的一方有对于非public构造函数的访问权限(类内部的static public的create()
构造方法、友元类里的构造),std::make_shared()
此时还是不能work,因为其访问权限永远被认为是外部的。解决:a. 简单来说,可以改用
std::shared_ptr<A>(new A)
这样的分离形式。但这样会造成其内存空间不连续。 b. 从原本的类派送一个类,这样获得了其public的构造函数的访问权。
c. 增加新构造函数,区分有访问权的构造。
-
…
底层实现|优化
Polymorphism & RTTI
-
多态对象(有虚函数或继承了虚类)的首元素为一个虚表指针,之后是自己的成员变量。对于多继承的多态对象来说,布局为多个对象(首先为派生类,后面依次为各个基类)的叠加,比如对于继承关系C->{A,B}来说,其布局为
[A虚表指针|A元素] [B虚表指针|B元素]
,注意到C本身不需要虚表指针,因为C的虚表其实就等价于A的虚表,因为它们的偏移量(vptr[-16]
)均为0。也就是说,在单继承链中,对象均只有一个虚表指针,而多继承对象则有多个虚表指针。这样,在做static_cast
的向下转型时只需将指针加上一个偏移量;而做dynamic_cast
的向上转型时只需减去一个偏移量(从虚表中获得)。 -
RTTI的信息放在虚表的负偏移处。如
vptr[-8]
存放多态类型的typeinfo
,vptr[-16]
存放多态类型到其最派生的类(Most Derived Class)的负偏移量offset_to_top
。在dynamic_cast
中。正是用基类对象指针加上vptr[-16]
这个偏移量,得到上层派生对象的实例。 -
对于偏移量不为0的基类虚表,其虚函数有所不同,前面会带一个
non-virtual thunk
。这是因为调用上层类的虚函数时,需要先调整this
指针为上层对象的this
,而带thunk
的函数就会先进行转换再去调用原虚函数。 -
对于虚继承,虚基类的派生类无法确定虚基类关于本对象
this
的偏移,因此需要在虚表中加入一项vbase_offset
,表示基类关于this
的偏移,一般在负偏移vptr[-24]
处。反过来,当虚基类要调用其虚函数时,需要的偏移量也不同,对于派生类没有覆盖的虚函数,偏移为0;而派生类覆盖了的虚函数则偏移不为0。该偏移记录在vcall_offset
中,在offset_to_top
上方。 -
(具体实现与具体编译器相关,上述内容在Clang中验证过)
性能优化
-
对于确定不会被继承的类,采用
final
修饰可能带来一定的性能提升,比如以下代码:1
2
3
4
5
6
7
8
9
10
11struct A {
virtual int f() const { return 1; }
};
struct B final : A {
virtual int f() const override { return 2; }
};
int call_f(B const& x) {
return x.f();
}由于
struct B
有final
修饰,编译器知道在call_f
函数中调用虚函数x.f()
一定是调用的B::f
,在此就可以采用inline优化。 -
std::unique_ptr<T>
并不是T*
的零开销抽象。背后的原因是ABI中定义的C++对象传递规则:System V ABI中说明,如果一个C++对象有非平凡复制构造(non-trivial copy constructor)或非平凡析构函数(non-trivial destructor),则它由不可见的引用进行传递,因为这类对象必须有明确的地址,不能通过寄存器传递。有着平凡复制构造和平凡析构函数,且能装进2个寄存器(大小不超过16 byte)的C++对象可以通过寄存器传递。在Itanium C++ ABI中也有类似的说明。
至于non-trivial对象为什么需要有明确的地址,跟异常的实现细节有关。在栈退解(stack unwinding)的过程中,自动储存周期的对象需要被析构,它们的地址必须要能通过函数栈帧得到,因为此时寄存器的值已经被污染了。为了调用析构函数,对象不能储存在寄存器中,因为这样的话对象将没有地址。
由于
std::unique_ptr<T>
的析构函数非平凡,根据ABI的要求需要将其放在栈上,即使理论上析构函数并不需要用到对象地址。注意ABI并不限制内联函数,此外能进行链接时优化的编译器也可能绕过该限制。
Clang的编译器扩展属性
[[clang::trivial_abi]]
能够绕过这个限制。 -
…
惯用技巧
模版
-
奇异递归模板模式(Curiously Recurring Template Pattern, CRTP):把派生类作为基类的模板参数。
应用:
- 将派生类个共同代码以模版的形式提取处理,以减少派生类中的冗余代码。如多态
clone()
函数,std::enable_shared_from_this
。 - 根据派生类的类型做区分处理,如对象计数。
- 产生不可被继承的类(final):将CRTP基类的构造函数声明为私有,同时将对应类设为其友元。
- 实现多态链调用(Polymorphic chaining):链式调用时,涉及到基类的方法返回的是基类的引用,这样就导致了链式调用的对象被“向下转换”了。解决方法就是采用CRTP,基类的方法返回派生类的引用。
- 提供静态的接口,可以充当类似Concept的用法(不过C++20后有Concept就不需要这个了)。
为了同时兼顾静态多态与动态多态,可以设计用于动态多态的基类
Base
,用于静态多态的中间类Base_CRTP<T>
,派生类为class Derived : Base_CRTP<Derived>
。此外,为了防止派生CRTP类时在模版参数中填错派生类,可以在
CRTP<T>
基类中将构造函数定义为private
的,同时设置T
为友元。注意派生类与基类的方法同名导致的名字遮蔽问题。
CRTP中经常要用到
static_cast<T&>(this)
,显得代码和冗余。可以写一个模版类自动完成static_cast
,详见参考5。 - 将派生类个共同代码以模版的形式提取处理,以减少派生类中的冗余代码。如多态
-
类型擦除(Type Erasure):使用单点多态实现包含不同实际类型的统一类型容器。
仅仅看上面这句话其实很绕…但其实拆开来说很容易理解:提供一个统一的接口,实际上为一个可能包含不同实际类型的容器,并且这是通过多态实现的。类型擦除实际上将多态的细节隐藏在了统一接口的后面,使其可以像值类型一样使用。此外,由于类型擦除基于多态,其难以解决涉及超过二元参数的多分派行为。
目前类型擦除广泛用于
std::function
、std::any
、std::variant
等类的实现中。还在提案中的std::polymorphic_value
(让多态类型像值类型一样使用)也是采用类型擦除实现的。另一个小话题:
shared_ptr<void>
可以存放任何类型的对象指针,并且能调用到正确的析构函数。1
2
3{
const shared_ptr<void> sp( new A );
} // calls A::~A() here因为其默认的deleter使用的是构造函数传入的类型,也是通过类型擦除实现的。
由于涉及到多态,自然就需要对派生对象分配空间。考虑到储存的对象大多较小,**小对象优化(Small Object Optimization)**基本也是类型擦除容器必备的。C++ 11中考虑到了让这些类型支持自定义分配器(如
std::function
),然而在C++ 17中完全去除了这个设定。这是因为在类型擦除语境中难以恢复迭代器以支持在拷贝赋值期间的分配操作。 -
成员检测(Member Detector):基于SFINAE检测一个类是否有某个成员函数,以实现基本的反射。在有了C++20 Concepts后,这个模式就不再需要了。
该模式的实现已经集成进了STL(
std::experimental::is_detected
)。 -
…