"> Effective C++读书笔记(中) | Stillwtm's Blog
0%

Effective C++读书笔记(中)

本文是笔者阅读Effective C++第三版时的读书笔记,主要内容包括:

  • 对书上内容的概括提取
  • 笔者自己不清楚的小知识点的提取
  • 对书中每个条款的后的“请记住”部分的(基本上是)原文摘录

这种灰色部分一般是笔者自己的一些注释

原书一共九章,本文(中篇)记录第四至六章的笔记

4. 设计与声明

条款18:让接口容易被正确使用,不易被误用
  • 为了预防传递参数顺序错误之类的事情发生,可以将参数封装成类,然后让用户必须显式构造后进行传递,例如:

    1
    2
    3
    4
    Date(int month, int day, int year);  // 这不好
    Date(const Month& m, const Day& d, const Year& y); // 这比较好

    Date d(Month(3), Day(3), Year(2022)); // 从而用户必须这样调用
  • 利用enum来限制参数的值的范围不够好,因为enum没有类型安全性(可以被当成int使用),比较安全的做法是预先定义好所有的可能值,例如:

    1
    2
    3
    4
    5
    class Month {
    public:
    static Month Jan() { return Month(1); }
    ...
    }

    使用函数是因为non-local static对象在不同编译单元的次序没有明确规定(见条款04)

  • 保持“一致性”是最重要的!(包括避免无端与内置类型不兼容)

  • 多数平台上,跨DLL(动态链接库)的new/delete的成对应用会导致RE,而使用shared_ptr可避免这一问题


好的接口很容易被使用,不容易被误用;你应该在你的所有接口中努力达成这些性质

“促进正确使用 ”的办法包括接口的一致性,以及与内置类型的行为兼容

“阻止误用 ”的办法包括建立新类型、限制类型上的操作,束缚对象值,以及消除客户的资源管理责任

tr1::shared_ptr支持定制型删除器;这可以防范 DLL问题,可被用来自动解除互斥锁等等


条款019:设计class犹如设计type
  • 像“语言设计者设计内置类型”一样设计设计你的class(亦即设计一个新type),并且回答下述问题:

    • 新type的对象应该如何被创建或销毁?(涉及构造析构、内存分配操作符等)

    • 对象的初始化和赋值的行为差异?

    • 新type对象的pass by value,意味着什么?(值传递的行为被拷贝构造函数定义)

    • 什么是新type的“合法值”?(这可能影响约束条件的维护、异常的抛出)

    • 新type需要配合某个继承图系吗?(例如受基类函数是否virtual的影响,以及是否有作为基类的需求)

    • 新type需要什么样的转换?(涉及单参数构造和类型转换操作符)

    • 什么样的操作符和函数对此新type而言是合理的?

    • 什么样的默认生成的函数应该驳回?(条款06)

    • 什么是新type的“未声明接口”?

      确实,什么是未声明接口?个人猜测是不在类中定义,而是语言本身提供的一些接口

    • 新type有多么一般化?(这决定了是否定义template class)

    • 你真的需要一个新type吗?也许一个或几个函数更能达到要求

class的设计就是 type的设计。在定义一个新 type之前,请确定已经考虑过上述问题


条款20:宁以pass-by-reference-to-const替换pass-by-value
  • pass-by-reference还可以防止派生类被切割为基类,导致行为出错
  • C++编译器的底层中,reference往往以指针实现,因此pass-by-reference通常真正传递的是指针
    • 因此,内置类型(如int),传值效率往往比传引用高
    • 包括STL对象
  • 对象小并不意味这拷贝构造不昂贵,因为复制它可能会导致复制对象中的指针指向的所有东西
  • 某些编译器对待“内置类型”和“用户自定义类型”的态度可能截然不同,即使他们底层表述类似
    • 例如,一个只含一个double的对象可能被拒绝放入缓存器,但是它会对double这么做
    • 而pass-by-reference不会有问题,因为编译器当然会把指针放进缓存器里
  • 另外一个原因是小的自定义类型有将来变大的可能,故不能传值

