假设你当前设计的应用程序里会涉及到矩形。每个矩形的区域都由它的左上角和右下角的坐标来表示。为了让Rectangle对象尽可能的小巧,你可能会做出这样的决定:并不在Rectangle内部保存这些点的坐标信息,而是将这些信息保存在一个辅助结构中,然后让Rectangle指向它:
class Point { // 表示点的类
public:
Point(int x, int y);
...
void setX(int newVal);
void setY(int newVal);
...
};
struct RectData { // 供Rectangle类使用的点的数据
Point ulhc; // ulhc = "左上角"
Point lrhc; // lrhc = "右下角"
};
class Rectangle {
...
private:
std::tr1::shared_ptr<RectData> pData;
// 关于tr1::shared_ptr请参见条目13
};
因为Rectangle的客户可能需要计算矩形的面积,所以这个类就应该提供upperLeft和lowerRight函数。然而,Point却是一个用户自定义的类型,因此伴着你对条目20中相关推论的回忆——通过引用传递用户自定义类型的对象要比直接传值更高效,你会让这些函数返回指向Point的引用:
class Rectangle {
public:
...
Point& upperLeft() const { return pData->ulhc; }
Point& lowerRight() const { return pData->lrhc; }
...
};
这样的设计可以通过编译,但是它却是错误的。实际上,它是自我矛盾的。一方面,由于upperLeft和lowerRight的设计初衷仅仅是为客户提供一个途径来了解Rectangle的两个顶点坐标在哪里,而不是让客户去修改它,因此这两个函数应声明为const成员函数。另一方面,这两个函数都返回指向私有内部数据的引用——通过这些引用,调用者可以任意修改内部数据!请看下边的示例:
Point coord1(0, 0);
Point coord2(100, 100);
const Rectangle rec(coord1, coord2); // rec是一个Rectangle常量
// 两顶点是(0, 0), (100, 100)
rec.upperLeft().setX(50); // 但现在rec的两顶点却变为
// (50, 0), (100, 100)!
upperLeft返回了rec内部的Point数据成员,在这里请注意:虽然rec本身应该是const的,但是调用者竟可以使用upperLeft所返回的引用来修改这个数据成员!
上面的现象立刻引出了两个议题:首先,数据成员仅仅与访问限制最为宽泛的、返回该数据成员引用的函数拥有同等的封装性。在这种情况下,即使ulhc和lrhc声明为私有的,它们实际上仍然是公共的,这是因为公共函数upperLeft和lowerRight返回了指向它们的引用。其次,假设一个const成员函数返回一个引用,这一引用指向的数据与某个对象相关,但该数据却保存在该对象以外,那么函数的调用者就可以修改这一数据。(这仅仅是按位恒定限制所留下的隐患之一——参见条目3。)
我们所做的一切都是围绕着返回引用的成员函数展开的,但是如果成员函数返回的是指针或者迭代器,同样的问题仍然会因为同样的理由发生。引用、指针、迭代器都可以称作“句柄”(获取其它对象的渠道),返回一个指向对象内部部件的句柄,通常都会危及到对象的封装性。就像我们看到的,即使成员函数是const的,返回对象的状态也是可以任意更改的。
大体上讲,对象的“内部部件”主要是它的数据成员,但是非公有成员函数(也就是protected和private的)同样也是对象的内部部件。与数据成员相同,返回指向成员函数的句柄也是糟糕的设计。这意味着你不应该让一个公有成员函数A返回一个指向非公用成员函数B的指针。如果你这样做了,B的访问权层次就与A一样了,这是因为客户将能够取得B的指针,然后通过这一指针来调用它。
所幸的是,返回指向成员函数指针的函数并不常见,所以我们还是把精力放在Rectangle类和它的upperLeft和lowerRight成员函数上。我们所发现的关于这些函数所存在的两个问题都可以简单的解决,只要将它们的返回值限定为const的就可以了:
class Rectangle {
public:
...
const Point& upperLeft() const { return pData->ulhc; }
const Point& lowerRight() const { return pData->lrhc; }
...
};
使用这一改进的设计方案,客户就可以读取用来定义一个矩形的两个顶点,但是他们不可以修改这两个Point。这就意味着将upperLeft和lowerRight声明为const并不是一个假象,因为它们将不允许调用者来修改对象的状态。至于封装问题,由于我们一直期望客户能够看到构造一个Rectangle的两个Point,所以这里我们故意放松了封装的限制。更重要的是,这一放松是有限的:这些函数仅仅提供了读的访问权限。写权限仍然是禁止的。
即使这样,upperLeft和lowerRight仍然会返回指向对象内部部件的句柄,而且会存在其他形式的问题。在某些特定的情况下,会导致悬空句柄:即引用对象中不再存在的某些部分的句柄。能够造成此类“会消失的对象”最普遍的东西就是函数返回值。举例说,请考虑以下函数,它以一个长方形的形式返回一个GUI对象的边界盒:
class GUIObject { ... };
const Rectangle boundingBox(const GUIObject& obj);
// 以传值方式返回一个矩形。关于返回值为什么是const的,请参见条目3
现在请考虑一下客户可能怎样来使用这个函数:
GUIObject *pgo; // 让pgo指向某个GUIObject
...
const Point *pUpperLeft = &(boundingBox(*pgo).upperLeft());
// 取得一个指向boundingBox左上角顶点的指针
调用boundingBox将会返回一个新的、临时的Retangle对象。这个对象没有名字,所以姑且叫它temp。随后temp将调用upperLeft,然后此次调用将返回一个指向temp内部部件的引用,特别地,指向构造temp的一个点。pUpperleft将会指向这一Point对象。到目前为止一切都很完美,但是任务尚未完成,因为在这一语句的最后,boundingBox的返回值(即temp)将会被销毁,这样间接上会导致temp的Point被销毁掉。于是,pUpperLeft将会指向一个并不存在的对象。这条语句创建了pUpperLeft,可也让它成了悬空指针。
这就解释了为什么说:任何返回指向对象内部部件句柄的函数都是危险的。至于句柄是指针还是引用还是迭代器,函数是否是const的,成员函数返回的句柄本身是不是const的,这一切都无关紧要。只有一点,那就是:只要返回了一个句柄,那么就意味着你正在承担风险:它可能会比它指向的对象存活更长的时间。
这并不意味着你永远也不能让一个成员函数返回一个句柄。有些时候你不得不这样做。比如说,operator[]允许你获取string和vector中的任一元素,这些operator[]的工作就是通过返回容器内部的数据来完成的(参见条目3)——当容器本身被销毁时,这些数据同时也会被销毁。然而,这仅仅是一个例外,不是惯例。
时刻牢记
l 避免返回指向对象内部部件的句柄(引用、指针或迭代器)。这样做可以增强封装性,帮助const成员函数拥有更加恒定的行为,并且使悬空句柄出现的几率降至最低。