.NET 11 Preview · 继承树上的穷举检查 · Union 的自然搭档
ClosedAttribute——GA 前 BCL 会内置。上节课我们学了 Union——"一个值恰好是几种无关类型之一"。但现实中有另一类场景:
| 场景 | 类型关系 | Union 合适吗? |
|---|---|---|
| AST 节点 | Literal / BinaryExpr / Variable——没有共同基类,各自独立 | ✅ 合适——这正是 Union 的甜区 |
| HTTP 响应 | Success / Redirect / ClientError / ServerError——共享 StatusCode、Headers | ⚠️ 勉强——每种 response 都要重复 StatusCode 字段 |
| 门禁状态 | Closed / Open(percent) / Locked——概念上是同一事物的不同状态 | ⚠️ 勉强——但 Open(percent) 和 Locked 在业务上"是一种 GateState" |
| 支付方式 | CreditCard / WeChatPay / Alipay——共享 ProcessPayment() 流程 | ❌ 不合适——每种支付方式有大量共享逻辑,继承层次更自然 |
核心张力:继承层次让你共享代码,但丢失了编译期穷举检查——新人加一个子类,分散在 47 个 switch 里都不会亮警告。Union 给你穷举检查,但丢失了继承的代码复用——没有基类字段、没有虚方法、没有 protected 成员。
C# 15 的 closed 修饰符回答了这个问题:继承层次 + 编译期穷举 = 同时拥有两边的优势。
public closed record class GateState; // ← 只加了 closed 一个词
public record class Closed : GateState; // 门关着——没有额外数据
public record class Open(float Percent) : GateState; // 门开着——带开度
public record class Locked(string Reason) : GateState; // 门锁了——带原因
三行派生类 + 基类前加一个 closed——和 Union 的"三行 case + 一行 union"几乎一样的声明量。
// ──── Assembly A: 定义方 ────
public closed record class Shape;
public record class Circle(double R) : Shape;
public record class Rect(double W, double H) : Shape;
// ──── Assembly B: 引用方 ────
public record class Triangle(double A, double B, double C) : Shape;
// ❌ 编译错误 CS9214:
// "'Shape' is a closed class and cannot be derived from outside assembly 'A'"
closed 的边界是程序集——不是命名空间、不是项目、不是文件。同一个 .dll 内可以随意派生;跨 .dll 不行。这给了库作者一个强保证:"我定义的类型,子类型只有我列出的这些——不会有外部代码偷偷加一个新的。"
sealed 的区别:sealed 是"零个子类"——完全禁止派生。 closed 是"有限个子类,但都在本程序集内"——允许派生,但集合是封闭的。你可以把 sealed 理解为 closed 的特殊情况(子类数量 = 0)。
closed class 的设计假设是"基类本身不应该被直接实例化"——它只是派生类的占位符。因此编译器把 closed 视为隐式 abstract:
public closed record class GateState; // 隐式 abstract——不能 new
var state = new GateState(); // ❌ 编译错误——和 abstract class 一样
// 下面这些修饰符不能和 closed 同时出现:
// closed sealed class → ❌ 矛盾:sealed 禁止派生,closed 允许有限派生
// closed static class → ❌ static class 根本不能派生
// closed abstract class → ⚠️ 警告:closed 已经隐含 abstract,重复多余
closed?——关键词的象征意义这个关键词有三层考量:
| 层面 | 含义 |
|---|---|
| 类型论——"封闭世界" | 在类型系统中,封闭类型(Closed Type)指编译器能枚举出完整的、有限的子类型集合——"这个集合已经封闭,不会再有新的了。"普通 abstract class 是开放类型——外部程序集随时可能加新子类。加 closed 就把这个集合封口了,编译器可以放心穷举。 |
语义梯度——补全 sealed 和 abstract 之间的空白 |
sealed = 零子类(彻底封死)→ closed = 有限子类,集合已知(封口但不封死)→ abstract = 无限子类,集合未知(完全开放)。closed 是 sealed 和 abstract 之间的中间地带——比 sealed 松一点,比 abstract 紧一点。 |
| 命名策略——刻意避开与 Java 的冲突 | Java 17 的 sealed class 等价于 C# 的 closed class(有限子类 + 穷举检查)。但如果 C# 15 也沿用 sealed,就会跟 C# 已有的 sealed(零子类)含义冲突——一个关键词两个意思。选 closed 避开了这场命名混乱:两个词、两个概念、一清二楚。其他候选词为什么没选: fixed(容易和 fixed 语句混淆)、final(Java 已占用,表示"不可重写")、finite(技术上准确但不够口语化)、enum class(太像枚举,且 C# enum 是值类型语义不搭)。closed 是唯一在类型论上有明确含义、跟 sealed 形成自然梯度、且不被现有特性抢占的词。 |
string Describe(GateState state) => state switch
{
Closed => "门关着",
Open(var percent) => $"门开着 {percent}%",
Locked(var reason) => $"门锁了: {reason}",
}; // ✅ 编译通过——三个直接派生类全部覆盖,不需要 _ 兜底
// 六个月后,有人加了第四种状态:
public record class Maintenance(string Worker) : GateState;
// 💡 编译器立即在所有 switch 上亮警告:
// "The switch expression does not handle 'Maintenance'"
// 同一个程序集内的所有 switch 全部收到——无一遗漏
closed 基类的所有直接派生类(只能在本程序集内),验证 switch 覆盖了每一个。加一个新派生类 → 所有 switch 亮 CS8509 警告。继承层次第一次有了和 Union 一样的编译期安全网。
同样的"三种消息类型",Union 和 Closed Hierarchy 写出来完全不同:
// ═══ 方案 A: Union——类型形状各异,毫无共同之处 ═══
public record class TextMessage(string Body, DateTime SentAt);
public record class ImageMessage(byte[] Data, string MimeType);
public record class Reaction(string Emoji, int TargetMsgId);
public union ChatEvent(TextMessage, ImageMessage, Reaction);
// ↑ 注意三个类型的字段完全不同:
// TextMessage → Body(string) + SentAt(DateTime)
// ImageMessage → Data(byte[]) + MimeType(string)
// Reaction → Emoji(string) + TargetMsgId(int)
// 没有任何共同字段——强行抽基类只会得到一个空壳
// ═══ 方案 B: Closed Hierarchy——各有各的数据,共享基类行为 ═══
public closed record class Notification
{
public required DateTime CreatedAt { get; init; } // ← 所有通知都有的时间戳
public abstract Task<bool> Send(); // ← 所有通知都能发送
}
public record class Email(string To, string Subject, string Body) : Notification
{ public override Task<bool> Send() => /* SMTP */; }
public record class Sms(string PhoneNumber, string Text) : Notification
{ public override Task<bool> Send() => /* 短信网关 */; }
public record class Push(string DeviceToken, string Title, string Body) : Notification
{ public override Task<bool> Send() => /* FCM/APNs */; }
// ↑ 三个子类的字段也完全不同:
// Email → To + Subject + Body(邮件地址)
// Sms → PhoneNumber + Text(手机号)
// Push → DeviceToken + Title + Body(设备令牌)
// 但它们都继承了 CreatedAt(时间戳)和 Send()(发送行为)
两者都能穷举检查。区别在于其他维度:
| 维度 | Union | Closed Hierarchy |
|---|---|---|
| case 类型之间的关系 | 无关——各自独立定义,字段集可以完全不同 | 有共同基类——"是一种"关系,共享基类成员 |
| 共享字段/方法 | ❌ 无——每个 case 类型独立定义所有字段 | ✅ 基类定义一次,所有子类自动继承 |
| 基类行为 | 无基类——union 是 struct | 可以有虚方法、抽象方法、模板方法模式 |
| 外部扩展 | ❌ 封闭——新 case 必须改 union 声明 | ❌ 封闭——新子类必须在同一程序集 |
| 多态调度 | 通过 switch 模式匹配 | switch 模式匹配 或 虚方法调用 |
| 存储开销 | struct + object?——值类型装箱 | 引用类型——天然在堆上,无额外装箱 |
| 典型场景 | API 返回值(Data / Error / Pending) 聊天事件(文字 / 图片 / 表情) AST 节点(Literal / BinaryExpr / Variable) | 通知渠道(邮件 / 短信 / 推送) 支付方式(信用卡 / 微信 / 支付宝) HTTP 响应(Success / Redirect / Error) |
有时同一个领域模型两种都能表达,选哪个取决于你预期未来会不会长出共享行为。比如一个简单的Pet:
// 场景:三种宠物目前只有名字不同——Union 更直接
public record class Cat(string Name);
public record class Dog(string Name);
public record class Bird(string Name);
public union Pet(Cat, Dog, Bird);
// ✅ 三个类型字段一样(都只有 Name)——但这是特例,现实中少见
// 但如果未来要加 FeedingSchedule、VetRecord、Adopt()……
// 共享行为开始累积 → 迁移到 Closed Hierarchy:
public closed record class Pet
{
public required string Name { get; init; }
public abstract FeedingSchedule DailyFeeding(); // 每种宠物喂食不同
public virtual VetRecord? LastCheckup { get; set; } // 所有宠物共享的结构
}
public record class Cat(string Name) : Pet { public override FeedingSchedule DailyFeeding() => /* 猫粮 2 次/天 */; }
public record class Dog(string Name) : Pet { public override FeedingSchedule DailyFeeding() => /* 狗粮 2 次/天 + 遛 */; }
public record class Bird(string Name) : Pet { public override FeedingSchedule DailyFeeding() => /* 鸟食 1 次/天 */; }
// ✅ 继承层次开始体现价值——FeedingSchedule 和 VetRecord 只需在基类定义一次
两者都正确——选让你现在的代码更清晰的那个。如果今天不确定,从 Union 开始。当共享行为开始涌现(基类不再空洞),迁移到 Closed Hierarchy 的成本很低:case 类型加上 : BaseType,union 声明改为 closed class。
一个常见的误解:以为 closed 会沿着继承链向下传递。实际上不会——封闭性只约束直接派生:
// ──── Assembly A ────
public closed record class Animal; // closed: 直接子类必须在本程序集
public record class Mammal : Animal; // 本程序集内派生 → ✅
public record class Bird : Animal; // 本程序集内派生 → ✅
// ──── Assembly B: 引用方 ────
public record class Dog : Mammal; // ✅ 合法!Mammal 不是 closed——可以从外部派生
public record class Cat : Mammal; // ✅ 同样合法
这意味着 Animal 的 switch 穷举只需要覆盖 Mammal 和 Bird——不需要知道 Dog 和 Cat 的存在。穷举检查的粒度是直接派生类。
给 Mammal 也加 closed:
public closed record class Mammal : Animal; // closed: Mammal 的直接子类也必须在本程序集
public record class Dog : Mammal;
public record class Cat : Mammal;
public record class Human : Mammal;
// 现在 Mammal 的 switch 也需要穷举 Dog、Cat、Human
closed 是传递的,继承层次中每一层都自动封闭——这会极大地限制外部程序集的扩展能力。非传递性给你精细控制:你可以在继承链的任意一层选择"到此为止",而让其他层保持开放。
和 Union 类似,closed 由编译器通过一个 attribute 实现。你写:
public closed record class GateState;
编译器生成:
[System.Runtime.CompilerServices.Closed] // ← 就是这个 attribute
public abstract record class GateState; // closed → abstract
编译时,编译器看到 [Closed] attribute → 枚举当前程序集中所有直接派生类 → 用这个列表验证 switch 穷举。运行时没有额外开销——attribute 只是元数据标记,不影响 JIT 生成的代码。
.NET 11 Preview 5 的 BCL 还没内置 ClosedAttribute。使用 closed 的项目需要自己声明:
namespace System.Runtime.CompilerServices;
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class ClosedAttribute : Attribute { }
ClosedAttribute 在 GA 前会移入 BCL。届时你不再需要手动声明。目前最简单的方式是项目里放一个 ClosedAttribute.cs 文件,GA 后删掉。
| 层面 | 发生了什么 |
|---|---|
| 编译期 | 编译器看到 [Closed] → 枚举同一程序集内所有直接派生类 → 在每次 switch 时用这个列表做穷举验证。新加派生类 → 重算列表 → 之前通过的 switch 可能现在失败。 |
| 运行时 | 什么也不发生。closed 是纯粹的编译期概念——CLR 不感知它。和 abstract class 完全一样的运行时行为。 |
| 跨程序集 | 引用方编译器看到 [Closed] → 拒绝在该程序集外定义直接派生类。这是编译期检查,不是运行时反射。 |
public closed record class HttpResponse
{
public required int StatusCode { get; init; }
public required Dictionary<string, string> Headers { get; init; }
public abstract string Summarize();
}
public record class Success<T>(T Body) : HttpResponse
{
public override string Summarize() => $"200 OK: {Body}";
}
public record class Redirect(string Location) : HttpResponse
{
public override string Summarize() => $"302 → {Location}";
}
public record class ClientError(string Message) : HttpResponse
{
public override string Summarize() => $"4xx: {Message}";
}
public record class ServerError(Exception Error) : HttpResponse
{
public override string Summarize() => $"5xx: {Error.Message}";
}
// 使用——StatusCode 在基类定义一次,所有子类自动拥有
var response = new Success<string>("ok") { StatusCode = 200, Headers = [] };
Console.WriteLine(response.StatusCode); // 200——基类属性,不是 Success 独有
这个场景如果用 Union,每种响应都要重复声明 StatusCode 和 Headers——样板地狱。Closed Hierarchy 让你在基类定义一次,所有派生类自动继承。
public closed record class GateState
{
public abstract bool CanPass();
public abstract GateState Next();
}
public record class Closed : GateState
{
public override bool CanPass() => false;
public override GateState Next() => new Open(100);
}
public record class Open(float Percent) : GateState
{
public override bool CanPass() => Percent > 0;
public override GateState Next() => new Closed();
}
public record class Locked(string Reason) : GateState
{
public override bool CanPass() => false;
public override GateState Next() => new Closed();
}
// 处理——用 switch 穷举所有状态,也可以用虚方法多态
string Describe(GateState state) => state switch
{
Closed => "关",
Open(var p) => $"开 {p}%",
Locked(var r) => $"锁: {r}",
};
// 或者直接用虚方法——两种方式都能获得编译期穷举检查
if (!state.CanPass()) Console.WriteLine("禁止通行");
CanPass(),编译器不会警告(基类的 abstract 会强制 override,但如果加了非 abstract 的虚方法就不一定了)。穷举检查来自 switch 表达式,不是虚方法。两种机制互补:虚方法给多态,switch 给穷举检查。
public closed record class PaymentMethod
{
public abstract Task<bool> Process(decimal amount);
public abstract string DisplayName { get; }
}
public record class CreditCard(string Number, string Cvv) : PaymentMethod
{
public override string DisplayName => $"信用卡 ****{Number[^4..]}";
public override Task<bool> Process(decimal amount) => /* 调用银行网关 */;
}
public record class WeChatPay(string OpenId) : PaymentMethod
{
public override string DisplayName => "微信支付";
public override Task<bool> Process(decimal amount) => /* 调用微信 API */;
}
public record class Alipay(string UserId) : PaymentMethod
{
public override string DisplayName => "支付宝";
public override Task<bool> Process(decimal amount) => /* 调用支付宝 API */;
}
// ──── 使用端 ────
async Task Pay(PaymentMethod method, decimal amount)
{
var typeLabel = method switch // 穷举检查——所有支付方式被覆盖
{
CreditCard => "信用卡",
WeChatPay => "微信",
Alipay => "支付宝",
};
Console.WriteLine($"使用 {typeLabel} 支付 {amount} 元");
await method.Process(amount); // 多态——每种支付方式有不同的处理逻辑
}
// 新加支付方式——改两处:加子类 + 更新 switch(编译器帮你找到所有 switch)
// public record class UnionPay(string BankCode) : PaymentMethod { ... }
// → 所有 PaymentMethod 的 switch 自动亮警告
| sealed | closed | union | abstract | |
|---|---|---|---|---|
| 子类型数量 | 0 | 有限个(同一程序集) | 有限个(在 union 声明中列出) | 无限(任意程序集) |
| 穷举检查 | N/A(无子类型可检查) | ✅ switch 覆盖所有直接子类 | ✅ switch 覆盖所有 case | ❌ 编译器不知道有多少子类 |
| 子类型能共享基类代码 | N/A | ✅ 继承字段、方法、protected | ❌ 无基类概念 | ✅ 标准继承 |
| 外部程序集能加子类 | ❌ | ❌(但外部可派生非 closed 的间接子类) | ❌ | ✅ |
| 存储类型 | 任意 | 引用类型 | struct(默认) | 引用类型 |
| 适合场景 | "这就是最终形态" | "子类到此为止——但我需要继承" | "这些类型互不相关——恰好属于同一集合" | "未来可能有新子类——来自任何地方" |
sealed = 零子类 · closed = 有限子类+继承 · union = 有限子类+无继承 · abstract = 无限子类。从最封闭到最开放——选你在安全性和灵活性之间的平衡点。
| 限制 | 详情 | 影响 |
|---|---|---|
| 需手动声明 ClosedAttribute | Preview 5 BCL 尚未内置——需项目内手动声明 | 小——一个文件的事,GA 后删掉 |
| 只支持 class/record class | closed struct 不合法——struct 本身不能继承 | 无——struct 无法被继承,closed 无意义 |
| 穷举检查只在 switch 表达式 | 和 Union 一致——普通 if/else 不触发穷举警告 | 风格约束——多用 switch 表达式 |
| 不支持泛型约束 | 不能写 where T : closed | 无法写泛型工具方法——和 Union 的泛型约束缺失对称 |
| 不传递 | 非 closed 的直接子类可被外部程序集派生 | 设计如此——见 §4.1。如果需要全链封闭,每层都加 closed |
closed class 的"封闭"边界是什么?
以下关于 closed 和 sealed 的说法,哪个正确?
public closed record class Animal,同程序集有 Mammal : Animal(Mammal 不加 closed)。外部程序集的 Dog : Mammal 合法吗?
以下哪个场景最适合用 Closed Hierarchy 而非 Union?
关于 C# 15 Closed Hierarchy,以下哪个说法正确?
| 要点 | 一句话 |
|---|---|
| 声明 | public closed record class Base; —— 基类前加一个词 |
| 封闭边界 | 同一程序集内可派生——跨 .dll 不行 |
| 隐式 abstract | 不能 new——和 abstract class 一样;不能和 sealed/static 共存 |
| 穷举检查 | switch 覆盖所有直接派生类 = 通过——和 Union 同一套机制 |
| 非传递 | 封闭只约束直接子类——非 closed 的间接子类可被外部派生 |
| 编译器降低 | [Closed] attribute + 隐式 abstract——纯编译期,运行时零开销 |
| vs Union | 需要继承 → Closed Hierarchy;不需要 → Union——穷举检查能力相同 |
| vs sealed | sealed = 0 子类;closed = 有限子类+继承+穷举检查 |
| 状态 | .NET 11 Preview 5 已合并——GA 预计 2026.11 |
closed 让你同时拥有两者。如果你的类型族有自然的继承关系(共享字段、虚方法、模板方法模式),Closed Hierarchy 是你的首选。
Closed Hierarchies 和 Union 构成了 C# 15 穷举检查的双支柱——一个管有继承的类型族,一个管无关联的 case 集合。下节课转向集合表达式的实用增强:
[with(capacity: 100), .. items] —— 向集合构造器传参,一行指定容量/比较器。
["key": value] 语法——字典字面量。仍在开发中,可能随后续 Preview 发布。
声明指针不再需要 unsafe 块——解引用仍需 unsafe。多版本演进第一步。
← L25: Union 类型 · L27: Collection Expression Arguments → · 🎯 回顾 Mission