为什么C#限制可以声明为常量的类型集合?

37

编译器错误CS0283表示只有基本的POD类型(以及字符串、枚举和空引用)可以声明为const。有人能解释一下这种限制的理由吗?例如,希望能够声明其他类型的const值,比如IntPtr。

我认为,在C#中,const的概念实际上是语法糖,它只是在编译时将名称的任何引用替换为文字值。例如,对于以下声明,任何对Foo的引用都将在编译时替换为"foo"。

const string Foo = "foo";
这可能会排除任何可变类型,所以也许他们选择了这个限制,而不是在编译时确定给定类型是否可变?
6个回答

36
C#规范,10.4章 - 常量

常量是表示常量值的类成员:可以在编译时计算的值。

这意味着您只能使用完全由文字量组成的表达式。任何调用任何方法、构造函数(无法表示为纯IL字面值的)都不能使用,因为编译器没有执行它的执行方式,从而计算结果,就没有办法在编译时进行计算。另外,由于没有办法将方法标记为不变的(即输入和输出之间存在一对一映射),编译器唯一的做法就是分析IL,看是否依赖于除了输入参数之外的其他内容,特殊处理某些类型(如IntPtr),或禁止对任何代码的每个调用。

例如,IntPtr虽然是值类型,但仍然是结构,而不是内置文字。因此,任何使用IntPtr的表达式都需要调用IntPtr结构中的代码,而这就是常量声明中不合法的内容。

我能想到的唯一的合法常量值类型示例是仅通过声明以零初始化的类型,这几乎没有用处。

至于编译器如何处理/使用常量,它将在代码中使用计算的值代替常量名称。

因此,您会得到以下效果:

  • 在此位置编译的代码中没有对原始常量名称、声明它的类或命名空间的引用
  • 如果反汇编代码,它将具有魔术数字,只是因为原始对常量的“引用”,如上所述,不存在,只有常量的值
  • 编译器可以使用这个来优化,甚至删除不必要的代码。例如,if (SomeClass.Version == 1),当SomeClass.Version的值为1时,实际上将删除if语句,并保留正被执行的代码块。如果常量的值不是1,则整个if语句及其块将被删除。
  • 由于常量的值编译到代码中而不是引用常量,因此从其他程序集使用常量不会自动更新已编译的代码,如果常量的值发生更改(它不应该!)

换句话说,具有以下情况:

  1. 程序集A 包含名称为“版本”的常量,其值为1
  2. 程序集B 包含一个表达式,该表达式分析程序集A的版本号并与1进行比较,以确保它可以使用该程序集
  3. 有人修改程序集A,将常量的值增加到2,并重新构建A(但不包括B)

在这种情况下,程序集B 在其编译形式中仍将比较1的值与1,因为当B被编译时,常量的值为1。

事实上,如果这是从程序集A 到程序集B 中任何内容的唯一用法,则程序集B 将被编译而无需依赖于程序集A。执行包含该表达式的代码的程序集B 不会加载程序集A。

因此,常量应仅用于永远不会更改的东西。如果它是可能或将来会更改的值,而您无法保证所有其他程序集同时重构,则只读字段比常量更合适。

public class Test
{
    public const decimal Value = 10.123M;
}

我们来看看使用ildasm工具查看这个类真正的样子:

.field public static initonly valuetype [mscorlib]System.Decimal X
.custom instance void [mscorlib]System.Runtime.CompilerServices.DecimalConstantAttribute::.ctor(int8, uint8, uint32, uint32, uint32) = ( 01 00 01 00 00 00 00 00 00 00 00 00 64 00 00 00 00 00 ) 

让我为您详细解释一下:

.field public static initonly

对应于:

public static readonly

没错,const decimal 实际上是一个 readonly decimal

真正的问题在于编译器会使用 DecimalConstantAttribute 来实现它的魔法。

我只知道这是 C# 编译器中唯一的这样的魔法,但我认为值得一提。


1
显然你不能这样做,但是假设我们有一个返回值类型的静态方法,它被标记为[Pure]属性,并且编译器实际上执行了它(目前并没有)。只要参数仅仅是字面量,语言设计者至少在理论上可以允许用静态值类型返回纯方法(具有实际的[Pure]属性强制执行)初始化const吗?他们似乎可以运行它与字面参数,得到显然不会改变的结果并替换值。我有什么疏忽吗? - KatDevsGames
在这种情况下,汇编B在其编译形式中仍将将值1与1进行比较,因为在编译B时,常量的值为1。今天在任何C#版本中,您仍然可以使用“public const int AssemblyVersion = 1”来执行相同的操作,因此结构本身没有问题。 - Alex Zhukovskiy

