【读书笔记】【More Effective C++】效率(Efficiency)

条款 16:谨记 80-20 法则

  • 二八原理指一件事情的 20% 需要投入 80% 的精力来做,即要分清主次点。
    • 这种情况在程序编写的时候尤为突出:关键性能点、重要逻辑代码一般都是集中在小部分区域,而这部分区域需要我们特别关注。
  • 我们要使用可重现的测试用例对程序进行测试,否则无法知道程序瓶颈、问题到底出在什么地方。

条款 17:考虑使用 lazy evaluation(缓式评估)

  • lazy evaluation 是一种延缓运算的思想,其体现在:只有在真正用到该值的时候,才对数值进行计算;如果不需要结果,将永远不进行计算。
  • 下面将从四种用途进行介绍,分别是引用计数区分读和写缓式取出表达式缓评估
  • Reference Counting(引用计数)
    • 将字符串 s1 赋值给 s2 时,有如下两种方案:
      1. 直接复制一个副本给 s2;【也就是所谓的 eager evaluate(急式评估),即立即求值】
      2. s2 只是指向 s1,当需要对 s2 修改时,才真正复制 s1 中的内容。【缓式评估】
    • 采用 lazy evaluation 的思想,就省去了即时调用 new 以及复制任何东西的高昂成本。
    • 唯一要做的是一些簿记工作,以记录共享同一内容的各个对象;对 s2 的任何读操作,只需要 s1 的值即可;然而,一旦需要对 s2 的值进行写操作,就不能再做任何拖延,必须为 s2 做一份真实副本并进行写操作。
    • 这种数据共享的观念就是 lazy evaluation:在真正需要之前,不为对象构造副本;在某些应用领域,可能永远也不需要提供那样一份副本,从而提高效率。
  • 区分读和写
    • 在引用计数的讨论中,我们知道了对于一个只读的数据,永远也不需要提供一份真实副本,所以对于一些操作来说,如果能区分是在读还是在写,就能更好的实现 lazy evaluation。
    • 如对于 operator [] 的操作就是可读可写:
      String s1="Hello World!";
      String s2=s1;
      cout<<s2[0];   // 读操作
      cin>>s2[1];    // 写操作
      
    • 如果能区分 operator [] 的读写动作,将可实现不同的操作,但实际上很难立即确定是处于读的环境还是处于写的环境。
    • 前面说的是“很难立即确定”,所以如果利用 lazy evaluation 和条款 30 的代理类,可以延缓决定读还是写,直到能确定答案为止。
  • Lazy Fetching(缓式取出)
    • 假设一个大型对象中有很多个字段,而经常被读取的只是其中个别字段,那么读取的时候直接创建整个对象的开销太巨大了。【读取此类对象的所有数据的操作其实是不必要的】
    • 采用 Lazy evaluation 的思想,在产生一个对象时,可以只产生该对象的空壳,而不从磁盘读取任何数据;只有当该对象的某个字段被需要时,才从数据库中取回对应的数据。【从而加速单次响应
    • 以下代码可以实现这种 demand-page 式的对象初始化行为:【mutable 关键字的作用很关键,其用于修饰类字段,使这些字段可以在 const 函数中被修改
      class LargeObject{
      public:
          LargeObject(ObjectID id);  // 根据 id 识别对象
          const string&field1()const;   // 不同字段
          int field2()const;
          double field3()const;
          const string& field4()const;
          const string& field5()const;
          ...
      private:
          ObjectID oid;
          // 对象内的每个字段都是指针,指向必要的数据
          mutable string *field1Vaule;// 注意使用了mutable修饰符
          mutable int *field2Value;
          mutable double *field3Value;
          mutable string *field4Value;
          ...
      }
      // 各参数值初始化为 0,即空指针 NULL。 
      LargeObject::LargeObject(Object  id):oid(id),field1Value(0),field2Value(0),field3Value(3)...{}
      const string& LargeObject::field1()const{
          // 判断指针是否为空,如果为空,则要进行读取
              // NULL初始值表示该字段未被读入,需要先从数据库读入
          // 将字段指针声明为mutable,可以保证字段指针可以**在任何时候都能被更改**以指向实际数据(即使是在const成员函数内也一样)
          if(field1Value==0){ 
              read the data from field 1 from the database and make field1Vaule point to it;
          }
          return *field1Value;
      }
      
    • 如果是在不支持 mutable 的场景下,则在 const 成员函数中可以使用 const_cast 移除 this 的常量特性:
      const string& LargeObject::field1()const{
          LargeObject* const fakeThis=const_cast<LargeObject*const>(this);
          if(field1Value==0){
              fakeThis->field1Value=the proper data from the database;
          }
          return *field1Value;
      }
      
  • Lazy Expression Evaluation(表达式缓评估)
    • 本节最后一种 on-demand 技术的应用是:推迟一些复杂的计算。
      • 计算两个大矩阵的乘法将会消耗大量资源,但是我们可能只需要计算结果的一小部分数据,那么对其他数据的计算与存储都做了无用功。
      • 可以用代码来进行讨论分析:
        // 矩阵运算的场景如下:
        template<class T>
        class Matrix{...}
        Matrix<int> m1(1000,1000);
        Matrix<int> m2(1000,1000);
        // ...
        Matrix<int>m3=m1+m2;// 将m3定位为m1和m2的和
        
      • 采用 lazy evaluation 的思想,可以先设一个数据结构于 m3 中,用于标记 m3m1m2 的总和:【这个数据结构可能只由两个指针一个 enum 组成:前者指向 m1m2,后者用来表示运算动作】
        // 假设接下来对m3使用之前,进行了更新:
        Matrix m4(1000,1000);
        m3=m4*m1;// 可以直接将m3定位为m4和m1的乘积
        
        // 但如果读m3的某个位置进行读取,就不能再拖延:
        cout<<m3[4];
        // 在此时,也只需要计算m3第四行的值,不需要计算所有的值
        
        // 但是有时候缓式评估并没有意义,例如:
        cout<<m3;// 读取整个矩阵
        
        m3=m1+m2;
        m1=m4;// 在此时需要保证m1的改变不会影响到m3,即m1和m3的相依关系导致做了额外的维护工作
        
  • 总结一下:【首先要知道 lazy evaluation 并非 C++ 的专属技能】
    • lazy evaluation 在许多领域中都可能有用途:可避免非必要的对象复制、可区别 operator[] 的读取和写操作、可避免非必要的数据库读取动作和可避免非必要的数值计算动作。
    • 但是提升效率的前提是(部分)计算可能可以被避免;否则不仅不能提升效率,还需要付出维护的代价。

