C++/CLI 中的确定性销毁






4.92/5 (24投票s)
2004年8月13日
6分钟阅读

179190
探讨 C++/CLI 如何实现确定性销毁
引言
许多 C++ 程序员对 .NET 垃圾回收算法提供的非确定性终结功能相当不满。C++ 程序员非常习惯 RAII(资源获取即初始化)习语,他们期望在对象超出作用域或对其显式调用 delete
时调用析构函数,因此非确定性析构函数根本不符合他们的期望或要求。Microsoft 另外提供了 Dispose 模式,其中类必须实现 IDisposable
,然后在对象超出作用域时对其对象调用 Dispose
。这里基本的问题是,这要求程序员在每次需要终结对象时手动且一致地调用 Dispose
,当对象包含托管成员对象本身也需要调用 Dispose
时,情况变得更糟,这意味着它们也需要实现 IDisposable
。听起来很累人,不是吗?
你猜怎么着?在 C++/CLI 中,Microsoft VC++ 团队为我们提供了一个析构函数,它在内部被编译为 Dispose
方法,而旧的终结器则采用替代语法,因此我们基本上将终结器和析构函数视为两个独立的实体,它们像在早期版本中那样表现不同。C# 的设计者最初犯了一个不幸的错误,将他们的终结器称为析构函数,我 HEY 猜想,可能有成千上万的 C# 程序员根本不知道他们将对象生命周期维护中的一个基本概念与错误的东西混淆了。
注意
在 C++/CLI 中,很容易将自动对象错误地称为堆栈对象,但应该记住,看似基于堆栈的对象实际上驻留在 CLR 堆上,因为它们仍然是正常的垃圾回收引用对象。这是一个 C++ 编译器技巧,允许我们将这些变量像过去在非托管 C++ 中对待基于堆栈的对象一样对待。
新语法
在 C++/CLI 中,析构函数遵循托管前时代使用的相同语法,其中 ~classname 是析构函数的方法名。它还引入了一种新的命名语法,!classname,它是终结器的方法名。典型的类将如下所示:-
ref class R1
{
public:
R1()
{
Show("R1::ctor");
}
~R1()
{
Show("R1::dtor");
}
protected:
!R1()
{
Show("R1::fnzr");
}
};
析构函数 (~R1
) 在生成的 IL 中被编译为 Dispose
方法。
.method public newslot virtual
final instance void
Dispose() cil managed
{
.override [mscorlib]System.IDisposable::Dispose
// Code size 17 (0x11)
.maxstack 1
IL_0000: ldstr "R1::dtor"
IL_0005: call void [mscorlib]
System.Console::WriteLine(string)
IL_000a: ldarg.0
IL_000b: call void [mscorlib]
System.GC::SuppressFinalize(object)
IL_0010: ret
} // end of method R1::Dispose
上述 C# 等效代码为:-
public void Dispose()//IDisposable::Dispose
{
Console.WriteLine("R1::dtor");
GC.SuppressFinalize(this);
}
在生成的 Dispose
方法中调用了 GC::SuppressFinalize
。这样做是为了确保在回收此对象内存的垃圾回收周期中不调用终结器。如果这听起来令人困惑,请记住我们仍然受到我们所针对的环境的限制,即 CLR。在 CLR 中,引用对象分配在 CLR 堆上,当垃圾回收器不再使用它们时,它们的内存会被回收,程序员无法自行释放内存。因此,即使我们的析构函数被调用,内存也只会在下一个 GC 周期中释放,此时我们不希望 GC 尝试在我们的对象上调用 Finalize
。GC::SuppressFinalize
基本上将对象从终结队列中删除。
它是如何实现的
void _tmain()
{
R1 r;
}
我将 r
声明为一个自动变量。现在让我们看看为此生成的 IL:-
.method public static int32
main() cil managed
{
.vtentry 1 : 1
// Code size 16 (0x10)
.maxstack 1
.locals (class R1 V_0)
IL_0000: ldnull
IL_0001: stloc.0
IL_0002: newobj instance void R1::.ctor()
IL_0007: stloc.0
IL_0008: ldloc.0
IL_0009: call instance void R1::Dispose()
IL_000e: ldc.i4.0
IL_000f: ret
} // end of method 'Global Functions'::main
C# 等效代码为:-
public static int main()
{
R1 r = null;
r = new R1();
r.Dispose();
return 0;
}
正如你所看到的,当对象超出作用域时调用了 Dispose
,这相当简单。你可能会有点惊讶,那里没有 try
-catch
块,但那是因为我们的代码片段太简单了。try
-catch
块仅在需要时使用,在上述情况下,则不需要。让我们看下面的代码片段:-
void _tmain()
{
R1 r;
int y=100;
}
生成的 IL:-
.method public static int32
main() cil managed
{
.vtentry 1 : 1
// Code size 28 (0x1c)
.maxstack 1
.locals (class R1 V_0,
int32 V_1)
IL_0000: ldnull
IL_0001: stloc.0
IL_0002: newobj instance void R1::.ctor()
IL_0007: stloc.0
.try
{
IL_0008: ldc.i4.s 100
IL_000a: stloc.1
IL_000b: leave.s IL_0014
} // end .try
fault
{
IL_000d: ldloc.0
IL_000e: call instance void R1::Dispose()
IL_0013: endfinally
} // end handler
IL_0014: ldloc.0
IL_0015: call instance void R1::Dispose()
IL_001a: ldc.i4.0
IL_001b: ret
} // end of method 'Global Functions'::main
编译器一旦意识到可能存在控制无法到达调用 Dispose
的行的偶然情况,它就会实现一个 try
块,并且在发生任何异常时,在故障处理程序中调用 Dispose
。C# 等效代码为:-
public static int main()
{
R1 r = null;
int y;
r = new R1();
try
{
y = 100;
}
catch
{
r.Dispose();
}
r.Dispose();
return 0;
}
你还可以将对象声明为句柄对象,然后手动对其调用 delete
,这等同于对你的对象调用 Dispose
。
void _tmain()
{
R1^ r = gcnew R1();
delete r;
}
在这种情况下,生成的 IL 稍微复杂一些(例如,我不完全确定为什么会引入一个不必要的 int
变量)。
.method public static int32
main() cil managed
{
.vtentry 1 : 1
// Code size 27 (0x1b)
.maxstack 1
.locals (class [mscorlib]System.IDisposable V_0,
class R1 V_1,
int32 V_2)
IL_0000: ldnull
IL_0001: stloc.1
IL_0002: newobj instance void R1::.ctor()
IL_0007: stloc.1
IL_0008: ldloc.1
IL_0009: stloc.0
IL_000a: ldloc.0
IL_000b: brfalse.s IL_0017
IL_000d: ldloc.0
IL_000e: callvirt
instance void [mscorlib]System.IDisposable::Dispose()
IL_0013: ldnull
IL_0014: stloc.2
IL_0015: br.s IL_0019
IL_0017: ldnull
IL_0018: stloc.2
IL_0019: ldc.i4.0
IL_001a: ret
} // end of method 'Global Functions'::main
正如我所提到的,我对 V_2 int32
变量感到非常困惑。对于那些不喜欢看 IL 的人,这里是 C# 等效代码。
public static int main()
{
int v2;
R1 r = null;
r = new R1();
IDisposable d = r;
if (disposable1 != null)
{
d.Dispose();
v2 = 0;
}
else
{
v2 = 0;
}
return 0;
}
我最好的猜测是,这有助于 CLR 执行引擎进行运行时优化;在上述情况下,如果 r
不为 null
,整个 if
循环可能会被跳过。
如何处理成员对象
请看下面的代码片段:-
#define Show(x) Console::WriteLine(x)
ref class R1
{
public:
R1()
{
Show("R1::ctor");
}
~R1()
{
Show("R1::dtor");
}
protected:
!R1()
{
Show("R1::fnzr");
}
};
ref class R
{
public:
R()
{
Show("R::ctor");
}
~R()
{
Show("R::dtor");
}
R1 r;
protected:
!R()
{
Show("R::fnzr");
}
};
让我们看看生成的 IL 中 R
的构造函数:-
.method public specialname rtspecialname
instance void .ctor() cil managed
{
// Code size 28 (0x1c)
.maxstack 2
IL_0000: ldarg.0
IL_0001: call instance void [mscorlib]System.Object::.ctor()
IL_0006: ldarg.0
IL_0007: newobj instance void R1::.ctor()
IL_000c: stfld class R1 modopt(
[Microsoft.VisualC]Microsoft.VisualC.IsByValueModifier) R::r
IL_0011: ldstr "R::ctor"
IL_0016: call void [mscorlib]System.Console::WriteLine(string)
IL_001b: ret
} // end of method R::.ctor
等效的 C# 代码将是:-
public R()
{
this.r = ((R1 modopt(Microsoft.VisualC.IsByValueModifier)) new R1());
Console.WriteLine("R::ctor");
}
编译器在 R1 对象的实例化中插入一个自定义的 modopt
修饰符,这将让 JIT 编译器了解如何处理它。在这种情况下,它将其标记为 Microsoft.VisualC.IsByValueModifier
,这大概意味着此对象将被视为按值传递对象。无论如何,这超出了本文的范围,我在这里想要提出的是,R
对象的构造函数也实例化并构造了 R1
成员对象。
现在让我们看看 R
类的析构函数:-
.method public newslot virtual final instance void
Dispose() cil managed
{
.override [mscorlib]System.IDisposable::Dispose
// Code size 42 (0x2a)
.maxstack 1
.try
{
IL_0000: ldstr "R::dtor"
IL_0005: call void [mscorlib]System.Console::WriteLine(string)
IL_000a: leave.s IL_0018
} // end .try
fault
{
IL_000c: ldarg.0
IL_000d: ldfld class R1 modopt(
[Microsoft.VisualC]Microsoft.VisualC.IsByValueModifier) R::r
IL_0012: call instance void R1::Dispose()
IL_0017: endfinally
} // end handler
IL_0018: ldarg.0
IL_0019: ldfld class R1 modopt(
[Microsoft.VisualC]Microsoft.VisualC.IsByValueModifier) R::r
IL_001e: call instance void R1::Dispose()
IL_0023: ldarg.0
IL_0024: call void [mscorlib]System.GC::SuppressFinalize(object)
IL_0029: ret
} // end of method R::Dispose
等效的 C# 代码是:-
public void Dispose()
{
try
{
Console.WriteLine("R::dtor");
}
catch
{
this.r.Dispose();
}
this.r.Dispose();
GC.SuppressFinalize(this);
}
正如你所看到的,成员对象也调用了 Dispose
。编译器确实为我们生成了很多代码,是吧?
在上面讨论的情况下,成员对象也是一个自动变量。但是如果我们的成员是一个句柄变量呢?在这种情况下,我们应该在析构函数中手动 delete
成员变量,否则如果成员对象必须等待不可预测的 GC 周期才能被释放,确定性销毁的益处就不会那么大。因此,对于这种情况,我们需要这样做:-
ref class R
{
public:
R()
{
r = gcnew R1();
Show("R::ctor");
}
~R()
{
delete r;
Show("R::dtor");
}
R1^ r;
protected:
!R()
{
Show("R::fnzr");
}
};
警告
不要从你的终结器中手动 delete
成员对象,因为很有可能在终结器在你的对象上调用时,它的成员对象可能已经终结了。
性能提升
尽可能使用析构函数而不是终结器,你会在代码中看到小到中等的性能提升。终结器的问题在于,GC 将需要终结的对象提升到至少第 2 代,然后终结器线程将不得不对需要终结的对象运行 Finalize
方法,然后 GC 必须在未来的周期中回收内存。
使用析构函数时要记住的要点
- 出于显而易见的原因,你的类中不能有一个名为
Dispose
的方法 - ~classname 是析构函数,!classname 是终结器
- 当对象超出作用域时会调用析构函数,但内存直到下一个 GC 周期才会释放
- 析构函数和终结器不会为同一个对象调用
- 对于自动成员变量,你不需要做任何特殊的事情
- 对于句柄成员变量,请确保在析构函数中手动
delete
它们
结论
本质上,C++/CLI 确定性析构函数实现内部是 Dispose-Pattern 的一种语法上令人愉悦的形式,编译器生成了我们所需的所有代码。我相信 C# 2.0 有一种稍微逊色的形式,它们使用 using
关键字。C++/CLI 析构函数语法的巨大优点是它自然地符合原生 C++ 程序员对其析构函数所期望的功能,而且他/她甚至不需要了解内部正在使用的 Dispose 模式。感谢 Herb Sutter 和他的团队 :-)