第2条:考虑用readonly代替const

C#有两种常量,一种是编译期(compile-time)的常量,另一种是运行期(runtime)的常量,它们的行为大不相同。常量如果选得不合适,那么程序开发工作可能会受影响。编译期的常量虽然能令程序运行得稍快一点,但却远不如运行期的常量那样灵活。只有当程序性能极端重要且常量取值不会随版本而变化的情况下,才可以考虑选用这种常量。

运行期的常量用readonly关键字来声明,编译期的常量用const关键字来声明:

上面这段代码演示了怎样在class(类)或struct(结构体)的范围之内声明这两种常量。此外,编译期的常量还可以在方法里面声明,而readonly常量则不行。

这两种常量在行为上面的区别可以在访问常量的时候体现出来。编译期的常量其取值会嵌入目标代码。比方说,下面这种写法:

编译成Microsoft Intermediate Language(微软中间语言,简称MSIL或IL)之后,就与直接使用字面量2000的写法是一样的:

运行期常量与之不同,如果代码里面用到了这种常量,那么由该代码所生成的IL也同样会通过引用的方式来使用这个readonly常量,而不会像刚才那样直接使用字面量2000。

这两种常量所支持的值也不一样。编译期的常量只能用来表示内置的整数、浮点数、枚举或字符串,也就是说,在初始化语句里面设定这种常量的时候,只能使用这些值来为其赋值,而且在生成IL的过程中,也只有用来表示这些原始类型的编译期常量才会替换成字面量。因此,下面这条语句是无法编译的,因为它试图用new操作符来给编译期的常量做初始化,即便初始化的是数值类型,编译器也不允许:

编译期常量只能用数字、字符串或null来初始化。readonly常量在执行完构造函数(constructor)之后,就不能再修改了,但和编译器常量不同,它的值是在程序运行的时候才得以初始化的。这种常量比编译期的常量灵活。其中一个好处在于,它的类型不受限制,例如刚才的DataTime型常量,虽然不能用const来声明,但却可以改用readonly来声明。这种常量可以在构造器里初始化,也可以在声明的时候直接初始化。

两者的另一个区别在于:readonly可以用来声明实例级别的常量,以便给同一个类的每个实例设定不同的常量值,而编译期的常量则是静态常量。

与刚才提到的两项区别相比,它们之间最为重要的区别还在于:readonly常量是在程序运行的时候才加以解析的,也就是说,如果代码里面用到了这样的常量,那么由这段代码所生成的IL码会通过引用的方式来使用这个readonly量,而不会直接使用常量值本身。这对代码的维护工作有很大影响,因为在生成IL的时候,代码中的编译期常量会直接以字面值的形式写进去,如果你在制作另外一个程序集(assembly)的时候用到了本程序集里面的这个常量,那么它会直接以字面值的形式写到那个程序集里面。

由于编译期常量的求值方式与运行期常量不同,因此,这可能导致程序在运行的时候出现不兼容的问题。比方说,在名为Infrastructure的程序集中,同时出现了用const和readonly来定义的两个字段:

而另外一个名为Application的程序集引用了这两个字段:

现在运行测试,可以看到下面这样的结果:

过了一段时间,你修改了源代码:

此时,如果你只发布新版的Infrastructure程序集,但不去重新构建Application程序集,那么程序就会出问题。你本来想看到的结果是:

然而运行之后却发现它并没有输出任何内容。for循环的起始值(StartValue)是105,这没有错,但是终止值(EndValue)却不是120,而是旧版源代码中的那个10,这是因为早前制作Application程序集时,C#编译器直接写入了10这个字面量,而没有去引用存放EndValue的那块空间。StartValue常量就不同了,由于它是用readonly声明的,因此要到运行的时候才加以解析,这使得Application程序集无须重新编译,即可看到新版的Infrastructure给该常量所设定的值。只需把新版的Infrastructure程序集安装好,就可以令所有使用StartValue常量的程序都体现出这一变化。修改访问级别为public的const常量相当于修改接口,因此,凡是使用该常量的代码都必须重新编译,而修改访问级别为public的readonly常量则相当于修改实现细节,这并不影响现有的客户端。

有的时候,开发者确实想把某个值在编译期固定下来。比方说,有个计税程序会为其他很多程序集所使用,但是该程序所用的计税方式又要随着税务规则的变化而修改。由于规则所发生的变化不一定会影响所有的算法,因此,有些程序集可能会按照自己的开发周期来更新,而未必会与这个计税程序一起更新。于是,这些算法就应该把税务规则的版本号记录下来,以便告诉使用该算法的人自己所依据的是哪个版本。该需求可以用编译期的常量来实现,以确保每个算法都能保留各自的版本号。

把税务规则的修订信息放到下面这样的类里面:

各种算法类都可以使用该类中的常量来表示自身的版本信息:

如果重新构建整个项目,那么每个算法类里面的版本号就都会变成最新的值,但如果仅以补丁的形式来更新其中的某些程序集,那么只有这些程序集里面的版本号才会变为最新值,而其他程序集则不受影响。

const常量还有一个地方要比readonly常量好,那就是性能。由于程序可以直接访问已知的值,而不用通过变量去查询,因此其性能会稍微高一些。但是,开发者需要考虑是否值得为了这一点点性能而令代码变得僵化。在决定这样做之前,应该先通过profile工具做性能测试(如果你还没有找到自己喜欢的profile工具,那么可以试试BenchmarkDotNet,该工具的网址是https://github.com/dotnet/BenchmarkDotNet)。

在使用命名参数与可选参数时,开发者也需要像面对运行期常量与编译期常量这样做出类似的权衡。可选参数的默认值是放在调用点(call site)的,这与用const所声明的编译期常量相似。因此,如果修改了可选参数的默认值,那么也需要考虑和刚才一样的问题,即修改后的效果能否正确地反映在程序中(参见本章第10条)。

const关键字用来声明那些必须在编译期得以确定的值,例如attribute的参数、switch case语句的标签、enum的定义等,偶尔还用来声明那些不会随着版本而变化的值。除此之外的值则应该考虑声明成更加灵活的readonly常量。