Skip to content

委托、事件、Lambda

委托是什么?

delegate-explained

委托 delegate类型安全的方法引用。它可以把方法像变量一样保存、传递和调用。

c
delegate int Operation(int a, int b);

int Add(int a, int b)
{
    return a + b;
}

Operation op = Add;

Console.WriteLine(op(1, 2)); // 3

这里 Operation 定义了方法签名:

c
int 方法名(int a, int b)

只要参数和返回值匹配的方法,都可以赋给这个委托。

委托有什么用

  1. 把方法当参数传递
c
void Calculate(int a, int b, Operation op)
{
    Console.WriteLine(op(a, b));
}

Calculate(1, 2, Add);
  1. 做回调 callback
c
void Download(Action onFinished)
{
    // 下载完成后
    onFinished();
}
  1. 事件 event 的基础

C# 的事件底层就是基于委托:

c
public event Action Clicked;
  1. 多播委托

一个委托可以绑定多个方法:

c
Action action = MethodA;
action += MethodB;

action(); // 先调用 MethodA,再调用 MethodB

常用内置委托

很多时候不用自己定义 delegate,直接用内置泛型委托:

c
Action<string> log = message => Console.WriteLine(message); // 无返回值

Func<int, int, int> add = (a, b) => a + b; // 有返回值

Predicate<int> isEven = x => x % 2 == 0; // 返回 bool

面试收尾:

委托可以理解成“方法类型的变量”。它要求方法签名匹配,调用委托就是调用它指向的方法。它常用于回调、事件、策略替换和 LINQ 这类把行为当参数传递的场景。

事件和委托有什么区别?

event-vs-delegate

委托是类型安全的方法引用;事件是对委托的封装,用来做发布订阅,限制外部只能订阅和取消订阅。

c
public delegate void NotifyHandler(string message);

委托可以像变量一样赋值、调用:

c
NotifyHandler handler = msg => Console.WriteLine(msg);

handler("hello");

事件基于委托:

c
public event NotifyHandler OnNotify;

外部只能这样用:

c
publisher.OnNotify += Handler;
publisher.OnNotify -= Handler;

外部不能这样做:

c
publisher.OnNotify = null;   // 不允许
publisher.OnNotify("hi");    // 不允许

只有声明事件的类内部可以触发:

c
class Publisher
{
    public event Action<string> OnMessage;

    public void Send(string message)
    {
        OnMessage?.Invoke(message);
    }
}

核心区别

对比点delegateevent
本质方法引用类型对委托的封装
外部能否调用如果可访问,可以直接调用不能直接触发
外部能否赋值如果可访问,可以重新赋值只能 += / -=
用途回调、策略、函数参数发布订阅、通知机制
控制权调用方可能控制太多发布者控制触发时机

面试收尾:

委托是事件的基础,但事件比委托多了一层封装。事件保护了发布者的控制权,外部只能订阅或取消订阅,不能随便清空订阅列表,也不能越权触发事件。

ActionFuncPredicate 分别是什么?

action-func-predicate

ActionFuncPredicate 都是 .NET 内置的泛型委托,用来少写自定义 delegate

类型含义返回值
Action表示一个动作没有返回值,void
Func表示一个函数/计算有返回值
Predicate表示一个判断条件返回 bool

Action

c
Action<string> log = message =>
{
    Console.WriteLine(message);
};

log("hello");

Action<T> 适合只执行动作、不需要返回结果的场景。

Func

c
Func<int, int, int> add = (a, b) => a + b;

int result = add(1, 2); // 3

Func 的最后一个泛型参数是返回值类型:

c
Func<TInput, TResult>
Func<T1, T2, TResult>

Predicate

c
Predicate<int> isEven = x => x % 2 == 0;

Console.WriteLine(isEven(4)); // true

Predicate<T> 专门表示“判断某个 T 是否满足条件”,本质上可以理解成:

c
Func<T, bool>

面试收尾:

Action 用于无返回值操作,Func 用于有返回值计算,Predicate 用于条件判断。它们本质都是委托,常和 lambda、LINQ、回调一起使用。

闭包是什么?

