属性进阶(init/required)
本教程共 100 篇 · 第 35 篇 · 更新于 2026-07-31 · 约 12 分钟阅读
35. 属性进阶(init / required)
本节目标:学完你能写出「创建时必须给值、创建后不可改」的健壮对象。
上一章的只读属性 { get; } 很安全,但只能在构造函数里赋值,用对象初始化器写不了。C# 9 的 init 和 C# 11 的 required 把这件事做得更顺手,也更适合不可变(immutable)数据。
init 访问器:初始化时可写,之后冻结
把 set 换成 init,属性就能在对象初始化器里赋值,但对象建好后再赋值会编译报错。它实现了「一次性写入、之后只读」。
namespace CSharpDemo;
class Person
{
public string Name { get; init; }
public int Age { get; init; }
}
var p = new Person { Name = "小兰", Age = 20 };
// p.Age = 21; // 报错:init 属性创建后不可改
Note
init和set的区别只在时机:set随时可改,init只在对象初始化期间可写。它正是record不可变语义的底层支撑(record 见第 49 章)。
required 成员:不给值就编译报错
有时候某个属性「必须填」,漏了就是 bug。C# 11 的 required 修饰符强制调用方在初始化器里提供该成员,否则直接编译失败,把错误挡在最早。
namespace CSharpDemo;
class Student
{
public required string Name { get; init; }
public int Score { get; set; }
}
// var s = new Student { Score = 90 }; // 报错:缺少必需的 Name
var s = new Student { Name = "小伟", Score = 90 };
required 可配合 init,也可配合普通 set。它管的是「有没有赋值」,不管值本身合不合法。
Tip
required+init是黄金组合:既强制必填,又保证创建后不可篡改,最适合表示「身份类」数据,比如订单号、用户名。
给 init 属性做校验
init 访问器里能写逻辑,所以可以在赋值时顺手校验。下面要求名字不能为空,否则抛异常——因为初始化阶段就会执行 init,错误立刻暴露。
namespace CSharpDemo;
class Person
{
private string name;
public string Name
{
get => name;
init
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("名字不能为空");
name = value;
}
}
}
var p = new Person { Name = "阿杰" }; // 正常
// var q = new Person { Name = " " }; // 抛异常
Note校验写在
init里,而不是等对象用完才发现问题,这正是「失败得越早越好」的体现。
required 与可空类型的配合
开启了可空引用类型(string?)时,required string? 表示「必须出现、但允许是 null」;若想连 null 都不允许,用非可空的 required string 配合上面的非空校验最稳。
namespace CSharpDemo;
class Product
{
public required string Name { get; init; } // 必填且不可为 null
public decimal Price { get; init; } = 9.99m; // 可选,有默认值
}
var p = new Product { Name = "水杯" };
Console.WriteLine($"{p.Name} 价 {p.Price}"); // 水杯 价 9.99
Price 没标 required,又有默认初值,所以可省略;Name 必须给,不给就编译不过。
init / required 解决了什么痛点
在没有这两个特性前,想做「必填 + 不可变」只能靠构造函数参数堆砌。字段一多,构造函数签名就长得吓人。init + required 让你用初始化器就能表达同样约束,代码短、可读性高。
Tip新手踩坑:在
init属性上同时想「外部创建后还能改一点」,这是不可能的——init就是为不可变而生的。需要可改就回到set。
为什么需要不可变
对象创建后数据就不再变,会带来三个好处:一是可预测,任何时刻看它都一样;二是线程安全,多线程读同一对象不会读到半成品;三是稳定,若拿它当字典的键或算哈希,值不变才不会出乱子。不可变不是炫技,是少 bug 的利器。
Note
init正是为「创建时定好、之后不变」量身定做的。它比构造函数参数更灵活——你可以只给需要的部分,其余走默认。
init 与 record 的关系
本书第 49 章会讲 record(记录类型)。记录的属性默认就是 init 的,这正是它「不可变 + 值语义」的底层来源。理解 init,就等于提前理解了 record 的一半。
namespace CSharpDemo;
record Point(int X, int Y); // 位置记录,X/Y 本质是 init 属性
var p = new Point(1, 2);
// p.X = 3; // 报错:record 的属性是 init
多个 required 成员一起用
一个类可以有多个 required 成员,调用方必须全部补齐,缺一不可。初始化器写起来依然直观。
namespace CSharpDemo;
class User
{
public required string UserName { get; init; }
public required string Email { get; init; }
public string Nickname { get; init; } = "匿名";
}
var u = new User { UserName = "alice", Email = "a@x.com" };
Console.WriteLine($"{u.UserName} <{u.Email}>");
required 与主构造器怎么选
C# 12 的主构造器也能强制必填(把参数写在类声明上)。两者的取向不同:required 配合初始化器,调用方可以「挑着填、其余用默认」;主构造器则强制按固定参数顺序传入。字段多且多数可选时,required 往往更舒服。
Tip常见坑:
required string?只要求「出现」,仍可能赋null。想连 null 都不允许,用非可空的required string并加非空校验,双保险。
同一个类里混合 init 与 set
一个类完全可以有的属性用 init(创建后不可改),有的用 set(随时可改)。这样既能锁定身份数据,又保留可变的业务状态。
namespace CSharpDemo;
class Order
{
public required int Id { get; init; } // 订单号:定死
public string Status { get; set; } = "新建"; // 状态:可改
}
var o = new Order { Id = 1001 };
o.Status = "已付款";
// o.Id = 2002; // 报错:init 不可改
required 配合集合属性
required 也能用在集合属性上,强制调用方至少给一个初始集合。结合 C# 12 集合表达式,初始化很简洁。
namespace CSharpDemo;
class Config
{
public required List<string> Roles { get; init; }
}
var c = new Config { Roles = ["admin", "user"] };
Console.WriteLine(string.Join(",", c.Roles));
Note集合表达式
["admin", "user"]是 C# 12 语法,详见第 26 章。它让required集合的初始化不再啰嗦。
用 required 表达「配置对象」
做配置类时,关键项用 required 强制填,可选项给默认值,调用方一眼就知道哪些不能漏。这比靠文档叮嘱靠谱得多——漏了编译器直接拦下。
Tip常见坑:
required只保证「写了这个成员」,不保证「写了合法值」。涉及非空、范围等约束,仍要在init里做校验,双保险才稳。
init 在结构(struct)上同样可用
init 不只用于类,值类型 struct 也能用。下面这个只读点结构创建后可初始化、之后不可改,适合做轻量不可变值。
namespace CSharpDemo;
struct Point
{
public int X { get; init; }
public int Y { get; init; }
}
var p = new Point { X = 1, Y = 2 };
// p.X = 3; // 报错:init 不可改
required 与可空引用的细节
开启可空引用类型后,required string 与 required string? 语义不同:前者要求调用方给一个非空的字符串;后者只要求「出现这个成员」,值仍可为 null。若要严格防 null,优先用非可空版本并配合非空校验。
Note可空分析只在编译期提醒,运行时不会自动拦截
null。所以「既 required 又非空」需要required string加上init/set里的判空,双重保险。
init 与对象初始化器的顺序
init 访问器在对象初始化器执行期间逐个被调用,全部完成后对象才「冻结」。因此多个 init 属性之间可以互相独立赋值,不必担心初始化中途被外界读到半成品——因为对象还没真正交付使用。
Tip想让一组属性「要么全给、要么用默认」,就把它们都设成
init并给默认值,调用方只填关心的那几个即可。
小结
init 访问器允许在对象初始化阶段写入、之后冻结,是构建不可变类型的利器,类与 struct 都可用,也是 record 的底层支撑。required 强制调用方在初始化器里提供成员,漏写直接编译报错。两者常组合成 required ... init,既必填又不可改。一个类里可混合 init 与 set:身份数据用 init 锁死,业务状态用 set 可变。init 里还能写校验逻辑,把非法数据挡在创建那一刻。可空类型下,required string 比 required string? 更严格、更防 null。