在学习算法导论的过程中,我深深地震撼于自己笔下C++代码的丑陋。于是我决定捧起这本《Effective C++》。本来打算看完这本书,写一篇完整的笔记博文,但是刚刚看到一半,我已经跃跃欲试地想动手改善我的代码了。所以,我将写完的这部分笔记整理成单独的一篇博文。
1. 视C++为一个语言联盟。
- C++ 包括 C & OO C++ & Template C++ & STL
2. 使用 const,enum,inline 代替#define。
3. 尽可能使用 const
- const 修饰指针的不同含义
char* const p1 = "hello"; // 固定指针:不能使p2指向其他对象 const char* p2 = "hello"; // 固定数据:不能修改p2指向的对象
- const 修饰函数时的不同含义
class Text { public: const std::size_t length() const; // 返回文本的长度 // 第一个 const 表示 函数返回一个常量,不可作为左值使用 // 第二个 const 表示 函数不修改 Text 类中的数据成员(除了 static 和 mutable 修饰过的) private: char* data; };
当类的实例被声明为 const 时,只能调用被第二个 const 修饰过的函数。
4. 保证使用对象前进行初始化
- 内置数据类型不进行初始化,所有对象类型都有默认初始化函数。
- 在构造函数中对类成员赋值并不是真正意义的初始化,进入构造函数体时,对象成员都已经调用过默认初始化函数了。应当使用初始值列进行初始化。
class Person { public: Person(): name(), // 调用 string 类默认构造函数 sex_isMale(true) // 内置类型,必须初始化 {}; Person(const std::string& tname, const bool& isMale): name(tname), // 调用 string 类的复制构造函数 sex_isMale(isMale) {}; private: std::string name; bool sex_isMale; };
- 不同编译单元的 non-local static 对象(如全局对象)的初始化顺序不可控,将对象放在一个全局函数中,并将对象其声明为静态成员。第一次调用该函数时必定会初始化该对象。
5. 了解C++默默做的事
- 如果没有声明任何构造函数,则编译器自动为类实现默认构造函数。
- 如果你没有实现,编译器会自动为类实现复制构造函数,复制运算符(operator=)函数,析构函数。
- 如果类中包含引用类型的成员 或 const 成员,则编译器不会实现复制运算符函数。因为更改 引用 或 const 成员是不允许的。
6. 如果不想使用编译器自动生成函数,就该明确拒绝
- 将不想使用(如果你不声明,编译器就会自动生成)的函数 声明 为 private,并且不实现它(防止友元类调用)。
- 声明基类,并在基类中将不想使用的函数声明为 private,且不实现。继承基类的派生类,编译器不会自动生成相应函数。
class Uncopyble { protected: Uncopyble(){} ~Uncopyble(){} private: // 声明但不实现复制构造函数,其派生类无法调用基类的复制构造函数(由于private) // 因此编译器无法自动生成派生类的复制构造函数(默认的逻辑上,该函数应当调用基类的复制构造函数) Uncopyble(const Uncopyble&); // 复制操作符函数同理 Uncopyble& operator=(const Uncopyble&); };
7. 为多态基类声明virtual析构函数
- 如果基类的析构函数不是虚函数,那么通过基类指针引用的派生类对象,在其销毁时,只能销毁积累部分,而不能销毁派生类部分。
8. 不让异常逃离析构函数
- 析构函数往往并不由类的使用者亲自调用,因此在析构函数中抛出的异常难以捕捉。
- 如果在对象的销毁阶段确实可能抛出异常(比如,由于网络原因,关闭远程数据库失败),应该另外实现一个使用者亲自调用的销毁函数如close(),在该函数中抛出异常,以此给用户以机会处理异常。在析构函数中,检查用户是否调用了销毁函数:如果用户已经调用过,则不再调用该函数;如果用户未曾调用过,则调用该函数,在出现异常的情况下,并吞下异常或直接停止程序(用户没有立场抱怨,因为我们已经给了他机会)。
9. 不在构造函数或析构函数中调用virtual函数
- 派生类初始化时,先对基类部分初始化,然后才是派生部分。基类的构造函数运行时,派生类还不存在,此时调用虚函数并试图完成派生类中相应地逻辑:如果该虚函数有实现,就仅仅调用虚函数而不是派生类中的函数;如果没有定义虚函数,会出现连接错误。
- 析构函数同理。
10. 令 operator= 返回一个对 this 的引用
- 这样就可以使用连等式来赋值了。
11. 在 operator= 中处理自我赋值
- 在 operator= 中需要考虑参数就是自身的情况,也要注意语句顺序,以保证“异常安全性”。
12. 复制对象时不要忘了对象的每一个部分
- 如果自己实现复制构造函数和复制运算符函数(而不使用编译器自动生成的版本),一定要记得将所有的成员都复制过来,编译器不会因为这是个复制构造函数或operater=而帮你检查。
- 如果你在派生类中自己实现以上两种函数,一定要记得显式地调用基类的相应函数,编译器不会帮你调用。
class Person{ public: Person(){} Person(const std::string& tname):name(tname){} private: std::string name; }; class Citizen:public Person{ public: Citizen():Person(),married(false){} Citizen(Citizen& pcitizen): Person(pcitizen), // 显式调用基类的复制构造函数, // 注意传入的是pcitizen而不是pcitizen.name, // 因为调用的是基类的复制构造函数而不是构造函数, // 而且基类的private也不允许你这样做 married(pcitizen.married){} // 派生类部分的初始化 private: bool married; };
13. 以对象管理资源
- 所谓资源,往往是由 new 运算符产生的,由指针控制和管理的对象和数组。它们通常分配在堆(而不是栈)上,所以程序流程发生变化时,这些对象和数组不能自动销毁(而分配在栈上的对象是可以的),需要手动销毁。
- RAII:对象的取得时机就是最好的初始化时机,两种常用的RAII对象(智能指针):std::auto_ptr<T>和std::tr1::shared_ptr<T>,前者的复制方案为“转让所有权”,后者的复制方案为“计数器”。
- 一个RAII对象示例
class FontHandle; class Font{ public: Font(FontHandle* ft): f(ft){} ~Font(){delete f;}
... private: FontHandle* f; };Font类的实例并不分配在堆上,但其指针成员 f 指向的对象 *f 分配在堆上。当流程变化时,Font 实例被正常销毁,其析构函数被调用,析构函数中将指针成员指向的对象销毁。这就保证了 *f 没有泄露。
14. 在资源管理器中小心 copying 行为
- 资源管理器的资源:即指针指向的对象,由资源管理器维护。当自己实现智能指针对象时,考虑一下四种 copying 行为。
- 禁止复制
- 引用计数(如shared_ptr,需用到类的静态成员)
- 深度复制
- 转让所有权(如auto_ptr)
- 考虑着四种 copying 行为的目的就是,避免在析构函数中多次试图销毁指针所指对象,或者完全不销毁。
15. 在资源管理器中提供对原始资源的访问
- 往往对 RAII 对象实现 operator-> 和 operator* 以实现对资源对象内部成员的访问。
- 实现显式转换函数,如 Font.get() 返回资源对象。
- 实现隐式转换函数,如 Font::operator FontHandle() 返回资源对象。此时,Font 对象 可 隐式转换为 FontHandle 对象,但也会带来部分风险。
class Font{ public: Font(FontHandle* ft): f(ft){} ~Font(){delete f;} operator FontHandle(){return *f;} FontHandle get(){return *f;} ... private: FontHandle* f; };
16. 成对使用 new 与 delete 时采取相同的形式
- 事实上,编译器中实现了两种指针,指向单个变量/对象 的 和指向变量/对象 数组的。使用 new 和 delete 时应当采取对应的形式。
std::string* s1 = new std::string("hello"); std::string* s2 = new std::string[100]; ... delete s1; delete [] s2;
17. 以独立语句将 newed 对象置入智能指针中
- 考虑这样做:
Font f1(new FontHandle);
独立语句的含义是:不将该语句拆开,也不将其合并到其他语句中,这样可以确保资源不被泄露,如:
// 不将其拆开 FontHandle* fh1 = new FontHandle; ... // 发生异常怎么办? Font f1(fh1); // 不将其合并 AnotherFunction(Font(new FontHandle), otherParameters /*发生异常怎么办?*/);
18. 让接口易于使用,难于误用
- 让接口易于使用,一般来说,就是尽量保持与内置类型(甚至STL)同样的行为。比如,你应当为 operator+ 函数返回 const 值,以免用户对计算结果进行赋值操作,内置类型不允许(对 int 型变量,语句 a+b=c 不能通过编译,所以你的类型也应该尽量保持同样的性质,除非你有更好的理由);又比如,对象的主要组成部分如果是一个数组,那么数组的长度的成员名最好使用 size 而不是 length,因为 STL 也这么做了。
- 让借口难于误用,包括在类中限制成员的值(比如 Month 类型不可能表示 13 月),限制类型上的操作,在工厂函数中返回智能指针。
19. 设计 class 犹如 设计 type
20. 用 pass-by-reference-const 替换 pass-by-value
- 为函数传递参数时,使用使用 const 引用传递变量。在定义函数时:
class Person{...}; class Citizen:public Person{...}; bool validatePerson(Person psn){...} // 值传递,尽量不要这样做 bool validatePerson(const Person& psn){...} // const引用传递
- 使用const引用类型传递函数参数的好处在于:
- 免去不必要的构造开销:如果使用值传递,实参到形参的过程调用了类型的复制构造函数,而引用不会。
- 避免不必要的割裂对象:如果函数的参数类型是基类,而函数中又调用了派生类中的某种逻辑(即调用了基类中的虚函数),那么值传递的后果就是,形参仅仅是个基类对象,虚函数也仅仅就调用了虚函数自己(而不是派生类中的函数)。
- 对于C++内置类型和STL迭代器,使用值传递,以保持一致性。
21. 必须返回对象时,不要试图返回 reference
- 考虑一个有理数类:
class Rational{ public: Rational(int numerator=0, int denominator=1):n(numerator),d(denominator){} private: int n, d; };
-
任何有理数可用分数表示, n 和 d 分别为分子和分母,他们都是 int 型的。现在考虑为该类实现乘法,我们希望它能像内置类型一样工作。
Rational x = 2; Rational y(1,3); Rational z = x*y*y; // z等于2/9
- 我们可能会令函数返回引用类型(尤其是意识到20条中关于值传递的种种劣迹后):
class Rational{ ... private: // 错误的代码 friend const &Rational operator* (const Rational& lhs, const Rational& rhs){ Rational result(lhs.n*rhs.n, lhs.d*lhs.d); return result; } ... };
result对象在 operator* 函数结束后就销毁了,但我们返回了它的引用!这个引用指向 result 对象原先的位置(编译器往往用指针实现引用),而且该位置在栈上!不仅无效,而且危险。
- 我们也可能用new运算符建立一个新的对象(以防止在函数结束后被销毁),并返回该对象的引用:
// 错误的代码 friend const &Rational operator* (const Rational& lhs, const Rational& rhs){ Rational* result = new Rational(lhs.n*rhs.n, lhs.d*lhs.d); return *result; }
这次,*result 对象不会因为函数结束而销毁了,它分配在堆上。但问题是,谁来负责销毁它?尤其是上文 z=x*y*y 中,由 y*y 计算而得到的临时变量,几乎不可能正常销毁。
- 正确的做法是:
// 正确的代码 friend const Rational operator* (const Rational& lhs, const Rational& rhs){ return Rational(lhs.n*rhs.n, lhs.d*lhs.d); }
虽然产生了构造消耗,但这是值得的。返回的对象 z 分配在栈上,也就是说会在适当的时候销毁,而原先函数中的临时变量也正常销毁了。
22. 将成员变量声明为 private
23. 以 non-member 和 non-friend 函数替换 non-member 函数
- 类的 public 方法越多,其封装性就越差,内部实现弹性就越小。设计类的时候应由其细心。对于一些便利函数(这些函数往往只调用函数的其他 public 方法),可考虑将其放置在类外。C++允许函数单独出现在类外,即使在C#等语言中,也可以使其出现在 工具 对象中。
- 将类外的函数与类声明在同一个命名空间中是不错的选择。
24. 如果函数的所有参数都需要类型转换,采用 non-member 函数
- 第21条中的代码已经体现出这一条的意思了。这一条大致就是希望 Rational 对象能像其他内置对象一样,直接参与运算。比如,希望这样:
Rational x(2,5); Rational y = x*2; Rational z = 2*x;
- 首先,Rational 构造函数没有使用 explicit 修饰,这意味着 x*2 可以正常计算,因为这会调用 x.operator*(Rational& a),而整数 2 会隐式转换成 Rational 对象。(等等,在第21条中我们好像没有定义,x.operator*(Rational& a)函数?对,这是因为其中的代码已经遵循了本条的忠告,定义了 non-member 函数。)
- 如果在 Rational 中定义了x.operator*(Rational& a),那么计算 z 时会遇到困难,因为系统会试图调用 Int32.operator*(Rational& a),这根本没有定义。所以,我们在代码中并没有定义成员函数,而是定义了友元函数 Rational operator*(Rational& a, Rational& b),正如在第21条的代码中显示的那样。
25. 考虑写一个不抛出异常的 swap 函数
- std::swap 函数采取复制构造的方法,效率比较低。
namespace std{ template <typename T> void swap(T& a, T& b){ T temp(a); a = b; b = a; } }
-
为自己的类实现 swap 方法并 特化 std::swap
class Person{ private: void* photo; }; namespace std{ template <> // 特化std::swap方法 void swap<Person>(Person& a, Person& b){ std::swap(a.photo, b.photo); } }
当自己的类较大时,可在类中定义swap方法,并在 std::swap<YourClass> 中调用该方法。
26. 尽量延后变量定义式的时间
- 仅当变量第一次具有“具有明显意义的初值”时,才定义变量,以避免不必要的构造开销。避免这样做:
std::string s; // 调用默认构造函数 ... // 如果发生异常呢,如果含有某个return语句呢?第一次调用构造函数的开销被浪费了 s = "Hello"; // 再一次调用构造函数,第一次调用构造函数的开销依然被浪费了
应当这样做:
std::string s("Hello"); // “hello”是具有明显意义的初值,只调用了一次构造函数
27. 尽量少做转型动作
- 四种转型动作
- const_cast:消除对象的常量性
- dynamic_cast:动态转换,开销较大。使用的场合往往是:想要在派生类上执行派生类的某个函数,但是手头上只有基类的指针指向该对象。
- reinterpret_cast:依赖于编译器的低级转型
- static_cast:强迫隐式转换,类似于C风格的转换,例如将int转换为double等
- 不要试图在派生类的成员函数中,通过dynamic_cast将(*this)转换为基类对象,并调用基类成员函数。
class Person{ public: void showMessage(){} }; class Citizen:public Person{ public: void showMessage(){ dynamic_cast<Person>(*this).showMessage(); // 错误,这样转型得到的并不是期望的“基类对象 } };
而应当这样做:
Person::showMessage(); // 这就对了
28. 避免返回 handles 指向对象内部部分
- handle 包括指针,引用,迭代器,用来获取某个对象,以前被翻译成句柄。
- 在函数的方法中返回对象内部成员的 handle 可能遭致这样的风险:返回的 handle 比对象本身更长寿,当对象销毁后,handle 所指向的区域就是不确定的。
- string 和 vector 类型的 operator[] 就返回了对象内部成员的 handle ,这只是例外。
29. 为“异常安全”而作的努力是值得的
- 函数异常安全类型:
- 基本承诺:如果异常抛出,程序内的所有元素仍然在有效状态下,没有任何元素受到损坏(如释放了指针指向资源却没有为其指定新的资源,该指针通向了不确定性)。
- 强烈保障:如果异常抛出,程序内的所有元素保持函数调用前的状态。
- 不throw异常:承诺绝不抛出异常。
- 一个函数异常安全的程度取决于所调用函数中异常安全程度最弱的。
- copy & swap 策略:为对象的数据制造一份副本,并对副本进行修改。如果发生异常,抛弃副本并返回;如果成功,则将对象数据与副本数据做 swap 操作,swap 操作承诺绝不抛出异常。