closure-explained

闭包就是:函数捕获了外部变量,并且在外部作用域结束后,仍然可以继续访问这些变量。

c
Func<int> CreateCounter()
{
    int count = 0;

    return () =>
    {
        count++;
        return count;
    };
}

var counter = CreateCounter();

Console.WriteLine(counter()); // 1
Console.WriteLine(counter()); // 2
Console.WriteLine(counter()); // 3

按理说 CreateCounter() 执行完后,局部变量 count 应该结束生命周期。但 Lambda 捕获了它,所以编译器会把 count 放到一个隐藏对象里,让 Lambda 持有这个对象。于是 count 活得比原方法更久。

关键点

闭包捕获的是变量本身,不是当时的值:

c
int x = 1;

Func<int> f = () => x;

x = 10;

Console.WriteLine(f()); // 10

所以面试可以这样说:

闭包是函数和它捕获的外部变量组成的结构。C# 中 Lambda 如果使用了外部局部变量,就会形成闭包;编译器通常会生成隐藏类来保存这些变量,从而延长变量生命周期。

加分点:闭包可能产生额外对象分配;循环里捕获变量时尤其要小心。

Lambda 表达式会不会产生闭包?

lambda-closure

Lambda 表达式不一定产生闭包;只有当 Lambda 捕获了外部局部变量时,才会产生闭包。

不产生闭包

c
Func<int, int> f = x => x * 2;

这里 Lambda 只使用自己的参数 x,没有使用外部局部变量,通常不需要闭包对象。

会产生闭包

c
int factor = 2;

Func<int, int> f = x => x * factor;

Console.WriteLine(f(10)); // 20

这里 Lambda 使用了外部变量 factor,编译器会生成一个隐藏的闭包对象,把 factor 放进去。这样即使原方法结束了,Lambda 仍然能访问 factor

关键点:捕获的是变量,不是值

c
int n = 1;

Func<int> f = () => n;

n = 10;

Console.WriteLine(f()); // 10

很多人以为输出 1,其实是 10。因为闭包捕获的是变量 n 本身,而不是创建 Lambda 那一刻的值。

循环里的经典坑

c
var actions = new List<Action>();

for (int i = 0; i < 3; i++)
{
    actions.Add(() => Console.WriteLine(i));
}

foreach (var action in actions)
{
    action();
}

可能输出:

c
3
3
3

修法是每轮拷贝一份局部变量:

c
for (int i = 0; i < 3; i++)
{
    int copy = i;
    actions.Add(() => Console.WriteLine(copy));
}

面试收尾:

Lambda 捕获外部局部变量时会形成闭包。闭包会让被捕获变量的生命周期延长,并可能产生额外对象分配。性能敏感场景要注意捕获;如果不想允许捕获,可以使用 static lambda。

闭包捕获变量有什么坑?

closure-capture-pitfalls

闭包最大的坑是:捕获的是变量,不是当时的值

c
int x = 1;

Func<int> f = () => x;

x = 10;

Console.WriteLine(f()); // 10

很多人以为输出 1,其实输出 10。因为 Lambda 捕获的是变量 x 本身。

坑 1:循环变量

c
var actions = new List<Action>();

for (int i = 0; i < 3; i++)
{
    actions.Add(() => Console.WriteLine(i));
}

foreach (var action in actions)
{
    action();
}

可能输出:

c
3
3
3

因为多个 Lambda 捕获的是同一个 i

修法:

c
for (int i = 0; i < 3; i++)
{
    int copy = i;
    actions.Add(() => Console.WriteLine(copy));
}

坑 2:生命周期被延长

c
Action Create()
{
    var bigObject = new byte[1024 * 1024 * 100];

    return () => Console.WriteLine(bigObject.Length);
}

只要返回的委托还活着,bigObject 就可能不能被 GC 回收。

坑 3:额外分配

捕获外部变量时,编译器通常会生成隐藏闭包对象。高频路径里大量闭包可能带来额外分配和 GC 压力。

可以用 static lambda 避免捕获:

c
Func<int, int> f = static x => x * 2;

坑 4:异步/多线程读到变化后的值

