面试过不少人,也被别人面过很多次。我观察到“常量和只读变量的区别”这道题有个很有意思的现象:候选人无论工作年限是两年还是八年,第一句话基本都是“const是编译期常量,readonly是运行时常量”,听起来稳得一批。但只要我顺着往下追一句“const在编译之后到底变成了什么”,或者“const和static readonly在跨程序集引用时有什么能出问题的差异”,现场就会安静下来。
这道题之所以常驻面试题库,正是因为它从表面看只是一个语法区别,往下挖却能扯出编译原理、CLR元数据、程序集版本管理、设计取舍一整条链路。今天不打算给你一份干巴巴的对比清单,而是想用一篇完整文章把这层的逻辑说透,也把我在真实项目里因const吃过的亏一并交代了。
1. 面试官反复问这道题,背后在考察三个层次
1.1 第一层:语言基础到底扎不扎实
const和readonly都是C#里限制变量再赋值的关键字,但它们的定位完全不同。最基础且必须脱口而出的结论是:
- const是编译期常量,值在编译时就被确定并内联,只能修饰编译期可求值的类型。
- readonly是运行时常量,更准确地说是“只读字段”,值在运行时确定,可以在声明处或构造函数中赋值。
如果候选人连这一层都说不利索,那基本不用往下聊了。这道题在初级面试里首要任务就是筛选“有没有写过正经C#代码”的人。你要是把“常量”和“只读变量”当成一个东西,说明你连日常coding的基础积累都不够。
1.2 第二层:是否理解编译期与运行时的边界
这一层是拉开差距的关键。面试官会问:
- “为什么const只能修饰基元类型和string,不能修饰DateTime?”
- “const在编译后,IL里到底长什么样?”
- “readonly字段在构造函数里赋值和声明时初始化有什么区别?”
- “我有一个类库,里面定义了const,我改了它的值,只重新编译类库,下游程序会马上拿到新值吗?”
一旦涉及到这些问题,靠背诵面试题是过不了关的。你需要对“编译期求值”“内联”“元数据常量表”“字段存储”这些底层概念有真实理解,才能答到位。这一层考察的是候选人平时写代码是浮在语法表面,还是真的会去想“这段代码最终编译成了什么”。
1.3 第三层:有没有在真实项目里做取舍判断的经验
工作过一段时间的人都知道,代码里到处都是const和readonly,但很多人从来没想过为什么这里用const、那里用readonly。面试官考察这一层,是看你的工程判断力:
- 如果这个值在程序发布后绝对不可能变化,比如圆周率、字节数转换因子,用const没问题。
- 如果这个值可能依赖配置文件、依赖环境、依赖构造参数,或者未来存在变更的可能性,用readonly。
- 如果这个值在一个被大量项目引用的公共类库里,修改它是否会影响下游程序集?这种时候readonly往往是更安全的选择。
能聊出这一层的人,面试官基本会认为你有架构层面的意识,而不是只会在类里堆字段。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. const在编译期做了哪些事,readonly在运行时又在等什么
2.1 const成员被编译成literal字段,引用处被替换成字面量
很多人知道const在编译期会被替换,但不清楚替换的粒度。我直接上一段代码和它的IL对比。
csharp复制public class Config
{
public const int MaxRetryCount = 5;
public static readonly int TimeoutSeconds = 30;
}
上面这个类编译后,你打开IL,会看到两个字段的定义完全不一样:
il复制.field public static literal int32 MaxRetryCount = int32(0x00000005)
.field public static initonly int32 TimeoutSeconds = int32(0x0000001E)
注意区别就两个词:literal和initonly。
- literal标记的字段不会在运行时占用任何存储空间,它的值写在元数据的常量表中,属于编译期字面量。
- initonly标记的字段表示这个字段只允许在构造函数中赋值,它是一块真实的字段存储空间。
光看定义还不够,关键是引用处的区别。假设另一个类里写了这样一行代码:
csharp复制int a = Config.MaxRetryCount;
int b = Config.TimeoutSeconds;
编译后的IL是这样的:
il复制// 常量内联:直接把5压栈,没有任何字段访问指令
ldc.i4.5
// 普通字段访问:先加载类的静态字段存储地址,再取字段值
call int32 Config::TimeoutSeconds
这就是“常量内联”的本质:使用const的地方,在编译完成后已经看不到“Config.MaxRetryCount”这个符号了,取而代之的是赤裸裸的字面量5。而readonly在使用它的地方,仍然保持了一次真实的字段读取。
这里顺便提一下热搜词里出现的“常量传播”。C#编译器不仅会把单独的const替换掉,还会对包含const的表达式做编译期折叠,也就是常量折叠。比如:
csharp复制public const int BasePort = 8080;
public const int RetryPort = BasePort + 100; // 合法,编译期结果就是8180
int target = BasePort + RetryPort;
// 反编译后你甚至看不到BasePort和RetryPort,直接是 int target = 16260;
编译器在编译期完成了所有算术运算,把结果直接内联。这种特性在条件编译、协议设计、数学常量计算里都很实用,但也容易让不熟悉的人误以为const可以参与复杂逻辑,实际上它只能使用编译期能算出结果的表达式。
2.2 readonly的初始化窗口只有两个:声明处和构造函数
readonly字段在IL中的本质是一个真实字段,加上initonly标记。它的赋值时机被严格限制:要么在字段声明处直接初始化,要么在构造函数里赋值。
csharp复制public class Order
{
private readonly string _orderId;
private readonly DateTime _createTime;
public Order(string orderId)
{
_orderId = orderId;
_createTime = DateTime.Now;
}
}
对于实例readonly字段,它随实例分配在托管堆上,每个对象各存一份。对于static readonly字段,它只存在一份,在类型首次被访问时完成初始化。这一点让static readonly非常适合承载“需要运行期计算一次的全局配置”。
readonly的本质约束是:离开构造函数后,这个字段的值不可再变。这里的“不可再变”指的是不能给字段重新赋值,但需要注意,它并不意味着字段指向的对象内容不可变。这一条陷阱我在第4部分会专门展开。
2.3 核心差异速查表
| 比较维度 | const | readonly |
|---|---|---|
| 赋值时机 | 声明时必须赋值 | 声明时或构造函数中赋值 |
| 求值时机 | 编译期 | 运行时 |
| 类型限制 | 基元类型、string、枚举,引用类型只能为null | 任意类型 |
| 实例/静态 | 隐式静态,不能修饰实例字段 | 可以是实例字段,也可以是静态字段 |
| 存储方式 | 不占用字段存储,值存于元数据常量表 | 真实字段,分配在类型或实例的存储空间 |
| 访问方式 | 编译期直接替换为字面量 | 运行时字段读取 |
| 是否可以用于switch case | 可以 | 不可以 |
| 是否可以用于attribute参数 | 可以 | 不可以 |
| 是否可以用于可选参数默认值 | 可以 | 不可以 |
| 跨程序集更新值 | 需要重新编译所有引用程序集 | 引用程序集无需重编译也能读到新值 |
| 性能 | 无运行时读取开销 | 有字段读取开销(实际差异可忽略) |
这张表基本上可以当作复习提纲。建议你把它背下来,不是死记硬背,而是要能对着每一行解释出背后的原因。下一节我就挑几个最值得深挖的点说透。
3. 从IL的literal和initonly看版本兼容隐患
3.1 const值被内联后,下游程序集只认旧值
这应该是const在生产环境中最容易炸的坑,也是面试官最喜欢追问的高级点。
假设你有两个项目:
csharp复制// Library项目,AssemblyA
namespace SharedLib
{
public class RetryPolicy
{
public const int MaxRetry = 3;
}
}
csharp复制// Application项目,AssemblyB,引用了AssemblyA
using SharedLib;
class Program
{
static void Main()
{
Console.WriteLine(RetryPolicy.MaxRetry);
}
}
AssemblyB在编译时,编译器看到RetryPolicy.MaxRetry是一个const,直接在IL里写死成了3。这个程序编译完成之后,AssemblyB和AssemblyA之间就解除关系了,至少对访问MaxRetry这一句来说是如此。
接下来我改一下Library:把MaxRetry从3改成5,重新编译发布AssemblyA。但是在不重新编译AssemblyB的前提下,把新的AssemblyA放到程序目录里。运行AssemblyB,它会输出什么?
输出3。
很多人第一次遇到这个问题都以为是部署出了问题,会去检查dll有没有更新、路径对不对、缓存有没有清。最后发现代码没问题、部署没问题,只因为const在AssemblyB编译时已被内联为字面量3,新的AssemblyA里那个常量值根本没机会参与运行。
如果这里用的是static readonly:
csharp复制public static readonly int MaxRetry = 3;
AssemblyB编译时不会内联这个值,IL里会生成一次对RetryPolicy.MaxRetry字段的真实读取指令。程序运行时,JIT看到的是当前已加载的AssemblyA中的实际值,因此把MaxRetry改成5后,只要正确替换AssemblyA,不改AssemblyB的代码,也能读到新值。
这就是我常说的const版本兼容问题。看起来只是一个小语法知识,但放到微服务、公共组件、公司内部NuGet包这些场景里,它就是一次线上事故的根源。
3.2 为什么stdcall、PInvoke场景也容易踩这个坑
热搜词里有一条“c# dll调用c/c++ dll,报错system.accessviolationexception”,刚好我可以顺带说一句。
在C#调用C++ DLL的时候,经常需要定义一些常量,比如方法序号、参数长度、协议版本号。如果你图省事用const,然后在调试器里发现“明明改了C++那边的常量,C#这边传过去的还是旧值”,不用惊讶——C#这边很可能在编译时就已经把它内联了,和C++ DLL那边没关系。这种问题排查起来很费劲,因为报错往往是AccessViolationException这类看不懂的运行时错误。
所以我在写PInvoke调用层时,凡是会引用外部DLL约定值的字段,一律用static readonly,至少把“值被内联”这个变量排除掉,这样排查问题时会省很多时间。
3.3 const常量值在元数据常量表中,readonly字段是普通字段存储
const和readonly在存储上的差异,也可以从反射的角度观察。
C#的FieldInfo提供了一个方法叫GetRawConstantValue(),专门用来读取const字段在元数据常量表中的原始值。这个方法对const有效,对readonly字段调用会抛出异常。
csharp复制var constField = typeof(RetryPolicy).GetField(nameof(RetryPolicy.MaxRetry));
var constValue = constField.GetRawConstantValue();
Console.WriteLine(constValue); // 输出3
var readonlyField = typeof(RetryPolicy).GetField(nameof(RetryPolicy.TimeoutSeconds));
// 下面这行会抛异常:FieldInfo.GetRawConstantValue 只支持常量字段
var readonlyValue = readonlyField.GetRawConstantValue();
这个点可以作为面试时的加分项。当你能从反射API的差异反推出“const的值是元数据的一部分,readonly的值是运行时字段内容”时,面试官基本能判断你是真的读过编译后的产物,而不是背结论。
3.4 内存与性能:被夸大成本差异
我见过不少文章把const和readonly在性能上的差异放大看,甚至有人为了“省一次字段读取”把所有常量都定义为const。实际上,在绝大多数业务系统里,这种所谓的性能差异完全可以忽略。
一次直接内联的字面量比一次静态字段读取能省下的时间,大概在纳秒量级。除非你在一个超高帧率的游戏循环里反复读取同一个字段,或者在做底层数值计算库,否则这点差异不会成为瓶颈。真正要关注的不是性能,而是语义和版本行为。
但如果真的谈性能,const还有一层很多人没意识到的字符串开销。const string虽然也是编译期常量,但它的值在运行时也并非完全零成本。引用const字符串的地方,编译器会生成ldstr指令,这个指令会让CLR去字符串驻留池里查找或创建对应的字符串对象。也就是说,const string在运行时依然有字符串驻留和对象引用的开销,只是这个开销被CLR优化得比较小罢了。
至于readonly,它是一次普通字段读取,从正确性角度看,它才是最符合“字段”直觉的声明方式。
4. CS0133、类型限制和其它绕不开的边界条件
4.1 表达式必须含有常量值:最大的编译期提示牌
热搜词里有一条“表达式必须含有常量值”,这正好是const最常见的编译错误CS0133。
什么时候会触发?答案很简单:你试图把一个运行时才能求值的表达式赋值给const字段。
csharp复制public class Config
{
// 编译错误 CS0133: 表达式必须含有常量值
public const int CurrentYear = DateTime.Now.Year;
// 编译错误 CS0133
public const int RandomSeed = new Random().Next();
// 正确做法:改成 static readonly
public static readonly int CurrentYearReadonly = DateTime.Now.Year;
public static readonly int RandomSeedReadonly = new Random().Next();
}
注意,DateTime.Now.Year在语法上是一个属性访问,它依赖运行时时钟,所以不能是常量表达式。很多初学者会想“年份虽然每年变,但也算一个固定值”,这种想法恰恰是把编译期和运行时混为一谈了。
编译器判断常量表达式的规则是:表达式中所有项都必须是编译期可确定的字面量、const成员、枚举值或上述内容组成的运算。一旦出现方法调用、属性访问、new表达式,就立刻判定为非常量。
这个报错其实是在保护你的代码库。如果一个const字段的值要依赖运行时环境,说明它压根不符合“编译期常量”的定位,编译器拦下来是对的。
4.2 为什么const不能修饰DateTime,却能修饰string
这是面试里最经典的质疑式提问。既然const只能修饰基元类型,为什么string可以?string明明是引用类型。
答案是:C#编译器对const类型有专门清单,其中string被单独豁免。你可以把const type理解为“CLI中能够在编译期编码为元数据字面量的类型”。CLI的常量表(Constant表)支持的类型包括数值类型、bool、char和string。DateTime尽管内部只是一个long,但没有对应的元数据常量编码方式,编译器无法把一个DateTime对象写进常量表,所以它不能做const。
而string是一个不可变引用类型,编译器可以把字符串内容以UTF-16形式存到元数据的#US堆或#Blob堆中,在引用处直接替换为ldstr指令。所以string虽然本质是引用类型,但凭借不可变性和CLI的元数据支持,成了const里的特例。
还有另一个细节:const可以修饰任何引用类型,但值只能是null。
csharp复制public const object NullObject = null; // 合法
public const string FullName = "Tom"; // 合法,string专属豁免
你把一个引用类型const声明成null是合法的,因为null在编译期是可确定的。但除了string之外,其他引用类型的非null值都没办法在编译期构造出来,所以等于不能用。这一点可以作为冷门知识点储备。
4.3 static readonly和const的混淆点
有一个高频误区是:const是隐式静态的,所以“static const”和“static readonly”容易让人迷糊。
先明确一个事实:C#禁止你显式写static const,会报CS0673错误。这是因为const已经隐式包含了static语义,不允许再显式标注。所以你在类里看到的所有const,都属于类型本身,而不是某个实例。
这样带来的结果是:const的访问方式跟static readonly很像,都是通过类型名访问。但区别在于static readonly还可以加static关键字,它是真正的静态字段。
另外一个容易混淆的点是:实例readonly和static readonly行为不同。实例readonly是每个对象一份,static readonly是整个类型一份。很多只背“readonly可以修饰实例字段”这个结论的人,没有意识到static readonly也是readonly的合法形态,在面试中被问到“static readonly可以用在哪”时会愣住。
4.4 const在switch、attribute、可选参数中的强制地位
为什么C#要求switch的case标签必须是常量?
因为switch在编译期会被优化成跳转表(jump table)或者查找表,这些表的数据结构布局要求每个case的值在编译期就能确定。如果允许运行时变量,编译器就无法生成静态的跳转结构。所以case标签只能是const、枚举值或字面量。
attribute参数也要求编译期常量。特性在运行时通过反射读取,但特性实例的元数据需要嵌入到目标类型的元数据中,因此参数值必须能被编码到元数据里。你可以传入const、typeof表达式、枚举值,但传入一个static readonly字段会直接报错。
可选参数默认值也一样。C#的可选参数默认值在调用点编译时会被内联到调用代码里,因此必须是编译期常量。拿readonly字段做默认值,编译器直接不认。
这些约束都在说同一件事:当编译器需要在编译期把值“固化”到代码或元数据里时,只有const能胜任。
4.5 readonly的引用类型陷阱:能改内容,不能改引用
readonly的实际语义是“字段本身不能再赋值”,而不是“对象内容不可变”。这一点在面试里经常被拿出来拷问。
csharp复制public class Cache
{
public static readonly int[] CacheSizes = { 16, 32, 64, 128 };
public static readonly List<string> Blacklist = new List<string> { "bad" };
}
下面两种操作,一个是合法的,一个是非法的:
csharp复制Cache.CacheSizes[0] = 64; // 合法!数组元素不是readonly
Cache.CacheSizes = new int[10]; // 编译错误 CS0191
Cache.Blacklist.Add("worse"); // 合法!List内容可以修改
Cache.Blacklist = new List<string>(); // 编译错误 CS0191
也就是说,readonly只是把“指向某个对象的引用”锁死了,但对象内部的字段、属性、集合元素都可以继续修改。如果你想做真正不可变的数组,C#里没有原生的readonly数组,得用ReadOnlyCollection<T>或ImmutableArray<T>,或者用数组副本。
这个点在并发编程里很关键。static readonly引用本身是线程安全的,但它指向的List如果同时被多个线程修改,该加锁还是要加锁。
5. 项目中的选型标准,以及我踩过的const发布事故
5.1 一次真实的const发布事故
几年前我维护过一个基础架构库,里面定义了一堆和外部服务端约定好的配置常量,比如超时上限、报文版本号、最大连接数。当时图省事,全部声明为const,理由是“这些值都是硬性约定,不会变”。
后来外部服务端升级,报文版本号从1变成2。我只改了基础库里的const,重新发布基础库。结果运行旧业务程序的时候,发出去的消息还是版本1,服务端直接拒绝。排查了一整天才找到原因:业务程序在编译时,已经把所有引用该const的地方内联成了1,就算运行时加载了新版基础库也没用。
那次之后我给自己定了一条规矩:凡是放在公共组件、被多个项目引用的配置值,一律用static readonly,禁止使用const。const只保留给“这个值在程序发布后绝对不可能变化”的局部使用场景。
这是一次用真实故障换来的教训,正好对应了第3章讲的版本兼容隐患。如果你在面试中能讲出一个类似的亲身经历,效果会比背定义好很多。
5.2 我现在的选型判断标准
经历那次事故后,我总结出了一套项目里实际使用的选型标准,不一定适合所有团队,但至少能帮你减少踩坑。
适合使用const的场景:
- 数学常数,比如圆周率、角度弧度转换因子。
- 语义上永不变化的固定值,比如字节数(1024)、协议版本等,且使用范围限在本程序集或可同步重编译的团队内。
- switch case标签、attribute参数、可选参数默认值等语法强制要求常量的地方。
- 枚举值本身(枚举成员也属于编译期常量)。
适合使用readonly的场景:
- 值依赖配置、环境变量、命令行参数、当前时间或任何运行时计算。
- 值所在程序集会作为NuGet包、公共库、插件被其他程序集引用,并且未来有变更预期的。
- 需要修饰任意类型,包括自定义类和结构体。
- 每个实例有独立值的情况,比如订单号、创建时间,这时必须使用实例readonly字段。
- 希望避免“内联导致下游程序集值被冻结”的跨程序集边界的场景。
一句话总结:能确定永远不变且不需要跨程序集热更新的值,用const;其他情况用readonly。
5.3 这道题的高分回答样本
最后给准备面试的人一份可直接用的回答结构,面试官问“常量和只读变量的区别”时,可以按这个顺序说:
先给结论:const是编译期常量,在编译时被内联替换,默认静态,只能修饰基元类型和string,不能在构造函数里赋值;readonly是运行时只读字段,在声明处或构造函数里赋值,可以是实例字段也可以是静态字段,可以修饰任意类型。
再说深层差异:const对应IL里的literal字段,值存在元数据常量表中,使用处被替换为字面量;readonly对应initonly字段,是真实字段存储,运行时读取。
然后说版本隐患:const如果跨程序集引用,值会在编译时内联到下游程序集,修改常量所在程序集后不重编译下游,旧值不会变化;readonly是运行时字段读取,避免了这个问题。
最后补充边界细节:const可以用于switch、attribute参数、可选参数默认值,readonly不行;readonly引用类型只能保证引用不可变,不能保证对象内容不可变;const不能修饰DateTime,因为DateTime不在编译期可编码的元数据字面量类型清单中,而string因为是不可变引用类型且CLI支持其元数据编码,所以是特例。
说完这套,面试官再想深挖,基本就是顺着程序集版本方向继续聊,你只要基于这篇文章的内容都能接得住。
我在实际项目里的习惯是:默认情况下,能用static readonly的地方绝不用const。这个习惯让我避开了不止一次线上问题。或者换个角度说,const只服务于那些真正不可变、且不会跨程序集变更的语义。读懂这两者的区别,不只是为了应付面试,更多是让你在写代码时能提前预判到三个月后可能的维护场景。
