C# vs C++之二:GC vs RAII

资源管理

在C语言中,资源管理是一个极为繁琐易错的工作,大多复杂的C系统都面临着内存泄露、悬挂指针等问题。这是一方面是由底层语言的特点决定;另一方面也是由于C语言特性相对较少,严重依赖程序员进行正确的资源管理,缺乏有效的支持手段。

C#和C++两门语言的定位不同,它们在资源管理方面采取了两种截然不同的方式:一为GC,一为RAII。GC让程序建立在更高的抽象层次上,使资源管理变得更方便,更安全;而RAII则保留了C的底层能力,同时在C++特性的支持下提供了简单有效的资源管理方式。我们知道C++最激烈的批评往往来自于C社区,而在我看来C程序员可以不接受虚函数,不接受模板,但有什么理由不接受RAII呢?可以说RAII是C++相对C来说几乎无副作用的明显进步。

下面就从GC开始:

引用代替指针

C#通过CLR管理托管内存,用引用抽象代替指针间接操作托管内存,让程序员在更高的层次上安全地使用资源。这使得C#失去了直接管理内存的能力,但换来了以下好处:

1.类型安全:在C/C++中可以通过类型转换把整数或其他类型的指针转换为特定类型的指针,这意味着指针是非类型安全的,必须由程序员来保证指针代表的内存空间的合法性。而C#引用可以看作是类型安全的指针,as运算符可以保证转换的类型安全性。

2.内存整理:创建对象需要从堆中动态分配连续的内存空间,由于不同对象的内存大小是不同的,常见的首次匹配和最优匹配堆分配算法都会造成堆中的内存碎片问题。碎片的存在使实际可用内存小于物理内存,所以应尽量减少碎片的产生。一个方向是设计更好的内存分配算法;另一个方向是通过周期性地进行内存整理调整优化。在C/C++中,由于指针代表了绝对地址,因此不存在通用的内存整理算法;而C#屏蔽了指针,通过引用操作对象,就使得内存整理成为可能。PS:这并不意味着C/C++内存分配就弱于C#,C/C++程序可以为某种类型的对象设计专用的内存分配方式,甚至把对象指定分配到某一物理地址空间,这些都是C#不具备的。

托管和非托管资源

在C#中,资源分为托管资源和非托管资源两种。GC在回收无用对象资源时,可以自动回收托管资源(比如托管内存),但对于非托管资源(比如Socket、文件、数据库连接)必须在程序中显式释放。

托管资源的回收首先需要GC识别无用对象,然后回收其资源。一般无用对象是指通过当前的系统根对象和调用堆栈对象不可达的对象。对象有一个重要的特点导致无用对象判断的复杂性:对象间的循环引用!如果没有循环引用,就可以通过“引用计数”这种简单高效的方式实现无用对象的判断,并实现实时回收。正是由于循环引用的存在导致GC需要设计更为复杂的算法,这样带来的最大问题在于丧失了资源回收的实时性,而变成一种不确定的方式。

对于非托管资源的释放,C#提供了两种方式:

1.Finalizer:写法貌似C++的析构函数,本质上却相差甚远。Finalizer是对象被GC回收之前调用的终结器,初衷是在这里释放非托管资源,但由于GC运行时机的不确定性,通常会导致非托管资源释放不及时。另外,Finalizer可能还会有意想不到的副作用,比如:被回收的对象已经没有被其他可用对象所引用,但Finalizer内部却把它重新变成可用,这就破坏了GC垃圾收集过程的原子性,增大了GC开销。

2.Dispose Pattern:C#提供using关键字支持Dispose Pattern进行资源释放。这样能通过确定的方式释放非托管资源,而且using结构提供了异常安全性。所以,一般建议采用Dispose Pattern,并在Finalizer中辅以检查,如果忘记显式Dispose对象则在Finalizer中释放资源。