c
int state = 1;

Task.Run(() => Console.WriteLine(state));

state = 2;

任务里读到的是 1 还是 2,取决于执行时机。闭包变量共享后,要小心并发和时序问题。

面试收尾:

闭包很方便,但要记住它捕获的是变量本身,会延长变量生命周期,也可能产生额外分配。循环、异步、多线程和事件订阅场景里尤其要小心。

多播委托是什么?

multicast-delegate

多播委托就是:一个委托变量可以绑定多个方法,调用时按顺序依次执行。

c
Action action = MethodA;
action += MethodB;
action += MethodC;

action(); // 依次调用 MethodA、MethodB、MethodC

可以用 += 添加方法,用 -= 移除方法:

c
action += MethodB;
action -= MethodB;

示例:

c
void A() => Console.WriteLine("A");
void B() => Console.WriteLine("B");

Action action = A;
action += B;

action();

输出:

c
A
B

注意点

如果多播委托有返回值:

c
Func<int> f = () => 1;
f += () => 2;

Console.WriteLine(f()); // 2

最终返回的是最后一个方法的返回值,前面方法的返回值会被忽略。所以多播委托更常用于 void 返回值场景,比如事件通知。

如果中间某个方法抛异常,后面的方法通常不会继续执行:

c
action += ThrowError;
action += MethodC;

action(); // ThrowError 抛异常后,MethodC 不会执行

面试收尾:

多播委托内部维护一个 invocation list 调用列表。+= 是把方法追加到列表,-= 是移除方法,调用委托时按顺序执行列表中的方法。C# 的事件机制底层就大量依赖多播委托。

事件没有取消订阅可能造成什么问题?

event-unsubscribe-leak

事件不取消订阅,可能导致订阅者对象一直被发布者引用,无法被 GC 回收,形成内存泄漏。

c
publisher.Changed += subscriber.OnChanged;

事件底层是委托。订阅后,发布者的事件调用列表里会保存:

c
目标对象 subscriber
目标方法 OnChanged

如果 publisher 生命周期很长,而 subscriber 本来应该被释放,但没有取消订阅:

c
publisher.Changed -= subscriber.OnChanged;

那么 publisher 仍然间接引用着 subscriber,GC 会认为它还活着。

可能造成的问题

问题说明
内存泄漏订阅者无法被回收
重复执行多次订阅但没取消,事件触发时执行多次
状态异常已经“关闭/销毁”的对象还收到事件
性能问题调用列表越来越长,触发事件越来越慢
难排查 bug对象看似不用了,却还在响应事件

典型危险场景:

c
长生命周期对象发布事件
短生命周期对象订阅事件
短生命周期对象没有退订

比如全局服务、单例、静态事件、UI 全局事件、消息总线。

常见解决方式

在不用时取消订阅:

c
public void Dispose()
{
    publisher.Changed -= OnChanged;
}

或者用一次性订阅、弱事件模式、消息总线的 unsubscribe token。

面试收尾:

事件订阅会让发布者持有订阅者的方法引用。如果发布者比订阅者活得更久,订阅者不退订就可能无法被回收。所以事件使用原则是:谁订阅,谁负责在合适时机取消订阅。

Unity 中事件系统怎么避免内存泄漏?

unity-event-memory-leak

Unity 中避免事件内存泄漏的核心是:订阅和退订成对出现,尤其是长生命周期发布者、静态事件、全局 EventBus、ScriptableObject 事件通道。

最常见写法:

c
private void OnEnable()
{
    PlayerHealth.OnDied += HandlePlayerDied;
}

private void OnDisable()
{
    PlayerHealth.OnDied -= HandlePlayerDied;
}

private void HandlePlayerDied()
{
}

如果对象禁用后不应该再收到事件,用 OnEnable / OnDisable 配对。

如果对象禁用后仍要接收事件,但销毁时必须清理,可以用:

c
private void Start()
{
    EventBus.OnMessage += HandleMessage;
}

private void OnDestroy()
{
    EventBus.OnMessage -= HandleMessage;
}

UnityEvent 也一样

c
private void OnEnable()
{
    button.onClick.AddListener(OnClick);
}

