0%

Effective-C++ Reading Notes

第一章 让自己习惯C++

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

  最初,C++仅是C加上了一些面象对象的特性。经历多年的发展,随着大量特性与功能的加入,使得C++成为了一个无可匹敌的工具。但也是这些特性与功能,会让我们困惑于,我们应如何使用它们,该如何理解这样一个复杂的语言呢?
  最简单的方法就是将C++视为一个相关语言的组成的一个联邦,对于C++来说,它大致由四个部分组成:

  • C语言
    • 作为C++的基础。C++中的区块、语句、预处理器、内置数据类型、数组、指针等皆是来自于C。部分特性展示:
      1
      2
      3
      #define k 45
      double blance[5] = {1,3,4,5,6};
      int *p;
  • Object-Oriented C++
    • 在初始C with Classes,缺乏真正的面向对象思想。因此加入了:class(包括构造与析构)、封装(encapulation)、继承(inheritance)、多态(polymorphism)…等,从而符合面向对象设计的古典守则。部分特性展示:
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      // 类
      class test {
      public:
      test();
      ~test();
      }

      // 继承
      class demo : public test {
      // 封装
      private:
      int k
      }
  • Template C++
    • 这是C++ 泛型编程部分,它们威力强大。它们也带来了新的编程范型,也就是template metaprogramming(模板元编程)。部分特性展示:
      1
      2
      3
      4
      5
      template <typename T>
      T add (T a,T b) {return a + b;}

      template <typename T>
      class test<T> {}
  • STL
    • 这是Template 程序库,它由六大组件构成。它们有着严格的要求,与它们工作时,我们也必须遵守它们的条约。六大组件图:
      avatar
      C++语言的高效编程需要根据使用它的不同部分不断变化的。

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

  #define属于一个从C继承而来的特性,那么说明我们对它还是存在一定的需求的。但是对于现代版本的C++来说,它存在了一些我们不得不去替换它的原因。

  1. #define会盲目替换目标码
  • 可以使用一个常量来替换宏定义,这样就会避免出现多个目标码的情况。
    1
    2
    3
    #define ASPECT_RATIO 2
    替换为
    const double AspectRatio = 2;
  1. 常量字符串最好使用std::string 替换 char *
  • char * 仅是声明了一个char指针指向字符串,C++中提供了专门处理字符串的类型即std::string。非必要则默认使用std::string。
    1
    2
    3
    const char* const a = "abc";
    //最好替换为
    const std::string test("abc");
  1. #define 不提供任何的封装性
  • 由于#define不重视作用域,可以被随意调用,因此它不具备封装性。我们可以使用static const为class创建专属变量,限制作用域实现封装。
    1
    2
    3
    4
    5
    class test {
    private:
    // static 限制作用域
    static const int demo = 5;
    };
  1. #define无法阻止reference或pointer指向某个常量
  • 可以通过enum来实现这个约束。
  1. 形似函数的宏会带来意想不到的错误
  • 使用inline替换,它遵守作用域与访问规则。

