除了null之外,为什么我不能将默认值作为可选参数?

我想要一个可选参数并将其设置为我确定的默认值,当我这样做时:

private void Process(Foo f = new Foo()) { } 

我收到以下错误( Foo是一个类):

‘f’是Foo的类型,除string之外的引用类型的默认参数只能用null初始化。

如果我将Foo更改为struct那么它可以工作,但只有默认的无参数构造函数。

我阅读了文档,它清楚地表明我不能这样做,但它没有提到为什么? ,为什么这个限制存在,为什么string被排除在外呢? 为什么可选参数的值必须是编译时常量? 如果这不是一个常数那么副作用会是什么?

一个起点是CLR不支持这一点。 它必须由编译器实现。 你可以从一个小测试程序中看到的东西:

 class Program { static void Main(string[] args) { Test(); Test(42); } static void Test(int value = 42) { } } 

哪个反编译为:

 .method private hidebysig static void Main(string[] args) cil managed { .entrypoint // Code size 15 (0xf) .maxstack 8 IL_0000: ldc.i4.s 42 IL_0002: call void Program::Test(int32) IL_0007: ldc.i4.s 42 IL_0009: call void Program::Test(int32) IL_000e: ret } // end of method Program::Main .method private hidebysig static void Test([opt] int32 'value') cil managed { .param [1] = int32(0x0000002A) // Code size 1 (0x1) .maxstack 8 IL_0000: ret } // end of method Program::Test 

请注意在编译器完成后,两个调用语句之间没有任何区别。 编译器应用了默认值并在调用站点执行了此操作。

还要注意,当Test()方法实际存在于另一个程序集中时,这仍然需要工作。 这意味着需要在元数据中编码默认值。 请注意.param指令是如何做到这一点的。 CLI规范(Ecma-335)在第II.15.4.1.4节中对其进行了说明

该指令在元数据中存储与方法参数号Int32相关的常量值 ,参见§II.22.9。 虽然CLI要求为参数提供值,但某些工具可以使用此属性的存在来指示工具而不是用户是否要提供参数的值。 与CIL指令不同,.param使用索引0指定方法的返回值,索引1指定方法的第一个参数,索引2指定方法的第二个参数,依此类推。

[注意:CLI不会对这些值附加任何语义 – 完全取决于编译器实现他们希望的任何语义(例如,所谓的默认参数值)。 结束说明]

引用的第II.22.9节详细介绍了常数值的含义。 最相关的部分:

类型必须是以下之一:ELEMENT_TYPE_BOOLEAN,ELEMENT_TYPE_CHAR,ELEMENT_TYPE_I1,ELEMENT_TYPE_U1,ELEMENT_TYPE_I2,ELEMENT_TYPE_U2,ELEMENT_TYPE_I4,ELEMENT_TYPE_U4,ELEMENT_TYPE_I8,ELEMENT_TYPE_U8,ELEMENT_TYPE_R4,ELEMENT_TYPE_R8或ELEMENT_TYPE_STRING; 或者值为零的ELEMENT_TYPE_CLASS

所以这就是降压停止的地方,甚至没有好的方法来引用匿名辅助方法,所以某种代码提升技巧也无法工作。

值得注意的是,它只是一个问题,您始终可以为引用类型的参数实现任意默认值。 例如:

 private void Process(Foo f = null) { if (f == null) f = new Foo(); } 

这是非常合理的。 以及您在方法中而不是调用站点所需的代码类型。

因为没有其他编译时常量而不是null。 对于字符串,字符串文字是这样的编译时常量。

我认为它背后的一些设计决策可能是:

  • 简单的实施
  • 消除隐藏/意外行为
  • 方法合同的清晰度,尤其是 在交叉assembly场景中

让我们再详细说明这三个问题,以便在问题的引导下获得一些见解:

1.实施简单

当限制为常量值时,编译器和CLR的作业都非常简单。 常量值可以轻松存储在程序集元数据中,编译器可以轻松实现。 Hans Passant的回答概述了如何做到这一点。

但是CLR和编译器可以做些什么来实现非常量默认值呢? 有两种选择:

  1. 存储初始化表达式本身,并在那里编译它们:

     // seen by the developer in the source code Process(); // actually done by the compiler Process(new Foo()); 
  2. 生成thunk:

     // seen by the developer in the source code Process(); … void Process(Foo arg = new Foo()) { … } // actually done by the compiler Process_Thunk(); … void Process_Thunk() { Process(new Foo()); } void Process() { … } 

两种解决方案都将更多新元数据引入到程序集中,并且需要编译器进行复杂处理。 此外,虽然解决方案(2)可以被视为隐藏的技术性(以及(1)),但它对感知行为有影响。 开发人员希望在调用站点而不是其他地方评估参数。 这可能会带来额外的问题(参见与方法合同相关的部分)。

2.消除隐藏/意外行为

初始化表达式可能是任意复杂的。 因此这样一个简单的调用:

  Process(); 

将展开在呼叫站点执行的复杂计算。 例如:

  Process(new Foo(HorriblyComplexCalculation(SomeStaticVar) * Math.Power(GetCoefficient, 17))); 

从没有彻底检查“过程”声明的读者的角度来看,这可能是相当意想不到的。 它使代码混乱,使其可读性降低。

3.方法合同的明确性,尤其是 在交叉assembly场景中

方法的签名与默认值一起签订合同。 该合同生活在特定的背景下。 如果初始化表达式需要绑定到某些其他程序集,那么调用者需要什么? 这个例子怎么样,’CalculateInput’方法来自’Other.Assembly’:

  void Process(Foo arg = new Foo(Other.Assembly.Namespace.CalculateInput())) 

在这里,实现这种方式的关键在于思考这是一个问题还是注意事项。 在“简单”部分,我概述了实现方法(1)和(2)。 因此,如果选择(1),则需要调用者绑定到“Other.Assembly”。 另一方面,如果选择了(2),那么从实现的角度来看,这种规则的需求远远少于此类规则,因为编译器生成的Process_ThunkProcess声明在同一个地方,因此自然会引用Other.Aseembly 。支持。 然而 ,一个理智的语言设计者甚至会施加这样的规则,因为同一事物的多个实现是可能的,并且为了方法契约的稳定性和清晰性。

然而,交叉汇编方案会强制使用汇编引用,这些引用从调用站点的普通源代码中无法清楚地看到。 这又是一个可用性和可读性问题。

这只是语言的工作方式,我不能说他们为什么这样做(而且这个网站不是像这样讨论的网站 ,如果你想讨论它就可以聊聊 )。

我可以告诉你如何解决它,只需制作两个方法并重载它(稍微修改你的例子以显示你将如何返回结果)。

 private Bar Process() { return Process(new Foo()); } private Bar Process(Foo f) { //Whatever. } 

默认参数以一种提供默认参数的方式操纵调用者,它将在编译时更改方法签名。 因此,您需要提供一个常量值,在您的情况下,“new Foo()”不是。

这就是你需要一个常数的原因。