【转】读书笔记——重述《Effective C++》

原链接:https://normaluhr.github.io/2020/12/31/Effective-C++/

为什么写这篇BLOG

我动手写这篇博文——或者说总结——的想法已经很久了,《Effective C++》这本书的作者和译者都是C++大师,这篇著作有也已享誉全球很多年。但是书无完书、人无完人,这本书也因为这样或那样的原因(我更愿称之为引起我不适的问题)让我有必要为此写一篇总结,使得这篇总结更像《Effective C++》对应的工具书版本,帮助我在未来想要回顾某一条款的内容时,最大限度地节约我的时间。如果没有读过这本书的读者因为翻译或者是其他问题没有耐心再读下去的时候,不妨也看看这篇文章,我会从一个中国人的逻辑角度,使用大陆人的语言习惯(原译者是中国台湾同胞),尽可能直接并清楚得涵盖每一个条款最重要的知识点,让你在最短的时间抓住核心,再逐个击破各个问题。我并不觉得这篇文章可以就此替代《Effective C++》,其实是远远不够,我并不会在文章中涵盖太多的代码和细节,如果你想要探究每一个细节,请拿起原著,乖乖把每一页看完。

首先,我想说一说这本书让我不适的地方:

  • 内容有点老旧。这本书没有涵盖C++11,可以说,有了更高版本的编译器,许多条款使用C++98解决问题的思路和方式都显得有些冗余了,我会在每一条款的总结中直接指出在更高版本C++下的解决方案,个人看来,书中提出的解决问题的方法就可以淘汰了,这些地方包括但不限于final, override, shared_ptr, = delete
  • 翻译僵硬。这并不能怪侯捷,因为面对一个大师的作品,我们肯定要在保留语言的原汁原味和尽量符合各国读者的语言风格面前摇摆取舍,但这也造成了相当英文的表达出现在了文中,比如“在这个故事结束之前”,“那就进入某某某变奏曲了”,让不太熟悉英文的读者感到莫名其妙——”变奏曲在哪?“。说实在话,就是知道英文原文的我读起这样的翻译也觉得怪怪的。因此在我的总结中,面对各种因果关系我会把舌头捋直了说,毕竟我不是大师,只注重效率就可以了。
  • 作者的行文之风让读者必须以读一本小说的心态去拜读这部著作。在了解每一个条款时,作者精心准备了各种玩笑、名人名言、典故以及例子,尽量让你感觉不到教科书般的迂腐之气,也用了俏皮的语言使授课不那么僵硬(尽管上述翻译还是让它僵硬了起来)。但对于第二甚至是第三次读这本书的我来说,我更希望这本书像一本工具书。例如某条款解决了一个问题,在第一遍读的时候我重点去体会解决问题的方法是什么,第二遍我可能更想知道这种问题在什么情况下可能会发生——什么时候去用,这是我最关心的。不幸的是,出现这一问题的三个场景可能分布在这个条款的角角落落,我必须重新去读一遍那些已经不好笑的笑话、已经不经典的典故,才能把他们整理好。所以,这篇博文替我把上述我在翻阅时更care的内容总结起来,争取两分钟可以把一个条款的纲要回忆起来,这便是这个博文的目的。

最后,再次向Meyers和侯捷大师致敬。

二、构造、析构和赋值运算

构造和析构一方面是对象的诞生和终结;另一方面,它们也意味着资源的开辟和归还。这些操作犯错误会导致深远的后果——你需要产生和销毁的每一个对象都面临着风险。这些函数形成了一个自定义类的脊柱,所以如何确保这些函数的行为正确是“生死攸关”的大事。

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

编译器会主动为你编写的任何类声明一个拷贝构造函数、拷贝复制操作符和一个析构函数,同时如果你没有生命任何构造函数,编译器也会为你声明一个default版本的拷贝构造函数,这些函数都是publicinline的。注意,上边说的是声明哦,只有当这些函数有调用需求的时候,编译器才会帮你去实现它们。但是编译器替你实现的函数可能在类内引用、类内指针、有const成员以及类型有虚属性的情形下会出问题。

  • 对于拷贝构造函数,你要考虑到类内成员有没有深拷贝的需求,如果有的话就需要自己编写拷贝构造函数/操作符,而不是把这件事情交给编译器来做。
  • 对于拷贝构造函数,如果类内有引用成员或const成员,你需要自己定义拷贝行为,因为编译器替你实现的拷贝行为在上述两个场景很有可能是有问题的。
  • 对于析构函数,如果该类有多态需求,请主动将析构函数声明为virtual,具体请看条款07 。

除了这些特殊的场景以外,如果不是及其简单的类型,请自己编写好构造、析构、拷贝构造和赋值操作符、移动构造和赋值操作符(C++11、如有必要)这六个函数。

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

