readonly 关键字在 C# 里算是那种“人人都见过,但未必人人都用对”的语法点。尤其是这两年,mybatisplus 和 MySQL 里的“关键字”话题火得不行,Java 那边天天讨论字段命名撞上数据库保留字怎么办,C# 这边倒是没这个烦恼——但 readonly 在并发编程、性能优化、代码可维护性上引发的讨论,一点不比数据库关键字少。我写这篇文章,就是想把 readonly 从“知道”讲到“用对”,从基础语法到底层机制、再到不同场景下的适配方案,一次讲透。
无论你是刚学 C# 没多久的新手,还是写了几年业务代码想回头补基础的老手,这篇文章都值得花十分钟看完。文章里不会有那种高高在上的理论堆砌,全部是我在实际项目里验证过的用法和踩过的坑。
1. 内容整体设计与思路拆解
先聊点宏观的。为什么一个简单的 readonly 关键字能单独写一篇文章?因为它牵涉的面实在太广了——从语法糖到 CLR 层面,从字段初始化时机到内存模型,从不可变设计模式到线程安全策略。很多人对 readonly 的理解停留在“只能赋值一次”,但真到用的时候,经常分不清它和 const 的区别,搞不懂 readonly 修饰的引用类型为什么还能改内部数据,更别提在泛型、静态字段、构造函数链这些复杂场景下直接翻车。
这篇文章的整体思路是这样设计的:
- 从最基础的定义和用法切入,确保零基础的读者也能跟上。
- 深入 IL 和 CLR 层面,讲清楚 readonly 到底是怎么实现的,为什么它和 const 有本质区别。
- 扣住 volatile、static、const 这些 C#/C 系关键字做对比,帮大家建立完整的知识网络。
- 重点分析实际开发中的应用场景,包括不可变设计、线程安全、配置对象、类型安全常量等。
- 用大量“我在项目里真实遇到过”的问题做案例,把错误示范和正确姿势摆在一起。
- 最后整理一份避坑清单和排查手册,方便大家直接“抄作业”。
这个设计思路背后有几个考量。第一,基础知识不砸实,后面全是空中楼阁;第二,只讲语法不讲机制,遇到问题还是不会排查;第三,没有对比就没有鉴别,关键字这东西最怕的就是混用错用。所以我刻意安排了对比章节和场景适配章节,这两个部分是干货密度最高的地方。
1.1 核心需求解析:为什么需要 readonly
要理解一个东西为什么存在,最好的方式是先想象没有它的世界。假设 C# 没有 readonly,你要实现“字段赋值后不允许再改”这个约束,只能靠纪律——大家约定好不修改这个字段。但人的纪律是最不可靠的,团队里总有人不小心在某个方法里给字段赋了新值,编译器不会报错,测试也不一定跑得出来,等到了生产环境,一个本应恒定不变的配置项被悄悄改了,Bug 就出现了。readonly 的核心价值就在于:把“约定”变成“编译期强校验”。
这个价值在大型项目里体现得特别明显。我自己维护过一个老项目,里面有个全局配置类,十几个字段全靠注释写着“不要修改”,结果一年内被改出了七八个难以排查的线上问题。后来我花了半天时间把关键字段全部改成 readonly,并且在构造函数里完成初始化,这样再有人想改字段值,编译器直接会报错——这比任何代码评审都管用。
1.2 适用范围与边界
readonly 能做的事情很多,但它不是万能的。它能作用于字段(实例字段和静态字段)、结构体成员、以及 readonly struct 整个类型;它不能作用于局部变量(虽然 C# 8 以后有 ref readonly 可以在一定程度上模拟),不能修饰方法参数(C# 7.2 有了 in 参数,但语义不同)。这些边界如果不搞清楚,很容易写出编译不过的代码然后一头雾水。
另外要注意,readonly 和不可变性是两回事。readonly 修饰引用类型字段,只是说“这个引用不能变”,而不是“这个引用指向的对象不能变”。这两个概念搞混的后果很严重——我在实际代码评审里见过有人因为 readonly 数组里的元素被改而紧张兮兮地来问我是不是编译器出 Bug 了,其实不是,这是语义理解的问题。所以这篇文章会把“引用不可变”和“对象不可变”作为重点区分,这是最容易踩坑的地方。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 readonly 的基础语法与赋值时机
先看最简单的用法。readonly 可以修饰字段,它有两个赋值窗口:一是在声明的时候直接赋初值,二是在构造函数里赋值。超出这两个窗口,任何写操作都会导致编译错误。
csharp复制public class OrderService
{
// 声明时赋值
private readonly IOrderRepository _orderRepository;
private readonly int _maxRetryCount = 3;
public OrderService(IOrderRepository orderRepository)
{
// 构造函数内赋值
_orderRepository = orderRepository;
}
public void UpdateMaxRetryCount(int count)
{
// 编译错误:readonly 字段不能在这里赋值
// _maxRetryCount = count;
}
}
上面这段代码是最典型的依赖注入场景。_orderRepository 在构造函数里从外部传入,之后整个生命周期内不允许替换。这种模式的好处是对象从创建那一刻起就处于完整、有效的状态,不会出现“对象建好了但依赖还没注入”的半初始化状态。
关于赋值时机,有几个很实用的经验:
- 声明时赋值适合那些与对象实例无关的默认值,比如
private readonly int _defaultPageSize = 20; - 构造函数里赋值适合需要外部传入的依赖项或配置项
- 如果有多个构造函数,每个构造函数都必须给 readonly 字段赋值(或者通过
this(...)链式调用统一处理),否则编译器会报错
csharp复制public class PaymentService
{
private readonly IPaymentGateway _gateway;
private readonly int _timeoutSeconds;
public PaymentService(IPaymentGateway gateway)
: this(gateway, 30) // 链式调用,避免重复代码
{
}
public PaymentService(IPaymentGateway gateway, int timeoutSeconds)
{
_gateway = gateway;
_timeoutSeconds = timeoutSeconds;
}
}
这种链式构造函数的写法我在实际项目中很推荐。它既保证了 readonly 字段在所有构造路径上都被正确赋值,又能避免构造函数之间复制粘贴代码。如果你用了 required 关键字(C# 11+),还可以用对象初始化器那种方式给 readonly 字段赋值,不过在老版本上就别想了,老实规规矩矩在构造函数里写。
2.2 const 与 readonly 的对比:别再傻傻分不清
const 和 readonly 的对比是 C# 面试里的经典题目,也是实际开发中最容易被混淆的一组关键字。很多新手会以为“const 就是编译期的 readonly”,这个说法不准确,而且会误导人。
直接上最核心的区别:
| 对比维度 | const | readonly |
|---|---|---|
| 赋值时机 | 声明时必须赋值 | 声明时或构造函数中赋值 |
| 存储位置 | 编译期常量,直接嵌入 IL 的元数据 | 运行期静态字段,存储在对象的静态/实例字段中 |
| 类型限制 | 只能使用内置基元类型(int、double、string、bool 等) | 任意类型,包括引用类型和自定义类型 |
| 二进制兼容性 | 修改后调用方必须重新编译 | 修改后无需重新编译调用方 |
| 内存占用 | 不占实例/静态字段内存,直接作为字面量 | 占用字段内存,是真正的变量 |
| 适用语义 | 真正永不改变的值,如圆周率 | 运行期初始化后不再改变的值,如依赖注入的实例 |
const 为什么必须是编译期常量?因为在编译包含 const 引用的代码时,编译器会把 const 的值直接“拷贝”到使用处。比如:
csharp复制public class Constants
{
public const int MaxPageSize = 100;
}
// 另一个程序集里的使用
int size = Constants.MaxPageSize;
编译第二个程序集时,Constants.MaxPageSize 会被替换为字面量 100。如果后来你把 MaxPageSize 改成了 150,只重新编译第一个程序集是不够的,第二个程序集里仍然是写死的 100——这就是经典的“改了常量调不起作用”Bug 的根源。
readonly 没有这个问题,因为它是运行期的字段读取,每次访问都是真正地从内存里读值。但注意,对于 static readonly,虽然它也是运行期的值,但 CLR 在类型初始化时只会赋一次值,之后所有人都看到同一个值。如果这个值本身是引用类型,对象内部数据变了,那所有调用方看到的值当然也会变——这就是不可变设计和 readonly 的深层关系,后面专门讲。
我个人的选择原则是:能确定值永远不会变、且是基本类型的,用 const;需要依赖外部环境(配置、依赖注入)或类型不是基元类型的,用 static readonly。这个原则在团队开发里几乎能覆盖所有场景。
2.3 static readonly 与实例 readonly 的区别
readonly 可以修饰静态字段,也可以修饰实例字段,两者的语义差别很大。
`
