《C++Primer》第十三章-复制控制-学习笔记(3)-智能指针&指针成员

本文探讨了C++中具有指针成员的类如何通过智能指针、使用计数和值型行为管理资源。重点介绍了智能指针类的设计,包括使用计数的实现,以及如何避免悬垂指针问题。

《C++Primer》第十三章-复制控制-学习笔记(3)-智能指针&指针成员

日志:
1,2020-03-17 笔者提交文章的初版V1.0

作者按:
最近在学习C++ primer,初步打算把所学的记录下来。

传送门/推广
《C++Primer》第二章-变量和基本类型-学习笔记(1)
《C++Primer》第三章-标准库类型-学习笔记(1)
《C++Primer》第八章-标准 IO 库-学习笔记(1)
《C++Primer》第十二章-类-学习笔记(1)

管理指针成员

本书始终提倡使用标准库。这样做的一个原因是,使用标准库能够大大减少现代 C++ 程序中对指针的需要。然而,许多应用程序仍需要使用指针,特别是在类的实现中。包含指针的类需要特别注意复制控制,原因是复制指针时只复制指针中的地址,而不会复制指针指向的对象。

设计具有指针成员的类时,类设计者必须首先需要决定的是该指针应提供什么行为。 将一个指针复制到另一个指针时,两个指针指向同一对象。当两个指针指向同一对象时,可能使用任一指针改变基础对象。类似地,很可能一个指针删除了一对象时,另一指针的用户还认为基础对象仍然存在。
指针成员默认具有与指针对象同样的行为。然而,通过不同的复制控制(copy control)策略,可以为指针成员实现不同的行为。大多数 C++ 类采用以下三种方法之一管理指针成员

  1. 指针成员采取常规指针型行为。这样的类具有指针的所有缺陷但无需特殊的复制控制。
  2. 类可以实现所谓的“智能指针”行为。指针所指向的对象是共享的,但类能够防止悬垂指针
  3. 类采取值型行为。指针所指向的对象是唯一的,由每个类对象独立管理。

本节中介绍三个类,分别实现管理指针成员的三种不同方法。

一个带指针成员的简单类

为了阐明所涉及的问题,我们将实现一个简单类,该类包含一个 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;
};

HasPtr 构造函数接受两个形参,将它们复制到 HasPtr 的数据成员。HasPtr类提供简单的访问函数:函数 get_int 和 get_ptr 分别返回 int 成员和指针成员的值:set_int 和 set_ptr 成员则使我们能够改变这些成员,给 int 成员一个新值或使指针成员指向不同的对象。还定义了 get_ptr_val 和set_ptr_val 成员,它们能够获取和设置指针所指向的基础值。

默认复制/赋值与指针成员

因为 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 值是清楚和独立的,而指针则纠缠在一起。

具有指针成员且使用默认合成复制构造函数(synthesized copy constructor)的类具有普通指针的所有缺陷。尤其是,类本身无法避免悬垂指针

指针共享同一对象

复制一个算术值时,副本独立于原版,可以改变一个副本而不改变另一个:

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

两个指针指向同一对象时,其中任意一个都可以改变共享对象的值。

可能出现悬垂指针

因为类直接复制指针,会使用户面临潜在的问题: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 中的指针不再指向有效对象。(悬垂指针 然而,没有办法得知对象已经不存在了。

定义智能指针类

上节中我们定义了一个简单类,保存一个指针和一个 int 值。其中指针成员的行为与其他任意指针完全相同。对该指针指向的对象所做的任意改变都将作用于共享对象。如果用户删除该对象,则类就有一个悬垂指针,指向一个不复存在的对象。
除了使指针成员与指针完全相同之外,另一种方法是定义所谓的智能指针类(smart pointer)智能指针除了增加功能外,其行为像普通指针一样。 本例中让智能指针负责删除共享对象。用户将动态分配一个对象并将该对象的地址传给新的 HasPtr类用户仍然可以通过普通指针访问对象,但绝不能删除指针。HasPtr 类将保证在撤销指向对象的最后一个 HasPtr 对象时删除对象。

HasPtr 在其他方面的行为与普通指针一样。具体而言,复制对象时,副本和原对象将指向同一基础对象,如果通过一个副本改变基础对象,则通过另一对象访问的值也会改变。
新的 HasPtr 类需要一个析构函数来删除指针,但是,析构函数不能无条件地删除指针。如果两个 HasPtr 对象指向同一基础对象,那么,在两个对象都撤销之前,我们并不希望删除基础对象。 为了编写析构函数,需要知道这个 HasPtr对象是否为指向给定对象的最后一个。

引入使用计数

定义智能指针的通用技术是采用一个使用计数智能指针类将一个计数器与类指向的对象相关联。 使用计数跟踪该类有多少个对象共享同一指针(地址)使用计数为 0 时,删除对象。使用计数(use count)有时也称为引用计数(reference count)

每次创建类的新对象时,初始化指针并将使用计数置为 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有三个对象共享同一个指针

如果使用计数保存在 HasPtr 对象中,创建 p3 时怎样更新它?可以在 p1 中将计数增量并复制到 p3,但怎样更新 p2 中的计数?

使用计数类

实现使用计数有两种经典策略,在这里将使用其中一种,另一种方法在第15.8.1 节中讲述。这里所用的方法中,需要定义一个单独的具体类用以封闭使用计数和相关指针

// private class for use by HasPtr only
class U_Ptr {
	friend class HasPtr; //将 HasPtr 类设置为友元,使其成员可以访问 U_Ptr 的成员。
	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 对象,可以画出这些对象,如下图:
在内存中分配一个对象,int值42,指针p指向它。然后用这个指针p来初始化HasPtr对象。
HasPtr对象中的指针指向U_Ptr对象,U_Ptr对象中的指针指向内存中的int值42。
在这里插入图片描述

使用计数类的使用

如果复制这个HasPtr对象,则对象如下图所示。新的 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
* 用户代码必须动态分配一个对象来初始化HasPtr类,而且用户代码不能删除这个对象,要让HasPtr对象来删除。
*/
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 基础对象。

赋值与使用计数

赋值操作符(assignment operator)比复制构造函数复杂一点:

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。)

改变其他成员

现在需要改变访问 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;
};

