C++入门不放弃自学NOIP(C++)

《Effective C++》学习笔记

2019-07-26  本文已影响25人  Cloudox_

让自己习惯C++

条款01:视C++为一个语言联邦

C++可视为:

四者的集合。

条款02:尽量以const、enum、inline替换 #define

对于单纯常量,尽量以const对象或enums枚举来代替#define。
对于函数宏,用inline函数代替#define(define是死板的替换,容易产生传递计算式类似累加多次的问题)

条款03:尽可能使用const

声明const可以帮助编译器侦测错误用法,避免改变不应改变的对象、参数、返回类型等。
编译器对const是“像素级”的不变检查,但编程时应该以“逻辑级”的不变思路来做,对于一些可能变化的变量,使用mutable修饰让编译器允许其变化。
由于函数有重载特性,当const和non-const成员函数有实质等价的实现时,用non-const版本调用const版本来避免代码重复,但不要反过来调用,这不符合逻辑。

条款04:确定对象被使用前已先被初始化

确定对象在使用前已经初始化,避免一些难以预测的问题。
为内置类型手动做初始化,C++不保证初始化它们。
构造函数使用成员初始化列表来赋值,而不是在构造函数里去赋值(会导致赋值两次,浪费了),列表的排列次序保持和class中声明次序一致。
对于一些可能在被别的类直接调用其成员函数、值的类,最好改为暴露一个返回其类对象的引用的函数的形式,而不是暴露其类对象本身,这可以保证在函数内完成初始化,避免被调用时还没有初始化。

构造/析构/赋值运算

条款05:了解C++默默编写并调用哪些函数

当没有声明时,编译器会自动为类创建默认构造函数、析构函数、复制构造函数和赋值构造函数,但如果成员变量中包含引用、const这些不能被改变的值,则不会去生成赋值构造函数,因为无法修改引用对象和const的值,除非我们自己去定义赋值构造函数的行为。

条款06:若不想使用编译器自动生成的函数,就该明确拒绝

若不想使用编译器自动生成的函数,可将相应的成员函数申明为private并且不予实现。或者继承一个类似uncopyable的基类,该基类的相应函数为private且不予实现,这样子类调用时会去调用基类的该函数,从而被编译器拒绝。

条款07:为多态基类声明虚析构函数

如果一个基类可能有多态子类,那么就该声明一个虚析构函数。
如果一个类有任何虚函数,那么它就应该有虚析构函数。
如果一个类不被用来做基类,那么就不该声明虚析构函数。

条款08:别让异常逃离析构函数

析构函数不要抛出异常,如果析构函数中调用的函数可能抛出异常,析构函数应该捕捉并记录下来然后吞掉他(不传播)或结束程序。同时最好提供一个普通函数用来供用户执行可能异常的该操作。

条款09:绝不在构造和析构过程中调用虚函数

在构造函数和析构函数中不要去调用虚函数,因为子类在构造/析构时,会调用父类的构造/析构函数,此时其中的虚函数是调用父类的实现,但这是父类的虚函数可能是纯虚函数,即使不是,也可能不符合你想要的目的(是父类的结果不是子类的结果)。
如果想调用父类的构造函数来做一些事情,替换做法是:在子类调用父类构造函数时,向上传递一个值给父类的构造函数。

条款10:令 operator= 返回一个*this 引用

赋值操作符要反回一个 *this 的引用,如:

TheClass& operator=(const TheClass& rhs) {
    ...
    return *this;
}

条款11:在 operator= 中处理“自我赋值”

由于变量有别名的存在(多个指针或引用只想一个对象),所以可能出现自我赋值的情况。比如 a[i] = a[j],可能是同一个对象赋值。这时就需要慎重处理赋值操作符以免删除了自己后再用自己来赋值。
解决方法有:

条款12:复制对象时勿忘其每一个成分

复制构造函数和赋值构造函数要确保复制了对象内的所有成员变量所有基类成分,这意味着你如果自定义以上构造函数,那么每增加成员变量,都要同步修改以上构造函数,且要调用基类的相应构造函数。
复制构造函数和赋值构造函数看似代码类似,但不要用一个调用另一个,好的做法是建立一个private的成员函数来做这件事,然后两个构造函数都调用该成员函数。

资源管理

条款13:以对象管理资源

为了确保一个对象在初始化后能够最终有效被delete,最好使用shared_ptr和auto_ptr,而前者更好,因为是基于引用计数机制,可以在复制时保持两个指针都指向同一对象,且只有两个指针都销毁时才delete,而auto_ptr只会保证一个指针有效,在复制时,原指针会指向null。
对于数组对象,两个指针不会使用对应的delete[],所以容易发生错误,最好使用string或vector来取代数组。

