Effective C++学习笔记五(实现)

实现


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

只要你定义了一个变量而其类型带有一个构造函数或析构函数,那么当程序的控制流(control flow)到达这个变量定义式时,你便得承受构造成本;当这个变量离开其作用域时,你便得承受析构成本。即使这个变量最终并未被使用,仍需耗费这些成本,所以你应该尽可能避免这种情形。

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

请记住

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

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

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

(T)expression       //将expression转型为T

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

T(expression);      //将expression转型为T

这两种形式被称为”旧式转型”。

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

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

各有不同的目的:

  • const_cast通常被用来将对象的常量性转除。它也是唯一由此能力的C++-style转型操作符。
  • dynamic_cast主要用来执行”安全向下转型”,也就是用来决定某对象是否归属继承体系中的某个类型。它是唯一无法由旧式语法执行的动作,也是唯一可能耗费重大运行成本的转型动作
  • reinterpret_cast意图执行低级转型。实际动作(及结果)可能取决于编译器,这也就表示它不可移植。例如将一个pointer to int转型为一个int。这一类转换在低级代码以外很少见。
  • static_cast用来强迫隐式转换(implicit conversions),例如将non-const对象转为const对象,或将int转为double等等。它也可以用来执行上述多种转换的反向转换,例如将void*指针转为typed指针,将pointer-to-base转为pointer-to-derived。但它无法将non-const————这个只有const_cast才能办得到。

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

我唯一使用旧式转型的时机是:当我要调用一个explicit构造函数将一个对象传递给一个函数时。例如:

class Widget{
public:
    explicit Widget(int size);
    ...
};
void doSomeWork(const Widget& w);
doSomeWork(Widget(15)); //以一个int加上"函数风格"的转型动作创建一个Widget。

doSomeWork(static_cast<Widget>(15));    //以一个int加上"C++风格"的转型动作创建一个Widget。

任何一个类型转换(不论是通过转型操作而进行的显示转换,或通过编译器完成的隐式转换)往往真的令编译器编译出运行期间执行的代码。

class Base{...};
class Derived:public Base{...};
Derived d;
Base* pb=&d;    //隐喻地将Derived*转换为Base*

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

