Effective C++: 05实现

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");
  }
  ...         
  return encrypted;
}

         如果有异常抛出,那么就得付出不必要的encrypted的构造成本和析构成本。所以最好延后encrypted的定义,直到确实需要它:

std::string encryptPassword(const std::string& password)
{
  using namespace std;
  if (password.length() < MinimumPasswordLength) {
     throw logic_error("Password is too short");
  }

  string encrypted;
  ...                      
  return encrypted;
}

         实际上,上面这个函数仍然没有达到它本可以达到的那样紧凑。因为encrypted虽获定义却无任何实参作为初值。这意味调用的是其default构造函数,之后肯定还会需要有一个赋值操作:

  std::string encrypted;                // default-construct encrypted
  encrypted = password;                 // assign to encrypted

          因此,更受欢迎的做法是:

std::string encrypted(password);  // define and initialize via copy constructor

  

         3:所谓“尽可能延后”的真正意义。你不只应该延后变量的定义,直到非得使用该变量的前一刻为止,甚至应该尝试延后这份定义直到能够给它初值实参为止。这样不仅能够避免构造(和析构)非必要对象,还可以避免无意义的default构造行为。

 

27:尽量少做转型动作

         1:对于转型,有三种不同的形式。

C风格的转型动作看起来像这样:

(T) expression                      // cast expression to be of type T

函数风格的转型动作看起来像这样:

T(expression)                       // cast expression to be of type T

 上面两种形式并无差别,纯粹只是小括号的摆放位置不同而己。我称此二种形式为“旧式转型”。

 

C++提供四种新式转型:

const_cast<T>(expression)
dynamic_cast<T>(expression)
reinterpret_cast<T>(expression)
static_cast<T>(expression)

const_cast通常被用来将对象的常量性转除(cast away the constness)。它也是唯一有此能力的C++-style转型操作符;

dynamic_cast主要用来执行“安全向下转型”,也就是用来决定某对象是否归属继承体系中的某个类型。它是唯一无法由旧式语法执行的动作,也是唯一可能耗费重大运行成本的转型动作;

reinterpret_cast意图执行低级转型,实际动作(及结果)可能取决于编译器,这也就表示它不可移植。例如将一个pointer to int转型为一个int;

static_cast用来强迫隐式转换,例如将non-const对象转为const对象,或将int转为double等等;

 

旧式转型仍然合法,但新式转型较受欢迎。原因是:第一,它们很容易在代码中被辨识出来(人眼或者类似grep的工具);第二,各转型动作的目标愈窄化,编译器愈可能诊断出错误的运用。

 

2:许多程序员相信,转型其实什么都没做,只是告诉编译器把某种类型视为另一种类型。这是错误的观念。任何一个类型转换(不论是通过转型操作而进行的显式转换,或通过编译器完成的隐式转换)往往真的令编译器编译出运行期间执行的码。例如:

int x, y;
double d = static_cast<double>(x)/y;

将int 转型为double几乎肯定会产生一些代码,因为在大部分计算器体系结构中,int的底层表述不同于double的底层表述。

 再比如:

class Base { ... };
class Derived: public Base { ... };

Derived d;
Base *pb = &d;    // implicitly convert Derived* to Base*

这里我们不过是建立一个base class指针指向一个derived class对象,但有时候上述的两个指针值并不相同。这种情况下会有个偏移量在运行期被施行于Derived*指针身上,用以取得正确的Base*指针值。

上个例子表明,单一对象可能拥有一个以上的地址(例如“以Base*指向它”时的地址和“以Derived*指向它”时的地址)。C不可能发生这种事,Java不可能发生这种事,C#也不可能发生这种事。但C++可能!

因此,通常应该避免做出“对象在C++中如何如何布局”的假设。当然更不该以此假设为基础执行任何转型动作。

 

3:考虑下面的代码:

class Window {
public:
  Window(int size): m_size(size) {}
  
  virtual void onResize() { 
    cout << "this is Window onResize
";
    m_size = 3;
  }
  
