RAII和垃圾收集(下)

上回说到,RAII 与现有的 GC 环境互不相容,也提到了问题的症结在于对析构函数的调用。这并非仅仅是一个令人遗憾的巧合,仔细想想不难发现,在这个矛盾背后,实际上是两者在“如何看待一个对象”这一问题上的分歧。

前面说过,RAII 的核心内容是把资源托管给对象,并保证资源在对象生命周期内始终有效。这样,我们实际上把管理资源的任务转化成了管理对象的任务,你拥有对象就等于拥有资源,对象存在则资源必定存在,反之亦然。RAII 的全部威力均源于此。这种做法的出发点,用 Bjarne Stroustrup 的话来说,是“use an object to represent a resource”(引自本文上篇开头所引用的代码中的注释)。换句话说,在 RAII 的眼中,对象代表了资源,它的行为和状态应该与资源的行为和状态完全一致,所以资源的申请和释放也就自然应该和对象的构造与析构对应起来。对于大多数资源,程序员有权而且需要控制它何时释放,那么很自然的,对于管理这些资源的对象,程序员也就应该有权而且需要控制它何时析构。一言以蔽之,这要求程序员有权控制对象的生命周期。

然而现有的 GC 环境并不满足上述条件。在本文的上篇中介绍过,对于 GC 来说,一个对象的生命周期有多长程序员是无权干涉的,任给对象 A,它是否存活必须严格地按照“是否存在一条从根出发的指针链能够到达 A”来判定。在 GC 的眼中,对象只是一段被分配的内存,而历史已经证明,决定内存何时释放这个任务决不能交给爱犯错的程序员,应该由专门的收集器完成,以确保每个内存回收动作都是安全的。什么?一个对象代表一份资源?哈!我 GC 大人的眼中只有内存,没有什么资源!

啧啧,两者眼中对象的角色差异如此之大,难怪会水火不容了。不过,这个矛盾真的没有调和的可能吗?倒也不见得。RAII 看重对象的语义,要让对象代表资源,因此需要程序员能够控制对象的生命周期,而 GC 眼中只有内存,反复强调只有让收集器来回收内存才能保证安全,这活不能让程序员来干。因此,要同时满足这两者的要求,对象的生命周期和必须和内存的释放脱钩。这不是什么新鲜想法。事实上,按照 C++ 标准的定义,一个对象的生命周期始于构造函数,终于析构函数,它本来就不是由内存分配释放来决定的,而 placement new 更是把这一点表现的极为充分,分配内存、构造对象、析构对象、释放内存四个操作清清楚楚、各自独立。至此,我们的解决方案已经呼之欲出了。一方面,我们要把释放内存的责任移交给 GC,同时又要允许程序员控制对象生命周期,那么,正确的做法就是 ……

在现有 GC 机制的基础上,允许程序员显式地析构对象,同时,若程序试图访问已经析构的对象,将抛出异常。

这初看起来非常荒谬(我已经可以看到台下飞来的无数鸡蛋和番茄 …… 哎哎?!你们真砸啊?),但其实并非如此。首先,内存的分配和释放由 GC 机制管理,凡是指针可到达的内存总是有效的,因此我们拥有同样的安全性保证 —— 没有悬挂引用和内存泄漏,所以,我们可以放心大胆地在模块之间共享对象,不必操心悬挂引用的问题,也不必担心内存管理的细节“弄脏”模块之间的接口;其次,由于允许显式析构对象,程序员能够控制对象的生命周期,因此我们可以继续应用 RAII “用对象代表资源”的语义并享受它带来的便利;最后,试图访问已经析构的对象将会抛出异常,堵上了无定义行为的口子。一句话,现在我们可以把对象放入支持 GC 的堆里而不必改变它的语义了。

瞧!两边不是合作得很好么?

必须承认的是,要实现这个方案,实际上是非常简单的,但是就我所知,目前并没有任何一个 GC 环境支持这个做法。这是因为大多数支持 GC 的语言并没有类似 C++ 中的析构函数这样的语言特征,换句话说,压根没有应用 RAII 的能力,而 C++ 本身又并不支持 GC。嗯嗯,面对这种情况,我决定效法先贤 —— 自己实现一个用于 C++ 的垃圾收集库,用智能指针来实现对指针访问的控制,并提供显式析构的接口。

