在上一篇博文里,我介绍了代理类的相关内容,如果记性好的朋友,应该已经对代理类有了比较深入的认识。在设计代理类的过程中,我们遇到的核心问题是:内存的分配和编译时类型未知对象的绑定。我们通过让所有子类自定义一个 copy 函数,来返回自身的复制,这种方式来解决需要我们自己来管理内存的繁琐,又通过定义代理类绑定子类的类型,通过一个基类指针来保存子类这种方式来实现运行时绑定。
但对代码的追求是永无止尽的,虽然代理类解决了我们的需求,但是对一些苛刻的程序员来说,复制对象这种行为是让人无法忍受的,在一个理想的程序世界里,任何事物如果是指代相同的内容,那么就应该只保存一份(好吧,这是我自己的理想世界)。难道就真的没有一种方法,能够让我们不去复制对象来实现运行时绑定吗?答案是肯定的。
在《C++ 沉思录》中作者介绍了一个耳熟能详的名字——句柄。
在介绍句柄之前,我们先来看看代理类的一些问题,显而易见的问题是它需要复制对象,这需要内存的开销,同时,某些特殊的对象,我们也不一定能够非常合适的定义出一个优美的 copy 函数,即使是复制自身这种看起来毫无疑问的操作,在程序员的世界里也不那么优雅了。
可以想到的一个例子就是如果对象非常大,那么 copy 必然需要很大一份内存;
同时如果有一个地方需要保存该对象的所有地址,那么在对象进行复制的时候也必须把新地址添加进去,这其实对于操作这个对象的人来说,很有可能是未知的。举个例子,就好像公安局要保存每个公民的合法身份,所以每个公民都必须登记到公安局去,但是如果你站在一个家庭的角度来看,如果没有强制规定或者是法律约束,你很有可能是不知道这件事的, 所以当你生了小宝宝的时候,很有可能忘记了去公安局登记,那么这个小宝宝就不会被承认,这里,你通过 copy 出来的那个副本就是这个没有身份的小宝宝;
除此之外,还有种情况也许你也在写 copy 函数时不知所措,就是当你的类非常复杂时,你真心是不知道定义在类里的那些东西,到底该如何进行复制,或者说你也不知道复制了那些内容后会不会对别的地方产生影响。
总而言之,除了内存的问题外,copy 这个函数的实现,看起来不像我们在代理类里面写的那样容易。而且这里还有一个最为重要的原因,让我们不去使用这种代理类,因为它需要你去修改基类所有的派生子类,去给它们都添加一个 copy 函数,但这样通常是不被允许的,因为我们很有可能是在原有代码的基础上来增加这个新的需求,当然,这个条件略显苛刻,但是作为一名IT从业人员,我得说这是一种常态。所以我们得想办法改进。
如果不去复制对象,我们有什么现成的武器吗?有!让我们想想,在 C++ 中,除了指针,还有什么东西不需要复制内存,也能够指向相同的内容。想到了吗?对了,就是引用。
使用对象的引用,我们可以避免对对象的复制,让我们想一想,引用我们通常把它用在函数的参数里,这是为什么呢?因为在函数里我有可能需要对函数外的变量进行操作,同时我又不想复制那些变量,因为这有时会非常困难,传一个引用进去,事情一下子变的简单很多。
那么引用在这里行不行的通呢?很可惜,我们最好不要这么做,因为如果我们想使用引用来指代对象,我们在很多地方需要返回这个对象的引用,比如在函数的返回值里,可是如果把引用作为函数返回值,通常是一件危险的事情,因为我不知道我的函数会被谁调用,同时我也就不知道我的函数返回值会被拿来干什么,要命的是这个返回值居然还是个引用变量,那么意味着我把家门钥匙给了别人,甚至我连对方是谁都不知道,当然,你会说可以写成 const 类型呗,对,是可以,但是把引用作为返回值还有几个问题,比如无法返回局部变量或者临时变量的引用,同时,引用作为返回值,意味着这个函数可以被拿做运算符的左值进行调用,还有,如果返回了动态内存的引用,那么这块内存就无法释放了,等等一系列令人头疼的问题。
好吧,放弃引用吧,让我们还是回到指针上来,指针是另一个可以让我们不复制内存,而指向同一个对象的方法,当然,指针有指针的问题,比如在代理类中我已经提到过的,关于未初始化指针的复制,以及悬垂指针、重复删除指针所指对象等等问题,那么难道我们就不能找到一种方法去避免指针的安全性问题,同时能够尝到指针的甜美吗?毕竟,指针是我认为在 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;
}
这个类没什么高深莫测的东西,非常清晰明了。现在让我们来定义句柄,如果学习过我前一篇博文代理类的相关内容(http://rangercyh.blog.51cto.com/1444712/1291958),那么你很快便会明白,这里的句柄其实也是一个概念,我们需要一个概念类来管理 Point ,当然也包括它的子类,为此,我们需要保存一个 Point 的指针,就好像之前代理类保存了 Vehicle 的指针一样,同时这个句柄也需要像代理类同样的功能,所以我们还需要默认构造函数、复制构造函数、赋值操作符等等,理由我就不再赘述了,不太清楚的朋友可以去看我关于代理类那篇,那么我们的句柄看起来应该是这个样子:
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;
}
我们先不去管如何处理多个句柄绑定相同的对象的问题,单看这两个类,如果我需要操作句柄来控制 Point ,那么我似乎还需要一个函数,用来返回 Point 指针给调用者,但是这里涉及到一个安全问题,如果我并不希望调用者通过句柄去访问实实在在的 Point 对象呢?既然我们已经增加了一层,那么就不该把底层再交给调用者,所以我也许不会去定义一个函数来返回 Point 指针,相反,我会增加好几个函数,去供句柄的使用来调用,这些函数都把 Point 的细节封装其中,让调用者只关心自己的内容,而不需要接触到它不想要的内容,举个例子,比如有一个调用者需要设置 x 的值,那么我是否应该把整个 Point 指针交给他,然后让他来调用 Point 的 Point &x(int xv) 函数呢?当然不行,如果我把 Point 的指针交给他了,谁知道他会怎么样去操作 Point ,也许他会直接把 Point 这个对象给删除掉,那么我句柄里保存的指针 p 也就成了悬垂指针了。所以一般来说,我会在句柄里定义一些外部会使用到的方法,可能和 Point 提供的方法类型,但一般来说不会包含 Point 的全部方法,这里我就只添加一个设置 x 的值的方法吧,那么我们的句柄变成这样了:
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;
}
OK,这个句柄越来越像样子了,下面进入正题,我们需要有多个句柄指向同一个 Point 对象,而又不会产生代理类中复制代理类就会多产生一个 Point 副本的内存开销。要重复,又不要复制,看起来我们只有计数这一条路可以走了,也就是说我们记录一下指向 Point 对象的相同句柄的数量,如果这个数量为 0 了,我们就可以删掉这个 Point 的副本了,这样,我们的句柄就只会在第一次给 p 赋值时产生 Point 对象的唯一副本,之后无论怎么复制句柄,都不会再产生代理类那样的多余副本了。我们就把可能存在的成百上千的副本内存,压缩到只有一份。
在你兴奋过头之前,还有一个问题需要解决,就是这个引用计数应该放在什么地方。首先,它的值肯定不能放在句柄里,因为如果这样的话,当你改变这个引用计数的时候,就必须找到所有指向相同 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;
这句话就会导致 h 所指的对象被绑定到 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;
}
完美了。两种语义下的 handle 类我们都设计完成了,而且看起来一切美好。我们设计的这个句柄类能够在运行时绑定未知类型的 Point 类及其继承,同时能够自己处理内存分配的问题,而且我们避免了代理类每次复制都拷贝对象的操作。唯一令我们还不太满意的地方是对引用计数这个变量的操作穿插在了整个 handle 类的实现当中,而且耦合的非常紧密,我们最好能把引用计数给抽离出来。在《C++ 沉思录》中给出了一种抽离引用计数的方法,它定义了一个引用计数的类,里面保存我们上面定义的引用计数变量 int *p ;虽然我并不认为实现的比较完美,但还算是中规中矩,它是这么干的:
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;
}
然后我们可以对应修改我们的 handle 类:
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 函数。在句柄这篇文章中,我们学到一种使用引用计数的方式,来避免每次复制都需要拷贝对象的操作,同时不用去修改原始的对象,因此它更加灵活。我们还看到了指针语义和值语义的区别已经如何实现两种语义的方式,明白了”写时复制“的含义。在最后我们把引用计数抽离成为一个单独的类,这样这个引用计数就可以嵌入到各个不同的句柄设计中,而不需要每个句柄都自己来控制,抽象是循序渐进的,我非常喜欢这种一气呵成的感觉。
阅读(1233) | 评论(0) | 转发(0) |