  int m_size;
  void getSize()
  { cout << "m_size is " << m_size << endl; }
};

class SpecialWindow: public Window {
public:
  SpecialWindow(int size): Window(size) {}

  virtual void onResize() {
    static_cast<Window>(*this).onResize();
    cout << "this is SpecialWindow onResize
";
  }
};

int main()
{
    SpecialWindow sw(10);
    sw.onResize();
    sw.getSize();
}

上述代码的本意,是想在派生类SpecialWindow的onResize函数中,首先调用基类Window的onResize函数。

但是代码中却使用了转型动作。这段代码将*this转型为Window,对函数onResize的调用也因此调用了Window::onResize。但恐怕你没想到,它调用的并不是当前对象上的函数,而是转型动作所建立的一个“当前对象之base class成分”的副本身上的onResize。

因此,当前对象的m_size没有改动,改动的是副本。所以代码结果如下:

this is Window onResize
this is SpecialWindow onResize
m_size is 10

 正确的做法应该是:

Window::onResize();

 4:dynamic_cast的许多实现版本执行速度相当慢。例如至少有一个很普遍的实现版本基于“class名称之字符串比较”,如果你在四层深的单继承体系内的某个对象身上执行dynamic_cast,刚才说的那个实现版本所提供的每一次dynamic_cast可能会耗用多达四次的strcmp调用,用以比较class名称。

因此,除了对一般转型保持机敏与猜疑,更应该在注重效率的代码中dynamic_cast保持机敏与猜疑。

 

绝对必须避免的一件事是所谓的“连串dynamic_cast",也就是看起来像这样的东西:

  if (SpecialWindow1 *psw1 =
       dynamic_cast<SpecialWindow1*>(iter->get())) { ... }

  else if (SpecialWindow2 *psw2 =
            dynamic_cast<SpecialWindow2*>(iter->get())) { ... }

  else if (SpecialWindow3 *psw3 =
            dynamic_cast<SpecialWindow3*>(iter->get())) { ... }
  ...

这样产生出来的代码又大又慢,而且基础不稳,因为每次Window class继承体系一有改变,所有这一类代码都必须再次检阅看看是否需要修改。

 

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

         所谓对象内部成分,就是指对象的成员变量(或成员函数)。所谓的handles,就是指引用、指针或者迭代器。

          考虑下面实现的表示矩形的代码,通过矩形的左上角和右下角的点坐标,就可以唯一确定一个矩形,表示矩形的类Rectangle提供了upperLeft和lowerRight函数返回这两个点的坐标:

class Point {                      // class for representing points
public:
  Point(int x, int y);

  void setX(int newVal);
  void setY(int newVal);
  ...
};

struct RectData {                    // Point data for a Rectangle
  Point ulhc;                        // ulhc = " upper left-hand corner"
  Point lrhc;                        // lrhc = " lower right-hand corner"
};

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

private:
  std::tr1::shared_ptr<RectData> pData;  
};                        

         upperLeft和lowerRight函数返回reference。这样的设计可通过编译,但却是错误的。实际上它是自我矛盾的。一方面这俩函数被声明为const成员函数,另一方面两个函数却都返回references指向private内部数据,调用者于是可通过这些references更改内部数据:

    Point coord1(0, 0);
    Point coord2(100, 100);
    const Rectangle rec(coord1, coord2);

    rec.upperLeft().setX(50);

         尽管rec是个const对象,但是upperLeft的调用者却能够使用被返回的reference来更改成员。

 

上面的例子说明:第一,成员变量的封装性最多只等于“返回其reference”的函数的访问级别。本例之中虽然ulhc和lrhc都被声明为private,它们实际上却是public,因为public函数upperLeft和lowerRight传出了它们的references;第二,如果const成员函数传出一个reference,后者所指数据与对象自身有关联,而它又被存储于对象之外,那么这个函数的调用者可以修改那笔数据。

