C# 9.0 引入了一个很有意思的语言特性——init 访问器。它允许属性只在对象初始化阶段被赋值,初始化完成之后再修改就会直接报编译错误。很多刚接触这个特性的开发者会疑惑:它和传统的 set 访问器有什么区别?和 readonly 字段又是什么关系?这篇文章把这些问题一次讲透。

init 访问器的基本概念与底层原理
先看最直观的对比。传统写法中,属性一旦定义了 set 访问器,就意味着它在对象生命周期的任何时刻都可以被修改:
public class Person
{
public string Name { get; set; }
}
这种写法的问题在于,如果设计意图是“Name 一旦确定就不该变”,set 访问器无法在语言层面约束这一点,只能靠开发者自觉。而把 set 换成 init 之后,约束就成了编译器强制的规则:
public class Person
{
public string Name { get; init; }
}
var p = new Person { Name = "张三" }; // 正确,初始化阶段
// p.Name = "李四"; // 编译错误,初始化已完成
从编译器实现的角度看,init 并不是把属性变成真正的只读。编译器会在构造对象时通过一个特殊的 modreq(IsExternalInit 类型修饰符)标记 set 访问器,使它只能在对象初始化器或构造函数中被调用。也就是说,IL 层面它仍然是一个 setter,只是被标记为“仅限初始化”。理解这一点很重要,后面讲跨版本兼容时会用到。
init 的典型使用场景与代码示例
init 最常见的用法是配合对象初始化器,构建不可变的数据对象。相比在构造函数里传一堆参数,对象初始化器的写法更清晰,参数顺序也不会出错:
public class Order
{
public int OrderId { get; init; }
public string CustomerName { get; init; }
public DateTime CreatedAt { get; init; }
}
var order = new Order
{
OrderId = 1001,
CustomerName = "王五",
CreatedAt = DateTime.Now
};
第二个典型场景是 record 类型。C# 9.0 的 record 本身就是为不可变数据设计的,它自动生成的主构造函数参数默认就是 init-only 属性。如果你手写一个简单的不可变 DTO,用 class 加 init 属性和用 record 效果接近,但 record 额外提供了基于值的相等性比较和 with 表达式支持。
第三个场景是在构造函数中赋值。init-only 属性允许在所在类的构造函数内部直接赋值,这一点和对象初始化器效果一致:
public class Config
{
public string Endpoint { get; init; }
public Config(string endpoint)
{
Endpoint = endpoint; // 构造函数内赋值,合法
}
}
需要注意,只有在定义该属性的类自己的构造函数里才能这样写。如果你在别的类里 new 完对象再赋值,编译器会直接拒绝。
init 与 readonly、set 的区别及常见坑
很多人分不清 init-only 属性和 readonly 字段。两者的核心区别在于初始化方式的灵活性:readonly 字段只能在声明时、所在类的构造函数中赋值,无法通过对象初始化器赋值;而 init 属性可以。下面的代码能直观看出差异:
public class Sample
{
public readonly int A = 1; // 声明时赋值
public int B { get; init; }
public Sample(int a) { A = a; } // 构造函数赋值
}
// var s = new Sample(5) { A = 10 }; // 错误,readonly 不能用初始化器
var s = new Sample(5) { B = 10 }; // 正确
再说说几个常见的坑。第一个坑是和 init 配合的自动属性必须使用简写形式,如果你手写完整的访问器体并加 init,在某些早期预览版本会有语法限制,正式版已经支持,但建议保持简写风格让代码更整洁。
第二个坑是跨版本兼容问题。因为 init 依赖 IsExternalInit 类型,在 .NET 5 及以上版本开箱即用,但如果你的类库需要被 .NET Framework 或旧版 .NET Standard 项目引用,消费方编译时会报缺少类型的错误。解决办法是在类库项目中手动声明一个内部命名空间兼容类型:
namespace System.Runtime.CompilerServices
{
// 为旧运行时补上 init 所需的类型标记
internal static class IsExternalInit
{
}
}
加上这个声明后,旧框架项目也能正常编译引用含 init 属性的类库,这是类库作者必须掌握的兼容技巧。
第三个坑和反序列化有关。JSON 反序列化器需要能写入属性才能完成绑定,主流库如 System.Text.Json 和 Newtonsoft.Json 的新版本都已经支持 init-only 属性的写入,但如果项目还停留在很旧的序列化库版本,反序列化含 init 属性的类型可能失败或得到默认值,升级序列化库即可解决。
什么时候该用 init,什么时候不该用
判断标准其实很简单:如果某个属性在对象创建后 logically 不应该再变化,就大胆用 init。典型的有配置项、订单编号、创建时间、身份标识这类“出生即定”的值。反之,像购物车数量、用户昵称这类会随业务流转而修改的状态,用普通 set 才是正确选择。
另外提醒一点,init 提供的是编译期保护,不是运行时安全机制。通过反射依然可以在初始化之后修改 init-only 属性的值,所以不要把它当作防止恶意篡改的手段,它约束的是正常的代码编写行为,让意图在类型签名上表达清楚,这本身已经能消除大量隐蔽的赋值 bug 了。
C# init访问器C# 9.0只读属性修改时间:2026-09-10 21:53:05