Lesson 06: C# 8 收尾 — 默认接口方法 Default Interface Methods 与其他实用特性

接口被"冻住"了 17 年——C# 8 解冻了它,但也带来了新的取舍

前置 / Prerequisite:已完成 Lesson 05(SynchronizationContext),熟悉接口和多态的基本概念。
本课目标:理解默认接口方法 Default Interface Methods 解决的问题、它的实现机制、菱形继承问题Diamond Problem和正确使用场景;同时掌握 ??=(空合并赋值Null-coalescing Assignment)、静态局部函数Static Local Functions、只读结构体成员Readonly Struct Members 这些日常高频小特性。

零、C# 8 全景回顾

在收尾之前,看看我们已经走了多远:

特性课程一句话
可空引用类型 Nullable Reference Types01编译器帮你追踪 null——用类型系统消灭 NullReferenceException
Switch 表达式 & 模式匹配 Switch Expressions & Pattern Matching02switch 从语句升级为表达式,配合属性/元组/when 模式
Using 声明 Using Declarations02不需要嵌套大括号了,变量离开作用域自动释放
索引与范围 Indices & Ranges02^1 取最后一个,.. 切片——从 Python 学来的
异步流 Async Streams03·04IAsyncEnumerable<T> + await foreach——边拉边处理
默认接口方法 Default Interface Methods06接口里可以写方法体了
空合并赋值 Null-coalescing Assignment06??=——只在 null 时赋值
静态局部函数 Static Local Functions06防止局部函数意外捕获外部变量
只读结构体成员 Readonly Struct Members06细粒度控制——struct 里单个成员标记 readonly
异步释放 IAsyncDisposable + await using06异步版 IDisposable——离开作用域自动 await DisposeAsync()

第一部分:默认接口方法 Default Interface Methods — 接口进化之谜

1.1 现象——加一个方法,50 个实现全炸

场景:你维护一个基础库,定义了一个 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 才跟上。

1.2 机制——Default Interface Methods 怎么工作的

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 走接口的默认实现;如果显式实现了,走实现类的版本——和类继承的虚方法分发类似,但发生在接口层面。

1.3 最诡异的坑——不同调用方式,结果不同

这是面试最喜欢考的 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++ 的多重继承不同。

关键理解:默认接口方法不是"让接口变成抽象类"。它解决的是一个很窄的问题:API 进化API Evolution——允许接口在不破坏已有实现的前提下增加新成员。如果你发现自己大量使用默认实现来"共享代码",那说明你应该用抽象类或者组合模式。

1.4 菱形继承 Diamond Problem

类可以实现多个接口。如果两个接口都提供了同名方法的默认实现,怎么办?

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++ 那样悄无声息地调用一个"父类"版本。

1.5 正确使用场景 vs 反模式

✅ 用 Default Interface Methods❌ 不用(反模式)
给已发布的接口加便捷方法(如 GetByIds 基于 GetById在接口里塞入复杂的业务逻辑
接口的"可选"成员——实现者可以不管,需要时覆盖把接口当抽象类用——共享字段、构造函数逻辑
跨平台的 .NET 运行时 API 进化(如 Span<T> 相关接口)为了省事——不想创建抽象类,直接用接口代替
为旧接口提供适应新特性的适配层(如给老接口加 async 版本)菱形继承——两个接口同名方法的意图完全不同
.NET 团队自己怎么用?默认接口方法最经典的应用是 IList<T> 接口——它提供了 CopyToIndexOf 等方法的默认实现,基于 Count 和索引器。如果你的集合有高性能的自定义实现,就覆盖;没有,就用默认的——不会报错。这就是 API 进化API Evolution的正确姿势。

第二部分:几个日常高频小特性

2.1 空合并赋值 Null-coalescing Assignment??=

这是 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]);
}

2.2 静态局部函数 Static Local Functions — 防止意外捕获

局部函数(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 是参数,不是捕获的外部变量 → 无堆分配 ✅
}
// 如果你不小心引用了外部变量 → 编译错误!
// 这让你明确知道这函数没有闭包,性能更可预测
何时用 static local function?当局部函数不需要访问任何外部变量时——这强制了"纯函数"语义,编译器不会生成闭包,性能更好(无堆分配),意图也更清晰。在性能敏感的路径上尤其重要。

2.3 只读结构体成员 Readonly Struct Members — 细粒度 readonly

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 运算),这减少了很多不必要的拷贝。

编译器防御性拷贝简述:当 struct 的某个方法可能修改 struct 自身时,编译器在非安全上下文中会先拷贝一份再调用方法——防止意外修改原值。readonly 标记告诉编译器"这个方法不会修改 struct"→ 编译器跳过防御性拷贝 → 更快。

2.4 异步释放 IAsyncDisposable + 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() 调用

IAsyncDisposableIDisposable 的异步对等物,定义了 DisposeAsync() 方法(返回 ValueTask 而非 void)。await using 的工作方式和 using 完全一样,只是它 await 异步释放。

什么时候需要 IAsyncDisposable?当析构逻辑涉及异步 I/O 时——例如 EF Core 的 DbContext 需要把连接归还到连接池、异步文件流需要 flush 缓冲区。如果你只是持有托管内存(如 List<T>),普通的 IDisposable 就够。运行时库中很多类型同时实现了 IDisposableIAsyncDisposable——用 await using 会优先走异步路径。
和 Lesson 04 的连接:IAsyncEnumerable<T> 的枚举器 IAsyncEnumerator<T> 本身也实现了 IAsyncDisposable——await foreach 在迭代结束时自动调用 DisposeAsync(),清理枚举器持有的异步资源。所以学异步流时你无形中已经在用 IAsyncDisposable 了。

三、C# 8 完结——你掌握了什么

六节课,你从 .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 —— 异步释放,配合异步流使用
C# 8 收官。下一站:C# 9——Records、Init-only properties、Top-level statements、Pattern matching 增强。这些才是真正改变你日常编码方式的特性。

📝 小测验

Q1. 以下代码的输出是什么?

public interface ISpeak
{
    void Say() => Console.WriteLine("Interface");
}

public class Speaker : ISpeak { }

var s = new Speaker();
s.Say();
选择正确答案:

Q2. Default Interface Methods 的首要设计目的是什么?

选择最准确的描述:

Q3. 一个类同时实现 IFlyable 和 ISwimmable(两个接口都有 Move() 默认实现),且该类没有提供自己的 Move() 方法,会发生什么?

选择正确答案:

Q4. x ??= y 等价于什么?

选择正确的等价表达式:

Q5. 关于 static 局部函数,以下哪句是正确的?

选择正确的描述:

Q6. 关于 IAsyncDisposableawait using,以下哪句是正确的?

选择正确的描述:

Lesson 06 · C# 8 收尾 · 下一课预告:C# 9 Records——不可变引用类型,值相等语义

有任何不清楚的地方?直接在对话中追问——Agent 就是你的私教。