上述例子表明,单一对象(例如一个类型为Derived的对象)可能拥有一个以上的地址(例如”以Base*指向它”时的地址和”以Derived*指向它”时的地址。C,Java,C#不可能发生这种事。但C++可能!,实际上一旦使用多重继承,这事几乎一直发生着。即使在单一继承中也可能发生。)

但请注意,有时候需要一个偏移量。对象的布局方式和它们的地址计算方式随编译器不同而不同,那意味着”由于知道对象如何布局”而设计的转型,在某一平台行的通,在其他平台并不一定行的通。

另一件关于转型的有趣的事情是:我们很容易写出某些似是而非的代码(在其他语言中也许真是对的)。例如许多应用程序框架(application frameworks)都要求derived classes内的virtual函数代码的第一个动作就先调用base class的对应函数。假设我们有个Window base class和一个SpecialWindow derived class,两者都定义了virtual函数onResize。进一步假设SpecialWindow的onResize函数被要求首先调用Window的onResize。下面是实现方式之一,它看起来是对的,但实际上是错的:

class Window    //base class
{
public:
    virtual void onResize(){...}    //base onResize实现代码
    ...
};
class SpecialWindow:public Window   //derived class
{
public:
    virtual void onResize(){                     //derived onResize实现代码
        static_cast<Window>(*this).onResize();  //将*this转型为Window,
                                             //然后调用其onResize;这不可行!
        ...                                      //这里进行SpecialWindow专属行为。
    }
    ...
};

我在代码中强调了转型动作一如你预期,这段程序将*this指针转型为Window,对函数onResize的调用也因此调用了Window::onResize。但恐怕你没有想到,它调用的并不是当前对象上的函数,而是稍早转型动作所建立的一个”*this对象之base class成分”的暂时副本身上的onResize!(函数就是函数,成员函数只有一份,”调用起哪个对象身上的函数”有什么关系呢,关键在于成员函数都有个隐藏的this指针,会因此影响成员函数操作的数据。)再说一次,上述代码并非在当前对象身上调用Window::onResize之后又在该对象身上执行SpecialWindow专属动作。不,它是在”当前对象之base class”的副本上调用Window::onResize,然后在当前对象身上执行SpecialWindow专属动作。如果Window::onResize修改了对象内容(onResize是个non-const成员函数),当前对象其实没被改动,改动的是副本。然而SpecialWindow::onResize内如果也修改对象,当前对象真的会被改动。这使当前对象进入一种”伤残”状态:其base class成分的更改没有落实,而derived class成分的更改倒是落实了。

解决之道是拿掉转型动作:

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

之所以需要dynamic_cast,通常是因为你想在一个你认定为derived class对象身上执行derived class操作函数,但你的手上却只有一个”指向base”的pointer或reference,你只能靠它们来处理对象。有两个一般做法可以避免这个问题。

第一,使用容器并在其中存储直接指向derived class对象的指针(通常是智能指针),如此便消除了”通过base class接口处理对象”的需要。当然啦,这种做法使你无法在同一个容器内存储指针”指向所有可能之各种父类的派生类”。如果需要多种派生类类型,你可能需要多个容器,它们都必须具备类型安全性(type-safe)。

另一种做法可让你通过base class接口处理”所有可能之各种父类派生类”,那就是在base class 内提供virtual函数做你想对各个父类派生类做的事。

优良的C++代码很少使用转型,但若说要完全摆脱它们又太过不切实际。我们应该尽可能隔离转型动作,通常是把它隐藏在某个函数内,函数的接口会保护调用者不受函数内部任何肮脏龌龊的动作影响。

请记住

  • 如果可以,尽量避免转型,特别是在注重效率的代码中避免dynamic_casts。如果有个设计需要转型动作,试着发展无需转型的替代设计。
  • 如果转型是必要的,试着将它隐藏于某个函数背后。客户随后可以调用该函数,而不需将转型放进他们自己的代码内。
  • 宁可使用C++-style(新式)转型,不要使用旧式转型。前者很容易辨识出来,而且也比较有着分门别类的职掌。

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

第一,成员函数的封装性最多只等于”返回其reference”的函数的访问级别。第二,如果const成员函数传出一个reference,后者所指数据与对象自身有关联,而它又被存储于对象之外,那么这个函数的调用者可以修改那笔数据。这正是bitwise constness的一个附带结果,见条款3。

成员函数除了返回reference之外,若返回的是指针或迭代器,相同的事情还是发生,原因也相同。Reference,指针和迭代器统统都是所谓的handles(号码牌,用来取得某个对象),而返回一个”代表对象内部数据”的handle,随之而来的便是”降低对象封装性”的风险。同时它也可能导致”虽然调用const成员函数却造成对象状态被更改”。

在这些函数身上遭遇的两个问题可以轻松去除,只要对它们的返回类型加上一个const即可。

但即使如此,也有可能在其它场合带来问题,比如可能导致dangling handles(空悬的号码牌):这种handles所指东西(的所属对象)不复存在。这种”不复存在的对象”最常见的来源就是函数返回值。

这并不意味着你绝不可以让成员函数返回handle。有时候你必须那么做,例如operator[]就允许你”摘采”strings和vectors的个别元素,而这些operator[]s就是返回references指向”容器内的数据”(见条款3),那么数据会随着容器被销毁而销毁。尽管如此,这样的函数毕竟是例外,不是常态。

请记住

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

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

假设有个class用来表现夹带背景图案的GUI菜单项。这个class希望用于多线程环境,所以它有个互斥器(mutex)作为并发控制(concurrency control)之用:

class PrettyMenu
{
public:
        ...
        void changeBackground(std::istream&imgSrc); //改变背景图像
        ...
private:
        Mutex mutex;                                //互斥器   
        Image*bgImage;                              //目前的背景图像
        int imageChange;                            //背景图像被改变的次数    
};

下面是PrettyMenu的changeBackground函数的一个可能实现:

void PrettyMenu::changeBackground(std::istream& imgSrc)
{
    lock(&mutex);                                   //取得互斥器(见条款14)
    delete bgImage;                                 //摆脱旧的背景图像
    ++imageChanges;                                 //修改图像变更次数
    bgImage=new Image(imgSrc);                      //安装新的背景图像
    unlock(&mutex);                                 //释放互斥器
}

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

当异常被抛出时,带有异常安全性的函数会:

  • 不泄露任何资源。上述代码没有做到这一点,因为一旦new Image(imgSrc)导致异常,对unlock的调用就绝不会执行,于是互斥器就永远被把持住了。
  • 不允许数据败坏。如果new Image(imgSrc)被抛出异常,bgImage就是指向一个已被删除的对象,imageChanges也已被累加,而其实并没有新的图像被成功安装起来。

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

void PrettyMenu::changeBackground(std::istream& imgSrc)
{
    Lock m1(&mutex);                //来自条款14:获得互斥器并确保它稍后被释放
    delete bgImage;
    ++imageChanges;
    bgImage=new Image(imgSrc);
}

关于”资源管理类”(resource management classes)如Lock者,一个最棒的事情是它们通常使函数更短。有个一般性规则这么说的:较少的代码就是较好的码,因为出错机会比较少,而且一旦有所改变,被误解的机会也比较少。

现在专注解决数据败坏,异常安全函数(Exception-safe functions)提供以下三个保证之一:

  • 基本承诺:如果异常被抛出,程序内的任何事物仍然保持在有效状态下。没有任何对象或数据结构会因此而败坏,所有对象都处于一种内部前后一致的状态。比如我们可以撰写changeBackground使得一旦有个异常被抛出时,PrettyMenu对象可以继续拥有原背景图像,或是令它拥有某个缺省背景图像,但客户无法预期哪一种情况。如果想知道,他们恐怕必须调用某个成员函数以得知当时的背景图像是什么。
  • 强烈保证:如果异常被抛出,程序状态不改变。调用这样的函数需要有这样的认知:如果函数成功,就是完全成功,如果函数失败,程序会恢复到”调用函数之前”的状态。
  • 不抛掷(nothrow)保证,承诺绝不抛出异常,因为它们总是能够完成它们原先承诺的功能(如果抛出异常,将是严重错误)。作用于内置类型身上的所有操作都提供nothrow保证。这是异常安全码中一个必不可少的关键基础材料。

异常安全代码(Exception-safe code)必须提供上述三种保证之一。如果不这样做,它就不具备异常安全性。

一般而言你应该会想提供可实施之最强烈保证。从异常安全性的观点视之,nothrow函数很棒,但我们很难在C part of C++领域中完全没有调用任何一个可能抛出异常的函数。任何使用动态内存的东西(例如所有STL容器)如果找到足够内存以满足需求,通常便会抛出一个bad_alloc异常。因此,可能的话请提供nothrow保证,但对大部分函数而言,抉择往往落在基本保证和强烈保证之间。

有个一般化的设计策略很典型地会导致强烈保证,这个策略被称为copy and swap。

当”强烈保证”不切实际时,你就必须提供”基本保证”。现实中你或许恢复发现,你可以为某些函数提供强烈保证,但效率和复杂度带来的成本会使它对许多人摇摇欲坠。

如果系统内有一个(惟有一个)函数不具备异常安全性,整个系统就不具备异常安全性,因为调用那个(不具备异常安全性的)函数有可能导致资源泄露或数据结构败坏。

请记住

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

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

inline函数背后的整体观念是,将”对此函数的每一个调用”都以函数本体替换之。这样做可能增加你的目标码大小。在一台内存有限的机器上,过度热衷inlining会造成程序体积太大(对可用空间而言)。即使拥有虚内存,inline造成的代码膨胀亦会导致额外的换页行为(paging),降低指令高速缓存装置的击中率,以及伴随这些而来的效率损失。

inline只是对编译器的一个申请,不是强制命令。这项申请可以隐喻提出,也可以明确提出。隐喻方式是将函数定义于class定义式内。明确声明inline函数的做法则是在其定义式前加上inline。

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

Templates通常也被置于头文件内,因为它一旦被使用,编译器为了将它具现化,需要知道它长什么样子。Template的具现化与inlining无关。Inlining需要成本,会引发代码膨胀。所以要切换实际考虑是否将template是否声明为inline

大部分编译器拒绝将太过复杂(例如带有循环或递归)的函数inlining,而所有对virtual函数的调用(除非是最平淡无奇的)也都会使inlining落空。因为virtual意味”等待,直到运行期才确定调用哪个函数”,而inline意味”执行前,先将调用动作替换为被调用函数的本体”。如果编译器不知道该调用哪个函数,你就很难责备它们拒绝将函数本体inlining。

构造函数和析构函数往往是inlining的糟糕候选人。因为由编译器于编译期间代为产生的代码会被安插到那些函数中。

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

大部分调试器面对inline函数都束手无策。毕竟你如何在一个并不存在的函数内设立断点呢?

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

请记住

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

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

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;     //实现细目
};