承接上一条款,如果你的类型在语义或功能上需要明确禁止某些函数的调用行为,比如禁止拷贝行为,那么你就应该禁止编译器去自动生成它。作者在这里给出了两种方案来实现这一目标:

  • 将被禁止生成的函数声明为private并省略实现,这样可以禁止来自类外的调用。但是如果类内不小心调用了(成员函数、友元),那么会得到一个链接错误。
  • 将上述的可能的链接错误转移到编译期间。设计一不可拷贝的工具基类,将真正不可拷贝的基类私有继承该基类型即可,但是这样的做法过于复杂,对于已经有继承关系的类型会引入多继承,同时让代码晦涩难懂。

但是有了C++11,我们可以直接使用= delete来声明拷贝构造函数,显示禁止编译器生成该函数。

条款07:为多态基类声明virtual

该条款的核心内容为:带有多态性质的基类必须将析构函数声明为虚函数,防止指向子类的基类指针在被释放时只局部销毁了该对象。如果一个类有多态的内涵,那么几乎不可避免的会有基类的指针(或引用)指向子类对象,因为非虚函数没有动态类型,所以如果基类的析构函数不是虚函数,那么在基类指针析构时会直接调用基类的析构函数,造成子类对象仅仅析构了基类的那一部分,有内存泄漏的风险。除此之外,还需注意:

  • 需要注意的是,普通的基类无需也不应该有虚析构函数,因为虚函数无论在时间还是空间上都会有代价,详情《More Effective C++》条款24。
  • 如果一个类型没有被设计成基类,又有被误继承的风险,请在类中声明为final(C++ 11),这样禁止派生可以防止误继承造成上述问题。
  • 编译器自动生成的析构函数时非虚的,所以多态基类必须将析构函数显示声明为virtual

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

析构函数一般情况下不应抛出异常,因为很大可能发生各种未定义的问题,包括但不限于内存泄露、程序异常崩溃、所有权被锁死等。

一个直观的解释:析构函数是一个对象生存期的最后一刻,负责许多重要的工作,如线程,连接和内存等各种资源所有权的归还。如果析构函数执行期间某个时刻抛出了异常,就说明抛出异常后的代码无法再继续执行,这是一个非常危险的举动——因为析构函数往往是为类对象兜底的,甚至是在该对象其他地方出现任何异常的时候,析构函数也有可能会被调用来给程序擦屁股。在上述场景中,如果在一个异常环境中执行的析构函数又抛出了异常,很有可能会让程序直接崩溃,这是每一个程序员都不想看到的。

话说回来,如果某些操作真的很容易抛出异常,如资源的归还等,并且你又不想把异常吞掉,那么就请把这些操作移到析构函数之外,提供一个普通函数做类似的清理工作,在析构函数中只负责记录,我们需要时刻保证析构函数能够执行到底。

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

结论正如该条款的名字:请不要在构造函数和析构函数中调用virtual函数。

在多态环境中,我们需要重新理解构造函数和析构函数的意义,这两个函数在执行过程中,涉及到了对象类型从基类到子类,再从子类到基类的转变。

一个子类对象开始创建时,首先调用的是基类的构造函数,在调用子类构造函数之前,该对象将一直保持着“基类对象”的身份而存在,自然在基类的构造函数中调用的虚函数——将会是基类的虚函数版本,在子类的构造函数中,原先的基类对象变成了子类对象,这时子类构造函数里调用的是子类的虚函数版本。这是一件有意思的事情,这说明在构造函数中虚函数并不是虚函数,在不同的构造函数中,调用的虚函数版本并不同,因为随着不同层级的构造函数调用时,对象的类型在实时变化。那么相似的,析构函数在调用的过程中,子类对象的类型从子类退化到基类。

因此,如果你指望在基类的构造函数中调用子类的虚函数,那就趁早打消这个想法好了。但很遗憾的是,你可能并没有意识到自己做出了这样的设计,例如将构造函数的主要工作抽象成一个init()函数以防止不同构造函数的代码重复是一个很常见的做法,但是在init()函数中是否调用了虚函数,就要好好注意一下了,同样的情况在析构函数中也是一样。

条款10:令operator =返回一个reference to *this

简单来说:这样做可以让你的赋值操作符实现“连等”的效果:

x = y = z = 10;

在设计接口时一个重要的原则是,让自己的接口和内置类型相同功能的接口尽可能相似,所以如果没有特殊情况,就请让你的赋值操作符的返回类型为ObjectClass&类型并在代码中返回*this吧。

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

自我赋值指的是将自己赋给自己。这是一种看似愚蠢无用但却在代码中出现次数比任何人想象的多得多的操作,这种操作常常需要假借指针来实现:

*pa = *pb;                  //pa和pb指向同一对象,便是自我赋值。
arr[i] = arr[j];        //i和j相等,便是自我赋值

那么对于管理一定资源的对象重载的operator = 中,一定要对是不是自我赋值格外小心并且增加预判,因为无论是深拷贝还是资源所有权的转移,原先的内存或所有权一定会被清空才能被赋值,如果不加处理,这套逻辑被用在自我赋值上会发生——先把自己的资源给释放掉了,然后又把以释放掉的资源赋给了自己——出错了。

第一种做法是在赋值前增加预判,但是这种做法没有异常安全性,试想如果在删除掉原指针指向的内存后,在赋值之前任何一处跑出了异常,那么原指针就指向了一块已经被删除的内存。

SomeClass& SomeClass::operator=(const SomeClass& rhs) {
  if (this == &rhs) return *this;
  
  delete ptr;   
  ptr = new DataBlock(*rhs.ptr);  //如果此处抛出异常,ptr将指向一块已经被删除的内存。
  return *this;
}

如果我们把异常安全性也考虑在内,那么我们就会得到如下方法,令人欣慰的是这个方法也解决了自我赋值的问题。

SomeClass& SomeClass::operator=(const SomeClass& rhs) {
  DataBlock* pOrg = ptr;
  ptr = new DataBlock(*rhs.ptr);  //如果此处抛出异常,ptr仍然指向之前的内存。
  delete pOrg;
  return *this;
}

另一个使用copy and swap技术的替代方案将在条款29中作出详细解释。

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

所谓“每一个成分”,作者在这里其实想要提醒大家两点:

  • 当你给类多加了成员变量时,请不要忘记在拷贝构造函数和赋值操作符中对新加的成员变量进行处理。如果你忘记处理,编译器也不会报错。
  • 如果你的类有继承,那么在你为子类编写拷贝构造函数时一定要格外小心复制基类的每一个成分,这些成分往往是private的,所以你无法访问它们,你应该让子类使用子类的拷贝构造函数去调用相应基类的拷贝构造函数:
//在成员初始化列表显示调用基类的拷贝构造函数
ChildClass::ChildClass(const ChildClass& rhs) : BaseClass(rhs) {        
    // ...
}

除此之外,拷贝构造函数和拷贝赋值操作符,他们两个中任意一个不要去调用另一个,这虽然看上去是一个避免代码重复好方法,但是是荒谬的。其根本原因在于拷贝构造函数在构造一个对象——这个对象在调用之前并不存在;而赋值操作符在改变一个对象——这个对象是已经构造好了的。因此前者调用后者是在给一个还未构造好的对象赋值;而后者调用前者就像是在构造一个已经存在了的对象。不要这么做!

三、资源管理

内存只是众多被管理的资源之一,对待其他常见的资源如互斥锁、文件描述器、数据库连接等时,我们要遵循同一原则——如果你不再使用它们,确保将他们还给系统。本章正是在考虑异常、函数内多重回传路径、程序员不当维护软件的背景下尝试和资源管理打交道。本章除了介绍基于对象的资源管理办法,也专门对内存管理提出了更深层次的建议。

条款13:以对象管理资源

本条款的核心观点在于:以面向流程的方式管理资源(的获取和释放),总是会在各种意外出现时,丢失对资源的控制权并造成资源泄露。以面向过程的方式管理资源意味着,资源的获取和释放都分别被封装在函数中。这种管理方式意味着资源的索取者肩负着释放它的责任,但此时我们就要考虑一下以下几个问题:调用者是否总是会记得释放呢?调用者是否有能力保证合理地释放资源呢?不给调用者过多义务的设计才是一个良好的设计。

首先我们看一下哪些问题会让调用者释放资源的计划付诸东流:

  • 一句简单的delete语句并不会一定执行,例如一个过早的return语句或是在delete语句之前某个语句抛出了异常。
  • 谨慎的编码可能能在这一时刻保证程序不犯错误,但无法保证软件接受维护时,其他人在delete语句之前加入的return语句或异常重复第一条错误。

为了保证资源的获取和释放一定会合理执行,我们把获取资源和释放资源的任务封装在一个对象中。当我们构造这个对象时资源自动获取,当我们不需要资源时,我们让对象析构。这便是“Resource Acquisition Is Initialization; RAII”的想法,因为我们总是在获得一笔资源后于同一语句内初始化某个管理对象。无论控制流如何离开区块,一旦对象被销毁(比如离开对象的作用域)其析构函数会自动被调用。

具体实践请参考C++11的shared_ptr

四、设计与声明

接口的设计与声明是一门学位,注意我说的是接口的设计——接口长什么样子,而不是接口内部是怎么实现的。接口参数的类型选择有什么学问?接口的返回类型又有什么要注意的地方?接口应该放在类内部还是类的外部?这些问题的答案都将对接口的稳定性、功能的正确性产生深远的影响。在这一章,我们将逐一讨论这些问题。

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