条款 18:分期摊还预期的成本

  • 三种策略简介:【无非就是时间与空间的博弈:用延长时间换来空间减小,或者用空间减小换来时间缩短】
    • lazy evaluation(延缓求值):只有在真正需要数据的时候,才对计算进行求值。
    • eager evaluation(马上求值):只要出现计算表达式,就进行求值。
    • over eager evaluation(超前求值):一种分摊策略。(之所以有超前求值,是因为前面两种方案都没有考虑到:一次性的大规模计算会让用户长时间等待)
  • 三种策略的实例分析:
    • 假设一个用于表现数值数据的大型收集中心:
      template<class NumericalType>
      class DataCollection{
      public:
          NumericalType min()const;
          NumericalType max()const;
          NumericalType avg()const;
          ...
      }
      // min、max、avg函数分别返回数据群的最小、最大、平均值
      
    • 实现方法一(lazy):令这些函数返回某些数据结构,直到这些函数的返回值真正被派上用场时,才计算它们的值。
    • 实现方法二(eager):在 minmaxavg 被调用时才检查所有数据,然后返回检查结果。
    • 实现方法三(over eager):随时记录程序集执行过程中的最小、最大、平均值,一旦 minmaxavg 常被调用,便可以分期(逐次)摊还随时记录数据群之最大、最小、平均值的成本。【该方法通过将大规模计算分摊到其他操作中,并将计算结果永久性保存下来,降低了单次用户响应时间】
  • over eager evaluation的应用场景:【较快的速度往往导致较大的内存成本】
    • cache(缓存)策略:如果内存空间足够大,可以在进行其他操作的时候,顺带将磁盘中的数据库数据读取到内存中,每次访问某条数据,直接读取内存中的变量即可。
    • prefetch(预取出)策略:cache 策略只取出并暂存需要的数据。由于 2/8 原则,可以将当前需要数据临近区域的数据取出作进一步缓存,存储在内存中,从而提高数据访问速度。(例如系统设计者设计出的磁盘缓存、指令与数据的内存缓存以及指令预先取出)
      • std::vector(动态数组)的空间分配:每次 vector 的空间耗尽时,若继续向 vector 中存储数据,系统会在新分配的空间在内存扩张时,分配 2 倍原有空间,以防止后续插入操作,再次引起原有数据的复制操作。【vector 的扩容就是 prefetch 思想的体现】
  • over-eager evaluation 和条款 17 的 lazy evaluation 并不矛盾:
    • 当必须支持某些运算但运算结果并不总是需要的时候,lazy evaluation 可以改善程序效率;
    • 当必须支持某些运算且其结果总是被需要,或常常被需要的时候,over-eager evaluation 可以改善程序效率。
    • 两者都比直接了当的 eager evaluation 难实现,但都可以为程序带来巨大的性能提升。