1
我相信在C#中,const这个概念实际上只是一种语法糖,它仅仅用字面值替换了变量名的任何使用。
那么在其他语言中,编译器对const对象会做什么呢?
对于可能在运行时被评估的可变类型,你可以使用readonly关键字。详情请参考本文以了解它们之间的区别。

我猜你可以使用Reflector来检查这个。 - BuddyJoe
关键字const在不同的语言中可能会产生不同的效果,例如在C++中它可以用来保证方法不改变对象的状态。 - Lasse V. Karlsen
@LasseV.Karlsen,但是对于非引用类型,const在C(++)和C#中确实意味着相同的事情。因此,在讨论这个问题时,这个答案是正确的。其他语言也有“const正确性”的概念。在C#中最接近的类比是不可变模式,结合由构造函数初始化的readonly类成员。 - binki

1
有谁对这种限制背后的理由有什么理论吗?
如果允许只是一种理论,那我的理论是原始类型的const值可以在MSIL的文字指令参数中表达...但其他非原始类型的值不能,因为MSIL没有语法来将用户定义类型的值表示为文字。

那似乎是可能的,是的(也请参阅我对@IAmCodeMonkey的评论)。 - Charlie
MSIL 通过一系列字节来定义常量。例如,对于像 struct Vector2d {public double X,Y;} 这样的类型,编译器可以允许声明一个常量 const Vector2d UnitXVector = {X=1.0, Y=0.0};,因为它可以确定该结构所表示的确切字节序列。然而,这仅适用于没有私有字段的结构;如果一个结构使用公共属性和私有后备字段,则编译器只能通过弄清楚这些属性的确切作用来构造它。 - supercat
@supercat,您是在暗示MSIL可以依赖于对象在内存中以某种特定的方式布局吗?StructLayoutAttribute 的存在难道不意味着它不能这样做吗? - binki
1
@binki:实际上有一个更大的问题,那就是使用结构的一个版本编译常量的代码,如果在不同版本的该结构下运行,则可能产生意外结果。微软可以保证永远不会更改Decimal类型,以使一个Decimal值所封装的字节序列可能在将来封装不同的字节序列,但对于大多数类型来说,无法做出这样的保证。 - supercat
@supercat但是他们可以通过确保CLR本身不会崩溃来解决这个问题,如果编译器序列化版本的结构体的byte []的长度不匹配-我想象这种ABI中断基本上与父类从调用继承类的构造函数中删除参数时相同。 C#并不试图让它变得困难。而且,更重要的是,已经有文档,讨论和理解readonly static通常比const更好(但对于internalAPI的默认方法参数!)听起来这一切都是关于“成功之坑”的。 - binki
@binki:如果编译器序列化版本的结构长度不匹配,则CLR能够以产生所需行为的方式运行程序的可能性并不大;即使长度正确,版本之间的布局仍然存在风险。在我看来,为了让编译器愿意序列化一个结构体,它应该被标记为“这个结构体的布局绝对不会改变”。 - supercat

0
简而言之,所有的简单类型、枚举和字符串都是不可变的,但结构体不是。你可以有一个带有可变状态(字段、属性,甚至是引用类型的引用)的结构体。因此,编译器无法在编译时确保结构体变量的内部状态不会被改变。所以编译器需要确保一个类型在定义上是不可变的,才能在常量表达式中使用。

0

在我看来,只有值类型可以被表达为常量(字符串除外,它们介于值类型和对象类型之间)。

这对我来说是可以接受的:对象(引用)必须在堆上分配,但常量根本不需要分配(因为它们在编译时被替换)。


1
字符串可以使用,因为编译器可以插入IL代码来加载具有给定值的字符串。由于这是专门为字符串添加的IL魔法,所以它们可以使用。 - Lasse V. Karlsen

-1

在C#中,常量仅限于数字和字符串,因为编译器会将变量替换为MSIL中的字面值。换句话说,当你写下以下代码时:

const string myName = "Bruce Wayne";
if (someVar == myName)
{
   ...
}

在编程中通常被视为

if (someVar == "Bruce Wayne")
{
   ...
}

是的,C#编译器足够智能,可以将字符串的等号操作符(==)作为相等比较处理。

string1.Equals(string2)

对于我不理解的是,为什么你不能声明一个const IntPtr,它在使用点也可以被替换。也许区别在于IntPtr必须被构造,而POD和字符串可以直接在IL中表示。 - Charlie
编译器必须执行IntPtr中的代码才能获得最终值,而这个“执行代码以获取值”的部分必须编译到使用常量的代码中,而这就是不允许的部分。 - Lasse V. Karlsen

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接