在上一篇博文里,我介绍了代理类的相关内容,如果记性好的朋友,应该已经对代理类有了比较深入的认识。在设计代理类的过程中,我们遇到的核心问题是:内存的分配和编译时类型未知对象的绑定。我们通过让所有子类自定义一个 copy 函数,来返回自身的复制,这种方式来解决需要我们自己来管理内存的繁琐,又通过定义代理类绑定子类的类型,通过一个基类指针来保存子类这种方式来实现运行时绑定。
但对代码的追求是永无止尽的,虽然代理类解决了我们的需求,但是对一些苛刻的程序员来说,复制对象这种行为是让人无法忍受的,在一个理想的程序世界里,任何事物如果是指代相同的内容,那么就应该只保存一份(好吧,这是我自己的理想世界)。难道就真的没有一种方法,能够让我们不去复制对象来实现运行时绑定吗?答案是肯定的。
在《C++ 沉思录》中作者介绍了一个耳熟能详的名字——句柄。
总而言之,除了内存的问题外,copy 这个函数的实现,看起来不像我们在代理类里面写的那样容易。而且这里还有一个最为重要的原因,让我们不去使用这种代理类,因为它需要你去修改基类所有的派生子类,去给它们都添加一个 copy 函数,但这样通常是不被允许的,因为我们很有可能是在原有代码的基础上来增加这个新的需求,当然,这个条件略显苛刻,但是作为一名IT从业人员,我得说这是一种常态。所以我们得想办法改进。
好吧,放弃引用吧,让我们还是回到指针上来,指针是另一个可以让我们不复制内存,而指向同一个对象的方法,当然,指针有指针的问题,比如在代理类中我已经提到过的,关于未初始化指针的复制,以及悬垂指针、重复删除指针所指对象等等问题,那么难道我们就不能找到一种方法去避免指针的安全性问题,同时能够尝到指针的甜美吗?毕竟,指针是我认为在 C 系语言中最为重要同时功能最为丰富的概念。
《C++ 沉思录》里说,对于 C++ 的解决方法是定义一个适当的类,这个类被称为句柄( handle ),它的另一个名字相信大家早已烂熟于心,也频繁出现在各大面试题当中,也就是智能指针。
提起这个名字大家立马想到什么引用计数啦、什么自动释放啦。慢着,我们先不要去理会那些概念,事物的产生都是按照一定规律来的,我们要做的不是去死记那些表现,而是去理解背后的规律。
讨论到这里,很多人可能已经忘了初衷,让我们再来回顾一下,我们的目的是要设计出一种方法,能够让我不必去复制对象,同时能够达到运行时绑定对象的方法。我们分析来分析去,最后还是只有指针似乎满足这个条件,毕竟我们的选择也不是很多。
代理类中我们用交通工具的例子来说明问题,这里我们也用一个例子,比如说一个平面坐标系上的点作为基类:
1
2
3
4
5
6
7
8
9
10
11
|
class
Point {
public
:
Point() : xval(0), yval(0) { }
Point(
int
x,
int
y) : xval(x), yval(y) { }
int
x()
const
{
return
xval; }
int
y()
const
{
return
yval; }
Point &x(
int
xv) { xval = xv;
return
*
this
; }
Point &y(
int
yv) { yval = yv;
return
*
this
; }
private
:
int
xval, yval;
}
|
1
2
3
4
5
6
7
8
9
10
11
|
class
handle {
public
:
Handle();
Handle(
int
,
int
);
Handle(
const
Point &);
Handle(
const
Handle &);
Handle &operator=(
const
Handle &);
~Handle();
private
:
Point *p;
}
|
1
2
3
4
5
6
7
8
9
10
11
12
|
class
handle {
public
:
Handle();
Handle(
int
,
int
);
Handle(
const
Point &);
Handle(
const
Handle &);
Handle &operator=(
const
Handle &);
Handle &x(
int
);
~Handle();
private
:
Point *p;
}
|
在你兴奋过头之前,还有一个问题需要解决,就是这个引用计数应该放在什么地方。首先,它的值肯定不能放在句柄里,因为如果这样的话,当你改变这个引用计数的时候,就必须找到所有指向相同 Point 对象的其他句柄,并一起修改它们的引用计数,这明显是不可能的;然后你的引用计数也不能放在 Point 对象里,因为这样的话你就必须修改 Point 对象的代码,这个在实际中是很难办到的,原因请参看我上面“IT从业人员”那句话。
这样看来只能在句柄中保存另一个指针,指向引用计数的那块内存了,比如下面这样:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
class
handle {
public
:
Handle();
Handle(
int
,
int
);
Handle(
const
Point &);
Handle(
const
Handle &);
Handle &operator=(
const
Handle &);
Handle &x(
int
);
~Handle();
private
:
Point *p;
int
*u;
}
|
首先让我们再明确一下,我们需要把句柄类第一次绑定到 Point 对象的时候复制一个 Point 对象的副本,然后使用引用计数来统计同时有多少个句柄指向了这个相同的对象,然后我们需要在引用计数减为 0 ,也就是这个副本没有被引用的时候删除掉这个副本。
仔细看看句柄里定义的函数,我们马上就能判断出哪些函数会进行对象的绑定,首先所有带 Point & 参数的复制构造函数都会进行 Point 对象的绑定,这是显而易见的;其次,当我们在复制 handle 的时候,也会进行对象的绑定,每进行一次复制就会增加一个指向相同 Point 对象的句柄,此时引用计数就应该增加;析构函数看起来也挺容易,每次析构我们都需要把引用计数减一,然后判断是否为 0 了,如果为 0 了则删除 Point 对象的副本就OK了。
剩下的一个不起眼的函数却有大文章可以做,就是我之前增加的那个设置 x 的值的函数,这个函数有些特殊,为什么这么说呢?让我们来看看下面这段代码:
1
2
3
4
5
6
7
8
9
|
// 首先定义一个新的句柄,并绑定到一个新的Point对象,x为3,y为4
Handle h(3, 4);
// 然后通过复制构造函数,使h2也绑定到这个对象
Handle h2 = h;
// 这句话值得玩味,我们的目的到底是设置绑定的那个Point对象的x值为5
// 还是说我们只是希望这个句柄的值为5
h2.x(5);
// 这里取得的值,你究竟希望它是3,还是5呢?
int
n = h.(x);
|
如果是指针语义,我们看起来比较好理解,因为这样句柄就只是控制 Point 对象的控制器,任何对指向对象的句柄的操作,都会实质性的影响到真正的对象本身,这个在单个句柄指向对象的时候是没有问题的,但是当出现有多个句柄指向同一个对象的时候,就会出现,你无法确定句柄里保存的对象的真正的值是否还是和绑定时相同,你手里拿着打开金库的钥匙,但是你不确定金库里放的东西还是不是当初交给你的了,因为别人也可以打开金库拿走里面的东西。
如果是值语义,那么我们就需要在值发生修改时,重新拷贝一份副本保存下来,而不是去修改原对象里的值。这是一个听起来高端大气上档次的想法,我们称之为“写时复制( copy on write )”。指导思想是只有当必要的时候才会进行复制,仔细想想这个必要时候一般来说指的是进行写操作的时候,因为只有当写入的值与原值不同,我们才需要复制一份副本,然后进行保存。
关于“写时复制”还有很多内容可以谈,这种思想在操作系统内存管理上使用的最为普遍,只在需要时进行内存的分配,听起来多么酷啊。举个例子,比如我们使用的 fork() 函数,在创建子进程的时候,就不会立马把父进程的进程空间拷贝一份给子进程,而是让子进程共享父进程的空间,只有当我们向子进程写入的时候才会进行拷贝父进程空间然后进行写入,这听起来和我们这个句柄的行为多么相像啊。如果你留心,你会在各种技术中发现”写时复制“的影子。
至此,我们已经看到了至少3种方案了,第一种是像代理类那样,每次拷贝都会复制其所绑定的对象;第二种是我们通过句柄实现指针语义,始终只保持一份对这个对象的句柄,通过引用计数来计算有多少句柄绑定其上;第三种也就是我们刚刚介绍的”写时复制“技术,句柄的行为在不进行写操作时和第二种是相同的,只有当进行写操作才会把绑定的对象再复制一份副本,这样实现达到值语义。
如果已经习惯程序性思维的人可能已经想到了,其实还有另一个分支我们还没有遍历到,就是一次也不进行拷贝,句柄指向的就是对象本身,不允许同时有两个及以上的句柄绑定同一个对象,这种句柄压根就没有引用计数,因为不可能同时有多个句柄绑定相同的对象,句柄在被赋值时,就会解除对原对象的绑定,来绑定新对象,这样每个对象只会被一个句柄绑定,比如:
1
|
h1 = h;
|
仔细一想,为什么要使用这种句柄呢?直接使用对象不就完了吗,反正对象是唯一的,绑定其上的句柄也是唯一的,那我还要句柄干嘛呢?在《C++ 沉思录》中指出”使用这种句柄可能会是相当危险的,因为我们可能在没有意识到的情况下把一个句柄从其所绑定的对象上断开“。
好像扯的有点远了,让我们还是回来继续,分析完了 handle 的各个函数实现,那么我们要开始写代码了,当然关于指针语义和值语义我们会分开来写,虽然通过上面的分析,值语义和指针语义各有各的优点也各有各的缺点,但是在实际中这两种语义确实都在被使用,所以这里我会分开实现两个版本的 handle 类,其实主要区别在于对原对象的赋值操作,我们先写出两种方式的公共操作:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
Handle::Handle() : u(
new
int
(1)), p(
new
Point) { }
Handle::Handle(
int
x,
int
y) : u(
new
int
(1)), p(
new
Point(x, y)) { }
Handle::Handle(
const
Point &p0) : u(
new
int
(1)), p(
new
Point(p0)) { }
Handle::Handle(
const
Handle &h) : u(h.u), p(h.p) { ++*u; }
Handle & Handle::operator=(
const
handle &h)
{
// 增加=号右侧句柄的引用计数,注意,必须先增加=号右侧的引用计数,
// 否则当把句柄赋值给自己时Point就被删除了
++*h.u;
// 减少=号左侧句柄的引用计数,如果为0了,则删除绑定的对象副本
if
(--*u == 0)
{
delete
u;
delete
p;
}
u = h.u;
p = h.p;
return
*
this
;
}
Handle::~Handle()
{
if
(--*u == 0)
{
delete
u;
delete
p;
}
}
|
1
2
3
4
5
|
Handle &Handle::x(
int
x0)
{
p->x(x0);
return
*
this
;
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
Handle &Handle::x(
int
x0)
{
/*
这里比较的目的是如果引用计数大于1,代表有多个句柄指向该对象,
所以我们需要减少引用计数,如果引用计数为1,
代表只有这一个句柄指向这个对象,
既然,我要修改这个对象的值,那么直接改原对象就可以了。
*/
if
(*u != 1)
{
--*u;
p =
new
Point(*p);
}
p->x(x0);
return
*
this
;
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
class
UseCount
{
public
:
UseCount() : p(
new
int
(1)) { }
UseCount(
const
UseCount &u) : p(u.p) { ++*p; }
~UseCount() {
if
(--*p == 0)
delete
p; }
bool
only() {
return
*p == 1; }
// 返回该引用计数是否为1
bool
reattach(
const
UseCount &u) {
++*u.p;
if
(--*p == 0) {
delete
p;
p = u.p;
return
true
;
}
p = u.p;
return
false
;
}
bool
makeonly() {
// 用于”写时复制“,产生一个新的引用计数
if
(*p == 1) {
return
false
;
}
--*p;
p =
new
int
(1);
return
true
;
}
private
:
UseCount &operator=(
const
UseCount &);
private
:
int
*p;
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
class
handle
{
public
:
Handle() : p(
new
Point) { }
Handle(
int
x,
int
y) : p(
new
Point(x, y)) { }
Handle(
const
Point &p0) : p(
new
Point(p0)) { }
~Handle() {
if
(u.only())
delete
p; }
Handle &operator=(
const
handle &h) {
if
(u.reattach(h.u))
delete
p;
p = h.p;
return
*
this
;
}
Handle &x(
int
x0) {
if
(u.makeonly()) p =
new
Point(*p);
p->x(x0);
return
*
this
;
}
private
:
Point *p;
UseCount u;
}
|
在代理类中,我们学会了如何设计一种容器来存放编译时未知类型的对象,并且找到了一种内存分配的方法来做这些
。但是代理类有两个问题,一个是每次复制代理类都会导致对象的复制,另一个是我们必须要从对象的设计开始就想到之后要使用代理类的问题,得为代理类留出接口,比如一个 copy 函数。在句柄这篇文章中,我们学到一种使用引用计数的方式,来避免每次复制都需要拷贝对象的操作,同时不用去修改原始的对象,因此它更加灵活。我们还看到了指针语义和值语义的区别已经如何实现两种语义的方式,明白了”写时复制“的含义。在最后我们把引用计数抽离成为一个单独的类,这样这个引用计数就可以嵌入到各个不同的句柄设计中,而不需要每个句柄都自己来控制,抽象是循序渐进的,我非常喜欢这种一气呵成的感觉。