条款 19:了解临时对象的来源

  • C++ 临时对象并不是程序员创建的用于存储临时值的对象,而是指编译器层面上的临时对象。
    • 这种临时对象不是由程序员创建,而是由编译器为了实现某些功能(例如函数返回、类型转换等)而创建。【无名对象】
    • 临时对象不是由程序员创建,其生存期由编译器掌控;因而也就不允许程序员对其进行更改,将其绑定到 non-const 左值引用也就被禁止。【所有编译器都禁止将内置类型的 non-const 左值引用绑定到内置类型的临时变量】【有些编译器支持将类类型的 non-const 左值引用绑定到对应类类型的临时对象】
  • 临时对象通常在两种情况下被产生:
    • 隐式类型转换在参数匹配时发生,以使函数能够调用成功:【值传参const 引用传参均可能会产生临时值(const 引用传参与实参不匹配时会产生临时值),non-const 引用传参和指针传参则不会产生类型转换】
      // 情形1:1.23临时转换,得到一个临时int类型对象,然后赋值给a
      int a = static_cast<int>(1.23); 
      
      // 情形2:实参与形参类型不一致,实参转换类型,生成string临时对象
      // 该情形只发生在“按值传参”和“const 引用传参”的情况下
      // 而在“引用传参”和“指针传参”的场景下,会严格按照类型进行匹配,不会出现类型转换
      int countChar(const std::string& str); // 注意参数是 const 引用,所以后续参数传入会产生类型转换,其中就会产生临时值
      char *str = "hello world";
      countChar(str); // 函数返回,临时对象被销毁
      
      int countChar(std::string &str); // 注意参数是 non-const 引用,所以后面调用错误
      countChar(str); // 错误,char*和std::string&类型不兼容
      
    • 函数返回对象的时候。【需要特别注意不能返回函数内局部变量的引用】【RVO(return value optimization,即返回值优化)技术用来避免返回值带来的临时变量开销,将在条款 20 中进行讨论】
  • 临时对象可能很消耗资源,因此找出并协助编译器消除它们可能会给程序带来性能上的提升。

条款 20:协助完成“返回值优化(RVO)”

  • 函数如果返回对象,就会产生临时对象的构造、析构等过程。
  • 令函数返回指针来消除临时对象的方法是行不通的,实际上,令函数返回指针或引用是一个不好的习惯:
    • 如果返回的指针指向的是函数内创建的 non-static 对象,那么当离开函数作用域时,该对象就被销毁,任何企图通过指针访问其指向的内存的行为都会导致程序错误。
    • 如果函数返回的是 heap 对象,那么就增加了额外的手动释放内存的负担。
  • 也就是说:通过返回指针或引用的方式去避免返回值产生临时对象的想法是不可取的。即函数以 by-value 的方式返回结果是不可避免的。
  • 但是有些成本是可以降低的。
    • 想要降低返回值时产生临时对象的成本,需要编译器的帮助。
    • 具体来说,需要以某种特殊的写法来写函数,编译器识别到之后会对其进行优化,进而消除临时对象的成本,编译器的这种优化被称为 RVO(return value optimization,返回值优化)。
    • 实例分析:
      // 片段1
      	// 在本片段中,编译器会在函数中创建一个匿名的临时变量
      	// 这个匿名变量的值,是经过有名变量temp的拷贝得来(生成匿名变量后,程序员设置的有名局部变量temp会被析构)
      	// 在主函数中,编译器会运用匿名临时变量来拷贝构造结果,在这里就是函数外部的result
      // 经过分析,可以知道,没有优化的版本是必然有额外的构造和析构代价
      MyClass result = obj1 * obj2;
      const MyClass operator * (const MyClass& lhs, const MyClass& rhs)
      {
      	MyClass temp = // do something
      	return temp;
      }
      
      // 片段2【协助RVO优化的版本】【以构造函数替代临时对象】
      	// 直接返回一个构造函数,即程序员不再显式建立一个临时变量temp
      const MyClass operator * (const MyClass& lhs, const MyClass& rhs)
      {
      	return MyClass(lhs.num() * rhs.num(), lhs.num0() * rhs.num0());
      }
      
  • 总结:
    • 程序员无法消除 by-value 返回结果时产生的临时对象,但可以辅助编译器进行优化,编译器会通过所谓的 RVO 优化技术来进行优化。
    • 而 RVO 优化技术究竟底层是如何实现,本章节没有深入讨论,在《在深度探索C++对象模型》书中有 RVO 更深一层的讨论,大致上就是编译器将函数改写,而要返回的结果会变成一个传参(如上面片段 2 所示,返回的是构造函数的参数)。【利用函数的 return 点消除一个局部临时对象】
    • 补充理解 NRVO(named return value):
      • 比 RVO 更进一步,它可以对具名返回值做优化,消除构造和析构临时对象的成本,如下所示代码就可被优化:
        const MyClass operator * (const MyClass& lhs, const MyClass& rhs)
        {
            MyClass tmp(lhs.num() * rhs.num(), lhs.num0() * rhs.num0());
            return tmp;
        }
        
      • NRVO 就是比 RVO 做多了一步(优化结果是一致的),因为编译器主动做了很多工作,所以很有可能结果并不是程序员的原意。

