《Effective C++》(五)

来源:互联网 发布:荣耀盒子pro 软件 编辑:程序博客网 时间:2024/03/29 08:22

  • 实现
    • 条款26尽可能延后变量定义式的出现时间
    • 条款27尽量少做转型动作
    • 条款28避免返回handles指向对象内部成分
    • 条款29为异常安全而努力是值得的
    • 条款30透彻了解inlining的里里外外
    • 条款31将文件间的编译依存关系降至最低

5 实现

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

1.只要定义了一个变量而且这个类型有一个构造函数或析构函数,那么,我们的程序到达这个变量的定义式时,就不得不承受构造的成本,当我们的变量离开作用域时,就要承担析构的成本。即使这个变量没有被使用。

2.考虑如下函数,他计算通行密码的加密版本而后返回。如果密码太短,函数会丢出一个异常:

std::string encryptPassword( const std::string& password )  {      using namespace std;      string encrypted;      if( password.length() < MinimumPasswordLength )  {      throw logic_error("Password is too short" );      }      ...    // 必要动作,将一个加密后的密码置入变量 encrypted内      return encrypted;  } 

以上encrypted变量在函数内可能被使用,但如果抛出了一个异常,构造和析构的成本还是要承担,但是其实这个变量却没用上。

3.把定义式放在抛出异常之后

std::string encryptPassword( const std::string& password )  {      using namespace std;      if( password.length() < MinimumPasswordLength )  {      throw logic_error("Password is too short" );      }      string encrypted;  //定义式放在抛出异常之后    ...    // 必要动作,将一个加密后的密码置入变量 encrypted内      return encrypted;  } 

这段代码仍然不够契合,因为encrypted虽获定义却无任何实参作为初值。这意味调用的是其default构造函数。依据条款4,”通过default构造函数构造出一个对象然后对它赋值” 比 “直接在构造时指定初值” 效率差。
所以,真正受欢迎的是这样的:

std::string encryptPassword( const std::string& password )  {      ...      std:string encrypted(password);    // 通过copy构造函数定义并初始化      encrypt(encrypted);  //加密函数    return encrypted;  } 

4.”尽可能延后”:不只应该延后变量的定义,直到非得使用该变量的前一刻为止,甚至应该尝试延后这份定义直到能够给它初值实参为止。这样做,不仅能够避免构造(和析构)非必要对象,还可以避免无意义的default构造行为。更深一层的说,以”具明显意义之初值”将变量初始化,还可以附带说明变量的目的。

5.对于循环:

// 方法A 定义于循环外  Widget w;  for( int i = 0 ; i < n ; ++i )  {      w = 取决于i的某个值;      ...  }  // 方法B:定义于循环内  for( int i = 0 ; i < n; ++i )  {      Widget w(取决于i的某个值);      ...  } 

方法A成本:1个构造函数 + 1个析构函数 + n个赋值操作
方法B成本:n个构造函数 + n个析构函数
所以,除非class的一个赋值成本低于一组构造+析构成本,否则应该选择方法B。另外方法A的变量w的作用域比方法B大,也须考虑。

6.小结:尽可能延后变量定义式的出现。这样做可增加程序的清晰度并改善程序效率。

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

1.旧式转型
C风格的转型动作: (T)expression
函数风格的转型动作: T(expression)

2.四种新式转型(常常被称为 new-style 或者 C++-style )
const_cast ( expression ) :这种通常用来将对象的常量性转除。它也是唯一有此能力的C++-style转型操作符。
dynamic_cast ( expression ):可能耗费重大运行成本的转型动作。
reinterpret_cast ( expression )
static_cast ( expression ):用来执行强迫隐式转换,或执行上述多种转换的反向转换

3.旧式转型仍然合法,但新式转型较受欢迎。原因有二:
①它们很容易在代码中被辨识出来(不论是人工辨识或使用工具),因而得以简化“找出类型系统在哪个地点被破坏”的过程。
②各转型动作的目标愈窄化,编译器愈可能诊断出错误的运用。

4.任何一个类型转换往往真的令编译器编译出运行期间执行的码,而并非只是告诉编译器把某种类型视为另一种类型。

5.某些似是而非的代码

class Window  {    // 基类  public:    virtual void onResize()  { ... }    ...  };  class SpecialWindow : public Window  {    // 派生类  public:    virtual void onResize()  {      static_cast<Window>(*this).onResize();    // 将*this转型为Window,然后调用其onResize  ,这样是错的      ...    }    ...  }; 

这段程序预期将*this转型为Window,对函数onResize的调用也因此调用了Window::onResize。但它调用的并不是当前对象的函数,而是稍早转型动作所建立的一个”*this对象的基类成分”的暂时副本上的onResize。
解决方法就是:拿掉转型动作

class SpecialWindow : public Window  {  public:    virtual void onResize()  {      Window::onResize();    // 调用Window::onResize作用于*this身上      ...    }    ...  }; 

6.dynamic_cast的许多实现版本执行速度相当慢,一定要避免一连串的 cascading dynamic_casts

7.小结:
<1>如果可以,尽量避免转型,特别是在注重效率的代码中避免 dynamic_casts。如果有个设计需要转型动作,试着发展无需转型的替代设计。
<2>如果转型是必要的,试着将它隐藏于某个函数的背后。用户随后可以调用该函数,而不需要将转型放进他们自己的代码内。
<3>宁可使用C++-style转型,不要使用旧式转型。新式转型容易辨识,并且有着分门别类的支持

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

1.假设定义一个矩形,每个矩形由其左上角和右下角表示。

class Point {    // 描述点 的类  public:  Point( int x ,int y );  …  void setX(intnewVal);  void setY(intnewVal);  …  };  struct RectData  {    //用这些”点”数据用来表现一个矩形  Point ulhc;    // 左上角  Point lrhc;    // 右下角  };  class Rectangle  {  public:  …  Point& upperLeft() const {  return pData->ulhc; }  //用以计算Rectangle的范围Point& lowerRight() const {  return pData->lrhc; } private:   std::tr1::shared_ptr<RectData> pData;  };

以上设计可以通过编译,但是它是错误的。实际上它是自我矛盾的——
①upperLeft和lowerRight被声明为const成员函数,因为它们的目的只是为了提供客户一个得知Rectangle相关坐标点的方法,而不是让客户修改Rectangle;
②两个函数却都返回reference指向private内部数据,调用者于是可通过这些 reference 更改内部数据。

2.成员变量的封装性最多只等于”返回其reference“的函数的访问级别。如上例中pData虽然是private,但函数upperLeft和lowerRight返回了其reference,所以pData的访问级别实际为public

3.如果const成员函数传出一个reference,后者所指数据与对象自身有关联,而它又被存储于对象之外,那么这个函数的调用者可以修改那笔数据。

4.reference、指针 和 迭代器统统都是所谓的 handles,而返回一个“代表对象内部数据”的handle,随之而来的便是“降低对象封装性”的风险。

5.对返回类型加上const可以解决写权限的问题

class Rectangle  {  public:  …  const Point& upperLeft() const { return pData->ulhc;  }  const Point& lowerRight() const { return pData->lrhc;  }  …  }; 

但是,即便如此,upperLeft和lowerLeft还是返回了“代表对象内部”的handles,这些还是有可能在其他场合带来问题。更准确的来说,可能导致 dangling handles(空悬的号码牌):这种handles所指东西(的所属对象)不复存在。这种“不复存在的对象”最常见的来源就是函数返回值。

6.小结:避免返回handles(包括references、指针、迭代器)指向对象内部。遵守这个条款可增加封装性,帮助const成员函数的行为像个const,并将发生“虚吊号码牌”的可能性降至最低。

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

1.关于异常安全性的两个条件
① 不泄露任何资源
② 不允许数据败坏

2.假设有一个用于多线程环境的 class ,用来表现夹带背景图案的GUI菜单

    class PrettyMenu  {      public:        ...        void changeBackground(std::istream& imgSrc);    // 改变背景图像        ...      private:        Mutex mutex;    // 互斥器        Image* bgImage;    // 目前的背景图像        int imageChanges;    // 背景图像被改变的次数      };      void PrettyMenu::changeBackground(std::istream& imgSrc )      {        lock(&mutex);    // 取得互斥器        delete bgImage;    // 摆脱旧的背景图像        ++imageChanges;    // 修改图像变更次数        bgImage = new Image(imgSrc);    // 安装新的背景图像        unlock(&mutex);    // 释放互斥器      }  

本例子对于上述异常安全两个条件,一个也没满足
① 一旦 “new Image(imgSrc)” 导致异常,对unlock的调用就绝对不会执行,于是互斥器就永远被锁住了。
② 如果 “new Image(imgSrc)” 抛出异常,bgImage就是指向一个已被删除的对象,imageChanges也已被累加,而其实并没有新的图像被成功安装起来。

3.用条款14中的“对象管理资源”方法解决上例中资源泄漏问题

void PrettyMenu::changeBackground(std::istream& imgSrc)  {    Lock m1(&mutex);  //Lock class的对象析构函数中释放mutex  delete bgImage;    ++imageChanges;    bgImage = new Image(imgSrc);  } 

4.异常安全函数(Exception-safe functions),需要提供以下三个保证之一
① 基本承诺:如果异常被抛出,程序内的任何事物仍然保持在有效状态下。没有任何对象或数据结构会因此而败坏,所有对象都处于一种内部前后一致的状态(例如所有的class约束条件都继续获得满足)。然而程序的现实状态恐怕不可预料。
② 强烈保证:如果异常被抛出,程序状态不改变。调用这样的函数需有这样的认知:如果函数成功,就是完全成功,如果函数失败,程序会回复到“调用函数之前”的状态。
③ 不抛掷(nothrow)保证:承诺绝不抛出异常,因为它们总是能够完成它们原先承诺的功能。作用于内置类型身上的所有操作都提供nothrow保证。这是异常安全码中一个必不可少的关键基础材料。

5.使用智能指针使changeBackground提供强烈保证

class PrettyMenu  {      ...      std::tr1::shared_ptr<Image> bgImage;      ...  };  void PrettyMenu::changeBackground(std::istream& imgSrc)  {      Lock m1(&mutex);      // 以"new Image"的执行结果设定bgImage的内部指针      bgImage.reset(new Image(imgSrc));      ++imageChanges;  }

3和5这两个改变几乎足够让 changeBackground 提供强烈的异常安全保证。唯一差的就是 参数imgSrc,因为如果 Image 构造函数跑出异常,有可能输入流的读取记号被移走,这样会给程序的其他部分带来麻烦;因此 在解决这个问题之前, changeBackground 仅仅提供 基本的异常安全保证。

6.copy and swap原则就是为你打算改动的对象做一个副本,然后在副本上做修改,修改完成后,将副本替换原件。这样如果修改期间抛出异常,原件是没有收到影响的。
它的实现通常是将所有”隶属对象的数据”从原对象放进另一个对象内,然后赋予原对象一个指针,指向哪个所谓的实现对象( implementation object ),这种手法常常被称作pimpl idiom(在条款31中详述)。

struct PMImp1  {      std::tr1::shared_ptr<Image> bgImage;      int imageChanges;  };  class PrettyMenu  {      ...  private:      Mutex mutx;      std::tr1::shared_ptr<PMImp1> pImp1;  };  void PrettyMenu::changeBackground(std::istream& imgSrc)  {      using std::swap;      // 获得mutex的副本数据      Lock m1(&mutex);      std::tr1::shared_ptr<PMImp1> pNew(new PMImp1(*pImp1) );      // 修改副本      pNew->bgImage.reset(new Image(imgSrc));      ++pNew->imageChanges;      // 置换数据,释放mutex      swap(pImp1,pNew);  }

7.当强烈保证不切实际时,我们要退而求其次寻求基本保证,对许多函数而言, 异常安全性之基本保证 是一个绝对通情达理的选择。一个软件系统要不就具备异常安全性,要不然就全然否定,没有所谓的”局部异常安全系统“。

8.小结:
<1>异常安全函数即使发生异常也不会泄露资源或允许任何数据结构败坏。这样的函数区分为三种可能的保证:基本型、强烈型、不抛异常型。
<2>”强烈保证“往往能够以 copy and swap 实现出来,但”强烈保证“并非对所有函数都可实现或具备现实意义。
<3>函数提供的”异常安全保证“通常最高只等于其所调用之各个函数的”异常安全保证“中的最弱者。

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

1.inline函数看起来像函数,动作像函数,比宏好很多,可以调用它们又不需要蒙受函数调用所招致的额外开销

2.inline函数也可能增加目标码(object code)的大小,inline造成的代码膨胀会导致额外的换页行为,降低指令告诉缓存装置的击中率,以及伴随这些而来的效率损失

3.inline只是对编译器的一个申请,不是强制命令。
明确声明做法则是在其定义式前加上关键字inline。也可以隐喻的提出inline:

class Person  {  public:      ...      // 一个隐喻的inline申请:age被定义于class定义式内      int age() const  {  return theAge;  }      ...  private:      int theAge;  }; 

4.Inline函数通常一定被置于头文件内,因为大多数建置环境在编译过程中进行inlining;Template 通常也被置于头文件内,因为它一旦被使用,编译器为了将它具现化,需要知道它长什么样子。
但是,Template的具现化与inlining无关。

5.大部分编译器拒绝将太过复杂(含有循环或递归)的函数inlining,而所有对virtual函数的调用也都会使inline落空。因为virtual意味着“等待,直到运行期才确定调用哪个函数”,而inline意味着“执行前,先将调用动作替换为被调用函数的本体”。

6.有时候虽然编译器有意愿inlining某个函数,还是可能为该函数生成一个函数本体。
比如,如果程序要取某个inline函数的地址,编译器通常必须为此函数生成一个 outlined 函数本体。毕竟编译器无法让一个指针指向并不存在的函数。
而且,编译器通常不对“通过函数指针而进行的调用”实施inlining,这意味对inline函数的调用可能被inlined,取决于该调用的实施方式:

    // 假设编译器有意愿inline“对f的调用”      inline void f()  {  ...  }        // pf 指向 f      void (*pf)() = f;      ...      // 这个调用将被inlined,因为他是一个正常调用      f();      // 这个调用或许不被inlined,因为它通过函数指针达成      pf();  

7.构造函数和析构函数往往是inlining的糟糕候选人。因为当你创建一个对象,其每一个base class以及每一个成员变量都会被自动构造;当你销毁一个对象,反向程序的析构行为亦会自动发生,这可能会增加大量代码。

8.程序库设计者必须评估“将函数声明为inline”的冲击;inline函数无法随着程序库的升级而升级。如果函数式non-inline函数,修改后只需重新连接就好,远比重新编译负担少很多;如果程序库采取动态链接,审计函数甚至可以直接被应用程序吸纳。

9.声明inline函数原则:
①一开始先不要将任何函数声明为inline,或至少将inlining施行范围局限在那些”一定成为inline”或“十分平淡无奇”的函数身上。
②慎重使用inline便是对日后使用调试器带来帮助,不过这么一来也等于把自己推向手工最优化的道路。
③不要忘记 80-20 经验法则:平均一个程序往往将80%的执行时间花在20%的代码上头。所以,找出可以有效增进程序整体效率的20%代码,然后将它inline或竭尽所能地将它瘦身。

10.小结:
<1>将大多数inlining限制在小型、被频繁调用的函数身上。这可使日后的调试过程和二进制升级更容易,也可潜在的代码膨胀问题最小化,使程序的速度提升机会最大化。
<2>不要只因为 function templates 出现在头文件,就将它们声明为inline。

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

1.假设你对C++程序的某个class实现文件做了些轻微的修改。注意,这里修改的并不是class接口,而是实现,而且只改private成分。然后重新建置这个程序,你会发现所有的东西都需要重新编译和连接。
问题出在C++并没有把”将接口从实现中分离”做的很好。
Class的定义式不只详细叙述了class接口,还包括十足的实现细目。例如

class Person  {  public:    Person(const std::string& name,const Date& birthday,const Address& addr);    std::string name() const;    std::string birthDate() const;    std::string address() const;    ...  private:    std::string theName;  //实现细目  Date theBirthDate;  //实现细目  Address theAddress;  //实现细目}; 

2.如果编译器没有取得以上的class Person实现代码所用到的classes string,Date 和 Address的定义式,其将无法通过编译,所以需要包含一些头文件,这就在Person定义文件和其含入文件之间形成了一种编译依存关系。

3.针对Person,我们可以将它分割为两个类,一个只提供接口,另一个负责实现接口:

    #include <string>  //标准程序库组件不应该被之间声明,仅需使用适当的#includes    #include <memory>  //为tr1::shared_ptr而含入    class PersonImpl;  //Person实现类的前置声明    class Date;  //Person借口用到的类的前置声明    class Address;      class Person  {      public:          Person( const std::string& name,const Date& birthday,const Address& addr);          std::string name() const;          std::string birthDate() const;          std::address() const;          ...      private:         std::tr1::shared_ptr<PersonImpl> pImpl;  //指向实现物的指针    };  

这样的设计常被称为 pimpl idiom(就是 pointer to implementation)。在这样的设计下,Person的客户就完全与Dates,Address 以及 Person的实现细目分离了。那些class的任何实现修改都不需要Person客户端重新编译。

4.如果使用 object references 或 object pointers 可以完成任务,就不要使用 objects。
你可以只靠一个类型声明式就定义出指向该类型的reference和pointer;但如果定义某类型的object,就需要用到该类型的定义式。

5.如果能够,尽量以 class 声明式替换 class 定义式。
当声明一个函数而它用到某个class时,你并不需要该class的定义;纵使函数以 by value方式传递该类型的参数(或返回值)亦然:

class Date;        // class声明式  Date today();        // 没问题,这里并不需要Date的定义式void clearAppointments(Date d);        // 并不需要Date的定义式

6.为声明式和定义式提供不同的头文件。

#include "datefwd.h"        // 这个头文件内声明(但未定义)class Date  Date today();  void clearAppointments( Date d ); 

7.像Person这样使用 pimpl idiom 的class,往往被称为 handle classes。它们的所有函数转交给相应的实现类并由后者完成实际的工作。

#include "Person.h"  //#include该class的定义式#include "PersonImpl.h"  //#include PersonImpl的class定义式,否则无法调用其成员函数Person::Person(const std::string& name,const Date& birthday,const Address& addr) : pImpl(new PersonImpl(name,birthday,addr)  //Person构造函数一new调用PersonImpl构造函数{}  std::string Person::name() const  {      return pImpl->name();  }

8.另一个制作handle classe的办法是令Person成为一种特殊的 abstract base class(抽象基类),称为Interface class。
这种class是用来 描述 derived class的接口,因此它通常不带成员变量,也没有构造函数,只有一个 virtual析构函数以及一组 pure virtual函数,用来叙述整个接口。

class Person  {  publicvirtual ~Person();      virtual std::string name() const = 0;      virtual std::string birthDate() const = 0;      virtual std::string address() const = 0;       ...  }; 

9.Handle class 和 Interface class 解除了接口和实现之间的耦合关系,从而降低了文件间的编译依存性。
①在Handle class身上,成员函数必须通过implementation pointer取得对象数据。那会为每一次访问增加一层间接性。而每一个对象消耗的内存数量必须增加implementation pointer的大小。最后,implementation pointer必须初始化,指向一个动态分配得来的implementation object,所以你将蒙受因动态内存分配而来的额外开销,以及遭遇bad_alloc异常(内存不足)的可能性。
②Interface class,由于每个函数都是virtual,所以你必须为每次函数调用付出一个间接跳跃成本。此外Interface class派生的对象必须内含一个vptr(virtual table pointer),这个指针可能会增加存放对象所需的内存数量——实际取决于这个对象除了Interface class之外是否还有其他的virtual函数来源。
③最后,不论Handle class 或 Interface class, 一旦脱离inline函数都无法有太大作为。但Handle class 和 Interface class 正是特别被设计用来隐藏实现细节如函数本体。
④然而,如果只因为若干额外成本便不考虑Handle class 和 Interface class将是严重的错误。Virtual 函数不也带来成本吗?但我们并不想放弃它们。所以,我们应该考虑以渐进的方式使用这些技术。在程序发展过程中使用Handle class 和 Interface class以求实现码有所变化时对其客户带来最小冲击。而当它们导致速度和/或大小差异过于重大以至于 class之间的耦合相形之下不成为关键时,就以具象类替换Handle class 和 Interface class。

10.小结:
<1>支持“编译依存性最小化”的一般构想是:相依于声明式,不要相依于定义式。基于此构想的两个手段是 Handle class 和 Interface class
<2>程序库头文件应该以“完全且仅有声明式”的形式存在。这种做法不论是否涉及template 都适用

原创粉丝点击