Effective C++笔记⑤

实现

大多数情况下,适当提出你的classes(和class template)定义以及functions(和function templates)声明,是花费最多心力的两件事。一旦正确完成它们,相应的实现大多直接了当。尽管如此,还是需要注意某些东西:

  • 太快定义变量可能造成效率上的拖延
  • 过渡使用转型(casts)可能导致代码变慢又难维护
  • 返回对象“内部数据之号码牌”可能会破坏封装并留给客户虚吊号码牌
  • 未考虑异常带来的冲击则可能导致资源泄露和数据败坏
  • 过度热心地inling可能引起代码膨胀
  • 过度耦合(coupling)则可能导致让人不舒服的冗长建置时间(build times)

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

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

考虑下面这个函数,它计算通行密码的加密版本而后返回,前提是密码够长。如果密码太短,函数会丢出一个异常,类型为logic_error(定义于C++标准程序库,见条款54):

//这个函数过早定义变量“encrypted”
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在此函数中并非完全未被使用,但如果有个异常被丢出,它就真的没被使用。也就是说如果函数encryptPassword丢出异常,你仍得付出encrypted的构造成本和析构成本。所以最好延后encrypted的定义式,直到确实需要它:

//这个函数延后“encrypted”的定义,直到真正需要它
std::string encryptPassword(const std::string& password)
{
    using namespace std;
    //string encrypted;
    if(password.length() < MinimumPasswordLength){
        throw logic_error("Password is too short");
    }
    string encrypted;
    ...                    //必要动作,能将一个加密后的密码
                           //置入变量encrypted内
    return encrypted;
}

但是这段代码仍然不够好,因为encrypted虽获定义却无任何实参作为初值。这意味调用的是其default构造函数。许多时候你该对对象做的第一次事就是给它个值,通常是通过一个赋值动作达成。条款4曾解释为什么“通过default构造函数构造出一个对象然后对它赋值”比“直接在构造时指定初值”效率差。那个分析也能运用于此。举个例子,假设encryptPassword的艰难部分在以下函数中进行:

void encrypt(std::string& s);//在其中的适当地点对s加密

于是,encryptPassword可实现如下,虽然还不算是最好的做法:

//这个函数延后“encrypted”的定义,直到需要它为止
//但此函数仍然有着不该有的效率低落
std::string encryptPassword(const std::string& password)
{
    ...                        //检查length,如前。
    std::string encrypted;     //default-construct encrypted
    encrypted = password;      //赋值给encrypted
    encrpt(encrypted);
    return encrypted;
}

最受欢迎的做法是以password作为encrypted的初值,跳过毫无意义的default构造过程:

//终于,这是定义初始化encrypted的最佳做法
std::string encryptPassword(const std::string& password)
{
    ...                                    //检查长度
    std::string encrypted(password); //通过copy构造函数
                                           //定义并初始化
    encrypt(encrypted);
    return encrypted;
}

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

“但循环怎么办?”

如果变量只在循环内使用,那么把它定义于循环外并在每次循环迭代时赋值给它比较好,还是该把它定义于循环内?也就是说下面左右两个一般性结构,哪一个比较好?

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

在Widget函数内部,以上两种写法的成本如下:

  • 做法A:1个构造函数+1个析构函数+n个赋值操作
  • 做法B:n个构造函数+n个析构函数

如果classes的一个赋值成本低于一组构造+析构成本,做法A大体而言比较高效。尤其当n值很大的时候。否则做法B或许比较好。此外做法A造成名称w的作用域比做法B更大,有时那对程序的可理解性和易维护性造成冲突。

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

C++规则的设计目标之一是,保证“类型错误”绝不可能发生。理论上如果你的程序很“干净地”通过编译,就表示它并不企图在任何对象身上执行任何不安全、无意义、愚蠢荒谬的操作。

让我们首先回顾转型语法,因为通常由三种不同的形式,可写出相同的转型动作。C风格的转型动作看起来像这样:

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

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

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

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

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

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

使用旧式转换的时机是,当要调用一个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

任何一个类型的转换(不论是通过转型操作而进行的显示转换,或通过编译器完成的隐式转换)往往真的令编译器编译出运行期间的码。例如在这段程序中:

int x,y;
...
double d = static_cast<double>(x)/y;        //x除以y,使用浮点数除法

将int x转型为double几乎肯定会产生一些代码,因为在大部分计算器体系结构中,int的底层表述不同于double的底层表述。这或许不会让你惊讶,但下面这个例子就有可能是让你稍微睁大眼睛:

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

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

