很多人第一次在 Object Pascal 里看到procedure of object这种写法时,都会停顿一下:一个类型声明里为什么还带着of object?它既不像普通的过程类型,也不像接口,偏偏整个 Delphi 的事件体系——按钮点击、定时器触发、数据集滚动——全都建立在它上面。这就是本章要讲的委托概念:把一段行为(而不是一份数据)当作值来传递、保存和调用。在 Object Pascal 的语境里,委托最基础的形态是方法指针,最常见的落地形态是事件,最灵活的进化形态是匿名方法。搞懂这三层,你才算真正理解 Delphi 的组件模型是怎么把"谁来做"和"什么时候做"解耦开的。
这篇笔记面向两类人:一类是刚接触 Object Pascal、能写类和方法但一看到TNotifyEvent就发怵的初学者;另一类是写了好几年业务代码、事件赋值天天用却从没想过Self到底怎么传进去的老手。我会从委托要解决的问题讲起,拆到TMethod的内存布局,再往上讲事件接线、多播、匿名方法与闭包捕获,最后整理一份我自己踩过的坑和排查套路。文中代码都在 Delphi 11 的 Object Pascal 编译器下验证过,可以直接抄进控制台工程跑。
1. 委托在 Object Pascal 里到底解决什么问题
1.1 从"把一段行为当参数传"说起
写业务代码时经常遇到这种需求:一个流程跑到一半,需要通知外面"我到哪一步了",但流程本身不该知道外面是谁在听。比如一个下载器、一个批量导入器、一个计算耗时较长的算法,它们只需要在关键节点喊一嗓子,至于喊完之后是更新进度条、写日志还是弹窗,那是调用方的事。如果没有委托机制,你只有两条路可走:一是让流程类持有一个具体的窗体或日志对象引用,二是继承出一个子类重写某个空方法。前者把流程和界面焊死,后者每加一种通知方式就多一个类,都不划算。
委托给出的答案是:把"要执行的动作"本身变成一种可以赋值、可以存字段、可以作为参数传递的值。在 Object Pascal 里,这个"值"就是一个过程类型变量。它保存的不是数据,而是"跳到哪里去执行"的地址信息。调用方把方法赋值给它,流程类在合适的时机调用它,双方只通过类型签名约定接口,彼此不需要知道对方的存在。这就是解耦的本质,也是整个事件驱动模型的起点。
1.2 过程类型与方法指针,一字之差天壤之别
Object Pascal 里有两类可赋值的"行为类型",初学者最容易在这里混淆。
第一类是普通过程类型,它指向一个独立的函数或过程,没有隐含的实例参数:
type TIntPredicate = function(Value: Integer): Boolean; TIntAction = procedure(Value: Integer);这类变量能接收的只有全局函数、单元级的普通函数,或者用@取址得到的静态方法。把它理解成 C 语言里的函数指针就行,一个指针,指向代码段某个地址。
第二类是方法指针,声明时带of object后缀:
type TProgressHandler = procedure(Percent: Integer) of object;多出来的of object意味着:这个类型不仅能记住"代码在哪",还能记住"这段代码属于哪个对象实例"。因为类的方法在编译后只有一份代码,所有实例共用,方法体里访问字段时靠的是调用时传进来的隐藏参数Self。方法指针必须把这个Self一起保存下来,否则你调用Obj.DoSomething时它根本不知道该操作哪份数据。所以方法指针在内存里是两个指针,而普通过程类型只有一个。
这个差异带来的直接后果是:两者不能互相赋值。你写FOnClick := MyGlobalProc;会直接编译不过,因为大小和语义都不匹配。很多新手在这里卡住,以为是语法问题,实际上是类型系统在拦一个真会崩的操作。
1.3 为什么 Delphi 的事件模型非它不可
Delphi 的组件体系是围绕"发布者—订阅者"建的。一个按钮不知道谁会响应点击,一个定时器不知道谁会处理超时,它们只负责在自己的生命周期节点上,检查一下对应的委托字段是否有值,有就调用。这套设计能成立,靠的正是方法指针同时携带Self的能力:按钮把Sender参数传出去,接收方的方法就能直接访问自己的字段、改自己的状态,全程没有强制类型转换,也没有全局变量。
对比一下常见的替代方案就更清楚了。如果用虚方法实现,每个接收方都得继承发布者的基类,一个窗体要响应十个不同组件的事件就得继承十次,语言层面根本不支持。如果用接口实现,每个事件都要定义一个只有一个方法的接口,再写一个实现类,代码量翻好几倍。方法指针用一个类型声明就搞定了,代价是它只支持单播、不支持自动解绑,这两点后面会专门讲。
2. 拆开方法指针:TMethod 与 Self 的真实去向
2.1 TMethod 记录里到底存了什么
Object Pascal 在System单元里公开了一个记录类型TMethod,它的定义短得让人意外:
type TMethod = record Code: Pointer; Data: Pointer; end;Code是方法代码的入口地址,Data就是绑定的对象实例引用。任何方法指针都可以硬转成TMethod来查看内部结构,这在需要比较、需要序列化、需要判断"这个事件到底绑给了谁"的场景里极其有用。注意这是硬转换,不做任何运行时检查,编译器只是把同一块内存按另一种布局解释。所以你不能拿一个普通过程指针去转TMethod,那样Data里读到的是垃圾值,调用时必崩。
我在写组件库时最常用的一招是:在调试窗口里把一个事件字段转成TMethod,看Data是不是空。空就说明没绑定,非空就能顺着地址查是哪个对象注册的。这个技巧比到处打断点快得多,尤其是排查"事件被重复赋值"这类问题时。
2.2 一段可运行的验证代码
光说不练容易含糊,下面这段代码在控制台工程里跑一遍,Self的去向就一清二楚了:
program MethodPtrDemo; {$APPTYPE CONSOLE} type TReceiver = class public procedure Handle(Sender: TObject); end; procedure TReceiver.Handle(Sender: TObject); begin WriteLn('Handle 被调用,Self 地址非空,Sender = ', Sender.ClassName); end; var R: TReceiver; E: TNotifyEvent; M: TMethod; begin R := TReceiver.Create; try E := R.Handle; M := TMethod(E); WriteLn('Code 是否为空: ', M.Code <> nil); WriteLn('Data 是否指向 R: ', M.Data = Pointer(R)); WriteLn('已绑定: ', Assigned(E)); E(nil); // 直接调用,行为等同 R.Handle(nil) R.Handle(nil); // 两种写法等价 E := nil; WriteLn('清空后已绑定: ', Assigned(E)); finally R.Free; end; ReadLn; end;跑完之后你会看到Data 是否指向 R输出TRUE。这就证明了方法指针确实把实例引用一起存了下来。同时注意最后两行:E := nil之后Assigned(E)为False,这是所有事件字段在调用前必须检查的原因——未绑定的方法指针Code为nil,直接调用就是访问违例。
注意:
Assigned对方法指针的判断依据是Code字段是否为空,而不是Data。理论上存在Code非空但Data为空的情况(比如把某个类的静态方法硬转成方法指针),这种指针调用时会崩,不要自己造。
2.3 判断两个事件是不是同一个
多播、去重、解绑这类逻辑都需要比较"两个方法指针是否指向同一个对象的同一个方法"。直觉上写if A = B好像可以,编译器也确实允许方法指针用=比较,它会同时比较Code和Data。但如果类型不完全一致(比如一个是TNotifyEvent,一个是自定义的TProgressEvent),编译器会拒绝。稳妥的写法是自己写个比较函数,统一转成TMethod再比:
function SameMethod(const A, B: TMethod): Boolean; begin Result := (A.Code = B.Code) and (A.Data = B.Data); end;这里有个隐蔽的坑:不能只比Code。同一个类的同一个方法,被两个不同实例绑定时Code完全相同,只有Data不同。如果你写多播时只按Code去重,就会出现"注册了三个监听者,只触发一次"的诡异现象,而且极难查。我第一次写多播容器时就栽在这上面,调了整整一个下午。
2.4 内存布局带来的两个实际约束
第一个约束是大小。在 64 位编译器下SizeOf(TNotifyEvent)是 16 字节,而普通过程类型只有 8 字节。这意味着你不能把方法指针塞进一个只预留了指针大小的结构里,也不能用Move在两种类型之间随便搬。跨模块传委托时,类型必须严格一致,用Pointer中转再转回来是危险动作。
第二个约束是生命周期。Data只是一个裸指针,它不参与任何引用计数。对象被Free之后,事件字段里残留的方法指针依然"看起来"是有效的,Assigned返回True,一调用就崩。这是 Delphi 事件模型最容易出事的地方,后面第 3.4 节专门展开。
3. 事件机制:委托最主流的落地形态
3.1 一个事件字段的完整解剖
把TNotifyEvent拆开看,它的定义是procedure(Sender: TObject) of object。约定俗成的规则是:第一个参数永远叫Sender,指向触发事件的对象本身。这个设计不是随便定的——同一个方法往往要响应多个组件的事件,没有Sender就无法区分"是哪个按钮被点了",只能给每个按钮写一个方法。
一个标准的事件发布者长这样:
type TProgressEvent = procedure(Sender: TObject; Percent: Integer) of object; TImporter = class private FOnStart: TNotifyEvent; FOnProgress: TProgressEvent; FOnFinish: TNotifyEvent; FCurrent: Integer; public procedure Execute; property OnStart: TNotifyEvent read FOnStart write FOnStart; property OnProgress: TProgressEvent read FOnProgress write FOnProgress; property OnFinish: TNotifyEvent read FOnFinish write FOnFinish; end; procedure TImporter.Execute; var I: Integer; begin if Assigned(FOnStart) then FOnStart(Self); for I := 1 to 100 do begin FCurrent := I; if Assigned(FOnProgress) then begin FOnProgress(Self, I); if FCurrent < 0 then // 允许订阅方请求中止 Break; end; end; if Assigned(FOnFinish) then FOnFinish(Self); end;几个设计选择值得说清楚。第一,字段用F前缀、属性用On前缀,这是 Object Pascal 的通行约定,别自创风格,RAD 设计器能识别On开头的属性并自动生成事件处理程序。第二,Assigned检查是必须的,不是防御性编程的洁癖,而是语法层面的硬要求。第三,我特意留了FCurrent < 0这个中止判断——订阅方通过修改发布者的状态来反馈,比返回值更灵活,因为事件处理程序通常是procedure而不是function。
3.2 设计期的事件接线做了什么
在窗体设计器里双击按钮生成事件处理程序,IDE 背后做的事其实很朴素:在.dfm里记录一行OnClick = Button1Click,窗体加载时通过 RTTI 找到TButton的OnClick属性,把Form1.Button1Click这个方法指针赋给它。整个过程没有任何黑魔法,就是一次属性赋值。
理解了这一点,你就能解释很多"奇怪"现象。为什么在代码里手动赋值也能工作?因为设计期赋值和运行期赋值走的是同一条路径。为什么改了方法名之后运行报错说找不到?因为.dfm里还存着旧名字,RTTI 按名字查找失败,加载窗体时抛异常。为什么把窗体上的按钮删掉、事件方法还在?因为方法本身是普通的类方法,删控件不会删代码,只是没人再调用它了。
还有一个常被问到的细节:事件方法名后面那串数字,比如Button1Click改成FormCreate再改回来,签名必须严格匹配。签名不匹配时设计器会拒绝绑定,因为方法指针类型是强类型的,差一个参数都不行。
3.3 手写一个多播容器
Delphi 的事件字段天生只支持单播——后赋值的会覆盖先赋值的。但现实需求经常是多播:一个数据加载完成,日志模块要记一笔,状态栏要更新,缓存要预热。有三条路:一是让窗体写一个"分发方法",分别调用三个子方法,简单但每加一个订阅者都要改代码;二是用观察者模式搞一个接口列表,重但通用;三是自己封装一个多播委托容器,轻量且贴合现有风格。我一般选第三条。
type TMulticastNotify = class private FItems: TArray<TNotifyEvent>; public procedure Add(const AEvent: TNotifyEvent); procedure Remove(const AEvent: TNotifyEvent); procedure Fire(Sender: TObject); end; procedure TMulticastNotify.Add(const AEvent: TNotifyEvent); var N: Integer; begin if not Assigned(AEvent) then Exit; N := Length(FItems); SetLength(FItems, N + 1); FItems[N] := AEvent; end; procedure TMulticastNotify.Remove(const AEvent: TNotifyEvent); var I, J: Integer; M1, M2: TMethod; begin M1 := TMethod(AEvent); J := 0; for I := 0 to High(FItems) do begin M2 := TMethod(FItems[I]); if not ((M1.Code = M2.Code) and (M1.Data = M2.Data)) then begin FItems[J] := FItems[I]; Inc(J); end; end; SetLength(FItems, J); end; procedure TMulticastNotify.Fire(Sender: TObject); var Snapshot: TArray<TNotifyEvent>; E: TNotifyEvent; begin Snapshot := Copy(FItems); for E in Snapshot do if Assigned(E) then E(Sender); end;Fire里的那行Copy是整个类的关键。如果不做快照,直接遍历FItems,而某个订阅者在自己的回调里调用了Remove把自己摘掉,数组长度会在遍历途中变化,轻则漏调用,重则越界访问。这个坑非常隐蔽,因为大多数时候回调不会修改容器,只有在"只触发一次就自动注销"这类需求下才会暴露。快照的代价是一次数组拷贝,对于几个到几十个订阅者的规模完全无感。
3.4 悬空委托:对象没了,指针还在
前面提过,方法指针里的Data是裸指针,不参与引用计数。这意味着:订阅者对象被Free之后,发布者的事件字段里仍然留着指向已释放内存的委托。如果发布者活得更久,下次触发事件时就会访问违例,而且错误现场往往在发布者内部,堆栈指向一行无辜的FOnProgress(Self, I),真正的原因却早就不在那儿了。
标准解法是谁的生命周期长,谁负责清理。如果发布者是长生命周期的单例或全局服务,那么订阅者在析构时必须主动注销:
destructor TLogListener.Destroy; begin if Assigned(GlobalImporter) then GlobalImporter.OnProgress := nil; inherited; end;如果发布者本身是TComponent的后代,还有更省事的办法——利用组件的FreeNotification机制。组件被释放时会通知所有在它这里登记过的对象,TComponent系的事件订阅方可以重写Notification方法收到这个消息,在里面把事件置空。这在写第三方控件时几乎是标配,因为你不控制使用者的生命周期。
注意:不要指望
TComponent.Owner能自动处理委托引用。Owner只负责"我创建的对象由我释放",和你手动赋给事件的方法指针没有任何关系。两者是完全独立的机制。
4. 匿名方法:带闭包的委托
4.1 语法与类型声明
从 Delphi 2009 开始,Object Pascal 支持匿名方法,声明时用reference to前缀:
type TIntFilter = reference to function(Value: Integer): Boolean; TIntAction = reference to procedure(Value: Integer);注意reference to不是可选的润色,它改变的是语义和底层实现。reference to类型底层是一个接口引用,由编译器生成的闭包对象实现,带引用计数;而of object类型只是两个裸指针。这意味着:
reference to类型的变量可以被赋值为匿名方法,也可以接收普通方法和对象方法;- 它支持
nil判断,也支持引用计数自动管理; - 它可以捕获外部变量,
of object完全做不到这一点。
我见过不少人把TProc(System.SysUtils里定义的reference to procedure)和自定义的procedure of object混用,导致赋值编译不过。记住一个简单规则:要传匿名方法、要闭包捕获,就必须用reference to。
4.2 闭包捕获的是变量,不是值
这是匿名方法最容易误解的一点。看这段代码:
var Threshold: Integer; Pred: TIntFilter; begin Threshold := 10; Pred := function(Value: Integer): Boolean begin Result := Value > Threshold; end; WriteLn(Pred(5)); // False Threshold := 100; WriteLn(Pred(150)); // True end;如果捕获的是"值的快照",第二次调用应该还是拿 10 去比,返回False。但实际返回True,说明闭包捕获的是变量本身,编译器把Threshold搬到了堆上,闭包和外部代码共享同一份存储。这个设计让闭包能实现状态机、累加器这类功能:
function MakeCounter: TFunc<Integer>; var Total: Integer; begin Total := 0; Result := function: Integer begin Inc(Total); Result := Total; end; end;MakeCounter返回后,局部变量Total本该销毁,但因为被闭包捕获,它被提升到堆上,生命周期跟着闭包走。这就是"闭包延长了变量的寿命",也是它比方法指针强大的地方。
4.3 循环变量捕获:一个必踩的坑
把闭包放进循环,几乎每个人都会踩一次:
var Handlers: TArray<TProc>; I: Integer; begin SetLength(Handlers, 3); for I := 0 to 2 do Handlers[I] := procedure begin WriteLn(I); end; for var H in Handlers do H(); end;直觉上应该输出 0、1、2,实际输出的是 3、3、3。原因很简单:I是循环外声明的同一个变量,三次迭代创建了三个闭包,但它们捕获的是同一个I,循环结束时I的值是 3,所以三个闭包都读到 3。
修法是在循环体内引入一份局部副本,让每次迭代捕获不同的变量:
for I := 0 to 2 do begin var Captured := I; // Delphi 10.3 起支持内联变量声明 Handlers[I] := procedure begin WriteLn(Captured); end; end;这里用到了内联变量声明var Captured := I;,Delphi 11 完全支持。它每次迭代都创建一个新的Captured,三个闭包各自捕获一份,输出就正常了。我在代码评审里看到过用TInterlocked或者包一层类来绕的写法,其实一行内联变量就够了。
4.4 跨线程与延迟执行里的实战用法
匿名方法最爽的场景是跨线程回主线程更新界面。传统写法要写一个专门的同步方法,参数还得自己打包:
TThread.Queue(nil, procedure begin StatusLabel.Caption := '导入完成'; ProgressBar1.Position := 100; end);相比Synchronize配一个独立方法,这种写法的优势是代码就在调用点旁边,读起来不用来回跳。代价是被捕获的变量必须小心——如果捕获了窗体上的控件,而窗体在排队期间被关闭,回调执行时就会访问已释放的对象。所以我在这类代码里通常只捕获值类型的局部变量,UI 控件通过窗体实例访问,并在回调开头加一句if not Assigned(...) then Exit;。
另一个高频场景是延迟执行和重试:
procedure Retry(const Action: TProc; MaxTimes: Integer); var Attempt: Integer; begin Attempt := 0; while Attempt < MaxTimes do begin try Action(); Exit; except Inc(Attempt); if Attempt >= MaxTimes then raise; Sleep(200); end; end; end;调用方写Retry(procedure begin Upload; end, 3);就行,不用为每次重试定义一个方法。这种"把一段逻辑打包成值"的能力,是匿名方法相对方法指针最大的生产力提升。
5. 委托的应用场景与选型对比
5.1 三种经典模式的委托写法
回调模式是最朴素的用法,调用方把后续动作交给被调用方,被调用方在合适时机回来。前面TImporter的例子就是。判断一个场景该不该用回调,我的经验标准是:被调用方需要知道"做完了要通知谁",但不需要知道"通知之后干什么"。
策略模式用委托替代继承,把可变算法抽出来:
type TDiscountStrategy = reference to function(Amount: Currency): Currency; function ApplyDiscount(Amount: Currency; const Strategy: TDiscountStrategy): Currency; begin if Assigned(Strategy) then Result := Strategy(Amount) else Result := Amount; end;调用时传function(A: Currency): Currency begin Result := A * 0.9; end,也可以在运行时切换成另一个闭包。比起为每种折扣建一个类,代码量少一个数量级。
观察者模式就是前面写的多播容器,一个发布者对多个订阅者。三者的区别在于基数:回调是一对一,策略是一对一但可替换,观察者是一对多。
5.2 委托、虚方法、接口,到底选哪个
选型没有绝对对错,但有一条清晰的判断线:变化发生在对象层面还是行为层面。
| 方案 | 绑定时机 | 是否支持多实现 | 代码量 | 适用场景 |
|---|---|---|---|---|
| 方法指针 / 事件 | 运行期赋值 | 单播,需自行扩展 | 最少 | 组件通知、生命周期钩子 |
| 虚方法 / 抽象方法 | 编译期继承 | 通过子类 | 中等 | 一族对象共享骨架、差异在子类 |
| 接口 | 运行期查询 | 可多接口 | 较多 | 跨模块解耦、需要引用计数管理 |
| 匿名方法 / 闭包 | 运行期构造 | 单播 | 少 | 一次性逻辑、带状态的回调 |
我的实际经验是:组件对外暴露的扩展点用事件,内部算法替换用匿名方法,跨单元的能力契约用接口。虚方法现在用得越来越少,因为它强制继承,一旦继承层次设计错了,改起来比重写还累。
5.3 参数个数与返回值的选择
System.SysUtils里预定义的TProc和TFunc覆盖了 0 到 4 个参数的情况:TProc、TProc<T1>……TProc<T1,T2,T3,T4>,TFunc<TResult>……TFunc<T1,T2,T3,T4,TResult>。超过四个参数就得自己声明reference to,这在正常业务里几乎不会遇到,遇到通常说明该把参数打包成一个记录了。
还有一个取舍点:该不该有返回值。事件几乎都是procedure,因为发布者通常不关心订阅者的处理结果。但我见过有人用function做事件,让订阅者返回"是否继续",这本质上已经不是通知而是决策了。我的建议是:通知类一律procedure,需要反馈就通过修改Sender的状态或者传一个var参数来实现,语义更清楚,也不容易在多播场景下出现"返回值被最后一个订阅者覆盖"的混乱。
6. 常见问题与排查实录
6.1 问题速查表
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 调用事件时访问违例 | 订阅对象已释放,委托未清理 | 析构里置 nil,或用FreeNotification |
| 赋值时编译报"类型不兼容" | 普通过程与of object混用 | 换成对应类型,或包一层方法 |
| 多播注册了三个只触发一个 | 去重时只比了Code | 同时比较Code和Data |
| 回调里移除自己后崩溃 | 遍历中修改容器 | Fire前做数组快照 |
| 匿名方法拿到的值都一样 | 捕获了循环共享变量 | 循环体内用内联变量做副本 |
| 对象该释放却没释放 | 闭包与对象形成引用环 | 析构里清空委托字段 |
| 设计期事件运行时报找不到方法 | .dfm里存着旧方法名 | 检查并更新事件绑定名 |
6.2 两段实战排查记录
有一次做一个长时间运行的数据校验模块,界面卡顿,我把校验逻辑挪到后台线程,用TThread.Queue回主线程更新进度。测试一切正常,但用户连续关闭又打开某个窗口几次后,偶尔崩。堆栈指向TThread.Queue里排队执行的那个匿名方法。我一开始怀疑是队列本身的问题,加了各种保护都没用。后来把匿名方法体里的每一行拆开单独打日志,发现崩在访问一个进度条控件那一行——窗口被关闭时那个控件已经释放,但排队中的回调还在队列里,等到主线程处理它时对象早没了。修复方式是在回调开头判断窗体是否还存在,并且窗体关闭时用TThread.RemoveQueuedEvents清掉挂起的回调。
另一次是自研的多播事件容器,现象是"后注册的监听者收不到通知"。排查时打印了数组长度,发现长度确实在增长,但Fire只走了一次。用TMethod把数组里每个元素拆开看,发现Code全不一样、Data却全是同一个对象。问题是那个容器是按"对象"去重的——我当时想当然地把Data相同当成重复,结果同一个对象的两个不同方法被误判成一个。改成Code和Data同时比较后恢复正常。这个教训让我后来所有涉及比较的代码都会先写一行注释,明确"比较的是什么粒度"。
6.3 我个人的几条实操建议
第一条,所有事件字段在使用前必须Assigned检查,没有例外。哪怕你确信已经赋值了,也要写。因为将来某个重构会让"确信"变成"假设",而崩溃发生在运行时。检查成本是一次指针比较,收益是免掉一次整夜的排查。
第二条,给委托字段写清理逻辑时,考虑"谁活得久"。发布者活得久,订阅者负责注销;订阅者活得久,发布者负责清空;两者差不多,优先用TComponent的通知机制。这个判断花不了三十秒,却决定了几个月后会不会冒出一个随机崩溃。
第三条,匿名方法里尽量只捕获值类型的局部变量。捕获对象引用、捕获接口、捕获另一个匿名方法,都会引入额外的生命周期复杂度。如果确实要捕获对象,务必想清楚这个对象和闭包谁先死。
第四条,闭包捕获会带来堆分配,不要在每秒执行几万次的内层循环里创建匿名方法。它的开销不在调用,而在构造——每次构造匿名方法都要在堆上分配闭包对象并做引用计数操作。热路径上用普通方法指针,或者把闭包提到循环外只创建一次,性能差距会在性能剖析器里清清楚楚地显示出来。
第五条,类型声明集中放。我习惯把所有事件类型和委托类型集中在一个单元的interface段里,命名用T前缀加Event或Proc后缀。这样当你需要在两个单元之间传递委托时,不会出现"这个类型在哪儿定义的"这种低级困扰,也避免了不同单元各定义一份相似类型导致的不兼容问题。
最后说个可能有人不同意的观点:不要为了"看起来高级"而滥用匿名方法。一个只有一行、没有捕获任何变量的匿名方法,写成命名方法往往更清晰,堆栈也更好读。委托是工具,不是风格。真正值得用闭包的场景是"逻辑需要携带一份私有状态,而这份状态不值得为它专门建一个类"——除此之外,老老实实写个方法,对三个月后的自己更友好。