资源清理

来源:互联网 发布:结婚证图片制作软件 编辑:程序博客网 时间:2024/06/11 01:10

资源清理

 

Stanley B. Lippman

 

下载本文的代码:NettingC++2006_08.exe (166KB)

在我的上次专栏文章中,我们成功地包装了处理自然语言文本的本机文本查询语言 (TQL) 应用程序。更确切地说,该应用程序能够正确执行,但是我实际上犯了几个小的程序设计错误,在本次专栏文章中,我将更正这些错误。如果您不记得这段代码, 1 显示了我们最后的代码。

#include "TextQuery.h"

 

ref class TQL {

public:

    // constructor creates a TextQuery object on native heap

    // destructor frees it ...

    TQL()  { pTQuery = new TextQuery; }

    ~TQL() { delete pTQuery;          }

 

    // the operations we wish to publish within .NET

    // these will generally be inlined by the compiler

 

    void build_up_text(){ pTQuery->build_up_text(); }

    void query_text()   { pTQuery->query_text();    }

 

private:

      // our native object through which to invoke

      // our application compiled in Section 1 ...

    TextQuery *pTQuery;

};

 

// Our Managed TQL Wrapper Put to Work ...

int main()

{

    TQL ^tq = gcnew TQL;

 

    tq->build_up_text();

    tq->query_text();

 

      return 0;

}

 

 

第一个问题很微不足道,但如果将类变成通用类的话,这个问题会产生很大的麻烦,因为模块内的所有类型都将默认为内部作用域。也就是说,它们在模块外不可见。这是一种好的默认方式,因为它可以防止应用程序的全局命名空间被仅用于模块的类型所混淆。您必须将想要在多个模块中可见的类显式声明为公用:

public ref class TQL {

public:

    // 无变化 ...

};

这也适用于您在 C++/CLI 下编译的本机类,它是本机类型的 C++ 可见性的对立面。因此如果需要使您的本机类在多个模块之间公用,您还需要将该类标记为 public。这是对 Visual C++® 2005 中的 ISO-C++ 的一个扩展。

第二个问题涉及从公共语言运行库 (CLR) 堆中清理除内存以外的资源,该堆是用于收集垃圾的堆。垃圾收集堆对析构函数有详细的规则,我们将从这里开始。在 Microsoft® .NET Framework 下,没有析构函数的概念,因此我们人为地将析构映射到 .NET,我会在稍后对此进行解释。

首先需要注意的是,析构是一种两阶段的操作。例如,当删除一个指针时,编译器先在内部检查该指针是否为空。如果为空,不再进行其他操作。否则,编译器会调用与所寻址的对象相关联的析构函数。如果完成过程中没有出现异常,则会释放实际堆内存。对于 CLR 类型,这个两阶段的操作由两个不同的组件来执行。析构函数的调用由编译器执行。内存的实际释放始终由垃圾收集器处理。在 .NET 中调用析构函数时,与引用对象相关联的实际内存并不会被收回,只有在垃圾收集器介入时才会被收回。

因此,我在初始实现中存在的问题是,虽然每个 TQL 对象最终会被垃圾收集器收回(析构的第二个阶段),但它并不会调用相关联的析构函数(析构的第一个阶段,也是我认为更重要的阶段)。在垃圾回收器收回与对象相关联的内存之前,若存在相关联的 Finalize 方法,则会调用该方法。您可以把该方法看作是一种超级析构函数,因为它不依赖于该对象的程序生存期。这称为定案。调用 Finalize 方法的时间和是否调用保持未定义,这意味着垃圾收集具有不确定性的定案。

不确定性的定案适用于动态内存管理。当可用内存严重不足时,垃圾收集器即会介入并维持工作能够进行。在垃圾收集的环境下,无需用析构函数释放内存。

然而,当对象使用某些关键资源(例如数据库连接、本机堆内存或某种类型的锁)时,不确定性定案不能顺利执行。在这种情况下,需要尽快释放该资源。在本机环境下,这是通过构造函数和析构函数配对来完成的。不管是因为声明对象的本机代码块执行结束还是因为引发异常而导致堆栈分解,对象的生存期一旦结束,析构函数即会介入并自动释放资源。这种方法很有效,只可惜在 CLR 环境下无法使用。

我们很快明白了这样一个事实,即使用 .NET 的程序员需要一种规范的方法来指示某种类型的对象占用的资源可能不足,需要尽快加以处理,其设计解决方案就是具有包含清理代码的单一 Dispose 方法的 System::IDisposable 接口。此解决方案的主要缺点是 Dispose 需要由用户进行显式调用。这易于出现错误,因而是种倒退。C# 语言通过一个特别的 using 语句提供了一种适度形式的自动化,如果使用正确的话,可以在语句中调用对象的 Dispose

Visual Studio® 2005 开始,Visual C++ 改为将类析构函数转换成类的 Dispose 方法。析构函数在内部重命名为 Dispose 方法,并且引用类自动扩展以实现 IDispose 接口:

// C++/CLI 下析构函数的内部转换

public ref class TQL : IDisposable

{

...

void Dispose() {

    // 抑制此对象的 finalize 方法

    // 然后生成用户代码 ...

    System::GC::SuppressFinalize(this);

    delete pTQuery;

}

};

当在 C++/CLI 下显式调用析构函数或对跟踪句柄应用 delete 方法时,会自动调用底层的 Dispose 方法。如果它是一个派生类,则会在合成的方法末尾插入对基类的 Dispose 方法的调用。

尽管这种转换很实用,但其本身却并不尽如人意。首先,引用对象没有作用域约束,因此如果程序员不显式删除该引用对象,将不会调用析构函数。其次,由于析构函数现在转到 Dispose 而不是 Finalize,因此垃圾收集器没有任何方法进行调用。因此,乍看起来,此设计变更似乎是一个错误!