上面我们所说的每件事情都是由于“成员函数返回references"。如果它们返回的是指针或迭代器,相同的情况还是发生。References、指针和迭代器统统都是所谓的handles(号码牌,用来取得某个对象),而返回一个“代表对象内部数据”的handle,随之而来的便是“降低对象封装性”的风险。同时,它也可能导致“虽然调用const成员函数却造成对象状态被更改”。

 

通过将upperLeft和lowerRight它们的返回类型加上const,就可以防止客户修改Rectangle内部数据了:

const Point& upperLeft() const { return pData->ulhc; }
const Point& lowerRight() const { return pData->lrhc; }

 尽管如此,upperLeft和lowerRight还是返回了“代表对象内部”的handles,有可能在其他场合带来问题。更明确地说,它可能导致dangling handles(空悬的号码牌):也就是handles所指的东西不复存在的问题。最常见的不复存在的对象问题,来源于函数返回值:

class GUIObject { ... };
const Rectangle boundingBox(const GUIObject& obj); 

GUIObject *pgo; 
const Point *pUpperLeft = &(boundingBox(*pgo).upperLeft());

上面的代码中,对boundingBox的调用获得一个新的、暂时的Rectangle对象。这个对象没有名称,所以我们权且称它为temp。随后upperLeft作用于temp身上,返回一个reference指向temp的一个内部成分,于是pUpperLeft指向那个Point对象。

目前为止一切还好,但故事尚未结束,因为在那个语句结束之后,boundingBox的返回值,也就是我们所说的temp将被销毁,而那间接导致temp内的Points析构。最终导致pUpperLeft指向一个不再存在的对象:也就是说一旦产出pUpperLeft的那个语句结束,pUpperLeft也就变成空悬、虚吊!

 

这就是为什么函数如果“返回一个handle代表对象内部成分”总是危险的原因。不论这所谓的handle是个指针或迭代器或reference,也不论这个handle是否为const,也不论那个返回handle的成员函数是否为const。这里的唯一关键是,有个handle被传出去了,一旦如此你就是暴露在“handle比其所指对象更长寿”的风险下。

尽管如此,有时候必须返回handle,比如operator[]这种,这样的函数毕竟是例外,不是常态。

 

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

         下面的代码:

void PrettyMenu::changeBackground(std::istream& imgSrc)
{
  lock(&mutex);                      // acquire mutex (as in Item 14)

  delete bgImage;                    // get rid of old background
  ++imageChanges;                    // update image change count
  bgImage = new Image(imgSrc);       // install new background

  unlock(&mutex);                    // release mutex
}

          从“异常安全性”的角度来看,这个函数很糟。“异常安全”有两个条件,而该函数没有满足其中任何一个条件:

a:不泄漏任何资源。上述代码没有做到这一点,因为一旦”new Image(imgSrc)”导致异常,就不会调用到”unlock(&mutex)”,于是互斥器就永远被把持住了。

b:不允许数据败坏。如果”new Image(imgSrc)”抛出异常,bgImage 就是指向一个己被删除的对象,imageChanges也己被累加,但其实并没有新的图像被成功安装起来。

 

解决资源泄漏的问题很容易,因为在之前的条款13讨论过如何以对象管理资源,而条款14也导入了Lock class作为一种“确保互斥器被及时释放”的方法;

解决了资源泄露问题后,现在我们可以专注解决数据的败坏了。此刻我们需要做个抉择,但是在我们能够抉择之前,必须先面对一些术语。

异常安全函数提供以下三个保证之一:

a基本承诺:如果异常被抛出,程序内的任何事物仍然保持在有效状态下。没有任何对象或数据结构会因此而败坏,所有对象都处于一种内部前后一致的状态(例如所有的class约束条件都继续获得满足)。比如,在changeBackground函数中,一旦有异常被抛出时,PrettyMenu对象可以继续拥有原背景图像,或是令它拥有某个缺省背景图像,但客户无法预期哪一种情况。如果想知道,他们恐怕必须调用某个成员函数以得知当时的背景图像是什么。

