C++/CLI中重复的析构函数调用和跟踪句柄
Repeated destructor calls and tracking handles in C++/CLI
我正在使用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()
问题:
在
#2
线上调用delete r
是未定义的行为,还是完全可以接受?如果我们去掉
#2
行,那么r
仍然是一个(在C++意义上)不再存在的对象的跟踪句柄有关系吗?这是一个"悬空把手"吗?它的引用计数是否意味着将有人试图双重删除?我知道没有实际的双重删除,因为输出变成这样:
Foo() ~Foo() !Foo()
然而,我不确定这是一个愉快的事故,还是一定是明确的行为。
在其他哪些情况下,托管对象的析构函数可以被多次调用?
在
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++的指导原则仍然适用:
-
对自动变量或已经清理过的变量调用
delete
仍然是个坏主意。 -
它是指向已处理对象的跟踪指针。取消引用是个坏主意。使用垃圾回收,只要存在任何非弱引用,内存就会一直保留,因此您不会意外访问错误的对象,但您仍然无法以任何有用的方式使用这个已处理的对象,因为它的不变量可能不再有效。
-
只有当您的代码以标准C++中的UB风格编写时,托管对象才会发生多次破坏(请参阅上面的1和下面的4)。
-
对自动变量显式调用析构函数,然后不在其位置创建新的析构函数以供自动销毁调用查找,这仍然是个坏主意。
一般来说,您认为对象的生存期与内存分配是分开的(就像标准C++一样)。垃圾回收用于管理释放——所以内存仍然存在——但对象已经死了。与标准C++不同,您不能将该内存重新用于原始字节存储,因为.NET运行时的某些部分可能会认为元数据仍然有效。
垃圾收集器和"堆栈语义"(自动变量语法)都不使用引用计数。
(丑陋的细节:处理一个对象不会破坏.NET运行时自身与该对象相关的不变量,因此您甚至可能仍然可以将其用作线程监视器。但这只会使设计变得丑陋而难以理解,所以请不要这样做。)
- 什么时候调用析构函数
- C++-明确何时以及如何调用析构函数
- 在c++中使用向量时,如何调用构造函数和析构函数
- C++ 防止在映射中放置()时调用析构函数
- 调用析构函数以释放动态分配的内存
- C++:使用方法调用析构函数的顺序是什么?
- 向量推回调用析构函数时调用析构函数
- 如何在调用析构函数时优雅地停止/销毁带有阻塞调用C++线程?
- C++,我应该调用析构函数吗?
- 如何获取有关在 Clang LibTooling 中调用析构函数的信息?
- 当我从 std::vector 中的新放置调用析构函数时会发生什么?
- 未调用的初始化静态thread_local结构的构造函数和析构函数
- 为什么这里不调用析构函数
- 在调用 std::bind 的产品后意外调用析构函数
- 为什么在传递给函数而不是构造函数时调用析构函数?
- 如何在C++中调用析构函数
- 为什么为未删除的对象调用析构函数?
- 调用析构函数时出错
- C++ 在不释放内存的情况下调用析构函数
- 从函数调用析构函数返回的对象