Lesson 26: C# 15 — Closed Hierarchies Closed Class Hierarchies

.NET 11 Preview · 继承树上的穷举检查 · Union 的自然搭档

前置:已完成 Lesson 25(Union 类型)。本课是 C# 15 系列的第二课——当你需要继承关系,又想获得 Union 级别的穷举检查。
状态:.NET 11 Preview 5 Preview 已合并。Preview 5 需手动声明 ClosedAttribute——GA 前 BCL 会内置。
阅读:C# 15 新增功能 · closed 修饰符语言参考 · 设计提案 #9499

〇、Union 做不到的事 What Unions Can't Do

上节课我们学了 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 修饰符回答了这个问题:继承层次 + 编译期穷举 = 同时拥有两边的优势。

一、基本语法 Closed Class Declaration

1.1 一行声明——和 Union 一样简洁

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"几乎一样的声明量。

1.2 跨程序集受限——封闭的边界

// ──── 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)。

1.3 隐式 abstract——不需要显式写

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,重复多余

1.4 为什么叫 closed?——关键词的象征意义

这个关键词有三层考量:

层面含义
类型论——"封闭世界" 在类型系统中,封闭类型(Closed Type)指编译器能枚举出完整的、有限的子类型集合——"这个集合已经封闭,不会再有新的了。"普通 abstract class 是开放类型——外部程序集随时可能加新子类。加 closed 就把这个集合封口了,编译器可以放心穷举。
语义梯度——补全 sealedabstract 之间的空白 sealed = 零子类(彻底封死)→ closed = 有限子类,集合已知(封口但不封死)→ abstract = 无限子类,集合未知(完全开放)。closedsealedabstract 之间的中间地带——比 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 形成自然梯度、且不被现有特性抢占的词。

二、穷举检查——和 Union 一模一样的体验 Exhaustive Pattern Matching

2.1 不加 default——编译器说 OK

string Describe(GateState state) => state switch
{
    Closed => "门关着",
    Open(var percent) => $"门开着 {percent}%",
    Locked(var reason) => $"门锁了: {reason}",
};  // ✅ 编译通过——三个直接派生类全部覆盖,不需要 _ 兜底

2.2 加新子类 → 全面亮警告

// 六个月后,有人加了第四种状态:
public record class Maintenance(string Worker) : GateState;

// 💡 编译器立即在所有 switch 上亮警告:
// "The switch expression does not handle 'Maintenance'"
// 同一个程序集内的所有 switch 全部收到——无一遗漏
这和 Union 的穷举检查是同一套机制。编译器在编译时枚举 closed 基类的所有直接派生类(只能在本程序集内),验证 switch 覆盖了每一个。加一个新派生类 → 所有 switch 亮 CS8509 警告。继承层次第一次有了和 Union 一样的编译期安全网。

三、Union vs Closed Hierarchy——选型决策 Choosing Between Them

3.1 看两组代码——一眼看出区别

同样的"三种消息类型",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 的 TextMessage / ImageMessage / Reaction ——字段集完全不同,没有任何共享成员,三个独立类型恰好属于"聊天事件"这个集合。Closed Hierarchy 的 Email / Sms / Push ——字段也各不相同,但CreatedAt 和 Send() 是所有通知的共性,天然属于基类。

记法:Union = "形状各异,凑成一堆" · Closed Hierarchy = "各有特征,共享骨架"。

两者都能穷举检查。区别在于其他维度:

维度UnionClosed Hierarchy
case 类型之间的关系无关——各自独立定义,字段集可以完全不同有共同基类——"是一种"关系,共享基类成员
共享字段/方法❌ 无——每个 case 类型独立定义所有字段✅ 基类定义一次,所有子类自动继承
基类行为无基类——union 是 struct可以有虚方法、抽象方法、模板方法模式
外部扩展❌ 封闭——新 case 必须改 union 声明❌ 封闭——新子类必须在同一程序集
多态调度通过 switch 模式匹配switch 模式匹配 虚方法调用
存储开销struct + object?——值类型装箱引用类型——天然在堆上,无额外装箱
典型场景API 返回值(Data / Error / Pending)
聊天事件(文字 / 图片 / 表情)
AST 节点(Literal / BinaryExpr / Variable)
通知渠道(邮件 / 短信 / 推送)
支付方式(信用卡 / 微信 / 支付宝)
HTTP 响应(Success / Redirect / Error)

3.2 一句话决策

如果子类型有共同的字段或方法需要复用 → Closed Hierarchy。如果子类型只是恰好属于同一集合,没有任何共享代码 → Union。

说人话:写代码时问自己一个问题——"如果我把这些类型放进一个继承层次,基类里能写什么?" 如果基类是空的(没有共享字段、没有共享方法),用 Union。如果基类里有实质内容(CreatedAt、Send()、StatusCode……),用 Closed Hierarchy。两者的穷举检查能力完全相同——选哪个取决于你需不需要继承

3.3 边界案例——当两种都能用时

有时同一个领域模型两种都能表达,选哪个取决于你预期未来会不会长出共享行为。比如一个简单的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

四、非传递性——封闭只到直接子类 Non-Transitive Closure

4.1 封闭不向下传递

一个常见的误解:以为 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 穷举只需要覆盖 MammalBird——不需要知道 DogCat 的存在。穷举检查的粒度是直接派生类

4.2 如果你想让子类也封闭

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 是传递的,继承层次中每一层都自动封闭——这会极大地限制外部程序集的扩展能力。非传递性给你精细控制:你可以在继承链的任意一层选择"到此为止",而让其他层保持开放。

五、编译器生成了什么? Compiler Lowering

5.1 一个 attribute 搞定

和 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 生成的代码。

5.2 Preview 5 的临时负担

