技术书籍精读笔记:Effective C++(下)

本文摘要总结了《Effective C++》中18-33条款的核心内容,主要涵盖设计规范、实现技巧和继承设计三个方面。设计规范部分强调接口易用性(条款18)、class设计原则(条款19)、参数传递优化(条款20)、返回值处理(条款21)和封装性(条款22-25)。实现技巧部分包括变量定义时机(条款26)、减少转型(条款27)、返回值安全(条款28)、异常处理(条款29)、inline使用(条款30)和降低编译依赖(条款31)。继承设计部分阐述了is-a关系(条款32)和避免名称遮掩(条款33)

作者
WildPointer
发布
2024.04.09
专栏
技术书籍精读笔记
阅读
约 20 分钟 / 795 次原文浏览

Designs and Declarations:

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

该条款所提的接口是什么?通俗来说无非就是函数。对于一个函数要确保它被正确的调用,而不是被误用。

为什么要让接口被正确的使用,不易被误用?有以下理由:

1.防止调用函数时传入错误的实参

cpp
void Date(int month,int day,int year);//接口Date接受三个实参分别为月,天,年Date(13,35,2023);//一个不正确的调用,月份不存在13,天数不存在35

2.将错误提示提升至编译期发现(更早的发现错误)

怎么做才能让接口被正确的使用,而不易被误用。有以下做法:

1.引入自定义的新类型(自定义类型应尽可能与内建类型一致),新类型拒绝隐式转换,且应自定义返回接口参数所支持的数据和类型

cpp
class Month{    //新建类型,对构造函数进行explicit修饰,拒绝隐式转换public:    explicit Month(int m){        if (m <= 12 &*& m >0){            month = m;        }    }private:    int month;}class Day{public:    explicit Day(int d){        if (m <= 31 &*& d >0){            day = d;        }    }private:    int day;}class Year{public:    explicit Year(int y){        //年份就不加以限制了,视情况分析    }private:    int year;}void Date(const Month& m,const Day& d,const Year& y);//新的Date函数声明

2.对接口返回的指针使用智能指针,防止多次的delete或忘记delete导致的内存泄漏

cpp
shared_ptr<int> fun(){    shared_ptr<int> Pointer;    return Pointer;}//是否选择智能指针作返回值//应该注意智能指针比原始指针大且慢//且使用辅助的多态内存,对执行成本的减少不多

总结:定义接口时,要对接受的参数进行限制,防止隐式转换带来的异常行为。如果接口返回值是指针,考虑声明为智能指针,能防止内存泄漏。


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

本条款没有什么特别的知识点,只是希望在新建自定义类型时反思以下问题:

1.新类型的对象应该如何被创建和销毁?

2.对象的初始化和对象的复制应该有什么区别?

3.新类型的对象在函数参数中或返回值中,以值进行传递实参,要进行什么操作?要防止什么操作?

4.新类型中的属性有哪些合法值?

5.是否允许新类型被继承?

6.新类型需要什么样子的类型转换函数?

7.什么样的操作符和函数对新类型来说是合理的调用?

8.新类中中是什么函数不允许被调用?

9.新类型中的成员哪些应该为private,哪些应该为public,哪些应该为proteted?

10.新类型中的函数应该做什么限制?保证不抛出异常?还是?

11.新类型是否支持多态?是否应该声明为模板类?

12.你真的需要一个新类型吗?

总结:以上十二点是值得思考的,但是我觉得思考有度,精神内耗对人来说是得不偿失的


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

为什么宁以pass-by-reference-to-const替换pass-by-value?有以下理由:

