“可选参数”趣事探轶

上一篇博文
中提到了“可选参数”这个C# 4.0中新增的语言特性,但是写过之后还是不满足,心里还是有一些疑问没有得到解释。于是又做了一些探索,过程中竟然发现这么一个小小
的语言特性背后隐藏着的有趣问题还真不少。这次就把探索过程中的发现和疑问记录下来。

“可选参数”的实现

Cnblogs上有一篇 蒋金楠的文章
中提到一句:“缺省参数最终体现为两个特殊的自定义特性OptionalAttribute和DefaultParameterValueAttribute
”。为了验证这个说法的正确性,我自己做了一些试验。

要研究语言特性的实现原理最好的方法莫过于反编译出IL代码来一探究竟了。所以,那就顺着这条线索走吧。

首先用C#代码写一个很简单的测试方法:

public void TestMethod(string str = "A")
{
}

上一篇博文
中提到过这种写法跟直接使用OptionalAttribute和DefaultValueAttribute这两个attribute的效果是一样的。

public void TestMethodWithAttributes([Optional, DefaultParameterValue("A")]string str)
{
}

这两段代码编译出来的IL除了名字之外别无二致,下面就以第一个方法为例,它的IL是这样的:

.method public hidebysig instance void  TestMethod([opt] string str) cil managed
{
  .param [1] = "A"
  // Code size       2 (0x2)
  .maxstack  8
  IL_0000:  nop
  IL_0001:  ret
} // end of method Program::TestMethod

同时其生成的Metadata是这样的:

MethodName: TestMethod (06000003)
Flags     : [Public] [HideBySig] [ReuseSlot]  (00000086)
RVA       : 0x0000205b
ImplFlags : [IL] [Managed]  (00000000)
CallCnvntn: [DEFAULT]
hasThis 
ReturnType: Void
1 Arguments
Argument #1:  String
1 Parameters
(1) ParamToken : (08000002) Name : str flags: [Optional] [HasDefault]  (00001010) Default: (String) 

说老实话,上面这两段“天书”我并没有完全读懂,但是还是发现有一些异常,觉得有些东西不太对头,为什么这么说呢?因为一般的attribute编译之后的结果通常不
是这样的。比如下面这个例子:

先自定义一个只能应用到参数上的attribute:

[AttributeUsage(AttributeTargets.Parameter, Inherited = false, AllowMultiple = true)]
sealed class MyAttribute : Attribute
{
}

然后定义一个被该attribute修饰的方法:

public void TestAttribute([My]string str)
{
}

这个方法编译之后的IL如下:

.method public hidebysig instance void  TestAttribute(string str) cil managed
{
  .param [1]
  .custom instance void HowDidTheyImplementOptionalParameters.MyAttribute::.ctor() = ( 01 00 00 00 ) 
  // Code size       2 (0x2)
  .maxstack  8
  IL_0000:  nop
  IL_0001:  ret
} // end of method Program::TestAttribute

可以看到上面代码中标红的部分是TestMethod的IL中没有的。而且,它的Metadata和TestMethod的也是不同的:

MethodName: TestAttribute (06000005)
Flags     : [Public] [HideBySig] [ReuseSlot]  (00000086)
RVA       : 0x00002061
ImplFlags : [IL] [Managed]  (00000000)
CallCnvntn: [DEFAULT]
hasThis 
ReturnType: Void
1 Arguments
    Argument #1:  String
1 Parameters
    (1) ParamToken : (08000004) Name : str flags: [none] (00000000)
    CustomAttribute #1 (0c000010)
    -------------------------------------------------------
        CustomAttribute Type: 06000001
        CustomAttributeName: HowDidTheyImplementOptionalParameters.MyAttribute :: instance void .ctor()
                Length: 4
                Value : 01 00 00 00                                      >                <
                ctor args: ()

这个方法的Metadata的最后多了一段CustomAttribute的描述,其flags也为空,不像TestMethod的flags后面跟有[Option
al] [HasDefault]这样的标志。

因为我没有读过 ECMA 335 的文档,所以下面只是做一个不太谨慎的推测:Op
tionalAttribute和DefaultParameterValueAttribute这两个attribute和其他的attribute不同,他们有自
己对应的专有的flags。调用TestMethod的代码在被编译时,编译器会去读取存储于元数据中的默认值,并把读取到的值嵌入到IL中去。

由于在TestMethod的C#代码中、编译出的IL代码中,及其元数据中都不见OptionalAttribute和DefaultParameterValue
Attribute
的踪迹,所以我认为“缺省参数最终体现为两个特殊的自定义特性OptionalAttribute和DefaultParameterValueAttribute
”这种说法是有待商榷的。

背后的陷阱

“可选参数”看起来方便又好用,但是使用它是不是真的是多快好省的绝佳选择呢?实际上不是的,它的背后隐藏着至少两个陷阱(我只发现了两个)。

