[C++ Series] Essential C++

作者 Shilei Tian 日期 2016-06-27
C++
[C++ Series] Essential C++

面向过程的编程风格

  1. 如果要删除数组中的所有对象(使用 new 声明的),必须在数组指针和 delete 表达式之间,加上一个空的下表运算符:delete [] pia;注意,无需检验 pi 是否为零,编译器会自动进行这项检查
  2. C++ 不允许我们改变 reference 所代表的对象,它们必须从一而终。
  3. pointer 和 reference 参数之间更重要的差异是,pointer 可能(也可能不)指向某个实际对象,但是 reference 必定会代表某个对象。
  4. 将函数声明为 inline,表示要求编译器在每个函数调用点上,将函数的内容展开。一般而言,最适合声明为 inline 的函数对象是那种体积小,常被调用,所从事的计算并不复杂的函数。inline 函数的定义常常被放在头文件中,由于编译器必须在它被调用的时候加以展开,所以这个时候其定义必须是有效的。
  5. 编译器无法根据函数返回类型来区分两个具有相同名称的函数。
  6. 每个重载函数的参数列表必须和其他重载函数不同,编译器无法根据函数返回类型来区分两个具有相同名称的函数。
  7. function template 同时也可以是重载函数。
  8. 函数的定义只能有一份,我们不把函数的定义放入头文件,因为同一个程序的多个代码文件可能都会包含这个头文件。“只定义一份”的规则有个例外:inline 函数的定义。我们必须将 inline 函数的定义放在头文件中,而不是把它放在不同的程序代码文件中。
  9. constant object 就和 inline 函数一样,是“一次定义”规则下的例外。constant object 的定义只要一出文件之外便不可见。这意味着我们可以在多个程序代码文件中加以定义,不会导致任何错误。
  10. 如果此文件被认定为标准的或项目专属的头文件,我们便以尖括号将文件名扩住;编译器搜索次文件时,会现在某些默认的磁盘目录中寻找。如果文件名由成对的双引号扩住,此文件便被认为是一个用户提供的头尖尖;搜索此文件时,会由要包含此文件的文件所在的磁盘目录开始找起。