                1.函数参数为引用能减少构造函数或析构函数的调用

2.内置类型应尽可能的使用值传递。因为针对内置类型,编译器都会进行优化

3.值传递会造成切割问题(什么是切割问题?指多态中,函数形参为父类对象,传入的实参为子类对象,子类对象会被隐式转换为父类对象,导致对象可调用的函数减少)

总结:如何将值传递改为引用传递,我希望你已经了解了,所以让你将值传递改为引用传递的理由十分充分


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

本条款涉及C++中的生命周期问题,有一个很有意思的关键字——const,对于书中延长函数中匿名对象的生命周期的代码示例,我觉得有误(经过多种途径了解,当使用一个const对象接收函数返回值的时候,该返回值的生命周期会延长到赋值结束。而书中只是将函数返回值设为const pointer(返回指针),并不会延长生命正确),故对于关键字const延长生命周期这件事,我觉得可以单独出一篇文章描述这件事(想了解就关注我的博客吧!)

为什么必须返回对象时,别妄想返回其reference?有以下理由:

1.当函数返回类型引用时,不会新建对象。倘若函数生命周期结束,对象将被析构,将可能会造成未定义行为

2.当函数被连续调用时,假如其中一次调用产生异常,会造成内存泄

cpp
def_Class a,b,c,d;        //自定义的def_Class类a = b * c * d;            //def_Class重载了*运算符//调用两次operator *,使用了两次new,对new申请的空间无法析构

如何延长返回值的生命周期?有以下做法:

1.将接收函数返回值的对象声明为const

cpp
int fun(){ int value; return value;}const int val = fun();//使用const修饰对象,将函数内的对象生命周期延长至赋值结束

总结:该条款简单明了,理由重复。针对延长返回值生命周期本文不再叙述,以下是针对延长返回值生命周期的参考文献:
GotW #88: A Candidate For the “Most Important const” – Sutter’s Mill (herbsutter.com)https://herbsutter.com/2008/01/01/gotw-88-a-candidate-for-the-most-important-const/使用const引用延长变量生命周期 - 天空之城00 - 博客园 (cnblogs.com)https://www.cnblogs.com/tkzc00/articles/17741551.html


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

本条款主要是为了体现封装的重要性,将成员变量隐藏到函数接口的背后,为“所有可能的实现”提供弹性。故也无需什么理由来支持本条款,告诉我面向对象的三大特性是什么?不就是封装,继承和多态?故封装的重要性就不言而喻了。

总结:有时候,protected并不比private更具封装性(封装大多数时候是为了防止修改成员时大量的函数需要更改,而protected修饰的变量仅次于public修饰的变量造成的代码修改),故尽量以private声明成员变量


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

条款宁以non-member,non-friend替换member函数通俗来说就是将非成员且非友元的函数替换成员函数。在了解本条款之前,让我们回顾面向对象的特性之一——封装。一个类的封装程度取决于该类的成员变量能有多少个方法可以访问它,越多方法可以访问该类的成员变量,那么该类的封装性就越低。

        为什么要以非成员且非友元的函数替换成员函数?有以下理由:

1.提高封装性。越多的成员被封装,我们对其成员操作程度就越大

2.友元函数可以分为类的私有成员,其封装性仅次于成员函数

        如何实现将非成员函数且非友元函数替换成员函数?有以下操作:

                1.在类外声明函数,接收参数为类对象的引用,通过引用调用类的成员函数,对成员变量进行操作

cpp
class Base{public:    void function(){};};void fun(Base& A){    //接受一个Base类的对象    //对对象A进行成员函数的调用    A.function();}

2.使用自定义名称空间,将非成员非友元函数定义在名称空间中,通过对名称空间的调用,对其类的私有成员进行操作(C++标准程序库都是如此操作)

cpp
//Base.h头文件namespace Base{    class base{    public:        void function(){}    }}//fun_1.h头文件class base;namespace Base{    void fun_1(base& A){ A.function;}    //该函数属于非成员非友元函数}//fun_2.h头文件class base;namespace Base{    void fun_2(base& A){ A.function;}}//main文件#include"Base.h"#include"fun_1.h"    //只使用fun_1函数就导入fun_1.h。使用fun_2就导入fun_2.husing namespace Base;int main(){    base A;    fun(A);    return 0;}

总结:将非成员非友元函数替换成员函数能提高封装性,对程序有更大的扩充性,对数据操作有更大的弹性。对于是否使用namespace也是针对需求,灵活使用名称空间也是利大于弊,在使用时要防止多次导入同一个库带来的后果


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

在了解本条款之前,你应该清楚explicit关键字的作用——不允许隐式转换。本条款较为特殊,为方便读者理解,我将采用原文的结构进行讲解。

问题导入:

