Object Pascal 这门语言的关键字表里找不到delegate这个词,你在 IDE 里敲下它,编译器不会有任何反应。可只要你在 Delphi 里拖过一个按钮、写过一行OnClick,你就已经在使用委托了。我这些年带过不少从别的语言转过来的同事,几乎每个人第一次看到procedure of object这种声明时都会愣一下:这玩意儿到底是函数指针,还是对象?为什么它比普通的procedure多一个of object?为什么把方法赋给事件属性之后,对象一释放程序就崩?这些问题看起来零碎,其实指向同一个核心——委托(delegate)这种“把一段行为当成值来传递”的机制,在 Object Pascal 里究竟是怎么落地的。这篇笔记围绕 Object Pascal 的委托概念展开,从方法指针的内存布局讲到事件属性、匿名方法、多播实现和自己动手写一个可用的委托类型,把我在实际项目里踩过的坑一并摊开。不管你是刚接触 Delphi 11 的新手,还是写了几年但一直把事件当黑盒用的老手,下面这些内容应该都能对上你的某个具体疑问。
1. 委托在 Object Pascal 里到底是个什么东西
先把结论摆在前面:Object Pascal 的委托,本质是一个“代码地址 + 上下文对象”的二元组。语言层面没有为它单独造一个类型,而是复用了方法指针(method pointer)这套机制,再在它之上长出了事件、匿名方法、泛型回调这一整棵树。理解了这个二元组,后面所有看着神奇的行为——包括那些让你半夜爬起来查 AV(Access Violation)的行为——都会变得可预测。
1.1 从“回调”这个需求说起
假设你写了一个后台任务类,跑完一批数据处理后需要通知调用方“进度到 60% 了”。最直接的做法是让任务类直接引用调用方的单元,调它的方法。这么写能跑,但依赖方向反了:底层模块反过来依赖上层模块,以后你想把这个任务类复用到另一个项目,就得把调用方的单元一起搬过去,越搬越多,最后整个工程变成一团扯不开的线。从业者管这叫“耦合”,但耦合本身不是罪,问题是它悄悄地限制了你以后改代码的自由度。
常见的解耦手段有三条路,各有各的适用场景:
| 方案 | 调用方向 | 编译期依赖 | 一个对象能挂几个处理者 | 典型场景 |
|---|---|---|---|---|
| 虚方法继承 | 子类覆盖父类 | 强,需要继承关系 | 一个 | 框架预留的扩展点 |
| 接口 | 调用方持有接口引用 | 中,需要共同引用接口单元 | 一个(可挂多个接口) | 跨模块、跨 DLL 的契约 |
| 委托(方法指针/事件) | 被调用方持有方法地址 | 弱,只需共同的类型声明 | 一个(多播需自己实现) | 通知、回调、UI 事件 |
三条路里,委托是唯一一个不需要建立类型关系就能挂上去的:调用方不必继承谁,也不必实现某个接口,只要有一个签名匹配的方法就行。这一点在 UI 编程里价值巨大——一个窗体上的按钮不可能为了响应点击去继承一个TButtonClickHandler基类,那太荒唐了。
我个人的取舍习惯是:跨模块的稳定契约用接口,模块内部的通知用委托,需要框架回调子类的才用虚方法。这条经验不是教科书上的,是被“接口单元被到处引用导致编译顺序一团糟”和“为了一个回调硬造继承层次”这两件事教出来的。
1.2 方法指针的两种形态与内存布局
Object Pascal 里的方法指针有两条分支,写法只差三个词,语义差得很远:
type TPlainProc = procedure (AValue: Integer); // 普通过程指针 TMethodProc = procedure (AValue: Integer) of object; // 方法指针TPlainProc这种叫普通过程指针,它存的就是一个代码地址,占一个指针大小的空间(32 位下 4 字节,64 位下 8 字节)。你能把全局函数、单元级的普通函数赋给它,但不能把某个对象的方法赋给它——因为对象方法执行时需要知道“我是谁”,也就是Self,而普通过程指针里没地方放这个信息。
TMethodProc这种带of object的,才是真正意义上的委托载体。它在内存里是两个指针,RTL 里有个现成的记录类型TMethod跟它一一对应:
type TMethod = record Code: Pointer; // 方法入口地址 Data: Pointer; // 上下文,普通方法里存的就是 Self end;Code指向编译出来的方法体,Data指向这个方法所属的对象实例。这也解释了为什么把一个方法赋给方法指针变量时,编译器不需要你额外传Self——它在赋值那一刻就已经把Self塞进Data里了:
type TWorker = class private FName: string; public constructor Create(const AName: string); procedure Report(AValue: Integer); end; var Worker: TWorker; Handler: TMethodProc; Raw: TMethod; begin Worker := TWorker.Create('采集器'); try Handler := Worker.Report; // 合法:Self 自动带入 Raw := TMethod(Handler); WriteLn(Format('Code=%p Data=%p', [Raw.Code, Raw.Data])); WriteLn('Data 是否等于对象地址: ', BoolToStr(Raw.Data = Pointer(Worker), True)); Handler(42); // 调用时 Self 从 Data 里取 finally Handler := nil; Worker.Free; end; end;上面这段代码里,Raw.Data = Pointer(Worker)恒为真,这是一个很实用的调试手段:当你怀疑某个事件指向的方法其实属于另一个对象时,把方法指针转成TMethod看一眼Data就知道真相了。
注意:
TMethod(Handler)这种强制转换只对方法指针成立,不要试图对普通过程指针做同样的事,得到的结果没有意义。另外,把方法指针强制转成TMethod之后只读不改,手动改Data字段属于自找麻烦,除非你在做非常底层的跳板(thunk)构造。
1.3 为什么 Object Pascal 的委托偏要“两段式”
看到这里你可能会问:为什么不做成一段?C 语言的函数指针就是一段,简单干脆。答案是对象方法的调用需要一个隐藏参数。C 里成员函数本质上是普通函数加一个this参数,只是语法上藏起来了;函数指针一旦只存代码地址,调用时this就无处安放,所以你只能写一堆void* context的参数来凑。
Object Pascal 把这件事在类型系统里做实了:procedure of object是一个自带上下文的类型。带来的好处是实打实的:
第一,存储统一。你在事件属性里、在 TList 里、在记录字段里,都可以用同一个 16 字节(64 位下)的结构保存任意对象的方法,不需要额外的 context 参数,也不需要额外的包装类。
第二,可比较、可解除。因为委托拆成Code和Data两个字段,判断“这个方法是不是已经挂上去了”就变成两次指针比较,解除绑定只要把两个字段清零。这套能力在实现“避免重复订阅”的功能时非常关键,后面第 4 章会用到。
第三,和事件模型天然契合。Delphi 的事件属性本质上就是把委托当成一个可赋值的属性暴露出来,OnClick := Button1Click这句代码背后没有任何魔法,就是一次两指针赋值。
代价也有:方法指针不持有对象的所有权。它只是记住了“这个对象在哪个地址”,对象什么时候死掉,方法指针完全不知情。这就是后面所有崩溃的根源,也是我在审代码时第一个要盯的地方。
2. 事件机制:委托最日常的落地形态
事件是委托在 Object Pascal 里最常见的形态。多数人写了几年 Delphi,天天用OnClick,却从没自己声明过一个事件类型,这其实挺可惜的——手写一遍事件属性,你对委托的理解会立刻从“知道有这么个东西”变成“能控制它”。
2.1 一个事件属性拆开来看就三行
拿最经典的TNotifyEvent举例,它的完整定义在 RTL 里只有一行:
type TNotifyEvent = procedure (Sender: TObject) of object;就一行。Sender这个参数不是语言强制的,是约定俗成的惯例,目的是让处理者知道“是谁触发的”。你自己声明事件类型时完全可以不带Sender,但从可维护性角度看,建议保留:一个 Form 上十个控件共用一个处理者时,没有Sender你根本分不清是谁点的。
自己声明一个事件属性的套路是固定的:
type TProgressEvent = procedure (Sender: TObject; ACurrent, ATotal: Integer) of object; TBatchTask = class private FOnProgress: TProgressEvent; FOnDone: TNotifyEvent; protected procedure DoProgress(ACurrent, ATotal: Integer); procedure DoDone; public procedure Run; property OnProgress: TProgressEvent read FOnProgress write FOnProgress; property OnDone: TNotifyEvent read FOnDone write FOnDone; end; procedure TBatchTask.DoProgress(ACurrent, ATotal: Integer); begin if Assigned(FOnProgress) then FOnProgress(Self, ACurrent, ATotal); end; procedure TBatchTask.DoDone; begin if Assigned(FOnDone) then FOnDone(Self); end;这里有个我在代码 review 里反复强调的细节:永远不要在触发点直接用FOnProgress(...),一定要先Assigned判断。事件属性是可选的,调用方不赋值是完全合法的用法。省掉三行判断,换来的是上线后随机出现的“某个用户没配处理者就崩了”的工单。
至于protected的DoProgress/DoDone这一层“触发方法”,它看着多余,其实很有价值:子类可以覆盖它来做统一的埋点、节流、或者屏蔽,而不用去动业务逻辑。这是 VCL 一直在用的模式,也是我建议你在新代码里延续的模式。
2.2 赋值、清除与野指针:最容易出事的三行代码
事件属性的赋值简单到不需要解释,但解除绑定这一步经常被漏掉,漏掉的后果很严重:
// 常见写法:窗体创建时挂上,但销毁时什么也不做 procedure TFormMain.FormCreate(Sender: TObject); begin FTask := TBatchTask.Create; FTask.OnProgress := HandleProgress; FTask.OnDone := HandleDone; end;这段代码的问题藏在销毁顺序里。假设这个任务对象被传给了别的模块、甚至被一个后台线程持有,而TFormMain先被释放了,那么FTask.FOnProgress.Code仍然指向一个已经被回收的代码段?不对,代码段不会回收,Code还是有效的,但Data指向的窗体对象已经没了。这时候FOnProgress(Self, ...)进去一执行,只要方法体里碰了任何窗体字段,立刻 AV;如果碰巧没碰,程序可能带着错误状态继续跑下去,这种“时好时坏”的 bug 是最难查的一类。
标准的清理写法是在窗体销毁时显式断开:
procedure TFormMain.FormDestroy(Sender: TObject); begin if Assigned(FTask) then begin FTask.OnProgress := nil; // 两行赋值,清零 Code 和 Data FTask.OnDone := nil; FreeAndNil(FTask); end; end;提示:别指望
FreeAndNil之后事件自动清空。对象死掉,它对别人的委托引用不受任何影响;反过来,别人持有对它的委托,也不会因为它的析构而失效。委托关系需要显式维护,这是 Object Pascal 委托模型里最需要建立的心理预期。
如果宿主对象和订阅者生命周期不一致、你又不方便在两边都写清理代码,那就需要在委托容器里做弱引用注销,第 4 章会给一套可直接抄的实现。
2.3 多播委托:原生不支持,但二十行能写出来
原生的事件属性只支持挂一个处理者,后赋值的会把前一个覆盖掉。这在多数 UI 场景下够用,但业务代码里常常需要“一个事件,多个订阅者”。这时候要么自己维护一个TList<TMethod>,要么直接写一个多播容器。
有个很容易忽略的细节:TMethod是一个记录(record),你可以把它当值类型放进列表里比较、删除:
type TMethodList = class private FItems: TList<TMethod>; public constructor Create; destructor Destroy; override; procedure Add(const AMethod: TMethod); procedure Remove(const AMethod: TMethod); procedure Clear; procedure Invoke; // 无参版本,简化演示 end; function SameMethod(const A, B: TMethod): Boolean; begin Result := (A.Code = B.Code) and (A.Data = B.Data); end; procedure TMethodList.Add(const AMethod: TMethod); var Item: TMethod; begin if AMethod.Code = nil then Exit; for Item in FItems do if SameMethod(Item, AMethod) then Exit; // 去重,避免重复订阅 FItems.Add(AMethod); end; procedure TMethodList.Remove(const AMethod: TMethod); var I: Integer; begin for I := FItems.Count - 1 downto 0 do if SameMethod(FItems[I], AMethod) then FItems.Delete(I); end;这里有两个点值得展开讲。去重:在实际项目里,重复订阅是高频 bug——某个初始化方法被调了两次,处理者就挂了两遍,用户点一次按钮,业务逻辑执行两次。用SameMethod拦住成本极低。倒序删除:for I := Count - 1 downto 0这个写法在“边遍历边删除”时是必须的,正序删会漏掉元素甚至越界,这个坑我从 Pascal 时代踩到别的语言,写多少遍都还得看一眼。
至于调用,多播的几个处理者应该互相隔离:一个处理者抛异常,不应该让后面的人收不到通知。这一点原生的单播事件反而不需要操心,多播就得自己兜着:
procedure TMethodList.Invoke; var Item: TMethod; P: procedure of object; begin for Item in FItems.ToArray do // 先快照,防止调用过程中增删导致列表变动 begin P := TMethodProc(Item); try P(); except // 这里按你的日志策略记录,关键是不能让异常中断整个循环 LogError('多播处理者执行失败'); end; end; end;FItems.ToArray这一步看着多余,实测下来是救命的:处理者内部很可能会回头去Remove自己(“一次性订阅”逻辑),如果直接遍历原列表,迭代器状态就乱了。多花一次数组复制的开销,换来确定的行为,这个交易我觉得值。
3. 匿名方法:带着上下文一起走的委托
前面讲的方法指针,本质上要依附在一个已有的对象上:你得先有个TWorker实例,才能把它的方法挂出去。可很多时候我想传的行为根本不需要一个类,它就是“拿到一个整数之后做点什么”这样一个动作。为这个动作专门建一个类,代码会膨胀得很难看。匿名方法解决的就是这类场景。
3.1 匿名方法的真面目:编译器替你造了一个接口对象
匿名方法的声明用reference to关键字:
type TIntAction = reference to procedure (AValue: Integer); TIntFilter = reference to function (AValue: Integer): Boolean; var Action: TIntAction; Threshold: Integer; begin Threshold := 10; Action := procedure (AValue: Integer) begin if AValue > Threshold then WriteLn('超阈值: ', AValue); end; Action(42); // 输出 超阈值: 42 end;关键在最后那几行里:Action内部引用了局部变量Threshold,而Threshold是在begin块里声明的栈变量。按常规理解,退出这段代码时Threshold就该消失了,可Action却还能正常读到它的值。这说明编译器做了两件事:把被捕获的变量从栈搬到堆上,并且生成一个隐藏的类来实现那个引用接口,Action变量本身存的其实是这个隐藏对象的接口引用。
所以匿名方法在内存里的样子跟方法指针完全不同:它占一个接口引用的空间(64 位下 8 字节,指向对象,另加引用计数管理),背后的对象里存着捕获的变量副本,以及调用入口。这也是为什么匿名方法在循环里创建大量实例时,内存曲线会比预期高——每个实例背后都是一个真实的堆对象。
3.2 捕获变量与生命周期:最容易翻车的地方
匿名方法给人最大的错觉是“它就像一个普通变量,作用域结束就没了”。实际不是。只要还有人持有这个匿名方法,被捕获的变量就一直活着,这是特性,但也常常变成事故。
看一个我实际踩过的场景——把 UI 更新逻辑委托给后台任务:
procedure TFormMain.StartTask; var UpdateUI: TProc; begin UpdateUI := procedure begin Self.StatusLabel.Caption := '处理中'; end; FTask.Execute(UpdateUI); // 任务内部把 UpdateUI 存起来,稍后回调 end;这段代码在Self.StatusLabel被调用时,如果窗体已经关闭并释放,Self就是一个悬空指针,AV 立刻到账。更要命的是另一面:如果窗体先关掉,FTask还持有着这个匿名方法,而这个匿名方法捕获了Self,那么窗体对象的引用计数下不去——在 ARC 模型下这是个实实在在的内存泄漏;在桌面非 ARC 编译下虽然不涉及对象引用计数,但任务对象生命周期比窗体长,依然会崩溃,只不过崩溃点从“泄漏”变成了“访问已释放内存”。
我给自己的规矩是:匿名方法里不要直接捕获Self,尤其是会被长期持有的匿名方法。确需更新 UI 时,要么把数据发到主线程用显式的方法指针处理,要么在窗体关闭前显式把委托置空:
procedure TFormMain.FormDestroy(Sender: TObject); begin if Assigned(FTask) then begin FTask.CancelCallback; // 任务内部把持有的 TProc 置为 nil FreeAndNil(FTask); end; end;还有一个经典坑是循环变量的捕获。下面这段代码想给十个按钮挂上带序号的日志:
// 错误写法 for var I := 0 to 9 do Buttons[I].OnClick := procedure begin WriteLn('第 ', I, ' 个按钮'); end;结果十个按钮点下去,打印的全是“第 10 个按钮”——因为I在循环外只声明了一次,十个匿名方法捕获的是同一个变量,循环结束时它的值是 10。正确姿势是在循环体内再生成一份副本:
for var I := 0 to 9 do begin var Index := I; // 每次迭代重新捕获 Buttons[I].OnClick := procedure begin WriteLn('第 ', Index, ' 个按钮'); end; end;提示:这类“捕获同一个变量”的问题在别的语言里也存在,表现形态不同但本质一致。养成“进循环体先复制一份”的肌肉记忆,能省掉大量排查时间。不要指望编译器给出警告,多数情况下它不提示。
3.3 匿名方法与方法指针的边界在哪
这两种委托载体语法上都能赋值给同签名的变量吗?不完全能。下面这张表是我自己整理的对照,实际写代码时对着看比较清楚:
| 维度 | 方法指针procedure of object | 匿名方法reference to procedure |
|---|---|---|
是否携带Self | 携带,指向一个已存在的对象 | 不必须有对象,捕获的是变量环境 |
| 能否捕获局部变量 | 不能 | 能,捕获的变量搬到堆上 |
| 是否参与引用计数 | 不参与 | 参与(背后是接口对象) |
| 赋值给事件属性 | 直接可以 | 可以,但要注意生命周期 |
| 比较两个实例是否相同 | 比较Code和Data即可 | 比较复杂,一般不这么用 |
| 典型用途 | UI 事件、显式回调 | 临时的行为参数、LINQ 式过滤 |
有一点需要单独说明:把匿名方法赋给一个procedure of object类型的事件属性是可以编译的。比如Buttons[I].OnClick := procedure begin ... end;这句在很多人的代码里都出现过。编译器在这里做了转换——它构造出一层跳板,把匿名方法背后那个隐藏对象的地址放进Data字段,再让Code指向一个能正确还原上下文的入口。从调用方看,就是一个普普通通的方法指针。
方便是方便,但这里埋了个雷:OnClick属性本身并不参与引用计数。也就是说,匿名方法赋值给事件属性之后,谁持有它?如果这个事件属性是唯一的持有者,那么匿名方法背后的隐藏对象在赋值语句结束时就可能被回收,之后点击按钮调用的是一个已经不存在的对象。实际项目里这类问题表现得很诡异:有时候能跑,有时候崩,取决于中间有没有别的引用恰好让它多活一会儿。我的做法是事件属性一律用对象方法赋值,匿名方法只用在“当场调用或者短期持有”的场景,用这种自律换掉一类没法定位的偶发崩溃。
4. 从零实现一个能用于生产的委托类型
前面三章把原理铺完了,这一章落地:写一个可以在真实项目里用的委托类型。需求很具体——支持挂多个处理者、支持解除、支持对象消亡时自动注销(避免 AV)、调用时一个处理者出错不影响其它人。
4.1 类型定义与基础骨架
先把处理者的签名固定下来。实际项目里参数各不相同,这里用泛型参数解决;为了保持代码可读,我用一个带参数的版本做示范,你换成别的签名逻辑一样。
type TDelegateProc = procedure (ASender: TObject; AArg: Pointer) of object; TDelegate = class private type TEntry = record Method: TMethod; Sender: TObject; // 弱引用,仅用于注销时判断 end; var FEntries: TList<TEntry>; FLock: TObject; // 简单锁对象,多线程环境必备 public constructor Create; destructor Destroy; override; procedure Add(const AMethod: TDelegateProc; ASender: TObject); procedure Remove(ASender: TObject); procedure RemoveMethod(const AMethod: TDelegateProc); procedure Invoke(AArg: Pointer); function Count: Integer; end;这里设计上做了一个取舍:不存储TDelegateProc类型的字段,而是存TMethod。因为TMethod是可以比较、可以清空的纯值类型,而一个TDelegateProc变量在某些编译器版本里比较起来行为不完全直观。存TMethod之后,去重和删除都变成了两次指针比较,逻辑清晰得多。
4.2 弱引用注销:解决 AV 的关键一环
对象被释放时,委托容器里对应的条目必须消失。Delphi 在某些编译器模型下支持用[Weak]特性标记字段来达到类似效果,但从可移植性和可控性考虑,我更倾向于自己维护注销入口——谁订阅,谁在析构里退订,同时在容器这一侧提供一个批量清理方法兜底。
procedure TDelegate.Add(const AMethod: TDelegateProc; ASender: TObject); var Entry: TEntry; Item: TEntry; begin if not Assigned(AMethod) then Exit; Entry.Method := TMethod(AMethod); Entry.Sender := ASender; for Item in FEntries do if SameMethod(Item.Method, Entry.Method) then Exit; // 已存在,去重 FEntries.Add(Entry); end; procedure TDelegate.Remove(ASender: TObject); var I: Integer; begin for I := FEntries.Count - 1 downto 0 do if FEntries[I].Sender = ASender then FEntries.Delete(I); end;订阅方在自己的析构函数里调FDelegate.Remove(Self),只要这一行没漏写,AV 基本就绝迹了。为了让这个“必须写”的动作不容易被忘掉,我在项目里的惯例是:凡是要对外提供委托订阅能力的类,一律在基类的析构里统一通知所有委托容器,而不是指望每个业务子类自己记得。
4.3 完整示例:一个带进度通知的批处理组件
把前面几块拼起来,跑一个能验证的完整例子。这次加上调用隔离和快照遍历:
procedure TDelegate.Invoke(AArg: Pointer); var Snapshot: TArray<TEntry>; Entry: TEntry; P: TDelegateProc; begin Snapshot := FEntries.ToArray; // 快照,防止回调中增删 for Entry in Snapshot do begin if Entry.Method.Code = nil then Continue; P := TDelegateProc(Entry.Method); try P(Self, AArg); except on E: Exception do LogError(Format('委托执行异常: %s', [E.Message])); end; end; end; // 使用方 type TProgressReporter = class private FDelegate: TDelegate; FCurrent, FTotal: Integer; public constructor Create(ADelegate: TDelegate); destructor Destroy; override; procedure Step; end; constructor TProgressReporter.Create(ADelegate: TDelegate); begin inherited Create; FDelegate := ADelegate; FDelegate.Add(HandleProgress, Self); end; destructor TProgressReporter.Destroy; begin FDelegate.Remove(Self); // 关键的一行 inherited; end; procedure TProgressReporter.HandleProgress(ASender: TObject; AArg: Pointer); begin Inc(FCurrent); WriteLn(Format('进度 %d / %d', [FCurrent, FTotal])); end;这套结构我在两个项目里用过,处理者数量从两三个到几十个都撑得住。要说心得,最重要的一条是把LogError换成你自己的日志规范:多播里吞掉异常是必须的,但绝对不能静默吞掉。我最初图省事,except end;空着,结果某个处理者一直抛异常,业务表现是“进度条走到 30% 就停了”,查了整整一个下午才发现是被吞掉的EConvertError。
注意:
Snapshot := FEntries.ToArray里的快照是浅拷贝,条目里的TMethod还是原来的值。如果回调过程中某个对象被释放并且恰好没注销,快照里的条目依然会指向已释放对象。快照解决的是“迭代器失效”,不解决“对象已死”,这两件事得分清楚。
5. 委托使用中的坑与排查手册
前面几章的代码你可能觉得都挺顺,那是因为我在写的时候已经把坑绕开了。这一章把绕的过程摊开讲,都是实实在在花过时间的。
5.1 访问违规的三种典型形态
委托相关的崩溃,症状高度相似——都是在某个通知方法里 AV,但成因差别很大。我把遇到过的归成三类:
第一类,宿主先死。前面提过,订阅者持有宿主的方法指针,宿主对象被Free之后指针还留着。表现是:程序运行一段时间后莫名 AV,崩溃点在Data指向的位置。排查手段很直接:崩溃时把TMethod(...).Data打出来,跟怀疑的几个对象地址对照,基本一眼就能认出来。预防手段就是严格的生命周期配对——订阅和退订成对出现。
第二类,事件在构造中被触发。这个坑特别隐蔽:如果一个类在构造函数里就调用DoProgress,而调用方是这样写的:
FTask := TBatchTask.Create; // 构造里已经触发了一次 OnProgress FTask.OnProgress := HandleProgress; // 这次赋值来得太晚如果构造函数内部触发的那个事件处理者刚好会去访问“尚未初始化完的对象字段”,那就崩了。约定俗成的规矩是构造函数里绝不触发事件,要触发就放到一个显式的Start方法里。这条约定我从第一次被坑到现在,一直守得很严。
第三类,循环内部现场构造的匿名方法被存下来。症状是崩溃点飘忽不定,有时候每隔几次才崩一回。原因在于匿名方法背后的隐藏对象生命周期不可控,前面 3.3 节讲过。解决方式是改用对象方法,或者把一个长期存活的匿名方法变量显式保存在字段里。
5.2 泄漏与循环引用
在支持引用计数的编译模型下,两件事很容易互相持着不放:宿主持有订阅者的委托,订阅者又持有宿主的引用。经典的解法是把其中一边改为弱引用。Delphi 在较新的版本里给接口字段提供了[Weak]特性:
type TObserver = class(TInterfacedObject, IObserver) private [Weak] FSubject: ISubject; // 弱引用,不增加引用计数 public procedure Notify; end;需要提醒的是,[Weak]的行为跟编译模型有关,在非引用计数的模型下它对对象字段不起作用。所以我在跨平台项目里的做法是不依赖[Weak],一律显式注销,多写几行代码,换来行为一致,这个账算下来是划算的。毕竟排查一次“某机型上内存缓慢增长”的成本,远高于写几十行注销代码的成本。
还有一类隐蔽的泄漏:委托容器本身忘了Free。我审过一个模块,TList<TMethod>作为字段创建了但析构函数里没释放,跑一天下来泄漏几百 KB,看着不多,但这个模块是常驻的,跑一周就上 GB 了。这类问题用内存快照工具对比两次分配就能定位到具体的类。
5.3 常见问题速查表
实际排查时我习惯先对着表过一遍,命中率不低:
| 症状 | 最可能的原因 | 排查动作 | 修复手段 |
|---|---|---|---|
| 点击按钮后随机 AV | 窗体已释放但事件仍挂着 | 崩溃时打印TMethod(Handler).Data与对象地址比对 | 销毁时显式把事件置nil |
| 业务逻辑执行两次 | 事件被重复订阅 | 在处理者里打断点看是否进两次 | 订阅前去重,或用TMethod比较 |
| 进度条卡在某处不动 | 某个处理者抛异常被静默吞掉 | 在except里加日志 | 补齐日志,修复抛异常的处理者 |
| 内存缓慢增长 | 委托容器未释放,或循环引用 | 定期抓内存快照对比 | 补析构、加显式注销 |
| 所有按钮行为都一样 | 循环变量捕获问题 | 检查循环内匿名方法是否捕获了循环变量 | 循环体内复制一份局部变量 |
判断Assigned为真但调用仍崩 | Code有效但Data已失效 | 打印Data字段 | 注销机制,而不是加更多Assigned |
提示:
Assigned只能告诉你“有没有挂东西”,不能告诉你“挂的东西还能用”。很多人在崩溃之后的第一反应是到处加Assigned判断,这是无效的努力方向,真正该做的是把生命周期管起来。
5.4 几个关于开销的实测心得
最后说点偏性能的观察,这些是我拿一个小基准压出来的,不是理论推导。
方法指针的存储开销是固定的 16 字节(64 位),无论指向的方法多复杂。一个类挂十个事件属性,实例多出 160 字节,多数场景可以忽略。调用开销上一次间接跳转,跟虚方法调用基本同一量级,比接口调用略快一点,因为少了一次接口方法表的跳转。所以我从来不会因为“性能敏感”而避免用事件机制——真正该担心的不是调用开销,而是每个处理者里访问的字段导致的缓存不友好。
匿名方法的开销明显更高,因为它背后有个堆对象,创建时需要分配和初始化,销毁时走引用计数。我压过一个循环里创建十万个匿名方法的场景,耗时是同等数量方法指针赋值的十几倍。所以有一条经验:热路径上不要现场 new 匿名方法。要么在初始化阶段一次性建好,要么直接用对象方法。
多播的遍历开销主要取决于处理者数量,TList的遍历本身很便宜,但Invoke里的快照复制是个 O(n) 的分配动作,如果这个委托每秒被调用几百次,快照的分配压力就不能忽视了。这种情况下我会改成“只在检测到增删时才复制”的策略,用一个版本号标记列表是否变动过,能省掉绝大部分无谓的分配。
还有一个经常被忽略的点:处理者的执行顺序。多播天然按订阅顺序执行,如果你的业务依赖某个特定顺序,别指望“碰巧是那个顺序”——显式地给条目排个优先级,比事后解释“为什么 A 总在 B 前面跑”要省事得多。我在一个数据同步模块里就吃过这个亏:两个处理者一个负责写缓存、一个负责发通知,顺序反了就会让下游读到旧数据,表现是“偶尔脏读”。加了个Priority字段排序之后,这类问题彻底消失。
踩过这些坑之后,我现在写任何跟委托相关的代码,脑子里会先过三个问题:这个东西谁持有、活多久、什么时候解绑。这三个问题答不上来的时候,代码就先别写,写出来大概率是要返工的。