本条款告教你如何帮助你的客户在使用你的接口时避免他们犯错误

在设计接口时,我们常常会错误地假设,接口的调用者拥有某些必要的知识来规避一些常识性的错误。但事实上,接口的调用者并不总是像正在设计接口的我们一样“聪明”或者知道接口实现的”内幕信息“,结果就是,我们错误的假设使接口表现得不稳定。这些不稳定因素可能是由于调用者缺乏某些先验知识,也有可能仅仅是代码上的粗心错误。接口的调用者可能是别人,也可能是未来的你。所以一个合理的接口,应该尽可能的从语法层面并在编译之时运行之前,帮助接口的调用者规避可能的风险。

  • 使用外覆类型(wrapper)提醒调用者传参错误检查,将参数的附加条件限制在类型本身

当调用者试图传入数字“13”来表达一个“月份”的时候,你可以在函数内部做运行期的检查,然后提出报警或一个异常,但这样的做法更像是一种责任转嫁——调用者只有在尝试过后才发现自己手残把“12”写成了“13”。如果在设计参数类型时就把“月份”这一类型抽象出来,比如使用enum class(强枚举类型),就能帮助客户在编译时期就发现问题,把参数的附加条件限制在类型本身,可以让接口更易用。

  • 语法层面限制调用者不能做的事

接口的调用者往往无意甚至没有意识到自己犯了个错误,所以接口的设计者必须在语法层面做出限制。一个比较常见的限制是加上const,比如在operate*的返回类型上加上const修饰,可以防止无意错误的赋值if (a * b = c)

  • 接口应表现出与内置类型的一致性

让自己的类型和内置类型的一致性,比如自定义容器的接口在命名上和STL应具备一致性,可以有效防止调用者犯错误。或者你有两个对象相乘的需求,那么你最好重载operator*而并非设计名为”multiply”的成员函数。

  • 从语法层面限制调用者必须做的事

别让接口的调用者总是记得做某些事情,接口的设计者应在假定他们总是忘记这些条条框框的前提下设计接口。比如用智能指针代替原生指针就是为调用者着想的好例子。如果一个核心方法需要在使用前后设置和恢复环境(比如获取锁和归还锁),更好的做法是将设置和恢复环境设置成纯虚函数并要求调用者继承该抽象类,强制他们去实现。在核心方法前后对设置和恢复环境的调用,则应由接口设计者操心。

当方法的调用者(我们的客户)责任越少,他们可能犯的错误也就越少。

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

本条款提醒我们设计class需要注意的细节,但并没有给每一个细节提出解决方案,只是提醒而已。每次设计class时最好在脑中过一遍以下问题:

  • 对象该如何创建销毁:包括构造函数、析构函数以及new和delete操作符的重构需求。
  • 对象的构造函数与赋值行为应有何区别:构造函数和赋值操作符的区别,重点在资源管理上。
  • 对象被拷贝时应考虑的行为:拷贝构造函数。
  • 对象的合法值是什么?最好在语法层面、至少在编译前应对用户做出监督。
  • 新的类型是否应该复合某个继承体系,这就包含虚函数的覆盖问题。
  • 新类型和已有类型之间的隐式转换问题,这意味着类型转换函数和非explicit函数之间的取舍。
  • 新类型是否需要重载操作符。
  • 什么样的接口应当暴露在外,而什么样的技术应当封装在内(public和private)
  • 新类型的效率、资源获取归还、线程安全性和异常安全性如何保证。
  • 这个类是否具备template的潜质,如果有的话,就应改为模板类。

条款20:宁以pass-by-reference-to-const替换pass-by-value

函数接口应该以const引用的形式传参,而不应该是按值传参,否则可能会有以下问题:

  • 按值传参涉及大量参数的复制,这些副本大多是没有必要的。
  • 如果拷贝构造函数设计的是深拷贝而非浅拷贝,那么拷贝的成本将远远大于拷贝某几个指针。
    对于多态而言,将父类设计成按值传参,如果传入的是子类对象,仅会对子类对象的父类部分进行拷贝,即部分拷贝,而所有属于子类的特性将被丢弃,造成不可预知的错误,同时虚函数也不会被调用。
  • 小的类型并不意味着按值传参的成本就会小。首先,类型的大小与编译器的类型和版本有很大关系,某些类型在特定编译器上编译结果会比其他编译器大得多。小的类型也无法保证在日后代码复用和重构之后,其类型始终很小。

尽管如此,面对内置类型和STL的迭代器与函数对象,我们通常还是会选择按值传参的方式设计接口。

条款21:必须返回对象时,别妄想返回其reference