第一个陷阱:版本更迭的问题

就以上面提到的TestMethod为例,写一个方法来调用它:

public void Caller()
{
    TestMethod();
}

这里在调用时没有传入参数,也就是说相当于传入了默认的参数“A”。Caller编译出来的IL是这样的:

.method public hidebysig instance void  Caller() cil managed
{
  // Code size       14 (0xe)
  .maxstack  8
  IL_0000:  nop
  IL_0001:  ldarg.0
  IL_0002:  ldstr      "A"
  IL_0007:  call     instance void HowDidTheyImplementOptionalParameters.Program::TestMethod(string)
  IL_000c:  nop
  IL_000d:  ret
} // end of method Program::Caller

请注意标红的两行,Caller的IL中实际是把“A”这个值写死了的。也就是说如果有一个包含“可选参数”的非强命名程序集在版本升级时修改了参数的默认值,其他引
用它的程序集如果没有重新编译就无法获得到新的默认参数值,在运行时仍然会传入老版本中定义的值。

第二个陷阱:跨语言调用

并不是所有的语言都被强制要求支持“可选参数”这一特性。对于不支持这一特性的语言来说,完全可以忽略掉元数据中包含的默认值而强制要求这一语言的用户去显式的提供参
数值。而这样就会导致代码的运行时行为不一致。

C#4.0之前都所有版本都是不支持“可选参数”的。也就是说如果在VS2010中用C#4.0的语法和.NET Framework
2.0的框架编一个含有“可选参数”的程序集,然后在VS2008中的项目中引用这个程序集的话,则只能显式的提供参数值。

针对以上两点,我觉得在使用“可选参数”时应该遵循以下的原则:在public API(包括公开类型的公开成员和公开类型的受保护成员)中尽量不要用“可选参数”,
而是使用方法重载,以避免API行为不一致。在程序集内部的私有API中,尽情享用吧。

关于CLS-Compliant

微软一站式示例代码库 的文档中提到说“可选参数”不是CLS-
Compliant的。我觉得这种说法是错误的。最简单的验证方式就是加上CLSCompliantAttribute来试试看。

在含有TestMethod(这里要保证TestMethod是公开类型中的公开方法,因为CLSCompliant只针对public
API)的项目的AssemblyInfo.cs中加上这么一行:

[assembly: CLSCompliant(true)]

然后编译,编译器没有给出任何警告。而如果是在public
API中使用了unit这一“臭名昭著”的类型的话,编译器会毫不犹豫的给出一个警告。比如这样的一个方法:

public void TestCLSCompliant(uint parameter)
{
}

在编译时就会得到一个警告:Argument type ‘uint’ is not CLS-compliant。

而且 MSDN的文档
中也提到了虽然“可选参数”没有被收录到CLS的规范中,但是CLS是可以“容忍”它的存在的。

Reflector中可能的Bug

以上所有反编译都是用IL Dasm来做的,而如果用最新版的Reflector(就是只能试用14天的那个版本)来查看反编译出的C#(把版本设为任何非None的
值)代码的话,会发现它会把TestMethod解释为使用了OptionalAttribute和DefaultParameterValueAttribute。
我怀疑这是因为无论是使用“可选参数”还是直接使用OptionalAttribute和DefaultParameterValueAttribute,编译出的结
果都是一样的,Reflector无从判断源代码中使用的是哪一种,索性就假定为是第二种了。

存疑

虽然OptionalAttribute没有出现在TestMethod的C#代码中,在编译出来的IL和元数据中也不见踪影,但是它还是出现在了编译出的程序集的T
ypeRefs中,而DefaultValueAttribute却没有出现。这是为什么呢?

参考

MSDN上的:

http://social.msdn.microsoft.com/Forums/en-US/csharplanguage/thread/d1be12e0-6325-427a-8e25-02fbd8396b1b/#18b08278-28a9-43dc-b3d4-e4694ca0260d

http://social.msdn.microsoft.com/Forums/en-US/csharplanguage/thread/31731806-dd83-4483-89b4-30001af14ab7/#352d019c-950c-42de-88f6-b0fecdf34351

http://social.msdn.microsoft.com/Forums/en-US/csharplanguage/thread/86f6d205-21b8-45e3-b5ec-3e9d5c1f9feb/

StackOverflow上的:

http://stackoverflow.com/questions/5456989/is-the-new-feature-of-c-4-0-optional-parameters-cls-compliant

http://stackoverflow.com/questions/5497514/what-does-opt-mean-in-msil

http://stackoverflow.com/questions/5522438/why-does-a-custom-attribute-appear-both-in-il-and-metadata

请问CSDN的工作人员一个问题,为什么用Live Writer发布的文章一开始排版,格式都是正确的,只要在CSDN的Web
Editor里面编辑一次就全乱了呢?