16

我正在使用 C++/CLI,使用 MSDN 文档和ECMA 标准,以及 Visual C++ Express 2010。令我印象深刻的是以下与 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
}

在 at 块的末尾#1,自动变量x消失,析构函数被调用(它反过来显式调用终结器,就像通常的习语一样)。这一切都很好。但是后来我通过引用再次删除了对象r!输出是这样的:

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

问题:

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

  2. 如果我们删除 line #2r是否仍然是一个对象的跟踪句柄(在 C++ 的意义上)不再存在?它是一个“悬垂的把手”吗?它的引用计数是否意味着将尝试双重删除?

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

    Foo()
    ~Foo()
    !Foo()
    

    但是,我不确定这是否是一个愉快的事故或保证是明确定义的行为。

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

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

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


为了响应@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;
};
4

2 回答 2

18

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

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

析构函数~Foo()简单地自动生成两个方法,一个是 IDisposable::Dispose() 方法的实现,一个是受保护的 Foo::Dispose(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 类仅在管理内存以外的资源时才实现 IDisposable。或者,如果它具有本身实现 IDisposable 的类类型的字段。

在这种情况下是否应该实现析构函数也是值得怀疑的。您的示例类持有一个相当适中的非托管资源。通过实现析构函数,您可以将使用它的负担强加给客户端代码。这在很大程度上取决于类的使用情况,客户端程序员这样做的难易程度,如果对象被期望存活很长时间,超出方法体以致于using语句不可用,这绝对不是. 您可以让垃圾收集器知道它无法跟踪的内存消耗,调用 GC::AddMemoryPressure()。这也处理了客户端程序员根本不使用 Dispose() 的情况,因为它太难了。

于 2012-09-09T15:56:52.390 回答
1

标准 C++ 的指南仍然适用:

  1. 调用delete一个自动变量,或者一个已经被清理的变量,仍然是一个坏主意。

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

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

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

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

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

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

于 2012-09-03T21:14:58.727 回答