Effective .NET演讲圆满结束,拖着疲惫的身体回到家,非常感谢大家的支持。
唯一的遗憾是时间安排太紧,一些item匆匆过了,而没有深入的展开,还有一些item由于考虑到讲座的覆盖度,没有列出。这些主题将会在我将来的书稿中得到完整的体现。
我会把Effective C++/CLI努力写好,也希望大家能贡献自己的智慧,一起来完善这本书。
本月23号(下周日)我在美罗大厦有一个topic:高效.NET应用程序设计原则。欢迎在上海附近,对.NET开发感兴趣的博客堂的朋友过来聊聊。
点击这里注册:http://www.zhucheng.biz/club/club.aspx
另外,本周日(16号)还有黄雪斌先生的“防范黑客攻击”,很有意思的话题。
BTW,注册的时候,建议你在地址部分加一个[博客堂],我会嘱咐给予特殊照顾:)
ninputer在关于“值类型的Finalize不会被调用”中(http://blog.joycode.com/lijianzhong/archive/2005/01/13/42991.aspx#FeedBack)评论到“VB对Finalize管的可松呢,可以直接重写、直接调用、允许不调用父类的Finalize,或者多次调用父类的Finalize等等…… 完全不像C#”。
其实C#的Finalize方法看起来只是比VB的好一点,但仍然有非常隐蔽的问题。问题如下。
首先来看如下的代码:
using System;
public class Grandpapa
{
~Grandpapa(){ Console.WriteLine("Grandpapa.~Grandpapa");}
}
public class Parent:Grandpapa
{
~Parent(){ Console.WriteLine("Parent.~Parent");}
}
public class Son:Parent
{
~Son(){ Console.WriteLine("Son.~Son");}
}
public class App
{
public static void Main()
{
Son s=new Son();
GC.Collect();
GC.WaitForPendingFinalizers();
}
}
这段代码的运行结果毫无疑问是:
Son.~Son
Parent.~Parent
Grandpapa.~Grandpapa
这没什么问题。但是如果将Parent类重新定义如下,会出现什么情况呢?
public class Parent:Grandpapa
{
protected void Finalize(){ Console.WriteLine("Parent.Finalize");}
}
运行结果变成了:
Son.~Son
Parent.Finalize
情况已经有些不妙了,我在Parent中定义了一个“普通”的Finalize方法,竟然被它的子类Son的析构器给调用了?
当然Finalize方法在C#中并不是一个“普通”的方法,析构器编译后就是一个有上述签名的Finalize方法。但C#编译器并没有禁止我们定义“普通”的Finalize,
C#规范也没有指出定义这样的Finalize方法就是在定义一个析构器——实际上也不是,只是上述代码的表现如此——甚至还有这样一句诱人犯错的话:The compiler behaves as if this method(Finalize), and overrides of it, do not exist at all。分析IL代码可以看出,Parent中定义的“普通”的Finalize方法实际上“欺骗”了它的子类。它的子类只关心其父类是否定义了Finalize(当然签名要为上述形式)方法,它并不关心那个Finalize方法是否具有“析构器”语义。
如果上述代码的行为通过理性分析还算可以接受的话,那么下面代码的运行结果就令人眩晕了,将Parent类重新定义如下(在上面的基础上添加了一个virtual关键字):
public class Parent:Grandpapa
{
protected virtual void Finalize(){ Console.WriteLine("Parent.Finalize");}
}
编译后运行结果如下:
Grandpapa.~Grandpapa
这一次从IL代码的角度也解释不清了,我怀疑CLR对于析构器的判断是否还有另外的附加条件,但无论如何C#编译器呈现的行为是诡异的,因为这种结果放到哪里都是难以自圆其说的。我曾经为此挖掘了sscli源代码很长时间,但是就是找不到原因。
这一方面是C#编译器的一个bug,另一方面也是CLR的一个bug。这个bug从.NET Framework的1.0版(VS.NET 2002),到1.1版(VS.NET 2003),以及Alpha版本的Longhorn操作系统中自带的1.2版都存在。后来我写信给C#的产品经理Eric Gunnerson(http://blogs.msdn.com/ericgu/)告诉他们这个bug。Eric Gunnerson随后回信告诉我他们会修复这个bug。
我现在使用Visual C# Express 2005编译器编译(version 8.00.41013)上述代码,后面两种修改版都会得到一个warning:
warning CS0465: Introducing a 'Finalize' method can interfere with destructor invocation. Did you intend to declare a destructor?
但是如果不理会这样的警告,得到的exe文件执行行为仍然是非常奇怪。也就是说CLR中的bug仍然没有fix。我个人认为对于C#编译器来说,warning是不够的,应该彻底禁止定义这样的Finalize方法。
实际上在我的Effective .NET (in C#)一书的draft里也有这样一个条款:
# 不要在一个类中有定义任何Finalize方法的念头,因为那样会对你的“析构器链”造成潜在的严重的伤害。
ninputer在这里(http://blog.joycode.com/ninputer/archive/2005/01/12/42866.aspx)有一篇blog提出了一个问题“值类型的Finalize不会被调用?”
我曾经对Rotor,也就是sscli(Shared Source Common Language Infrastructure),有过一些粗略的探索——不过现在由于比较忙,慢慢也半途而废了:)
这个问题可以从sscli里得到解释——sscli和目前运行在我们机器上的CLR实现差别主要在效率和扩展层面,因此研究它有助于理解CLR的行为。所有有关底层运作的代码都在目录sscli\clr\src\vm下。结合sscli的源码,下面我来聊聊这个话题。
首先给出一个结论:这是因为CLR对值类型进行了专门的设计,让它不可能进入Freachable Queue 里面。
下面根据sscli源码来对上述结论进行解释:
1。有关CLR类型一个最关键的类就是MethodTable。它的第一个字段m_wFlags(一个DWORD)的第21位 bit用来标示这个类是否有Finalizer。
MethodTable有一个方法为HasFinalizer就做此用:
MethodTable::HasFinalizer()
{
return (m_wFlags & enum_flag_HasFinalizer);
}
其中enum_flag_HasFinalizer = 0x100000,
GC在判断一个类型的实例对象是否需要放到Freachable Queue中,就是采用MethodTable::HasFinalizer()方法来判断。
2。最关键的是EEClass::BuildMethodTable,这个方法负责建立类型的方法表,它会被ClassLoader::LoadTypeHandleFromToken调用,ClassLoader::LoadTypeHandleFromToken又被ClassLoader::LoadTypeHandle和Module::BuildClassForModule调用。
用通俗的语言来解释就是“每一个类型被load到内存中的时候,它都会建立和该类型相关的方法表”,而我们在CLR中的所有对象都有自己的类型。
3。下面就是看EEClass::BuildMethodTable如何设置MethodTable::m_wFlags。
EEClass::BuildMethodTable中和“值类型的Finalize”这个主题相关的动作有以下几个调用(为简便起见我没有在这里写方法的参数):
EEClass::BuildMethodTable
{
...
CheckForValueType
...
CheckForEnumType
...
GetMethodTable()->MaybeSetHasFinalizer
...
}
4。来看CheckForValueType和CheckForEnumType分别做了什么。
HRESULT EEClass::CheckForValueType(bmtErrorInfo* bmtError)
{
if(...) //查看类型元数据
SetValueClass();
}
HRESULT EEClass::CheckForEnumType(bmtErrorInfo* bmtError)
{
if(...) //查看类型元数据
SetValueClass();
}
再来看SetValueClass做了什么:
inline void EEClass::SetValueClass()
{
m_VMFlags |= VMFLAG_VALUETYPE;
}
就是设置EEClass::m_VMFlags的第24位bit来表示这个类为“值类型”。
其中VMFLAG_VALUETYPE = 0x00800000,
5。最后来看MaybeSetHasFinalizer做了什么(我简化了其中很多代码,只展示和本问题相关的代码逻辑)。
void MethodTable::MaybeSetHasFinalizer()
{
if ( !IsValueClass())
{
if(...)
{
m_wFlags |= enum_flag_HasFinalizer;
}
}
}
这段代码的意思是只要IsValueClass()为true,那么MethodTable::m_wFlags的第21位 bit就不会被置1。
那么MethodTable::IsValueClass()做了什么呢?
inline BOOL MethodTable::IsValueClass()
{
return GetClass()->IsValueClass();
}
inline DWORD EEClass::IsValueClass()
{
return (m_VMFlags & VMFLAG_VALUETYPE);
}
判断EEClass::m_VMFlags的第24位bit看看其是否为“值类型”。
至此,整个来龙去脉已经非常清晰——CLR的设计者通过MethodTable::m_wFlags的第21位bit来控制一个类型是否有Finalizer,同时通过EEClass::m_VMFlags的第24位bit来控制一个类型是否为值类型。最后在调用EEClass::BuildMethodTable的时候,判断如果一个类型为值类型,那么就让它不可能具有Finalizer语义。
Ninputer 在随后的回复中还提了一个问题“如果值类型用了非托管资源怎么释放呢?”。
我的回答是:不要这么做,值类型当初就是为象integer这样的轻量级类型而设计的,持有非托管资源的类型天生就是一个“重量级类型”。当然你可以使struct实现IDisposable,但是那是不完整的Dispose模式。
实际上在我的Effective .NET (in C#)一书的draft里就有这样一个条款:
# 如果使用非托管资源,请把它封装在class而不是struct里面。
C++/CLI中新推出的自动确定性资源回收(Automatic deterministic destruction)被视为一个优秀的设计。是使用所谓C++/CLI这个“新瓶”来装Bjarne Stroustrup提出的RAII这个“旧酒”。
这的确不错,相对而言,这个比C#中的using 关键字(dispose模式),以及Java中的hard-coded的dispose方法都要好许多。这个特性是由C++/CLI中栈对象(局部对象)来提供的,局部对象本身没错,RAII也是局部对象应有之义。
但问题在于C++/CLI中栈对象的可用性由于许多原因会大打折扣,使用起来已经远远不如ISO-C++中那样流畅。下面列出了损伤其可用性的几大硬伤:
#1。C++/CLI的栈对象并非真的位于栈中
只要类型是ref class,C++/CLI中的栈对象就仍位于托管堆中。仍然使用newobj IL指令来分配。如果R没有定义析构器(~R)(注意:C++/CLI中的析构器和C#中的析构器完全两回事),那么下面两行代码实际上将生成完全一样的IL代码:
R r;
R h=gcnew R;
好像记得Herb Sutter曾经说过他们将来可能会在真正的方法栈中分配r ——说实话恐怕只有C++背景的人敢这么“胡思乱想”:) 他们现在只是想在语法层面让程序员"感觉"就像r是从栈中分配的一样。又一个syntax sugar:)
当然为了对称和语义的完美,有时候还需要在r上应用%——虽然背后仍是什么也没做:)
#2。C++/CLI编译器默认情况下不会自动产生拷贝构造函数和拷贝赋值操作符
这一点非常令人烦恼,几乎让人“望栈对象而却步”。更糟糕的是BCL中的所有类型都没有提供拷贝构造函数和拷贝赋值操作符——因为恐怕只有C++/CLI会用到他们。
话说回来,即使C++/CLI会自动产生拷贝构造函数和拷贝赋值操作符,那么继承自BCL的类型还是会很麻烦。
#3。如果函数要被其他CLI语言调用,那么就不能将其参数设计为栈对象
a. static void add(R r){...}
编译出来有一个modopt元数据,所以可以被其他语言调用,但是如果被其他语言调用,比如C#,那么其他语言将是以传值的方式传递引用,而C++/CLI将是传递对象拷贝(要调用拷贝构造器),所以语义混乱,完全不可以这样做。
b. static void add(R% r){...}
由于编译出来都有一个modreq元数据,所以不能被其他CLI语言调用。
#4。如果函数要被其他CLI语言调用,那么也不能将其返回值设计为栈对象
a. static R add(){...}
b. static R% add(){...}
两者编译出来都有一个modreq元数据,所以都不能被其他CLI语言调用。
#5。使用BCL时,如果要传递栈对象,总要使用“莫名其妙”的%操作符
比如:
String s("abc");
ArrayList list;
list.Add(%s);
实在很不好,还是使用追踪引用比较好:
String^ s="abc";
ArrayList^ list=gcnew ArrayList();
list->Add(s);
总结一下:
#1和#5对栈对象的可用性影响不算大,毕竟从语义层面来理解,还是行得通的。
但是,#2、#3、#4的影响就很大。#3和#4使得我们必须放弃使用栈对象来进行互操作。而#2会让编写C++/CLI代码非常的不方便——除非你以后不想使用栈对象。
现在的问题是,是否C++/CLI中的栈对象只是为了获得自动确定性资源回收而存在?值得这样做吗?