这里的class Person无法通过编译————如果编译器没有取得其实现代码所用到的classes string,Date和Address的定义式。这样的定义式通常由#include指示符提供,所以Person定义文件的最上方很可能存在这样的东西:

#include <string>
#include "date.h"
#include "address.h"    

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

如果像下面代码那样定义Person,将实现细目分开叙述:

name std{
    class string;       //前置声明(不正确,详下)
}
class Date;             //前置声明
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::string address() const;
    ...
};

如果可以那么做,Person的客户就只需要在Person接口被修改过时才重新编译。

可是这个想法存在两个问题。第一,string不是个class,它是个typedef(定义为basic_string)。因此上述针对string而做的前置声明并不正确;正确的前置声明比较复杂,因为涉及额外的templates。然而那并不要紧,因为你本来就不该尝试手工声明一部分标准程序库。你应该仅仅使用适当的#include来完成目的。标准头文件不太可能成为编译瓶颈。

关于”前置声明每一件东西”的第二个(同时也是比较重要的)困难是,编译器必须在编译期间知道对象的大小,比如:

int main()
{
    int x;                  //定义一个int
    Person p(params);       //定义一个Person
}

当编译器看到x的定义式,它知道必须分配多少内存(通常位于stack内)才能够持有一个int。每个编译器都知道一个int有多大。当编译器看到p的定义式,它也知道必须分配足够空间以放置一个Person,但它如何知道一个Person对象有多大呢?编译器获得这项信息的唯一办法就是询问class的定义式