获取和设置 int 成员的函数不变。那些使用指针操作的函数必须对 U_Ptr解引用,以便获取 int* 基础对象。
复制 HasPtr 对象时,int 成员的行为与第一个类中一样。所复制的是 int成员的值,各成员是独立的,副本和原对象中的指针仍指向同一基础对象,对基础对象的改变将影响通过任一 HasPtr 对象所看到的值。 然而,HasPtr 的用户无须担心悬垂指针。只要他们让 HasPtr 类负责释放对象,HasPtr 类将保证只要有指向基础对象的 HasPtr 对象存在,基础对象就存在。

定义值型类

处理指针成员的另一个完全不同的方法,是给指针成员提供值语义(value semantics)
具有值语义的类(值型类)所定义的对象,其行为很像算术类型的对象:复制值型对象时,会得到一个不同的新副本。对副本所做的改变不会反映在原有对象上,反之亦然。 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) { }
	//复制构造函数不再复制指针,它将分配一个新的 int 对象,并初始化该对象以保存与被复制对象相同的值
	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;
}

换句话说,改变的是指针所指向的值,而不是指针。

即使要将一个对象赋值给它本身,赋值操作符也必须总是保证正确。本例中,即使左右操作数相同,操作本质上也是安全的,因此,不需要显式检查自身赋值。

建议:管理指针成员

具有指针成员的对象一般需要定义复制控制成员。如果依赖合成版本,会给类的用户增加负担。用户必须保证成员所指向的对象存在,只要还有对象指向该对象。

为了管理具有指针成员的类,必须定义三个复制控制成员:复制构造函数赋值操作符析构函数。这些成员可以定义指针成员的指针型行为值型行为
值型类将指针成员所指基础值的副本给每个对象。复制构造函数分配新元素并从被复制对象处复制值,赋值操作符撤销所保存的原对象并从右操作数向左操作数复制值,析构函数撤销对象。

作为定义值型行为或指针型行为的另一选择,是使用称为“智能指针”的一些类。这些类在对象间共享同一基础值,从而提供了指针型行为
但它们使用复制控制技术以避免常规指针的一些缺陷。为了实现智能指针行为,类需要保证基础对象一直存在,直到最后一个副本消失。使用计数(第 13.5.1 节)是管理智能指针类的通用技术。同一基础值的每个副本都有一个使用计数。复制构造函数将指针从旧对象复制到新对象时,会将使用计数加 1。赋值操作符将左操作数的使用计数减 1 并将右操作数的使用计数加 1,如果左操作数的使用计数减至 0,赋值操作符必须删除它所指向的对象,最后,赋值操作符将指针从右操作数复制到左操作数。析构函数将使用计数减 1,并且,如果使用计数减至 0,就删除基础对象。

管理指针的这些方法用得非常频繁,因此使用带指针成员类的程序员必须充分熟悉这些编程技术。

小结

类除了定义该类型对象上的操作,还需要定义复制、赋值或撤销该类型对象的含义。特殊成员函数(复制构造函数、赋值操作符和析构函数)可用于定义这些操作。这些操作统称为“复制控制”函数
如果类没有定义这些操作中的一个或多个,编译器将自动定义它们。合成操作执行逐个成员初始化(memberwise initialization)、赋值或撤销:合成操作依次取得每个成员,根据成员类型进行成员的复制、赋值或撤销。

  • 如果成员为类类型的,合成操作调用该类的相应操作(即,复制构造函数调用成员的复制构造函数,析构函数调用成员的析构函数,等等)。
  • 如果成员为内置类型或指针,则直接复制或赋值,析构函数对撤销内置类型或指针类型的成员没有影响。
  • 如果成员为数组,则根据元素类型以适当方式复制、赋值或撤销数组中的元素。

与复制构造函数和赋值操作符不同,无论类是否定义了自己的析构函数,都会创建和运行合成析构函数。如果类定义了析构函数,则在类定义的析构函数结束之后运行合成析构函数。定义复制控制函数最为困难的部分通常在于认识到它们的必要性。

分配内存或其他资源的类几乎总是需要定义复制控制成员来管理所分配的资源。
如果一个类需要析构函数,则它几乎也总是需要定义复制构造函数和赋值操作符。

参考资料

【1】C++ Primer 中文版(第四版·特别版)

注解

【2】关于“指针成员的指针型行为或值型行为”

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值