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 可以修饰静态字段,也可以修饰实例字段,两者的语义差别很大。
csharp复制public class AppConfig
{
// 静态 readonly:所有实例共享同一个值,只能初始化一次
public static readonly string AppName = "OrderSystem";
// 实例 readonly:每个对象实例有自己的值,只能通过构造函数赋值
public readonly string TenantId;
public AppConfig(string tenantId)
{
TenantId = tenantId;
}
}
static readonly 最常见的用途是实现“单例”和“全局配置”。它在类型第一次被访问时由 CLR 负责初始化(这就是 beforefieldinit 和类型初始化器的微妙区别,感兴趣可以查一下,简单说就是静态构造函数和静态 readonly 字段的组合会影响类型初始化时机)。
实例 readonly 则相反,它绑定了对象的生命周期。比如多租户应用里,每个订单服务实例绑定一个租户 ID,这个 ID 在构造时确定后就再也不变了——这就是实例 readonly 的典型场景。
这里有个经验之谈:能用 static readonly 的就不要用const来做“魔法数字”语义,因为 readonly 的版本兼容性更好。反过来,如果只是类内部的私有常量,用 const 也没毛病,反正是内部使用,重编译就能解决。
3. 底层机制深度解析:readonly 在 IL 和 CLR 里是怎么工作的
3.1 readonly 背后的 IL 差异
很多 C# 程序员写了几年代码也没打开过 IL 查看器,这其实很可惜。readonly 在 IL 层面和普通字段的区别,一句话就能说清:readonly 字段被标记了 initonly 标志。这个标志告诉 CLR:该字段只能在构造函数或初始化器中赋值,其他任何写入行为都是非法的。
看一个实例。假设代码是这样:
csharp复制public class Example
{
private readonly int _value;
public Example(int value)
{
_value = value;
}
public int GetValue() => _value;
}
对应的 IL 里,_value 字段定义是这样的:
il复制.field private initonly int32 _value
注意看 initonly 这个标志。没有 readonly 的普通字段,这里就是 field private int32 _value,没有 initonly。就这么一个标志,在 CLR 的验证阶段就会被执行引擎严格检查——任何尝试对 initonly 字段写入非构造函数上下文的代码,都会被拒绝,甚至不是编译期拒绝,是运行时的验证错误。
这也是为什么有人可以通过反射修改 readonly 字段的值却又不稳定:反射可以绕过编译器检查去 SetValue,但 JIT 可能已经基于“该字段永不改变”做了优化,你把值改了反而会出现不符合预期的行为。所以千万别用反射去改 readonly 字段,这是在跟 CLR 的优化机制对着干。
3.2 readonly 对 JIT 优化的影响
readonly 不只是提供编译期保护,它还给 JIT 提供了优化的线索。因为 JIT 知道 initonly 字段在构造后不会被修改,它就可以在某些场景下做更大胆的优化,比如避免重复读取内存。
举一个典型例子:
csharp复制public readonly struct Point
{
public int X { get; }
public int Y { get; }
}
public class Renderer
{
private readonly Point _origin;
public void Draw()
{
// JIT 知道 _origin 在构造后不会变,可以安全缓存
var ox = _origin.X;
var oy = _origin.Y;
// 连续多次使用 ox、oy 而不必重新从内存加载
}
}
当然,这里我要说实话:现代 JIT 对普通字段也有“不变性推测”,不是说没有 readonly 就一定反复读内存。但 readonly 让 JIT 的推测有了依据,在频繁访问的热路径上确实可能会带来微小的性能提升。这不是主要卖点,但确实是一个额外的 bonus。
不过要特别提醒一句:readonly 对性能的影响通常远小于对代码安全性和可维护性的影响。不要为了性能去到处加 readonly,要为了语义正确和防止误改去加,这样你的代码才真正受益。
3.3 readonly struct 与只读成员的底层支持
C# 7.2 引入了 readonly struct,这是对 readonly 家族的一个重大扩展。它约束整个结构体是不可变类型:所有实例字段都必须是 readonly,所有自动属性都必须是只读属性。
csharp复制public readonly struct Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
Amount = amount;
Currency = currency;
}
// 这个方法不会修改任何字段,编译器会强制约束
public Money Add(Money other)
{
if (Currency != other.Currency)
{
throw new InvalidOperationException("Currency mismatch");
}
return new Money(Amount + other.Amount, Currency);
}
}
为什么 readonly struct 在 C# 7.2 以后变得那么重要?核心原因是性能。结构体是值类型,赋值和传递时默认会整体拷贝。如果一个结构体被 readonly 修饰,JIT 就知道它在生命周期内不可变,在很多场景下可以避免“防御性拷贝”。比如 in 参数传递时,如果传入的是 readonly struct,JIT 可以放心地直接传递引用而不是拷贝整个结构体,这在涉及大结构体的高性能场景下可能带来明显的性能提升。
我曾经参与过一个图像处理项目,里面有个 PixelData 结构体,128 字节大小。改成 readonly struct 并用 in 参数传递后,热点路径的性能提升了 20% 左右。当然这需要同时放宽心去接受不可变约束,但在大批量数值计算的场景下非常值。
需要注意的是,readonly struct 是整体约束,不能只对部分成员生效。如果只想让结构体里的某个属性是只读的,可以用 readonly 修饰属性本身(C# 8+):
csharp复制public struct Temperature
{
public double Celsius { get; }
// 只标记这个属性是只读的,其他成员不做限制
public readonly double Fahrenheit => Celsius * 9 / 5 + 32;
}
这种逐成员级别的 readonly 控制给了开发很大的灵活性——不用为了一个不可变属性把整个结构体锁死。
3.4 volatile 与 readonly 的关系和区别
搜索引擎里 volatile 和 readonly 经常一起出现,因为两者都牵涉到“线程安全”这个知识点。但我要纠正一个误区:readonly 和 volatile 服务的层级完全不同,它们并不冲突,甚至可以共存。
volatile 解决的是“内存可见性”问题。它告诉编译器和 CPU:这个字段每次访问都必须从主内存读取,每次写入都必须立即刷新到主内存,不能依赖 CPU 寄存器里的缓存值。这个约束在单线程下毫无意义,但对多线程环境至关重要——一个线程改了值,另一个线程必须立刻能看到最新值。
readonly 解决的是“赋值不可变”问题。它限制的是“谁能在什么时候给字段赋值”,跟可见性一毛钱关系没有。一个 static readonly 字段,虽然只能赋值一次,但在多线程下,如果没有额外的内存屏障,某个线程在类型初始化完成后立刻去读,不一定能看到最新值(这里涉及 CLR 类型初始化器的内存屏障保证,实际的 CLR 实现通常在类型初始化后有完整的屏障,但语言规范并没有详细到每个细节)。
最典型的组合是 static readonly 配合不可变对象来构建安全发布:
csharp复制public class Cache
{
private static readonly ImmutableDictionary<string, string> Mapping =
CreateMapping();
private static ImmutableDictionary<string, string> CreateMapping()
{
// 构建不可变字典
var builder = ImmutableDictionary.CreateBuilder<string, string>();
builder.Add("key1", "value1");
builder.Add("key2", "value2");
return builder.ToImmutable();
}
}
这种模式在多线程下非常安全,因为 immutable 对象一旦创建就不再变化,而 readonly 保证了这个引用不会被替换。线程之间不需要加锁就能安全共享数据。我在一个高并发的配置服务里就是用这个方案维护核心映射表,实测下完全没有并发问题。
至于有些人问“readonly 能不能替代 volatile?”答案是:不能。它们解决的问题不同,不存在替代关系。如果字段在初始化后永远不变,用 readonly 是好的,但如果你需要字段的值可以被更新且更新必须立刻对其他线程可见,那需要 volatile(或者用 lock、Interlocked 等方法)。
4. 实操过程与关键场景适配
4.1 在依赖注入和配置系统中使用 readonly
现代 C# 后端开发几乎离不开依赖注入(DI)容器。在 ASP.NET Core 的项目里,最常见的 readonly 使用场景就是构造函数注入。我自己在代码审查时有个硬性要求:凡是通过构造函数注入的服务字段,一律加 readonly。
csharp复制public class OrderEventHandler : IEventHandler<OrderCreatedEvent>
{
private readonly IOrderRepository _orderRepository;
private readonly IMessageBus _messageBus;
private readonly ILogger<OrderEventHandler> _logger;
public OrderEventHandler(
IOrderRepository orderRepository,
IMessageBus messageBus,
ILogger<OrderEventHandler> logger)
{
_orderRepository = orderRepository;
_messageBus = messageBus;
_logger = logger;
}
public async Task HandleAsync(OrderCreatedEvent eventData)
{
var order = await _orderRepository.GetByIdAsync(eventData.OrderId);
// 业务逻辑
await _messageBus.PublishAsync(new OrderValidatedEvent(order.Id));
}
}
这样做的理由很简单:一个对象依赖的服务从构造后就该是固定的,如果在运行期间某个服务字段被替换掉了,整个对象的行为就失控了。readonly 从语法层面杜绝了这种失控。尤其是在单元测试里,如果要重新设置被测试类的私有字段以便 mock 某个依赖,那是一种设计上的坏味道,正确的做法是重构为构造器注入或属性注入,而不是打破 readonly 的约束。长痛不如短痛。
配置系统也是一个极佳的应用场景。我见过很多项目把配置做成一个巨大的静态类,全是 public static 字段,谁都能改,改了就全局生效,排查问题的时候欲哭无泪。正确的姿势是用 readonly 封装:
csharp复制public class DatabaseOptions
{
public readonly string ConnectionString;
public readonly int MaxPoolSize;
public readonly TimeSpan CommandTimeout;
public DatabaseOptions(IConfiguration config)
{
ConnectionString = config.GetConnectionString("Default")
?? throw new ArgumentNullException(nameof(ConnectionString));
MaxPoolSize = config.GetValue<int>("Database:MaxPoolSize", 100);
CommandTimeout = TimeSpan.FromSeconds(
config.GetValue<int>("Database:CommandTimeoutSeconds", 30));
}
}
这样配置项一旦从 IConfiguration 中加载出来,整个进程生命周期内都是稳定不变的。比到处读配置、到处传字符串健壮得多。注意我用了构造函数抛异常的方式来校验必填配置,这也是实践中很实用的技巧——配置缺失时让应用立即失败,而不是带着错误配置慢慢跑出更诡异的问题。
4.2 不可变对象与线程安全实现
聊到线程安全,很多人第一反应是加锁。但在很多场景下,不可变性比锁更优雅、更高效。不可变对象天然的线程安全:所有字段都在构造时初始化,之后无人能修改,因此不存在数据竞争和可见性问题。
readonly 是实现不可变类型的第一块基石。看一个实际例子——实现一个不可变的用户会话对象:
csharp复制public sealed class UserSession
{
public readonly Guid SessionId;
public readonly string UserName;
public readonly IReadOnlyList<string> Roles;
public readonly DateTime CreatedAt;
public UserSession(Guid sessionId, string userName,
IReadOnlyList<string> roles, DateTime createdAt)
{
SessionId = sessionId;
UserName = userName ?? throw new ArgumentNullException(nameof(userName));
Roles = roles ?? throw new ArgumentNullException(nameof(roles));
CreatedAt = createdAt;
}
public bool HasRole(string role)
{
// 注意:Roles 是 IReadOnlyList,但底层可能是可变 List
// 构造时最好传入不可变集合
return Roles.Contains(role);
}
}
这里有个非常容易被忽略的细节:IReadOnlyList<string> Roles 只是限制了“通过这个接口不能修改”,但如果调用方传入的是 List<string>,底层数组还是可以被修改的。这就是我之前提的“引用不可变不等于对象不可变”。正确做法是在构造函数里拷贝到不可变集合:
csharp复制public UserSession(Guid sessionId, string userName,
IEnumerable<string> roles, DateTime createdAt)
{
// 用 ToImmutableList 创建真正的不可变集合
Roles = roles?.ToImmutableList()
?? throw new ArgumentNullException(nameof(roles));
}
这相当于给不可变对象加了一个“深拷贝保护壳”。用 System.Collections.Immutable 命名空间里的不可变集合,配合 readonly 字段,才能构建出真正意义上完整的不可变对象。这个细节非常重要,我在很多号称“不可变”的代码里都见过这个漏洞,一旦底层集合被强转回可变类型,线程安全瞬间崩塌。
4.3 泛型与 readonly 的结合使用
泛型和 readonly 结合是个有趣的题目。在泛型类中,readonly 字段有一个大坑:你不能保证 T 类型是引用类型还是值类型,而 readonly 对两者的语义不同。参考这个例子:
csharp复制public class Repository<T>
{
private readonly T _defaultValue;
public Repository(T defaultValue)
{
_defaultValue = defaultValue;
}
}
这个例子能编译。但如果你试图让 T 携带一些信息,比如 “T 是否可空”,那 readonly 本身帮不上忙。不过一个实用的技巧是结合 static readonly 和泛型来实现泛型缓存:
csharp复制public static class Metadata<T>
{
public static readonly string TypeName = typeof(T).FullName;
public static readonly bool IsNullable =
Nullable.GetUnderlyingType(typeof(T)) != null;
public static readonly int HashCode = typeof(T).GetHashCode();
}
这是一个很有用的设计模式。Metadata<T> 是一个泛型静态类,它的 static readonly 字段在每种 T 首次访问时只会初始化一次,之后快速读取。我在日志系统里就用这个模式缓存每种异常类型的信息,省去了大量反射调用,性能提升非常明显。这种“泛型 + static readonly”的组合,正确使用后相当于给每个 T 定制了一个常驻内存的元数据缓存。
4.4 readonly 与 MySQL/MyBatis Plus 关键字的类比思考
网络上关于关键字还有一个高频热点:MySQL 表设计时字段名不小心撞了关键字,比如 order、group、desc,MyBatis Plus 里就会各种报错,还得加 @TableField 或反引号去处理。这个痛点和 readonly 在 C# 里的价值其实有异曲同工之处:都是“关键字”的约束问题。
先说 MySQL 那边的情况。如果字段名是 order,在 SQL 里必须写成:
sql复制SELECT `order` FROM orders
如果用了 MyBatis Plus,要么注解声明字段名,要么配置全局的驼峰转换和小写映射,否则自动生成的 SQL 就带上了关键字的坑。这些问题的根源是:开发者把数据库的保留字当成了普通标识符,系统不会主动纠正。
反过来看 C# 的 readonly,它做的就是“主动纠正”:在编译期就把不该发生的赋值行为拦截掉。虽然一个是数据库层面的问题,一个是语言层面的问题,但背后的思维模式是共通的——代码的健壮性要靠工具和约束来保证,而不能依赖开发者自觉。
MySQL 的关键字问题需要靠设计阶段的规范来防护(比如统一加前缀、启用 sql_quote_show_create 等),而 C# 的 readonly 是语言已提供的现成护栏。所以我的建议是:用数据库时做好命名规范,防患于未然;写 C# 时善用 readonly 这类编译器关卡,把低级错误消灭在萌芽状态。
另外我碰巧在项目里见过一个有意思的现象:C# 里的 MySQL 查询封装类,如果一个参数是 object[],设计者居然用了 readonly 修饰来防止 SQL 参数数组被意外替换。虽然不够彻底,但有了这层保障,至少没人能直接给这个字段赋新数组了。混用多个关键字的机制,有时候反而能解决不少实际痛点。
4.5 避免过度设计:哪些场景不适合用 readonly
readonly 虽然好,但用过头也会带来麻烦。我在实际项目里总结过几类不适合用 readonly 的情况:
第一,对象的属性需要支持序列化与反序列化。 很多 ORM 和 JSON 序列化器在反序列化时依赖无参构造函数和可写属性。如果所有属性都做成 readonly,反序列化就会非常困难。虽然 System.Text.Json 在 .NET 5+ 支持了参数化构造函数,但兼容性远不如可写属性。所以 DTO、ViewModel 这类需要经常跟外部系统打交道的对象,不建议一刀切全部 readonly。
第二,字段需要延迟初始化。 比如缓存、懒加载的单例,字段一开始可能是 null,第一次访问时才真正赋值。这时候用 readonly 就会强制你在构造函数里赋值,反而破坏了懒加载的语义。这种场景下用 Lazy
第三,对象池和重用对象。 有些高性能场景会复用对象实例来避免 GC 压力,对象里的字段会被反复重设。readonly 在这种场景就是纯粹的绊脚石。
本质上,readonly 是“写一次,永不改”的语义表达。如果你的需求是“写多次但希望安全”,或者“延迟初始化”,那请选择其他机制,不要为了用关键字而用关键字。
5. 常见问题与排查技巧实录
5.1 为什么 readonly 修饰的 List 还是能 Add?
这是我在博客评论区和社区问答里见过最多的问题之一。很多人写了这样的代码:
csharp复制public class ShoppingCart
{
public readonly List<Item> Items = new List<Item>();
}
然后发现虽然 Items 是 readonly,但还是能 Items.Add(new Item()),而且真的添加成功了。于是产生了困惑:readonly 怎么没有用?
答案是:readonly 限制的是“字段的赋值”,不是“对象内部状态的变化”。Items = new List<Item>() 是替换引用,这是被禁止的;但 Items.Add(...) 是调用对象本身的方法,它改变的是 List 内部的数据,和 readonly 没有关系。
这个理解非常重要。如果希望 list 不可变,应该使用 IReadOnlyList<T>(配合不可变实现),或者在构造后不暴露可修改的接口,而不是简单地加 readonly。
诡异的场景是很多人同时用了 readonly 和 IReadOnlyList 但没有做深拷贝,结果底层 List 仍然被改了。这个在前面已经提到,这里再强调一次:IReadOnlyList 只是一个视图,真正的安全性取决于底层实现。要彻底不可变,就用 ImmutableList 或者在构造函数里克隆。
5.2 何时该选 const,何时该选 static readonly(典型混淆场景)
我画了个“决策流程”,帮团队里的小朋友快速判断:
- 值本身是不是“数学常量”?比如 π、e、一天的小时数——这类用 const。
- 是不是需要从配置文件、环境变量、数据库读取?是——用 static readonly。
- 是不是引用了另一个程序集里定义的常量?是——最好用 static readonly,避免二进制兼容性问题。
- 值类型是 string 或基元类型吗?不是(比如 DateTime、Guid、自定义类型)——必须用 static readonly。
- 这些值只在本类里用,且永远不会变?——const 也行,但如果你哪天想改成从配置读取,又得全部改回 readonly,所以直接 static readonly 下不为例。
这个流程基本能覆盖 98% 的场景。剩下的 2% 就是像 [Obsolete] 这种属性相关的情况,需要结合上下文决定。
5.3 反射修改 readonly 字段的坑
我在实际维护老系统时遇到过一幕:某同事为了在测试里注入 mock 对象,用反射强行修改了 readonly 字段:
csharp复制var field = typeof(Service).GetField("_dependency",
BindingFlags.Instance | BindingFlags.NonPublic);
field.SetValue(service, mockDependency);
测试倒是跑通了。但这背后有风险:
一是 JIT 可能已经对 readonly 字段做了缓存优化,运行时修改值后,读取处拿到的可能还是旧值。这是不确定性最高的地方,同样的代码可能在自己电脑上正常,在服务器上表现不同,最坑人的是这种问题根本没法稳定复现。
二是违反了类的设计契约。readonly 字段的存在就是为了告诉所有维护者“这个字段在运行期不能换”,你用反射绕过这个约束,相当于告知编译器“我要和 CLR 对着干”,未来升级 .NET 版本或启用更激进的内存保护后,代码有可能就崩了。
正确做法:设计时预留 internal 构造函数或 internal 可见字段用于测试(配合 InternalsVisibleTo),或者使用依赖注入容器在构造时替换依赖。别和 CLR 的规则对着硬碰硬。
5.4 静态构造与 readonly 初始化的时序陷阱
静态字段的初始化顺序在 C# 里有明确的规范:在子类中访问静态字段时,其静态构造函数先执行;静态构造函数的执行时机和静态字段初始化顺序则是按照代码中的文本顺序。但如果涉及静态 readonly 字段和静态构造函数,有一个经典的坑:
csharp复制public class Config
{
public static readonly Config Instance = new Config();
public static readonly string Version = "1.0";
static Config() { }
private Config()
{
// 这里如果访问 Version
// 此时 Version 可能还没被初始化(取决于文本顺序)
Console.WriteLine(Version); // 输出 null!
}
}
看到了吗?Instance 在 Version 之前被初始化,所以 new Config() 被执行时,Version 还是默认值 null。如果构造函数里访问了一个尚未初始化的 static readonly 字段,拿到的就是 null。这种 bug 很隐蔽,因为代码看起来完全没问题。
解决方法是调整字段声明的顺序,确保被依赖的字段先声明;更稳妥的做法是避免在构造函数里直接访问其他静态字段,把逻辑放到初始化方法里。
5.5 常见错误速查表
为了方便大家直接排查问题,我把常见错误整理成一个速查表,可以贴在自己的开发笔记里:
| 典型场景 | 错误现象 | 原因 | 正确做法 |
|---|---|---|---|
| 类内部赋值 | CS0191 编译错误 | readonly 字段只能在声明或构造函数中赋值 | 把赋值挪到构造函数 |
| 修改 List 内容 | 编译通过,数据变化 | readonly 限制引用,不限制对象内容 | 用不可变集合或 IReadOnlyList |
| 序列化 | JSON 反序列化后值为空 | 无参构造函数无法给 readonly 赋值 | DTO 用属性而非 readonly 字段 |
| 静态字段顺序 | 构造函数读到 null | 静态初始化顺序问题 | 调整声明顺序或避免在构造中读其他静态字段 |
| 修改 const | 调用方没生效 | const 是编译期字面量 | 改用 static readonly |
| 反射赋值 | 偶发读到旧值 | JIT 优化缓存了旧值 | 避免反射修改 readonly 字段 |
这张表是跟着我多年的踩坑记录,现在分享出来,希望能帮大家省几晚加班。
5.6 单元测试与私有 readonly 字段
做单元测试时,有的测试人员会执着于验证私有字段的值。比如想验证某个服务是否依赖了正确的仓储:
csharp复制[TestMethod]
public void Service_Should_UseSpecifiedRepository()
{
var repo = new FakeRepository();
var service = new MyService(repo);
// 这是被诟病的做法
var field = typeof(MyService).GetField("_repository", BindingFlags.NonPublic);
Assert.AreEqual(repo, field.GetValue(service));
}
我个人的建议是:别测私有字段,测公有行为。如果一个对象被注入了某个依赖,你应该验证它在调用后产生了正确的副作用,而不是验证它内部存了谁。测试私有字段会让测试和实现细节强耦合——实现一调整,测试就挂,而其实行为没有变。
如果你真的需要验证构造逻辑,考虑暴露 internal 只读属性并用 InternalsVisibleTo 提供给测试程序集,这样至少不用反射:
csharp复制public class MyService
{
private readonly IRepository _repository;
internal IRepository Repository => _repository;
}
这个做法比反射安全得多,也更容易维护。
6. 从 C 语言关键字看 readonly 的谱系
搜索引擎里有大量关于“C语言有哪些属性关键字”“extern关键字的作用”“static关键字的作用”的搜索词。这说明很多人学习关键字时会跨语言比较。所以这一节我专门从 C/C++ 的视角来反观 C# 的 readonly,帮助大家建立知识迁移的桥梁。
C 语言本身没有 readonly,但有一个类似语义的修饰符:const。C 的 const 表示“我不想让这个值被修改”,但 C 里 const 的“强制力”比较弱——用指针强转就能绕过。比如:
c复制const int x = 10;
int *p = (int *)&x;
*p = 20; // 编译可以通过(有警告),运行会修改只读数据段的话就会崩
而 C# 的 readonly 是 CLR 和编译器的双重强制,正常代码不可能绕过。从这种意义上说,C# 的 readonly 比 C 的 const 更强。
C++ 里有 const 成员函数的概念:在函数参数列表后面加 const,表示这个函数不会修改对象的状态。C# 8+ 的 readonly 成员与之类似:
csharp复制public struct AccountInfo
{
public decimal Balance { get; set; }
// 这个只读成员不能修改任何实例字段
public readonly string Describe()
{
// 错误:无法在 readonly 成员中修改 Balance
// Balance = 0; // 编译错误
return $"Balance is {Balance}";
}
}
这种语法对结构性不可变很有帮助:不是整个对象不可变,而是某些方法承诺不改状态。
至于 extern 关键字,它和 readonly 没啥直接关系,但了解 C# 的 extern 用法还是有意思的。C# 里 extern 常见于 DllImport(调用非托管代码)和外部别名,它的作用是把“方法的实现在别处”告诉编译器。这给了我们一个很重要的思维:关键字是编译器与运行时的“合同”,每个关键字都在限制或扩展某种能力。
最后再提一个 C 系关键字的共同点:static。C 的 static 有“内部链接”和“静态存储期”两种语义;C# 的 static 则更接近“属于类型而不是实例”。C# 的 readonly 和 static 可以结合(static readonly),但两者关注的维度不同。理解它们在各语言中的差异,可以在多语言项目里更自如地切换思维模式。
7. 工具选型与进阶建议
7.1 IDE 与代码分析工具
用了这么多年 readonly,我越来越觉得它不只是关键字,更是一种代码“契约”。为了把这个契约贯彻好,工具辅助非常重要。Visual Studio 自带的分析器就能识别很多 readonly 用法问题,比如:
- IDE0052:建议将私有成员设为 readonly(读但从不赋值)
- IDE0044:建议将字段设为 readonly(只赋值一次)
开启这些规则很简单:在 .editorconfig 里设置:
ini复制[*.cs]
dotnet_diagnostic.IDE0044.severity = suggestion
dotnet_diagnostic.IDE0052.severity = suggestion
我建议把 severity 至少开到 suggestion。团队开发时,这些规则会直接把可改进的代码暴露出来,减少代码评审时“这个字段可以加 readonly”这类无效讨论。
JetBrains Rider 也有类似的检查,而且它的 Quick Fix 可以一键把符合条件的字段改成 readonly。我在老项目上大规模应用过这个重构,安全系数很高——因为编译器会帮你检查出所有不符合条件的地方,不会误改。
7.2 从 readonly 延伸到 Source Generator 和代码契约
如果 readonly 的编译期保护已经不能满足你对“代码契约”的追求,下一步可以考虑 Source Generator(源生成器)。它能在编译时生成代码来保证某些约束,比如生成不可变类型的 With 方法、生成工厂方法、生成构造器校验等。
举个例子,我们可以在源生成器里检查某个类型的所有字段是否都标记为 readonly,如果不是则报编译错误:
csharp复制[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class ImmutableAnalyzer : DiagnosticAnalyzer
{
// 这里实现检查逻辑
// 当检测到 marked as ImmutableAttribute 但字段没有 readonly
// 就报告一个诊断
}
这套玩法比单纯依赖语言关键字更进一步:从“语言提供的约束”到“自定义的领域约束”。不过它需要比较多的知识储备,适合有一定 Roslyn 经验的开发者尝试。普通项目的话,先用好 readonly 已经能提升不少代码质量。
7.3 现代化 .NET 趋势下的 readonly 新用法
随着 .NET 版本演进,readonly 相关的能力也在扩展。我整理几个值得关注的新特性:
ref readonly返回:方法可以返回一个只读引用,避免复制大结构体,同时防止调用方修改被引用对象。这在 C# 7.2 引入,适合高性能场景。in参数:配合 readonly struct,可以在不拷贝的前提下安全传递大结构体参数。record struct+ readonly:record struct 在声明时用 readonly 修饰,会自动生成只读属性,配合with表达式实现方便的生产副本。- 集合表达式(C# 12):配合不可变集合声明,能简化不可变数据的初始化。
这些新特性的底层逻辑都跟 readonly 一致:让数据在语义上更安全、在实现上更高效。所以说 readonly 不是“老掉牙的语法”,它是现代 .NET 数据安全性的重要基石。
8. 避坑清单与最后的实操建议
在项目的代码规范里,我会把下面这份清单直接贴进文档,作为团队成员写代码时的必读项。
| 检查项 | 说明 |
|---|---|
| 依赖字段是否 readonly | 构造函数注入的依赖,必须用 readonly |
| 常量是否该用 readonly | 除了真正的编译期常量,优先用 static readonly |
| 集合字段是否真的不可变 | readonly + IReadOnlyList 不等于深不可变,必须用不可变集合或深拷贝 |
| readonly struct 是否滥用 | 大型结构体做成 readonly struct 要慎重,确认不会频繁拷贝 |
| 静态字段顺序是否安全 | 静态 readonly 字段互相引用时,注意声明顺序 |
| 测试代码是否反射修改字段 | 尽量避免反射绕过 readonly 契约,用 InternalsVisibleTo 代替 |
| 序列化对象是否兼容 | DTO 和 ORM 实体不要无脑加 readonly,否则反序列化会卡壳 |
最后再分享一个我自己的小习惯。每当一个类的字段第一次被赋完值后,我会下意识地在代码评审时问一句:“这个字段之后还需要被重新赋值吗?”如果答案是不需要,我基本都会要求加 readonly。这个过程可能只花五秒钟,但它会迫使大家认真思考每个字段的生命周期,而不是无意识地允许所有字段在类内部被随意修改。
readonly 是那种“会呼吸的文档”,它告诉所有人:这个字段的初始化窗口已经关闭,接下来只需要信任即可。信任编译器的强制,总比信任团队成员的自觉要可靠得多。
不要小看这一个关键字。它不是一个装饰性的语法糖,而是 C# 类型系统里一道实实在在的安全防线。用好 readonly,你的代码会比原来干净一个档次,排查并发和状态问题也会轻松不少。如果这篇文章能让你在实际代码里多写一个 readonly 字段、少踩一个状态被意外修改的坑,那就算没有白写。