最后,我来试着回答某些可能出现的质疑:

问:显式析构和显式调用 close 有什么区别?不一样要程序员自己动手吗?
答:有区别。请参阅本文上篇中,关于 RAII 相对于显式释放资源方案的优势。

问:你允许程序员析构对象,那么可能出现指向已析构对象的指针,这和悬挂指针不是一样么?
答:不一样。如果你试图访问已析构对象,只会抛出一个异常(当然,这得通过智能指针来实现),而访问悬挂指针 …… 不用我多说吧?

问:(续上)这样一来,通过指针访问对象可能抛出异常,这是原来没有的。
答:这种说法不准确。在现有的 GC 机制下,对象占有的资源需要调用(例如)close 成员函数显式释放,而在调用了 close 之后,再试图访问这个对象一样会抛出异常,指明使用这个对象所需的资源已经被释放。而且,对于现有的方案,每种对象释放资源的函数可能都不同,标识“资源已释放”的异常可能都不同,但如果使用显式析构的方案,释放资源的手段是一致的(析构对象),标识“资源无效”的异常也是一致的(“对象已析构”),这大大减轻了程序员的簿记负担。

问:(再续)但是你多一个检查啊?每个指针访问都需要检查对象是否已析构,效率太低。
答:这种说法也不准确。采用显式调用 close 释放资源的方案,在该对象内部,每个完成实质性工作(因此需要访问对象所需的资源)的成员函数同样必须检查资源是否有效,也是一个检查。而且,这样的检查程序员需要为每个类分别撰写,而采用上述显式析构的方案,只有智能指针需要做这个检查。

问:(再续)你上述两个问题的回答多少有点偏颇吧?并不是对象的每个成员函数都需要访问资源,你上述两个辩解对于不需要访问资源的成员函数是不成立的。而且,也并不是所有的对象都需要管理资源,如果我想在支持 GC 的堆里放一个 int 怎么办?这样,“可能会抛异常”和“多一个检查”无疑是两个很大的缺点了吧?
答:嗯嗯,这可能是最有力的质疑了,我的回答是这样的:我会另外提供一种不支持显式析构的智能指针,它的使用就像传统 GC 环境下的指针一样,也就没有“可能会抛异常”和“多一个检查”的问题。换句话说,如果你的对象不管理资源,或者你一定要在资源释放之后还能继续访问对象,用它你可以退回到传统的 GC 方案去。

问:我就是不喜欢你这套想法!有意见吗?
答: …… 没意见。如果你确实无法接受“显式析构”的想法,你也可以只使用上面提到的另一种智能指针 —— 嗯嗯,除了“显式析构”之外,我对于如何在 C++ 中具体实现垃圾收集机制也有很多其他的想法,除了“显式析构”之外,我的收集器应该还会有其他方面的优点。当然,这是其他文章的内容了。

好,大致就是这样,希望大家多提意见。如果哪位对实现垃圾收集器这个具体工作有兴趣,请来这边坐坐:
http://www.allaboutprogram.com/bb/viewtopic.php?t=1520


相关文章
对该文的评论
Elminster ( 2004-02-25)
好吧,我承认试图用自然语言来说清楚这个问题看来是很困难了。吸取教训,接下来我重新说明一下这个问题,并尽可能地使用代码和例子。首先,假设我们需要设计一个类 connection 管理某种网络链接,它需要打开和关闭,并且可以通过它进行读写。那么仿照 file_handle 的做法,它大概是这个样子:

class connection
{
public:
// 假设底层提供了 conn_open 和 conn_close 来负责打开、关闭链接
connection(const char* target) { __conn = conn_open(target); }
~connection() { conn_close(__conn); }
... ...
private:
// conn_t 是类似 FILE* 的东西
conn_t __conn;

// 禁止 copy
connection(const connection&);
const connection& operator=(const connection&);
};