b强烈保证:如果异常被抛出,调用这样的函数,如果函数成功,就是完全成功,如果函数失败,程序会回复到“调用函数之前”的状态。

c不抛掷(nothrow)保证:承诺绝不抛出异常,因为它们总是能够完成它们原先承诺的功能。作用于内置类型身上的所有操作都提供nothrow保证。这是异常安全码中一个必不可少的关键基础材料。

异常安全的代码必须提供上述三种保证之一。如果它不这样做,它就不具备异常安全性。因此,我们的抉择是,该为我们所写的每一个函数提供哪一种保证?

 

从异常安全性的观点来看,最强烈保证是最好的。但我们很难在C part of C++领域中完全不调用任何一个可能抛出异常的函数:任何使用动态内存的东西(例如所有STL容器)如果无法找到足够内存以满足需求,通常便会抛出一个bad_alloc异常。因此对大部分函数而言,抉择往往落在基本保证和强烈保证之间。

 

对changeBackground而言,提供强烈保证几乎不困难。首先改变PrettyMenu的bgImage成员变量的类型,从一个类型为Image*的内置指针改为一个智能指针。这样做是为了防止资源泄漏。然后,重新排列changeBackground内的语句次序,使得在更换图像之后才累加imageChanges:

class PrettyMenu {
  ...
  std::tr1::shared_ptr<Image> bgImage;
  ...
};

void PrettyMenu::changeBackground(std::istream& imgSrc)
{
  Lock ml(&mutex);

  bgImage.reset(new Image(imgSrc));  
  ++imageChanges;
}

这里不再需要手动delete旧图像,因为这个动作己经由智能指针内部处理掉了。此外,删除动作只发生在新图像被成功创建之后。这两个改变几乎足够让changeBackground提供强烈的异常安全保证。

 

有个一般化的设计策略很典型地会导致强烈保证,很值得熟悉它。这个策略被称为copy and swap。原则很简单:为你打算修改的对象(原件)做出一份副本,然后在那副本身上做一切必要修改。若有任何修改动作抛出异常,原对象仍保持未改变状态。待所有改变都成功后,再将修改过的那个副本和原对象在一个不抛出异常的操作中置换。

对PrettyMenu而言,典型写法如下:

struct PMImpl {                               // PMImpl = "PrettyMenu
  std::tr1::shared_ptr<Image> bgImage;        // Impl."; see below for
  int imageChanges;                           // why it's a struct
};

class PrettyMenu {
  ...
private:
  Mutex mutex;
  std::tr1::shared_ptr<PMImpl> pImpl;
};

void PrettyMenu::changeBackground(std::istream& imgSrc)
{
  using std::swap;

  Lock ml(&mutex);

  std::tr1::shared_ptr<PMImpl> pNew(new PMImpl(*pImpl));

  pNew->bgImage.reset(new Image(imgSrc));
  ++pNew->imageChanges;

  swap(pImpl, pNew);
} 

"copy-and-swap"策略是对对象状态做出“全有或全无”改变的一个很好办法,但一般而言它并不保证整个函数有强烈的异常安全性。为了解原因,让我们考虑一个抽象函数:someFunc,它使用copy-and-swap策略,但函数内还包括对另外两个函数fl和f2的调用:

void someFunc()
{
  ...                                     // make copy of local state
  f1();
  f2();
  ...                                     // swap modified state into place
}

很显然,如果f1或f2的异常安全性比“强烈保证”低,就很难让someFunc成为“强烈异常安全”。举个例子,假设f1只提供基本保证,那么为了让someFunc提供强烈保证,我们必须写出代码获得调用f1之前的整个程序状态、捕捉f1的所有可能异常、异常发生后恢复原状态。

如果f1和f2都是“强烈异常安全”,情况并不就此好转。毕竟如果f1圆满结束,程序状态在任何方面都可能有所改变,因此如果f2随后抛出异常,程序状态和someFunc被调用前并不相同。