这个问题在java语言上并不存在,因为当我们以那种语言定义对象时,编译器只分配足够空间给一个指针(用以指向该对象)使用。也就是说它们将上述代码视同这样子:

int main()
{
    int x;              //定义一个int
    Person *p;          //定义一个指针指向Person对象
    ...
}

这当然也是合法的C++代码,所以针对Person我们也可以这样做:把Person分割为两个classes,一个只提供接口,另一个负责实现该接口。如果负责实现的那个所谓implementation class取名为PersonImp1,Person将定义如下:

#include <string>       //标准程序库组件不该被前置声明,直接用#include来完成
#include <memeory>      //此乃为了shared_ptr而含入;详后。

class PersonImp1;       //Person实现类的前置声明。
class Date;             //Person接口用到的classes的前置声明。
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::string address()   const;
    ...
private:
    std::shared_ptr<PersonImp1> pImp1;  //指针,指向实现物;
};

在这里,main class(Person)只内含一个指针成员(shared_ptr),指向其实现类(PersonImp1)。这般设计常被称为pimpl idiom(pimpl是”pointer to implementation”的缩写)。这种classes内的指针名称往往就是pImp1。

这样设计之下,Person的客户就完全与Dates,Addresses以及Persons的实现细目分离了。那些classes的任何实现修改都不需要Person客户端重新编译。此外由于客户无法看到Person的实现细目,也就不可能写出”取决于那些实现细目”的代码。这真正是”接口与实现细目的分离”!

这个分离的关键在于以”声明的依存性”替换”定义的依存性”,那正是编译依存性最小化的本质:现实中让头文件尽可能自我满足,万一做不到,则让它与其他文件内的声明式(而非定义式)相依。其他每一件事都源自于这个简单的设计策略:

  • 如果使用object reference或object pointers可以完成任务,就不要使用object。你可以只依靠一个类型声明式就定义出指向该类型的reference和pointer;但如果定义某些类型的objects,就需要用到该类型的定义式。
  • 如果能够,尽量以class声明式替换class定义式。注意,当你声明一个函数而它用到某个class时,你并不需要该class的定义式;纵使函数以by value方式传递该类型的参数(或返回值)亦然:
class Date;                     //class声明式
Date today();                   //today函数,没问题——这里也
void clearAppointments(Date d); //不需要Date的定义式。