上个例子表明,单一对象(例如一个类型为Derived的对象)可能拥有一个以上的地址(Base*指向它以及Derived*指向它时的地址)的行为。

另一件关于转型的有趣事情是:我们很容易写出某些似是而非的代码。例如许多应用框架都要求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{               //derive class
public:
    virtual void onResize(){                     //derive onResize实现代码
        static_cast<Window>(*this).onResize();   //将*thiis转型为Window
                                                 //然后调用其onResize
                                                 //这不可行
        ...    //这里进行SpecialWindow专属行为
    }
    ...
};

这段程序将*this转型为Window,对函数onResize的调用也因此调用了Window::onResize。但恐怕没想到的是,它调用的并不㐊当前对象上的函数,而是稍早转型动作所建立的一个“*this对象之base class成分”的暂时副本身上的onResize!本段代码是在“当前对象之base class成分”的副本上调用Window::onResize,然后再当前对象身上执行SpecialWindow专属动作。如果Window::onResize修改了对象内容,当前对象其实没被改动,改动的是副本。然后SpecialWindow::onResize内如果也修改对象,当前对象真的会被改动。

解决之道是拿掉转型动作,代之以你真正想说的话。你并不想哄骗编译器将*this视为一个base class对象,你只是想调用base class版本的onResize函数,令它作用于当期那对象身上。所以请这么写:

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

这个例子也说明,如果你发现你自己打算转型,那活脱是个警告信号:你可能正将局面发展至错误的方向上。如果你用的是dynamic_cast更是如此。

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

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

  • 使用容器并在其中存储直接指向derived class对象的指针(通常是智能指针,见条款13),如此便消除了“通过base class接口处理对象”的需要。假设先前的Window/SpecialWindow继承体系中只有SpecialWindows才支持闪烁效果,试着不要这样做:
class Window { ... };
class SpecialWindow:public Window{
public:
    void blink();
    ...
};

typedef std::vector<std::tr1::shared_ptr<Window>> VPW;
VPW winPtrs;
...
for(VPW::iterator iter = winPtrs.begin();iter != winPtrs.end();++iter){//不希望使用
    if(SpecialWindow* psw = dynamic_cast<SpecialWindow*>(iter->get())) //dynamic_cast
        psw->blink();
}

应该改而这样做:

typedef std::vector<std::tr1::shared_ptr<SpecialWindow>> VPSW;
VPSW winPtrs;
...
//这样写比较好,不使用dynamic_cast
for(VPSW::iterator iter = winPtrs.begin();iter!=winPtrs.end();++iter)
    (*iter)->blink();
  • 另一种做法可让你通过base class接口处理“所有可能之各种WIndow派生类”,那就是在base class内提供virtual函数做你想对各个Window派生类做的事。例如,虽然只有SpecialWIndows可以闪烁,但或许将闪烁函数声明于base class内并提供一份“什么也没做”的缺省实现码是有意义的:
class Window{
public:
    virtual void blink() { }   //条款34说明,缺省实现代码是个馊主意
    ...
};

class SpecialWindow:public Window{
public:
    virtual void blink() { ... };    //blink做某些事
    ...
};

typedef std::vector<std::tr1::shared_ptr<Window>> VPW;
VPW winPtrs;                        //容器,内含指针,指向所有可能的Window类型
...
for(VPW::iterator iter = winPtrs.begin();iter != winPtrs.end();++iter){
    (*iter)->blink();
}

不论哪一种写法---“使用类型安全容器”或“将virtual函数往继承体系上方移动”---都并非放之四海皆准,但在许多情况下它们都提供一个可行的dynamic_cast替代方案。

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

