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

Ker*_* SB 14 c++ garbage-collection c++-cli

我正在使用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
}
Run Code Online (Sandbox Code Playgroud)

在块的末尾#1,自动变量x死亡,并且析构函数被调用(它反过来调用终结器,就像通常的习惯用法一样).这一切都很好.但后来我通过引用再次删除了该对象r!输出是这样的:

Foo()
~Foo()
!Foo()
~Foo()
!Foo()
Run Code Online (Sandbox Code Playgroud)

问题:

  1. delete r在线呼叫是不确定的行为,还是完全可以接受#2

  2. 如果我们删除行#2,那么r对于一个(在C++意义上)不再存在的对象的跟踪句柄是否重要?这是一个"晃来晃去的手柄"吗?它的引用计数是否会导致尝试双重删除?

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

    Foo()
    ~Foo()
    !Foo()
    
    Run Code Online (Sandbox Code Playgroud)

    但是,我不确定这是一个幸福的事故还是保证是明确的行为.

  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;
};
Run Code Online (Sandbox Code Playgroud)

Han*_*ant 16

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

对于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...
}
Run Code Online (Sandbox Code Playgroud)

StreamWriter对象将获取其基本流的所有权,并在最后一个大括号中调用其Dispose()方法.FileStream对象上的using语句第二次调用Dispose().编写此代码以便不会发生这种情况仍然提供异常保证太困难了.指定可以多次调用Dispose()来解决问题.

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

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

换句话说,管理对象"永远活着"

不,垃圾收集器声明对象死了,并在它找不到对该对象的任何引用时收集它.这是一种故障安全的内存管理方式,没有办法意外引用已删除的对象.因为这样做需要参考,GC将始终看到.常见的内存管理问题(如循环引用)也不是问题.

代码段

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

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