声明today函数和clearAppointments函数而可以无需定义Date,然而一旦任何人调用那些函数,调用之前Date的定义式一定得先曝光才行。假设你有一个函数库内含数百个函数声明,不太可能每个客户叫遍每一个函数。如果能够将”提供class定义式”(通过#include完成)的义务从”函数声明所在”之头文件转移到”内含函数调用”之客户文件,便可将”并非真正必要之类型定义”与客户端之间的编译依存性去除掉

  • 为声明式和定义式提供不同的头文件。为了促进严守上述准则,需要两个头文件,一个用于声明式,一个用于定义式。当然,这些文件必须保持一致性,如果有个声明式被改变了,两个文件都得改变。因此程序库客户应该总是#include一个声明文件而非前置声明若干函数,程序库作者也应该提供这两个头文件。举个例子,Date的客户如果希望声明Date和clearAppointments,他们不该像先前那样以手工方式前置声明Date,而是应该#include适当的,内含声明式的头文件:
#include "datefwd.h"  //这个头文件内声明(但未定义)class Date。
Date today();         //同前
void clearAppointments(Date d);

只含声明式的那个头文件名为”datefwd.h”,命名方式取法C++标准程序库头文件的。内含iostream各组件的声明式,其对应定义则分布在若干不同的头文件内,包括,,和。

深具启发意义的另一个原因是,它分外彰显”本条款适用于templates也适用于non-templates”。虽然条款30说过,在许多建置环境(build environments)中template定义式通常被至于头文件内,但也有某些建置环境允许template定义式放在”非头文件”内,这么一来就可以将”只含声明式”的头文件提供给templates。就是这样的一份头文件。

像Person这样使用pimpl idiom的classes,往往被称为Handle classes.这样的classes如何真正做点事情。办法之一是将它们的所有函数转交给相应的实现类(implementation classes)并由后者完成实际工作。例如下面是Person两个成员函数的实现:

#include "Person.h"
#inline "PersonImp1.h"  //这里必须#include PersonImp1的class定义式,否则无法调用其成员函数;
        //注意,PersonImp1有着和Person完全相同的成员函数,两者接口完全相同。

Person::Person(const std::string&name,const Date &birthday,const Address &addr)
:pImp1(new PersonImp1(name,birthday,addr))
{}
std::string Person::name() const
{
    return pImp1->name();
}

请注意,Person构造函数以new调用PersonImp1构造函数,以及Person::name函数内调用PersonImp1::name。这是重要的,让Person变成一个Handle class并不会改变它做的事,只会改变它做事的方法。

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

Interface classes类似Java和.NET的Interfaces,但C++的Interface classes并不需要负担Java和.NET的Interface所需负担的责任。举个例子,Java和.NET都不允许在Interfaces内实现成员变量或成员函数,但C++不禁止这两样东西。C++这种更为巨大的弹性有其用途,因为一如条款36所言,”non-virtual函数的实现”对继承体系内所有classes都应该相同,所以将此等函数实现为Interface class(其中写有相应声明)的一部分也是合理的。

一个针对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来撰写应用程序,因为它不可能针对”内含pure virtual函数”的Person class具现出实体。就像Handle classes的客户一样,除非Interface class的接口被修改否则其客户不需要重新编译。

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

class Person{
public:
    ...
    static std::shared_ptr<Person> create(  //返回一个shared_ptr,指向一个新的Person,
        const std::string& name,    //并以给定之参数初始化。条
        const Date& birthday,       //款18告诉你为什么返回的是shared_ptr                   
        const Address& addr);
    ...
};

客户会这样使用它们:


std::string name;
Date dateOfBirth;
Address address;
...
//创建一个对象,支持Person接口
std::shared_ptr<Person> pp(Person::create(name,dateOfBirth,address));
...
std::cout<<pp->name()           //通过Person的接口使用这个对象
    <<"was born on "
    <<pp->birthDate()
    <<" and now lives at "
    <<pp->address();
...                 //当pp离开作用域,对象会被自动删除,见条款13.

当然支持Interface class接口的那个具象类/派生类(concrete classes)必须被定义出来,而且真正的构造函数必须被调用。

Handle classes和Interface classes解除了接口与实现之间的耦合关系,从而降低文件间的编译依存性(compilation dependencies)。这些付出的代价:

  1. 在Handle classes身上,成员函数必须通过implementation pointer取得对象数据。那会为每一次访问增加一层间接性。而每一个对象消耗的内存数量必须增加implementation pointer
    的大小。最后,implementation pointer必须初始化(在Handle class构造函数内),指向一个动态分配得来的implementation object,所以你将蒙受因动态分配内存(及其后的释放动作)而来的额外开销,以及遭遇bad_alloc异常(内存不足)的可能性。
  2. 至于Interface classes,由于每个函数都是virtual,所以你必须为每次函数调用付出一个间接跳跃(indirect jump)成本(见条款7).此外Interface class 派生的对象必须内含一个vptr(virtual table pointer ,再次见条款7),这个指针可能会增加存放对象所需的内存数量————实际取决于这个对象除了Interface class 之外是否还有其他virtual函数来源。
  3. 最后,不论Handle classes或Interface classes,一旦脱离inline函数都无法有太大作为。条款30解释过为什么函数本体为了被inlined必须(很典型地)置于头文件内,但Handle classes和Interface classes正时特别被设计用来隐藏实现细节如函数本体。

请记住

  • 支持”编译依存性最小化”的一般构想是:相依于声明式,不要相依于定义式。基于此构想的两个手段是Handle classes和Interface classes。
  • 程序库头文件应该以”完全且仅有声明式”(full and declaration-only forms)的形式存在。这种做法不论是否涉及templates都适用。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值