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

Sel*_*enç 21 c# optional-parameters

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

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

}
Run Code Online (Sandbox Code Playgroud)

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

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

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

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

Han*_*ant 18

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

class Program {
    static void Main(string[] args) {
        Test();
        Test(42);
    }
    static void Test(int value = 42) {
    }
}
Run Code Online (Sandbox Code Playgroud)

哪个反编译为:

.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
Run Code Online (Sandbox Code Playgroud)

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

还要注意,当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();

}
Run Code Online (Sandbox Code Playgroud)

这是非常合理的.而那种代码,你需要在方法而不是调用点.


Ond*_*cny 11

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

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

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

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

1.简单的实施

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

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

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

    // seen by the developer in the source code
    Process();
    
    // actually done by the compiler
    Process(new Foo());  
    
    Run Code Online (Sandbox Code Playgroud)
  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()
    {
        … 
    }
    
    Run Code Online (Sandbox Code Playgroud)

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

2.消除隐藏/意外行为

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

    Process();
Run Code Online (Sandbox Code Playgroud)

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

    Process(new Foo(HorriblyComplexCalculation(SomeStaticVar) * Math.Power(GetCoefficient, 17)));
Run Code Online (Sandbox Code Playgroud)

从读者的角度来看,这可能是相当出乎意料的,因为他没有彻底检查"过程"的声明.它使代码混乱,使其可读性降低.

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

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

    void Process(Foo arg = new Foo(Other.Assembly.Namespace.CalculateInput()))
Run Code Online (Sandbox Code Playgroud)

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

然而,交叉装配场景会强制显示从呼叫站点的普通源代码中无法清楚看到的程序集引用.这又是一个可用性和可读性问题.

  • @JohnSaunders 不,我不。然而,在花了几年的编译器和语言设计之后,我有足够的勇气做出合格的猜测:-)(我过去也必须解决同样的问题。) (2认同)