现在一个 connection 对象和一个网络链接是严格对应的,connection 对象构造时打开网络链接,析构时关闭,connection 对象存在,链接就存在,反之亦然。接着,我给它加上 read 和 write,这很简单:

// 其他不变,并假设底层提供 conn_read 和 conn_write 来负责读写
class connection
{
... ...
char read() { return conn_read(); }
void write(char ch) { conn_write(ch); }
... ...
};

很简单吧。这个 connection 类的对象可以放在栈上,链接会自动关闭;也可以放在堆上,在程序员用 delete 析构这个对象时,链接关闭。那么接下来,我们引入 GC。假设程序中许多模块需要共享某些网络链接,由于这种共享的复杂性,我们希望最好能有 GC 的支持,把 connection 对象放进使用 GC 的堆里。问题出现了,我们可以直接把 connection 对象放进 GC 的堆里么?

答案是不行。我前面详细说过,终结机制不能起到析构函数的作用,因为从本质上说,什么时候调用终结机制无法确定(事实上,GC 里面没有终结机制最好,那是个 trouble-maker)。若这种链接是计时收费的,嘿嘿 …… 所以我们必须对 connection 对象做些改变,加上一个 close 成员函数,来关闭网络链接。像这样:

class connection
{
... ...
void close() { conn_close(__conn); }
};

这样可以吗?可惜不行。close 是个普通的成员函数,调用它对对象的生命周期没影响,调用 close 之后程序可能会继续使用那个对象,继续用它 read/write ,这个显然是有问题的,我们得对此进行检查。因此正确的做法是:

// 改个名字,以示区别
class connection_gc
{
public:
connection_gc(const char* target)
{
__conn = conn_open(target);
__closed = false;
}
// 析构函数不用了,其角色由 close 来代替
void close()
{
conn_close(__conn);
__closed = true;
}
char read()
{
if(!__closed)
return conn_read();
else
throw "Connection closed";
}
void write(char ch)
{
if(!__closed)
conn_write(ch);
else
throw "Connection closed";
}
... ...
private:
conn_t __conn;
bool __closed; // 标志链接是否已经关闭
... ...
};

这差别很明显。大家已经熟知的,是当 connection_gc 作为栈对象的时候,有不能忘记调用 close 的问题,然而这仅仅是问题的一面而已。从这个例子还可以看出,read/write 的实现变得更加复杂了,需要判断链接是否已经关闭,而且可能抛出异常,这会导致工作量的增大和效率的降低。另一方面,对于使用 connection2 对象的程序员来说,他必须记住要调用 close —— 同样的,这不仅仅是在栈上,举例而言:

// 这个使用原有的 RAII 版本的 connection 
class use_connection
{
private:
connection __c1, __c2;
public:
use_connection() : __c1("target1"), __c2("target2") { ... ... }
... ...
};

// 现在改用 GC 版本的 connection_gc
class use_connection_gc
{
private:
connection_gc __c1, __c2;
public:
use_connection_gc() : __c1("target1"), __c2("target2") { ... ... }
... ...
void close()
{
__c1.close();
__c2.close();
// 当然也可以放到 use_connection_gc 的析构函数里 —— 但那样
// use_connection_gc 自己又不能放进支持 GC 的堆了。
... ...
}
... ...
};

瞧,记得调用 close 的任务不仅仅是栈对象吧?除此之外,程序员还要记得 read/write 可能抛出标志“链接已关闭”的异常,并正确处理它们。而且这些任务是会“传染”的:上面给出的 use_connection_gc ,若是使用了 connection_gc::read/write,同样也需要担心这个异常的问题。完了吗?没有,还有最后一个问题:为了处理这些 close、处理这些标志、处理这些异常所做的工作是无法重用的,为 connection 你要做这些工作,为 file 你要做这些工作,为其他管理资源的类你也要做这些工作。

先生们,这些是不是负担?然后 try ... finally 和 using/auto 之类的机制又能帮我们解决多少?

当然,有人会争辩说这些工作量并不大,而 GC 带来的便利远远大于这些。没错,我不反对这个,但若是我们能够把工作量再减小一点,何乐而不为呢?然后我的方案就是内存让 GC 管,程序员负责控制对象的构造和析构。多说无益,还是举例子,假设我已经实现了我的 GC 收集器,并且要把 connection 对象放进支持 GC 的堆里,那么简单的用法类似这样:

void some_func()
{
// gc_heap 代表支持 GC 的堆,gc_ptr 是种智能指针,GC 堆里的
// 对象必须通过它访问

// 在堆里构造一个对象,暂时叫它 target 好了,返回一个指向它的 gc_ptr
gc_ptr< connection > p1 = gc_heap.construct< connection >("target");

// 缺省构造的 gc_ptr 视作 NULL
gc_ptr< connection > p2, p3, p4;

p1->write('c'); // OK
p2 = p3 = p1; // OK
p2->write('d'); // 写入 target
char ch = p3->read(); // 从 target 读
p1->write(ch); // 继续写入 target

try{ ch = p4->read(); } // p4 是 NULL,抛出异常 no_object_here
catch(...){}

gc_heap.destruct(p3); // 将 target 析构(可以通过 p1/p2/p3 中任意一个),但
// 是那个对象占据的内存并不在此刻释放

try{ p1->write('a'); } // p1 所指对象不存在,抛出异常 no_object_here
catch(...){}

// 除此之外,某些对象不像 connection ,它不需要释放资源,
}
// 函数执行完毕之后,不再有指向 target 占据内存的引用,在合适的时刻由 GC 来回收内存。

OK,这样做的好处是什么?

首先,有了 GC,我不用担心悬挂引用和内存泄漏,可以放心大胆的共享对象,不存在无定义行为和内存访问错误。其次,connection 可以保持 RAII 的语义,不用加什么 close ,connection 内部不用检查链接是否已经关闭,可以简化实现提高效率。这样,程序员不用担心 connection::read/write 会抛出“connection_closed”异常。此外,把 connection 当作栈对象或是其他类的成员时,程序员也不需要再记得去调用它的 close 函数了。最后,try ... finally 之类的东西可以不要了,终结机制也可以不要了(不知道大家是否清楚这一条对于 GC 实现者的价值)。

接着,照例是 FAQ:

问:现在 gc_ptr 会抛异常了,这和让 connection::read/write 抛异常有什么区别?
答:不一样。之前,每个需要管理资源的类可能抛出各自不同异常,程序员必须把它们都记住,而且在使用新的类时还必须重新学习;而现在,程序员只要记住一个异常,“no_object_here”,而且是一劳永逸。

问:对于这样的例子:

void f()
{
gc_ptr< connection > p = gc_heap.construct< connection >("target");
... ...
gc_heap.destruct(p);
}

不是一样要保证 construct 和 destruct 的配对吗?没有 try ... finally 之类的东西,你怎么办?
答:很简单,仿照 auto_ptr 我提供一个 auto_gc_ptr 好了 —— 而且要求低的多,我不需要支持 copy。

问:对象没有资源怎么办?不需要析构怎么办?
答:那这个对象实际上就是“一片内存”,对此,我提供另一种智能指针 gc_mem_ptr ,它不支持 destruct,但内存一样由 GC 回收。

………………

差不多就是这样了,剩下的问题可以很容易地从原来的 FAQ 里“翻译”过来。我希望,这一次,我把我的想法表达清楚了。
jarjarbink ( 2004-02-25)
to: Wolf0403
呵呵,感谢你的提醒。C# Language Spec v1.2上说明了相关内容(那个里面说的很明白,你也可以去看一下),我在这里简要解释一下。

C#里面的struct与其说是对象,还不如说是数值类型,虽然struct可以具有成员方法,但是struct根本没有对象的特征——没有继承,当然也没有多态,最多说是有点封装的味道。C# Spec里面说的很明白,struct不能实现继承(Struct types do not support user-specified inheritance),而且struct完全是通过值复制来进行赋值的,也就是说struct的行为完全和int/byte之类的数值类型完全相同。

C#之所以要引入struct,并不是为了实现所谓的栈对象和什么RAII,而仅仅是为了结构数据的存储而已,因此C#的struct类型也就和Pascal里面的record一样,仅仅是数值类型而已。而并不能看成是“栈对象”。