class Window { ... };
...
typedef std::vector<std::tr1::shared_ptr<Window>> VPW;
VPW winPtrs;
...
for(VPW::iterator iter = winPtrs.begin();iter!=winPtrs.end();++iter)
{
    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继承体系一有改变,所有这一类代码都必须再次检阅看看是否需要修改。例如一旦加入新的derived class,或许上述连串判断中需要加入新的条件分支。这样的代码应该总是以某些“基于virtual函数调用”的东西取而代之。

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

假设你的程序涉及矩形。每个矩形由其左上角和右下角表示。为了让一个Rectangle对象尽可能小,你可能会决定不把定义矩形的这些点存放在Rectangle对象内,而是放在一个辅助的struct内再让Rectangle指向它:

class Point{                //这个class用来表述点
public:
    Point(int x, int y);
    ...
    void setX(int newVal);
    void setY(int newVal);
    ...
};

struct RectData{
    Point ulhc;    //左上角
    Point lrhc;    //右下角
};

class Rectangle{
    ...
private:
    std::tr1::shared_ptr<RectData> pData;    //关于tr1::shared_ptr见条款13
};

Rectangle的客户必须能够计算Rectangle的范围,所以这个class提供upperLeft函数和lowerRight函数。Point是个用户自定义类型,所以根据条款20给我们的忠(它说以by reference方式传递用户自定义类型往往比以by value方式传递更高效),这些函数于是返回references,代表底层的Point对象:

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

这样的设计可以通过编译,但确实错误的。实际上它是自我矛盾的。一方面upperLeft和lowerRight被声明为const成员函数,因为它们的目的只是为了提供客户一个得知Rectangle相关坐标点的方法,而不是让客户修改Rectangle(见条款3)。另一方面两个函数却都返回references指向private内部数据,调用者于是可通过这些references更改内部数据!例如:

Point coord1(0, 0);
Point coord2(100, 100);
const Rectangle rec(coord1, coord2);    //rec是个const矩形,从(0,0)到(100,100)
rec.upperLeft().setX(50);               //现在rec却变成从(50,0)到(100,100)

这里请注意,upperLeft的调用者能够使用被返回的reference(指向rec内部的Point成员变量)来更改成员。但rec其实是不可变的(const)!

我们可以得到两个教训

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

以上两个问题可以通过对它们的返回类型加上const来解决:

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

有了这样的改变,客户可以读取矩形的Points,但不能涂写它们,意味着不能改变对象状态。

例如:

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

现在客户这么使用这个函数:

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

【注】对boundingBox的调用获得了一个新的、暂时的Rectangle对象。我们暂且称为temp。随后upperLeft作用于temp上,返回一个reference指向temp的一个内部成分。但是,在这个语句结束后,boundingBox的返回值,也就是temp将被销毁,从而导致pUpperLeft指向一个不再存在的对象。

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

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

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

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

void PrettyMenu::changeBackground(std::istream& imgSrc)
{
    lock(&mutex);
    delete bgImage;
    ++imageChanges;
    bgImage = new Image(imgSrc);
    unlock(&mutex);
}

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

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

  • 不泄露任何资源。上述代码没有做到这一点,因为一旦“new Image(imgSrc)”导致异常,对unlock的调用就绝不会执行,于是互斥器就永远被把持住了。
  • 不允许数据破坏,imageChanges也已被累加,而其实并没有新的图像被成功安装起来。(但从另外一个角度说,旧图像已被消除,所以你可能会争辩说图像还是“改变了”)。

解决资源西楼的问题很简单,条款13已经做过说明,条款14也导入了Lock clas作为一种“确保互斥器被及时释放”的方法:

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

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

  • 基本承诺:如果异常被抛出,程序内的任何事物仍然保持在有效状态下。没有任何对象或数据结构因此而破坏,所有对象都处于一种内部前后一致的状态。然而程序的现实状态恐怖不可预料;
  • 强烈保证:如果异常被抛出,程序状态不改变。调用这样的函数有这样的认知:如果函数成功,就是完全成功,如果函数失败,程序会回复到“调用函数之前”的状态;
  • 不抛掷保证:承诺绝不抛出异常,因为它们总是能够完成它们原先承诺的功能。作用于内置类型(例如ints,指针等等)升上的所有操作都提供nothrow保证;

有一个一般化的设计策略很典型地会导致强烈保证,值得熟悉它。这个策略成为copy and swap。原则很简单:

为你打算修改的对象(原件)做出一份副本,然后在那副本身上做一切必要修改。若有任何修改动作抛出异常,原对象仍保持未改变状态。待所有改变都成功后,再将修改过的那个副本和原对象在一个不抛出异常的操作中置换(swap)。

实现上通常是将所有“隶属对象的数据”从原对象放进另一个对象内,然后赋予原对象一个指针,指向那个所谓的实现对象(即副本)。这个手法常被称为pimpl idiom,条款31详细描述了它。对PrettyMenu而言,典型写法如下:

struct PMImpl{
    std::tr1::shared_ptr<Image> bgImage;
    int imageChanges;
};

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

void PrettyMenu::changeBackground(std::istream& imgSrc)
{
    using std::swap;                    //见条款25
    Lock m1(&mutex);                    //获得mutex的副本数据
    std::tr1::shared_ptr<PMImpl> pNew(new PMImpl(*pImpl));
    pNew->bgImage.reset(new Image(imgSrc));    //修改副本
    ++pNew->imageChanges;

    swap(pImpl,pNew);                //置换(swap)数据,释放mutex
}

此例中,让PMImpl成为一个struct而不是一个class,这是因为PrettyMenu的数据封装性已经由于“pImpl是private”而获得了保证。如果令PMImpl成为一个class,虽然一样好,有时候却不太方便。

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

void someFunc(){
    ...        //对local状态做一份副本
    f1();
    f2();
    ...        //将修改后的状态置换过来
}

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

“连带效应”:举个例子,如果调用f1带来的影响是某个数据库被改动了,那就很难让someFunc具备强烈安全性。一般而言在“数据库修改动作”送出之后,没有什么做法可以取消并恢复数据库旧观,因为数据库的其他客户可能已经看到了这一笔新数据。

 

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

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

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

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

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

明确声明inline函数的做法则是在其定义式前加上关键字inline。例如标准的max template往往是这样实现出来:

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

有时候虽然编译器有意愿inlining某个函数,还是可能为该函数生成一个函数本体。举个例子,如果程序要取某个inline函数的额地址,编译器通常必须以此函数生成一个outlined函数本体。毕竟编译器哪有能力提出一个指针指向并不存在的函数呢?与此并提的是,编译器通常不对“通过函数指针而进行的调用”实施inlining,这意味着inline函数的调用有可能被inlined,也可能不被inlined,取决于该调用的实施方式:

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

实际上构造函数和析构函数往往是inlining的糟糕候选人-----虽然漫不经心的情况下你不会这么认为。考虑一下Derived class构造函数:

class Base{
public:
    ...
private:
    std::string bm1,bm2;    //base成员1和2
};

class Derived:public Base{
public:
    Derived() { }           //Derived构造函数是空的,哦,是吗?
    ...
private:
    std::string dm1,dm2,dm3;//derived成员1-3
};

这个构造函数看起来是inling的绝佳候选人,因为它根本不含任何代码。但是你的眼睛可能会欺骗你。

C++对于“对象被创建和被销毁时发生什么事”做了各式各样的保证。若new则调用构造函数,若delete则调用析构函数。这些情况中C++描述了什么一定会发生,但没有说如何发生。“事情如何发生”是编译器实现者的权责,不过至少有一点很清楚,那就是它们不可能凭空发生。你的程序内一定有某些代码让那些事情发生,而那些代码----由编译器于编译期间代为产生并安插到你程序中的代码----肯定存在于某个地方。有时候就放在你的构造函数和析构函数内,所以我们可以想象,编译器为稍早说的那个表面上看起来为空的Derived构造函数所产生的代码,相当于以下所列:

Derived::Derived()
{
    Base::Base();
    try{
        dm1.std::string::string();
    }catch(...){
        Base::~Base();
        throw;
    }

    try{
        dm2.std::string::string();
    }catch(...){
        dm1.std::string::~string();
        Base::~Base();
        throw;
    }

    try{
        dm3.std::string::string();
    }catch(...){
        dm2.std::string::~string();
        dm1.std::string::~string();
        Base::~Base();
        throw;
    }
}

这段代码并不能代表编译器真正制造出来的代码,因为真正的编译器会以更精致复杂的做法来处理异常。

相同的理由也适用于Base构造函数,所以如果它被inlined,所有替换“Base构造函数调用”而插入的代码也都会被插入到“Derived构造函数调用”内(因为Derived构造函数调用了Base构造函数)。如果string构造函数恰巧也被inlined,Derived构造函数将获得五份“string构造函数代码”副本,每一份副本对应于Derived对象内的五个字符串(两个来自继承,三个来自自己的声明)之一。

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

假设你对C++的程序的某个class实现文件做了轻微修改,而且只修改了prvate成分。然后重新建置这个程序,并预计只花数秒就好。但是,事实可能是整个程序重新编译了,牵扯甚大!

问题出在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;     //实现细目
};

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

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