可以说,GC为程序带来安全方便的同时也付出了不小的代价:一则丧失了托管资源回收的实时性,这在实时系统和资源受限系统中是致命的;二则没有把托管资源和非托管资源的管理统一起来,造成概念割裂。C++的定位之一是底层开发能力,所以不难理解GC并没有成为C++的语言特性。虽然我们在C++0x和各种第三方库都能看到GC的身影,但GC对于C++来讲并不是那么重要,至多是一个有益的补充。C++足以傲视C,并和C# GC一较高下的是它的RAII。

栈语义

在介绍RAII之前,让我们先来看一道C++面试题:“重构下面的代码,在保证正确释放资源的情况下,去掉多余的try catch”

//C++

void f(){

    try{

        int *ptr = new int(123);

        …//do something with ptr

        delete ptr;

    }

    catch {

        delete ptr;

    }

}

代码中new int在堆上分配内存,并通过delete小心翼翼地释放内存。这是典型的C风格的C++代码,虽然用了try、catch等高级语法,但资源管理方式依旧是C。按C++特有的方式可以重构成这样:

//C++

//定义资源代理类模板

template<typename T>

class Resource{

public:

    Resource(T *ptr) { this->ptr = ptr;} //构造函数中初始化资源

    ~Resource() { delete ptr; } //析构函数中释放资源

    T& operator*() { return *ptr; } //重载*运算符

    T* operator->() { return ptr; } //重载->运算符

    //…省略了拷贝构造函数和赋值运算符等

private:

   T* ptr;

};

void f(){

    Resource<int> r(new int(123));

    //do something with r

}

f函数中,我们在栈上创建了一个资源模板类Resource的对象r,并通过r来提供服务。只是这么简单的一包装,就省掉了繁琐易错的try,catch,不管f内部出什么问题,抛什么异常,都能保证r所管理的内存资源最终被正确释放。C++保证一旦离开词法作用域,在任何情况下都会调用栈上对象的析构函数,这就是所谓的“栈语义”(stack semantics)。事实上,STL已经有auto_ptr这个智能指针类模板,其实现和上面的Resource类模板类似。

RAII

RAII是resource acquisition is initialization的缩写,意为“资源获取即初始化”。它是C++之父Bjarne Stroustrup提出的设计理念,其核心是把资源和对象的生命周期绑定,对象创建获取资源,对象销毁释放资源。在RAII的指导下,C++把底层的资源管理问题提升到了对象生命周期管理的更高层次。上面的例子,我们把new所获取的内存块视为资源,把r对象视为资源的代理对象,r应负责资源的获取和释放。在栈语义和操作符重载的支持下,C++的RAII体现出了简洁、安全、实时的特点:

1.概念简洁性:让资源(包括内存和非内存资源)和对象的生命周期绑定,资源类的设计者只需用在类定义内部处理资源问题,提高了程序的可维护性

2.类型安全性:通过资源代理对象包装资源(指针变量),并利用运算符重载提供指针运算方便使用,但对外暴露类型安全的接口

3.异常安全性:栈语义保证对象析构函数的调用,提高了程序的健壮性

4.释放实时性:和GC相比,RAII达到了和手动释放资源一样的实时性,因此可以承担底层开发的重任

也许你还在惊讶RAII如此简单的时候,关于RAII的主要内容已经介绍完了。简单不意味着简陋,在我看来RAII虽然不像GC一样,是一套具体的机制,但它蕴含的对象与资源关系的哲学深度的理解却使得我对Bjarne Stroustrup肃然起敬!

最后,不得不提醒RAII的理念固然简单,不过在具体实现的时候仍有需要小心的地方。比如对于STL的auto_ptr,可以视为资源的代理对象,auto_ptr对象间的赋值是一个需要特别注意的地方。简单说来资源代理对象间赋值的语义不满足“赋值相等”,其语义是资源管理权的转移。

什么是“赋值相等”呢?比如:

int a;

int b = 10;

a = b; //这句话执行后 a == b

但对于资源代理对象,这是不满足的,比如:

auto_ptr<int> a(null);

auto_ptr<int> b(new int(123));

a = b; //这句话执行后a != b,赋值的语义是b把资源的管理权交给了a

总结

对比介绍了C#和C++的资源管理方式:GC和RAII。

你可能感兴趣的:(C++)