而Java就更没有“栈对象”这种说法了。
ThinkX ( 2004-02-25)
仔细想想,确实有些画蛇添足的味道,
比如一个函数内的local变量,既然用RAII自动调用了析构函数,为什么不进一步,收回内存资源呢?
如果一个函数返回一个堆上变量的指针,而且这个变量还存在类似dispose()的函数,那么在dispose时,为什么不一起将内存也收回呢?
GC比较大的优势就是清除没有dispose语义的纯内存资源,而在这个时候RAII却没有用武之地。
RAII的好处还是模拟了finally或者C#中using的作用,而在这个时候只调用析构函数,而不释放内存好像没有什么好处。
Wolf0403 ( 2004-02-24)
首先: jarjarbink:
虽然我对于 C# 不是那么了解,但是我还是直到 .net 中有 value-type 一说。C# 中的 struct 定制的类型就是 value-type,在栈上构建。至于它是否会被自动析构,我承认我不了解。但是你说 Java C# 没有栈对象的说法显然是站不住脚的。
其次:
我猜想 Elminster 的解决方案是:继续利用 C++ 现有的 RAII 的方法进行资源的回收,唯独对内存资源不进行手工释放而交由 GC 处理。也就是说,让 delete 只调用析构函数而不调用 operator delete(void *) 释放内存。这样确实应该是一个不错的办法。。。除了显得有点画蛇添足。。。
jarjarbink ( 2004-02-23)
或许是我才疏学浅,这里说说拙见。

我觉得RAII和GC说的根本不是一回事,RAII和GC基本上没有什么关系。C++中所谓的RAII,只是很“取巧”的利用C++的“栈对象”的特性,编译器可以自动析构且调用析构函数,可以把资源管理交给由编译器静态分配的“栈对象”(注意:这里是栈对象!)来做。然而GC需要管理的内存(或者资源)并非是“栈对象”,而是程序动态分配的“堆对象”。RAII是根本不可能做在“堆对象”里面的。

也就是说,RAII仅仅能够用“栈对象”实现,“堆对象”根本不可能用于实现RAII(除非你自己调用delete来显式调用析构函数,但是这样RAII根本没有意义);另一方面,而GC仅仅用于管理和解决“堆对象”的问题,“栈对象”早就由编译器管理得非常好,根本用不着GC。

由于C++既有栈对象,也有堆对象,所以才出现了RAII的所谓“巧妙”方法,来隐藏资源分配/收回的细节。实际上这种既有栈对象也有堆对象的设计会导致混乱(当引用栈对象的对象指针离开栈对象作用域时,由于对象被编译器自动析构,导致指针隐式自动无效,这是相当危险的!),所以C++之后的OO语言(包括Java/Object Pascal(Delphi)/Java/C#等等)全部取消了栈对象,所有对象只能是动态分配的堆对象(指针或者引用),这样GC可以不加区别的管理所有的对象。也正因为这样,这些没有“栈对象”概念的语言中,RAII根本没有讨论的可能性和必要性。

C#的using也仅仅是为了简化程序编写而设置的语言设施,也就是相当于C/C++里的一个预处理宏,因为using约定了当using的对象超出using域时,该对象的哪一个方法被调用。说到底,RAII也就是和这个相似的约定,约定对象超出作用域就调用析构函数一样。

总的来说,所谓RAII,也就是“栈对象”的副产品而已。而“栈对象”早已被现代语言取消了。所以RAII也没有什么讨论的必要。如果希望能在现有的含有GC的语言中使用类似RAII的功能的话,可以通过定义新的语言设施(如C#的using)来实现,因为RAII的用途就是为了简化代码编写而已。

再插几句,在C++中,在一个类的身上同时实现GC和RAII总觉得很别扭,那么一个类就需要提供两个dtor,一种是释放资源的disposer,还有一个是释放内存的destructor,分别给编译器和GC调用(这倒有点类似C#里的东西)。但是实际上如果这个类被用于RAII,一个栈对象出了作用域就被干掉了,那还需要把它登记到GC做什么呢?

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值