这个条款的核心观点在于,不要把返回值写成引用类型,作者在条款内部详细分析了各种可能发生的错误,无论是返回一个stack对象还是heap对象,在这里不再赘述。作者最后的结论是,如果必须按值返回,那就让他去吧,多一次拷贝也是没办法的事,最多就是指望着编译器来优化。

但是对于C++11以上的编译器,我们可以采用给类型编写“转移构造函数”以及使用std::move()函数更加优雅地消除由于拷贝造成的时间和空间的浪费。

条款22:将成员变量声明为private

先说结论——请对class内所有成员变量声明为privateprivate意味着对变量的封装。但本条款提供的更有价值的信息在于不同的属性控制——public, privateprotected——代表的设计思想

简单的来说,把所有成员变量声明为private的好处有两点。首先,所有的变量都是private了,那么所有的public和protected成员都是函数了,用户在使用的时候也就无需区分,这就是语法一致性;其次,对变量的封装意味着,可以尽量减小因类型内部改变造成的类外外代码的必要改动

一旦所有变量都被封装了起来,外部无法直接获取,那么所有类的使用者(我们称为客户,客户也可能是未来的自己,也可能是别人)想利用私有变量实现自己的业务功能时,就必须通过我们留出的接口,这样的接口便充当了一层缓冲,将类型内部的升级和改动尽可能的对客户不可见——不可见就是不会产生影响,不会产生影响就不会要求客户更改类外的代码。因此,一个设计良好的类在内部产生改动后,对整个项目的影响只应是需要重新编辑而无需改动类外部的代码

我们接着说明,publicprotected属性在一定程度上是等价的。一个自定义类型被设计出来就是供客户使用的,那么客户的使用方法无非是两种——用这个类创建对象或者继承这个类以设计新的类——以下简称为第一类客户和第二类客户。那么从封装的角度来说,一个public的成员说明了类的作者决定对类的第一种客户不封装此成员,而一个protected的成员说明了类的作者对类的第二种客户不封装此成员。也就是说,当我们把类的两种客户一视同仁了以后,publicprotectedprivate三者反应的即类设计者对类成员封装特性的不同思路——对成员封装还是不封装,如果不封装是对第一类客户不封装还是对第二类客户不封装。

条款23:宁以non-member, non-friend替换member函数

我宁愿多花一些口舌在这个条款上,一方面因为它真的很重要,另一方面是因为作者并没有把这个条款说的很清楚。

在一个类里,我愿把需要直接访问private成员的public和protected成员函数称为功能颗粒度较低的函数,原因很简单,他们涉及到对private成员的直接访问,说明他们处于封装表面的第一道防线。由若干其他public(或protected)函数集成而来的public成员函数,我愿称之为颗粒度高的函数,因为他们集成了若干颗粒度较低的任务,这就是本条款所针对的对象——那些无需直接访问private成员,而只是若干public函数集成而来的member函数。本条款告诉我们:这些函数应该尽可能放到类外。

class WebBrowser {  //  一个浏览器类
public:ß
    void clearCache();  // 清理缓存,直接接触私有成员
    void clearHistory();  // 清理历史记录,直接接触私有成员
    void clearCookies();  // 清理cookies,直接接触私有成员
  
    void clear();     // 颗粒度较高的函数,在内部调用上边三个函数,不直接接触私有成员,本条款告诉我们这样的函数应该移至类外
}

如果高颗粒度函数设置为类内的成员函数,那么一方面他会破坏类的封装性,另一方面降低了函数的包裹弹性。

  1. 类的封装性
    封装的作用是尽可能减小被封装成员的改变对类外代码的影响——我们希望类内的改变只影响有限的客户。一个量化某成员封装性好坏的简单方法是:看类内有多少(public或protected)函数直接访问到了这个成员,这样的函数越多,该成员的封装性就越差——该成员的改动对类外代码的影响就可能越大。回到我们的问题,高颗粒度函数在设计之时,设计者的本意就是它不应直接访问任何私有成员,而只是公有成员的简单集成,这样会最大程度维护封装性,但很可惜,这样的愿望并没有在代码层面体现出来。这个类未来的维护者(有可能是未来的你或别人)很可能忘记了这样的原始设定,而在此本应成为“高颗粒度”函数上大肆添加对私有成员的直接访问,这也就是为什么封装性可能会被间接损坏了。但设计为非成员函数就从语法上避免了这种可能性。

  2. 函数的包裹弹性与设计方法
    将高颗粒度函数提取至类外部可以允许我们从更多维度组织代码结构,并优化编译依赖关系。我们用上边的例子说明什么是“更多维度”。clear()函数是代码的设计者最初从浏览器的角度对低颗粒度函数做出的集成,但是如果从“cache”、“history”、和“cookies”的角度,我们又能够做出其他的集成。比如将“搜索历史记录”和“清理历史记录”集成为“定向清理历史记录”函数,将“导出缓存”和“清理缓存”集成为“导出并清理缓存”函数,这时,我们在浏览器类外做这样的集成会有更大的自由度。通常利用一些工具类如class CacheUtilsclass HistoryUtils中的static函数来实现;又或者采用不同namespace来明确责任,将不同的高颗粒度函数和浏览器类纳入不同namespace和头文件,当我们使用不同功能时就可以include不同的头文件,而不用在面对cache的需求时不可避免的将cookies的工具函数包含进来,降低编译依存性。这也是namespace可以跨文件带来的好处。