不幸的是,这样便在Person定义文件和其含入文件之间形成了一种编译依存关系。如果这些头文件有任何一个被改变,或这些头文件所依赖的其他头文件有所改变吗,那么均导致每一个含Person class的文件重新编译。

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

这个想法存在两个问题:

  • string不是个class,它是个typedef(定义为basic_string<char>).因此上述针对string而做的前置声明兵不正确;正确的前置声明比较复杂,因为涉及额外的templates;
  • 关于“前置声明每一样东西”的第二个困难是,编译器必须在编译期间知道对象的大小。考虑这个:
int main()
{
    int x;
    Person p(params);
}

当编译器看到x的定义式,它知道必须分配多少内存(通常位于stack内)才够持有一个int。当看到Person定义式,编译器也知道必须分配足够空间放置一个Person,但它如何才能知道一个Person对象有多大呢?唯一办法就是询问class定义式。然而如果class定义式可以合法地不列出实现细目,编译器如何知道该分配多少空间?

针对Person我们可以这样做:把Person分割为两个classes,一个只提供接口,另一个负责实现该接口。如果负责实现的那个所谓implementation class取名为PersonImpl,Person将定义如下:

#include <string>    //标准程序库组件不该被前置声明
#include <memory>    //此乃为了tr1::shared_ptr而含入,详后

