C++/CLI中重复的析构函数调用和跟踪句柄

Repeated destructor calls and tracking handles in C++/CLI

本文关键字:函数调用 析构 跟踪 句柄 CLI C++      更新时间:2023-10-16

我正在使用MSDN文档和ECMA标准以及Visual C++Express 2010来玩C++/CLI。让我印象深刻的是以下对C++的背离:

对于ref类,必须编写终结器和析构函数,以便它们可以在尚未完全构造的对象上多次执行。

我炮制了一个小例子:

#include <iostream>
ref struct Foo
{
    Foo()  { std::wcout << L"Foo()n"; }
    ~Foo() { std::wcout << L"~Foo()n"; this->!Foo(); }
    !Foo() { std::wcout << L"!Foo()n"; }
};
int main()
{
    Foo ^ r;
    {
        Foo x;
        r = %x;
    }              // #1
    delete r;      // #2
}

#1的块末尾,自动变量x失效,并调用析构函数(这反过来显式地调用终结器,这是通常的习惯用法)。一切都很好。但随后我通过引用r再次删除了该对象!输出是这样的:

Foo()
~Foo()
!Foo()
~Foo()
!Foo()

问题:

  1. #2线上调用delete r是未定义的行为,还是完全可以接受?

  2. 如果我们去掉#2行,那么r仍然是一个(在C++意义上)不再存在的对象的跟踪句柄有关系吗?这是一个"悬空把手"吗?它的引用计数是否意味着将有人试图双重删除?

    我知道没有实际的双重删除,因为输出变成这样:

    Foo()
    ~Foo()
    !Foo()
    

    然而,我不确定这是一个愉快的事故,还是一定是明确的行为。

  3. 在其他哪些情况下,托管对象的析构函数可以被多次调用?

  4. r = %x;之前或之后插入x.~Foo();可以吗?

换句话说,托管对象是否"永远存在",并且可以一次又一次地调用它们的析构函数和终结器?


为了响应@Hans对一个非平凡类的需求,您也可以考虑这个版本(使析构函数和终结器符合多调用要求):

ref struct Foo
{
    Foo()
    : p(new int[10])
    , a(gcnew cli::array<int>(10))
    {
        std::wcout << L"Foo()n";
    }
    ~Foo()
    {
        delete a;
        a = nullptr;
        std::wcout << L"~Foo()n";
        this->!Foo();
    }
    !Foo()
    {
        delete [] p;
        p = nullptr;
        std::wcout << L"!Foo()n";
    }
private:
    int             * p;
    cli::array<int> ^ a;
};

我将尝试按顺序解决您提出的问题:

对于ref类,必须编写终结器和析构函数,以便它们可以在尚未完全构造的对象上多次执行。

析构函数~Foo()简单地自动生成两个方法,一个是IDisposable::Dispose()方法的实现,另一个是实现可丢弃模式的受保护的Foo::Dispuse(bool)方法。这些都是简单的方法,因此可以多次调用。在C++/CLI中,允许直接调用终结器this->!Foo(),而且通常是这样做的,就像您所做的那样。垃圾收集器只调用终结器一次,它在内部跟踪是否调用了终结器。考虑到允许直接调用终结器,并且允许多次调用Dispose(),因此可以多次运行终结器代码。这是特定于C++/CLI的,其他托管语言不允许这样做。你可以很容易地阻止它,nullptr检查通常可以完成任务。

在第2行调用delete r是未定义的行为,还是完全可以接受?

这不是UB,完全可以接受。delete运算符只是简单地调用IDisposable::Dispose()方法,从而运行析构函数。在它内部所做的事情,通常是调用非托管类的析构函数,很可能会调用UB。

如果我们去掉第2行,r仍然是跟踪句柄有关系吗

没有。调用析构函数是完全可选的,没有很好的方法来强制执行它。没有任何问题,终结器最终将始终运行。在给定的示例中,CLR在关闭前最后一次运行终结器线程时会发生这种情况。唯一的副作用是程序运行"繁重",占用资源的时间超过了必要的时间。

在其他哪些情况下,托管对象的析构函数可以被调用多次?

这很常见,一个过于热心的C#程序员可能会多次调用Dispose()方法。同时提供Close和Dispose方法的类在框架中非常常见。在一些模式中,这几乎是不可避免的,比如另一个类承担对象的所有权。标准示例是C#代码的这一位:

using (var fs = new FileStream(...))
using (var sw = new StreamWriter(fs)) {
    // Write file...
}

StreamWriter对象将获得其基流的所有权,并在最后一个大括号处调用其Dispose()方法。FileStream对象上的using语句第二次调用Dispose()。编写这段代码以避免这种情况发生并提供异常保证太困难了。指定Dispose()可以被多次调用就解决了这个问题。

插入x.~Foo()可以吗;在r=%x;之前或之后;?

没关系。结果不太可能是令人愉快的,NullReferenceException将是最有可能的结果。这是您应该测试的东西,引发ObjectDisposedException,为程序员提供更好的诊断。所有标准的.NET框架类都是这样做的。

换句话说,托管对象是否"永生"

不,垃圾收集器会声明对象已死亡,并在找不到对该对象的任何引用时进行收集。这是一种故障安全的内存管理方式,不可能意外引用已删除的对象。因为这样做需要一个引用,GC将始终看到一个引用。像循环引用这样的常见内存管理问题也不是问题。

代码段

删除a对象是不必要的,并且没有任何效果。您只删除实现IDisposable的对象,而数组则不这样做。常见的规则是,.NET类只有在管理内存以外的资源时才实现IDispasable。或者,如果它有一个本身实现IDisposable的类类型的字段。

在这种情况下,是否应该实现析构函数还值得怀疑。您的示例类正在保留一个相当适中的非托管资源。通过实现析构函数,你会给客户端代码增加使用它的负担。这在很大程度上取决于类的使用情况,客户端程序员这样做有多容易,如果对象的寿命超过了方法的主体,那么肯定不是这样,所以using语句就不可用了。您可以让垃圾收集器知道它无法跟踪的内存消耗,调用GC::AddMemoryPress()。它还处理了客户端程序员因为太难而不使用Dispose()的情况。

标准C++的指导原则仍然适用:

  1. 对自动变量或已经清理过的变量调用delete仍然是个坏主意。

  2. 它是指向已处理对象的跟踪指针。取消引用是个坏主意。使用垃圾回收,只要存在任何非弱引用,内存就会一直保留,因此您不会意外访问错误的对象,但您仍然无法以任何有用的方式使用这个已处理的对象,因为它的不变量可能不再有效。

  3. 只有当您的代码以标准C++中的UB风格编写时,托管对象才会发生多次破坏(请参阅上面的1和下面的4)。

  4. 对自动变量显式调用析构函数,然后不在其位置创建新的析构函数以供自动销毁调用查找,这仍然是个坏主意。

一般来说,您认为对象的生存期与内存分配是分开的(就像标准C++一样)。垃圾回收用于管理释放——所以内存仍然存在——但对象已经死了。与标准C++不同,您不能将该内存重新用于原始字节存储,因为.NET运行时的某些部分可能会认为元数据仍然有效。

垃圾收集器和"堆栈语义"(自动变量语法)都不使用引用计数。

(丑陋的细节:处理一个对象不会破坏.NET运行时自身与该对象相关的不变量,因此您甚至可能仍然可以将其用作线程监视器。但这只会使设计变得丑陋而难以理解,所以请不要这样做。)