Appearance
委托、事件、Lambda
委托是什么?
委托 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)只要参数和返回值匹配的方法,都可以赋给这个委托。
委托有什么用
- 把方法当参数传递
c
void Calculate(int a, int b, Operation op)
{
Console.WriteLine(op(a, b));
}
Calculate(1, 2, Add);- 做回调 callback
c
void Download(Action onFinished)
{
// 下载完成后
onFinished();
}- 事件 event 的基础
C# 的事件底层就是基于委托:
c
public event Action Clicked;- 多播委托
一个委托可以绑定多个方法:
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 这类把行为当参数传递的场景。
事件和委托有什么区别?
委托是类型安全的方法引用;事件是对委托的封装,用来做发布订阅,限制外部只能订阅和取消订阅。
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);
}
}核心区别
| 对比点 | delegate | event |
|---|---|---|
| 本质 | 方法引用类型 | 对委托的封装 |
| 外部能否调用 | 如果可访问,可以直接调用 | 不能直接触发 |
| 外部能否赋值 | 如果可访问,可以重新赋值 | 只能 += / -= |
| 用途 | 回调、策略、函数参数 | 发布订阅、通知机制 |
| 控制权 | 调用方可能控制太多 | 发布者控制触发时机 |
面试收尾:
委托是事件的基础,但事件比委托多了一层封装。事件保护了发布者的控制权,外部只能订阅或取消订阅,不能随便清空订阅列表,也不能越权触发事件。
Action、Func、Predicate 分别是什么?
Action、Func、Predicate 都是 .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); // 3Func 的最后一个泛型参数是返回值类型:
c
Func<TInput, TResult>
Func<T1, T2, TResult>Predicate
c
Predicate<int> isEven = x => x % 2 == 0;
Console.WriteLine(isEven(4)); // truePredicate<T> 专门表示“判断某个 T 是否满足条件”,本质上可以理解成:
c
Func<T, bool>面试收尾:
Action用于无返回值操作,Func用于有返回值计算,Predicate用于条件判断。它们本质都是委托,常和 lambda、LINQ、回调一起使用。
闭包是什么?
闭包就是:函数捕获了外部变量,并且在外部作用域结束后,仍然可以继续访问这些变量。
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 表达式不一定产生闭包;只有当 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 捕获外部局部变量时会形成闭包。闭包会让被捕获变量的生命周期延长,并可能产生额外对象分配。性能敏感场景要注意捕获;如果不想允许捕获,可以使用
staticlambda。
闭包捕获变量有什么坑?
闭包最大的坑是:捕获的是变量,不是当时的值。
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,取决于执行时机。闭包变量共享后,要小心并发和时序问题。
面试收尾:
闭包很方便,但要记住它捕获的是变量本身,会延长变量生命周期,也可能产生额外分配。循环、异步、多线程和事件订阅场景里尤其要小心。
多播委托是什么?
多播委托就是:一个委托变量可以绑定多个方法,调用时按顺序依次执行。
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# 的事件机制底层就大量依赖多播委托。
事件没有取消订阅可能造成什么问题?
事件不取消订阅,可能导致订阅者对象一直被发布者引用,无法被 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 中避免事件内存泄漏的核心是:订阅和退订成对出现,尤其是长生命周期发布者、静态事件、全局 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做兜底;静态事件和全局事件总线尤其要谨慎。
回调、事件、消息系统有什么区别?
回调是点对点把函数传过去;事件是发布者通知多个订阅者;消息系统是通过消息总线/调度器做更大范围解耦。
| 方式 | 核心 | 适合场景 |
|---|---|---|
| 回调 | 把方法当参数传给别人 | 一次性完成通知、异步结果 |
| 事件 | 发布者维护订阅列表 | 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,什么时候用自定义事件总线?
对象内部或局部状态通知,用 C# event;跨模块、广播式、发送方不该知道接收方时,用自定义事件总线。
用 C# event 的场景
适合关系明确、范围较小的通知:
c
class Player
{
public event Action<int> HpChanged;
public void Damage(int value)
{
HpChanged?.Invoke(value);
}
}比如:
Button.ClickedPlayer.HpChangedInventory.ItemAddedDownload.Completed- 一个对象通知它附近的几个依赖对象
优点是类型清楚、调用关系直观、调试容易。
用事件总线的场景
适合发送方不想知道谁接收:
c
EventBus.Publish(new PlayerDiedMessage(playerId));比如:
- 玩家死亡后,任务系统、UI、音效、统计系统都要响应
- 成就系统监听很多玩法事件
- 跨模块广播通知
- 插件式系统
- Unity 中多个系统之间解耦通信
事件总线的优点是解耦,但代价是调用链隐藏,调试更难,也更容易出现重复订阅、忘记退订、消息泛滥。
选择口诀
近距离状态变化用
event;远距离跨模块通知用EventBus。
面试加分点:
不要把事件总线当万能工具。模块内部优先用普通方法、接口或
event,只有确实需要跨模块广播、发送方不应该依赖接收方时,才引入事件总线。事件总线最好用强类型消息、明确命名、可退订 token、日志追踪和生命周期管理。