class PersonImpl;    //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::tr1::shared_ptr<PersonImpl> pImpl;    //指针,指向实现物
};

这样的设计之下,Person的客户就完全与Dates,Addresses以及Persons的实现细目分离了。那些classes的任何实现修改都不需要Person客户端重新编译。

有以下的简单的设计策略:

  • 如果使用对象引用或者对象指针可以完成任务,就不要使用objects。你可以只靠一个类型声明式就定义指向该类型的引用和指针;但如果定义某类型的对象,就需要使用到该类型的定义式;
  • 如果能够,尽量以class声明式替代class定义式。注意,当你声明一个函数而它用到某个class时,你并不需要该class的定义;纵使函数以值传递的方式传递该类型的参数(或返回值)亦然:
class Date;        //class声明式
Date today();      //没问题 --- 这里并不需要
void clearAppointments(Date d);    //Date的定义式
  • 为声明式和定义式提供不同的头文件。为了促进严守上述准则,需要两个头文件,一个用于声明式,一个用于定义式。当然,这些文件必须保持一致性,如果有个声明式被改变了,两个文件都得改变。因此程序库客户应该总是#include一个声明文件而非前置声明若干函数,程序库作者也应该提供这两个头文件。举个例子,Date的客户如果希望声明today和clearAppointments,他们不该像先前那样以手工方式前置声明Date,而是应该#include适当地、内含声明式的头文件:
#include "datefwd.h"        //这个头文件内声明(但未定义)class Date
Date today();               //同前
void clearAppoinments(Date d);

只含声明式的那个头文件名为“datefwd.h”,命名方式取法C++标准成宿头文件(见条款54)的<iosfwd>内含iostream各组件的声明式,其对应定义则分布在若干把不同的头文件内。

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

#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构造函数以new(见条款16)调用PersonImpl构造函数,以及Person::name函数内调用PersonImpl::name。这是重要的,让Person编程一个Handle class并不会改变它做的事,只会改变它做事的方法。

另一个制作Handle class的办法是,令Person称为一种特殊的abstract base class(抽象基类),称为interface class。这种class的目的是详细一一描述derived classes的接口(见条款34),因此它通常不带成员变量,也没有构造函数,只有一个virtual析构函数(见条款7)以及一组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的指针或引用来撰写程序,因为它不可能针对“内含pure virtual函数”的Person classes具体出实体。就像Handle classes的客户一样,除非interface class的接口被修改,否则其客户不需要重新编译。

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

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

客户会这样使用它:

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

//创建一个对象,支持Person接口
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();
...                                //当pp离开作用域,对象会被自动删除,见条款13

当然,支持interface class接口的那个具象累必须被定义出来,而且真正的构造函数必须被调用。一切都在virtual构造函数实现码所在的文件内秘密发生。假设interface class Person有个具象的derived class RealPerson,后者提供继承而来的virtual函数的实现:

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;
    std::string birthDate() const;
    std::string address() const;

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

有了RealPerson之后,写出Person::create就真的一点都不稀奇了:

std::tr1::shared_ptr<Person> Person::create(const std::string& name,
                                            const Date& birthday,        
                                            const Address& addr)
{
    return std::tr1::shared_ptr<Person> (new RealPerson(name,birthday,addr));
}
发布了90 篇原创文章 · 获赞 6 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/weixin_37160123/article/details/102762275