条款三:尽可能使用const

  const是一件奇妙的关键字,它给所有被修饰对象添加了一个约束,即不可被改动。编译器会严格的帮你执行这个约束直至程序结束。
  使用const修饰变量,我们常用的两种方法:Top-level const 与 Low-level const;

  • 顶层const (Top-level const)
    1
    2
    3
    int a = 1;
    // 顶层const
    int* const b = &a;
    • const所修饰的变量本身是一个常量无法修改,指的是指针。
  • 底层const(Low-level const)
    1
    2
    3
    4
    int a = 2;
    // 底层const,它存在两种写法(根据习惯)
    int const *b = &a;
    const int *b = &a;
    • const所修饰的变量所指向的对象是一个常量,指的是变量。

  根据const的性质,合理的使用const。有利于我们侦测代码中的错误,避免一些类似于”==”打成”=”的低级错误。
  使用好const也是编写高效代码的关键其原因有二:第一,它使得class接口比较容易理解,清晰的知道什么可以改动,什么不可以;第二,使得操作const对象成为可能

  在const与non-const有着同等的实现时,我们最好使用non-const版本去调用const版本。倘若我们使用const版本调用non-const版本,我们岂不是违背了const的性质。当然有人说,可以使用转型不就可以了。但是转型之中又存在了安全性与破坏类型系统的风险,因此,使用non-const版本去调用const版本就成了优选。这样还成功避免了代码重复。

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

  读取未初始化值将会导致某些意想不到的行为,以及之后一些痛苦的调试过程。对于内置类型我们只能进行手工的初始化,对此之外的初始化操作全都落到了构造函数的身上。
  对于构造函数初始化,我们常用的两种方式:赋值初始化、初始化列表。

  • 赋值初始化
    • 一个伪初始化,可能创建临时对象,并调用operator=();
      1
      2
      3
      4
      5
      6
      7
      8
      9
      class test {
      public:
      test(int num);
      private:
      int len;
      };
      test::test(int num) {
      len = num;
      }
  • 成员列表初始化
    • 单纯的初始化,编译器会一一操作初始化列表,在任何显式用户代码之前。
      1
      2
      3
      4
      5
      6
      7
      class test {
      public:
      test(int num);
      private:
      int len;
      };
      test::test():len(2) {}

  对于成员列表初始化操作,对于成员排列顺序应和它们在class中的声明次序相同。当然,如果忘记了排序,编译器也会对列表进行一个重新排序。

  为了免除”跨编译单元的初始化次序”问题,我们需要使用local static对象替换non-local static对象。其原因是:C++保证函数中local static对象会在”该函数被调用期间”首次遇上该对象的定义式时被初始化。

第二章 构造、析构、赋值运算

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

  当我们创建了一个空类时,它真的是空类?当我们在编译运行时,编译器会为我们创建一个copy函数、一个assignment操作符、一个析构函数还有一个默认构造函数。这些类型都是public类型且它们都是内联(inline)里面。当然,仅在它们被需要的时候,都会被创建出来。这样就像如下的代码:

1
2
3
4
5
6
7
8
class Empty {
public:
Empty() {..} // default构造函数
Empty(const Empty& rhs) {...} //copy构造函数
~Empty() {...} // 析构函数

Empty& operator=(const Empty& rhs) {...} //copy assignment操作符
};

  这些默认函数都提供了哪些功能呢?接下来我们将对它们进行一一分析
  首先对于copy构造与assignment操作符的作用仅是把每一个non-static(class 中的成员)成员变量拷贝到目标对象。
  对于析构函数,它们仅仅是将对象释放掉。由于是编译器提供,所以它的默认属性是virtualness,在多重继承中这可会导致内存泄漏之类的麻烦问题
  默认的构造函数被构造出来,仅仅是为了让满足编译器需要。它们并不会被直接合成出来,只有被需要时都会被合成。它也不会显式设定class的init值。

avatar

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

  在一个系统中,若是每个数据都是独有的。那么对于编译器的copy构造与copy assignment操作符,就应该明确拒绝。
  但是如何拒绝编译器提供的版本呢?大师提供了一个思路,那么就是在类的private中将它们声明出来,以此来明确拒绝编译器提供的版本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>
using namespace std;

class demo {
};

int main()
{
demo obj1; // 使用default constructor
demo obj2(obj1); // 企图拷贝obj1 不可以通过编译
return 0;

}

// 将其替换成,避免使用默认版本
class demo {
private:
demo(const demo&); // 仅声明
demo& operator= (const demo&);
};

  但是对于class 的member function 与友元函数,依旧可以调用private函数。所以最好的办法,使用继承的办法。将copy构造与copy assignment操作符写入基类,用以防止子类调用它们。*

