Table of Contents generated with DocToc
- Effective C++ 记录与速览
- C++的一些最佳实践,也就是怎么避开坑写出好的代码。
- 本书按照章节和条款组织内容。这里简单总结,细节建议直接去看书。
- 第三版成书2005年,所以C++11及之后的新特性可能不会被讲到,但在传统C++和现代C++中这本书里的东西基本都是通用的。
- 很大一部分在C++Primer中都已经提到过了,只是这里单独提出来讲了。
C++的多个编程范式:
- 继承自C的过程式编程
- C with classes的面向对象编程
- 基于模板的泛型编程,深奥的模板元编程
- STL,标准模板库
当从一个范式切换到另一个时,编程策略可能需要改变。每个子语言会有自己的规约和属于自己的最佳实践,用的哪一个部分,就使用什么原则。必须对这一点有强烈意识。
- 以全局的常量定义替换宏定义的常量,以提供类型检查等一系列好处。
- 用enum值替换宏定义得到编译期常量,以避免非必要内存消耗,在模板中广泛使用(aka enum hack)。
- 用(模板)内联inline函数替换带参宏,获得更加健壮的程序。
宏定义和条件编译依然会扮演重要角色,但不再所有事情都要交给他们来做。
const可以修饰基本任何变量:
- 在所有可能的地方使用const,常量,返回值(避免对右值的修改),参数(避免对参数的修改),成员函数(支持const对象调用)。
- 充分理解指针引用的顶层底层const。
- 将所有不会修改对象的成员函数定义为const,以提供const对象或者const引用参数来访问。
- 在const对象中有想要修改的内容可以使用mutable。
- const和non-const重载成员函数逻辑相似,可以用non-const调用const辅以
const_cast
来避免重复(注意绝不应该反过来做)。
不同部分的C++:
- 源自于C部分可能会沿用C的行为,对未初始化的内置类型变量不做任何事情,保持其为内存中的随机值。如局部变量,数组,类的内建成员等。
- 使用C++部分则会对内置类型做值初始化,如容器中的元素。
- 自定义类型的默认初始化由默认构造函数完成。
原则:
- 在任何时候保证对内置类型做初始化能避免很多错误,C++并不保证初始化他们。
- 使用构造函数成员初始化列表而非在构造函数体中对成员进行赋值以提高效率。
- 类中数据成员有着确定的初始化顺序,基类、成员按照声明顺序依次初始化,避免构造成员的初始化有数据依赖。
- 注意不同编译单元中的非局部静态对象的初始化顺序没有任何保证,避免这些对象的初始化之间存在依赖关系。
- 最佳实践:使用单例模式返回局部静态变量的引用以替代全局的静态对象。规避初始化次序问题同时还能即用即初始化。
- C++在一些情况下会默认生成默认构造函数、析构函数、拷贝构造函数、拷贝赋值运算符。
- 在已经有构造函数声明的情况下,不会生成默认构造。
- 在生成的函数的默认行为不合理、无法做到时则会将其生成为删除的函数。
- 比如类中含有const成员、引用成员时拷贝赋值运算符会生成为删除。
- 某个成员或者基类析构函数不可用时析构函数会生成为删除。
- 某个成员或者基类没有默认构造时、有const或者引用成员时默认构造生成为删除。
不想要编译器默认生成的行为时,应该使用=delete
明确声明为删除,或者自己定义。
- 在不支持
=delete
特性时比较老式的表达删除一个函数的意思的伎俩是:- 将不想要生成的函数声明为
private
,并且不做实现。 - 或者继承一个拷贝构造、拷贝赋值被定义为private且未实现的基类
Uncopyable
。
- 将不想要生成的函数声明为
- 当然现代C++中使用
=delete
就行了,不用这么绕弯子。
这已经是任何一个菜鸟都知道的共识了!
要点:
- 如果一个类要作为基类,那么可以肯定它需要一个虚析构函数。
- 如果一个类不会作为基类,那么不要将析构函数定义为虚函数。会增加一个虚指针的内存消耗。
- 如果你想要一个抽象类,但又找不到合适的函数来定义为虚函数。那么可以将析构函数定义为纯虚函数,但是同时也需要为其提供定义(合法可行且必须这样做,即使是抽象类甚至纯虚类,派生后这个子对象也是需要析构的)。
- 不要继承没有定义虚析构函数的类作为基类,使用多态特性时会出现内存泄漏。使用final阻止继承。
因为异常处理的过程中会调用析构函数销毁对象,如果在这期间再发生异常,那么将会导致UB。
- 所以永远不要在析构函数中抛出异常。
- 析构函数中的可能发生的异常都需要在函数体中就得到恰当的处理。
- 如果析构会发生无法恢复的异常,那么直接结束程序告知用户是一个好主意。
- 当然无法处理时另一个选择也可以选择吞掉异常,这取决于程序维持稳定运行重要还是暴露出所有的一个错误更重要。当然即使吞掉异常也应该将其记录在日志上之类的手段将其记录下来。
- 另一个策略是重新设计接口,将这些操作从析构函数中分离出来,将调用失败的处理交给使用类的客户去做。如果客户需要对这些异常做出反应,那么将其放在一个普通函数里面来做就是必须的。
因为在此时调用虚函数并不会达到我们想要的行为。
- 构造函数中,基类构造先于派生类构造,在构造基类时,this指代的对象是基类对象,虚函数当然也不会被解析为派生类的虚函数,因为这时候派生类成员都还没有被初始化。
- 析构函数中,派生类析构先于基类析构,在基类析构时,this同样指代基类对象,虚函数当然也会被解析为基类虚函数,此时派生类的成员已经被销毁了。
- 因为在执行基类的构造和析构时,派生类中的成员都是不可以用的,要么还没有初始化,要么已经销毁,所以这时不能将虚函数下降至派生类。
- 即是在构造函数和析构函数中,虚函数是不具备多态的,此时this也是指向该基类对象,而非派生类对象。此时做RTTI,
typeid(*this)
得到的将会是基类的type_info
对象,dynamic_cast
为派生类对象将会失败(而且这时派生类都不是完整类型,这样做必然是编不过的)。 - 除了不直接在构造和析构中调用虚函数,也应当注意不要间接调用虚函数。【当然除非这就是你期望的行为】。
这也是常识了,为了和内置运算符的语义对标,为自定义类型重载赋值,前置++/--,取成员,解引用这种返回左值的运算符都应该返回一个左值引用。以实现和内置类型类似的语义。
当然这并不是强制的,只是这是最佳实践,为了减少心智负担而已。
我们应该假定使用代码的人可能会做出任何事情,包括自己给自己赋值,虽然这并没有什么意义。
- 某些时候赋值运算符中的逻辑本身就可以正确处理自赋值的情况,但某些情况并不能,每次写赋值运算符都应该考虑这种情况。
- 比如类中申请了内存,不要急于在还未复制右侧对象内存之前,就将自己的内存释放掉。
- 如果不好通过调整赋值中的操作顺序来处理自我赋值,那么也可以显式做一个判断。
- 通常的做法时,复制了右侧对象的内容分配好了新内存之后,再来释放左侧对象内存,兼具自我赋值安全和异常安全。
- 使用copy and swap技术来处理自我赋值也是一种常见手段:
- 可以使用const引用传参,在拷贝复制内部做拷贝之后和左侧对象做swap。
- 也可以使用另一种更加巧妙的手段:使用值传递(在此时复制),内部做交换之后返回
*this
。这种方式还会将移动赋值和拷贝赋值统一到一个赋值运算符中。 - 适用于在实现了对象交换的类型(特化了
std::swap
)中这样做。
这也算是基本常识了。
- 拷贝构造和拷贝赋值运算符中一定不要忘记复制每一个成员。(所以,为类添加新成员时千万不要忘记在构造函数中添加初始化,在拷贝控制成员中添加其复制逻辑,如果有必要析构函数中也需要处理)。
- 除了自己的成员之外,如果是对一个派生类实现拷贝构造和拷贝赋值,那么一定不要忘记去对自己的基类部分执行拷贝。
- 特别地,应该在拷贝赋值运算符中调用
Base::operator=(rhs)
。
- 特别地,应该在拷贝赋值运算符中调用
- 通常来说,拷贝构造和拷贝赋值运算符不应该互相调用,因为前者是构造一个新对象,后者是对一个已有对象赋值覆盖其原有状态。如果两者有很多重复逻辑,应该定义一个新的私有成员函数给两者使用(通常命名为
init
)。
- 将资源放进对象,依靠析构的自动调用来释放对象,无论是抛出异常还是中途控制流退出都能够释放资源。
- 可以使用智能指针来管理资源。
- 获取资源后立即放到管理对象(智能指针)中。即是RAII(Resource Acquisition Is Initialization,资源获取即初始化)。
- 管理对象的析构函数确保资源被释放。
注意:这个条目中提到的auto_ptr
现在已经有shared_ptr unique_ptr weak_ptr
替代了,并且智能指针都支持自定义deleter了,所以现在也可以管理new []
分配的内存了。
当我们用RAII将资源释放委托给资源管理对象的析构函数后。需要小心资源管理对象的复制行为:
- RAII:资源在构造期间获得,析构时释放。
- 几种资源管理对象复制行为的处理:
- 如果复制行为不合理,那么需要禁止复制(但一般允许移动),也就是使用类似于
unique_ptr
的行为。 - 如果复制行为是合理的,效果是多个管理对象指向同一资源,那么可以使用引用计数管理。也就是
shared_ptr
的行为,可以通过自定义deleter来实现非内存类资源的释放。 - 复制底层资源,这也是一种复制时可以考虑的行为。对资源做深拷贝,和原先的资源便没有了关系,独立了起来。
- 转移底部资源拥有权,也可以配合
unique_ptr
使用。某些时候可能需要确保只有一个RAII对象指向底层资源,那么可以这样做。
- 如果复制行为不合理,那么需要禁止复制(但一般允许移动),也就是使用类似于
- 不同的资源可能需要考虑不同的复制行为,但都可以用RAII来管理。如果单纯的智能指针功能不够,可能需要自定义RAII类来管理资源。
资源管理类将原始资源封装了一层,那么在需要这些原始资源的地方就需要提供对原始资源的访问:
- 智能指针的
get
接口提供原始指针,. ->
运算符提供了对资源对象的成员访问。 - 在自定义资源管理类中可以提供智能指针
get
这样的显式转换接口,或者编写类型转换运算符以提供隐式转换。显式转换更安全,隐式转换更方便,各有优劣,怎样提供视具体情况选择。 - RAII类并不是为了封装,而是为了确保资源能够在析构时得到释放。
- 良好设计的类会隐藏客户不需要的部分,提供用户需要的所有东西。
new/delete
以及new[]/delete[]
是配套的,不能混用,这也是常识了。
- 特别是用智能指针管理
new[]
分配的内存时需要自定义删除器为[](T* p) -> void{ delete[] p; }
。 - 用RAII管理时,在构造和析构中一定要配套使用。并且多个构造中必须要使用相同的
new
运算符。
简单来说就是,防止资源创建和资源被转换为资源管理对象之间发生干扰(比如抛出异常),导致内存泄漏。
- 典型如使用智能指针传参数时,先将智能指针构造出来再传入接受智能指针的函数。而不是在函数调用中直接构造临时的智能指针。
- 【因为编译器对于跨越语句的操作没有重新排列的自由度】。
应该在所有接口中努力达成“容易被正确使用,不易被误用”的原则:
- 促进正确使用的方法有:设计更符合直觉的接口,设计与内置类型行为兼容的接口。
- 防止误用的方法有:建立新类型(通过类型系统防止错误数据),限制类型上的操作,束缚对象的值,消除客户的资源管理责任(典型如使用智能指针)。
std::shared_ptr
自定义删除器,可以防止DLL问题(跨DLL释放内存,一般会报运行时错误),自动解除互斥锁等。- 总之:在最理想的情况下,如果用户错误地使用了接口,那么应该出现编译错误,这基本不可能达不到,但应该为此而努力。
也就是说设计类从来不只是定义一个class那么简单,要像语言设计者设计内置类型那样谨慎。一些设计要点:
- 类的对象如何被创建,影响到构造析构函数即内存分配和释放函数(
operator new/delete/new[]/delete[]
)。 - 对象的初始化和赋值该有什么样的区别。
- 对象被值传递时,拷贝构造函数怎么实现。
- 对象的合法状态,也就是那些值对对象来说是有效的,这将决定了成员函数中的错误检查、抛出异常等。
- 配合继承体系。如果新类是否继承自既有的类,那么需要遵守既有的基类的影响。如果你的类开放给用户作为基类,那么将影响函数的声明,尤其是析构函数需要声明为虚函数。
- 新的类型需要怎么样类型转换,其他类型转换到该类型,该类型转为其他类型。
- 是否需要为新类型重载运算符,那些运算符是合理的。
- 不该对外部暴露的函数应该声明为private。
- 谁会来使用这个新的类,也就是这个类的用户是谁。这将决定对外暴露的接口,对派生类暴露的接口,非成员函数接口。
- 这个类是一个独立的类,还是一整族类,如果是一族类,那么也许应该定义为类模板。
- 你确实需要一个新的类吗?如果只是在现有类基础上派生,并加上很少的功能,那么为什么不直接修改现有类,或者添加几个函数解决。
一个好的设计需要对上述所以问题作出自己的回答,确保在所有方面做到最好。
- 永远记住值传递会对类型调用拷贝构造函数进行拷贝。
- 对自定义类型来说,在非必要以值传递的方式传递时,都应该以引用参数传递,不会修改源对象则使用const,会则不使用const。
- 在多态场景中,采用引用传递也可以避免对象被切割为基类对象。
- 对于内置类型则一般采用值传递。
- 一些特殊的自定义类型比如智能指针、STL中的迭代器和函数对象,一般来说采用值传递更为合适。
简单来说就是不要试图在不应该返回引用的地方返回一个引用。
- 比如返回表示计算结果的右值的重载运算符(
operator +-*/%
等)。 - 不要试图返回局部变量的引用。
- 不要去焦虑这一点拷贝构造导致的性能损失,现代C++中有移动语义以及复制消除(构造函数消除、RVO、NRVO等)的优化手段。
封装就是要将成员声明变量为private,只对外部暴露出接口:
- 成员变量隐藏的背后,可以为所有可能的实现提供弹性。可以在用户无感知的情况下修改背后的实现。
- 隐藏成员才能确保类的约束条件通过接口函数得到恰当地维护,访问权限得到更精细的控制,而非任由用户直接读取修改成员变量。
- 封装同样保留了日后变更的权利,不封装几乎意味着不可改变。没有人会需要一个不可改变的程序。
- 封装性(隐藏的程度)与其被修改时可能造成的代码破坏量成反比:
- public成员的修改可能破坏大量用户代码。
- protectd成员修改可能破坏大量继承了该类的代码。
- 唯有private成员具有最高的封装性,得以在用户无感知的情况下替换修改。(友元破坏了封装,所以友元除外)。
- 所以从封装角度看,只有两种访问权限:private和其他。
- 最后:记住将所有实现而非接口相关的内容封装起来,声明为private。这可以给与用户访问数据的一致性、可细微划分的访问控制、约束条件得到保证,并提供给类的作者充分的实现弹性。
如果一个非成员函数与友元函数或者成员函数提供同样的功能,那么应该选择非成员非友元函数。
- 因为成员函数和友元函数会增加能够访问类中私有成分的函数数量。(能够访问类中私有成分越多,封装性越弱)。
- 所以实现为一个非成员非友元函数会具有更强的封装性。
- 一个好的选择是将其放在与类同一个命名空间中作为一个非成员非友元的全局函数。
- 这也能降低编译依赖性,也就是代码耦合度更低。
很容易理解,如果定义为成员函数,那么第一个隐式参数this是不能够由其他类型参数隐式转换而来的,必须显式构造之后才能使用其调用成员函数。
- 最常见的是重载的运算符,比如算术运算
+-*/%
这种。定义为非成员函数以允许所有参数都能够隐式转换会更加合理一些。 - 这通常用在可以由其他类型隐式转换为该类型时。
std::swap
函数模板的平凡实现就是我们想的那样,利用一个临时变量,交换两个变量的值。
- 当要实现交换函数时,通常我们是使用我们的自定义了类型对
std::swap
做一个(全)特化(特化到命名空间std
中)。 - 一般来说的实践是定义一个
swap
成员函数来做交换,然后std::swap
特化直接调用即可。 - 但如果我们定义的是类模板而非普通类时,就不能够对
std::swap
做偏特化了(因为C++只支持对类做偏特化,不支持对函数做偏特化),这时的做法时给std::swap
添加一个新的重载版本(然而直接加在std
是不合法的)。 - 但是有一点比较特殊的是命名空间
std
,我们可以对其中的模板做特化,但是不能往其中添加新的模板。所以通常的做法是,将类和非成员的swap
模板放到同一个命名空间。因为对类做函数调用时同时会去类所在的命名空间查找名字,所以这样做就足够了。调用时应该使用swap
而不是std::swap
。【如果没有使用命名空间,那么就是定义全局命名空间,这也是可行的,怎么用取决于你】。 - 对于类模板来说,在新的或者全局命名空间定义一个新的
swap
模板是最佳选择。 - 而对于普通类来说,也可以定义一个普通函数重载版本的
swap
。通常情况下的建议是特化std::swap
和普通函数定义同时做,他们都调用类内部的swap
。 - 无论什么情况,使用时的最佳实践是:
- 在作用域内
using std::swap;
,就像这样:
template<typename T> void doSomething(T& a, T& b) { using std::swap; ... swap(a, b); ... }
- 这样做时,匹配顺序会是:T类型同命名空间中的
swap
、自己添加的特化版本的std::swap
、非特化版本的std::swap
库实现。 - 而不应该直接使用
std::swap
(这样会忽略同命名空间中的swap
实现,如果是类模板,就意味着直接去实例化std::swap
)。
- 在作用域内
- 值得注意的是:
swap
的使用就是为了让成员在拷贝和移动时确保异常安全,所以应该确保成员版本的swap(最终被调用的那个实现)不抛出任何异常。
另外:
- 更一般地,重载决议时,会将所有参数所在命名空间中的同名函数加入可行函数集(在没有说明所调用函数的命名空间的情况下)。
本章关注实现中的各种细节问题:
- 变量定义时机。
- 不要滥用转型。
- 避免返回内部handle。
- 为异常安全而努力,避免异常导致的资源泄漏。
- 不要滥用内联。
- 降低代码的耦合度。
- 通常来说,最好就是变量即用即定义,也就是直到要用到变量的前一刻再定义。
- 并且定义的时候用一个有意义能用到的值来初始化,好过默认构造之后再赋值。
- 在循环中使用一个多轮循环中没有关联的临时变量时,在循环前定义还是每一轮循环定义则取决于一组构造+析构与一个赋值操作谁的成本更高。
- 通常来说两者成本是差不多的,非效率敏感部分代码是可以在循环中定义的,会更加清晰,且不会延长该临时变量的生命周期。
显式类型转换同时破坏了类型系统,非必要不应该使用,优秀的代码很少使用显式类型转换:
- C中转型格式:
(T)expression
继承自的C的最原始的转型风格。T(expression)
构造转型风格。- 两者等价,前者在C++中不应该使用,后者常用在要构造一个临时变量的地方,这种情况亦可理解为临时的纯右值对象的构造,通常是合理的。
- C风格转换会尝试去使用C++的
static_cast const_cast
,如果不合理则会使用reinterpret_cast
。
- C++中显式类型转换:
const_cast<T>(expression)
dynamic_cast<T>(exression)
reinterpret_cast<T>(exression)
static_cast<T>(exression)
- 通常来说不应该用C风格转换,当然如果语义是调用转换构造构造一个临时对象,那是可以使用的。
- 通常也不应该使用C++显式类型转换,可能存在些许例外:
const_cast
应该只用于类似于条款3所述的等少量场景。dynamic_cast
具有不小的运行时消耗。试着使用无需dynamic_cast
的设计:- 不使用基类指针,而使用派生类指针保存派生类对象。
- 添加虚函数,在基类添加空实现,从而使用多态来处理,避免转换。
reinterpret_cast
通常意味着坏的设计,通常不会需要重新解释内存,除非一些非常非常特殊的场景,尝试使用其他方法替代。static_cast
也应该尽量被避免,非要用也应该封装在函数中,而不是让用户来做。典型如std::move
。
- 就算要使用显式类型转换,也应该使用C++风格而不是C风格。(当然单参数构造一个临时对象的风格依然很常用,因为可以理解为一个临时对象的构造,这是使用
static_cast
还会更费解)。
这里的句柄包括指针、引用、迭代器或者传统的句柄。
- 典型如
std::shared_ptr::get
返回的原始指针,通常我们只应该将其用于只能接受原始指针而不能接受智能指针的函数调用场景,调用结束后即释放,避免出现空悬的句柄。 - 其他类型同理,返回对象内部句柄代表着封装性的降低,内部封装的成员的访问级别其实被提高了。
- 对一个const对象返回其内部句柄,并且可通过句柄修改内部状态的话,在逻辑上就是错误的(语法却是合法的)。
- 要让const成员函数的行为像一个const,此时应该在返回的句柄上加上const修改时,让其变为只读。
- 不得不用的时候避免返回的句柄空悬也是非常重要的。使用得到的句柄时避免源对象已经被析构:
- 如果将返回的句柄作为返回值,可能应该值返回,而不是返回指针、引用或者迭代器。
- 能用外层对象完成的事情就避免使用内部句柄来做。
- 比较特殊的情况下可能不得不这么做,比如
operator[]
、迭代器等。但都要时刻注意使用时决不能在对象析构之后还在用返回的句柄。
编写异常安全的代码,给与了程序更高的健壮性,也给了用户在异常抛出时更好的操作空间:
- 当异常抛出时,有异常安全性的函数需要满足:
- 不泄露任何资源。
- 不允许数据损坏。
- 防止资源泄漏可以使用资源管理类利用RAII特性解决,将发生异常时的资源释放动作委托给RAII类的析构。见第三章。
- 不允许数据损坏就要求我们小心安排申请新资源和释放旧资源的顺序:
- 通常做法是统一先申请新资源/获取并计算新状态,成功之后再释放旧资源/设置新状态。避免旧的资源已经释放,但新的资源却申请失败的情况。
- 特别是有多个资源和状态时,要避免一部分状态已更新,一部分还没有更新时抛出异常的问题。
- 异常安全提供以下三个程度的保证:
- 基本承诺:异常抛出时,程序内内部事物依然处于有效状态,但状态是否改变并不确定。
- 强烈保证:异常抛出时,程序状态不发生任何改变,和调用前一致。
- 不抛出(nothrow)保证:承诺绝不抛出异常,通常我们会为这种函数加上
noexcept/throw()
修饰。
- 如果函数不提供以上三种保证之一,那么它就不具备异常安全性。
- 编写异常安全的代码时,我们需要抉择提供哪一种保证:
- 不抛出保证很诱人,那么很简单的函数很容易提供不抛出保证,但是如果我们调用了任何可能抛出异常的函数,那就不可能实现了。
- 通常情况下都是在基本承诺和不抛出保证中做选择。
- 实现强烈保证的一个一般化的设计策略就是我们前面提到过的copy and swap技术。先创建打算修改的对象的副本(用智能指针保存以避免资源泄漏),在副本上做状态修改,修改完之后在与目标对象做交换(swap操作通常都需要承诺不抛出异常)。典型实现示例:
// member of Foo example: // Mutex mutex; // shared_ptr<FooImpl> pImpl; void Foo::someExceptionSafeFunc(const Bar& bar) { using std::swap; Lock m(&mutex); // RAII manage mutex // copy old states // RAII make sure the copy will surely be released shared_ptr<FooImpl> pNew(new FooImpl(*pImpl)); // set new states pNew->xxxMember.reset(new Bar(bar)); ++pNew->xxxMember; // swap swap(pImpl, pNew); }
- copy and swap技术提供了一个“全有或者全无”的一个好方法。
- 但是因为一个函数能提供的异常安全保证取决于函数实现中调用的所有函数中最弱的那个保证,如果函数使用了copy and swap提供强烈保证,但是额外调用了一些只提供基本保证的函数,那么就只能有基本保证。要提供强烈保证就必须在基本保证的函数调用两侧去记录原始状态,在发生异常时做状态恢复,会有一定的性能代价。
- 上面所说都是函数只操作局部状态的情况,如果函数还会操作全局状态,那么提供强烈保证就更为困难了。
- 总结:
- 强烈保证不一定容易实现,很多时候强烈保证是不切实际的(可能的巨量效率损失与繁杂的实现成本),这时尽可能提供基本保证也许是更好的选择。
- 因为异常安全是传递的,所以一个程序要么是全局异常安全的,要么是不安全的。不存在说局部异常安全的。
- 但并不应该滑坡谬误,已经存在异常不安全的代码不是继续编写异常不安全的代码和不再为异常安全做任何努力的理由。任何时候都应该努力编写出异常安全的代码。
- 异常安全应该作为接口的一部分,被写进文档中。
作为曾今很少为异常安全考虑的人来说,这一节具有非凡的指导意义。
函数内联是典型的空间换时间策略,减少函数调用的开销,但会增大程序的体积。取决于你对哪一个资源的敏感度更高。
- 函数体足够小是使用内联的一个有效理由,当函数体小到比函数调用的开销更小时,内联就只有好处没有坏处了(减小程序体积同时提高效率)。
- 内联只是一个向编译器提供的建议,不是强制命令。
- inline的细节:
- 在类中定义函数是隐式内联的。
- C++的内联是在编译时做的。
- 显式的内联建议通常将函数定义在头文件中,为了能够将代码嵌入到调用的位置,编译器需要知道函数体是什么。因为最终不会生成函数,所以可以有多份同样的定义而不会造成符号重定义。
- 模板通常也会将定义写在头文件中,但满足同样规则,并不会直接隐式内联,要内联同样需要显式使用inline。并且记住他们定义在头文件里面并不是他们应该内联的理由。
- 编译器并不会执行过于复杂的内联。
- 虚函数的调用也不会执行内联(除非非常简单的编译期就能确定调用哪一个的,属于编译优化的一种)。
- 多数编译器如果无法内联一个inline函数,可能会给出警告。
- 某些时候编译器虽然内联了某个函数,但却依然生成了函数代码(比如需要取其地址时)。同理编译也通常不为通过函数指针调用的函数内联。
- 构造和析构函数虽然函数体里面没什么东西,但是通常他们会做很多事情,基类和成员的构造、析构,异常处理等。通常也不是内联的好候选。
- 内联也可能有一系列其他缺点:
- 因为没有生成函数代码导致定义发生改变时,必须重新编译。内联增加了模块间的耦合度。
- 很多编译器在调试环境下禁止内联。
总结:
- 只对必要的代码做内联。
- 80-20经验法则:平均而言一个程序往往将80%的时间花在20%的代码上。在profiling的时候再来做优化可能才是一个好选择,毕竟过早优化是万恶之源。
首先分别考虑类和函数的声明和定义对其使用到的自定义数据类型的声明和定义的依赖程度。
- 最根本原则就是,编译器需要能够有足够的的信息来生成代码。
对于函数来说:
- 函数声明中:参数、返回值类型中可以出现任何只声明了但未定义的类型,以及其引用或指针。
- 函数定义中:
- 参数中、返回值类型中、函数体中可以出现只声明未定义类型的引用和指针。但不能通过引用和指针去引用类型的成员,某些情况下就算只使用了引用或者指针也需要完整类型,比如做了
static_cast dynamic_cast
这种类型转换时需要转换构造函数/类型转换运算符、类的派生关系可见,换言之也就需要定义可见。 - 参数、返回值、函数体中使用了自定义类型的变量,或者通过任何方式引用了自定义类型的任何成员,都需要自定义类型的定义可见而不能只有声明(即是完整类型)。
- 参数中、返回值类型中、函数体中可以出现只声明未定义类型的引用和指针。但不能通过引用和指针去引用类型的成员,某些情况下就算只使用了引用或者指针也需要完整类型,比如做了
对于类来说:
- 成员函数声明和定义对用到的类型与普通函数的要求一致。
- 数据成员是指针或者引用的话,只需要声明可见即可,如果是该自定义类型的数据成员的话,则要求是完整类型(以便确定占用内存空间)。
为了避免编译依赖我们应该做什么:
- 一般的构想是,能依赖于声明就不要依赖于定义。
- 鉴于在头文件中我们只放声明(内联函数、模板除外,当然还以后类的定义,但成员函数仅仅放声明),所以只有在类成员中包含自定义类型的变量(而非指针引用)时,类定义才会依赖于成员类型的定义。
- 此时只要这个成员类型的定义有修改就会影响所有使用了这个类以及其他用了这个成员类型的定义的函数、类的代码。牵一发而动全身,降低编译效率。
- 解决方法可以是将成员类型换为其指针或者引用,但会带来内存资源管理的复杂度。
- 当然包含标准库类型一般不会成为编译瓶颈,标准库类型不会更改,并且一般都有预编译头。
- 考虑到上面一点,有两种手段可以实现使用该类的代码不依赖于其成员:
- 一种叫做Handle classes(句柄类):
- 典型实现如下:
class FooImpl; class Foo { public: Foo(Args ... args) : pImpl(make_shared<FooImpl>(args...)) {} private: shared_ptr<FooImpl> pImpl; };
- 将一个类型的实现全部委托给其实现类,接口类中仅仅只做一个转调。这种实现方式也叫做pImpl idiom(pImpl惯例)。
- 所有涉及到这个类内部实现的东西都在其实现类中。所以修改成员、添加成员等不会影响接口的操作,不会引起使用该类的代码的重新编译。
- 另一中实现叫做Interface classes(接口类):
- 典型实现:
class FooInterface { public: FooInterface(Args ... args) = 0; virtual ~FooInterface() = 0; virtual xxx otherVirtualMethods() = 0; xxx someNonVirtualFunc() { ... } // factory static shared_ptr<FooInterface> create(Args ... args) { return shared_ptr<FooInterface>(new Foo(args...)); } }; class Foo : public FooInterface { public: Foo(Args ... args) {} ~Foo() {} xxx otherVirtualMethods() {} };
- 即将接口和实现分离,定义一个抽象类作为基类,接口全部定义为虚函数,在派生类中实现。非虚的接口则可以实现。
- 将构造委托给静态工厂来做。
- 实现内部的改变也不会影响到使用接口类的代码,只要接口不发生改变,用户代码都不需要重新编译使用该类的代码。
- 但这两种方法都存在一定缺点:
- 前者进行了一层转调。
- 后者所有接口调用都是虚调用,有一层间接层次,并且增加了一个虚指针的内存消耗。
- 都有一定性能损耗与内存空间损耗。
- 这两种方法也都可以隐藏内部实现细节,在实际生产中广泛使用,且更多用在对外部的API/SDK等强烈需要隐藏内部实现(比如类有哪些数据成员,有哪些私有函数)的地方。
- 实际使用时还是应该权衡编译速度、运行时性能、代码解耦需求程度、代码规模等因素综合考虑是否使用。
- 某些程序库还会提供单独的仅有声明的头文件以提供给自定义的头文件使用(如标准库
<iosfwd>
中声明了IO相关类型),而包含定义的头文件则提供给实现源文件来使用。 - 以上做法是否涉及模板都可以使用。
公有继承意味着is-a关系,根据面向对象的里氏替换原则,任何能用基类对象的地方应该要都能使用派生类对象。
- 具体到C++语言中,因为引用和指针才具有多态,为了避免基类子对象被拷贝切割,应该说任何能使用基类指针或者引用的地方都可以使用派生类对象。
- is-a的关系即是“是一个”的关系,也即是说每一个派生类对象一定是一个基类对象。
- is-a关系的特点就是基类能做的事情,派生类也一定可以做。
- 应该仔细思考要建模的事物是否满足这种关系。某些现实中表现为is-a关系的事物,用面向对象来描述时不一定就能完美地建模为公有继承。
讨论继承中的名称覆盖问题,其实和继承没有关系,而是和作用域有关。
- 规则就是派生类作用域被嵌套在了基类作用域中,所以派生类的名称会隐藏基类的名称。
- 名称可以是变量名、函数名、嵌套类名、枚举、类型别名。
- 永远记住名称查找先于类型检查,从内层作用域向外找,找到的一定是最近的那一个,并且只会找到最近的那一个。
- 在派生类成员函数中,一个名称的作用域查找顺序是:成员函数、派生类、基类、全局作用域。
- 名称隐藏几乎总是简单清晰的,推荐的实践原则:
- 不要在派生类中重新定义基类的非虚函数。
- 不要在派生类中定义与基类数据成员同名的数据成员。
- 这两个原则已经基本足够了。
- 但面临到虚函数有多个形式的重载时就有点特殊了:
- 一般来说如果有多个重载的话,通常要么都是虚函数,要么都是非虚函数。一部分虚而一部分非虚会让人非常迷惑。
- 多个重载形式的虚函数在派生类中该怎么办呢?
- 如果我们覆写了全部,自然无任何问题。
- 但如果只想覆写一部分,那么在派生类中就只能使用覆写的那一部分,因为名称查找只能查找到那一部分。【注意这其实违反了公有继承的is-a关系,这使得派生类无法使用未覆写的那一部分了。】
- 一个可行做法是,覆写所有,不需要覆写的那一部分直接调用基类实现,做一个转发(forwarding)。但这也许并不是最佳做法。当然如果你确实仅需要其中一部分可见,使用转发无疑会更加灵活。
- 最佳做法是使用using声明(
using Base::func;
),将派生类作用域声明该基类名字,使基类该名字可见,从而基类实现与派生类覆写的部分构成重载。
- 总结:
- 名字查找先于类型检查。
- 针对需要做到的事情选择合适的做法,确保你知道你做的事情意味着什么。
- 当继承结合模板时,事情又有些不一样,见条款43。
公有继承中,派生类总是继承基类的所有接口,但继承接口还是继承实现亦有区别:
- 纯虚函数(pure virtual)只指定接口继承,派生类负责实现。
- 非纯虚的虚函数(impure virtual)指定接口继承以及默认实现继承。
- 当提供默认实现,但是又想要派生类显式指明想要继承实现时才给与实现继承可以这样做:
- 声明为纯虚,但提供默认实现,派生类中必须覆写,想要继承默认实现就去显式调用基类实现。
- 声明为纯虚,默认实现提供在另一个非虚函数中(这样可以更精细地控制访问权限),派生类需要继承默认实现时在派生类覆写中显式去调用该默认实现。是否真需要这样做也有点争议。
- 非虚函数(non-virtual)指定接口继承以及强制性的实现继承,也就是派生类基类应该使用同一实现,派生类绝不应该去重写(这会将基类实现隐藏,并且得不到多态的动态绑定特性)。
最后,final和override关键字可以阻止覆写、显式指明覆写,前者可以用来指定基类应该继承接口以及实现、后者用来表明覆写更加健壮与清晰。
考虑使用非虚接口(Non-Virtual Interface)来实现模板方法(Temlate Method)模式:
- 简称NVI手法。
- 即将接口定义为非虚函数,实现在基类中,在其中调用一个虚函数完成实际工作。
- 这个虚函数可以是private、protected、public,取决于默认实现是否要对派生类可见(某些场景需要派生类首先调用基类实现就不能private必须protected)、是否对用户可见(这是实现,通常可能并不会选择暴露给用户)。
- 好处是在基类非虚接口实现中可以在调用虚函数前后做一些更多的事情,比如获取/释放一个互斥锁、记录日志、验证约束条件、验证函数先决条件等。可以更加灵活,让客户直接调用虚函数则不是很好做这些事情(在每个实现中都这样做一遍显然不是一个好选择)。
借由函数指针实现策略(Strategy)模式:
- 如果一个接口的功能与具体类无关,而且可以在构造时由外部定制,甚至在运行时变更。那么可以使用策略模式,用函数指针来保存这个功能。
- 有一点要求就是这个定制的函数需要仅由类的公有接口就能够实现功能。如果需要弱化类的封装,将这一族功能定义为友元,看起来就不是那么方便了。
- 优点是:可以定制、可以运行时变更,缺点就是:需要弱化类的封装。可根据设计情况抉择。
使用std::function
完成策略模式:
- 在C++11之后,基于函数指针的做法就显得有点死板了,有了
std::function
之后我们可以保存函数指针、函数对象、成员函数指针等多种可调用对象,弹性大大提高。
古典的策略模式:
- 古典的策略模式可能需要对这个功能本身再定义一个继承体系,将虚函数定义其最顶层基类(称其为功能类好了)中。然后我们要使用这一族功能的基类中包含一个该功能类的对象或者指针。
- 在Java这种语言中可能就需要这样来实现。
- 有了
std::function
之后还使用这种实现方式可能会显得有那么一点呆。但如果这个功能如此复杂以至于完全有必要搞一个复杂的继承体系来充分地复用,那么这种传统实现也不失为一个好选择。
最后,面向对象的设计是灵活多变的,而并非死板固定的,需要视实际情况抉择、视问题规模抉择、视运行性能抉择、视设计风格统一度来抉择。
这恐怕是面向对象设计的常识了。从实践层面考虑,要覆写,那么就定义为虚函数。定义为非虚,就一定不要去重新定义,因为得不到多态动态绑定的支持,会让代码陷入不确定性的漩涡。
从理论层面去考虑,非虚函数建立起了对该类型来说 不变性(invariant)凌驾于特异性(sepcialization) 的接口。公有继承is-a的关系也就是说每一个派生类对象都应该是一个基类对象。该非虚接口表示的属性是基类的一部分,也是这一族类型的一部分,它是is-a关系的组成部分。如果派生类中重新定义了基类非虚接口,那么就违背了is-a关系,也就不应该使用公有继承。
一言以蔽之:默认参数值是静态绑定,而虚函数是动态绑定,这是冲突的。重新定义继承而来的默认参数同样会让代码陷入不确定的漩涡。
通常来说,如果基类给了虚函数默认参数,那么选择可以是:
- 派生类不要再给默认参数,由基类指针引用调用该函数时可以使用基类的默认参数。由派生类调用则需要指定参数。
- 派生类定义相同的默认参数,这会带来代码重复与依赖,如果基类默认参数改变,派生类所有默认参数必须相应改变。通常来说不要这样做。
- 但如果就是要派生类也可以使用该默认参数呢?使用条款35提到的NVI(Non-Virtual Interface),将虚函数变为非虚接口并定义默认参数,实际工作在私有虚接口中做即可。
复合(composition,也成组合、聚合(aggregation))是指一种类型对象内含其他类型对象的关系。
- 它有两个含义:has-a有一个,或者is-implemented-in-terms-of(根据某物实现出)。
- 当自定义类型对应于现实世界中的事物时,称这样的对象处于应用域(application domain)。复合发生在应用域内的对象之间时,表现出has-a的关系。
- 当自定义类型对应于实现细节上的人工制品时(比如缓冲区、互斥锁、查找树),称之处于实现域(implementation domain)。复合发生在实现域时,表现出is-implemented-in-terms-of的关系。
我们称私有继承为继承实现(对应地公有继承是继承接口):
- 在派生类中基类子对象是private的,所以不可以在外部访问其接口,派生类指针引用也不会隐式转换为基类。
- 私有继承通常意味着is-implemented-in-terms-of关系(某些时候我们也说是has-a关系)。而复合的含义也是如此。所以私有继承能做到的事情复合同样可以做到。
- 某些时候可能只有复合能做到某些事情:复合可以阻止派生类重写虚接口。(当然现代C++中其实已经有final语法可以阻止重写虚接口了)。
- 指导原则是:尽可能使用复合,在必要时才使用私有继承。
使用私有继承的理由:即大名鼎鼎的空基类优化:
- 对于没有任何非静态成员变量、没有任何虚函数、没有任何虚基类、的空类对象不会有任何存储空间,但是C++规定任何独立非附属对象都必须有非零大小(至少为1)。
- 所以如果对这种类型使用组合的话,大小至少为1,再加上字节对齐,可能会造成一些不必要的内存消耗。
- 而如果将这个类型作为基类的话,就可以实现大小为0,即是所谓的空基类优化(Empty Base Optimization)。
- 通常在对内存十分在意的情况下才会使用这个技巧,库中(如STL)可能会比较常见,使用私有和保护继承很多时候是因为这个原因,但相比而言公有继承还是会占有压倒性的比例(通过超过99%)。
总结:
- 一般来说我们应该直接选择复合而非私有继承,在考虑每一毫的性能与内存占用时可以考虑因为空基类优化而使用私有继承。
- 另一个使用私有继承的原因是:is-imlemented-in-terms-of关系,但派生类需要访问基类的保护成员、或者需要重新定义基类虚函数(继承+组合亦可做到)的情况。
- 如果你考虑了多方因素之后依然有理由选择私有继承,那么选择私有继承也是可以接受的。程序设计是多样化的。
- 现实世界中私有继承和保护继承相对来说比较罕见。
私有继承与保护继承:
- 这两者都是继承实现而非继承接口,区别仅仅是私有继承实现仅对该派生类可见,而保护继承中实现还对下层的派生类可见。
- 保护继承亦可使用复合,并将访问权限设置为protected来取代。
讨论起多重继承这个话题,有忠实的拥护者,也有坚决的反对者。
- 多重继承(multiple inheritance)就是继承了多个基类的意思。
- 多重继承的首要问题就是可能会引入名称冲突,从多个基类继承了相同名称的成员、函数该怎么办。如果是函数的话,构成重载,如果无法决议,则需要显式指定作用域。如果是成员变量,则需要显式指定作用域。
- 这些基类可能又有自己的继承体系,最复杂的情况会导致菱形继承:
A
/ \
B C
\ /
D
- 菱形继承的问题是,最终D中到底存储几份A的数据。C++支持存储一份与两份。
- 存储两份是默认行为,存储一份则需要虚继承(令BC虚继承A)。
- 虚继承是需要代价的:编译器做了若干幕后工作,最后只使用公共基类数据的虚继承最终产生的对象往往比非虚继承要大,访问虚基类成员变量时,往往比访问非虚基类慢。所以默认行为选择了非虚继承。
- 虚继承的初始化规则也不同于普通多重继承:虚基类的初始化责任由继承体系中的最底层负责。
- 忠告:
- 非必要不使用虚继承。
- 非要使用虚基类,避免在其中放置数据,逃避初始化责任(这种行为就类型与Java或者C#中的纯粹的接口类了)。
- 多重继承也是有合理用途的,比如公有继承与私有/保护继承混用(公有继承一个接口类,私有继承某个帮助实现的实现类),同时表达is-a和has-a/is-implemented-in-terms-of关系。
- 多重继承无可替代(我们总是应该先考虑单一继承)或者有显著优势时,是值得使用的,相比私有、保护继承,多重继承可能使用频率还会更高一些。
- 总之,明智而审慎地使用多重继承。
在一个普通函数中,要使用多态,可以将参数定义为基类接口(指针引用),传入派生类对象来实现运行时多态,这种接口也叫做显式接口。
而在函数模板中,可以将使用模板类型参数作为函数参数类型,在函数中调用该模板参数类型的成员函数,只要拥有这些函数的类型(函数类的表达式有效)都可以做为模板类型实参用以实例化函数模板,这种接口被称作隐式接口(implicit interface)。对于重载的函数模板,在编译期确定的多态行为称之为编译期多态(compile-time polymorphism)。
- 隐式接口仅仅由一组有效表达式组成。只要支持这一组表达式,就可以作为类型参数实例化模板。(很像动态类型的鸭子类型,不过发生在编译期)。
- 编译期多态则是通过模板实例化与函数重载解析发生于编译期。
- 首先,typename用在模板类型参数中时,和class语义完全相同。我更倾向于使用typename。
- 在模板中指代一个嵌套从属类型时,必须使用typename作为前缀,但不能使用在基类列表、以及构造函数成员初始化列表中作为基类修饰符。
模板内部typename
用以显式表明这是一个类型:
- 指代类型必须加
typename
的原因是嵌套从属名称(nested dependent names,比如T::iterator
这样是嵌套在模板类型参数T作用域的名称,非嵌套的则是普通从属名称dependent names比如T&
,不依赖于模板参数的则是非从属名称non-dependent names比如int)可能导致解析困难。 - 为了区分普通嵌套从属名称与嵌套从属类型名称,编译器在遇到一个嵌套从属名称(nested dependent type names)时,直接假定其不是一个类型名称。如果其是一个类型名称,需要在前面添加
typename
关键字。 typename
只被用来验明嵌套从属名称。单纯的模板参数T&
这种则不应该使用。- 应该用在参数、返回值类型等所有用到嵌套从属名称的地方。
- 但不能用于基类列表、以及构造函数成员初始化列表中表示基类。(估计是这种情况能够确定一定是一个类型?)。这样的不一致有点令人烦恼。
template<typename T>
class Derived : public Base<T>::Nested // do not allow typename
{
public:
explicit Derived(int x) : Base<T>::Nested(x) // do not allow typename
{
typename Base<T>::Nested temp; // must need typename
...
}
}
类模板比普通类更为泛化,但是也就会造成更多的不确定,比如上面的typename
需要显式指明一个嵌套从属名称。还有一点也很类似,就是在派生类模板中使用基类名称时需要显式指明其使用的是基类的东西(比如在不知道模板参数的情况下,解析到派生类模板的成员函数中调用了一个函数,不知道基类针对某一个模板参数是否进行了特化,这个特化中是否包含这个被调用的基类函数,所以需要在派生类中显式声明),有三个方法可以避免这个问题:
- 在基类函数调用前加上
this->
。 - 使用
using
声明使基类名称可见(推荐做法)。 - 显式使用作用域运算符指定使用基类函数。
- 这样会导致不支持多态,如果是派生类非虚函数中调用基类虚函数的话不推荐这样做。
- 当然如果是在重写的虚函数中调用基类实现,那么这就是标准做法。
- 例:
template<typename T>
class Foo
{
public:
void bar() {}
};
template<typename T>
class DerivedFoo : public Foo<T>
{
public:
using Foo<T>::bar; // solution 1
void derivedBar()
{
bar(); // invalid without using declaration, there are no arguments to 'bar' that depend on a template parameter, so a declaration of 'bar' must be available
this->bar(); // solution 2
Foo<T>::bar(); // solution 3
}
};
定义了一个函数模板或者类模板时,对于不同的模板参数会生成不同的代码。模板参数是不同的,最终生成的代码也是不同的,但我们应该最大限度地提取出其中本质上来说是二进制相同的部分以减少最终的二进制代码冗余。
就像定义类时,如果多个类拥有相同的某些操作,我们不会重复实现他们,而会将他们提取到一个公共类中,使用继承或者组合来复用。
为了最大化地减少最终生成代码臃肿,我们应该使用共性与变性分析(commonality and variability analysis):
- 在模板中代码都是共用的,但是最终会生成二进制相同的代码,主要是只有非类型模板参数变化的那一部分。
- 可以将涉及到非类型模板参数的代码提出来将非类型模板参数作为函数参数实现为模板参数无关的函数,将该部分代码提取到不含该非类型模板参数的公共基类模板中来做。在派生类中传入非类型模板参数去调用(继承实现,私有或者保护继承)。
- 优点是能够减小生成的二进制体积,一族类模板使用同一函数来实现功能。
- 缺点是生成的代码可能没有直接使用运行时的非类型模板参数作为常量表达式的版本高效(编译期常量版本能得到更好的优化,基于常量传播、常量折叠等手段),并且可能需要额外增加对象大小(可能需要在基类中存储必要信息以实现该函数)。
- 例子:
template<typename T>
class SquareMatrixBase
{
protected:
SquareMatrixBase(size_t n, T* pMem) : size(n), pData(pMem) {}
void setDataPtr(T* ptr) { pData = ptr; }
void invert(); // common function for all SquareMatrix<T, N>, different N shares one invert()
private:
size_t size;
T* pData;
};
template<typename T, size_t N>
class SquareMatrix : private SquareMatrixBase<T>
{
public:
SquareMatrix() : SqaureMatrixBase<T>(N, data) {}
private:
T data[N*N];
};
- 因类型参数而造成的代码膨胀,也有可能可以消除,前提是他们拥有完全相同的模板实例化后的二进制代码。比如在容器中保存指针:用
void*
类型(无类型指针)可以保存所有类型指针,而不是使用强类型指针然后为所有指针类型生成同样的二进制代码。 - 总结:无论怎样设计都需要权衡(tradeoff),精密的做法会让事情变得复杂,时空占用与代码复杂度代码清晰程度存在取舍,时间和空间也存在取舍。视具体情况抉择。
当我们实现智能指针这种类型时,要使其行为就像内置指针一样,就需要支持派生类指针向基类指针的转换。但是我们不可能为所有可能用到的具体类型定义转换构造函数,这时就需要在为类模板编写泛化的成员函数模板:
- 在实现过程中需要允许满足预期的合法行为,将非预期的非法行为筛选掉(让其在编译期报错)。通常来说这可以由实现中的有效表达式来约束。
- 就智能指针这个例子:我们需要泛化的接受裸指针的构造函数、拷贝构造、拷贝赋值、移动赋值以及
get
接口等。 - 泛化的构造、赋值运算符不会阻止编译器生成默认构造、默认赋值,如果要阻止编译器生成默认构造、默认赋值需要自行定义默认构造、默认赋值。
条款24中说明了,要实现在所有实参上都能够进行隐式转换,应该将其定义为非成员函数。
但在模板中有点不一样,因为在模板实参推导中,不将隐式类型转换考虑在内。
- 例子:
template<typename T>
class Rational
{
friend const Rational operator*(const Rational& lhs, const Rational& rhs)
{
return Rational(lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator());
}
// equal to:
// friend const Rational<T> operator*(const Rational<T>& lhs, const Rational<T>& rhs);
public:
Rational(const T& numerator = 0, const T& denominator = 1) : nume(numerator), denom(denominator) { }
const T numerator() const { return nume; }
const T denominator() const { return denom; }
private:
T nume;
T denom;
};
- 如果将
operator*
定义在类外部会导致Rational(1, 2) * 2
这样的代码无法编译通过。因为在模板实参推导中,不考虑隐式类型转换。这就是C++的模板部分与OO部分的众多区别之一。 - 那么要怎么做才能编译成功呢?
- 可行的方法是将这个非成员定义为友元,因为需要在类内有了声明,编译器便知道可以去匹配这个函数了。和友元的常见用法有点区别。
- 上面的友元声明中,在类模板内部可以不写模板参数,如果使用类模板名称默认就是使用类模板同样的模板参数的意思,如果要定义成员模板函数或者泛化的友元模板才必须加(同时需要在前面加上
template<typename xxx>
)。当然就类模板的同一个模板参数来说加不加都是可以的(前提是在作用域内,如果在类外实现,那么在进入作用域之前是必须加的,和类的作用域限定差不多)。
- 上面的友元声明中,在类模板内部可以不写模板参数,如果使用类模板名称默认就是使用类模板同样的模板参数的意思,如果要定义成员模板函数或者泛化的友元模板才必须加(同时需要在前面加上
- 但只有声明而没有定义会导致链接时找不到定义(即使在外部给了定义)。这时外部的定义依然没有实例化。解决方法可以是将模板函数的定义放在友元声明中,令友元声明成为一个定义。
- 这时候声明为友元,并且在外部定义,且进行显式实例化也会链接时找不到函数,具体原理未知?进行了显式实例化定义依然未实例化?
- 看来目前来说只有定义为类模板内部的友元函数并在类内实现这一个途径处理。
- 如果逻辑很长的话,可以转调一个外部函数。友元仅做一个转调以内联处理。
看一个例子,编写标准库std::advance(iter, diff)
功能,对迭代器移动给定的距离:
- 很容易想到,对不同类型的迭代器,实现可能不同,输入要求也不同。
- C++为不同的迭代器类型定义了多个空的struct结构来标识:
struct input_iterator_tag {};
struct output_iterator_tag {};
struct forward_iterator_tag : input_iterator_tag {};
struct bidirectional_iterator_tag : forward_iterator_tag {};
struct random_access_iterator_tag : bidirectional_iterator_tag {};
- 每个迭代器类中都会有一个名为
iterator_category
的类型别名,这个别名指代的类型就是上面的结构类型 - 我们可能会想到在实现中去做这样一个
if
判断,用typeid
去检测输入迭代器类型是否是对应类型。但是这样就不能兼容内置的指针类型了,因为内置类型中没有这样一个类型别名。还有if
判断会带来运行时消耗,当然其实还会有编译的时候有不支持的操作导致编不过的问题。 - 标准的做法是在定义一个
traits
类型,即标准库中的std::iterator_traits<T>
,其中定义了iterator_category
类型别名,对于标准库中迭代器而言指代其内部的iterator_category
类型,对于内置指针偏特化一个版本,将其定义为random_access_iterator_tag
(即内置指针实质上等价于随机访问迭代器)。 - 接下来在实现时使用traits类,根据模板参数中的迭代器类型获取到其tag结构类型,为不同类别的迭代器做一个重载,将实际工作转发到一个添加了tag参数的重载函数中做,即可实现编译期的分支选择。
- 标准库
std::advance
实现模拟:
// simulation of std::iterator_traits
template<typename IterT>
struct my_iterator_traits
{
using iterator_category = typename IterT::iterator_category;
};
template<typename IterT>
struct my_iterator_traits<IterT*>
{
using iterator_category = std::random_access_iterator_tag;
};
template<typename IterT, typename DistT>
void doAdvance(IterT& iter, DistT d, std::random_access_iterator_tag)
{
iter += d;
}
template<typename IterT, typename DistT>
void doAdvance(IterT& iter, DistT d, std::bidirectional_iterator_tag)
{
if (d >= 0)
{
while (d--)
++iter;
}
else
{
while (d++)
--iter;
}
}
template<typename IterT, typename DistT>
void doAdvance(IterT& iter, DistT d, std::input_iterator_tag)
{
if (d < 0)
{
throw std::out_of_range("Negative distance");
}
}
// implementation of advance
template<typename IterT, typename DistT>
void advance(IterT& iter, DistT d)
{
doAdvance(iter, d, typename my_iterator_traits<IterT>::iterator_category());
}
如何设计并实现一个traits类:
- 确认若干希望将来可以取得的类型相关信息。(例如对于迭代器可以取得其分类)
- 为该信息选择一个名称。(这个例子中是
iterator_category
,更典型的是value_type
) - 提供一个模板和一组特化版本,内含希望支持的类型相关信息。
如何使用traits类:
- 建立一组重载函数或者函数模板,彼此差异只在traits参数。不同traits参数可以根据其提供的信息来编写不同具体实现。
- 建立一个控制函数或者函数模板,使用traits类在编译期获得类型相关信息,用其来调用上面的重载函数或者重载函数模板。以实现根据类型在编译期选择特定实现的目的。
总结:
- traits类的作用:在编译期获得类型相关信息。通过模板和模板特化实现。
- 通过整合重载技术,traits类可以在编译期对类型进行
if-else
测试。 - 标准库中的traits类定义在
<type_traits>
中,很多常用的traits类都在其中,比如:remove_reference add_const add_pointer
等,都是通过类似手法来做的。 - traits类是模板编程中的重要一环,可以通过这一条款认识其意义。
模板元编程(Template Metaprogramming,TMP)是编写基于模板的执行于编译期的C++程序,也就是通过编译这个过程来执行。一旦TMP程序结束执行,其执行的输出结果就是从模板实例化出的若干C++源码,一如往常会被编译。
- TMP是图灵完全(Turing complete)的,也就是任何计算都能够在编译期做到。
- 就像前面使用函数模板特化与重载和traits类来实现if-else一样。TMP中的各种程序结构和正常的C++中可能存在一定的区别。
- TMP是嵌入在C++中的一门子语言,准确地说,一门函数式语言(functional language)。
- 在TMP也可以进行循环,是通过递归模板实例化(recursive template instantiation)来做到的。
- 起手式,编译期计算阶乘:
template<unsigned n>
struct Factorial
{
enum { value = n * Factorial<n-1>::value };
};
template<>
struct Factorial<0>
{
enum { value = 1 };
};
- 可以看到
enum
常量在编译期的妙用,枚举值不占用对象空间,当写出Factorial<10>::value
这种表达式时,它已经在编译期就算好了。(这叫enum hack,在条款2中介绍过。) - 模板递归同普通递归一样,需要特别注意递归终止条件,TMP没有调试器,而模板特别是TMP相关的报错众所周知也是非常晦涩,所以编写起来更多地需要靠经验。
- 更多内容这里也没有,需要另外的资料来学习(如《C++ Templates》)。
标准库<new>
中定义了new_handler
类型,是一个函数类型,签名是void()
,含义是operator new
无法分配够内存时调用的函数。
- 可以通过
new_handler set_new_handler(new_handler) noexcept
这个函数设置,返回旧的new_handler
。 - 一个设计良好的
new_handler
必须做的事情(拥有很大的弹性,可以自行选择怎么处理):- 让更多内存可被使用(比如释放某些不必要的内存)。
- 安装另一个
new_handler
。(这个做法的变种之一是让new_handler
修改自己的行为,为了达成这种目的,做法之一是修改静态或者全局数据。) - 卸载
new_handler
,也就是set_new_handler(nullptr)
,这样在内存不足时会执行默认行为抛出bad_alloc
异常。 - 抛出
bad_alloc
(或派生自其的)异常。 - 不返回,通常调用
abort
或者exit
结束程序运行。
- C++支持类定制自己的
operator new
,但不支持其定制自己的new_handler
。但我们可以自己实现这一点:- 为类定义一个静态成员函数
set_new_handler
,类似于全局的,作用是为类的operator new
设置专门的new_handler
。 - 当然上述的
set_new_handler
操作的数据应该是一个类的new_handler
类型的静态数据成员。 - 在
operator new
中做以下事情:- 将该类的静态
new_handler
成员调用全局set_new_handler
设置给全局,并保存全局的new_handler
。 - 调用全局
operator new
来分配内存。 - 将全局的
new_handler
恢复回来。 - 这个步骤可以通过自定义一个资源管理类来做,以保证抛出异常时能够正确恢复。
- 将该类的静态
- 典型实现:
class Foo { public: static new_handler set_new_handler(new_handler nh) noexcept { new_handler oldHandler = currentNewHandler; currentNewHandler = nh; return oldHandler } void* operator new(size_t size) { currentNewHandler = set_new_handler(currentNewHandler); void* pMem = ::operator new(size); set_new_handler(currentNewHandler); return pMem; } private: static new_handler currentNewHandler; }; new_handler Foo::currentNewHandler = nullptr;
- 为类定义一个静态成员函数
- 为了避免调用全局
operator new
过程中抛出bad_alloc
异常导致new_handler
不能恢复的情况,更好的方式是使用RAII:- 典型实现:
// RAII class that manage new_handler class NewHandlerHolder { public: explicit NewHandlerHolder(new_handler nh) : handler(nh) {} ~NewHandlerHolder() { set_new_handler(handler); } NewHandlerHolder(const NewHandlerHolder&) = delete; // prevent copying NewHandlerHolder& operator=(const NewHandlerHolder&) = delete; private: new_handler handler; }; class Bar { public: static new_handler set_new_handler(new_handler nh) noexcept { new_handler oldHandler = currentNewHandler; currentNewHandler = nh; return oldHandler; } static void* operator new(size_t size) { NewHandlerHolder holder(set_new_handler(currentNewHandler)); return ::operator new(size); } private: static new_handler currentNewHandler; }; new_handler Bar::currentNewHandler = nullptr;
- 任何类都可以这样做。在每个类做一次依然会带来代码的重复。
- 最终级的做法是将这些功能定制为一个公共基类模板,只要派生就可以得到这个功能:
- 典型实现:
// generic RAII
template<typename T>
class NewHandlerSupport
{
public:
static new_handler set_new_handler(new_handler nh) noexcept
{
new_handler oldHandler = currentNewHandler;
currentNewHandler = nh;
return oldHandler;
}
static void* operator new(size_t size)
{
NewHandlerHolder holder(set_new_handler(currentNewHandler));
return ::operator new(size);
}
private:
static new_handler currentNewHandler;
};
template<typename T>
new_handler NewHandlerSupport<T>::currentNewHandler = nullptr;
class Buz : public NewHandlerSupport<Buz>
{
};
- 为了不同的类型拥有不同的静态
currentNewHandler
成员,需要将派生类加到基类的模板类型参数中。即使这个类型参数在基类中并没有被使用。这在模板编程中算是一个很常用的技术手段(初看起来确实奇怪)。 - 这种手段主要用来表示:我要针对我自己继承某个模板,这个基类与继承该模板的其他派生类的基类是全然不同的类型。
- 另外存在
nothrow
版本的operator new
,主要用来兼容比较老的代码,行为是分配失败不抛出异常,而是返回空指针。但是众所周知new
运算符包含两个阶段,分配内存和构造,这并不保证在构造中就不抛出异常,所以nothrow
版本其实没有多少使用场景。
常见理由:
- 用来检测运用上的错误:检测是否有内存没有释放、多次delete、或者发生了overrun或者underrun(写入到分配区块之后或之前)。在替换的
operator new/delete
中管理这些事情。 - 为了强化效能:现实实现中的
operator new/delete
采用中庸之道,既要适合小内存分配,也要满足大内存分配。所以不可能根据程序的内存分配状况表现出最佳的性能,而是对所有情况都表现出适度好的性能。如果你对你的程序的动态内存分配状况有深刻了解,可以定制operator new/delete
替换标准库版本,以获得更佳的性能和内存占用。这属于比较高级的用法了。 - 为了收集使用上的统计数据:在深度定制动态内存分配之前,必须先收集软件上的动态内存是怎么使用的的信息。区块大小分布如何?寿命分布如何?分配释放次序倾向于FIFO还是LIFO?最大动态内存分配量是多少?等等各种信息。这些信息就可以通过定制
operator new/delete
来实现。 - 为了检测运行时错误。
- 为了收集动态内存使用的统计信息。
- 为了增加分配和释放的速度。
- 为了降低内存管理器带来的额外空间开销。
- 为了弥补分配器中的非最佳对齐。
- 为了将相关对象组织得更加集中。降低换页频率,提高缓存命中。
- 为了获得非传统的行为,比如将释放掉的内存置为0以提高数据安全性。
- 总而言之,自定义
operator new/delete
属于比较高级的内容,写一个好用的分配器是不简单的,通常的程序可能不会这样做,大型程序中几乎都需要这样做。
无论什么目的,无论怎样实现,实现new和delete时有一些必须遵守的原则:
首先是operator new
:
operator new
应该实现的正确行为:- 如果有能力提供该内存,就返回一个指针指向那块内存。
- 如果没有能力,就抛出
std::bad_alloc
异常。 - 还有条款49中提到的:如果
new_handler
为空,才抛出异常。如果不为空则在每次失败后调用new-handler函数。 - C++规定,即使用户要求0字节,也返回一个合法指针。实现时可以简单处理为在分配0字节时分配1个字节。
- 典型实现示例:
void* operator new(size_t size)
{
using namespace std;
if (size == 0)
{
size == 1
}
while (true)
{
if (/*allocation is successful*/)
{
return /*pointer to memory*/;
}
new_handler globalNewHandler = get_new_handler();
if (globalNewHandler)
{
(*globalNewHandler)();
}
else
{
throw std::bad_alloc();
}
}
}
- 需要注意的是这个无限循环,如果设置了
new_handler
但是其中既没有抛出异常、也没有设置其他new_handler
、也没有直接结束程序、也没有通过释放一部分内存来让下一次分配成功,那么就会一直死循环,所以new_handler
必须做到条款49所述的事情。
为自定义类型定制的operator new
:
- 关于
operator new
还需要注意的一点是,其可以被派生类继承,也就是说在基类中重载了operator new
,动态分配派生类对象时也会使用基类的operator new
。 - 但通常来说基类的
operator new
可能是针对基类大小优化的,派生类大小改变了。因为可能为派生类分配内存,所以不能假定一定是为基类分配内存: - 这时候的典型实现是在基类
operator new
中做一个判断,如果要分配的内存大小等于基类大小,照常做,不等则调用全局的operator new
:
static void* operator new(size_t size)
{
if (size != sizeof(Base)) // include size == 0
{
return ::operator new(size);
}
else
{
// process of base class allocation
}
}
- 但是对于
operator new[]
这就行不通,同理我们也不能通过size / sizeof(Base)
这种方式获取要分配的动态数组大小。只能所有大小同等处理。
关于operator delete
:
- 需要记住的唯一一件事就是C++保证删除空指针永远是安全的。
- 典型实现:
void operator delete(void* pMem) noexcept
{
if (pMem == nullptr)
return;
// process of delete
}
- 成员版本与成员版本的
operator new
同理:
static void operator delete(void* pMem, size_t size) noexcept
{
if (pMem == nullptr)
return;
if (size != sizeof(Base))
{
::operator delete(pMem);
return;
}
// process of base class deallocation
}
每个人看到这里可能都会奇怪为什么会有placement delete这种东西,因为placement delete(狭义版本的)确实是不可用的,我们使用显式的析构调用来替代placement delete的地位。但读完之后你会发现placement new的定义被扩充了(所有加了多余参数的版本都可以叫做placement new,加了对应参数的operator delete
也就是其对应的placement delete),所以对应的placement delete可以是有用的,并且只被用在非常有限的场景。
所以标题中并非我们通常意义上的狭义的placement new(因为狭义的placement new不分配内存,何来内存泄漏一说。),而是加了其他参数的广义版本的placement new的意思(也就是说同样要分配内存)。分清这一点就能理解了,细节不赘述,赘述了也大概率很久都用不到,直接看总结:
总结:
- 当编写一个palcement operator new时,也需要写出对应的placement operator delete。如果没有这样做,则会在分配内存成功,但构造函数抛异常时,发生内存泄漏(编译器会使用placement new对应的placement delete来释放,如果没有就会直接不管从而造成内存泄漏)。
- 当声明了placement new和placement delete时,请不要无意识地遮盖他们的正常版本。通常是说在类中定义的成员版本的情况:
- 方法1:为类定义所有需要的
operator new
和operator delete
,其余的非关注的可以直接调用全局的实现。 - 方法2:定义一个基类,实现所有重载的
operator new
和operator delete
(直接调用全局版本),在派生类中使用using
声明使基类名称operator new
和operator delete
可见,然后定义自己需要定制的版本。
- 方法1:为类定义所有需要的
- 严肃对待编译器提供的警告信息。
- 不要过度依赖编译器的报警能力,不同编译器对待同一件事情的态度可能不同。
已经成为历史,现已进入标准库,略。
如标题,去看https://Boost.org。