如果函数只操作局部性状态,例如someFunc只影响其“调用者对象”的状态,便相对容易地提供强烈保证。但是当函数对非局部性数据有连带影响时,提供强烈保证就困难得多。举个例子,如果调用f1带来的影响是某个数据库被改动了,那就很难让someFunc具备强烈安全性。一般而言在“数据库修改动作”送出之后,没有什么做法可以取消并恢复数据库旧观,因为数据库的其他客户可能已经看到了这一笔新数据。

 

当“强烈保证”不切实际时,你就必须提供“基本保证”。现实中你或许会发现,你可以为某些函数提供强烈保证,但效率和复杂度带来的成本会使它对许多人而言摇摇欲坠。对许多函数而言,“异常安全性之基本保证”是一个绝对通情达理的选择。

一个软件系统要么就具备异常安全性,要么就全然否定,没有所谓的“局部异常安全系统”。即使系统内只有一个函数不具备异常安全性,整个系统就不具备异常安全性,因为调用那个函数有可能导致资源泄漏或数据结构败坏。

 

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

         1:inline函数背后的整体观念是,将“对此函数的每一个调用”都以函数本体替换之。这样做可能增加你的目标码大小。

         2:inline只是对编译器的一个申请,不是强制命令。这项申请可以隐喻提出,也可以明确提出。隐喻方式是将函数定义于class定义式内:

class Person {
public:
  ...
  int age() const { return theAge; }    
private:
  int theAge;
};

这样的函数通常是成员函数,但friend函数也可被定义于class内,如果是那样,它们也是被隐喻声明为inline。

 明确声明inline函数的做法则是在其定义式前加上关键字inline。例如:

template<typename T> 
inline const T& std::max(const T& a, const T& b)  
{ return a < b ? b : a; } 

3:Inline函数通常一定被置于头文件内,因为大多数构建环境是在编译过程中进行inlining,而为了将一个“函数调用”替换为“被调用函数的本体”,编译器必须知道那个函数长什么样子。

 

4:大部分编译器拒绝将太过复杂(例如带有循环或递归)的函数inlining。

所有对virtual函数的调用也都会使inlining落空。因为virtual意味着直到运行期才确定调用哪个函数”,而inline意味“执行前,先将调用动作替换为被调用函数的本体”。

有时候虽然编译器有意愿inlining某个函数,还是可能为该函数生成一个函数本体。比如,如果程序要取某个inline函数的地址,编译器通常必须为此函数生成一个outlined函数本体。与此并提的是,编译器通常不对“通过函数指针而进行的调用”实施inlining:

inline void f() {...}  // assume compilers are willing to inline calls to f

void (*pf)() = f;      // pf points to f
...
f();     // this call will be inlined, because it's a "normal" call

pf();   // this call probably won't be, because it's through a function pointer

 即使你从未使用函数指针,“未被成功inlined”的inline函数还是有可能缠住你,因为程序员并非唯一要求函数指针的人。有时候编译器会生成构造函数和析构函数的outline副本,如此一来它们就可以获得指针指向那些函数,在array内部元素的构造和析构过程中使用。

实际上构造函数和析构函数往往是inlining的糟糕候选人。即使是一个空函数体的构造函数或析构函数,编译器也会生成大量代码。当创建一个对象,其每一个base class,以及每一个成员变量都会被自动构造;销毁一个对象,反向程序的析构行为亦会自动发生。如果有个异常在对象构造期间被抛出,该对象已构造好的那一部分会被自动销毁。编译器为了实现这些操作,会在构造函数和析构函数中产生大量代码。

 

5:程序库设计者必须评估“将函数声明为inline”的冲击:inline函数无法随着程序库的升级而升级。换句话说如果f是程序库内的一个inline函数,客户将“f函数本体”编进其程序中,一旦程序库设计者决定改变f,所有用到f的客户端程序都必须重新编译。然而如果f是non-inline函数,一旦它有任何修改,客户端只需重新连接就好,远比重新编译的负担少很多。

 

6:大部分调试器对于inline函数都束手无策。

 