条款14:在资源管理类中小心copying行为

如果对想要自行管理delete(或其他类似行为如上锁/解锁)的类处理复制问题,有以下方案,先创建自己的资源管理类,然后可选择:

条款15:在资源管理类中提供对原始资源的访问

封装了资源管理类后,API有时候往往会要求直接使用其原始资源(作为参数的类型只能接受原始资源,不接受管理类指针),这时候就需要提供一个获取其原始资源的方法。有显式转换方法(如指针的->和(*)操作,也比如自制一个getXXX()函数),还有隐式转换方法(比如覆写XXX()取值函数)。显式操作比较安全,隐式操作比较方便(但容易被误用)。

条款16:成对使用new和delete时要采取相同形式

new 对应 delete。
new a[4] 对应 delete [] a。
两者的使用必须对应。对于数组,不建议使用typedef行为,这会让使用者不记得去delete []。对于这种情况,建议使用string或者vector。

条款17:以独立语句将newed对象置入智能指针

如果有函数参数接收智能指针对象,那么该智能指针对象一定要在调用该函数前用独立语句去创建,否则在创建所指对象和用该对象绑定智能指针两个操作之间,可能插入一些操作(由于C++的独特性),这时候如果出异常,那么会造成创建的对象还没来得及用智能指针修饰,也就无法被自动回收了。

设计与声明

条款18:让接口容易被正确使用,不易被误用

好的接口要容易被正确使用,不容易被误用,符合客户的直觉。

条款19:设计class犹如设计type

在设计class时,要考虑一系列的问题,包括

条款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:尽可能延后变量定义式的出现时间

尽可能延后变量定义式的出现,既包括延后构造它,保证只有真正使用才构造;也包括只有到赋值时才构造它,避免默认构造函数无畏调用。
对于循环操作,在循环前还是中进行构造,取决于赋值操作与构造+析构操作的成本对比。

条款27:尽量少做转型操作

尽量避免使用转型cast(包括C的类型转换和C++的四个新式转换函数),特别是注重效率的代码中避免用dynamic_casts。如果一定要用,试着考虑无需转型的替代设计,例如为基类添加一个什么也不做的衍生类使用的函数,避免在使用时需要将基类指针转型为子类指针。
如果一定要转型,试着将其隐藏于某个函数后,客户调用该函数而无需自己用转型。
宁可使用C++新式转型,也不用用C的旧式,因为新式的更容易被注意到,而且各自用途专一。

条款28:避免返回handles指向对象内部成分

避免让外部可见的成员函数返回handles(包括引用、指针、迭代器)指向对象内部(更隐私的成员变量或函数),即使返回const修饰也有风险。这一方面降低了封装性,另一方面可能导致其指向的对象内部元素被修改或销毁。

条款29:为异常安全而努力是值得的

异常安全函数是指即使发生异常也不会泄露资源或者导致数据结构破坏,分三种保证程度:基本保证、强烈保证和不抛异常型。
只有基本类型才确保了不抛异常型。对于我们自己设计的函数,往往想要提供强烈保证,即一旦发生异常,程序的整个状态会回到执行函数前的状态,实现方法一般用复制一个副本然后执行操作,全部成功后再替换原对象的方式来实现。但这一操作有时对时间和空间的消耗较大,适用性不强。这种情况下可以提供基本保证。
函数提供的保证程度通常最高只等于其所调用的各个函数中的保证的最弱者——木桶理论。

条款30:透彻了解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:区分接口继承和实现继承

声明一个纯虚函数的目的是为了让衍生类只继承其函数接口,而自己进行函数定义实现。
声明一个非纯虚函数的目的是为了让衍生类继承该函数的接口和缺省实现(一般实现),如果有特别的操作需求,可以在衍生类中进行实现来覆盖。如果担心因此忘记做特异化实现,可以利用纯虚函数,在父类给纯虚函数一个实现,然后在子类的该函数的实现中调用它,这样就会记得在需要特异化的子类中进行其他特异化实现。
声明一个非虚函数的目的是为了让衍生类完全继承该函数的接口和实现,也就是声明该函数的实现方式不得更改,所有子类都表现一致。

条款35:考虑虚函数以外的其他选择

虚函数(本质是希望子类的实现不同)的替代方案:

本条款的启示为:为避免陷入面向对象设计路上因常规而形成的凹洞中,偶尔我们需要对着车轮猛推一把。这个世界还有其他许多道路,值得我们花时间加以研究。

条款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吧。
http://boost.org


《Effective C++:改善程序与设计的55个具体做法(第3版)》豆瓣

查看作者首页

上一篇下一篇

猜你喜欢

热点阅读