《Effective C++》:条款28-条款29

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

这里的handles指的是引用、指针、迭代器等可以修改对象的传递方法。

假设现在编写一个表示矩形的class,每个矩形有其左上角和右下角表示。先定义平面内的点

class Point{
public:
    Point(int x, int y);
    ……
    void setX(int newVal);
    void setY(int newVal);
    ……
};

再定义矩形对象

struct RectData{
    Point ulhc;//upper left-hand corner
    point lrhc;//lower right-hand corner
};

后面定义使用的类

class Rectangle{
……
private:
    std::tr1::shared<RectData> pData;
};

Rectangle客户应该知道矩形的范围,因此扎个class应该提供得到两个坐标点的函数。根据**条款**20的忠告,我们知道by reference方式传递值比by value方式效率更高,如果使用by reference方式传递

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

这样设计,语法上没有问题。但是从逻辑上来看,upperLeft和lowerRight被声明为const成员函数,它们被设计用来给客户提供Rectangle坐标,而不是让客户修改Rectangle。但是返回的引用 的数据是private内部数据,客户可以通过引用修改内部数据。因此我们得到两个教训:

  • 1.成员变量的封装性最多等于“返回其reference”的函数访问级别。上面的例子中,ulhclrhc虽然都被声明为private,但实际却是public,因为public函数upperLeftlowerRight传出了它们的reference。
  • 2.如果成员函数传出一个reference,reference所指数据与对象自身关联,且存储在对象之外,通过这个引用可以修改其所指数据。这时bitwise constness的一个附带结果,见**条款**3。

如果把上面的reference换成指针、迭代器等handles,同样适用。返回对象内部数据的handle会带来降低对象封装性的风险。

我们都知道对象的“内部”是指其成员变量,其实非public成员函数(即protected和private)也是对象“内部”数据的一部分。因此要留心不要返回它们的handles。也就是说,不要让成员函数返回一个指针指向“访问级别较低”的成员函数;如果这样做,后者的访问级别会提高到与前者一致。虽然返回成员函数的指针情况不多见,但是也要注意这一点。

下面讨论怎么解决Rectangle class的成员函数upperLeftlowerRight的问题。我们遇到的问题是客户可能会修改这两个函数的返回值,那么如果其返回值是const,那么就可以解决这个问题。

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

这样一来,客户可以读取矩形Points的值,但是不能修改它们。这里,蓄意放松了封装。但是有时即使如此,还是返回了“代表对象内部”的handles,有可能在其他场合带来问题。更明确一点,是可能会 导致dangling handles(空悬的号码牌):handles所指对象不存在。这种不复存在的对象,最常见的来源就是函数返回值。例如某个函数返回对象外框(bounding box),这个外框采用矩形形式:

class GUIObject{……};
const Rectangle boundingBox(const GUIObject& obj);//,by value返回,条款3讨论过为什么是const

现在客户这样使用

GUIObject* pgo;
const Point* pUpperLeft=&(boundingBox(*pgo).upperLeft());//取得一个指针,指向外框左上点

调用boundingBox获得一个新的、临时的Rectangle对象,临时对象没有名字,暂且成为temp,随后upperLeft作用于temp身上,返回一个reference指向temp的一个内部成分。于是,pUpperLeft指向这个Point对象。问题出在temp是一个临时对象,当这个语句结束后,临时对象便会析构,这时pUpperLeft指向一个不再存在的对象。pUpperLeft变成空悬、虚吊(dangling)。

所以,“返回一个handle代表对象内部成分”总是危险的,不论这个handle是不是const。问题关键是:handle一旦被传出去,此handle的寿命可能比起所指对象更长。

但这并不意味着你绝对不可以让成员函数返回handle,有时候你还必须这样做。例如重载operator[],必须返回其reference,但是要牢记,reference所指对象会随着class对象的销毁而销毁。

总结避免返回handles(reference、指针、迭代器)指向对象内部。遵守这个条款可以增加封装性,帮助const成员函数的行为像个const,并将发生“虚吊号码牌”(dangling handles)的风险降到最低。

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