7:我们在决定哪些函数该被声明为inline而哪些函数不该时,要掌握一个合乎逻辑的策略:一开始先不要将任何函数声明为inline,或至少将inline施行范围局限在那些“一定成为inline 或“十分平淡无奇”的函数身上。

不要忘记80-20经验法则:平均而言一个程序往往将80%的执行时间花费在20%的代码上头。这是一个重要的法则,它提醒你,作为一个软件开发者,你的目标是找出这可以有效增进程序整体效率的20%代码,然后将它inline或竭尽所能地将它瘦身。但除非你选对目标,否则一切都是虚功。

 

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

假设某个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;         // 实现细目
};

代码中必须#include必要的头文件才能编译通过,也就是Person类所用到的string, Date, Address类的头文件。

但是,这么一来便是在Person定义文件和其含入文件之间形成了一种编译依存关系(compilation dependency)。如果这些头文件中有任何一个被改变,或这些头文件所倚赖的其他头文件有任何改变,那么每一个含入Person class的文件就得重新编译,任何使用Person class的文件也必须重新编译。这样的连串编译依存关系会对许多项目造成难以形容的灾难。

 

为了避免这种情况,考虑将实现细节从类定义中抽离,也就是像下面这样:

namespace std {
     class string;
}

class Date;                    // forward declaration
class Address;                 // forward declaration

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;
    ...
};

 但是,编译器必须在编译期间知道对象的大小。比如:

 int x;                // define an int
 Person p( params );   // define a Person

 当编译器看到x的定义式,它知道必须分配多少内存才够持有一个int。没问题,每个编译器都知道一个int有多大。当编译器看到p的定义式,它也知道必须分配足够空间以放置一个Person,但它如何知道一个Person对象有多大呢?编译器获得这项信息的唯一办法就是询问class定义式。然而如果class定义式不列出实现细节,编译器如何知道该分配多少空间呢?

 

有两种方法解决这种问题:

1:PIMPL  idiom(pointer to implementation)

这就是所谓的“将对象实现细目隐藏于一个指针背后”。

针对Person我们可以这样做:把Person分割为两个classes,一个只提供接口,另一个负责实现该接口。定义Person的代码如下:

//Person.h
#include <string>
#include <memory> 

class Date;
class Address;

class Person {
public:
 Person(const std::string& name, const Date& birthday,const Address& addr);
 ~Person();
 
 std::string name() const;
 std::string birthDate() const;
 std::string address() const;
 ...

private:   
  class PersonImpl;
  std::unique_ptr<PersonImpl> pImpl;
}; 

在这里,Person类只内含一个指针成员(这里使用std::unique_ptr),指向其实现类(PersonImpl)。

注意,因为这里使用了std::unique_ptr智能指针,因此必须声明一个析构函数~Person,并在实现文件cpp文件中,定义一个空的~Person函数。这是因为:如果不定义该析构函数的话,编译器会自己定义一个。在编译器定义的析构函数中,会析构各个成员变量,也就是会调用类std::unique_ptr<PersonImpl>的析构函数。而std::unique_ptr是个模板,它的析构模板函数会在实际调用时被实例化,因此,调用~std::unique_ptr<PersonImpl>时就会实例化该模板函数,该函数中调用到了delete,delete中又会调用sizeof,而sizeof的参数PersonImpl此时是个不完全类型,因此会造成编译错误。解决方法就是在Person中要声明其析构函数,在Person的实现源码文件中,定义一个空的析构函数即可。因为此时PersonImpl的定义已经是可见的了。

如果这里用的是shared_ptr,则不存在这个问题,具体原因跟shared_ptr的实现原理有关。但是,针对PIMPL idiom而言,一般是使用unique_ptr,因为多数情况下,具体的实现类不会是共享的。有关PIMPL idiom的问题,参考:

https://herbsutter.com/gotw/_100/

http://www.cppsamples.com/common-tasks/pimpl.html

https://stackoverflow.com/questions/8619708/must-provide-destructor-in-the-pimpl