这当然是我在实现中犯的一个错误。作为一个本机程序员,我没有意识到垃圾收集的本质决定了 .NET 下的析构函数不是管理对象的完全解决方案。因此,我没有考虑为垃圾收集器显式提供一个要调用的定案函数。仅对于一个对象,可能没有人会注意到这一点。但在一个可为每个查询会话衍生出一个新 TQL 对象的运行系统,这将是一个相当严重的问题。为了大家的方便,我重新给出了 TQL 对象的声明:

// tq 对象永远也不会调用析构函数 ...

// 而且我们没有提供 finalize 方法 ...

// 因此 tq 占用的本机内存从未释放 ...

int main()

{

    TQL ^tq = gcnew TQL;

 

    tq->build_up_text();

    tq->query_text();

 

    return 0;

}

您需要提供一个终结器,稍后我会说明如何提供。首先让我们演示一下 Visual C++ 中的 CLR 功能是如何模拟确定性定案的:在语法上将引用对象绑定到本地或类作用域;每个对象都拥有一个确定性生存期。棘手的问题是 .NET 本身不支持这种功能,因此我们必须动动脑筋。

Visual C++ 支持通过使用类型名称而无需形式上的顶帽 (^) 在本地堆栈上或作为类的成员来声明引用类的对象。对该对象的任何使用( 比如调用成员函数)都是通过成员选择点 (.) 而不是箭头 (->) 完成的。在代码块的末尾,会自动调用相关联的析构函数(已转换成 Dispose):

// 好的,这样可以调用我们的析构函数 ...

int main()

{

    TQL tq;

 

    tq.build_up_text();

    tq.query_text();

 

    //此处调用析构函数 ...

 

    return 0;

}

更倾向于使用符合语法的库的人可以使用功能等效的自动处理 <> 模板。(我更愿意在设计中不使用顶帽。)对于 C# 中的 using 语句,这是语法上的美化,而不是违反底层的 .NET 约束,即所有的引用类型必须在 CLR 堆上分配。除了自动调用析构函数外,底层的语义保持不变。实际上,析构函数会重新与构造函数配对,作为一种绑定到对象生存期的自动获得/释放机制。

此解决方案的问题是不能强制程序员使用它,因此,如果不同时提供终结器,则无法安全地提供析构函数。下面是实现的方法:

public ref class TQL {

public:

    // 构造函数在本机堆上创建一个 TextQuery 对象

    TQL()  { pTQuery = new TextQuery; }

 

    // 析构函数将其释放

    ~TQL() { delete pTQuery;          }

   

    // 终结器将其释放,如果未调用析构函数,

    // 则会由垃圾收集器进行调用 ...

    !TQL() { delete pTQuery;          }

};

! 前缀表示引入类析构函数的同功颚化符 (~),也就是说,两种生存期后方法在类名称前都有一个符号前缀。如果派生类中有一个合成的 Finalize 方法,则会在其末尾插入对基类的 Finalize 方法的调用。 如果显式调用该析构函数,则会抑制终结器。

对于此类语言设计,总是会有遗留的问题。一般而言,终结器不会有很高的效率(有关此问题的主要论述,请参阅书籍 CLR via C#, Second Edition,作者 Jeffrey Richter)。尽可能不要定义终结器。但目前仍无法将包含析构函数的类限制为总是定义其对象,以确保获得确定性的定案。我不能强制类用户总是声明本地引用对象:

void f()

{

    TQL t;     //  好的,确保被处理

    TQL ^ht;   //  哎呀!无法保证。需要一个终结器,然后 ...

    ...

}

 

一种可能是使用终结器的可访问性来指示是否允许用户调用该终结器。也就是说,将终结器放在私有部分中表示您不允许使用绑定作用域外的对象。然而,在当前的扩展设计中,所有终结器都是公共的,与用户指定的可访问性无关。

因此,作为引用类的故障保护辅助定义,终结器看起来总是必要的,因为从规范角度来说,您通常需要将复制构造函数与复制赋值运算符配对,或将运算符 new 与运算符 delete 配对。

另一个微不足道的遗留问题是,析构函数和终结器的代码往往相同,因此,我们中的有些人往往会因为不得不复制该代码或考虑如何实现规范设计而苦恼。加州理工学院的编程语言教授 Michael Vanier 建议用 ~! 语法来指示编译器应使用此代码同时支持析构函数和终止化。我很喜欢这个想法,也许 ECMA C++/CLI 委员会将会在将来对此进行修订!

这样看来,在一小段代码中就有这么多的错误。为什么?我认为这是因为这些问题(类型的可见性、不确定性定案)在本机编程中并不存在,因此,意味着我们在考虑 .NET 时有漏洞。下一次,我们将研究正则表达式,以寻求将冗长的 C++/CLI 转换为精练的 Perl 的途径。只有到那时,您的代码才会在编译中不出现错误,并能够顺畅正确地执行完成。

请将您要提交给 Stanley 问题和意见发送至 purecpp@microsoft.com

Stanley B. Lippman 1984 年开始在 Bell 实验室与 C++ 的发明者 Bjarne Stroustrup 一起使用 C++。之后,Stan Disney DreamWorks 从事功能动画方面的工作,并担任 Fantasia 2000 的软件技术主管。此后他一直担任 JPL 的名誉顾问,并在 Microsoft 担任 Visual C++ 团队的结构设计师。感谢 Jim Hogg Michael Vanier 对本专栏的帮助。

本文摘自 2006 8 出版的MSDN Magazine