自己在看这本书的时候,回去翻看目录的时候,有些规则会被遗忘,因此做个简单的小总结供自己和其他人参考,没读过的还是要先去读一遍的
一.让自己习惯C++
1.视C++为一个语言联邦
C++是一种包含许多特性的语言,因而不要把它视为一个单一语言。理解C++至少需要学习一下4个部分:
①C语言。C++仍以C为基础
②objected-oriented C++。面向对象编程,类、封装、继承、多态
③template C++。C++泛型编程、模板元编程的基础
④STL。容器、迭代器、算法
2.尽量使用const等替换#define
这条个人觉得没必要,有许多功能除了宏是很难实现的,像许多优秀的开源项目,例如tensorflow中,就大量使用的宏。
3.尽可能使用const
①const可以作用于:变量、指针、函数参数类型、类中的常函数。const可以防止变量被意外的修改,有助于编译器检测这些意外改变。
②当non-const和const实现相同逻辑时,non-const对象可以调用const成员函数,这样可以缩减代码量。另外注意const对象不能调用non-const成员函数,编译报错:discards qualifiers。
4.确定对象使用前被初始化
这里提到一个重要的基本概念:
在构造函数的初始化列表中的才算是初始化,而构造函数的内容是在初始化列表之后执行的,已经不算是初始化操作。这里就存在效率问题,假设你的类成员是个其他类的对象,比如std::string name,你在初始化列表中进行初始化,调用的是string的拷贝构造函数,而在构造函数中进行赋值的话,调用的是:默认构造函数+赋值函数,调用默认构造的原因是,调用构造函数之前会先对成员进行初始化(这也就是为什么在构造函数中进行的操作不能称之为初始化操作),而对于大多数类,默认构造函数+赋值函数的效率是小于只调用拷贝构造函数的。
①因此最好是在初始化列表中进行初始化操作。
②内置类型要手动初始化,不同的平台不能保证对内置类型进行初始化
二.构造/析构/赋值运算
5.C++默认编写并调用的函数
编译器默认实现的函数:默认构造、析构、拷贝构造、赋值函数。这里注意深拷贝和浅拷贝问题。
6.不想使用默认生成的函数,可以明确拒绝
默认的构造可以被其他构造替换,拷贝构造和赋值函数如果不想被外面调用可以将其声明为private。
7.多态基类声明virtual析构函数
当一个父类指针指向子类对象时(即多态的用法),在释放对象时,如果父类的析构函数不是virtual的,那么编译器会将这个指针视为父类类型的,只会释放掉这个对象的一部分空间。如果声明为virtual的,那么在释放的时候,编译器就知道这是一个子类类型,会将对象都释放掉,即防止内存泄漏问题。
8.别让异常逃离析构函数
绝不要让析构函数抛出异常,应该让用户自己处理可能发生异常的操作
9.不要在构造和析构函数调用virtual函数
由于父类的构造函数发生在子类之前,而此时子类的成员变量等并未初始化,因此在父类的构造函数中调用virtual函数,绝对不会调用子类的方法,即使现在你在创建一个子类对象。换句话说,在构造函数中调用的virtual函数,都会下降到父类类型,即都不是virtual函数。同样的道理,子类析构函数调用在父类之前,因此在父类析构函数调用virtual函数时,子类都不存在了,你让编译器怎么调用。因此一定不要再构造和析构中调用virtual函数。
10.令operator=返回reference to *this 并 11处理自我赋值
第一条一般都这么写,没毛病,返回引用比临时变量要少几次构造析构,效率高;第二条是因为,赋值的时候要先释放自己的资源然后赋予新的资源(资源假设为一个指针,这样便于理解),如果你自己给自己赋值,按照这个先释放再赋值的逻辑,自己直接就没了。所以发现是自己赋值自己的时候(this = &object)直接返回*this即可。
12复制对象的时候勿忘其每个部分
没啥可解释的
三.资源管理
13.以对象管理资源
①在构造中获得资源并在异构函数中释放资源
②两个常用的自动管理资源的类是shared_ptr和auto_ptr,其中auto_ptr的复制动作,会导致复制对象变为null,容易造成意外的错误,一般推荐使用shared_ptr,其使用引用计数的原理实现对象共享的目的,并且在计数为0时自动释放对象
14.在资源管理类中小心copy行为
15.在资源管理类中提供对原始资源的访问
16.成对的使用new和delete
使用new申请内存,必须使用delete释放内存,使用new [] 申请内存,必须使用delete []释放内存
17以单独的语句将newed对象置入shared_ptr
意思为不要写下类似Function(shared_ptr<Class>(new Class), x()),其中Function和x为函数,Class是一个类。原因在于shared_ptr的构造分为两步,第一步是new Class创建一个对象,第二部是执行构造函数,但是像上面那样写可能导致x()在两步之间运行(与编译器如何编译代码有关了),如果这个时候x()发生了异常,那么就会导致内存泄漏。因此初始化shared_ptr的时候,最好不要掺杂其他东西。
四.设计与生命
18.让接口容易被正确使用,不易被误用
文章举了个Day,Month,Year的例子进行简单的说明。在实际中就是要考虑客户如果使用你写的接口,怎么样能降低错误使用率,就像给别人提供API一样。要做到这一点还是很不容易的。
19.设计class
这个真的很难一开始就设计的很好,一般都是根据需求慢慢调整的,但是有一些公共特征还是考虑,比如构造和析构,需要什么操作等。
20.以pass-by-reference-const替换pass-by-value
主要考虑两点:
①效率问题,pass-by-value会导致很多临时对象的产生和销毁,就会多调用几次构造和析构,因此效率更低
②对象切割问题,pass-by-value的方式将一个子类对象传入一个参数类型父类的函数,那么拷贝的对象将被切割成只有父类对象被保留。引用可以解决这个问题,因为引用本质上也是指针,就和多态是一样的
另外,内置类型,STL迭代器和函数对象一般采用pass-by-value,因为其复制代价很小
21.不要返回临时对象的引用
①不要返回一个临时对象的引用
②不要返回在堆上分配的对象的引用,因为这违背了new和delete成对出现的原则,这样的方式是很不合理的,稍加不注意就会导致内存泄漏问题。
③也不要返回一个static对象的引用,因为static可能同时被很多地方需要,这样的话共享就存在问题。
所以对于这种问题,最好的解决方法就是不返回引用就OK了。
22.将成员变量声明为private
常规操作
23.宁以non-member、non-friend函数替换member函数
文中提到了封装性强弱的概念:一个类中的成员或者成员函数,被越少的代码访问,那么封装性越高。所以增加一个成员函数会增加访问成员变量的代码,因此降低了封装性。所以这个条款才如此建议。仁者见仁智者见智,这个条款目前楼主没有在实际项目中应用过,大部分还是以成员函数的方式实现,因此个人感觉这个条款并没有感觉有什么卵用!
24.若所有参数皆需类型转换,那么请采用non-member函数
文中举了一个有理数Rational运算的例子,在类中加入一个operator*(const Rational& other)的函数,可以实现类似 rational * 2的操作,其中2是个int,但是因为rational有一个以int为参数的构造,因此编译器帮你执行了隐式类型转换。但是反过来写2 * rational的时候,编译就报错了。因为2是个int,并没有operator*这个函数。但是为什么这样写就没有执行隐式类型转换呢?这又引出一个问题:隐士类型转换的合格条件是什么?答案是:必须是参数列中的参数才是隐士类型转换的有效参与者,类的执行者也就是'.'前面的那个对象(this指向的对象,比如说rational.func()中的rational是类执行者,相当于他是函数的调用人,地位较高,不能参与隐式类型转换),这就解释了为什么2放在前面是不行的。解决此种问题的方法是提供一个non-mem的operator*(Rational a, Rational b)即可。
25.写一个不抛出异常的swap函数
一般写swap最普通的方法就是利用中间变量,temp = a;a = b;b = temp,这种方法对于内置类型没任何问题,内置类型上的赋值绝对不会抛出异常,并且效率很高。但是如果a,b不是内置类型,就会调用类的copy构造函数和assign函数,并且必须是深拷贝。这样如果类的成员较多就会造成交换的效率很低,特别是针对pimpl实现方法,即成员中包含指针(即资源)时。更好的做法就是直接交换指针就可以了,相当于交换了两个int(指针都是4字节的),这就比拷贝这个指针指向的资源要快得多。
如何实现呢?只要将swap都转换成内置类型的swap就可以了,做法就是在类中提供一个public的swap(T& b)函数(T为一个类),将每个成员进行交换(如果成员中包含其他非内置对象,调用这个对象的swap函数即可)。然后提供一个non-member的swap(T& a, T& b)重载函数,在函数内部调用类中的a.swap(b),就可以像如下方式实现交换两个对象的操作:swap(a, b)。
注意:
①在类内的swap交换内置类型时要调用std命名空间内的swap函数,必须使用using std::swap,否则就变成递归函数了
②另外文中说在std命名空间内不能加入新东西,比如重载swap函数,但是经博主测试是可以在std内重载swap函数的(g++版本为5.4.0)。
五.实现
26.尽可能延后变量定义得时间
①因为变量(对类而言)的定义,需要承担一次构造函数的时间,在函数结束后还可能承担一次析构函数的时间,假如该变量未被使用,那么构造函数和析构函数的时间就白白浪费了,尤其是在可能发生异常的函数中,假如你过早的定义变量,然后在你使用这个变量之前抛出了异常,那么这个变量的构造函数就没有意义而且降低效率。所以应该尽可能延后变量定义得时间,只有真正使用这个变量的时候才定义它
②条款4讲过,copy construction的效率 > default construction +assign function,所以最好的做法是直接调用copy construction函数对变量直接进行初始化,而不是先定义,再赋值
③对于有循环的情况,假设一个n次的循环,如图所示:
那么方法A的代价:1次构造+1次析构+n次赋值
方法B的代价:n次构造+n次析构
如果n较大,那么应该选择方法A,如果n较小,可以选择方法B。
27.尽量避免转型
①最好使用C++4个新式的类型转换函数,因为这很容易辨识,代码可读性提高
②尽量避免使用dynamic_cast,因为这种转换效率很低,一般用虚函数的方式来避免转型
28.避免返回一个指针、引用或者迭代器指向类内的成员
原因是如果返回了成员的引用或者指针,就可以通过这个引用或者指针修改雷内的private成员,这样是不合理的(这样的话成员就相当于public的了),这一点可以通过给函数的返回类型加const修饰符来防止内部成员变量被修改。但是还有一种情况是,如果获得的类内的一个成员的引用或指针,但是在使用之前,对象被释放了,那么这个引用或指针就变成了野指针了,必然会导致core dump错误。所以应该避免返回类内成员的指针或引用。
29.异常安全函数
发生异常时的处理主要分一下几类:资源不泄漏、数据不丢失、不抛出异常。反正就是考虑程序的各种可能的情况,如果异常了要尽可能保证你的程序某些功能或数据不丢失。这个也没啥可说的。
30.inline 函数
inline只是一种申请,编译器会根据具体情况来决定一个函数是否可以是inline得,比如递归函数、virtual函数、代码较多的函数,即使你声明了inline关键字,编译器也不会将此类函数视为inline的函数。
31.编译依存关系降低至最低
理解此条款关键是要理解C++的编译方式,具体可以参考:https://www.cnblogs.com/jerry19880126/p/3551836.html文中说的很详细。理解了文中的意思,再回头看这个条款就清晰多了。其关键点如下:
①关于前置声明的一个限制是:编译器必须在编译时确定类的大小,即分配多少内存。因此如果你前置声明一个类class TEST,然后在后面试图创建一个TEST的对象TEST test,那么这个代码是不会通过编译的,因为编译器不确定要给test分配多少内存。那怎么规避这个问题呢?答案就是指针,典型的pimpl方式,因为指针的字节数是固定的(相对于平台,一般就是4或者8字节)。例如下面的代码就是可以通过编译的。
#include <iostream> using namespace std; class TEST; class AA{ public: TEST* aa; //TEST& b; //引用不可以,因为引用必须在初始化列表中进行初始化 void T(TEST& tt) { } void wdt(TEST* tt) { } void PP() { cout << "AA::PP()" << endl; } AA() {} ~AA() {} }; int main() { AA aa; aa.PP(); return 0; }
可见,前置声明一个TEST类,并没有对应的类的实现,在另外一个类A中声明一个TEST*的成员,包括在函数中使用TEST* 或者 TEST&类型的参数都没问题。但是在用这两个函数的时候还是要有TEST的定义和实现,那么这个时候怎么办,就是再创建一个TEST.h和TEST.cc,然后在A.cc中#Include"TEST.H"(假如class A也单独创建一个A.cc和A.h),这样当TEST中的内容有所改变的时候,只有TEST.cc和A.cc被重新编译。所有使用class A和class TEST的地方都不会被重新编译。假如使用这两个类的地方特别多,那么这样就可以减少很多文件的编译了。
②上面利用指针是一种实现方法,另一种就是Interface class,即类中全部都是pure virtual函数,这样的类在使用的时候只能是以指针的形式出现,这样就同样达到了减少编译依赖的效果。
③当然这两种方式都存在一定的代价:指针方式的实现要多分配指针大小的内存,每次访问都是间接访问。接口形式的实现方式要承担虚函数表的代价以及运行时的查找表的代价。但是一般这两种实现对资源和效率的影响通常不是最关键的,因此可以放心的使用,类似tensorflow源码中就大量使用这种方式降低编译依赖。
六.继承与面向对象设计
32.确保public继承是is-a关系
33.名称遮掩问题
子类会遮掩父类同名的函数,可以使用类名作用域决定调用父类还是子类的函数。
34.接口继承与实现继承
理解接口继承和实现继承的区别,纯虚函数、非纯虚函数和普通函数在这两方面的区别:纯虚函数只指定接口继承、非纯虚函数指定接口继承并存在默认的实现继承、普通函数指定接口继承及强制实现继承。
35.考虑virtual函数以外的选择
......
36.不要重新定义继承来的non-virtual函数
non-virtual在实现上是静态绑定的,调用父类还是子类的函数完全取决于指针或者对象的类型。在子类重定义non-virtual时,父类的相同的函数是不会被覆盖的。这条与33条类似。
37.不要重新定义重写函数(virtual)的默认参数
默认参数都是静态绑定的,即你的指针是什么类型,默认参数就是什么类型。而virtual函数是动态绑定的,在运行期才决定调用哪个函数。所以如果你在父类class Father有一个virtual函数并带有默认参数,例如void p(int default = 100),在子类重写这个函数,然后换了新的默认参数为default = 10,在你以多态的方式调用p的时候:Father* f = new Son; f->p();这种情况p的默认参数为100而非10。因为f指针的静态类型为Father,而动态类型为Son。所以如果你的函数必须包含默认参数,不要这样写,解决方法是将带有默认参数的函数改为non-virtual函数,内部再调用一个virtual函数。因为non-virtual函数是从来不应该被重写的(条款36,覆盖问题)
38.类与类之间的关系:复合(has a的关系)
39.私有继承
子类继承父类的方式决定了在子类中父类函数的属性,一般规则就是所有属性都按照继承方式对其。比如采用protected继承方式,那么父类中的public成员在子类都升级为protected,其他保持不变。如果采用private继承方式,父类中的所有成员全部变为private,特殊之处之一是父类中原本就是private的成员不可继承,即在子类中也无法使用父类的private成员。
40.多重继承
七.模板与泛型编程
41.隐式接口和编译器多态
class的继承和template都支持接口和多态。只不过class实现的接口是显示的,就是说一定能直接找到这个接口的实现代码。而template实现的接口,只能模糊的知道接口的特征,一般间接能找到实现的代码。用继承实现的多态属于运行期多态、模板实现的多态则是编译期多态。
42.了解typename
①在声明template参数时,class和typename可互换。
②typename的第二个用处是告诉编译期某一个嵌套从属类型是类型,最典型的就是STL中容器的迭代器类型,例如:T::iterator(T是个容器的类型,例如:vector<int>),这个时候就要在T::iterator前面加一个typename,告诉编译器这是一个类型,否则编译器不能确定这是什么,因为有可能iterator是个静态变量或者某一namespace下的变量。
③类的继承列表和初始化列表中的类型不需要typename指定类型,因为继承的一定是个类,而初始化列表一定是调用父类的构造或者初始化某个成员。
43.调用基类模板成员
当一个类的基类包含模板参数时,需要通过this->的方式调用基类内的函数,例如 class F: public S<C>,在F中的成员函数中调用S中的成员函数this->test(),而直接写test()无法通过编译,原因是因为C是个模板没有办法确定类S的具体长相,或者说无法确定S中一定有test函数,即使你写的所有C都包含test函数,但是在编译器看来它是不确定这个问题的,因此无法通过编译。
解决办法是:①使用this->test,这样做告诉编译器假设这个test已经被继承了。②使用using声明式:using S<C>::test告诉编译期这个test位于S内。相当于必须手动通知编译器这个函数是存在的。
44.将与template参数无关的代码抽离到模板外
原因是模板会根据具体类型具象化不同的代码,如果将与模板无关的代码也放入模板函数或者类中,那么就会生成重复的代码,就会导致代码膨胀的问题,函数模板中与参数无关的代码可以包装成单独的函数。类模板中与参数无关的模板可以放到父类中。
45.运用成员函数模板接受所有兼容类型
文中以shared_ptr为例讲解了使用成员函数模板实现智能指针不同类型间的转换,以及如何避免任意类型之间的相互转换,这种函数可称为泛化拷贝构造函数。另外泛化拷贝构造不同于默认拷贝构造函数。
46.需要类型转换时请为模板定义非成员函数
条款24中Rational函数仅以int为例,说明了隐士类型转换的合格参与者的条件,并提出了非成员函数的解决方法。现在将其扩展为template形式:
template <typename T> const Rational<T> operator*(const Rational<T>& lhs, const Rational<T>& rhs) {……}
发现在调用的时候无法通过编译,即以下代码无法通过编译:
Rational<int> oneHalf(1, 2); Rational<int> ret = oneHalf*2;
相比于条款24,换成模板之后为什么就无法通过编译了呢?原因在于模板的运行需要进行模板推算,即operator*函数的两个参数类型的T要根据传入的参数类型进行确认,第一个参数因为是oneHalf,其本身就是Rational<int>类型,因此第一个参数的类型中的T很容易进行推理,但是第二个传入的参数是int,如何根据这个int参数推导出第二个参数的类型T呢?显然编译器无法进行推理,条款24能推理的原因是进行了隐士类型转换,或者说Rational的构造函数中有一个以int为参数的构造函数,但是template在进行参数推到的过程中从不将隐士类型转换函数考虑在内,这也是合理的因为你没法根据参数类型推导出模板参数,这个Ratinal的例子貌似看起来可以,因为构造函数的参数类型是const T& 但是假如其构造参数类型是个固定类型,比如说float,那么难道模板参数能永远是float么。因此编译器不考虑隐士类型转换也是有道理的。
那么这个问题怎么解决呢,该如何让这个模板函数的参数能进行隐式类型转换,答案就是:先具象化这个函数,相当于先确定T,然后就可以进行隐士类型转换了,做法是在类中声明一个非成员函数,这该如何做到呢,答案就是友元函数,在类中定义的友元函数都被视为非成员函数,对于本例该像如下方式声明:
friend const Rational operator*(const Rational& lhs, const Rational& rhs) { return (lhs.numrator()*rhs.numrator()/lhs.denominator()*rhs.denominator()); }
由于此函数是在模板类的内部,因此当oneHalf对象生成之后,T就被确定为int,那么operator*函数的参数和返回值中的T也均是确定的了。
另外,由于此函数的功能过于简单,因此可直接将其实现放入类中(inline的),假如类的功能很复杂,那么一般都采用调用类外的某一个功能函数,这时候代码这样实现:
friend const Rational<T> operator*(const Rational<T>& lhs, const Rational<T>& rhs) { return DoMultiply<T>(lhs, rhs); }
这样写实际和上面的写法是一样的,当T被确定为int时,会生成一份Rational<int>的类,具象化出里面的函数,这样就能确定调用的是T为int的operator*函数,然后在另一个模板函数内实现其操作,本例来说就是DoMultiply函数。
47.traits编程技巧
traits是用来获取参数类型信息,因为有时候需要根据参数类型信息做不同的处理,下面这篇博客中列举了两个简单的例子,https://blog.csdn.net/my_business/article/details/7891687(其实可以使用typeid进行简单的实现,但是这种做法效率低,因为typeid需要配个if使用,if是在运行期才决定的,而traits可以在编译器就进行类型的判别,效率更高),文中以STL迭代器相关函数中的advance为例,advance函数原型为:
template<typename IterT, typename DistT> void advance(IterT& iter, DistT& d) { if(iter 为 random_access_iterator_tag) iter += d; …… }
其中要根据iter为不同类型(一共5种)执行不同操作,这时候就必须能提供一种判别类型的方法,前面说了利用typeid的方法,要使用if进行判断,if是在运行期实现的,效率低,而且typeid有可能在编译器就确定了类型,却要等到运行期if才能决定运行哪个分支,为什么不在编译器就定好呢。traits技术可以在编译器就做到类型的判别,标准做法是声明一个traits class然后进行各种不同版本的特化,本例中的traits class为(说是class,其实一般都声明为结构体):
template<typename IterT> struct iterator_traits;
然后针对不同类型特化不同的struct,但是每个iterator类必须有一个相同的typedef 名为 iterator_category,iterator_traits只是将其再typedef成iterator_category
template <typename Iter> struct iterator_traits { typedef typename Iter::iterator_category iterator_category; }
也就是说iterator_traits中存储了一个类型为iterator_category,但是根据不同的模板参数其有不同的值,这个例子,其实就是将类型统一名称,当然traits的功能远非如此,你可以根据需求添加相应功能。现在通过调用:iterator_traits<IterT>::iterator_category就拿到了迭代器的类型,那么怎么在编译期做到判断if呢,方法就是重载函数,重载函数是在编译期就确定了调用哪个的,原理就是和所有名字相同的重载函数比较,直到找到参数类型一致的,这就是编译期实现了if判断,可以利用这个特点在编译期就决定advance函数要运行什么功能。例如random的DoAdvance方法如下:
template <typename IterT, typename DistT> void DoAdvance(IterT& iter, DistT& d, std::random_access_iterator_tag) { iter += d; }
在调用的时候,将advance函数改为:
template<typename IterT, typename DistT> void advance(IterT& iter, DistT& d) { DoAdvance(iter, d, iterator_traits<IterT>::iterator_category()); }
这样根据参数的不同就在编译确定好了,你到底调用的是哪个DoAdvance函数。这里有个问题就是如果不用iterator_traits,DoAdvance函数的第三个参数直接换为 IterT::iterator_category也是可以,那么这么麻烦的用traits干嘛?其实对本例来说还有一种IterT是指针迭代器的情况,指针迭代器都属于random分类,这种情况,不用trait就很难实现了,如果用traits的话,写一个偏特化就OK了,例如:
template<typename IterT> struct iterator_traits<Iter*>{ typedef random_access_iterator_tag iterator_category; }
有点类似前面提到的博客中,如何判定一个类型是指针类型。
另外抛开本例,在traits中也可以附加其他额外信息:就类似如何判定T是否是指针类型,就在traits中放入了一个bool;还有一种需求是声明一个和参数类型相同的临时变量,这时候不用traits就很难实现了,根据你的需求你可以任意添加附加信息实现更复杂的功能。
48.模板元编程
采用模板编程的好处是:①可将工作由运行期移动到编译器完成,造成更高的执行效率(占用内存小,运行速度快)和更早的侦测错误②编码更加简洁;坏处:①编译时间长②代码不易理解
八.定制new和delete(条款49~52)
这章讲了new和delete的一些高级用法:set_new_handler、operator new/delete的重载及应该遵循的规则、placement new。一般情况下较少会重载new,所以倒不如了解new/delete的基础知识更好,参考:https://www.cnblogs.com/deepllz/p/9927807.html
九.杂项讨论(条款53~55)