// 头文件 webbrowser.h     针对class WebBrowserStuff自身
namespace WebBrowserStuff {
class WebBrowser { ... };       //核心机能
}

// 头文件 webbrowsercookies.h      针对WebBrowser和cookies相关的功能
namespace WebBrowserStuff {
    ...                                             //与cookies相关的工具函数
}

// 头文件 webbrowsercache.h        针对WebBrowser和cache相关的功能、
namespace WebBrowserStuff {
    ...                                             //与cache相关的工具函数
}

最后要说的是,本条款讨论的是那些不直接接触私有成员的函数,如果你的public(或protected)函数必须直接访问私有成员,那请忘掉这个条款,因为把那个函数移到类外所需做的工作就比上述情况远大得多了。

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

这个条款告诉了我们操作符重载被重载为成员函数和非成员函数的区别。作者想给我们提个醒,如果我们在使用操作符时希望操作符的任意操作数都可能发生隐式类型转换,那么应该把该操作符重载成非成员函数。

我们首先说明:如果一个操作符是成员函数,那么它的第一个操作数(即调用对象)不会发生隐式类型转换。

首先简单讲解一下当操作符被重载成员函数时,第一个操作数特殊的身份。操作符一旦被设计为成员函数,它在被使用时的特殊性就显现出来了——单从表达式你无法直接看出是类的哪个对象在调用这个操作符函数,不是吗?例如下方的有理数类重载的操作符”+”,当我们在调用Rational z = x + y;时,调用操作符函数的对象并没有直接显示在代码中——这个操作符的this指针指向x还是y呢?

class Rational {
public:
  //...
  Rational operator+(const Rational rhs) const; 
pricate:
  //...
}

作为成员函数的操作符的第一个隐形参数”this指针”总是指向第一个操作数,所以上边的调用也可以写成Rational z = x.operator+(y);,这就是操作符的更像函数的调用方法。那么,做为成员函数的操作符默认操作符的第一个操作数应当是正确的类对象——编译器正式根据第一个操作数的类型来确定被调用的操作符到底属于哪一个类的。因而第一个操作数是不会发生隐式类型转换的,第一个操作数是什么类型,它就调用那个类型对应的操作符。

我们举例说明:当Ratinoal类的构造函数允许int类型隐式转换为Rational类型时,Rational z = x + 2;是可以通过编译的,因为操作符是被Rational类型的x调用,同时将2隐式转换为Ratinoal类型,完成加法。但是Rational z = 2 + x;却会引发编译器报错,因为由于操作符的第一个操作数不会发生隐式类型转换,所以加号“+”实际上调用的是2——一个int类型的操作符,因此编译器会试图将Rational类型的x转为int,这样是行不通的。

因此在你编写诸如加减乘除之类的(但不限于这些)操作符、并假定允许每一个操作数都发生隐式类型转换时,请不要把操作符函数重载为成员函数。因为当第一个操作数不是正确类型时,可能会引发调用的失败。解决方案是,请将操作符声明为类外的非成员函数,你可以选择友元让操作符内的运算更便于进行,也可以为私有成员封装更多接口来保证操作符的实现,这都取决于你的选择。

希望这一条款能解释清楚操作符在作为成员函数与非成员函数时的区别。此条款并没有明确说明该法则只适用于操作符,但是除了操作符外,我实在想不到更合理的用途了。

题外话:如果你想禁止隐式类型转换的发生,请把你每一个单参数构造函数后加上关键字explicit

条款25:考虑写出一个不抛出异常的swap函数

六、继承与面对对象设计

在设计一个与继承有关的类时,有很多事情需要提前考虑:

  • 什么类型的继承?
  • 接口是虚函数还是非虚的?
  • 缺省参数如何设计?

想要得到以上问题的合理答案,需要考虑的事情就更多了:各种类型的继承到底意味着什么?虚函数的本质需求是什么?继承会影响名称查找吗?虚函数是否是必须的呢?有哪些替代选择?这些问题都在本章做出解答。

条款32:确定你的public继承保证了is-a关系