private void OnDisable()
{
    button.onClick.RemoveListener(OnClick);
}

避免匿名 lambda 订阅

容易退不掉:

c
button.onClick.AddListener(() => DoSomething());

更稳:

c
private UnityAction clickHandler;

private void Awake()
{
    clickHandler = () => DoSomething();
}

private void OnEnable()
{
    button.onClick.AddListener(clickHandler);
}

private void OnDisable()
{
    button.onClick.RemoveListener(clickHandler);
}

最危险的场景

  • static event
  • 单例管理器事件
  • 全局 EventBus
  • ScriptableObject Event Channel
  • UI 按钮重复 AddListener
  • 场景切换后旧对象没退订
  • 订阅用了 lambda,无法 Remove

面试收尾:

Unity 事件泄漏的本质是发布者通过委托调用列表持有订阅者引用。发布者活得更久时,订阅者不退订就可能无法回收,或者销毁后仍被回调。实践上用 OnEnable 订阅、OnDisable 退订,OnDestroy 做兜底;静态事件和全局事件总线尤其要谨慎。

回调、事件、消息系统有什么区别?

callback-event-message-system

回调是点对点把函数传过去;事件是发布者通知多个订阅者;消息系统是通过消息总线/调度器做更大范围解耦。

方式核心适合场景
回调把方法当参数传给别人一次性完成通知、异步结果
事件发布者维护订阅列表UI 点击、状态变化、对象通知
消息系统通过中介分发消息跨模块、跨系统、全局广播

回调

c
void Load(Action onFinished)
{
    // 加载完成
    onFinished?.Invoke();
}

Load(() => Console.WriteLine("Done"));

特点:简单直接,调用关系清楚。适合“一件事完成后通知我”。

事件

c
class Player
{
    public event Action<int> HpChanged;

    public void Damage(int value)
    {
        HpChanged?.Invoke(value);
    }
}

player.HpChanged += OnHpChanged;

特点:一对多,发布者不需要知道具体有多少订阅者。缺点是要注意取消订阅。

消息系统

c
EventBus.Publish(new PlayerDiedMessage(playerId));

EventBus.Subscribe<PlayerDiedMessage>(OnPlayerDied);

特点:发送方和接收方不直接认识,适合跨模块通信。缺点是链路更隐蔽,调试和治理更难。

面试收尾

回调适合明确的一对一流程;事件适合对象内部状态变化通知;消息系统适合跨模块解耦和广播。越往消息系统走,耦合越低,但调试、生命周期管理、命名规范和重复订阅治理也越重要。

什么时候用 C# event,什么时候用自定义事件总线?

event-vs-event-bus-choice

对象内部或局部状态通知,用 C# event;跨模块、广播式、发送方不该知道接收方时,用自定义事件总线。

用 C# event 的场景

适合关系明确、范围较小的通知:

c
class Player
{
    public event Action<int> HpChanged;

    public void Damage(int value)
    {
        HpChanged?.Invoke(value);
    }
}

比如:

  • Button.Clicked
  • Player.HpChanged
  • Inventory.ItemAdded
  • Download.Completed
  • 一个对象通知它附近的几个依赖对象

优点是类型清楚、调用关系直观、调试容易。

用事件总线的场景

适合发送方不想知道谁接收:

c
EventBus.Publish(new PlayerDiedMessage(playerId));

比如:

  • 玩家死亡后,任务系统、UI、音效、统计系统都要响应
  • 成就系统监听很多玩法事件
  • 跨模块广播通知
  • 插件式系统
  • Unity 中多个系统之间解耦通信

事件总线的优点是解耦,但代价是调用链隐藏,调试更难,也更容易出现重复订阅、忘记退订、消息泛滥。

选择口诀

近距离状态变化用 event;远距离跨模块通知用 EventBus

面试加分点:

不要把事件总线当万能工具。模块内部优先用普通方法、接口或 event,只有确实需要跨模块广播、发送方不应该依赖接收方时,才引入事件总线。事件总线最好用强类型消息、明确命名、可退订 token、日志追踪和生命周期管理。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0