异常安全(Exception safety)有点像怀孕,但是在完成求偶之前,实在无法确实地讨论生育安全。

假设在多线程环境中,一个class用来表现夹带背景图案的GUI菜单,它应该有个互斥量(mutex)来控制并发(concurrency control)

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

从“异常安全性”来看,这个函数没有满足“异常安全”两个条件中的任何一个:

  • 1.不泄露任何资源。上面,一旦在lockunlock之间发生异常,unlock的调用就不会执行,互斥量永远被锁住了。

  • 2.不允许数据败坏。上述代码中,如果new Image(imgSrc)抛出异常,bgImage就会指向不存在的对象,但imageChanges已经累加,其实并没有新的图像安装起来。
    解决上面第一个问题比较容易,条款**13介绍过以对象管理资源的方法,条款**14页导入Lock class作为一种确保互斥量被及时释放的方法。

    void PrettyMenu::changeBacground(std::istream& imgSrc)
    {
    Lock m(&mutex);
    deelte bgImage;
    ++imageChanges;
    bgImage=new Image(imgSrc);
    }
    这个实现代码更短,一个一般性的规则:较少的代码就是较好的代码,因为出错机会比较少,且一旦有所改变,被误解的机会也比较少。

在解决了资源泄露后,来看看如何解决数据败坏。首先来定义一些术语。

异常安全函数(Exception-safe functions)提供以下三个保证之一:

  • 1、基本承诺:如果异常被抛出,程序内的任何事物仍然保持在有效状态下。没有任何对象或数据结构因此而破坏,所有对象都处于一种内部前后一致的状态。例如,刚刚的changeBackground如果抛出异常,PrettyMenu对象应该可以继续使用原背景图像,或令它拥有某个缺省背景图像。
  • 2、强烈保证如果抛出异常,程序状态不变。这点有点像事物,成功则完全成功,失败则回到调用函数之前的状态。和第一个保证不同,第一个保证只能保证程序处于一个合法状态,而强烈保证能保证程序处于两个状态的一种。
  • 3、不抛掷(nothrow)保证,承诺绝不抛出异常,因为它们总是能够完成它们原先承诺的功能。作用于内置类型(int,指针等)身上的所有操作都提供nothrow保证。这时异常安全码中一个必不可少的关键基础材料。

异常安全码(Exception-safe code)必须提供三个保证之一(本条款末尾讨论不惧异常安全的传统代码),上面三个保证依次增强,我们需要做的是应该为所写的每一个函数提供哪一种保证。当然nothrow最好,但是C part of C++领域中全部调用完全没有异常的函数不大可能。任何使用动态内存(比如STL容器),如果内存无法开辟,通常会有bad_alloc异常(**条款**49)。所以,我们的选择往往在基本保证和强烈保证之间。

对于changeBackground来说,提供强烈保证并不困难。使用智能指针,改变语句顺序即可

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

智能指针使用reset,当new Image(imgSrc)成功后,才会删除旧图像。这些改变几乎让changeBackground提供强烈异常安全保证。美中不足的是参数imgSrc,如果Image构造函数抛出异常,有可能是输入流(input stream)的读取记号(read marker)已被移走,而这样的搬移对程序其余部分是一种可见状态的改变。所以changeBackground在解决这个问题之前只提供异常安全保证。

有个一般化的设计可以很典型的导致强烈保证,这个策略是copy and swap。原则是:为你打算改变的对象(原件)做出一份副本,然后再副本身上做一切必要修改。如果修改动作抛出异常,原对象仍保持未改变状态。当所有修改都成功后,再讲修改过的副本和原对象在一个不抛出异常的操作中置换(swap)。

在实现上,通常将所有“隶属对象的数据”从原对象放进另一个对象内,然后赋予原对象一个指针,指向那个所谓的实现对象(implementation object,即副本)。这种手法叫做pimpl idiom,**条款**31描述了它。对PrettyMenu来说,实现如下:

struct PMImpl{  //PMImpl:Pretty Menu Impl。后面说明为什么是struct
    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);
    std::tr1::shared_ptr<PMImpl> pNew(new PMImpl(*pImpl));
    pNew->bgImage.reset(new Image(imgSrc));;//修改副本
    ++pNew->imageChanges;
    swap(pImpl,pNew);
}