尽量以 pass-by-reference-to-const替换 pass-by-value;前者通常比较高效,并可避免切割问题

以上规则不适用于内置类型,以及STL的迭代器和函数对象;它们应该 pass-by-value


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

个人认为这里说“必须返回’新‘对象”比较恰当,若非新对象应参见条款29

  • 返回指向local栈对象的引用或指针会导致接下来对返回值的运用陷入“无定义行为”的恶地(因为该对象已被销毁)
  • 返回指向堆中对象的引用几乎必然导致内存泄漏(根本不知道如何delete)
  • 返回指向local static对象则会导致多线程以及更严重的行为完全错误(当同时需要多个返回值时,具体例子见书P93)
  • 一个“必须返回新对象”的函数的正确写法是:就让那个函数返回一个新对象呗

绝不要返回一个 pointer或 reference指向一个 local stack对象,或返回一个 reference指向一个 heap-located对象,或返回 pointer或 reference指向一个 local static对象而有可能同时需要多个这样的对象。条款 04中有返回 reference指向 local static对象的合理设计实例


条款22:将成员变量声明为private
  • 成员变量不是public的理由:
    • 如果成员变量不是public,用户就无需记住访问成员时是否要加小括号(语法一致性!)
    • 非public可以精确控制成员变量的可访问性(是否可读可写之类)
    • 封装性,日后可以方便地以不同的实现方式替换某个变量,而用户接口并不变化
  • protected的封装性几乎和public一样差,因为一旦该变量被改变(比如删除),大量的用户的派生类代码需要重写
  • 其实只有两种访问权限:private(提供封装)和其他(不提供封装)

    个人认为protected本身就是为了破坏封装性的一种设计,就像friend一样


切记将成员变量声明为 private;这可赋予客户访问数据的一致性、可细微划分访问控制、允许约束条件获得保证,并提供 class作者以充分的实现弹性

protected并不比 public更具封装性


条款23:宁以non-member、non-friend替换member函数
  • 非成员且非友元函数能够提供更好的封装性,因为这并不会增加能看到成员变量的函数的个数

    • 也可以是另一个类(例如一个工具类utility class)的static member函数

    本质上这样的函数并不能完成客户以其他方式无法取得的机能,即只是一种便利函数

  • 能提供更大的包裹弹性,这导致更低的编译依赖度(修改非成员函数以包括更多功能并不需要编译相关类)

  • 通常的做法是吧这个函数和类放在同一个命名空间里,而这么做可以方便建立一些新的便利函数并与原来的便利函数拥有相同地位

宁可那 non-member non-friend函数替换 member函数;这样做可以增加封装性、包裹弹性和机能扩充性


条款24:若所有参数皆需类型转换,请为此采用non-member函数
  • 例如有理数类的乘法实现(可能需要将int隐式转换为Rational类型)。并且它不应该是个friend,因为完全可以通过公有接口实现

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


