设计具有指针成员的类时,类设计者必须首先需要决定的是该指针应提供什么行为。
将一个指针复制到另一个指针时,两个指针指向同一对象。
当两个指针指向同一对象时,可能使用任一指针改变基础对象。
类似地,很可能一个指针删除了一对象时,另一指针的用户还认为基础对象仍然存在。
指针成员默认具有与指针对象同样的行为。
然而,通过不同的复制控制策略,可以为指针成员实现不同的行为。
大多数 C++ 类采用以下三种方法之一管理指针成员:
1. 指针成员采取常规指针型行为。这样的类具有指针的所有缺陷但无需特殊的复制控制。
2. 类可以实现所谓的“智能指针”行为。指针所指向的对象是共享的,但类能够防止悬垂指针。
3. 类采取值型行为。指针所指向的对象是唯一的,由每个类对象独立管理。
[1. 一个带指针成员的简单类]
为了阐明所涉及的问题,我们将实现一个简单类,该类包含一个 int 值和一个指针:
// class that has a pointer member that behaves like a plain pointer class HasPtr { public: // copy of the values we're given HasPtr(int *p, int i): ptr(p), val(i) { } // const members to return the value of the indicated data member int *get_ptr() const { return ptr; } int get_int() const { return val; } // non const members to change the indicated data member void set_ptr(int *p) { ptr = p; } void set_int(int i) { val = i; } // return or change the value pointed to, so ok for const objects int get_ptr_val() const { return *ptr; } void set_ptr_val(int val) const { *ptr = val; } private: int *ptr; int val; };
1.1 默认复制/赋值与指针成员
因为 HasPtr 类没有定义复制构造函数,所以复制一个 HasPtr 对象将复制两个成员:
int obj = 0; HasPtr ptr1(&obj, 42); // int* member points to obj, val is 42 HasPtr ptr2(ptr1); // int* member points to obj, val is 42
复制之后,ptr1 和 ptr2 中的指针指向同一对象且两个对象中的 int 值相同。
但是,因为指针的值不同于它所指对象的值,这两个成员的行为看来非常不同。
复制之后,int 值是清楚和独立的,而指针则纠缠在一起。
注意:具有指针成员且使用默认合成复制构造函数的类具有普通指针的所有缺陷。
尤其是,类本身无法避免悬垂指针。
1.2 指针共享同一对象
复制一个算术值时,副本独立于原版,可以改变一个副本而不改变另一个:
ptr1.set_int(0); // changes val member only in ptr1 ptr2.get_int(); // returns 42 ptr1.get_int(); // returns 0
复制指针时,地址值是可区分的,但指针指向同一基础对象。
如果在任一对象上调用 set_ptr_val,则二者的基础对象都会改变:
ptr1.set_ptr_val(42); // sets object to which both ptr1 and ptr2 point ptr2.get_ptr_val(); // returns 42
两个指针指向同一对象时,其中任意一个都可以改变共享对象的值。
1.3 悬垂指针(Dangling Pointers)
因为类直接复制指针,会使用户面临潜在的问题:HasPtr 保存着给定指针。
用户必须保证只要 HasPtr 对象存在,该指针指向的对象就存在:
int *ip = new int(42); // dynamically allocated int initialized to 42 HasPtr ptr(ip, 10); // Has Ptr points to same object as ip does delete ip; // object pointed to by ip is freed ptr.set_ptr_val(0); // disaster: The object to which Has Ptr points was freed!
这里的问题是 ip 和 ptr 中的指针指向同一对象。
删除了该对象时,ptr 中的指针不再指向有效对象。
然而,ptr 却没有办法得知对象已经不存在了。
[2. 定义智能指针类]
智能指针除了增加功能外,其行为像普通指针一样。
本例中让智能指针负责删除共享对象。
用户将动态分配一个对象并将该对象的地址传给新的 HasPtr 类。
用户仍然可以通过普通指针访问对象,但绝不能删除指针。
HasPtr 类将保证在撤销指向对象的最后一个 HasPtr 对象时删除对象。
具体而言,复制对象时,副本和原对象将指向同一基础对象,
如果通过一个副本改变基础对象,则通过另一对象访问的值也会改变。
新的 HasPtr 类需要一个析构函数来删除指针,但是,析构函数不能无条件地删除指针。
如果两个 HasPtr 对象指向同一基础对象,
那么在两个对象都撤销之前,我们并不希望删除基础对象。
为了编写析构函数,需要知道这个 HasPtr 对象是否为指向给定对象的最后一个。
2.1 引入使用计数
定义智能指针的通用技术是采用一个使用计数(或引用计数)。
智能指针类将一个计数器与类指向的对象相关联。
使用计数跟踪该类有多少个对象共享同一指针。
使用计数为 0 时,删除对象。每次创建类的新对象时,初始化指针并将使用计数置为 1。
当对象作为另一对象的副本而创建时,复制构造函数复制指针并增加与之相应的使用计数的值。
对一个对象进行赋值时,
赋值操作符减少左操作数所指对象的使用计数的值(如果使用计数减至 0,则删除对象),
并增加右操作数所指对象的使用计数的值。
最后,调用析构函数时,析构函数减少使用计数的值,如果计数减至 0,则删除基础对象。
唯一的创新在于决定将使用计数放在哪里。
计数器不能直接放在 HasPtr 对象中,为什么呢?考虑下面的情况:
int obj; HasPtr p1(&obj, 42); HasPtr p2(p1); // p1 and p2 both point to same int object HasPtr p3(p1); // p1, p2, and p3 all point to same int object
如果使用计数保存在 HasPtr 对象中,创建 p3 时怎样更新它?
可以在 p1 中将计数增量并复制到 p3,但怎样更新 p2 中的计数?
2.2 使用计数类
实现使用计数有两种经典策略,另一种方法将在后续章节中讲述。
这里所用的方法中,需要定义一个单独的具体类用以封闭使用计数和相关指针:
// private class for use by HasPtr only class U_Ptr { friend class HasPtr; int *ip; size_t use; U_Ptr(int *p): ip(p), use(1) { } ~U_Ptr() { delete ip; } };
这个类的所有成员均为 private。
我们不希望用户使用 U_Ptr 类,所以它没有任何 public 成员。
将 HasPtr 类设置为友元,使其成员可以访问 U_Ptr 的成员。
U_Ptr 类保存指针和使用计数,每个 HasPtr 对象将指向一个 U_Ptr 对象,
使用计数将跟踪指向每个 U_Ptr 对象的 HasPtr 对象的数目。
U_Ptr 定义的构造函数复制指针,而析构函数删除它。
构造函数还将使用计数置为 1,表示一个 HasPtr 对象指向这个 U_Ptr 对象。
假定刚从指向 int 值 42 的指针创建一个 HasPtr 对象,可以画出这些对象,如下图:
如果复制这个对象,则对象如下图所示。
2.3 使用计数类的使用
新的 HasPtr 类保存一个指向 U_Ptr 对象的指针,U_Ptr 对象指向实际的 int 基础对象。
必须改变每个成员以说明的 HasPtr 类指向一个 U_Ptr 对象而不是一个 int* 值。
先看看构造函数和复制控制成员:
/* smart pointer class: takes ownership of the dynamically allocated * object to which it is bound * User code must dynamically allocate an object to initialize a HasPtr * and must not delete that object; the HasPtr class will delete it */ class HasPtr { public: // HasPtr owns the pointer; pmust have been dynamically allocated HasPtr(int *p, int i): ptr(new U_Ptr(p)), val(i) { } // copy members and increment the use count HasPtr(const HasPtr &orig):ptr(orig.ptr), val(orig.val) { ++ptr->use; } HasPtr& operator=(const HasPtr&); // if use count goes to zero, delete the U_Ptr object ~HasPtr() { if (--ptr->use == 0) delete ptr; }
private: U_Ptr *ptr; // points to use-counted U_Ptr class int val; };
接受一个指针和一个 int 值的 HasPtr 构造函数使用其指针形参创建一个新的 U_Ptr 对象。
HasPtr 构造函数执行完毕后,HasPtr 对象指向一个新分配的 U_Ptr 对象,该 U_Ptr 对象存储给定指针。
新 U_Ptr 中的使用计数为 1,表示只有一个 HasPtr 对象指向它。
复制构造函数从形参复制成员并增加使用计数的值。
复制构造函数执行完毕后,新创建对象与原有对象指向同一 U_Ptr 对象,该 U_Ptr 对象的使用计数加 1。
析构函数将检查 U_Ptr 基础对象的使用计数。
如果使用计数为 0,则这是最后一个指向该 U_Ptr 对象的 HasPtr 对象,
在这种情况下,HasPtr 析构函数删除其 U_Ptr 指针。
删除该指针将引起对 U_Ptr 析构函数的调用,U_Ptr 析构函数删除 int 基础对象。
2.4 赋值与使用计数
赋值操作符比复制构造函数复杂一点:
HasPtr& HasPtr::operator=(const HasPtr &rhs) { ++rhs.ptr->use; // increment use count on rhs first if (--ptr->use == 0) delete ptr; // if use count goes to 0 on this object, delete it ptr = rhs.ptr; // copy the U_Ptr object val = rhs.val; // copy the int member return *this; }
在这里,首先将右操作数中的使用计数加 1,然后将左操作数对象的使用计数减 1 并检查这个使用计数。
如果这是指向 U_Ptr 对象的最后一个对象,就删除该对象,这会依次撤销 int 基础对象。
将左操作数中的当前值减 1(可能撤销该对象)之后,
再将指针从 rhs 复制到这个对象。赋值照常返回对这个对象的引用。
这个赋值操作符在减少左操作数的使用计数之前使 rhs 的使用计数加 1,从而防止自身赋值。
注意:如果左右操作数相同,赋值操作符的效果将是 U_Ptr 基础对象的使用计数加 1 之后立即减 1。
2.5 改变其他成员
现在需要改变访问 int* 的其他成员,以便通过 U_Ptr 指针间接获取 int:
class HasPtr { public: // copy control and constructors as before // accessors must change to fetch value from U_Ptr object int *get_ptr() const { return ptr->ip; } int get_int() const { return val; } // change the appropriate data member void set_ptr(int *p) { ptr->ip = p; } void set_int(int i) { val = i; } // return or change the value pointed to, so ok for const objects // Note: *ptr->ip is equivalent to *(ptr->ip) int get_ptr_val() const { return *ptr->ip; } void set_ptr_val(int i) { *ptr->ip = i; } private: U_Ptr *ptr; // points to use-counted U_Ptr class int val; };
那些使用指针操作的函数必须对 U_Ptr 解引用,以便获取 int* 基础对象。
复制 HasPtr 对象时,int 成员的行为与第一个类中一样。
所复制的是 int 成员的值,各成员是独立的,副本和原对象中的指针仍指向同一基础对象,
对基础对象的改变将影响通过任一 HasPtr 对象所看到的值。
然而,HasPtr 的用户无须担心悬垂指针,只要他们让 HasPtr 类负责释放对象,
HasPtr 类将保证只要有指向基础对象的 HasPtr 对象存在,基础对象就存在。
为了管理具有指针成员的类,
必须定义复制构造函数、赋值操作符和析构函数这三个复制控制成员。
值型类将指针成员所指基础值的副本给每个对象。
复制构造函数分配新元素并从被复制对象处复制值,
赋值操作符撤销所保存的原对象并从右操作数向左操作数复制值,析构函数撤销对象。
“智能指针”的类在对象间共享同一基础值,从而提供了指针型行为。
使用计数是管理智能指针类的通用技术。
同一基础值的每个副本都有一个使用计数。
复制构造函数将指针从旧对象复制到新对象时,会将使用计数加 1。
赋值操作符将左操作数的使用计数减 1 并将右操作数的使用计数加 1,
如果左操作数的使用计数减至 0,赋值操作符必须删除它所指向的对象,
最后,赋值操作符将指针从右操作数复制到左操作数。
析构函数将使用计数减 1,并且,如果使用计数减至 0,就删除基础对象。
[3. 定义值型类]
处理指针成员的另一个方法是给指针成员提供值语义。
具有值语义的类所定义的对象,其行为很像算术类型的对象:
复制值型对象时,会得到一个不同的新副本。
对副本所做的改变不会反映在原有对象上,反之亦然。string 类是值型类的一个例子。
要使指针成员表现得像一个值,复制 HasPtr 对象时必须复制指针所指向的对象:
/* * Valuelike behavior even though HasPtr has a pointer member: * Each time we copy a HasPtr object, we make a new copy of the * underlying int object to which ptr points. */ class HasPtr { public: // no point to passing a pointer if we're going to copy it anyway // store pointer to a copy of the object we're given HasPtr(const int &p, int i): ptr(new int(p)), val(i) {} // copy members and increment the use count HasPtr(const HasPtr &orig):ptr(new int (*orig.ptr)), val(orig.val) { } HasPtr& operator=(const HasPtr&); ~HasPtr() { delete ptr; } // accessors must change to fetch value from Ptr object int get_ptr_val() const { return *ptr; } int get_int() const { return val; } // change the appropriate data member void set_ptr(int *p) { ptr = p; } void set_int(int i) { val = i; } // return or change the value pointed to, so ok for const objects int *get_ptr() const { return ptr; } void set_ptr_val(int p) const { *ptr = p; } private: int *ptr; // points to an int int val; };
复制构造函数不再复制指针,它将分配一个新的 int 对象,并初始化该对象以保存与被复制对象相同的值。
每个对象都保存属于自己的 int 值的不同副本,所以析构函数将无条件删除指针。
赋值操作符不需要分配新对象,
它只是必须记得给其指针所指向的对象赋新值,而不是给指针本身赋值:
HasPtr& HasPtr::operator=(const HasPtr &rhs) { // Note: Every HasPtr is guaranteed to point at an actual int; // We know that ptr cannot be a zero pointer *ptr = *rhs.ptr; // copy the value pointed to val = rhs.val; // copy the int
return *this; }
换句话说,改变的是指针所指向的值,而不是指针。
即使要将一个对象赋值给它本身,赋值操作符也必须总是保证正确。
本例中,即使左右操作数相同,操作本质上也是安全的,因此,不需要显式检查自身赋值。