浏览知识库目录

C#

委托、事件、Lambda 与扩展成员

掌握委托、Lambda、事件生命周期及 C# 14 扩展块,安全组合行为。

委托、事件、Lambda 与扩展成员

委托把“可以调用的行为”变成类型安全的值;Lambda 提供紧凑实现;事件在发布者和订阅者之间建立通知;扩展成员则为无法修改的类型补充调用表面。这些工具都在组合行为,但生命周期和隐藏依赖必须清楚。


一、学习目标

  • 声明和调用自定义委托
  • 使用 ActionFunc 与 Lambda
  • 理解闭包捕获和委托组合
  • 正确发布、订阅和取消订阅事件
  • 编写传统扩展方法与 C# 14 扩展块
  • 避免把关键业务规则藏进回调

二、委托是类型安全的调用契约

public delegate bool TaskPredicate(StudyTask task);

static IReadOnlyList<StudyTask> Filter(
    IEnumerable<StudyTask> source,
    TaskPredicate predicate)
{
    var result = new List<StudyTask>();
    foreach (StudyTask task in source)
    {
        if (predicate(task))
        {
            result.Add(task);
        }
    }

    return result;
}

调用:

IReadOnlyList<StudyTask> pending = Filter(
    tasks,
    task => task.Status == TaskStatus.Todo);

委托实例包含目标方法;实例方法委托还持有目标对象引用。长期保存委托时,要考虑它是否让本应回收的对象继续存活。


三、Action 与 Func

常用签名无需自定义委托:

Action<string> writeLine = Console.WriteLine;
Func<StudyTask, bool> isPending =
    task => task.Status == TaskStatus.Todo;
Func<StudyTask, string> selectTitle =
    task => task.Title;

Action 无返回值,Func 的最后一个类型参数是返回值。若委托代表重要领域概念,或参数含义仅靠类型看不清,自定义命名委托仍更易读。

副作用回调要明确失败策略。多播委托中一个处理器抛异常时,后续处理器不会自动执行;不要把它当作可靠消息队列。


四、Lambda 与闭包

Lambda 可以捕获外层变量:

TaskStatus expected = TaskStatus.Todo;
Func<StudyTask, bool> matches =
    task => task.Status == expected;

捕获的是变量而不是声明瞬间的值:

expected = TaskStatus.Done;
bool result = matches(task); // 使用新的 expected

捕获可能生成额外对象,并延长外部对象生命周期。高频路径先测量;更重要的是避免回调依赖随时变化的隐藏状态。

无捕获 Lambda 可标记 static

Func<StudyTask, string> titleSelector =
    static task => task.Title;

编译器会阻止它读取局部变量,让依赖更显式。


五、事件用于通知

public sealed class TaskCompletedEventArgs(
    StudyTask task) : EventArgs
{
    public StudyTask Task { get; } = task;
}

public sealed class TaskService
{
    public event EventHandler<TaskCompletedEventArgs>? TaskCompleted;

    private void OnTaskCompleted(StudyTask task)
    {
        TaskCompleted?.Invoke(
            this,
            new TaskCompletedEventArgs(task));
    }
}

event 限制外部只能订阅和取消订阅,不能直接触发或替换委托。发布者决定何时通知。

订阅:

service.TaskCompleted += HandleCompleted;
try
{
    service.Complete(id);
}
finally
{
    service.TaskCompleted -= HandleCompleted;
}

长生命周期发布者会持有订阅者引用。不再需要通知时必须取消订阅,或使用与发布者相同的受控生命周期。


六、事件不是事务消息

进程内事件适合 UI 更新、指标或弱耦合通知,但不保证:

  • 进程崩溃后重放
  • 跨服务传递
  • 每个处理器都成功
  • 与数据库提交原子一致

如果“任务完成后必须同步远端”是业务要求,应把它纳入用例事务或可靠 outbox,而不是只挂一个事件处理器。

发布事件时还要决定处理器异常策略。默认直接传播最诚实;若要隔离处理器,逐个调用并收集失败,同时记录清晰结果。


七、传统扩展方法

public static class TaskExtensions
{
    public static string ToDisplayLabel(this StudyTask task)
    {
        ArgumentNullException.ThrowIfNull(task);
        string marker = task.Status == TaskStatus.Done ? "[x]" : "[ ]";
        return $"{marker} {task.Id}: {task.Title}";
    }
}

导入命名空间后:

Console.WriteLine(task.ToDisplayLabel());

扩展方法是静态方法的调用语法糖,不能访问类型私有成员,也不能真正修改原类型。实例成员与扩展成员同名时,实例成员优先。


八、C# 14 扩展块

C# 14 可以在顶层、非泛型静态类中声明扩展块:

public static class StudyTaskExtensions
{
    extension(StudyTask task)
    {
        public string DisplayLabel
        {
            get
            {
                string marker =
                    task.Status == TaskStatus.Done ? "[x]" : "[ ]";
                return $"{marker} {task.Id}: {task.Title}";
            }
        }

        public bool IsOlderThan(
            TimeProvider timeProvider,
            TimeSpan age) =>
            timeProvider.GetUtcNow() - task.CreatedAt > age;
    }
}

调用:

Console.WriteLine(task.DisplayLabel);
bool stale = task.IsOlderThan(timeProvider, TimeSpan.FromDays(30));

扩展块可以定义方法、属性和运算符,也可以带泛型接收者:

public static class SequenceExtensions
{
    extension<T>(IEnumerable<T> source)
    {
        public bool IsEmpty => !source.Any();
    }
}

扩展成员应补充局部、通用能力。若类型源码属于自己且能力是核心语义,优先修改类型本身。


九、行为参数与策略

public static IReadOnlyList<StudyTask> SortTasks(
    IEnumerable<StudyTask> tasks,
    Comparison<StudyTask> comparison)
{
    var result = tasks.ToList();
    result.Sort(comparison);
    return result;
}

调用方选择策略:

var sorted = SortTasks(
    tasks,
    static (left, right) =>
        left.CreatedAt.CompareTo(right.CreatedAt));

这种方式适合小型纯策略。策略需要配置、日志或多个方法时,建立命名接口和实现类型会比传递一组相关委托清楚。


十、常见错误

在循环中意外捕获可变变量

确认每个回调捕获的是独立局部值,并用测试验证延迟执行结果。

订阅后从不取消

长生命周期发布者会让订阅者无法回收,还可能重复处理通知。

用事件保证关键业务

普通内存事件没有持久性和交付保证。关键副作用需要显式可靠机制。

用扩展成员隐藏复杂 I/O

task.LoadRemoteState() 看似普通实例调用,却可能访问网络。扩展成员应保持语义透明。


十一、练习与自测

练习:

  1. Func<StudyTask,bool> 实现可组合筛选器。
  2. 用静态 Lambda 消除一次不必要捕获。
  3. 添加任务完成事件,并证明取消订阅后不再收到通知。
  4. 用 C# 14 扩展块实现 DisplayLabelIsEmpty

自测:

  • 委托实例可能持有哪些对象引用?
  • 多播委托中处理器抛异常会发生什么?
  • event 比公开委托字段多了什么限制?
  • 扩展成员和实例成员同名时调用哪个?

十二、官方资料

上一篇:可空引用、异常与资源安全 | 下一篇:LINQ、迭代器与惰性查询