条款25:考虑写出一个不抛出异常的swap函数
  • 需要重写swap有几种原因:

    • 为了异常安全性编程(条款29)
    • 对于使用了pimpl(pointer to implementation)手法的class,可以大大提高复制效率

      这个手法是指类中只存储一些指针指向大量的资源,而它们的拷贝构造和赋值一般是深拷贝,开销很大,但事实上做swap只需要交换类里的指针即可,可以大大减少开销

  • 模板函数不支持偏特化(或译为部分具体化,partially specialize),只有模板类支持

    • 一般做法是直接对函数进行重载(即舍去函数名后面为了特化而加的< >
  • std是个特殊的命名空间,只允许对其中的模板进行特化,但是不可以添加任何新的templates;std的内容完全由C++标准委员会决定
    • 虽然加了东西几乎也可编译运行,但它们的行为是未定义的,所以尽量避免
  • 编译器看到swap(T& a, T&b),会找到global作用域或T所在命名空间内的任何T专属的swap,并且调用的优先级为:T所在命名空间里的T专属的swap > 特化的std::swap > 一般化的std::swap(前提是提前加了using std::swap;

std::swap对你的类型效率不高时,提供一个 swap成员函数,并确定这个函数不抛出异常

如果你提供一个 member swap,也该提供一个 non-member-swap用来调用前者;对于 class(非 template),也请特化std::swap

调用 swap时应针对std::swap使用using声明式,然后调用 swap并且不带任何 ”命名空间资格修饰 “

为 ”用户自定义类型 “进行 std template全特化是好的,但千万不要尝试在 std里加入某些对 std而言全新的东西


5. 实现

条款26:尽可能延后变量定义式的出现时间
  • 延后变量定义的原因:
    • 出现异常之类的情况可能使你实际并没有使用这个变量,然而仍需承担构造析构成本
    • 可以将变量延后至需要初值时再定义,并直接传给构造函数,消除赋值开销
  • 关于变量在循环外还是循环内定义:
    • 如果赋值成本相较于构造+析构较低,那么可以在循环外定义
      • 但是循环外定义使得变量的作用域更大,这有可能降低程序的可理解性和易维护性
    • 所以除非(1)你明确赋值成本(2)你正在处理代码中的效率高敏感度部分,否则应该使用循环内定义

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


条款27:尽量少做转型动作
  • 即使指向同一个对象,基类指针和派生类指针的值可能并不相同(也就是说,单一变量可能拥有一个以上的地址!)
  • dynamic_cast的成本可能很高(例如有一个很普遍的实现版本是基于“class名称之字符串比较”,这可能会调用一堆strcmp)
  • 一连串基于dynamic_cast的判断总应该被“基于virtual函数调用”的方式取代

如果可以,尽量避免转型,特别是在注重效率的代码中避免 dynamic_cast;如果有个设计需要转型动作,试着发展无需转型的替代设计

如果转型是必要的,试着将它隐藏在某个函数后面;客户可以调用该函数,而无需将转型放入他们自己的代码内

宁可使用 C++-style转型,不要使用旧式转型;前者更容易辨识出来,而且也比较有着分门别类的职掌


条款28:避免返回handles指向对象内部成分

这里的handles是指引用、指针、迭代器等用来取得某个对象的东西

  • 成员变量的封装性最多只等于“返回其reference的函数”的访问级别

  • 为了让某个类尽可能小,可能不会把东西存放在类里,而是另起一个struct之类来存东西,再让类去指它

    即先前所提pimpl设计方式

  • 一旦某个handle比其所指变量更长寿,就会导致虚吊的问题(比如某个匿名变量被销毁,但是某个引用指向它的成员)

  • 但是某些情况,例如operator[]对于string、vector仍需要返回引用


避免返回 handles指向对象内部;遵守这个条款可增加封装性,帮助 const成员的行为像个 const,并将发生虚吊(dangling) handle

的可能降到最低


条款29:为“异常安全”而努力是值得的
  • 当异常被抛出时,带有异常安全性的函数会:

    • 不泄露任何资源
    • 不允许数据败坏(比如变量不再满足class的约束条件,本该有序的数组不再有序)
  • 异常安全函数提供以下三个保证之一:

    • 基本承诺:如果异常被抛出,程序内的所有事物仍然有效,没有对象或数据的败坏

    • 强烈保证:如果函数成功,就完全成功;如果失败,程序就恢复到“调用函数之前”的状态

      相较而言,基本承诺的函数可能在失败后使程序处于任意一个合法状态(比如缺省值之类,用户不能预料)

    • 不抛掷保证:承诺绝不抛出异常

      • 作用于内置类型身上的所有操作都提供nothrow保证
  • 在某件事情发生之后再改变表事情发生的对象的状态(比如一个计数变量cnt)

  • 当“强烈保证”不切实际时(如时间空间消耗过大),应提供基本保证

异常安全函数即使发生异常也不会泄漏资源或允许任何数据结构败坏。这样的函数区分为三种可能的保证:基本型、强烈型、不抛异常型

“强烈保证 ”往往能够以 copy-and-swap实现出来,但 “强烈保证 ”并非对所有函数都可实现或具备实现意义

函数提供的 ”异常安全保证 “通常最高只等于其所调用各个函数的 ”异常安全保证 “中的最高者


条款30:透彻了解inlining的里里外外
  • inline可以免除函数调用的开销,但也会使目标码变大
  • 编译器优化机制通常被设计用来浓缩那些“不含函数调用”的代码,所以当inline某个函数之后,或许编译器就因此有能力对它执行语境相关最优化;而大部分编译器不会对一个“outlined函数调用”执行如此优化
  • 如果inline函数的本体很小,编译器针对“函数本体”所产出的码可能比“函数调用”所产出的码更小,从而导致高效率
  • inlining在大多数C++程序中是编译期行为(但仍有少数可以实现连接期甚至运行期inlining!)
  • inline只是对编译器的一个申请,不是强制命令

    • 大部分编译器拒绝太复杂(如带有循环递归)的函数inlining
    • 会拒绝所有的virtual函数inlining(因为虚函数要在运行期确定如何调用)
  • 编译器通常不对“通过函数指针进行的调用”实施inlining

  • 实际上构造和析构函数往往是inlining的糟糕候选人,即使是空的构造函数,编译器在实现时也可能在其中包含了基类构造,成员对象构造,以及相应的构造发生异常后的销毁以及异常传播
  • inline函数一旦改变需要重新编译,而non-inline函数只需要重新连接

将大多数 inlining限制在小型、被频繁调用的函数身上。这可使日后的代码调试过程和二进制升级更容易,也可使潜在的代码膨胀问题最小化,使程序的速度提升机会最大化

不要只因为 function templates出现在头文件,就将他们声明为 inline


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

如果某个类的定义文件被改变,所有包含它的文件都需要被重新编译
而如果将接口和实现分离,就只有在接口被改变的时候才需要全部重新编译

  • 设计策略

    • 如果使用object reference或object pointers可以完成任务,就不要使用objects

    • 尽量以class声明式替换class定义式

    • 为声明式和定义式提供不同的头文件

      例如C++标准库里的就是一个声明头文件,对应的定义包含在

  • 更一般的设计策略:

    • Handle class:使用pimpl思路,设计两个类,一个类中是实现,一个类只存储指针指向实现类的对象。两个类接口应当完全相同,在pointer类中只用调用实现类的函数即可
    • Interface class:使用抽象基类,并且一般在其中设置一个static的factory函数,用来调用派生类(具象类)的构造函数;具体的实现在派生类中完成

支持 ”编译依存最小化 “的一般构想是:相依于声明式,不要相依于定义式。基于此构想的两个手段是 Handle classes和 Interface classes

程序库头文件应该以 “完全且仅有声明式 ”的形式存在。这种做法无论是否涉及 templates都适用


6. 继承与面向对象设计

条款32:确定你的public继承塑膜出is-a关系
  • public继承意味着“is-a”关系

    • 所谓Liskov Substitution Principle:如果令class D继承自class B,那么B对象可派上用场的地方,D对象一样可以派上用场

      个人理解是,将基类完全替换成public继承的派生类,程序仍然应该正常运作


“public继承 ”意味着 is-a;适用与 base class身上的每一件事情也一定适用于 derived class身上,因为每一个 derived class对象也都是一个 base class对象


条款33:避免遮掩继承而来的名称
  • C++的名称查找从最内的作用域开始,一层层向外查找直至global作用域

  • derived class作用域实际上是被嵌套在base class作用域里的

  • 如果想在derived class里对base class的某个成员函数进行重载,不能够直接重载,这会导致函数名称的“遮掩”,即名称查找只能看到derived class里的函数,看不到base class里的函数

    这相当于在名称查找规则下is-a的关系被破坏了,这不好

  • 解决手法有两种:

    • 使用using Base::func;的声明,消除遮掩
    • 在derived class中另写一个函数(转交函数),在其中直接调用Base::func()
      • 这种手法主要应该适用于private继承,对于public继承来说并不算好

derived class内的名称会遮掩 base class内的名称;在 public继承下没有人希望如此

为了让被遮掩的名称再见天日,可使用 using声明式或转交函数


条款34:区分接口继承和实现继承
  • 成员函数的接口总是会被继承
  • 声明一个pure virtual函数的目的,是让派生类只继承函数接口
  • 声明一个impure virtual函数的目的,是让派生类继承该函数的接口和缺省实现

    • 如果直接在基类的virtual函数中定义一个缺省实现,那么它可能会在不想被调用的时候被调用(比如也许某个派生类忘了重写这个virtual函数)
    • 改进方法有二:

      • func()的缺省行为放在另一个函数defaultFunc()中,并将func()变为纯虚函数,只在想要的时候调用defaultFunc(通常defaultFunc()应该是protected non-virtual的)
      • 为纯虚函数func()提供一个缺省实现,然后通过BaseClass::func()对其进行显式调用(但是这种方法无法将缺省实现为protected)

        缺省也只是代码复用而已,是否缺省应该要受到程序设计者的主观控制,而这样的设计方法较好地减小了缺省不被控制的可能

  • 声明一个non-virtual函数的目的,是让派生类继承函数的接口以及一份强制性实现


接口继承和实现继承不同;在 public继承之下,derived class总是继承 base class的接口

pure virtual函数之具体指定接口继承

impure virtual函数具体指定接口继承及缺省实现继承

non-virtual函数具体指定接口继承以及强制性实现继承


条款35:考虑virtual函数以外的其他选择
  • 使用non-virtual interface(NVI)手法,这是Template Method设计模式的一种特殊形式,即用public non-virtual成员函数包裹较低访问性(private/protected)的virtual函数
    • 好处是可以在调用virtual函数之前之后确保进行某些操作
  • 将virtual函数替换为“函数指针成员变量”,这是Strategy设计模式的一种分解表现形式
    • 好处是可以让同一类对象的某个行为有不同的实现方法,甚至可以在运行期更换实现方法
  • 将virtual函数替换为tr1::function成员变量,这也是Strategy设计模式的一种表现形式
    • 在上一种的情况下更进一步,允许使用任何可调用物搭配一个兼容于需求的签名式
    • 如果要用成员函数,使用tr1::bind()可以为tr1::function绑定一个对象(解决隐藏参数this的问题)
  • 将继承体系内的virtual函数替换为另一个继承体系内的virtual函数,这是Strategy设计模式的传统手法
    • 好处是容易添加算法,只要进行派生即可

对两种设计模式的个人理解:

Template Method设计模式:先定义框架,推迟定义具体实现

Strategy设计模式:定义一系列的算法,把它们一个个封装起来,并且使他们可相互替换


virtual函数的替代方案包括 NVI手法以及 Strategy设计模式的多种形式;NVI手法自身是一个特殊形式的 Template Method设计模式

将机能从成员函数移到 class外部函数,带来的一个缺点是,非成员函数无法访问 class的 non-public成员

tr1::function对象的行为就像一般函数指针;这样的对象可接纳 “与给定的签名式兼容 ”的所以可调用物


条款36:绝不重新定义继承而来的non-virtual函数
  • non-virtual函数都是静态绑定的

  • virtual函数是动态绑定

    静态绑定指编译期就能将方法与所在的类关联起来,与之相对的,动态绑定指运行期才能确定

  • 重新定义non-virtual函数造成的问题:

    • 使用基类指针的和派生类指针指向派生类对象,调用的方法不同
    • 违反了public继承的Liskov原则

绝对不要重新定义继承而来的的 non-virtual函数


条款37:绝不重新定义继承而来的缺省参数值
  • 静态类型:变量被声明时所采用的类型
  • 动态类型:变量当前所指对象的类型

    例如,积累指针Shape* p = new Circle();,那么p的静态类型是Shape*,动态类型是0Circle*

  • virtual函数是动态绑定,而缺省参数值是静态绑定,因此,有可能调用派生类方法,但是使用基类缺省值

  • 可以使用NVI(见条款35)手法,在non-virtual函数中指定缺省值

绝对不要重新定义一个继承而来的缺省参数值,因为缺省参数值都是静态绑定,而 virtual函数——你唯一应该覆写的东西——却是动态绑定


条款38:通过复合塑模出has-a或is-implemented-in-terms-of
  • 应用域中的对象(比如人、汽车、一些图片等等),表现出has-a关系
  • 实现域中的对象(比如互斥器,缓冲区,查找树等等),表现出is-implemented-in-terms-of
  • 举例,希望用list来实现一个set,这时不应该用public继承,而是应该用复合,因为set并不应该支持list的所有操作

    这和一般的教科书出入还挺大


复合的意义和 public继承完全不同

在应用域,复合意味着 has-a;在实现域,复合意味着 is-implemented-in-terms-of


条款39:明智而审慎地使用private继承
  • 编译器可以将public继承的派生类隐式转换成基类,但不能对private继承的派生类这么做

  • private继承意味着implemented-in-terms-of,它纯粹只是一种实现技术,在设计层面上没有意义,其意义只在于软件实现层面(因此继承而来的东西全部都是private:它们都仅仅是实现枝节)

  • private继承可以用这样的技术替代:在类中内嵌一个类用来public继承原来要继承的类

    • 这种设计可以模拟Java的final和C#的sealed,即阻止派生类重新定义virtual函数
    • 如果把内嵌类移出去,并改为在原来的类里包含一个指针,就可以降低编译依存性
  • 任何独立(非附属)对象一定有非零大小

    • 一种激进的情况:当类中没有任何数据时(包括非静态成员变量,virtual函数造成的vptr,以及虚基类),但是当在类中复合一个这样的对象,将造成类的大小变大(大多数编译器会填充一个char到空对象里,甚至还会引发对齐位的问题,具体见书P190)
    • 但是当继承这个空基类时,派生类的大小并不会如上增加,这是由于EBO(empty base optimization,空白基类最优化)
      • EBO一般只在单继承下可行,多继承不可行

        这里还不清楚是为什么


private继承意味着 is-implemented-in-terms-of;它通常比复合的级别低;但是当 derived class需要访问 protected base class的成员,或需要重新定义继承而来的 virtual函数时,这么设计是合理的

和复合不同,private继承可以造成 empty base最优化;这对致力于 “对象尺寸最小化 ”的程序库开发者而言,可能很重要


条款40:明智而审慎地使用多重继承
  • 使用virtual继承的类所产生的对象往往比non-virtual继承的体积更大,访问其成员变量时速度也更慢,且虚基类的初始化必须由最下层的派生类负责
  • 因此,有以下建议:
    • 尽量不使用virtual继承
    • 如果必须使用,尽量避免在虚基类中放置数据
  • 多重继承可以用于将“public继承某接口”和“private继承实现”结合起来

多重继承比单一继承复杂;它可能导致新的歧义性,以及对 virtual继承的需要

virtual继承会增加大小、速度、初始化(及赋值)复杂度等成本;如果 virtual base class不带任何数据,将是最具实用价值的情况

多重继承确有其正当用途;其中一个情节涉及“public继承某个Interface class”和“private继承某个协助实现的class”的两相结合


受笔者知识水平和表达能力所限,有些问题上难免出现疏漏,欢迎在评论区指正