                1.未使用explicit修饰,允许隐式转换的情况:

cpp
class Base{public:    Base(int numer=0,int num=1);    //未使用explicit修饰,允许隐式转换    const Base operator*(const Base& A)const{}};Base A(1,2);    //定义对象ABase B(2,3);    //定义对象BB = A*2;    //合理调用,相当于A.operator*(2),其中调用了一次构造函数进行隐式转换B = 2*A;    //错误,不存在2.operator*(A)//当*号运算符应满足乘法交换律,也就是A*2与2*A要同时成立

2.使用explicit修饰,不允许隐式转换的情况:

cpp
class Base{public:    explicit Base(int numer=0,int num=1);    //使用explicit修饰,不允许隐式转换    const Base operator*(const Base& A)const{}};Base A(1,2);    //定义对象ABase B(2,3);    //定义对象BB = A*2;    //错误,相当于A.operator*(2),但构造函数不允许隐式转换B = 2*A;    //错误,不存在2.operator*(A)

问题解决:

cpp
class Base{};    //Base构造函数允许隐式转换const Base operator*(const Base& A,const Base& B){    ...}Base A(1,4);Base B;B = A*2;    //正确B = 2*A;    //正确

总结:如果一个函数的所有参数对允许隐式转换,那么这个函数应该为非成员函数


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

本条款主要讨论的是关于swap函数,标准库中的swap函数是由模板实现的,传入的参数必须支持复制构造函数或者赋值重载运算。关于本条款,书中还讲解了pipml手法——pointer to implementation,对此我在这里简单提一下。

        什么是pipml手法?有以下特征和优点:

1.自定义的两个类型中,类A实现私有部分,类B继承类A实现对外接口部分。这种定义的手法就叫pipml手法

cpp
class Base{    //只定义私有部分private:    int number;};class Derived : public Base {    //只定义对外接口部分public:    void function(){        //对Base类的私有部分number进行操作    }};//这种实现手法就叫pipml手法

2.优点:更好的封装和隐私,更容易维护和修改,减少编译时间和依赖问题,更灵活的接口设计

写出一个不抛异常的swap函数应该考虑以下几点:

                1.针对自定义类型,提供一个成员swap函数,令其高效的置换自定义类型的两个对象(该swap不该抛出异常,轻则内存泄漏重则程序崩溃。如果可能会抛出异常请及时捕获处理)

2.为提高封装性,在自定义类型的使用范围内应该提供一个非成员的swap函数,令其调用成zdy员中的swap函数

cpp
class Base{public:    void swap(Base& object){}    //针对自定义类型的swap函数};void swap(Base& A,Base& B){    //为提高封装性而定义的非成员的swap函数    A.swap(B);}

3.针对自定义类型,可以对标准库的swap函数进行特化,并使其调用自定义类型中的成员swap函数

总结:以上就是写出一个不抛异常的swap函数需要注意的几点,并且该自定义函数swap不应该放在std名称空间中


Implementations:

  条款26:尽可能延后变量定义式出现的时间

        本条款通俗易懂,了解基础语法的同学都应该知道——对象定义被执行后会调用构造函数,如果该对象被构造后没被调用(例如该对象被调用之前有函数抛出异常的情况,该对象可能就没有被调用),就会造成时间和空间上的浪费,还需要承担该对象的析构成本

总结:确保该对象被调用,再对其提供定义,防止造成时间和空间上的花销


  条款27:尽量少做转型动作

