条款28:灵巧(smart)指针(上)
灵巧指针是一种外观和行为都被设计成与内建指针相类似的对象,不过它能提供更多的功能。它们有许多应用的领域,包括资源管理(参见条款9、10、25和31)和重复代码任务的自动化(参见条款17和29)
当你使用灵巧指针替代C++的内建指针(也就是dumb pointer),你就能控制下面这些方面的指针的行为:
构造和析构。你可以决定建立灵巧指针时应该怎么做。通常赋给灵巧指针缺省值0,避免出现令人头疼的未初始化的指针。当指向某一对象的最后一个灵巧指针被释放时,一些灵巧指针负责删除它们指向的对象。这样做对防止资源泄漏很有帮助。
拷贝和赋值。你能对拷贝灵巧指针或设计灵巧指针的赋值操作进行控制。对于一些类型的灵巧指针来说,期望的行为是自动拷贝它们所指向的对象或用对这些对象进行赋值操作,也就是进行deep copy(深层拷贝)。对于其它的一些灵巧指针来说,仅仅拷贝指针本身或对指针进行赋值操作。还有一部分类型的灵巧指针根本就不允许这些操作。无论你认为应该如何去做,灵巧指针始终受你的控制。
Dereferencing(取出指针所指东西的内容)。当客户端引用被灵巧指针所指的对象,会发生什么事情呢?你可以自行决定。例如你可以用灵巧指针实现条款17提到的lazy fetching 方法。
灵巧指针从模板中生成,因为要与内建指针类似,必须是strongly typed(强类型)的;模板参数确定指向对象的类型。大多数灵巧指针模板看起来都象这样:
template<class T> //灵巧指针对象模板
class SmartPtr {
public:
SmartPtr(T* realPtr = 0); // 建立一个灵巧指针
// 指向dumb pointer所指的
// 对象。未初始化的指针
// 缺省值为0(null)
SmartPtr(const SmartPtr& rhs); // 拷贝一个灵巧指针
~SmartPtr(); // 释放灵巧指针
// make an assignment to a smart ptr
SmartPtr& operator=(const SmartPtr& rhs);
T* operator->() const; // dereference一个灵巧指针
// 以访问所指对象的成员
T& operator*() const; // dereference 灵巧指针
private:
T *pointee; // 灵巧指针所指的对象
};
拷贝构造函数和赋值操作符都被展现在这里。对于灵巧指针类来说,不能允许进行拷贝和赋值操作,它们应该被声明为private(参见Effective C++条款27)。两个dereference操作符被声明为const,是因为dereference一个指针时不能对指针进行修改(尽管可以修改指针所指的对象)。最后,每个指向T对象的灵巧指针包含一个指向T的dumb pointer。这个dumb pointer指向的对象才是灵巧指针指向的真正对象。
进入灵巧指针实作的细节之前,应该研究一下客户端如何使用灵巧指针。考虑一下,存在一个分布式系统(即其上的对象一些在本地,一些在远程)。相对于访问远程对象,访问本地对象通常总是又简单而且速度又快,因为远程访问需要远程过程调用(RPC),或其它一些联系远距离计算机的方法。
对于编写程序代码的客户端来说,采用不同的方法分别处理本地对象与远程对象是一件很烦人的事情。让所有的对象都位于一个地方会更方便。灵巧指针可以让程序库实现这样的梦想。
template<class T> // 指向位于分布式 DB(数据库)
class DBPtr { // 中对象的灵巧指针模板
public: //
DBPtr(T *realPtr = 0); // 建立灵巧指针,指向
// 由一个本地dumb pointer
// 给出的DB 对象
DBPtr(DataBaseID id); // 建立灵巧指针,
// 指向一个DB对象,
// 具有惟一的DB识别符
... // 其它灵巧指针函数
}; //同上
class Tuple { // 数据库元组类
public:
...
void displayEditDialog(); // 显示一个图形对话框,
// 允许用户编辑元组。
// user to edit the tuple
bool isValid() const; // 返回*this是否通过了
}; // 合法性验证
// 这个类模板用于在修改T对象时进行日志登记。
// 有关细节参见下面的叙述:
template<class T>
class LogEntry {
public:
LogEntry(const T& objectToBeModified);
~LogEntry();
};
void editTuple(DBPtr<Tuple>& pt)
{
LogEntry<Tuple> entry(*pt); // 为这个编辑操作登记日志
// 有关细节参见下面的叙述
// 重复显示编辑对话框,直到提供了合法的数值。
do {
pt->displayEditDialog();
} while (pt->isValid() == false);
}
在editTuple中被编辑的元组物理上可以位于本地也可以位于远程,但是编写editTuple的程序员不用关心这些事情。灵巧指针类隐藏了系统的这些方面。程序员只需关心通过对象进行访问的元组,而不用关心如何声明它们,其行为就像一个内建指针。
注意在editTuple中LogEntry对象的用法。一种更传统的设计是在调用displayEditDialog前开始日志记录,调用后结束日志记录。在这里使用的方法是让LogEntry的构造函数启动日志记录,析构函数结束日志记录。正如条款9所解释的,当面对异常时,让对象自己开始和结束日志记录比显示地调用函数可以使的程序更健壮。而且建立一个LogEntry对象比每次都调用开始记录和结束记录函数更容易。
正如你所看到的,使用灵巧指针与使用dump pointer没有很大的差别。这表明了封装是非常有效的。灵巧指针的客户端可以象使用dumb pointer一样使用灵巧指针。正如我们将看到的,有时这种替代会更透明化。
灵巧指针的构造、赋值和析构
灵巧指针的的析构通常很简单:找到指向的对象(一般由灵巧指针构造函数的参数给出),让灵巧指针的内部成员dumb pointer指向它。如果没有找到对象,把内部指针设为0或发出一个错误信号(可以是抛出一个异常)。
灵巧指针拷贝构造函数、赋值操作符函数和析构函数的实作由于所有权的问题所以有些复杂。如果一个灵巧指针拥有它指向的对象,当它被释放时必须负责删除这个对象。这里假设灵巧指针指向的的对象是动态分配的。这种假设在灵巧指针中是常见的(有关确定这种假设是真实的方法,参见条款27)。
看一下标准C++类库中auto_ptr模板。这如条款9所解释的,一个auto_ptr对象是一个指向堆对象的灵巧指针,直到auto_ptr被释放。auto_ptr的析构函数删除其指向的对象时,会发生什么事情呢?auto_ptr模板的实作如下:
template<class T>
class auto_ptr {
public:
auto_ptr(T *ptr = 0): pointee(ptr) {}
~auto_ptr() { delete pointee; }
...
private:
T *pointee;
};
假如auto_ptr拥有对象时,它可以正常运行。但是当auto_ptr被拷贝或被赋值时,会发生什么情况呢?
auto_ptr<TreeNode> ptn1(new TreeNode);
auto_ptr<TreeNode> ptn2 = ptn1; // 调用拷贝构造函数
//会发生什么情况?
auto_ptr<TreeNode> ptn3;
ptn3 = ptn2; // 调用 operator=;
// 会发生什么情况?
如果我们只拷贝内部的dumb pointer,会导致两个auto_ptr指向一个相同的对象。这是一个灾难,因为当释放quto_ptr时每个auto_ptr都会删除它们所指的对象。这意味着一个对象会被我们删除两次。这种两次删除的结果将是不可预测的(通常是灾难性的)。
另一种方法是通过调用new,建立一个所指对象的新拷贝。这确保了不会有许多指向同一个对象的auto_ptr,但是建立(以后还得释放)新对象会造成不可接受的性能损耗。并且我们不知道要建立什么类型的对象,因为auto_ptr<T>对象不用必须指向类型为T的对象,它也可以指向T的派生类型对象。虚拟构造函数(参见条款25)可能帮助我们解决这个问题,但是好象不能把它们用在auto_ptr这样的通用类中。
如果quto_ptr禁止拷贝和赋值,就可以消除这个问题,但是采用“当auto_ptr被拷贝和赋值时,对象所有权随之被传递”的方法,是一个更具灵活性的解决方案:
template<class T>
class auto_ptr {
public:
...
auto_ptr(auto_ptr<T>& rhs); // 拷贝构造函数
auto_ptr<T>& // 赋值
operator=(auto_ptr<T>& rhs); // 操作符
...
};
template<class T>
auto_ptr<T>::auto_ptr(auto_ptr<T>& rhs)
{
pointee = rhs.pointee; // 把*pointee的所有权
// 传递到 *this
rhs.pointee = 0; // rhs不再拥有
} // 任何东西
template<class T>
auto_ptr<T>& auto_ptr<T>::operator=(auto_ptr<T>& rhs)
{
if (this == &rhs) // 如果这个对象自我赋值
return *this; // 什么也不要做
delete pointee; // 删除现在拥有的对象
pointee = rhs.pointee; // 把*pointee的所有权
rhs.pointee = 0; // 从 rhs 传递到 *this
return *this;
}
注意赋值操作符在接受新对象的所有权以前必须删除原来拥有的对象。如果不这样做,原来拥有的对象将永远不会被删除。记住,除了auto_ptr对象,没有人拥有auto_ptr指向的对象。
因为当调用auto_ptr的拷贝构造函数时,对象的所有权被传递出去,所以通过传值方式传递auto_ptr对象是一个很糟糕的方法。因为:
// 这个函数通常会导致灾难发生
void printTreeNode(ostream& s, auto_ptr<TreeNode> p)
{ s << *p; }
int main()
{
auto_ptr<TreeNode> ptn(new TreeNode);
...
printTreeNode(cout, ptn); //通过传值方式传递auto_ptr
...
}
当printTreeNode的参数p被初始化时(调用auto_ptr的拷贝构造函数),ptn指向对象的所有权被传递到给了p。当printTreeNode结束执行后,p离开了作用域,它的析构函数删除它指向的对象(就是原来ptr指向的对象)。然而ptr不再指向任何对象(它的dumb pointer是null),所以调用printTreeNode以后任何试图使用它的操作都将产生不可定义的行为。只有在你确实想把对象的所有权传递给一个临时的函数参数时,才能通过传值方式传递auto_ptr。这种情况很少见。
这不是说你不能把auto_ptr做为参数传递,这只意味着不能使用传值的方法。通过const引用传递(Pass-by-reference-to-const)的方法是这样的:
// 这个函数的行为更直观一些
void printTreeNode(ostream& s,
const auto_ptr<TreeNode>& p)
{ s << *p; }
在函数里,p是一个引用,而不是一个对象,所以不会调用拷贝构造函数初始化p。当ptn被传递到上面这个printTreeNode时,它还保留着所指对象的所有权,调用printTreeNode以后还可以安全地使用ptn。从而通过const引用传递auto_ptr可以避免传值所产生的风险。(“引用传递”替代“传值”的其他原因参见Effective C++条款22)。
在拷贝和赋值中,把对象的所有权从一个灵巧指针传递到另一个中去,这种思想很有趣,而且你可能已经注意到拷贝构造函数和赋值操作符不同寻常的声明方法同样也很有趣。这些函数同上会带有const参数,但是上面这些函数则没有。实际上在拷贝和赋值中上述这些代码修改了这些参数。也就是说,如果auto_ptr对象被拷贝或做为赋值操作的数据源,就会修改auto_ptr对象!
是的,就是这样。C++是如此灵活能让你这样去做,真是太好了。如果语言要求拷贝构造函数和赋值操作符必须带有const参数,你必须去掉参数的const属性(参见Effective C++条款21)或用其他方法实现所有权的转移。准确地说:当拷贝一个对象或这个对象做为赋值的数据源,就会修改该对象。这可能有些不直观,但是它是简单的,直接的,在这种情况下也是准确的。
如果你发现研究这些auto_ptr成员函数很有趣,你可能希望看看完整的实作。在291页至294页上有(只原书页码),在那里你也能看到在标准C++库中auto_ptr模板有比这里所描述的更灵活的拷贝构造函数和赋值操作符。在标准C++库中,这些函数是成员函数模板,不只是成员函数。(在本条款的后面会讲述成员函数模板。也可以阅读Effective C++条款25)。
灵巧指针的析构函数通常是这样的:
template<class T>
SmartPtr<T>::~SmartPtr()
{
if (*this owns *pointee) {
delete pointee;
}
}
有时删除前不需要进行测试,例如在一个auto_ptr总是拥有它指向的对象时。而在另一些时候,测试会更为复杂。一个使用了引用计数(参见条款29)灵巧指针必须在判断是否有权删除所指对象前调整引用计数值。当然还有一些灵巧指针象dumb pointer一样,当它们被删除时,对所指对象没有任何影响。
实作Dereference 操作符
让我们把注意力转向灵巧指针的核心部分,the operator* 和 operator-> 函数。前者返回所指的对象。理论上,这很简单:
template<class T>
T& SmartPtr<T>::operator*() const
{
perform "smart pointer" processing;
return *pointee;
}
首先无论函数做什么,必须先初始化指针或使pointee合法。例如,如果使用lazy fetch(参见条款17),函数必须为pointee建立一个新对象。一旦pointee合法了,operator*函数就返回其所指对象的一个引用。
注意返回类型是一个引用。如果返回对象,尽管编译器允许这么做,这也将会导致灾难性后果。必须时刻牢记:pointee不用必须指向T类型对象;它也可以指向T的派生类对象。如果在这种情况下operator*函数返回的是T类型对象而不是派生类对象的引用,你的函数实际上返回的是一个错误类型的对象!(这是一个slicing问题,参见Effective C++条款22和本书条款13)。在返回的这种对象上调用虚拟函数,不会触发与所指对象的动态类型相符的函数。实际上就是说你的灵巧指针不能支持虚拟函数,象这样的指针再灵巧也没有用。而返回一个引用还能够具有更高的效率(不需要构造一个临时对象,参见条款19)。能够兼顾正确与效率当然是一件好事。
如果你是一个急性子的人,你可能会想如果一些人在null灵巧指针上调用operator*,也就是说灵巧指针的dumb pointer是null。放松。随便做什么都行。dereference一个空指针的结果是未定义的,所以这不是一个“错误”的行为。想排除一个异常么?可以,抛出吧。想调用abort函数(可能被assert在失败时调用)?好的,调用吧。想遍历内存把每个字节都设成你生日与256模数么?当然也可以。虽说这样做没有什么好处,但是就语言本身而言,你完全是自由的。
operator->的情况与operator*是相同的,但是在分析operator->之前,让我们先回忆一下这个函数调用的与众不同的含义。再考虑editTuple函数,其使用一个指向Tuple对象的灵巧指针:
void editTuple(DBPtr<Tuple>& pt)
{
LogEntry<Tuple> entry(*pt);
do {
pt->displayEditDialog();
} while (pt->isValid() == false);
}
语句
pt->displayEditDialog();
被编译器解释为:
(pt.operator->())->displayEditDialog();
这意味着不论operator->返回什么,它必须使用member-selection operator(成员选择操作符)(->)。因此operator->仅能返回两种东西:一个指向某对象的dumb pointer或另一个灵巧指针。多数情况下,你想返回一个普通dumb pointer。在此情况下,你这样实作operator-> :
template<class T>
T* SmartPtr<T>::operator->() const
{
perform "smart pointer" processing;
return pointee;
}
这样做运行良好。因为该函数返回一个指针,通过operator->调用虚拟函数,其行为也是正确的。
对于很多程序来说,这就是你需要了解灵巧指针的全部东西。条款29的引用计数代码并没有比这里更多的功能。但是如果你想更深入地了解灵巧指针,你必须知道更多的有关dumb pointer的知识和灵巧指针如何能或不能进行模拟。如果你的座右铭是“Most people stop at the Z-but not me(多数人浅尝而止,但我不能够这样) ”,下面讲述的内容正适合你。