public继承的意思是:子类是一种特殊的父类,这就是所谓的“is-a”关系。但是本条款指出了其更深层次的意义:在使用public继承时,子类必须涵盖父类的所有特点,必须无条件继承父类的所有特性和接口。之所以单独指出这一点,是因为如果单纯偏信生活经验,会犯错误。

比如鸵鸟是不是鸟这个问题,如果我们考虑飞行这一特性(或接口),那么鸵鸟类在继承中就绝对不能用public继承鸟类,因为鸵鸟不会飞,我们要在编译阶段消除调用飞行接口的可能性;但如果我们关心的接口是下蛋的话,按照我们的法则,鸵鸟类就可以public继承鸟类。同样的道理,面对矩形和正方形,生活经验告诉我们正方形是特殊的矩形,但这并不意味着在代码中二者可以存在public的继承关系,矩形具有长和宽两个变量,但正方形无法拥有这两个变量——没有语法层面可以保证二者永远相等,那就不要用public继承。

所以在确定是否需要public继承的时候,我们首先要搞清楚子类是否必须拥有父类每一个特性,如果不是,则无论生活经验是什么,都不能视作”is-a”的关系。public继承关系不会使父类的特性或接口在子类中退化,只会使其扩充。

条款33:避免遮掩继承而来的名称

这个条款研究的是继承中多次重载的虚函数的名称遮盖问题,如果在你设计的类中没有涉及到对同名虚函数做多次重载,请忽略本条款。

在父类中,虚函数foo()被重载了两次,可能是由于参数类型重载(foo(int)),也可能是由于const属性重载(foo() const)。如果子类仅对父类中的foo()进行了覆写,那么在子类中父类的另外两个实现(foo(int),foo() const)也无法被调用,这就是名称遮盖问题——名称在作用域级别的遮盖是和参数类型以及是否虚函数无关的,即使子类重载了父类的一个同名,父类的所有同名函数在子类中都被遮盖,个人觉得是比较反直觉的一点。

如果想要重启父类中的函数名称,需要在子类有此需求的作用域中(可能是某成员函数中,可能是public 或private内)加上using Base::foo;,即可把父类作用域汇总的同名函数拉到目标作用域中,需要注意的是,此时父类中的foo(int)foo() const都会被置为可用。

如果只想把父类某个在子类中某一个已经不可见的同名函数复用,可使用inline forwarding function。

条款34:区分接口继承和实现继承

我们在条款32讨论了public继承的实际意义,我们在本条款将明确在public继承体系中,不同类型的接口——纯虚函数、虚函数和非虚函数——背后隐藏的设计逻辑

首先需要明确的是,成员函数的接口总是会被继承,而public继承保证了,如果某个函数可施加在父类上,那么他一定能够被施加在子类上。不同类型的函数代表了父类对子类实现过程中不同的期望

  • 在父类中声明纯虚函数,是为了强制子类拥有一个接口,并强制子类提供一份实现
  • 在父类中声明虚函数,是为了强制子类拥有一个接口,并为其提供一份缺省实现
  • 在父类中声明非虚函数,是为了强制子类拥有一个接口以及规定好的实现,并不允许子类对其做任何更改(条款36要求我们不得覆写父类的非虚函数)。

在这其中,有可能出现问题的是普通虚函数,这是因为父类的缺省实现并不能保证对所有子类都适用,因而当子类忘记实现某个本应有定制版本的虚函数时,父类应从_代码层面提醒子类的设计者做相应的检查_,很可惜,普通虚函数无法实现这个功能。一种解决方案是,在父类中为纯虚函数提供一份实现,作为需要主动获取的缺省实现,当子类在实现纯虚函数时,检查后明确缺省实现可以复用,则只需调用该缺省实现即可,这个主动调用过程就是在代码层面提醒子类设计者去检查缺省实现的适用性。

从这里我们可以看出,将纯虚函数、虚函数区分开的并不是在父类有没有实现——纯虚函数也可以有实现,其二者本质区别在于父类对子类的要求不同,前者在于从编译层面提醒子类主动实现接口,后者则侧重于给予子类自由度对接口做个性化适配。非虚函数则没有给予子类任何自由度,而是要求子类坚定的遵循父类的意志,保证所有继承体系内能有其一份实现。

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

条款36:绝不重新定义继承而来的non-virtual函数

意思就是,如果你的函数有多态调用的需求,一定记得把它设为虚函数,否则在动态调用(基类指针指向子类对象)的时候是不会调用到子类重载过的函数的,很可能会出错。

反之同理,如果一个函数父类没有设置为虚函数,你千万千万不要在子类重载它,也会犯上边类似的错误。

理由就是,多态的动态调用中,只有虚函数是动态绑定,非虚函数是静态绑定的——指针(或引用)的静态类型是什么,就调用那个类型的函数,和动态类型无关。