条款 21:利用重载技术(overload)避免隐式类型转换(implicit type conversions)

  • 正如条款 19 所言,临时对象的构造和析构会增加程序的运行成本,因此有必要采取措施尽量避免临时对象的产生:
    • 条款 20 介绍了一种用于消除函数返回对象而产生临时对象的方法(即 RVO 优化)。
    • 本条款(条款 21)建议考虑利用重载技术避免隐式类型转换。
  • 避免隐式类型转换有两种方法:
    1. 构造函数由 explicit 关键字修饰。
    2. 重载函数通过不同函数签名来识别:【但不推荐使用大量重载函数,会使软件更难维护】
    const UInt operator+(int lhs, const UInt& rhs);
    const UInt operator+(const UInt& lhs, int rhs);
    const UInt operator+(const UInt& lhs, const UInt& rhs);
    // 以上都正确,下面错误
    const UInt operator+(int lhs, int rhs); // 重载的和库中的签名一致(即函数名和参数变量均一致),错误。
    
  • 有了更匹配的重载函数,很多意想不到的隐式类型转换就可以得到避免。

条款 22:考虑以操作符复合形式(op=)取代其独身形式(op)

  • 本条款的意思是:以 += 取代 + 的使用。【本条款其实也是基于临时对象而提出】
  • 单独操作符会涉及临时变量的拷贝,而复合操作符并不涉及临时变量的拷贝,所以优先使用复合形式。
    • 通常来说 + 都是由 += 实现得来,如果 + 的实现版本不是基于 +=,那么可能 result=a+b+c+d 没有 result+=a+=b+=c+=d 效率更快。【因为使用 + 的式子可能会产生三个临时变量】
    • 来看看如何用 += 操作符来实现 + 操作符:【用 += 来实现 + 是《Effective C++》条款 21 中介绍的方法,其实就相当于只有 += 需要被维护】
      template<class T>
      T& operator+=(const T& rhs);
      
      // 可以使用template来实现
      template<class T>
      const T operator +(const T& lhs, const T& rhs)
      {
      	return T(lhs) += rhs;
      }
      
  • 之所以用 += 实现 + 能避免临时对象的开销,仍然是因为 RVO:
    • 关键在于 T(lhs) 调用了拷贝构造函数产生了一个临时对象,而这个临时对象被用来调用 +=,仍然返回该临时对象的引用(也就是返回自身)。【所以编译器就可以利用 RVO 来优化】
  • 但是书中说下述代码是无法被优化的:【RVO 无法优化下述代码】
      template<class T>
      const T operator +(const T& lhs, const T& rhs)
      {
          T result(lhs);   // 将 lhs 复制给 result
      	return result += rhs;   // 将lhs 加到 result 上然后返回
      }
    
    • 但目前已经出现了 NRVO,所以虽然 RVO 优化无法被触发,但是 NRVO 优化可以被触发。