https://stackoverflow.com/questions/21699201/pimpl-with-smart-ptr-why-constructor-destructor-needed

 

下面是Person的实现代码:

//Person.cpp
#include "Person.h" 
#include "PersonImpl.h"      

Person::Person(const std::string& name, const Date& birthday,
               const Address& addr)
: pImpl(new PersonImpl(name, birthday, addr))
{}

std::string Person::name() const
{
  return pImpl->name();
}

~Person::Person(){}

这样的设计之下,Person的客户就完全与Dates, Addresses以及Persons的实现细节分离了。那些classes的任何实现的修改(也就是Person.cpp中的修改)都不需要Person客户端重新编译,因为客户端只会包含Person.h文件 :

//main.cpp
#include "Person.h" 

int main()
{
    std::string name("Peter");
    Date date("1980-01-01");
    Address addr("USA");
    Person p(name, date, addr);
    ...
}

像Person这样使用pimpl idiom的classes,往往被称为Handle classes。这个分离的关键在于以“声明的依存性”替换“定义的依存性”,那正是编译依存性最小化的本质:现实中让头文件尽可能自我满足,万一做不到,则让它与其他文件内的声明式(而非定义式)相依。

 

2:抽象基类

另一个制作Handle class的办法是,令Person成为一种特殊的抽象基类,称为Intertace class。这种class的目的是详细描述derived classes的接口,它通常不带任何成员变量,也没有构造函数,只有一个virtual析构函数以及一组pure virtual函数,用来叙述整个接口。

一个针对Person而写的Interface class或许看起来像这样:

class Person {
public:
  virtual ~Person();

  virtual std::string name() const = 0;
  virtual std::string birthDate() const = 0;
  virtual std::string address() const = 0;
  ...
};

 这个class的客户必须以Person的pointers和references来撰写应用程序,因为它不可能对抽象基类Person classes进行实例化。这种情况下,除非Intertace class的接口被修改否则其客户不需重新编译。

Interface class的客户必须有办法为这种class创建新对象。他们通常调用一个特殊函数,此函数扮演构造具体derived classes的角色。这样的函数通常称为factory(工厂)函数。它们返回指针(或更为可取的智能指针),指向动态分配所得对象,而该对象支持Interface class的接口。该函数往往在Interface class内被声明为static:

class Person {
public:
 ...

 static std::shared_ptr<Person>  
   create(const std::string& name,
          const Date& birthday,
          const Address& addr);
};

 假设Person 有一个派生类:

class RealPerson: public Person {
public:
  RealPerson(const std::string& name, const Date& birthday,
             const Address& addr)
  : theName(name), theBirthDate(birthday), theAddress(addr)
  {}

  virtual ~RealPerson() {}

  std::string name() const;        // implementations of these 
  std::string birthDate() const;   // functions are not shown, but
  std::string address() const;     // they are easy to imagine

private:
  std::string theName;
  Date theBirthDate;
  Address theAddress;
};

 而Person::create的实现如下:

std::shared_ptr<Person> Person::create(const std::string& name,
                                       const Date& birthday,
                                       const Address& addr)
{
  return std::shared_ptr<Person>(new RealPerson(name, birthday,addr));
}

 一个更现实的Person::create实现代码会创建不同类型的derived class对象,取决于诸如额外参数值、读自文件或数据库的数据、环境变量等等。

 

客户会这样使用它们:

std::string name;
Date dateOfBirth;
Address address;
...

std::tr1::shared_ptr<Person> pp(Person::create(name, dateOfBirth, address));

std::cout << pp->name()
          << " was born on "
          << pp->birthDate()
          << " and now lives at "
          << pp->address();

 

最后,不论Handle classes或Interface classes,一旦脱离inline函数都无法有太大作为。条款30解释过为什么函数本体为了被inlined必须置于头文件内,但Handle classes和Interface classes正是特别被设计用来隐藏实现细节如函数本体。

原文地址:https://www.cnblogs.com/gqtcgq/p/7684934.html