话说回来,虚函数的意思是“接口一定被继承,但实现可以在子类更改”,而非虚函数的意思是“接口和实现都必须被继承”,这就是“虚”的实际意义。

条款37:绝不重新定义继承而来的缺省参数值

这个条款包含双重意义,在继承中:

  • 不要更改父类非虚函数的缺省参数值,其实不要重载父类非虚函数的任何东西,不要做任何改变!
  • 虚函数不要写缺省参数值,子类自然也不要改,虚函数要从始至终保持没有缺省参数值。

第一条在条款36解释过了,第二条的原因在于,缺省参数值是属于_静态绑定_的,而虚函数属于动态绑定。虚函数在大多数情况是供动态调用,而在动态调用中,子类做出的缺省参数改变其实并没有生效,反而会引起误会,让调用者误以为生效了。

缺省参数值属于静态绑定的原因是为了提高运行时效率。

如果你真的想让某一个虚函数在这个类中拥有缺省参数,那么就把这个虚函数设置成private,在public接口中重制非虚函数,让非虚函数这个“外壳”拥有缺省参数值,当然,这个外壳也是一次性的——在被继承后不要被重载。

条款38:通过复合塑膜出has-a关系,或“根据某物实现出”

两个类的关系除了继承之外,还有“一个类的对象可以作为另一个类的成员”,我们称这种关系为“类的复合”,这个条款解释什么情况下我们应该用类的复合。

第一种情况,非常简单,说明某一个类“拥有”另一个类对象作为一个属性,比如学生拥有铅笔、市民拥有身份证等,不会出错。

第二种情况被讨论的更多,即“一个类根据另一个类实现”。比如“用stack实现一个queue”,更复杂一点的情况可能是“用一个老版本的Google Chrome内核去实现一个红芯浏览器”。

这里重点需要区分第二种情形和public继承中提到的”is-a”的关系。请牢记“is-a”关系的唯一判断法则,一个类的全部属性和接口是否必须全部继承到另一个类当中?另一方面,“用一个工具类去实现另一个类”这种情况,是需要对工具类进行隐藏的,比如人们并不关心你使用stack实现的queue,所以就藏好所有stack的接口,只把queue的接口提供给人们用就好了,而红芯浏览器的开发者自然也不希望人们发现Google Chrome的内核作为底层实现工具,也需要“藏起来”的行为。

条款39:明智而审慎地使用private继承

与类的复合关系相似,private继承正是表达“通过某工具类实现另一个类”。那么相似的,工具类在目标类中自然应该被隐藏——所有接口和变量都不应对外暴露出来。这也解释了private继承的内涵,它本质是一种技术封装,和public继承不同的是,private继承表达的是“只有实现部分被继承,而接口部分应略去”的思想。

与private继承的内涵相对应,在private继承下,父类的所有成员都转为子类私有变量——不提供对外访问的权限,外界也无需关心子类内有关父类的任何细节。

当我们拥有“用一个类去实现另一个类”的需求的时候,如何在类的复合与private继承中做选择呢?

  • 尽可能用复合,除非必要,不要采用private继承。
  • 当我们需要对工具类的某些方法(虚函数)做重载时,我们应选择private继承,这些方法一般都是工具类内专门为继承而设计的调用或回调接口,需要用户自行定制实现。

如果使用private继承,我们无法防止当前子类覆写后的虚函数被它的子类继续覆写,这种要求类似于对某个接口(函数)加上关键字final一样。为了实现对目标类的方法的防覆写保护,我们的做法是,在目标类中声明一私有嵌套类,该嵌套类public继承工具类,并在嵌套类的实现中覆写工具类的方法。

class TargetClass {                         //目标类
private:
        class ToolHelperClass : public ToolClass {      //嵌套类,public继承工具类
    public:
        void someMethod() override;   //本应被目标类覆写的方法在嵌套类中实现,这样TargetClass的子类就无法覆写该方法。
    }  
}

如此一来,目标类的子类就无法再次覆写我们想要保护的核心方法。

条款40:明智而审慎地使用多继承

原则上不提倡使用多继承,因为多继承可能会引起多父类共用父类,导致在底层子类中出现多余一份的共同祖先类的拷贝。为了避免这个问题C++引入了虚继承,但是虚继承会使子类对象变大,同时使成员数据访问速度变慢,这些都是虚继承应该付出的代价。

在不得不使用多继承时,请慎重地设计类别,尽量不要出现菱形多重继承结构(“B、C类继承自A类,D类又继承自B、C类”),即尽可能地避免虚继承,一个完好的多继承结构不应在事后被修改。虚基类中应尽可能避免存放数据。

原链接:https://normaluhr.github.io/2020/12/31/Effective-C++/

你可能感兴趣的:(【转】读书笔记——重述《Effective C++》)