接口被"冻住"了 17 年——C# 8 解冻了它,但也带来了新的取舍
??=(空合并赋值Null-coalescing Assignment)、静态局部函数Static Local Functions、只读结构体成员Readonly Struct Members 这些日常高频小特性。
在收尾之前,看看我们已经走了多远:
| 特性 | 课程 | 一句话 |
|---|---|---|
| 可空引用类型 Nullable Reference Types | 01 | 编译器帮你追踪 null——用类型系统消灭 NullReferenceException |
| Switch 表达式 & 模式匹配 Switch Expressions & Pattern Matching | 02 | switch 从语句升级为表达式,配合属性/元组/when 模式 |
| Using 声明 Using Declarations | 02 | 不需要嵌套大括号了,变量离开作用域自动释放 |
| 索引与范围 Indices & Ranges | 02 | ^1 取最后一个,.. 切片——从 Python 学来的 |
| 异步流 Async Streams | 03·04 | IAsyncEnumerable<T> + await foreach——边拉边处理 |
| 默认接口方法 Default Interface Methods | 06 | 接口里可以写方法体了 |
| 空合并赋值 Null-coalescing Assignment | 06 | ??=——只在 null 时赋值 |
| 静态局部函数 Static Local Functions | 06 | 防止局部函数意外捕获外部变量 |
| 只读结构体成员 Readonly Struct Members | 06 | 细粒度控制——struct 里单个成员标记 readonly |
异步释放 IAsyncDisposable + await using | 06 | 异步版 IDisposable——离开作用域自动 await DisposeAsync() |
场景:你维护一个基础库,定义了一个 IRepository<T> 接口,被团队 50 个类实现:
// v1.0 —— 发布时只有 3 个方法
public interface IRepository<T>
{
T GetById(int id);
void Add(T entity);
void Delete(int id);
}
// v2.0 —— 你想加一个批量查询方法:
public interface IRepository<T>
{
T GetById(int id);
void Add(T entity);
void Delete(int id);
IEnumerable<T> GetByIds(IEnumerable<int> ids); // ⚠️ 加了这行
}
// → 编译错误!50 个实现类全都缺少 GetByIds 方法
// → 要么改 50 个实现类,要么放弃加这个方法
这就是 接口进化问题API Evolution Problem:C# 1.0~7.3 的接口是"冻住的"——一旦发布,不能再加任何成员。Java(从 Java 8 开始)和 Swift 早就解决了这个问题,C# 直到 8.0 才跟上。
C# 8 允许在接口里给方法写默认实现:
public interface IRepository<T>
{
T GetById(int id);
void Add(T entity);
void Delete(int id);
// C# 8: 带默认实现的新方法——已有的 50 个实现类不需要改动!
IEnumerable<T> GetByIds(IEnumerable<int> ids)
{
foreach (var id in ids)
yield return GetById(id);
}
}
已有的 50 个实现类——它们没有显式实现 GetByIds——自动继承这个默认实现。新的实现类可以覆盖它(如果需要批量查询优化的话)。
virtual 方法 + 一个 sealed 方法(保证默认实现有地方存放)。实现类如果没有显式实现这个方法,CLR 走接口的默认实现;如果显式实现了,走实现类的版本——和类继承的虚方法分发类似,但发生在接口层面。
这是面试最喜欢考的 Default Interface Methods 题目:
public interface ILogger
{
void Log(string msg) => Console.WriteLine($"[Default] {msg}");
}
public class MyLogger : ILogger
{
// 没有实现 Log —— 用接口的默认实现
}
// 调用:
MyLogger logger = new MyLogger();
logger.Log("hello"); // ⚠️ 编译错误!MyLogger 类型上没有 Log 方法
ILogger iLogger = new MyLogger();
iLogger.Log("hello"); // ✅ 输出 [Default] hello —— 通过接口类型访问才走默认实现
默认接口方法只通过接口类型的引用访问——它是接口的成员,不是类的成员。这和 C++ 的多重继承不同。
类可以实现多个接口。如果两个接口都提供了同名方法的默认实现,怎么办?
public interface IFlyable
{
void Move() => Console.WriteLine("Flying");
}
public interface ISwimmable
{
void Move() => Console.WriteLine("Swimming");
}
// ❌ 编译错误:Duck 没有提供自己的 Move,两个接口都有默认实现 → 歧义
public class Duck : IFlyable, ISwimmable
{
// 空的——不写任何 Move 实现 → CS8705 编译错误
}
// ✅ 解决方案:显式实现消歧(或者提供一个 public Move() 统一定义)
public class Duck : IFlyable, ISwimmable
{
void IFlyable.Move() { Console.WriteLine("Flying"); }
void ISwimmable.Move() { Console.WriteLine("Swimming"); }
}
C# 的解决方案很务实:类自己的实现优先级最高,如果类没有实现,则必须显式消歧——不会像 C++ 那样悄无声息地调用一个"父类"版本。
| ✅ 用 Default Interface Methods | ❌ 不用(反模式) |
|---|---|
给已发布的接口加便捷方法(如 GetByIds 基于 GetById) | 在接口里塞入复杂的业务逻辑 |
| 接口的"可选"成员——实现者可以不管,需要时覆盖 | 把接口当抽象类用——共享字段、构造函数逻辑 |
跨平台的 .NET 运行时 API 进化(如 Span<T> 相关接口) | 为了省事——不想创建抽象类,直接用接口代替 |
| 为旧接口提供适应新特性的适配层(如给老接口加 async 版本) | 菱形继承——两个接口同名方法的意图完全不同 |
IList<T> 接口——它提供了 CopyTo、IndexOf 等方法的默认实现,基于 Count 和索引器。如果你的集合有高性能的自定义实现,就覆盖;没有,就用默认的——不会报错。这就是 API 进化API Evolution的正确姿势。
??=这是 C# 8 最简单的特性——从"检查 + 赋值"两行变一行:
// 旧写法(C# 7.3 及以前):
if (_cache == null)
_cache = LoadFromDb();
// 或者:
_cache = _cache ?? LoadFromDb();
// C# 8 新写法——只在左边是 null 时才赋值:
_cache ??= LoadFromDb();
本质上是 a ?? (a = b) 的语法糖。适用于惰性初始化、缓存填充、配置默认值等场景:
// 典型场景一:惰性初始化
private List<Order> _orders;
public List<Order> Orders => _orders ??= LoadOrdersFromDb();
// 典型场景二:字典缓存
public T GetOrCreate<T>(string key, Func<T> factory)
{
// 没有就创建,有了不覆盖
_cache[key] ??= factory();
return (_cache[key]);
}
局部函数(C# 7 引入)可以访问外部变量——但有时你不想它访问,因为这会导致隐式的堆分配(闭包):
// 问题:非 static 局部函数可能捕获外部变量
int factor = 10;
int total = 0;
int Multiply(int x)
{
return x * factor; // ← 捕获了外部变量 factor → 编译器生成闭包类 → 堆分配
}
// C# 8 解决:加 static —— 不允许捕获任何外部变量
static int Multiply(int x, int factor) // ← 加了 static
{
return x * factor; // factor 是参数,不是捕获的外部变量 → 无堆分配 ✅
}
// 如果你不小心引用了外部变量 → 编译错误!
// 这让你明确知道这函数没有闭包,性能更可预测
C# 7.2 引入了 readonly struct——整个结构体不可变。但有时你只想标记个别方法或属性为 readonly:
public struct Vector3
{
public float X, Y, Z;
// C# 8: 标记单个成员为 readonly
public readonly float Magnitude => MathF.Sqrt(X * X + Y * Y + Z * Z);
// 普通方法——可能修改字段
public void Translate(float dx, float dy, float dz)
{
X += dx; Y += dy; Z += dz;
}
}
这有什么好处?编译器在调用 readonly 成员时,会创建防御性拷贝defensive copy的规则更宽松——因为它知道这个成员不会修改结构体。在高性能场景(比如大量 Vector3 运算),这减少了很多不必要的拷贝。
readonly 标记告诉编译器"这个方法不会修改 struct"→ 编译器跳过防御性拷贝 → 更快。
await using回顾 Lesson 02 学的 using 声明——变量离开作用域时自动调用 Dispose()。但如果是异步资源(比如 EF Core 的 DbContext、网络流),Dispose() 可能是同步阻塞的——C# 8 提供了异步版本:
// 旧写法:手动 try-finally + await DisposeAsync()
var db = new AppDbContext();
try
{
var orders = await db.Orders.ToListAsync();
}
finally
{
await db.DisposeAsync(); // DisposeAsync 返回 ValueTask
}
// C# 8: await using —— 离开作用域自动调用 DisposeAsync()
await using var db = new AppDbContext();
var orders = await db.Orders.ToListAsync();
// } ← 离开作用域时,编译器生成 await DisposeAsync() 调用
IAsyncDisposable 是 IDisposable 的异步对等物,定义了 DisposeAsync() 方法(返回 ValueTask 而非 void)。await using 的工作方式和 using 完全一样,只是它 await 异步释放。
IDisposable 就够。运行时库中很多类型同时实现了 IDisposable 和 IAsyncDisposable——用 await using 会优先走异步路径。
IAsyncEnumerable<T> 的枚举器 IAsyncEnumerator<T> 本身也实现了 IAsyncDisposable——await foreach 在迭代结束时自动调用 DisposeAsync(),清理枚举器持有的异步资源。所以学异步流时你无形中已经在用 IAsyncDisposable 了。
六节课,你从 .NET Framework 4.8 的 C# 7.3 基线,完整覆盖了 C# 8 的所有重要特性:
| 能力维度 | 达成 |
|---|---|
| Null 安全 | Nullable Reference Types —— 编译期追踪 null,消灭 NRE |
| 模式匹配 | switch 表达式 + 属性模式 + 元组模式 + when —— 声明式分支 |
| 资源管理 | using 声明 —— 作用域结束自动释放 |
| 集合操作 | Indices/Ranges + yield return 状态机 —— 切片 + 惰性枚举 |
| 异步编程 | IAsyncEnumerable + await foreach + SynchronizationContext/ConfigureAwait |
| API 进化 | Default Interface Methods —— 安全地向接口添加新成员 |
| 性能与安全 | static local functions + readonly struct members + ??= |
| 异步资源 | IAsyncDisposable + await using —— 异步释放,配合异步流使用 |
public interface ISpeak
{
void Say() => Console.WriteLine("Interface");
}
public class Speaker : ISpeak { }
var s = new Speaker();
s.Say();
x ??= y 等价于什么?static 局部函数,以下哪句是正确的?IAsyncDisposable 和 await using,以下哪句是正确的?Lesson 06 · C# 8 收尾 · 下一课预告:C# 9 Records——不可变引用类型,值相等语义
有任何不清楚的地方?直接在对话中追问——Agent 就是你的私教。