Effective C++ 入门学习笔记
作者:Cloudox_
让自己习惯C++
条款01:视C++为一个语言联邦
可以将C++看作四部分组成,因为每个部分的思路都不同,分而治之效果更佳:1.C语言。C++仍以C为基础2.objected-oriented C++。面向对象编程,类、封装、继承、多态3.template C++。C++泛型编程、模板元编程的基础4.STL。容器、迭代器、算法
通常,内置类型通过值传递比引用效率更高;而自定义类型通过引用比值传递效率更高。
条款02:尽量以const、enum、inline替换 #define
#define
最好只用于复杂变量名替换。
条款03:尽可能使用const
通常可以将const理解为只读权限。使用const有以下好处:1.让编译器参与帮助检测变量的权限。当使用了const声明变量后,就不必担心该变量被改变了,因为编译器会帮你检测;2.让使用者明白。当参数中使用了const后,使用者会很容易知道这个参数是只读还是读写,这样就可以判断是入参还是出参了,如果不声明const,恐怕使用者都会进去看一眼。
const可以批量设置变量属性,如一个结构体被声明为const,这时结构体内成员都将成为只读权限。如果此时结构体内某个成员需要可写属性,就可以使用 mutable
关键字来解除限制,使其永久可写。
对于成员函数中使用后置const的行为,如 void printall() const
,主要是让使用者更有信心,证明这个函数只读不写。
条款04:确定对象被使用前已先被初始化
对于内置类型:全局变量如果初始化时没被赋值,则会被初始化为默认值;局部变量一定要被初始化,不然其内部存放的是上一次存放过的值,然而该值是未知的。对于内置类型以外的类型:初始化的责任落在构造函数身上。初始化构造函数时建议使用初始化列表而不是在其体内使用赋值语句。
构造/析构/赋值运算
条款05:了解C++默默编写并调用哪些函数
当没有声明时,编译器会自动为类创建默认构造函数、析构函数、复制构造函数和赋值运算符。
条款06:若不想使用编译器自动生成的函数,就该明确拒绝
若不想使用编译器自动生成的函数,可将相应的成员函数申明为private并且不予实现。
条款07:为多态基类声明虚析构函数
如果一个类有任何虚函数,那么它就应该有虚析构函数。
条款08:别让异常逃离析构函数
析构函数不要抛出异常,如果析构函数中调用的函数可能抛出异常,析构函数应该捕捉并记录下来然后吞掉他(不传播)或结束程序。同时最好提供一个普通函数用来供用户执行可能异常的该操作。
条款09:绝不在构造和析构过程中调用虚函数
在构造函数和析构函数中不要去调用虚函数,因为子类在构造/析构时,会调用父类的构造/析构函数,此时其中的虚函数是调用父类的实现,但这是父类的虚函数可能是纯虚函数,即使不是,也可能不符合你想要的目的(是父类的结果不是子类的结果)。如果想调用父类的构造函数来做一些事情,替换做法是:在子类调用父类构造函数时,向上传递一个值给父类的构造函数。
条款10:令 operator= 返回一个*this 引用
注意这只是个协议,但建议遵守。因为该形式被内置类型和标准库类型共同遵守。
TheClass& operator=(const TheClass& rhs) { ... return *this; }
条款11:在 operator= 中处理“自我赋值”
由于变量有别名的存在(多个指针或引用只想一个对象),所以可能出现自我赋值的情况。比如 a[i] = a[j]
或 *px=*py
,可能是同一个对象赋值。一般的解决办法是先检测再赋值。
条款12:复制对象时勿忘其每一个成分
复制构造函数和赋值构造函数要确保复制了对象内的所有成员变量和所有基类成分,这意味着你如果自定义以上构造函数,那么每增加成员变量,都要同步修改以上构造函数,且要调用基类的相应构造函数。
资源管理
条款13:以对象管理资源
为了确保一个对象在初始化后能够最终有效被delete,最好使用shared_ptr和auto_ptr,而前者更好,因为是基于引用计数机制,可以在复制时保持两个指针都指向同一对象,且只有两个指针都销毁时才delete。
这本书可能旧了,auto_ptr已经被废弃了。
条款14:在资源管理类中小心copying行为
如果对想要自行管理delete(或其他类似行为如上锁/解锁)的类处理复制问题,有以下方案,先创建自己的资源管理类,然后可选择:
- 禁止复制,使用条款6的方法
- 对复制的资源做引用计数(声明为shared_ptr),shared_ptr支持初始化时自定义删除函数(auto_ptr不支持,总是执行delete)
- 做真正的深复制
- 转移资源的拥有权,类似auto_ptr,只保持新对象拥有。
条款15:在资源管理类中提供对原始资源的访问
封装了资源管理类后,API有时候往往会要求直接使用其原始资源(作为参数的类型只能接受原始资源,不接受管理类指针),这时候就需要提供一个获取其原始资源的方法。有显式转换方法(如指针的->和(*)操作,也比如自制一个getXXX()函数),还有隐式转换方法(比如覆写XXX()取值函数)。显式操作比较安全,隐式操作比较方便(但容易被误用)。
不明觉厉。
条款16:成对使用new和delete时要采取相同形式
new 对应 delete。new a[4] 对应 delete [] a。两者的使用必须对应
条款17:以独立语句将newed对象置入智能指针
如果有函数参数接收智能指针对象,那么该智能指针对象一定要在调用该函数前用独立语句去创建,否则在创建所指对象和用该对象绑定智能指针两个操作之间,可能插入一些操作(由于C++的独特性),这时候如果出异常,那么会造成创建的对象还没来得及用智能指针修饰,也就无法被自动回收了。
不明觉厉。
设计与声明
条款18:让接口容易被正确使用,不易被误用
好的接口要容易被正确使用,不容易被误用,符合客户的直觉。
- 促进正确使用的办法包括保持接口的一致性,既包括自定义接口之间的一致性,也包括与内置类型行为的相似一致性。
- 阻止误用的办法包括建立新类型来限制该类型上的操作、束缚对象的值以及消除客户管理资源的责任,以此来作为接口的参数与返回类型。
- shared_ptr支持定制删除函数,所以可以很方便的实现上述问题,以及防范DLL问题。
条款19:设计class犹如设计type
在设计class时,要考虑一系列的问题,包括:
- 对象的创建和销毁(构造、析构)
- 对象的初始化与赋值(构造、赋值操作符)
- 复制操作(复制构造)
- 合法值(约束条件)
- 继承体系(注意虚函数)
- 支持的类型转换(显示转换、类型转换操作符)
- 成员函数和成员变量的可见范围(public/protected/private)
- 是否用模板就能实现?
条款20:宁以传递const引用替换传递值
尽量用 常量引用类型 来作为函数的参数类型,这通常比较高效,也可以解决基类参数类型被赋值子类时引起的内容切割问题。但对于内置类型和STL的迭代器与函数对象,通常编译器会对其专门优化,直接传值类型往往比较恰当。
条款21:必须返回对象时,别妄想返回其引用
虽然函数参数最好用引用值,但函数返回值却不要随便去用引用,这回造成很多问题,比如引用的对象在函数结束后即被销毁,或是需要付出很多成本和代码来保证其不被销毁且不重复,这大概率没有必要,就返回一个值/对象就好了。
条款22:将成员变量声明为private
切记将成员变量声明为private,这可以保证客户访问数据的一致性、可以细微划分访问控制、允许约束条件获得保证,并提供类作者充分的实现弹性来修改对其的处理,因为这保证了“封装性”,作者可以改变实现和对成员变量的操作,而不改变客户的调用方式。protected并不比public更加具有封装性,因为protected修饰的成员变量一旦修改,也会造成子类的大量修改。
条款23:宁以非成员、非友元替换成员函数
宁可拿非成员非友元函数来替换成员函数。因为这种函数位于函数之外,不能访问类的private成员变量和函数,保证了封装性(没有增加可以看到内部数据的函数量),此外,这些函数只要位于同一个命名空间内,就可以被拆分为多个不同的头文件,客户可以按需引入头文件来获得这些函数,而类是无法拆分的(子类继承与此需求不同),因此这种做法有更好的扩充性。
条款24:若所有参数皆需类型转换,请为此采用非成员函数
如果你要为某个函数的所有参数(包括this所指对象本身)进行类型转换,那么该函数必须是个非成员函数。举个例子,你想为一个有理数类实现乘法函数,支持与int类型的乘积,可以,因为传参int进去后会调用构造函数隐式转换为有理数类型,同时你想满足交换律,这时就会报错,因为int类型并没有一个函数用来支持你的有理数类做参数的乘法运算。解决方案是将该乘法运算函数作为一个非成员函数,传两个参数进去,这样不管你的int放在前面还是后面,都能作为参数被转换类型了。但是,非成员函数不代表就一定成为友元函数,能够通过public函数调用完成功能的,就不该设为友元函数,避免权力过大造成麻烦。
条款25:考虑写出一个不抛异常的swap函数
由于swap函数如此重要,需要特别对他做出一些优化。常规的swap是简单全复制三次对象进行交换(包括temp对象),如果效率足够就用常规版。如果效率不够,那么给你的类提供一个成员函数swap,用来对那些复制效率低的成员变量(通常是指针)做交换。然后,提供一个非成员函数的swap来调用这个成员函数,供别人调用置换。对于类(非模板),为标准std::swap提供一个特定版本(swap是模板函数,可以特化)。在使用swap时,记得 using std::swap,让编译器可以获取到标准swap或特化版本。编译器会自行从所有可能性中选择最优版本。
实现
条款26:尽可能延后变量定义式的出现时间
直到使用它时才进行定义。但对于循环,定义在循环体前还是定义在循环体内,哪一个比较好呢?方法A:定义在循环体外
Widget w; for(int i=0;i<n;i++) { w=... }
方法A:定义在循环体内
for(int i=0;i<n;i++) { Widget w(...); }
做法A:1个构造函数+1个析构函数+n个赋值操作;做法B:n个构造函数+n个析构函数。
除非(1)你知道赋值成本比“构造+析构”成本低,(2)你正在处理代码中效率高度敏感的部分,否则你应该使用做法B。
条款27:尽量少做转型操作
尽量避免使用转型cast(包括C的类型转换和C++的四个新式转换函数),特别是注重效率的代码中避免用dynamic_casts。如果一定要用,试着考虑无需转型的替代设计,例如为基类添加一个什么也不做的衍生类使用的函数,避免在使用时需要将基类指针转型为子类指针。如果一定要转型,试着将其隐藏于某个函数后,客户调用该函数而无需自己用转型。宁可使用C++新式转型,也不用用C的旧式,因为新式的更容易被注意到,而且各自用途专一。
条款28:避免返回handles指向对象内部成分
避免让外部可见的成员函数返回handles(包括引用、指针、迭代器)指向对象内部(更隐私的成员变量或函数),即使返回const修饰也有风险。这一方面降低了封装性,另一方面可能导致其指向的对象内部元素被修改或销毁。
条款29:为异常安全而努力是值得的
异常安全函数是指即使发生异常也不会泄露资源或者导致数据结构破坏,分三种保证程度:基本保证、强烈保证和不抛异常型。只有基本类型才确保了不抛异常型。对于我们自己设计的函数,往往想要提供强烈保证,即一旦发生异常,程序的整个状态会回到执行函数前的状态,实现方法一般用复制一个副本然后执行操作,全部成功后再替换原对象的方式来实现。但这一操作有时对时间和空间的消耗较大,适用性不强。这种情况下可以提供基本保证。函数提供的保证程度通常最高只等于其所调用的各个函数中的保证的最弱者——木桶理论。
条款30:透彻了解inline的里里外外
inline还真的和宏很像,用一个名称替换一段代码。类声明中的带有实现的成员函数就是内联函数。一般用于返回私有值。
只将inline用在小型、被频繁调用的函数身上。inline会带来体积增大的问题,此外,不要对构造函数、析构函数等使用inline,即使你自己在其中写的代码可能很少,编译器却会为他添加很多代码。不要只因为模板函数出现在头文件,就将它们声明为inline,模板函数和inline并不是必须结对出现的。
条款31:将文件间的编译依存关系降至最低
为了增加编译速度,应该减少类文件之间的相互依存性(include),但是类内又常常使用到其他类,不得不相互依存,解决方案是:将类的声明和定义分开(不同的头文件),声明相互依存,而定义不相依存,这样当定义需要变更时,编译时不需要再因为依赖而全部编译。基于此构想的两个手段是Handle classes和Interface classes。Handle classes是一个声明类,一个imp实现类,声明类中不涉及具体的定义,只有接口声明,在定义类中include声明类,而不是继承。而Interface classes是在接口类中提供纯虚函数,作为一个抽象基类,定义类作为其子类来实现具体的定义。
继承与面向对象设计
条款32:确定你的public继承是is-a关系
public继承意味着 is-a 关系,也就是要求,适用于基类身上的每一件事情,是每一件,也一定适用于衍生类身上。有时候,直觉上满足这一条件的继承关系,可能并不一定,比如,企鹅是鸟,但并不会飞。
条款33:避免遮掩继承而来的名称
就如函数作用域内的变量会掩盖函数作用域外的同名变量一样。衍生类中如果声明了与基类中同名的函数(无论是虚、非虚,还是其他形式),都会掩盖掉基类中的所有同名函数,注意,是所有,包括参数不同的重载函数,都会不再可见。此时再通过子类使用其基类中的重载函数(子类没有声明接收该参数的重载函数时),都会报错。解决方案一是使用using声明式来在子类中声明父类的同名函数(重载函数不需要声明多个),此时父类的各重载函数就是子类可见的了。二是使用转交函数,即在子类函数的声明时进行定义,调用父类的某个具体的重载函数(此时由于在声明时定义,成为inline函数),此举可以只让需要的部分父类重载函数于子类可见。
条款34:区分接口继承和实现继承
Public继承由两部分组成:接口(interface)继承和实现(implementation)继承.
class Shape{ public: virtual void draw() const = 0;//纯虚函数 virtual void error(const std::string& msg);//非纯虚函数 int ObjectID() const;//非虚函数 .... }; class Rectangle:public Shape{...}; class Ellipse:public Shape{...};
上述代码中,类Shape是一个抽象类,因为draw()是纯虚函数,所以客户不能创建Shape的实体。纯虚函数的作用是:让派生类只继承函数接口,而派生类必须提供实现;非纯虚函数的作用是:让派生类继承函数接口,并提供给派生类默认的实现方法(实现继承),当派生类不打算复写方法时,将应用基类提供的默认方法;非虚函数的作用是:派生类不能复写该函数。
条款35:考虑虚函数以外的其他选择
虚函数(本质是希望子类的实现不同)的替代方案:
- 用public的非虚函数来调用private的虚函数具体实现,非虚函数必须为子类继承且不得更改,所以它决定了何时调用以及调用前后的处理;虚函数实现可以在子类中覆写,从而实现多态。
- 将虚函数替换为函数指针成员变量,这样可以对同一种子类对象赋予不同的函数实现,或者在运行时更改某对象对应的函数实现(添加一个set函数)。
- 用tr1::function成员变量替换虚函数,从而允许包括函数指针在内的任何可调用物搭配一个兼容于需求的签名式。
- 将虚函数也做成另一个继承体系类,然后在调用其的类中添加一个指针来指向其对象。
本条款的启示为:为避免陷入面向对象设计路上因常规而形成的凹洞中,偶尔我们需要对着车轮猛推一把。这个世界还有其他许多道路,值得我们花时间加以研究。
条款36:绝不重新定义继承而来的非虚函数
不要重新定义继承而来的非虚函数,理论上,非虚函数的意义就在于父类和子类在该函数上保持一致的实现。
条款37:绝不重新定义继承而来的缺省参数值
不要重新定义一个继承而来的函数(虚函数)的缺省参数的值(参数默认值),因为函数是动态绑定(调用指针指向的对象的函数实现),但参数默认值却是静态绑定(指针声明时的类型所设定的默认参数,比如基类设定的)。这会导致两者不对应,比如: Base *p = new SubClass();
条款38:通过复合表示 has-a 或者“根据某物实现出”的关系
注意 has-a 和 is-a 的区分。如果是 is-a 的关系,可以用继承,但如果是 has-a 的关系,应该将一个类作为另一个类的成员变量来使用,以利用该类的能力,而不是去以继承它的方式使用。
条款39:明智而审慎地使用private继承
Private继承意味着“根据某物实现出”,而不是 is-a 的关系。与上面的复合(has-a)很像,但比复合的级别低。当衍生类需要访问 protected 基类的成员,或需要重新定义继承而来的虚函数时,可以这么设计。此外,private继承可以让空基类的空间最优化。
条款40:明智而审慎地使用多重继承
多重继承确实有正当使用场景,比如public继承某个接口类的接口(其接口依然是public的),private继承某个类的实现来协助实现(继承来的实现为private,只供自己用)。虚继承会增加大小、速度、初始化(及赋值)复杂度等成本,如果虚基类不带任何数据,将是最具使用价值的情况。
模板与泛型编程
条款41:了解隐式接口和编译期多态
类和模板都支持接口和多态。类的接口是显式定义的——函数签名。多态是通过虚函数在运行期体现的。模板的接口是隐式的(由模板函数的实现代码所决定其模板对象需要支持哪些接口),多态通过模板具现化和函数重载解析在编译期体现,也就是编译期就可以赋予不同的对象于模板函数。
条款42:了解typename的双重意义
声明模板的参数时,前缀关键字 class 和 typename 可互换,功能相同。对于嵌套从属类型名称(即依赖于模板参数类型的一个子类型,例如迭代器),必须用typename来修饰,但不能在模板类的基类列和初始化列表中修饰基类。
条款43:学习处理模板化基类内的名称
如果基类是模板类,那么衍生类直接调用基类的成员函数无法通过编译器,因为可能会有特化版的模板类针对某个类不声明该接口函数。解决方法有:
条款44:将与参数无关的代码抽离templates
任何模板代码都不该与某个造成膨胀的参数产生相依关系:
条款45:运用成员函数模板接受所有兼容类型
真实指针允许父类指针指向子类对象,如果想要让自制的智能指针也支持这种对象转换,那就需要特殊操作,因为一般的模板类(智能指针能指向多种对象,必然是模板类)只能以自身模板声明的类型来构造。做法是声明一个泛化构造函数,也就是定义一个模板构造函数,接收模板参数,声明一个指向的真实对象指针,声明一个获取该对象指针的get函数,用该get函数放在初始化列表中来构造模板类。这样就能使用一种类型特化出的自制智能指针来构造另一种类型特化出的自制智能指针了。同时,在初始化列表中编译器会为你检查是否允许该类型转换(比如只允许子类往父类的转换,不能相反)。虽然这种模板构造函数也能作为复制构造函数使用(用相同类型来构造即可),但编译器还是会当做你没有声明复制构造函数,从而为你创建一个,因此如果想要彻底控制行为,你还是需要自行声明你的复制构造函数和赋值构造函数。
条款46:需要类型转换时请为模板定义非成员函数
模板类中的模板函数不支持隐式类型转换,如果你在调用时传了一个其他类型的变量,编译器无法帮你做类型转换,从而报错。解决方案是将该模板函数定义为模板类内的友元模板函数,从而支持了参数的隐式转换。如果函数的功能比较简单,可以直接inline,如果比较复杂,可以调用一个类外的定义好的模板函数(此时,友元函数已经给参数做了类型转换,因此可以调用模板函数了)。
条款47:请使用traits classes表现类型信息
对于模板函数,可能对于接收参数的不同类型,有不同的实现。此时,可以提供一个traits class,其中包含了某一系列类型的类型信息(通常以枚举区分具体类型),然后,在该类中实现接收多种traits参数的重载工具函数,用来根据标识的不同类进行不同的具体函数操作。这使得该行为能在编译期就被区分。
条款48:认识模板元编程(TMP)
TMP可将工作由运行期移往编译器,因而得以实现早期错误侦测和更高的执行效率。实现方式以模板为基础,因为模板会在编译时确定,上一条款的traits classes就是一种TMP,依靠模板函数参数不同的重载来在编译器模拟if else(其在运行期才会判断)。另一个例子是用模板来在编译器实现阶乘:
template<unsigned n> struct Factorial { enum { value = n * Factorial<n-1>::value }; }; template<> struct Factorial<0> { enum { value = 1 }; }
用模板来实现递归从而在编译器实现阶乘运算,用参数为0的特异化来做递归的终结。
定制 new 和 delete
条款49:了解 new-handler 的行为
在对象new操作分配内存时,如果分配失败,默认会返回null(老编译器)或抛出bad_alloc 异常(新编译器)。如果想要自定义分配失败的操作,可以调用 set_new_handler 函数来设置new_handler。如果想要让类在构造时自动调用自定义的new_handler,并在构造结束后回到系统默认的new_handler 。可以继承一个声明了set_new_handler函数接口和包含设置与回归new_handler的new函数的模板类,然后让你的自定义类继承自你的类名所特化的该模板类,从而能够为每一个你的类做一个特化的new_handler函数。
条款50:了解new和delete的合理替换时机
有很多理由让你想要写个自定的new和delete,比如改善定制版的效能、对heap运用错误进行调试、收集heap使用信息等。也有许多商业或开源的内存分配器供你使用。
条款51:编写new和delete时需固守常规
自定义的new应该内含一个无穷循环,在其中尝试分配内存,如果失败,就该调用new-handler以退出循环。同时它应该有能力处理0 bytes的申请(可以简单判断并改为1bytes)。Class专属版本还要处理衍生类的申请,不要直接调用基类的(大小不同),可以判断并转调普通的new函数。自定义的delete应该可在收到null指针时不做任何事,Class专属版本还应该处理衍生类的申请,不要直接调用基类的(大小不同),可以判断并转调普通的delete函数。
条款52:写了 placement new 也要写 placement delete
如果你的new接收的参数除了必定有的size_t外还有其他,就是个placement new。delete类似。当创建对象时,会先进行new,然后调用构造函数,如果构造出现异常,就需要delete,否则内存泄漏。如果用了placement new,那么编译器会寻找含有同样参数的placement delete,否则不会delete,因此必须成对写接收同样参数的placement new和placement delete。同时,为了让用户主动使用delete时能进行正确操作,你需要同时定义一个普通形式的delete,来执行和placement delete同样的特殊实现。你在类中声明placement new后,会掩盖C++提供的new函数,因此除非你确实不想用户使用默认的new,否则你需要确保它们还可用(条款33)。
杂项讨论
条款53:不要轻忽编译器的警告
对于编译器编译时给出的警告信息,最好立即修复,避免后续调试半天来寻找编译器早就告知你的问题。
条款54:让自己熟悉包括TR1在内的标准程序库
C++98的标准程序库有:
而TR1是新的一系列组件,在std内的tr1命名空间中,比如:std::tr1::shared_ptr。它包含:
条款55:让自己熟悉Boost
Boost是一个程序库,其由C++标准委员会成员创设,可视为一个“可被加入标准C++的各种功能”的测试场,涵盖众多经过多轮复核的优质程序,如果想知道当前C++最高技术水平、想一瞥未来C++的可能长相?看看Boost[https://boost.org]吧。
- 在调用动作前加上“this->”
- 使用using声明式来在子类中声明基类的该接口
- 明确指出被调用的函数位于基类:Base::xxx();
- 以上做法都是承诺被调用的函数一定会在各种特化基类中均声明。如果没有声明,还是会在运行期报错。
- 因非类型模板参数造成的代码膨胀(比如用尺寸做模板参数导致为不同尺寸的同一对象生成多份相同代码),往往可消除,做法是将该参数改为函数参数或者类成员变量,而不要放到模板的参数中。
- 因类型参数造成的代码膨胀(比如int和long有相同的二进制表述,但作为模板参数会产生两份代码),往往可降低,做法是让带有完全相同二进制表述的具体类型共享实现码——使用唯一一份底层实现。
- STL
- Iostreams,包括cin、cout、cerr、clog等
- 国际化支持
- 数值处理
- 异常阶层体系
- C89标准程序库
- 智能指针,包括shared_ptr和weak_ptr。
- function:支持以函数签名(出参类型+入参类型)作为模板
- bind:绑定器
- 无序hash表,用以实现无序的set、multiset、map、multimap
- 正则表达式
- tuples:扩充pair,能持有任意个数的对象,类似python中的tuples。
- array:STL化的数组,支持begin和end,不过其大小固定,不适用动态内存。
- mem_fn
- reference_wrapper:让引用的行为更像对象,可以被容器持有。
- 随机数生成工具:大大超越rand
- 数学特殊函数:多种数学函数
- C99兼容扩充。
- type traits,使用见条款47,提供类型的编译期信息。
- result_of:是个模板,用来推到函数调用的返回类型。