        在了解条款《尽量少做转型动作》之前,我们得知道除去旧式的转型动作,C++还提供了哪些新式转型动作?以下便是四个新式转新动作:

1.const_cast:通常用来将对象的常量性解除

2.dynamic_cast:主要用来执行“安全向下转型”

3.static_cast:主要用来强制隐式转换

4.reinterpret_cast:转型低级转型(整数与指针之间的转换,不同类型的指针之间的转换,void指针与具体类型指针之间的转换,函数指针与数据指针之间的转换)

为什么要少做转型动作?如果非要做转型操作,如何进行?有以下几个理由:

                1.非要转型,尽量使用新式转型动作,功能更多,较旧式转型更容易辨识

2.转型操作可能会导致未定义行为

cpp
class Base{public:    virtual void fun(){}};class Derived : public Base{public:    virtual void fun(){        static_cast<Base>(*this).fun();        //将Derived对象转换为Base对象,调用Base对象中的fun()        //这段转换操作创建了一个匿名的Base对象,执行完后会析构        //会造成数据未保存,其修改的操作是基于副本上执行的        //正确操作: Base::fun();    }};

3.转型操作,无论新旧转型都会导致执行速度变慢(例如在使用dynamic_cast进行转换时,会频繁地检查对象的实际类型,产生额外的内存分配导致速度变慢)

4.针对子类转换为父类的情况,可以使用virtual关键字定义函数,防止转换操作.

        总结:转型操作是一把双刃剑,合理的理由转型操作能如虎添翼。减少使用或清楚哪里使用了转型操作,都能让你对代码的认识更上一步


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

        条款28主要是讲解了关于函数的返回值问题,依稀记得条款21《必须返回对象时,别妄想返回其reference》跟本条款有则异曲同工之妙。如果你了解了条款21,那么对于28也是易如反掌

为什么避免返回引用指向对象内部成分?有以下理由:

1.针对对象的私有变量,不应该使用引用返回,这样会导致其他对象修改其值

cpp
class Base{public:    int& fun() const {    //const成员函数,不能修改成员number        return number;    }private:    int number;};Base A;const int value = A.fun();    value = 4;    //修改value值,其对象A中的number也会被修改

2.返回对象内部成分,会导致其封装性降低(例如:上面例子中的number是私有的,却被一个外部对象value修改)

总结:在使用函数返回值为引用时,还需要确定返回的对象不是在函数内部声明的,谨防虚吊指针(参见条款21)


  条款29:为“异常安全”而努力是值得的

