《effective C++》笔记

65 阅读13分钟

effective C++

1. 视C++为一个语言联邦

C++包括C的基本内容,面向对象,模板编程和STL。

2. 尽量以const, enum, inline替换#define

  • 对于单纯常量,最好以const对象或enums替换#define
  • 对于形似函数的宏,最好改用inline函数替换#define

3. 尽可能使用const

const可以修饰函数体、返回值、参数。

修饰参数:会忽略顶层常量。原因是函数中的是副本,不会影响到主函数的值。

image

当我们定义了一个常量对象时,我们理所当然的认为它不应该被改变。此时我们需要提供某些成员函数的常量版本,因为常量对象只能调用常量成员函数。

更多情况下是:因为函数间的参数传递大多是pass-by-reference-to-const,也就是const class& object;它只能调用常量成员函数。需要注意的是,const修饰函数体并不会影响到它的返回值,此时我们应当把常量成员函数的返回值也用const修饰。

还有一点:对于const版本和non-const版本,公共的操作最好使用一个私有工具函数来承担。

4. 确定对象被使用前已先被初始化

  • 为内置类型对象进行手动初始化,因为C++不保证初始化它们;
  • 构造函数最好使用成员初始化列表,而不要在构造函数本体内使用赋值操作。成员初始化列表列出的成员变量,其排列次序应该和它们在class中的声明次序相同
  • 为免除“跨编译单元值的初始化次序”问题,以local static对象替换non-local 对象。即声明静态函数,返回静态对象。

static对象包括global对象、定义于namespace作用域内的对象、在classes内、在函数内、以及在file作用域内被声明为static的对象,其中函数内的static对象称为local static对象,其它的都是non-local static对象。

5. 了解C++默默编写并调用哪些函数

  • 编译器会暗自为class创建默认构造函数、拷贝构造函数、拷贝赋值运算符以及析构函数。

前提是class中的成员变量不是引用和常量;如果该类是派生类的话,它的父类的拷贝构造函数不可以是private或delete的。

6. 若不想使用编译器自动生成的函数,就该明确拒绝

当想要禁止一个类的拷贝操作,就要禁止拷贝构造函数和拷贝赋值运算符。此时有两种方法:

  • 将类的拷贝构造函数和拷贝赋值运算符设置成private,但缺点是该类的友元函数和其它成员函数还是可以访问它;此时,我们可以定义一个不可以拷贝的基类,让该类去继承它。
  • C++11的新语法,将函数设置为delete即可。

7. 为多态基类声明virtual析构函数

image

首先:不管是继承关系还是组合管理,构造函数调用:先基类后派生类,析构函数调用:先派生类后基类。红色的部分是编译器为我们加上的

动态绑定:只发生在使用基类引用或指针调用了虚函数时才发生。

当销毁类的时候,实际上是调用了析构函数。例如:

 Base* p = new Derived();

当该对象销毁,因为动态绑定只发生在虚函数上,如果该基类析构函数为non-virtual函数,则此时调用的是静态类型对象的析构函数,也即基类对象的析构函数,只释放了基类对象的资源。

  • 指针调用
  • 向上转型
  • 调用的是虚函数

7.1 vptr和vtbl

vptr跟着对象走,所以对象什么时候创建出来,vptr就什么时候创建出来,也就是运行的时候。

虚函数表创建时机是在编译期间。编译期间编译器就为每个类确定好了对应的虚函数表里的内容。

虚函数的实现原理:

  1. 每个含有virtual函数的class都有⼀个vtbl(虚函数表),vtbl中存储的是指向各个虚函数的函数指针;
  2. 每个对象内存空间内存在⼀个vptr(虚指针),这个vptr指向类的vtbl
  3. 当对象调⽤某个virtual函数的时候,编译器根据对象的vptr找到类的vtbl,在vtbl中寻找适当的函数指针

image

 //
 // Created by ruan'ruan on 2023/7/26.
 //
 #include <iostream>
 #include <string>
 #include <vector>
 ​
 using std::cin;
 using std::cout;
 using std::endl;
 using std::string;
 using std::vector;
 ​
 class A {
 public:
     virtual void vfun1() {};
     virtual void vfun2() {};
 ​
     void fun1() {};
     void fun2() {};
 ​
 private:
     int data1, data2;
 };
 ​
 class B: public A {
 public:
     virtual void vfun1() override { };
     void fun2() { };
 ​
 private:
     int data3;
 };
 ​
 class C: public B {
 public:
     virtual void vfun1() override {};
     void fun2() {};
 ​
 private:
     int data1, data4;
 };
 ​
 int main() {
     A a;
     B b;
     C c;
     cout << "end" << endl;
     return 0;
 }
  • 带多态性质的基类应该声明一个virtual析构函数。如果类带有任何virtual函数,它就应该拥有一个virtual析构函数。
  • 类的设计目的如果不是为了作为基类使用,或不是为了具备多态性,就不该声明virtual析构函数。