范型编程风格

  1. 任何一个 key 值在 map 内最多只会有一份。如果我们需要存储多份相同的 key 值,就必须使用 multimap。
  2. function object 实现了我们原本可能以独立函数加以定义的事物,主要是为了效率。我们可以令 call 运算符成为 inline,从而消除“通过函数指针来调用函数”时需要付出的额外代价。
  3. 如果我们想知道某值是否存在于某个集合内,就可以使用 set。在图的遍历算法中,我们可以使用 set 存储每个遍历过的节点。在移至下一个节点前,我们可以先查询 set,判断该节点是否已经遍历过。
  4. 标准库提供了三个所谓的 insertion adapter,这些 adapter 让我们得以避免使用容器的 assignment 运算符。

    1. back_inserter() 会以容器的 push_back() 函数取代 assignment 运算符。对 vector 来说,这是比较合适的 inserter。举个例子,如果我们按照下面的代码进行 copy 操作,那么会发生 Runtime Error (Segment Error)
      vector<int> vec = {1, 3, 5, 7, 9, 2, 4, 6, 8, 10};
      vector<int> nvec;
      copy(vec.begin(), vec.end(), nvec);

    理由很简单,nvec 的容量为 0,显然不能把值复制进来。但是如果使用 back_inserter() 操作的话,就没有问题。

    vector<int> vec = {1, 3, 5, 7, 9, 2, 4, 6, 8, 10};
    vector<int> nvec;
    copy(vec.begin(), vec.end(), back_inserter(nvec));
    1. inserter() 会以容器的 insert() 函数取代 assignment 运算符。
    2. front_inserter() 会以容器的 push_front() 函数取代 assignment 运算符。
      然而这些 adapter 并不能用在 array 上,因为 array 并不支持元素插入操作。
  5. 代码 Triangular t3 = 8;,这究竟是调用 constructor 还是 assignment operator 呢?答案是 constructor!但是代码 Triangular t5(); 无法成功定义一个 Triangular 对象。这个代码将 t5 定义为一个函数,其参数列表是空的,返回 Triangular 对象。为什么会这样解释呢?因为 C++ 必需兼容于 C。对 C 而言,t5 之后带有小括号,会使 t5 被视为函数。正确(符合我们意图(的 t5 声明方式应该是 Triangular t5;
  6. 最好不要相信“用户永远都是对的”这句话。
  7. 对于 default constructor 而言,如果我们按照下面这种方式进行定义

    class Triangular {
    public:
    // 也是 default constructor
    Triangular(int len = 1, int bp = 1);
    }

    由于我们为两个整数提供了默认值,所以这两个 default constructor 同时支持原本的三个 constructor(就是分别应对每种情况重载的 constructor)。

  8. 对于 destructor 而言,由于其参数列表是空的,所以也绝不可能被重载。
  9. 当我们设计 class 时,必须问问自己,在此 class 之上进行“成员逐一初始化”的行为模式是否适合?如果答案肯定,我们就不需要另外提供 copy constructor。但如果答案是否定的,我们就必需另行定义 copy constructor,并在其中编写正确的初始化操作。
  10. 如果有必要为某个 class 编写 copy constructor,那么同样有必要为它编写 copy assignment operator。
  11. class 设计者必须在 member function 身上标注 const, 以此告诉编译器:这个 member function 不会更改 class object 的内容。
  12. 凡是再 class 主体以外定义者,如果它是一个 const member function,那就必须同时在声明与定义中都指定 const。
  13. 没有一个 const reference 参数可以调用公开接口中的 non-const 成分(但目前许多编译器对此情况都只给警告)。
  14. 欲以一个对象复制出另一个对象,先确定两个对象是否相同是个好习惯。
  15. 静态(static)data member 用来表示唯一的、可共享的 member。它可以在同一类的所有对象中被访问。对 class 而言,static data member 只有唯一的一份实体,因此我们必须在程序代码文件中提供其清楚地定义。这种定义看起来很像全局对象(global object)的定义。唯一的差别是,其名称必须附上 class scope 运算符,如:

    // 以下放在程序代码文件中,例如 Triangular.cpp
    vector<int> Triangular::_elems;
  16. member function 只有在“不访问任何 non-static member”的条件下才能够被声明为 static,声明方式是在声明之前加上关键字 static。

  17. 当我们在 class 主体外部进行 member function 的定义时,无需重复加上关键字 static(这个规则也适用于 static data member)。
  18. Non-member 运算符的参数列表中,一定会比相应的 member 运算符多出一个参数,也就是 this 指针。对 member 运算符而言,这个 this 指针隐式代表左操作数。
  19. 后置版自增运算符的参数列表原本也应该是空的,然而重载规则要求,参数列表必须独一无二。因此,C++ 语言想出了一个变通办法,要求后置版本得有一个 int 参数。令人生疑的是,对后置版而言,其唯一的那个 int 参数从何发生,又到哪里去了呢?事实的真相是,编译器会自动为后置版产生一个 int 参数(其值必为 0)。用户不必为此烦恼。

基于对象的编程风格

  1. 任何 class 都可以将其他 function 或 class 指定为朋友(friend)。而所谓 friend,具备了与 class member function 相同的访问权限,可以访问 class 的 private member。
  2. 只要在某个函数的原型(prototype)前加上关键字 friend,就可以将它声明为某个 class 的 friend。这份声明可以出现在 class 定义的任意位置上,不受 private 或 public 的影响。如果你希望将数个重载函数都声明为某个 class 的 friend,你必须明确地为每个函数加上关键字 friend。
  3. 友谊的建立,通常是为了效率考虑。(个人理解:如果 class A 想要访问 class B 的某个 private member,如果不建立友谊,那么需要在 class B 中提供一个函数来返回该 private member。如果这种访问次数不多,那么对于效率造成的影响可以忽略;但是如果这种访问非常频繁,那么由于反复调用函数造成的性能开销将不能被忽视,所以需要建立友谊来提高效率。
  4. 默认情况下,当我们将某个 class object 赋值给另一个,class data member 会被依次复制过去,这被称为 default memberwise copy(默认的成员逐一复制操作)。
  5. 为什么不把 output 运算符设计为一个 member function 呢?因为作为一个 member function,其左操作数必须是隶属于同一个 class 的对象。如果 output 运算符被设计为 class member function,那么 object 就必须被放在 output 运算符的左侧,如 tri << cout << '\n;'

面向对象编程风格

  1. 面向对象编程概念的两项最主要特质是:继承(inheritance)和多态(polymorphism)。
  2. 多态和动态绑定的特性,只有在使用 pointer 或 reference 时才能发挥。
  3. 默认情况下,member function 的解析(resolution)皆在编译时静态地进行。若要令其在运行时动态进行,我们就得在它的声明前加上关键字 virtual。
  4. 当程序定义出一个派生对象,基类和派生类的 constructor 都会执行。(当派生类被销毁,基类和派生类的 destructor 也都会被执行[但次序颠倒])。
  5. 被声明为 protected 的所有成员都可以被派生类直接访问,除此(派生类)之外,都不得直接访问 protected 成员。
  6. 使用派生类时不必刻意区分“继承而来的成员”和“自身定义的成员”,两者的使用完全透明。
  7. static member function 无法被声明为虚拟函数。
  8. 任何类如果声明有一个(或多个)纯虚函数,那么由于其接口的不完整性(纯虚函数没有函数定义,是谓不完整),程序无法为它产生任何对象。这种类只能作为派生类的子对象(subobject)使用,而且前提是这些派生类必须为所有需函数提供确切的定义。
  9. 根据一般规则,凡基类定义有一个(或多个)需函数,应该要将其 destructor 声明为 virtual。但是并不建议在基类中将其 destructor 声明为 pure virtual(纯虚函数),最好提供空白定义。
  10. 派生类的虚函数必须精确温和基类中的函数原型。在类之外对虚函数进行定义时,不必指明关键字 virtual。
  11. 逐步测试自己的实现代码,比整个程序都完成后才测试好多了。
  12. 派生类对象之中其实含有多个子对象:由基类 constructor 初始化的“基类子对象”,以及由派生类(自己)constructor 所初始化的“派生类子对象”。
  13. 如果我们继承了纯虚函数(pure virtual function),那么这个派生类也会被视为抽象类,也就无法为它定义任何对象。
  14. 如果我们决定覆盖基类所提供的虚函数,那么派生类提供的新定义,其函数原型必须完全符合基类所声明的函数原型,包括:参数列表、返回类型、常量性(const-ness)。但是这个规则有个例外——当基类的虚函数返回某个基类形式(通常是 pointer 或 reference)时,派生类中的同名函数便可以返回该基类所派生出来的类型。
  15. 当我们在派生类中,为了覆盖基类的某个虚函数,而进行声明操作时,不一定得加上关键字 virtual。
  16. 有两种情况,虚函数机制不会出现预期行为:(1) 基类的 constructor 和 destructor 内,(2) 当我们使用的是基类的对象,而非基类对象的 pointer 或 reference 时。在基类的 constructor 中,派生类的虚函数绝对不会被调用。
  17. 在 C++ 中,唯有用基类的 pointer 和 reference 才能支持面向对象编程的概念。当我们为基类声明一个实际对象,同时也就分配出了足以容纳该实际对象的内存空间。如果稍后传入的却是个派生类对象,那就没有足够的内存放置派生类中的各个 data member。
  18. static_cast 其实有潜在危险,因为编译器无法确认我们所进行的转换操作是否完全正确。dynamic_cast 运算符就不同,它提供有条件的转换,它也是一个 RTTI 运算符,会进行运行时检验操作。

以 template 进行编程

TBD;

异常处理

  1. 异常处理机制有两个主要成分:异常的鉴定与发出,以及异常的处理方式。
  2. 所谓异常(exception)是某种对象。最简单的异常对象可以设计为整数或字符串:throw 42;throw "panic: no buffer!";
  3. catch 子句由三部分组成:关键字 catch、小括号内的一个类型或对象、大括号内的一组语句(用以处理异常)。
  4. 重新抛出时,只需要写下关键字 throw 即可。它只能出现于 catch 子句中。它会将捕获的异常对象再一次抛出,并由另一个类型吻合的 catch 子句接手处理。
  5. 如果我们想要捕获任何类型的异常,可以使用一网打尽(catch-all)的方式。只需在异常声明部分指定省略号(…)即可,像这样:

    // 捕获任何类型的异常
    catch (...) {
    log_message("Exception of unknown type");
    // 清理(clean up)然后退出……
    }
  6. 当程序抛出异常时,异常处理机制开始查看,异常由何处抛出,并判断是否位于 try 块内?如果是,就检验相应的 catch 子句,看它是否具备处理此异常的能力。如果有,这个异常便被处理,而程序也就继续正常执行下去。如果“函数调用链”不断地被解开,一直回到了 main() 还是找不到合适的 catch 子句,会发生什么事?C++ 规定,每个异常都应该被处理,因此,如果在 main() 内还是找不到合适的处理程序,便调用标准库提供的 terminate()——其默认行为是中断整个程序的执行。

  7. 初学者常犯的错误是,将 C++ 异常和 segment fault 或是 bus error 这类硬件异常混淆在一起。面对任何一个被抛出的 C++ 异常,你都可以在程序某处找到一个相应的 throw 表达式。(有些深藏在标准库中。)
  8. 在异常处理机制终结某个函数之前,C++ 保证,函数中的所有局部对象的 destructor 都会被调用。
  9. 如果 new 表达式无法从程序的空闲空间(free space)分配到足够的内存,它会抛出 bad_alloc 异常对象。如果要抑制不让 bad_alloc 异常被抛出,我们可以这么写:ptext = new (nothrow) vector<string>;。这么一来如果 new 操作失败,会返回 0。任何人使用 ptext 之前都应该先检验它是否为 0。
  10. 标准库定义了一套异常类体系(exception class hierachy),其根部是名为 exception 的抽象基类。exception 声明有一个 what()虚函数,会返回一个 const char *,用以表示被抛出异常的文字描述。
  11. 将自定义的异常类融入标准的 exception 类体系的好处是,它可以被任何“打算捕获抽象基类 exception”的程序代码捕获。
  12. ostringstream 提供“内存内的输出操作”,输出到一个 string 对象上。当我们需要将多笔不同类型的数据格式化为字符串时,它尤其有用。它能自动将数值对象转换为相应的字符串,我们不必考虑存储空间、转换算法等问题。ostringstream 所提供的一个 member function str(),会将“与 ostringstream 对象相呼应”的那个 string 对象返回。
  13. string class 的转换函数 c_str() 会返回我们所要的 const char *
  14. iostream 库也对应提供了 istringstream class,如果我们需要将非字符串数据(例如,整数值或内存地址)的字符串表示转换为实际类型,istringstream 可派上用场。