条款 23:考虑使用其他程序库

  • 本条款着重比较了 iostream 和 stdio 程序库。
    • stdio 提供的 I/O 操作速度通常比 iostream 快,可执行文件也比 iostream 小;【I/O 效率要求高】
    • iostream 却提供了更好的扩展性和类型安全性。【健壮性和可扩展性】

条款 24:了解 virtual functions、multiple inheritance、virtual base classes、runtime type identification 的成本

  • 成本一:维护 virtual table

    • virtual table(vtbl)通常是一个函数指针的数组或链表,每一个声明或继承虚函数的类都有自己的 vtbl,其中的每一个元素就是该类的各个虚函数的指针
    • 通常,virtual table 的存放会采取下面两种方式之一:【每个类只有一个 vtbl】【C++ 支持多文件编译,而每一个目标文件都是独立生成的,所以类的 vtbl 放在哪一个目标文件就需要选择】
      1. 【暴力式】在每一个需要 vtbl 的目标文件内都产生一个 vtbl 副本,最后由链接器剥除重复副本。
      2. 【勘探式】将 vtbl 产生于内含其第一个 non-inline、non-pure 虚函数定义式的目标文件中。
        • 勘探式的做法要求类必须有一个 non-inline 的虚函数
        • 如果所有函数都声明为 inline,本做法便告失败。
        • 实际上,后面的讨论会涉及:尽量避免将虚函数声明为 inline。(目前很多编译器都忽略虚函数的 inline 请求)
  • 成本二:维护 virtual table pointer

    • 前面成本一讲的是每个 class 维护了一个 vtbl,而成本二讲的是每个 class 对象维护了一个指向该类 vtbl 的指针,即 vptr,该指针可以用于标识该对象的动态类型。【注意动态类型和静态类型的区别】
    • 当我们通过基类指针调用虚函数的时候,首先通过 vptr 找到当前对象指向的 class 的 vtable,再用函数的索引直接调用函数即可。
    • 虚函数的调用成本基本上和通过一个函数指针来调用函数相同,虚函数本身并不构成性能上的瓶颈。
  • 成本三:虚函数与 inline 的互动。【成本就是丧失了 inline 的使用】

    • 原则上不应该对虚函数实行 inline:
      • inline 意味着在编译期将调用端的调用操作被被调用函数的函数本体取代
      • 而 virtual 则意味着等待、直到运行期才知道哪个函数被调用
  • 成本四:虚函数和多重继承的互动。【多重继承机制导致开销加大,而设置为虚继承,能稍微减轻】

    • 多重继承下,找出对象内的 vptr 会比较复杂:
      • 此时一个对象内会有多个 vptr,每个 base class 各对应一个
      • 针对 base classes 而形成的特殊 vtbl 也会被产生出来。
    • 多重继承机制导致开销加大,且特殊的菱形继承会产生很多问题,所以往往需要 virtual base classes(虚基类),也就是设置虚继承:
      • 然而虚基类也可能导致另一成本:中间的派生类会携带一个指向虚基类的指针;所以最后的布局中,一个对象可能携带多个这样的指针。(要注意这里提及的指针不是 vptr)
      • 如下图所示,除了 vptr,派生类还携带指向虚基类的指针:【具体这个对象是如何生成的以及更多细节,可以看书本内容,这里不展开】
        指向虚基类的指针
  • 成本四:RTTI(runtime type identification,即运行时类型识别)。【RTTI 与虚函数息息相关】

    • RTTI 使得可以在运行时获得 objects 和 classes 的相关信息,因此其实现必须需要一些内存来存储那些信息:
      • 类型信息用 type_info 类型的对象存放,可以用 typeid 操作符取得 class 对应的 type_info 对象。
    • 一个类只需要一份 RTTI 信息,这和 vtbl 的理念相符合,所以 RTTI 便是根据 class 的 vtbl 来实现
      • 通常在 vtbl 索引为 0 的元素处存放一指针,用来指向该vtbl所对应的class相应的 type_info 对象
    • 运用这种实现方法,RTTI 的运行成本就只需要在每一个 class vtbl 内增加一个条目。
    • 也正是因为大多数编译器采取了这种实现方法,所以 RTTI 和虚函数就有了联系:
      • 因为要某个类要使用 RTTI,就必须有 vtbl;而要有 vtbl,就必须有虚函数。
      • 也就是说,没有虚函数的类是无法使用 RTTI 的,也就无法进行 dynamic_cast。
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值