Effective C++ 条款总结
自己在看这本书的时候,回去翻看目录的时候,有些规则会被遗忘,因此做个简单的小总结供自己和其他人参考,没读过的还是要先去读一遍的
一、让自己习惯C++
1 视C++为一个语言联邦
C++是一种包含许多特性的语言,因而不要把它视为一个单一语言。理解C++至少需要学习一下4个部分:
- C语言。C++仍以C为基础
- objected-oriented C++。面向对象编程,类、封装、继承、多态
- template C++。C++泛型编程、模板元编程的基础
- STL。容器、迭代器、算法
2 尽量使用const等替换#define
- 对于单纯常量,最好以const或者enums替换#define
- 对于形似函数的宏(macros),最好改用inline函数替换
3 尽可能使用const
- const可以作用于:变量、指针、函数参数类型、类中的常函数。const可以防止变量被意外的修改,有助于编译器检测这些意外改变。
- 当non-const和const实现相同逻辑时,non-const对象可以调用const成员函数,这样可以缩减代码量。另外注意const对象不能调用non-const成员函数,编译报错:discards qualifiers。
- const类型 迭代器,指针不能变;const_iterator类型,指针可以变,所指向的值不变:
const vector<int>::iterator it;
it++;错误
*it=10;正确
vector<int>::const_iterator it;
it++;正确
*it=10;错误
4 确定对象使用前被初始化
在构造函数的初始化列表中的才算是初始化,而构造函数的内容是在初始化列表之后执行的,已经不算是初始化操作。这里就存在效率问题。
假设你的类成员是个其他类的对象,比如std::string name,你在初始化列表中进行初始化,调用的是string的拷贝构造函数,而在构造函数中进行赋值的话,调用的是:默认构造函数+赋值函数,调用默认构造的原因是,调用构造函数之前会先对成员进行初始化(这也就是为什么在构造函数中进行的操作不能称之为初始化操作),而对于大多数类,默认构造函数+赋值函数的效率是小于只调用拷贝构造函数的。
- 内置类型要手动初始化,不同的平台不能保证对内置类型进行初始化
- 因此最好是在初始化列表中进行初始化操作。
- 为避免“夸编译单元初始化次序”,以local static对象替换non-local static对象。
二、构造/析构/赋值运算
5 C++默认编写并调用的函数
- 编译器默认实现的函数:默认构造、析构、拷贝构造、赋值函数。这里注意深拷贝和浅拷贝问题。①对类内引用型数据对象进行赋值操作,必须定义拷贝赋值函数;②某个基类将拷贝赋值函数声明为private,则编译器对派生类不能生成该函数。
6 不想使用默认生成的函数,可以明确拒绝
- 默认的构造可以被其他构造替换,拷贝构造和赋值函数如果不想被外面调用可以将其声明为private。(member函数与friend函数仍然可以调用private函数,但是会获得连接错误)
- 不想被复制,也可以设计基类,其中基类的复制构造函数和赋值函数均为private类型,则派生类的对象,可以避免被复制。(但可能会导致多重继承)
7 多态基类声明virtual析构函数
- 当一个父类指针指向子类对象时(即多态的用法),在释放对象时,如果父类的析构函数不是virtual的,那么编译器会将这个指针视为父类类型的,只会释放掉这个对象的一部分空间。如果声明为virtual的,那么在释放的时候,编译器就知道这是一个子类类型,会将对象都释放掉,即防止内存泄漏问题。
- 此处涉及虚表指针和虚函数表
- 当类内含有至少一个virtual函数时,声明为virtual析构函数
- 纯虚析构函数,必须进行一份定义。子类继承父类,析构函数的运行方式是,最深处的子类的析构被调用,然后是每个基类的析构函数被调用
- 并非所有基类的设计都是为了多态,如6中,设计带有private的基类为了避免子类被复制。
8 别让异常逃离析构函数
- 绝不要让析构函数抛出异常,应该让用户自己处理可能发生异常的操作
解决办法①:调用absort()
解决办法②:析构中吞下因调用函数而发生的异常(try…catch)
或者说,重新设计函接口,对可能出现的问题做出反应。
9 不要在构造和析构函数调用virtual函数
- 在构造函数和析构函数期间不要调用virtual函数,因为这类调用函数从不降到派生类
由于父类的构造函数发生在子类之前,而此时子类的成员变量等并未初始化,因此在父类的构造函数中调用virtual函数,绝对不会调用子类的方法,即使现在你在创建一个子类对象。换句话说,在构造函数中调用的virtual函数,都会下降到父类类型,即都不是virtual函数。同样的道理,子类析构函数调用在父类之前,因此在父类析构函数调用virtual函数时,子类都不存在了,你让编译器怎么调用。因此一定不要再构造和析构中调用virtual函数。
10 令operator=返回reference to *this 并 11处理自我赋值
①这么写,没毛病,返回引用比临时变量要少几次构造析构,效率高
②第二条是因为,赋值的时候要先释放自己的资源然后赋予新的资源(资源假设为一个指针,这样便于理解),如果你自己给自己赋值,按照这个先释放再赋值的逻辑,自己直接就没了。
- 所以发现是自己赋值自己的时候(this = &object)直接返回*this即可。
Widget& operator=(const Widget& rhs)
{
...
return* this;
}
11 在operator=中处理“自我赋值”
- 确保当对象自我赋值时,operator=的行为良好,技术包括“来源对象”和“目标对象”的地址、精心周到的语句顺序、以及copy-and-swap
- 确定任何函数如果操作一个以上的对象,而其中多个对象是同一个对象时,其行为依然正确。
12 复制对象时勿忘其每一个成分
- 复制函数应确保复制“对象内的所有成员变量”以及所有base类成分
- 不要尝试某个复制函数实现另一个复制函数。应将共同机制放进第三个函数中
三、资源管理
13 以对象管理资源
- 在构造中获得资源并在析构函数中释放资源
- 两个常用的自动管理资源的类是shared_ptr和auto_ptr,其中auto_ptr的复制动作,会导致复制对象变为null,容易造成意外的错误,一般推荐使用shared_ptr,其使用引用计数的原理实现对象共享的目的,并且在计数为0时自动释放对象。
14 在资源管理类中小心copy行为
- 复制RAII对象必须复制它所管理的资源,所以资源的复制行为决定对象的复制行为。
- 普遍且常见的RAII复制行为是:抑制复制、实行引用计数法等。
15 在资源管理类中提供对原始资源的访问
- shared_ptr和auto_ptr都提供了get成员函数,用来执行显式转换(有些函数参数为原始指针,则使用智能指南针不符合要求,故使用get方式)
- 对原始数据访问可能经由显式转换和隐式转换,显式一般安全,隐式转换对客户比较方便。
16 成对的使用new和delete
- 使用new申请内存,必须使用delete释放内存,使用new [] 申请内存,必须使用 delete [] 释放内存
17 以单独的语句将newed对象置入shared_ptr
- 意思为不要写下类似Function(shared_ptr(new Class), x()),其中Function和x为函数,Class是一个类。
原因在于shared_ptr的构造分为两步,第一步是new Class创建一个对象,第二部是执行构造函数,但是像上面那样写可能导致x()在两步之间运行(与编译器如何编译代码有关了),如果这个时候x()发生了异常,那么就会导致内存泄漏。因此初始化shared_ptr的时候,最好不要掺杂其他东西。
std::tr1::shared_ptr<Widget> pw(new Widget);
processWidget(pw, priority());
四、设计与声明
18 让接口容易被正确使用,不易被误用
- 文章举了个Day,Month,Year的例子进行简单的说明(如月1~12,使用enum等)。在实际中就是要考虑客户如果使用你写的接口,怎么样能降低错误使用率,就像给别人提供API一样。要做到这一点还是很不容易的。
19 设计class如涉及type
- 这个真的很难一开始就设计的很好,一般都是根据需求慢慢调整的,但是有一些公共特征还是考虑,比如构造和析构,需要什么操作等。
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 x 2的操作,其中2是个int,但是因为rational有一个以int为参数的构造,因此编译器帮你执行了隐式类型转换。但是反过来写2 x 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 尽量避免转型
转型(casts)破坏了系统类型
-
①最好使用C++4个新式的类型转换函数,因为这很容易辨识,代码可读性提高
-
②尽量避免使用dynamic_cast,因为这种转换效率很低,一般用虚函数的方式来避免转型
28 避免返回一个指针、引用或者迭代器指向类内的成员
- 原因是如果返回了成员的引用或者指针,就可以通过这个引用或者指针修改雷内的private成员,这样是不合理的(这样的话成员就相当于public的了),这一点可以通过给函数的返回类型加const修饰符来防止内部成员变量被修改。但是还有一种情况是,如果获得的类内的一个成员的引用或指针,但是在使用之前,对象被释放了,那么这个引用或指针就变成了野指针了,必然会导致core dump错误。所以应该避免返回类内成员的指针或引用。
29 异常安全函数
- 发生异常时的处理主要分一下几类:资源不泄漏、数据不丢失、不抛出异常。反正就是考虑程序的各种可能的情况,如果异常了要尽可能保证你的程序某些功能或数据不丢失。这个也没啥可说的。
30 inline 函数
- inline函数既可以隐喻,也可以明确提出。
- inline只是一种申请,编译器会根据具体情况来决定一个函数是否可以是inline得,比如递归函数、virtual函数、代码较多的函数,即使你声明了inline关键字,编译器也不会将此类函数视为inline的函数。
31 编译依存关系降低至最低
- ①关于前置声明的一个限制是:编译器必须在编译时确定类的大小,即分配多少内存。因此如果你前置声明一个类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源码中就大量使用这种方式降低编译依赖。
6 继承与面向对象设计
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的关系)
- 复合(composition)是类型之间的关系。,与public继承不同
39 谨慎使用 private私有继承
- 子类继承父类的方式决定了在子类中父类函数的属性,一般规则就是所有属性都按照继承方式对其。比如采用protected继承方式,那么父类中的public成员在子类都升级为protected,其他保持不变。如果采用private继承方式,父类中的所有成员全部变为private,特殊之处之一是父类中原本就是private的成员不可继承,即在子类中也无法使用父类的private成员。
40 多重继承
- 导致二义性,使用作用域区分
- 菱形继承,使用虚继承解决