8. 别让异常逃离析构函数

  • 若析构函数出现异常,调用abort结束程序;
  • 记录信息,吞下异常。

9. 绝不再构造和析构过程中调用virtual函数

因为派生类的构造函数晚于基类的构造函数,如果在构造函数中调用了virtual函数,那么会调用基类版本的虚函数。

这是因为:当基类的构造函数执行时,派生类的成员变量尚未初始化,如果此时虚函数下降至派生类层次,会存在使用派生类未初始化成员的风险。

更根本的原因:派生类对象在调用基类构造函数期间,对象类型是基类而不是派生类

10.令operator=返回一个reference to *this

为了实现连锁赋值和处理。

11. 在operator=中处理自我赋值

一般的做法是在赋值前做一个检查:

 A& A::operator=(const A& rhs) {
     if(this == *rhs) return *this;
     delete p;
     p = new B(*rhs.p);
     return *this;
 }

但这个代码虽然可以处理自我赋值,却并不是异常安全的。假如这里的new分配失败,该对象就会有一个指向被删除内存的指针

关键是在分配成功前不改变指针p。

 A& A::operator=(const A& rhs) {
     B* tmp = p;
     p = new B(*rhs.p);
     delete tmp;
     return *this;
 }

12. 复制对象时勿忘记其每一个成分

  • 当类中新添加了元素,不要忘记修改类的拷贝构造函数和拷贝赋值运算符;

  • 在自定义派生类的拷贝构造函数和拷贝赋值运算符时,记得调用基类的对应函数为派生类中的基类部分赋值/拷贝。

     D(cosnt D& d): Base(d), n(d.n) {};
     D& D::operator=(const D& rhs) {
         Base::operator=(rhs);
         n = rhs.n;
         return *this;
     }
    

13. 以对象管理资源

  • 为了防止资源泄露,使用RAII对象,它们在构造函数中获得资源并在析构函数中释放资源;
  • 使用智能指针shared_ptr和unique_ptr;

14. 在资源管理类中小心copying行为

  • 普遍而常见的RAII class copying行为是:禁止coping、实行引用计数法;
  • 复制RAII对象必须一并复制它所管理的资源,所以资源的coping行为决定RAII对象的coping行为。

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

  • 很多时候APIs往往要求访问原始资源,所以每一个RAII class都应该提供一个取得其所管理资源的方法;
  • 对原始资源的访问可能由显示转换或隐式转换提供,一般而言,显示转换比较安全,但隐式转换对客户较为方便。

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

系统在new一个数组对象的时候会把数组大小存放在返回的内存地址的前4个字节中。

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

  • 若在调用形参为智能指针的函数时,临时构造一个智能指针对象传入,当该函数有多个参数时,由于顺序的不确定,当另外的参数出现异常时,会发生资源泄露。

18. 让接口容易被正确使用,不易被误用

  • 好的接口应该很容易被正确使用,不容易被误用;
  • “促进正确使用”的办法包括接口的一致性,以及与内置类型的行为兼容;
  • “阻止误用”的办法包括建立新类型、限制类型上的操作,束缚对象值,以及打消让用户管理资源的想法。

19. 设计class犹如设计type

20. 宁以pass-by-reference-to-const替换pass-by-value

  • 尽量以pass-by-reference-to-const替换pass-by-value,前者更加高效,并可以避免切割问题;
  • 以上规则并不适用于内置类型,以及STL的迭代器和函数对象。

21. 返回对象时,别妄想返回其reference

  • 千万不要返回函数局部对象的reference,应该pass-by-value,如果可以,最好是pass-by-const-value

22. 将成员变量声明为private

  • 切记将成员变量声明为private,这可赋予可恶访问数据的一致性、可细微划分访问控制、允诺约束条件获得保证,并提供class作者以充分的实现弹性;
  • protected并不比public更具封装性。

23. 宁以non-member、non-friend替换member函数

如果某些东西被封装,它就不再可见。愈多东西被封装,愈少人可以看到它。而愈少人看到它,我们就有愈大的弹性去改变它,因为我们的改变仅仅直接影响看到改变的那些人事物。因此,愈多东西被封装,我们改变那些东西的能力也就愈大。作为一种粗糙的测量,愈多函数可以访问它,数据的封装性就愈低。

  • 拿non-member、non-friend函数替换member函数。这样坐可以增加封装性、包裹弹性和机能扩充性。