.NET 11 Preview 5 的 BCL 还没内置 ClosedAttribute。使用 closed 的项目需要自己声明:

namespace System.Runtime.CompilerServices;

[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class ClosedAttribute : Attribute { }
和 Lesson 25 Union 的 Preview 5 情况类似:ClosedAttribute 在 GA 前会移入 BCL。届时你不再需要手动声明。目前最简单的方式是项目里放一个 ClosedAttribute.cs 文件,GA 后删掉。

5.3 编译期行为 vs 运行时行为

层面发生了什么
编译期编译器看到 [Closed] → 枚举同一程序集内所有直接派生类 → 在每次 switch 时用这个列表做穷举验证。新加派生类 → 重算列表 → 之前通过的 switch 可能现在失败。
运行时什么也不发生。closed 是纯粹的编译期概念——CLR 不感知它。和 abstract class 完全一样的运行时行为。
跨程序集引用方编译器看到 [Closed] → 拒绝在该程序集外定义直接派生类。这是编译期检查,不是运行时反射。

六、三个实战场景 Real-World Examples

6.1 HTTP 响应建模——共享 StatusCode 和 Headers

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 让你在基类定义一次,所有派生类自动继承。

6.2 状态机——门禁系统

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("禁止通行");
注意:虚方法调用本身没有穷举检查——如果你加了一个新状态但忘了 override CanPass(),编译器不会警告(基类的 abstract 会强制 override,但如果加了非 abstract 的虚方法就不一定了)。穷举检查来自 switch 表达式,不是虚方法。两种机制互补:虚方法给多态,switch 给穷举检查。

6.3 支付方式——策略模式 + 穷举检查

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 自动亮警告

七、一张大表——四种"封闭"方式的最终对比 The Complete Picture

sealedclosedunionabstract
子类型数量0有限个(同一程序集)有限个(在 union 声明中列出)无限(任意程序集)
穷举检查N/A(无子类型可检查)✅ switch 覆盖所有直接子类✅ switch 覆盖所有 case❌ 编译器不知道有多少子类
子类型能共享基类代码N/A✅ 继承字段、方法、protected❌ 无基类概念✅ 标准继承
外部程序集能加子类❌(但外部可派生非 closed 的间接子类)
存储类型任意引用类型struct(默认)引用类型
适合场景"这就是最终形态""子类到此为止——但我需要继承""这些类型互不相关——恰好属于同一集合""未来可能有新子类——来自任何地方"
记忆法:sealed = 零子类 · closed = 有限子类+继承 · union = 有限子类+无继承 · abstract = 无限子类。从最封闭到最开放——选你在安全性和灵活性之间的平衡点。

八、当前限制与未来 Current Limitations & Roadmap

限制详情影响
需手动声明 ClosedAttributePreview 5 BCL 尚未内置——需项目内手动声明小——一个文件的事,GA 后删掉
只支持 class/record classclosed struct 不合法——struct 本身不能继承无——struct 无法被继承,closed 无意义
穷举检查只在 switch 表达式和 Union 一致——普通 if/else 不触发穷举警告风格约束——多用 switch 表达式
不支持泛型约束不能写 where T : closed无法写泛型工具方法——和 Union 的泛型约束缺失对称
不传递非 closed 的直接子类可被外部程序集派生设计如此——见 §4.1。如果需要全链封闭,每层都加 closed

九、测验 Quiz

1/5 · 基本概念

closed class 的"封闭"边界是什么?

2/5 · 和 sealed 的区别

以下关于 closedsealed 的说法,哪个正确

3/5 · 非传递性

public closed record class Animal,同程序集有 Mammal : Animal(Mammal 不加 closed)。外部程序集的 Dog : Mammal 合法吗?

4/5 · Union vs Closed Hierarchy

以下哪个场景最适合用 Closed Hierarchy 而非 Union?

5/5 · 综合判断

关于 C# 15 Closed Hierarchy,以下哪个说法正确

十、总结 Summary

要点一句话
声明public closed record class Base; —— 基类前加一个词
封闭边界同一程序集内可派生——跨 .dll 不行
隐式 abstract不能 new——和 abstract class 一样;不能和 sealed/static 共存
穷举检查switch 覆盖所有直接派生类 = 通过——和 Union 同一套机制
非传递封闭只约束直接子类——非 closed 的间接子类可被外部派生
编译器降低[Closed] attribute + 隐式 abstract——纯编译期,运行时零开销
vs Union需要继承 → Closed Hierarchy;不需要 → Union——穷举检查能力相同
vs sealedsealed = 0 子类;closed = 有限子类+继承+穷举检查
状态.NET 11 Preview 5 已合并——GA 预计 2026.11
最值得记住的一件事:继承层次第一次拥有了编译期穷举检查。你不再需要"要么放弃继承改用 Union,要么放弃穷举检查接受运行时异常"——closed 让你同时拥有两者。如果你的类型族有自然的继承关系(共享字段、虚方法、模板方法模式),Closed Hierarchy 是你的首选。

十一、下一步 Next: Collection Expression Arguments

Closed Hierarchies 和 Union 构成了 C# 15 穷举检查的双支柱——一个管有继承的类型族,一个管无关联的 case 集合。下节课转向集合表达式的实用增强:

📦 Collection Expression Args

[with(capacity: 100), .. items] —— 向集合构造器传参,一行指定容量/比较器。

🗂️ Dictionary Expressions

["key": value] 语法——字典字面量。仍在开发中,可能随后续 Preview 发布。

🔒 Memory Safety

声明指针不再需要 unsafe 块——解引用仍需 unsafe。多版本演进第一步。

← L25: Union 类型 · L27: Collection Expression Arguments → · 🎯 回顾 Mission