        在了解该条款之前,我需要补充部分知识:第一动态申请内存,如果系统找不到足够的内存会抛出bad_alloc异常(无法分配内存);第二多个异常发生可能会导致程序崩溃。目前需要强调的内容只有这些,接下来便是对条款的解释。

异常未被捕获,会有什么情况发生?有以下几点:

1.如果函数内存在会抛出异常的语句,将会导致前后状态不一

cpp
int value=0,number=0;void function(){ //执行function函数修改value的值    value = 1;    Demo();      //Demo语句抛出异常    number++;  //number用于存储value修改次数}/*    由于Demo()函数抛出异常,系统执行中断    但value值已被修改,number++未被执行,导致状态错误*/

2.多个异常发生可能会导致程序崩溃

        如何处理程序中的异常?有以下几点:

                1.使用关键字noexcept保证函数函数不抛出异常(你敢吗?我不敢)

cpp
void function() noexcept;//使用noexcept修饰function,保证该函数不抛出异常

                2.aopy and swap策略:为你打算修改的对象构造一个独立的副本(深复制),并在副本中对数据进行修改,若修改未抛出异常则覆盖原对象,抛出异常则不覆盖,保持前后状态的统一性

3.使用thorw关键字指出会抛出异常的函数,并且在外部使用try和catch捕获异常

cpp
// 函数中使用 throw 抛出异常void myFunction(int divisor) {    if (divisor == 0) {        throw MyException(); // 抛出自定义异常    }}int main() {    try {        // 调用可能抛出异常的函数        myFunction(0);    } catch (exception& e) {        // 捕获并处理异常    }    return 0;}

4.使用 RAII(资源获取即初始化)

        如何判断程序中异常安全性?有以下几点:

1.程序中异常安全的等级:

1.基本异常安全:程序保证不会发生资源泄漏。如果发生异常,所有已分配的资源都会被正确释放,但程序的状态可能是不一致的

2.强异常安全:程序保证不会发生资源泄漏,并且如果发生异常,程序的状态会恢复到操作前的状态

3.不抛出异常:程序保证不会抛出任何异常

2.程序中的异常安全性会根据等级判断,通常判断等级就是程序中各个函数的“异常安全保证”等级的最弱者(基本异常安全 < 强异常安全 < 不抛出异常)

总结:该条款讲解了异常的危害,如何确保异常抛出并安全的处理以及对程序的异常安全等级评估,需要补充的是系统内只要有一个函数不具备异常安全等级,整个系统就不具备异常安全性,而且是否对函数提供异常处理还需要考虑效率的问题(毕竟为每一个函数提供异常处理对系统来说也是一个负担)


  条款30:透彻了解inline的里里外外 

        针对80-20法则:平均而言一个程序往往将80%的执行时间花在20%的代码上,所以该条款将会对关键字inline进行简单的了解,在方便自己的同时,也得清楚inline会造成的后果

        使用inline关键字系统会如何做?有以下几点:

1.inline关键字会增加目标码(C++中编译器在编译阶段将源代码转换为目标代码)

2.inline只是对编译器的一个申请,不是强制命令(是否转换为inline函数依编译器决定)

3.inline意味着执行之前先将调用动作替换为被调用函数的本体(例如多态情况下,只有运行期才能确定调用哪一个函数,所以针对虚函数等多态行为编译器不会将其编译为inline函数)        

4.编译器通常不对通过函数指针而进行的调用实施inline修饰

cpp
inline void fun(){}         //inline修饰fun函数void (*fun_Ptr)() = fun;    //定义函数指针fun_Ptr指向fun函数fun();        //inline函数fun的调用fun_Ptr();    //通过函数指针调用fun,本次调用不会被inline

针对使用inline修饰函数需要注意什么?有以下几点:

1.将大多数inline函数限制在代码量小,被频繁调用的函数身上。可使日后的调试过程和程序库升级更容易,也可使潜在的代码膨胀问题最小化,使程序的速度提升

2.针对构造函数和析构函数不建议使用inline(针对编译器提供的默认构造函数和析构函数,可能会频繁的调用构造函数或析构函数,这会导致代码量增大)

                3.inline函数如果被修改将会导致需要花费大量时间在编译阶段,而非inline函数不会如此

       总结:只将大多数inline函数限制在代码量小,被频繁调用的函数身上


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

编译依存关系是指在项目中导入了其他头文件和库,本条款将解释为什么要降低文件之间的编译依存关系?要如何降低文件之间的编译依存关系

为什么要降低文件之间的编译依存关系?有以下几点:

1.当项目中的一个头文件内的函数被修改,任何使用了该头文件的文件也需要重新编译,会导致程序编译的时间开销变大

                2.降低文件之间的编译依存关系,会导致较低的依赖性,也使得代码库更容易分割成模块,每个模块可以独立开发、测试和维护,提高效率

        如何降低文件之间的编译依存关系?有以下几点:

                1.使用前置声明,在文件中提供类声明,不提供具体的方法和实现。并将该类对象声明为指针(减少# include的使用,降低编译依存关系)

cpp
class Base;//声明Base类int main(){    Base* Ptr;    //定义类指针     return 0;}

2.针对标准程序库内的类(例如String)。由于涉及模型,前置声明复杂,可以使用# include完成声明

cpp
#include<string>//标准程序库不应该被前置声明

3.使用pimpl idiom手法实现Handle classes,减少实现细节的编译,提高编译速度,减少编译依赖性(两个类,一个类实现细节,一个类对外提供接口)

cpp
class Handle {    //接口类public:    void doSomething();    //操作private:    class HandleImpl;      //定义HandleImpl类作为操作类    HandleImpl* pImpl;     //定义操作类HandleImpl对象pImpl实现操作};class Handle::HandleImpl { //提供HandleImpl类义public:    void doSomethingImpl() {        //具体操作    }};void Handle::doSomething() {    pImpl->doSomethingImpl();    //通过操作类对象pImpl调用doSomethingImpl()实现操作}

4.定义纯虚函数实现接口类,从而实现Handle classes(原理与第三点相同,只是实现方式不同)

5.使用export关键字在指定的动态链接库中导出函数或变量

cpp
export void myFunction();  //使用export导出函数myFunction()

总结:通过以上五个方法可以降低文件之间的编译依存关系,从而降低编译期的时间,毕竟80%的执行时间花费在20%的代码上


Inheritance and Objrct-Orienten Design:

  条款32:确定你的public继承塑膜出is-a关系

本条款重点在于,保证类之间的public继承关系,能够实现用于基类对象对象的函数,也能够用于子类对象,对此我将简单对类之间的关系作描述

针对类的关系,有以下几种:

1.is-a关系:表示类之间的继承关系,适用于base class身上的每一个函数也一定适用于derived class身上。意味着derived class的对象也是一个base class对象

2.has-a关系:表示类之间的组合关系,意味着类中的成员是另一个类的对象

3.is-implemented-in-terms-of关系:表示类之间的实现关系,一个类的实习依赖于另一个类,例如类之间的private继承

总结:简单了解这些类之间的关系,根据需求定义类之间的关系,而不是一昧的使用public继承


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

本条款讲述的是避免遮掩继承而来的名称,对于遮掩一词我觉得覆盖会更合适也更容易理解,在这我将提醒一点:重载函数在类的继承中并不适用。所以对此我将分析什么情况下会遮掩继承而来的名称,这种情况应该如何避免?

有以下情况会导致继承而来的名称被遮掩:

1.如果子类要调用基类函数,而子类实现了该函数,区别只是在于函数重载了,这时由于类之间是继承关系,导致子类的作用域内的函数覆盖父类的函数(无论函数是否为虚函数,都会导致子类作用域内的函数覆盖父类的函数)

cpp
class Base{public:    virtual void fun_1(int value){};    //虚函数fun_1接受一个参数value};class Derived:public Base{public:    void fun_1(){};  //函数fun_1不接受参数};//以上两个fun_1函数像是函数重载,但并不是int main(){    Base object_B;    Derived object_D;    object_B.fun_1(10);    //调用合理,因为Base中的fun_1接受参数    object_D.fun_1();      //调用合理,因为Derived中的fun_1不接受参数    object_D.fun_1(20);;   //调用不合理,因为Derived中的fun_1遮掩了Base中的fu_1    return 0;}

        如何防止函数被遮掩?有以下做法:

1.要使用父类对应的函数可以直接使用using声明,声明父类对应的函数,使其在子类中可见

cpp
//使用using声明使Base中的函数可见class Derived:public Base{public:    using Base::fun_1;    void fun_1(){};  };

2.使用转交函数,既定义一个新的函数,函数内对父类的函数使用using声明,然后在该转交函数内进行父类函数的调用

cpp
//使用转交函数class Derived:public Base{public:    void function(int value){    //定义转交函数function         Base::fun_1(value);    }    void fun_1(){};  };

总结:通过该条款就知道频繁的使用publi继承也会存在一些不容易发现的细节,为了让这些遮掩的名称重见天日,可以使用using声明或者转交函数

19次原文点赞;这里的喜欢仅保存在本机
WildPointer

专注系统编程、工程实践与底层技术,记录 C++、Qt、OpenCV 与 VTK 的学习和实践。