24. 若所有类型皆需类型转换,请为此采用non-member函数

当我们设计一个分数类时,为了支持混合运算,该类的构造函数为non-explicti的。为这个类重载operator+运算符,考虑到需要支持混合运算,它必须被设计成non-member函数。与之不同的是,operator+=应该被设计成member函数

  • 如果你需要为某个函数的所有参数进行类型转换(包括被this指针所指的那个隐含参数),那么这个函数必须是个non-member函数。

25.考虑写出一个不抛异常的swap函数

26. 尽可能延后变量定义式的出现

  • 尽可能延后变量定义式的出现。这样做可增加程序的清晰度并改善程序效率

27. 尽量少做转型动作

  • 如果可以,尽量避免转型,特别是在注重效率的代码中避免dynamic_casts,如果有个设计需要转型动作,试着发展无需转型的替代设计;
  • 如果转型式必要的,试着将它隐藏于某个函数背后。客户随后可以调用该函数,而不需将转型放进他们自己代码内;
  • 使用C++ style(新式)转型,不要使用旧式转型。

28. 避免返回handles指向对象内部成分

  • 避免返回handle指向对象内部。遵守这个条款可以增加封装性,帮助const成员函数的行为像个const。

29. 为“异常安全”而努力是值得的

异常安全有两个条件:

  1. 不泄露任何资源;
  2. 不允许数据被破坏
  • 异常安全函数即使发生异常也不会泄露找资源或允许任何数据结构被破。这样的函数分为三种可能的保证:基本型、强烈型、不抛异常型;
  • “强烈保证”往往能够以copy-and-swap实现出来,但并非所有函数都可实现或具备现实意义;
  • **函数提供的“异常安全保证”通常最高只等于其所调用之各个函数的“异常安全保证”中的最弱者。

30. 透彻了解inlining的里里外外

虚函数不可以加上inline,因为虚函数意味着“等待,直到运行期才决定调用哪个函数”;而inline意味着“执行前,先将调用动作替换为被调用函数的本体”。

  • 将大多数inline限制在小型、被频繁调用的函数身上。这可使日后的调试过程和二进制升级更容易,也可使潜在的代码膨胀问题最小化;
  • 不要只因为function templates出现在头文件,就将他们声明为inline。

31 将文件间的编译依存关系降至最低

  • 支持“编译依存最小化”的一般构想是:相依于声明式,不要相依于定义式。基于此构想的两个手段是Handle classes和Interface classes;
  • 程序库文件应该以“完全且仅有声明式”的形式存在。这种做法不论是否设计templates都适用。

对于书中的例子,它以“pointer to implementation"的形式避免了依赖,把原先的依赖关系:

main.cpp --> Person.h, 这意味者任何时候Person.h的更改都会导致main.cpp重新编译。

之后变为:

main.cpp --> Person.h

Person.cpp --> Person.h PersonImpl.h

PersonImpl.cpp --> PersonImpl.h

由于Person类的具体实现由PersonImpl完成,它彻底解除了main.cpp和PersonImpl.h的依赖关系。

32. 确定你的public继承塑模出is-a关系

  • ”public继承“意味着is-a。适用于基类身上的每一件事情一定也适用于派生类身上,因为每一个派生类对象也是一个基类对象。

33. 避免遮掩继承而来的名称

派生类的作用域嵌套在基类作用域中。

  • 派生类内的名称会遮掩基类内的名称。在public继承下从来没有人希望如此;
  • 为了让被遮掩的名称重见天日,可以使用using声明式。

34. 区分接口继承和实现继承

  • 接口继承和实现继承不同。在public继承下,派生类总是继承基类的接口;
  • 纯虚函数只具体指定接口继承;
  • 简单的虚函数具体指定接口继承和缺省实现继承;
  • non-virtual函数具体指定接口继承以及强制性实现继承。
  • 当想要切断虚函数的缺省继承时候,把该函数定义为纯虚函数,具体的实现放在另一个函数中(且为protected)。

42. 了解typename的双重意义

  • 声明template参数时,前缀关键字class和typename可互换;
  • 使用关键字typename标志嵌套从属类型名称;但不得在基类列或成员初始化列表内以它作为基类修饰符。

43. 学习处理模板化基类内的名称

因为基类模板有可能有特化版本,不支持某个操作。所以派生类中编译器不进入基类作用域内查找。

解决方案有三种:

  • 在派生类模板内通过"this->"指涉基类模板的成员名称;
  • 使用using声明式;
  • 使用template Base::修饰。