条款七: 为多态基类声明virtual 析构函数

  在条款五中,我们提及了virtualness的虚拟函数会导致灾难的。这节我们将深入理解这个背后的原因。
  当deirved class经由一个base class指针被删除,而被base class带着一个non-virtual析构函数,其结果未有定义——实际执行时通常发生的是对象的derived成分没被销毁。
  如果派生类中含有基类的成员,但是调用析构函数后。诡异的事情发生了,派生类中的基类对象被销毁了,但是对于派生类对象没有被销毁。这就产生了局布销毁的对象,这也是导致资源泄漏、败坏数据结构的源头。给base class 析构加上虚函数,在delete父类指针时,会先调用子类的析构函数,再调用父类析构函数。
  当然,对析构使用虚函数仅在多重继承使用。如果是在单继承,随意的使用虚函数会额外的增加编译器的负担。
avatar

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

  如果我们在析构中捕获异常,如果程序正常结束,一切OK。但是如果调用导致异常,析构将会传播这个异常,将程序带向一个不归路。对于这个问题,我们提供了两个办法来解决。
  如果出现异常就通过abort()结束程序。强制结束,也就避免了异常从析构传播出去,提前阻止的不明确行为的发生。

1
2
3
test::~test() {
std::abort();
}

  一般来说,将异常吞掉会是一个馊主意。但是这种行为可比草率结束程序或发生不明确行为带来的风险好。
  对于异常处理,我们最好是提供一个普通函数(非在析构函数中)来执行这个操作。

条款九:绝不在构造与析构过程中调用virtual 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Transation {
public:
Transation();
virtual void logTransaction() const = 0;
};

Transation::Transation() {
...
logTransaction();
}

class BuyTransaction: public Transation {
public:
virtual void logTransaction() const;
...
};

class SellTransaction: public Transation {
public:
virtual void logTransaction() const;
}

BuyTransaction b;

  对于b来说,作为子类初始化时。一定会对基类成员进行先初始化,但是在基类构造函数最后一行调用了virtual logTransaction,这也是引发错误的点。那么这里就导致b使用了错误版本的logTransaction()。这直接导致的结果是,virtual 将不再是virtual 函数,这也是一条通往不归路的直达列车。
  但是如何确保derived class 对象不会调用错误的函数版本? 确保构造与析构都没有调用virtual 函数,而它们调用的所有函数也都服从这个约束。

条款十:今operator= 返回一个reference to * this

  为了实现连续赋值,赋值操作符必须返回一个reference指向操作符的左侧实参;其标准的赋值形式,同样也适用于所有的赋值相关的运算,代码如下:

1
2
3
4
Widget& operator=(const Widget& rhs) {
...
return *this;
}

条款十一:在operator= 中处理”自我赋值”

  自我赋值的存在,会在你不注意时引诱你进入一个”在停止使用资源之前意外释放了它”的陷阱。我们以下面代码为例,进行一个理解:

1
2
3
4
5
6
7
8
9
10
11
12
class Bitmap {...};
class Widget {
...
private:
Bitmap *pb;
};

Widget& Widget::operator=(const Widget& rhs) {
delete pb;
pb = new Bitmap(*rhs.pb);
return *this;
}

   * this与rhd可能是同一个对象。那么不仅删除了当前对象的bitmap,也删除了rhs的bitmap。那么最后 * this 发现自己指向一个已被删除的对象。
  为了阻止这种错误,我们可以在这个最前面添加一个”证同测试”,达到检测的目的。具体如这条语句所示:if (this == &rhs) return * this;。由于证同测试使用次数较低,因此,我们使用copy and swap技术。其实现手法如下:

1
2
3
4
5
6
7
8
9
10
class Widget {
...
void swap(Widget& rhs); // 交换*this与rhs数据
};

Widget& Widget::operator=(const Widget& rhs) {
Widget temp(rhs);
swap(temp);
return *this;
}

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

  如果为class添加一个成员变量,你必须同时修改copying函数,同时也需要修改class中的构造函数。
  我们在编写一个copying函数,我们就需要确保:1、复制所有local 成员变量,2、调用所有base classes内的适当的copying 函数。由于大量的copying函数几近相似,有人为了避免代码的重复,就会在一个copying 函数中调用另一个copying函数,这样可能还是无法达到你的目标。对于copy assignment操作符调用copying 函数以及它的反调用,都是不合理且没有意义的。这里最好的办法就是,新建一个成员函数供两者调用,这样就可以安全消除代码的重复问题了。