上面代码中,PMImpl是一个struct,而不是class,因为PrettyMenu的数据封装性已经有“pImpl是private”而获得保证。当然如果PMImpl是class,虽然一样好,但是有时却不太方便(但也保持了对象纯度)。也可以将PMImpl嵌套于PrettyMenu内,但打包问题(packaging,例如独立撰写异常安全码)是这里所考虑的事。

虽然“copy-and-swap”的策略是改变对象全有或全无的一个好办法,但一般而言,它并不保证整个函数有强烈的异常安全。为了原因,把changeBackground换成一个抽象概念:someFunc

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

很显然,这个函数的异常安全性和函数f1f2有关。如果它们异常安全性更低,那么会拉低someFunc()的异常安全性。例如,如果f1只提供基本保证,为了让someFunc提供强烈保证,我们必须写代码获得调用f1之前的整个程序状态、捕捉f1所有可能异常、然后恢复状态。

即使f1f2都是强烈异常安全,情况也不会好转。如果f1圆满结束,程序状态在任何方面都有可能有所改变;如果f2随后抛出异常,程序状态和someFunc被调用之前也并不相同,甚至当f2没有改变任何东西时也是如此。

这个问题的原因是“连带影响”(side effects)。函数对“非局部性数据”(non-local data)有连带影响时,就很难提供强烈保证。例如当f1函数修改了数据库,那么其他客户就能看到这一笔修改了,很难再提供强烈保证了。

既然强烈保证有这么多麻烦,或许你不再想为函数提供强烈保证了。还有一个理由支持你这个想法,那就是效率。copy-and-swap的关键在于“修改对象数据的副本,然后在一个不抛出异常的函数中将修改后的数据和原数据置换”,这需要耗费新建数据副本的空间以及时间。因此从效率上来看,“强烈保证”未必显得那么实际。

为实际考虑,有时放弃“强烈保证”,可以退而求其次的追求“基本保证”。对于许多函数来说,“异常安全性之基本保证”是一个比较适当的选择。

加入我们写的函数不提供异常安全,那么他人又可以假设我们在这方面有缺失,除非我们证明自己。我们应该写出异常安全的代码,但有时我们也可以有理由不这么做。例如someFunc函数中调用f1f2,如果其中一个没有提供异常安全保证,甚至没有提供最基本的异常安全,那么someFunc是无法补偿这些问题的。因为someFunc调用函数没有提供任何异常安全保证,someFunc自身也不可能提供任何保证。

一个系统要么具备异常安全性,要么不具备,没有“局部异常安全系统”。许多老旧C++代码不具备异常安全性,所以今天 许多系统仍然不是异常安全的,因为它们使用了一些非异常安全的代码。

因此当我们编写或修改代码时,应该考虑如何让它具备异常安全性。首先是“以对象管理资源”(**条款**13),组织那些可能泄露的资源。然后挑选三个“异常安全保证”中的一个实施到我们所编写的每一个函数身上(以“现实可操作条件”为最强烈等级)。如果我们调用非异常安全代码,那么我们就别无选择了,只能将它设为“无任何保证”。将我们做的选择写成文档,为了给客户看,以及为了将来的维护代码。函数的“异常安全性保证”是其可见接口的一部分,我们应该慎重选择。

goto代码以前被视为美好的时间,如今我们却致力写出结构化控制流(structured control flows);以前全局数据结构(globally accessible data)被视为美好的实践,如今我们却致力于数据封装。以前我们没考虑过异常安全,如今我们却致力于写出异常安全代码。时间在前进,我们与时俱进。

总结

  • 1.异常安全函数(Exception-safe functions)即使发生异常,也不会发生资源泄露或数据结构败坏。这样的函数分为三种:基本型、强烈型、不抛异常型。
  • 2.“强烈保证”往往能够以copy-and-swap实现,但是“强烈保证”未必那么必要或有现实意义。
  • 3.函数提供的“异常安全保证”通常最高只等于其所调用函数中,“异常安全性”最低的决定。

郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。