readonly 和 const 的区别,不只是能不能修改

张开发
2026/6/6 1:08:03 15 分钟阅读

分享文章

readonly 和 const 的区别,不只是能不能修改
一、引言常量设计的常见误解表面差异「能否修改」只是冰山一角很多开发者对 const 和 readonly 的理解停留在constintA1;readonlyintB1;二者似乎都代表“不可修改”。于是很多面试中也会出现这样一个经典问题const 和 readonly 有什么区别得到的答案通常是const 是常量readonly 是只读字段。这样的回答虽然不能算错但远远没有触及问题本质。实际上const 解决的是编译期确定性Compile-Time Determinismreadonly 解决的是对象生命周期内的引用稳定性Reference Stability二者在 CLR 中拥有完全不同的实现方式。甚至从 IL 角度看它们都不是同一种东西。真实场景如果你做过大型项目可能遇到过下面这些问题为什么修改了public const int Timeout 30;重新发布 DLL 后调用方仍然输出旧值为什么readonly Listint ids new();仍然能够执行ids.Add(1);为什么微软官方设计指南中建议尽量避免公开暴露 public const。理解这些问题之后你会发现 const 和 readonly 根本不是同一层面的设计。二、核心差异深度解析初始化的本质虽然二者都表现为“不可修改”但初始化时机完全不同。const编译时初始化const 的值必须在编译阶段就能确定。publicconstintMaxRetry3;这是合法的。但下面的代码会直接编译失败publicconstintMaxRetry;publicDemo(){MaxRetry3;}原因很简单const 的值必须在编译阶段确定。对于编译器而言const int MaxRetry 3;本质上已经不是一个普通字段而是一个能够被直接替换的字面量。readonly运行时初始化readonly 属于运行时概念。既可以使用字段初始化器publicreadonlystringApiKeyLoadConfig();也可以在构造函数中赋值publicreadonlystringApiKey;publicDemo(){ApiKeyLoadConfig();}但一旦对象初始化结束在普通方法中再次赋值publicvoidChange(){ApiKeyNewValue;}编译器会立即报错readonly 字段只能在声明处或构造函数中赋值。因为 readonly 限制的是赋值时机而不是编译期常量。类型行为的分歧const 仅支持编译期常量类型并不是所有类型都能声明为 const。publicconstintRetryCount3;publicconstdecimalTaxRate0.13m;publicconststringAppNameDemo;这些合法因为它们属于编译期常量。常见支持类型包括bool、char、byte、sbyte、short、ushort、int、uint、long、ulong、float、double、decimal、string、enum。例如public const DayOfWeek StartDay DayOfWeek.Monday;完全合法。但下面无法通过编译publicconstDateTimeMinDatenewDateTime(2024,1,1);因为 DateTime 不是编译期常量类型即使这个对象永远不会改变也无法成为 const。new DateTime(...)必须在运行时执行。需要特别注意的类型限制细节const string可以赋值为null例如const string? s null;合法。但其他引用类型即使为null也不能声明为const例如const Uri uri null;会编译错误因为Uri不是编译期常量类型。本质原因const 要求值能在编译时完全确定且类型必须是基元类型、string或枚举任何自定义引用类型都不满足要求。readonly 支持任意类型readonly 没有这种限制。publicreadonlyDateTimeStartTime;publicreadonlyHttpClientClient;publicreadonlyListintValidIds;全部合法因为 readonly 本质仍然是字段只是增加了赋值限制而已。实例级与类型级差异除了初始化时机不同const、static readonly 与 readonly 的作用域也完全不同。类型作用域访问方式const类型级别ClassName.Fieldstatic readonly类型级别ClassName.Fieldreadonly实例级别instance.Field例如publicclassConfig{publicconstintConstValue1;publicstaticreadonlyintStaticValue2;publicreadonlyintInstanceValue;publicConfig(intvalue){InstanceValuevalue;}}访问方式Config.ConstValue;Config.StaticValue;varconfignewConfig(100);config.InstanceValue;从内存模型角度看const 和 static readonly 属于类型readonly 属于对象实例。因此所有对象共享同一个 const 或 static readonly但每个对象都可以拥有不同的 readonly 值。这也是为什么new Config(1024)和new Config(2048)能够产生不同实例状态。作用域与存储机制const 隐含 static很多开发者忽略了这一点。publicconstintMaxRetry3;虽然没有写static但Config.MaxRetry是合法的写法而new Config().MaxRetry只是编译器允许的语法糖。从设计角度看const 天生属于类型而不是对象因为它根本不需要实例存在。需要明确const 实际等价于同时拥有static和const修饰但不能显式写出static关键字。例如public const int Max 100;的效果等同于假设中的public static const int Max 100;该语法不合法但语义如此。const 字段始终是类型级别的成员永远不会属于某个实例。readonly 默认是实例字段publicreadonlyintBufferSize;访问时必须通过实例varconfignewConfig();Console.WriteLine(config.BufferSize);不同对象可以拥有不同值。当然也可以声明为public static readonly int BufferSize 1024;此时字段属于类型级别。编译时的魔法替换这是 const 最重要、也最容易被忽视的特性。假设publicconstintMaxRetry3;Console.WriteLine(Config.MaxRetry);很多人以为运行时会读取字段实际上编译器会直接替换为Console.WriteLine(3);调用方可能根本不会读取这个字段。从 IL 可以看得更明显。对于Console.WriteLine(Config.MaxRetry);生成的 IL 类似ldc.i4.3 call void [System.Console]Console::WriteLine(int32)ldc的意思是 Load Constant直接把常量压栈没有字段读取动作。而如果改成public static readonly int MaxRetry 3;IL 会变成ldsfld int32 Config::MaxRetry call void [System.Console]Console::WriteLine(int32)ldsfld即 Load Static Field运行时真正访问字段。为什么 CLR 要这样设计原因在于性能与优化。例如constintA10;constintB20;constintCAB;编译器直接计算得到30最终 IL 只是ldc.i4.s 30无需运行时参与。这带来了常量折叠、死代码消除、更简单的执行路径和更高的 JIT 优化空间。const 的核心价值是编译期优化而不是“不可修改”。三、陷阱与最佳实践版本兼容性陷阱这是最经典的生产事故来源之一。假设两个程序集程序集 ApublicconstintTimeout30;程序集 BConsole.WriteLine(Config.Timeout);编译 B 时Console.WriteLine(30);已经被写入 IL。此时程序集 B 不再依赖字段本身。后来将程序集 A 修改为public const int Timeout 60;并重新发布但没有重新编译程序集 B结果 B 仍然输出 30。这就是著名的 const 跨程序集版本陷阱。微软为什么不推荐 public const对于公共 APIpublic const int Timeout 30;一旦发布调用方就会把值嵌入自己的程序集。后续修改数值不会自动生效必须重新编译所有调用方这会导致非常隐蔽的版本问题。解决方案公共 API 推荐使用public static readonly int Timeout 30;因为 readonly 始终在运行时读取真实值调用方升级 DLL 后即可获得新值无需重新编译。动态初始化场景很多配置天然无法使用 const例如配置文件、环境变量、数据库配置、依赖注入、运行时计算等。publicreadonlystringConnectionString;publicConfig(IConfigurationconfiguration){ConnectionStringconfiguration.GetConnectionString(Default);}这是 readonly 的典型应用场景而 const 根本无法完成。集合的特殊性这是很多人的第二个认知误区。privatereadonlyListint_idsnew();很多人认为集合不能修改实际上完全错误。不能做的是_ids new Listint();因为引用发生变化。但_ids.Add(1)、_ids.Add(2)、_ids.Clear()全都合法。readonly 限制的是引用变化而不是对象状态变化。readonly 并非绝对不可修改readonly 的约束主要由编译器保证。正常代码无法再次赋值publicreadonlystringKeySECRET;// 编译错误KeyNEW;但通过反射仍然能够绕过限制这可能带来安全隐患publicclassConfig{publicreadonlystringSecretDATA;}varinstancenewConfig();varfieldtypeof(Config).GetField(Secret);field!.SetValue(instance,HACKED);Console.WriteLine(instance.Secret);// 输出 HACKED这是因为 readonly 并不是 CLR 层面的绝对不可变而是一种字段写入约束反射拥有绕过普通访问限制的能力。不过生产代码不应该依赖这种行为。这种技术主要出现在测试框架、Mock 框架、ORM、序列化框架等特殊场景。readonly 保证的是正常程序路径下的安全性而不是防御恶意代码。这与 Java 中的final ListInteger本质相同。IReadOnlyListT 与 ImmutableListT如果希望真正表达不可变语义仅仅readonly ListT远远不够。IReadOnlyListT表示调用方无法修改集合。例如public IReadOnlyListint Items _items;调用方无法执行Add()、Remove()、Clear()但底层_items.Add(...)依然可以发生。ImmutableListT来自System.Collections.Immutable才能真正做到数据不可变ImmutableListintidsImmutableListint.Empty;idsids.Add(1);这里不会修改原集合而是返回新实例。三者含义完全不同方案含义readonly ListT引用不可变IReadOnlyListT接口不可写ImmutableListT数据真正不可变使用枚举代替相关常量组当多个常量表达同一组状态时不推荐publicconstintState_Active1;publicconstintState_Inactive2;publicconstintState_Deleted3;更推荐publicenumUserState{Active1,Inactive2,Deleted3}原因在于类型安全更强、可读性更好、更容易扩展、避免常量污染命名空间。因此单个固定值适合 const一组具有关联关系的常量更适合 enum。四、实战代码综合应用示例下面是一段实际项目中比较合理的设计publicclassAppConstants{// 编译期常量publicconststringAppNameC# Core;// 运行时字段publicstaticreadonlyDateTimeStartTimeDateTime.Parse(2024-01-01);// 依赖注入publicreadonlyDbConnectionConnection;// 安全只读暴露publicstaticreadonlyIReadOnlyListstringCountriesnewListstring{US,CN}.AsReadOnly();publicAppConstants(DbConnectionconnection){Connectionconnection;}}这段代码同时体现了const 用于真正编译期常量static readonly 用于公共共享数据readonly 用于依赖注入对象IReadOnlyList 用于安全暴露集合。五、总结选型指南场景首选原因永远不会变化的编译期常量const支持编译期优化运行时动态初始化readonly灵活且安全公共 API 常量static readonly避免版本兼容问题复杂类型readonlyconst 无法支持真正不可变集合ImmutableListT数据不可变核心认知const 关注的是Value值readonly 关注的是Identity引用enum 关注的是Domain领域状态。三者解决的是完全不同的问题const编译期常量readonly生命周期内的引用稳定性enum业务状态建模理解这一点才能在实际项目中正确选择它们。示意图设计建议图一const 与 readonly 的 IL 对比const │ ▼ literal │ ▼ ldc.i4.3 │ ▼ 直接使用字面量 ------------------ readonly │ ▼ initonly │ ▼ ldfld / ldsfld │ ▼ 运行时读取字段图二跨程序集版本陷阱Library v1 │ ▼ public const Timeout 30 │ ▼ App 编译 │ ▼ 写入字面量 30 │ ▼ Library v2 │ ▼ public const Timeout 60 │ ▼ App 未重新编译 │ ▼ 仍然输出 30公共 API 优先使用 static readonly 而不是 public const——对于库设计者来说版本兼容性往往比那一点点编译期优化更重要。

更多文章