☰
Delphi 委托机制详解:方法指针、匿名方法与事件总线
2026/10/26 12:57:48 网站建设 项目流程

做 Delphi 开发的这些年,被问得最多的问题之一就是"C# 里有 delegate,Object Pascal 里对应的是哪个东西"。这个问题不好一句话回答,因为 Object Pascal 从来没有一个叫delegate的关键字,但委托这种"把一段行为打包成值、到处传递、稍后调用"的能力,它其实一直都有,只是分散在几个不同的语法形态里。这一节学习笔记就专门来啃这块硬骨头——委托的概念在 Object Pascal 中到底落在了哪些语法设施上,它们各自的内存模型长什么样,什么时候该用方法指针、什么时候该用匿名方法、什么时候老老实实定义一个接口更省心。

如果你是从 C#、Java、TypeScript 转过来的,对"回调""事件""Lambda"这些词很熟,但一写到procedure of object就犯迷糊,或者你写过事件赋值却发现对象释放后程序莫名其妙崩在无关代码行上,那这篇内容基本就是为你准备的。我会从最底层的内存布局讲起,一路走到手搓一个多播委托、再到一个能直接拿去用的事件总线,中间夹着几个我自己踩过的坑和完整的排查链路。基础薄弱也能看懂,因为每个概念我都会先拿生活里的东西打个比方。

1. 委托本质上是什么:先把概念从语法里剥出来

1.1 抛开语言差异,委托就是"可以携带上下文的可调用值"

先说概念本身,不带任何语言。委托要解决的问题是:我有一段逻辑,现在不想执行,我想把它存起来,等到某个时机再执行,而且执行的时候它还能记得自己属于谁、能访问哪些数据。

举个生活里的例子。你上班的时候跟前台说:"待会儿有快递送到,你帮我签收一下,然后放我桌上。"这句话本身就构成了一个委托:前台不知道快递什么时候来,但她记住了一件事——"快递到了要做签收放桌"这个动作,以及"放谁的桌"这个上下文。等快递真来了,她照着执行就行,不需要再问你一遍。

对应到程序里,这里有三个关键信息:做什么(签收并放桌上)、对谁做(你的工位)、什么时候做(由别人决定)。一个完整的委托值必须同时携带"做什么"和"对谁做",缺一不可。这就是为什么在 Object Pascal 里,纯粹的函数指针procedure只能表达一半,必须加上of object才能表达"对谁做"。

C# 把这三件事用delegate关键字、闭包对象、Target属性完整地封装好了;Object Pascal 则是把这三件事拆开,让你自己用方法指针、匿名方法或者接口去拼装。明白了这一点,后面所有的语法细节就都能归位了。

1.2 Object Pascal 里可用的三种"可调用值",各有各的脾气

在实际写代码的时候,能承载"可调用值"的形态主要有三种,它们的成本和适用场景差别很大,我整理成一张表对比一下:

形态典型声明携带上下文生命周期管理适合场景
普通过程指针procedure(X: Integer)否,只有一个代码地址无(就是个地址)纯函数、算法策略、排序比较器
对象方法指针procedure(Sender: TObject) of object是,代码地址 + Self无,需手动保证对象存活事件、观察者、UI 回调
匿名方法引用reference to procedure(X: Integer)是,捕获变量打包成闭包对象有,编译器生成引用计数Lambda、延迟执行、异步任务

普通过程指针最轻,本质上就是一个指针大小的地址,任何"上下文"都得靠参数显式传进去,天然无状态、无副作用,也没有悬空风险。对象方法指针是事件机制的基石,它把 Self 一起带上了,所以调用时能直接访问对象字段,但代价是它不管理对象生命周期,对象没了指针还留着,一调用就是访问违例。匿名方法引用最"现代",它把捕获的变量搬到堆上,用引用计数管理,写起来最像 C# 的 Lambda,但引用计数本身又会带来循环引用和生命周期延长的问题。

我的经验是:能在接口层面表达清楚的关系,优先用接口;只是单向通知、生命周期跟随宿主,用方法指针;需要临时组合逻辑、把行为当参数传,用匿名方法。三者不是替代关系,而是分工。

1.3 为什么 Delphi 不直接抄一个 delegate 关键字过来

这个问题我在带新人的时候被问过。答案其实不复杂:Object Pascal 的方法指针机制比 C# 的 delegate 出现得更早,而 C# 的 delegate 在语义上是一个类——它有自己的类型、可以+=、可以-=、可以多播、可以异步BeginInvoke。Object Pascal 如果把delegate变成一个类,就会出现"同样的东西有两套实现"的尴尬:VCL 的事件体系全部建立在方法指针上,不可能推翻重来。

所以 Delphi 的路线是补齐而不是替换:匿名方法(reference to)在 2009 版本引入,用来补足"闭包"这块能力;泛型容器和TProc、TFunc家族补足类型抽象;多播则留给你自己用容器拼。这条路线的代价是多播委托没有任何内建支持,你得自己写;好处是你完全知道它在做什么,不会被框架隐藏的开销惊讶到。

2. 解剖procedure of object:十六个字节里的秘密

2.1 TMethod 的内存布局,以及 Self 是怎么被塞进去的

这是理解 Object Pascal 委托最关键的一步。声明一个方法指针类型:

type TNotifyEvent = procedure(Sender: TObject) of object;

这个类型在 64 位下SizeOf是 16 字节,不是 8 字节。为什么?因为它在内存里其实是System.TMethod这个 record 的语义等价物:

type TMethod = record Code: Pointer; // 方法入口地址,8 字节 Data: Pointer; // Self,即对象实例地址,8 字节 end;

当你写FOnClick := Button1Click;的时候,编译器悄悄做了两件事:把Button1Click的入口地址写进Code,把你当前Self(也就是窗体实例)写进Data。等到调用FOnClick(Sender)的时,编译器生成的代码会先把Data作为隐藏的第一个参数(64 位下走 RCX/ECX 寄存器,也就是 Self 参数位)准备好,再跳转到Code。方法体内部访问Self.Field之所以能拿到正确的对象,就是因为这个隐藏参数已经就位了。

理解了这一点,很多"诡异"现象就解释得通了。比如为什么不能把对象方法直接赋给普通过程指针:

var P: procedure(Sender: TObject); begin P := Button1Click; // 编译错误,E2010 之类的类型不兼容 end;

因为procedure(Sender: TObject)只占 8 字节,装不下 16 字节的信息,Self 会丢失。就算你强行用@取地址绕过编译器,调用时也缺少 Self 参数,方法体一访问字段立刻爆炸。编译器在这里拦住你,其实是在保护你。

2.2 事件机制就是委托的经典落地,别把它当成另一回事

VCL 里的OnClick、OnClose、OnTimer这些事件属性,定义全部是... of object的方法指针。所谓"发布事件",本质上是宿主对象持有一个委托字段,触发时调用它:

type TTicker = class private FOnTick: TNotifyEvent; public property OnTick: TNotifyEvent read FOnTick write FOnTick; procedure Tick; end; procedure TTicker.Tick; begin if Assigned(FOnTick) then FOnTick(Self); end;

Assigned(FOnTick)这个判断非常重要,它是"委托值为空"的检查。这里有个细节值得说一下:对于方法指针,Assigned判断的是Code <> nil,而Data可能是 nil 吗?理论上可能——如果你手工构造一个只填了 Code 的TMethod,或者用类方法(class method)赋值,Data 会是 nil 而 Code 有值。这种"半截"委托调用的后果是方法体内一碰 Self 就崩。所以更严格的自检写法是:

function IsMethodAssigned(const M: TMethod): Boolean; begin Result := (M.Code <> nil) and (M.Data <> nil); end;

我在自己维护的公共库里一直用这个判断而不是裸Assigned,就是为了挡住这种半截委托。代价是每次多两个比较,性能上完全可以忽略。

2.3 用TMethod做"跨对象绑定",以及它的危险边界

既然委托只是两个指针,那就可以手工拼装。下面这段代码把一个对象的方法"移植"到另一个对象上执行:

var M: TMethod; begin M.Code := TMethod(@TWorker.DoWork).Code; // 只取代码地址 M.Data := SomeOtherObject; // 换上另一个 Self TNotifyEvent(M)(Self); // 强行调用 end;

这段代码能跑,前提是SomeOtherObject的内存布局和TWorker兼容——也就是说,DoWork里访问的字段偏移必须能在SomeOtherObject上找到合理的东西。这在做某些框架层的"消息转发"、或者把一个通用方法复用给多个数据对象时是有实际价值的,但它属于明确的高危操作,只在你能完全掌控两边布局时使用,而且必须有注释说明。我见过有人用它做插件系统的字段共享,结果插件升级后字段顺序变了,程序在无关地方崩溃,排查了整整两天。

还有一个更常见的场景,是把TMethod从方法指针里"解"出来做比较,判断两个事件是不是指向同一个处理函数:

function SameHandler(const A, B: TMethod): Boolean; begin Result := (A.Code = B.Code) and (A.Data = B.Data); end;

注意必须同时比较 Code 和 Data。只比 Code 会把"同一个类的不同实例的同一个方法"误判成同一个处理器,这是我早期写订阅去重逻辑时犯过的错误,导致一个订阅取消把另一个实例的订阅也取消了。

3. 手搓一个多播委托:从单播到"一呼百应"

3.1 为什么现成的方案不够用,以及容器的选型理由

C# 的event天然支持+=挂多个处理器,触发时依次调用。Object Pascal 的事件属性只能挂一个,第二个赋值会直接覆盖第一个。这在需要"多处关注同一件事"的时候就很别扭——比如一个数据模型变更后,网格要刷新、状态栏要更新、日志要记录,你只能写一个中转方法在里面依次调用三个,或者用TComponent.Notification那套重量级机制。

手搓一个多播委托其实不难,核心就是一个容器加上遍历调用。容器我选TList<TMethod>而不是TArray<TMethod>,理由有三个:一是增删操作是日常行为,列表的Add、Remove已经够用,不用自己维护容量和计数;二是TMethod是纯值类型,两个指针的浅拷贝,装进列表不会有所有权问题,也不用担心复制开销;三是TList<T>.Remove内部用默认比较器,对TMethod这种简单 record 会走二进制比较,正好等价于同时比较 Code 和 Data,去重和退订都能直接工作。

如果你用的是TArray<TMethod>,增删都得走System.Generics.Collections里那套 helper,代码更长,收益不大。只有在订阅数量极大、又需要频繁按下标随机访问时,数组的缓存友好性才有意义。

3.2 一份可以直接抄走的实现

下面是我在几个项目里反复用过的版本,砍掉了不必要的特性,只保留核心:

unit MulticastDelegateU; interface uses System.SysUtils, System.Classes, System.Generics.Collections; type TMulticastNotify = class private FHandlers: TList<TMethod>; FLocked: Boolean; public constructor Create; destructor Destroy; override; procedure Add(const AMethod: TMethod); procedure Remove(const AMethod: TMethod); procedure Clear; function Count: Integer; procedure Invoke(Sender: TObject); end; implementation constructor TMulticastNotify.Create; begin inherited Create; FHandlers := TList<TMethod>.Create; end; destructor TMulticastNotify.Destroy; begin FHandlers.Free; inherited Destroy; end; procedure TMulticastNotify.Add(const AMethod: TMethod); begin if (AMethod.Code = nil) or (AMethod.Data = nil) then Exit; FHandlers.Add(AMethod); end; procedure TMulticastNotify.Remove(const AMethod: TMethod); var I: Integer; begin for I := FHandlers.Count - 1 downto 0 do if (FHandlers[I].Code = AMethod.Code) and (FHandlers[I].Data = AMethod.Data) then FHandlers.Delete(I); end; procedure TMulticastNotify.Clear; begin FHandlers.Clear; end; function TMulticastNotify.Count: Integer; begin Result := FHandlers.Count; end; procedure TMulticastNotify.Invoke(Sender: TObject); var Snapshot: TArray<TMethod>; M: TMethod; begin if FLocked then Exit; FLocked := True; try Snapshot := FHandlers.ToArray; // 关键:先做快照 for M in Snapshot do if (M.Code <> nil) and (M.Data <> nil) then TNotifyEvent(M)(Sender); // 利用布局一致性直接调用 finally FLocked := False; end; end; end.

用法很朴素:

FOnDataChanged := TMulticastNotify.Create; FOnDataChanged.Add(TMethod(@Grid.RefreshData)); FOnDataChanged.Add(TMethod(@StatusBar.UpdateCount)); ... FOnDataChanged.Invoke(Self);

TMethod(@Obj.Method)这个写法是把方法指针"降级"成TMethodrecord,因为两者布局完全一致,所以这个转换是安全且零开销的。反过来TNotifyEvent(M)(Sender)是把TMethod升回方法指针类型再调用,同样零开销。这种双向转换在 Delphi 社区里是通用做法,但前提是被调方法的签名必须和TNotifyEvent完全一致——包括参数类型、参数个数、调用约定。签名不一致的话,寄存器里的传参会对不上,轻则读到垃圾值,重则调用返回时栈失衡直接崩。

3.3 边遍历边删的陷阱,以及我为什么加了 FLocked

上面代码里那个Snapshot和FLocked不是凑数的。假设你在遍历处理器列表时,某个处理器内部调用了FOnDataChanged.Remove(...)或者干脆Clear,而Invoke又正好在用一个迭代器遍历FHandlers,那迭代器立刻失效,行为不可预期——可能跳过元素,可能访问越界,可能死循环。

我第一次写这个类的时候就掉进去了。当时的症状是:某个处理器在收到通知后把自己摘掉了,结果后面的处理器一个都没收到通知。原因是TList<T>的枚举器内部有个游标,删除元素后整体左移,游标继续往前走就跳过了一个。这个 bug 藏得很深,因为只有"处理器自我退订"这个特定组合才触发。

解决方案有两个层次。第一层是Snapshot:遍历前用ToArray复制一份,遍历的是副本,随便你怎么改原表都不影响本次循环。代价是每次Invoke都分配一次数组,如果委托调用频率极高(比如每帧调用几千次),这个开销就要掂量一下。第二层是FLocked:防止重入。如果某个处理器在调用过程中又触发了同一个委托,不加锁就会无限递归直到栈溢出。我加了FLocked之后,重入的调用会被直接丢弃,虽然语义上是"丢通知",但在绝大多数场景下比栈溢出好得多。如果你的业务确实需要支持重入,那把FLocked换成一个深度计数器,允许嵌套但要限制最大层数。

注意:FLocked这种丢弃重入的策略在 UI 场景很安全,但在数据流水线里可能丢事件。是否丢弃要看你业务对"通知完整性"的要求,不要盲目照抄。

4. 匿名方法:Delphi 11 里最像 Lambda 的那块拼图

4.1reference to和of object的本质区别

写法和普通方法指针很像,只差两个单词:

type TIntCallback = reference to procedure(X: Integer); TIntFunc = reference to function(X: Integer): Integer;

reference to声明出来的东西,在内存里不是16 字节的双指针,而是一个接口引用(8 字节),指向编译器生成的闭包对象。这个闭包对象实现了某个隐藏接口,接口上有个Invoke方法。所以调用Callback(42)实际上是接口方法调用,多了一层间接。

这个设计带来的最大好处是捕获变量。你可以这样写:

function MakeAdder(Base: Integer): TIntFunc; begin Result := function(X: Integer): Integer begin Result := Base + X; end; end;

Base是MakeAdder的局部变量,函数返回后栈帧本该销毁,但因为闭包捕获了它,编译器会把Base"提升"到堆上,由闭包对象持有。所以返回后调用仍然能拿到正确的值。这种能力用方法指针是不可能实现的——方法指针只能捕获 Self,捕获不了任意局部变量。

of object捕获的是"一个对象实例",reference to捕获的是"一个变量集合",后者自由度大得多。代价是编译器的隐式代码变多,调试时调用栈会多出莫名其妙的匿名方法帧,而且闭包的生命周期由引用计数决定,不再是线性的。

4.2 捕获语义:引用捕获还是值捕获,循环变量为什么会被"共享"

这是最容易翻车的地方。Object Pascal 的匿名方法捕获是按引用捕获——闭包持有的是变量本身的地址(提升到堆上之后就是堆上的那块内存),而不是捕获那一刻的快照。这意味着闭包内外对同一个变量的修改是互相可见的。

坑就出在for循环上:

var Callbacks: TArray<TIntFunc>; I: Integer; begin SetLength(Callbacks, 3); for I := 0 to 2 do Callbacks[I] := function(X: Integer): Integer begin Result := X * I; end; for I := 0 to 2 do WriteLn(Callbacks[I](10)); // 输出 30 30 30,不是 0 10 20 end;

三个闭包捕获的是同一个变量I,循环结束后I的值是 3(Delphi 的 for 循环变量在循环结束后保持终值),所以三次调用都算 10 * 3。

修复方式是把循环变量复制到内层局部变量,让每次迭代产生一个新的存储位置:

for I := 0 to 2 do begin var Captured := I; // Delphi 10.3 起支持内联变量声明 Callbacks[I] := function(X: Integer): Integer begin Result := X * Captured; end; end;

注意var Captured必须声明在循环体内部,这样每次迭代都是一次新的变量声明,编译器会为每次迭代分配独立的存储。如果把它挪到循环外面,那就又变成共享了。这个坑在其他主流语言里普遍存在,规律是一致的:捕获的是变量的存储位置,不是那一刻的值。

4.3 泛型委托类型:能不自己声明就别自己声明

每需要一种签名就声明一个reference to类型,代码会膨胀得很难看。好在标准库已经覆盖了绝大多数情况,System.SysUtils里有现成的:

TProc = reference to procedure; TProc<T1> = reference to procedure(Arg1: T1); TProc<T1, T2> = reference to procedure(Arg1: T1; Arg2: T2); TFunc<TResult> = reference to function: TResult; TFunc<T1, TResult> = reference to function(Arg1: T1): TResult;

参数最多到四个,返回值和参数可以随意组合。日常用法:

var Loader: TFunc<string, Boolean>; OnDone: TProc<Boolean>; begin Loader := function(FileName: string): Boolean begin Result := FileExists(FileName); end; OnDone := procedure(Ok: Boolean) begin if not Ok then ShowMessage('加载失败'); end; OnDone(Loader('config.ini')); end;

只有在参数超过四个、或者需要表达业务语义(比如TDataChangedHandler这种一看就知道用途的命名)时,才值得自己声明一个别名类型。命名类型还有个好处:编译器会做类型检查,两个签名相同但语义不同的委托类型不会互相赋值,能挡住一些低级错配。

4.4 闭包的内存代价:别在高频路径上随手写 Lambda

匿名方法的分配成本比很多人想的高。一次捕获至少涉及:堆上分配闭包对象、分配捕获变量的存储块、引用计数的增减。如果捕获了 Self,还要多一层对象引用。在每秒调用几万次的路径上随手写闭包,性能会肉眼可见地掉。

我做过一个粗略的对比,同一段业务逻辑用方法指针和用匿名方法在新版本编译器下各跑一遍,匿名方法那版在频繁创建闭包的场景里明显更慢——因为它每次都要分配。解决办法很简单:把闭包当成一次性的、低频的东西,比如启动时注册一次回调、事件触发时才创建一次;循环里、渲染循环里、每帧都要跑的地方,老老实实用方法指针或者预先创建好的闭包字段。

另外一个隐蔽的成本是调试。闭包里的代码在调试器里的地址映射经常跳到奇怪的位置,断点打在begin上不生效,要打在Result := ...那一行才行。这只是体验问题,不影响运行,但排查线上问题时会多花时间。

5. 落地一个轻量事件总线:把委托用到刀刃上

5.1 需求拆解:为什么最后没选接口

拿一个具体场景说。假设有个多面板的应用:面板 A 修改了数据,面板 B 的列表要刷新,面板 C 的统计数字要重算,还有一个日志模块要记一条。这四者对 A 一无所知,A 也不该知道它们。这就是典型的"发布-订阅",核心诉求只有三条:按主题订阅、按主题发布、订阅者能安全退订。

一开始我选的是接口方案:定义IObserver,订阅者实现它,总线里存IInterface列表。写着写着问题来了——每个订阅者都要声明一个类实现接口,哪怕逻辑只有三行;而且接口方法签名固定,想传不同参数就得定义一堆接口。对于这种"轻量通知"的需求,接口的仪式感太重了。

最后选了泛型 + 方法指针的组合:主题用字符串或者枚举,处理器用方法指针,按主题分组存在字典里。下面是最小可用版本:

type TEventBus = class private FSubs: TObjectDictionary<string, TList<TMethod>>; FLock: TMonitor; public constructor Create; destructor Destroy; override; procedure Subscribe(const Topic: string; const Handler: TMethod); procedure Unsubscribe(const Topic: string; const Handler: TMethod); procedure Publish(const Topic: string; Sender: TObject); end; procedure TEventBus.Subscribe(const Topic: string; const Handler: TMethod); var List: TList<TMethod>; begin if (Handler.Code = nil) or (Handler.Data = nil) then Exit; TMonitor.Enter(FLock); try if not FSubs.TryGetValue(Topic, List) then begin List := TList<TMethod>.Create; FSubs.Add(Topic, List); end; List.Add(Handler); finally TMonitor.Exit(FLock); end; end; procedure TEventBus.Publish(const Topic: string; Sender: TObject); var List: TList<TMethod>; Snapshot: TArray<TMethod>; M: TMethod; begin TMonitor.Enter(FLock); try if not FSubs.TryGetValue(Topic, List) then Exit; Snapshot := List.ToArray; finally TMonitor.Exit(FLock); end; for M in Snapshot do if (M.Code <> nil) and (M.Data <> nil) then TNotifyEvent(M)(Sender); end;

TObjectDictionary用的是doOwnsValues,字典销毁时自动释放内部的TList,省掉手写清理循环。锁用TMonitor而不是TCriticalSection,因为前者是 record 放栈上或者字段里都行,不需要额外分配,而且支持Pulse/Wait做更细的同步,虽然这里用不到。

5.2 一个必须提前想清楚的问题:谁负责断线

订阅发布模式最大的坑不在实现,而在生命周期契约。订阅者销毁的时候如果没退订,总线里就留下一个指向已释放对象的委托,下一次发布就是一次访问违例,而且崩的位置往往离真正的原因十万八千里。

这个问题有三种解法,我按推荐程度排一下。

第一种,最原始也最可靠:在订阅者的析构函数里显式退订。缺点是容易忘,尤其是临时加进来的订阅者。我的做法是在类的Destroy第一行就写退订,甚至放在inherited之前,养成肌肉记忆。

第二种,用TComponent的FreeNotification机制。订阅者如果是TComponent派生,可以调用FreeNotification(Self)让总线在自己销毁时收到通知,总线收到回调后清理对应的订阅记录。这套机制 VCL 里到处都是,但要求订阅者必须是TComponent,纯数据对象用不了。

第三种,用弱引用 + 存活检测。每次发布前检查Data指向的对象是否还在。这在 Object Pascal 里没有标准做法,需要自己维护一个"活跃对象注册表",实现成本最高,但能在开发期抓到忘记退订的问题。我在关键模块里会用Assert版本:调试构建下检查对象是否已销毁,发布构建下跳过检查。

提示:切换项目配置时,务必确认这些Assert检查只在 Debug 下生效。我就见过有人把带存活检测的代码带到正式环境,每次发布多几万次查表,卡得莫名其妙。

5.3 线程边界:委托本身不解决并发,别指望它

TMethod只是个值,两个指针拷来拷去是原子的(在 64 位下未必,取决于对齐,但通常没问题),可委托调用不是。如果发布发生在工作线程、订阅者却期望在 UI 线程执行,直接在发布点调用就是跨线程访问 UI,运气好是花屏,运气不好直接崩。

我在总线上加了两个策略:一是在Publish里用TThread.CurrentThread.ThreadID和主线程 ID 比较,如果当前不是主线程且订阅者标记了"必须主线程",就TThread.Queue排队到主线程执行;二是给订阅加一个Async: Boolean参数,让调用方自己决定。第一种是默认行为,覆盖 90% 的情况。

TThread.Queue和TThread.Synchronize的区别要清楚:后者会阻塞工作线程直到主线程执行完,前者只是排队立即返回。在发布-订阅这种"通知一下就走"的场景里,Queue明显更合适,用Synchronize会导致工作线程被 UI 卡住。但Queue也有隐患:工作线程发布得比主线程处理得快,队列会一直堆积,最后内存涨上去,表现是程序越来越卡但 CPU 不高。这时候要么做节流(同一主题短时间内的多次发布合并成一次),要么在队列长度超阈值时丢事件并记日志。

6. 踩过的三个坑,以及完整的排查链路

6.1 悬空方法指针:崩溃发生在完全无关的代码行

症状是这样的:程序跑一段时间后,在某个跟订阅毫无关系的按钮点击里崩了,调用栈顶部显示的是 VCL 内部的某段代码,根本看不出跟我的模块有什么关系。重启之后不一定复现,跑十几分钟才出一次。

第一条排查线索是崩溃地址。打开 CPU 窗口看崩溃时的指令地址,发现它落在一个已经被卸载的模块范围里——这说明跳转目标已经无效了。这就把嫌疑指向了函数指针。

第二条线索是回溯"最后一次有效的用户代码"。在调用栈里往上翻,找到第一个属于自己模块的帧,发现是事件总线里的TNotifyEvent(M)(Sender)。至此基本可以确定:某个订阅者的方法指针还在表里,但对象已经被释放了,调用时Self指向一块已回收的内存,方法体访问字段就崩了。

验证方法很直接:在发布前加一句检查,把Data指针打印出来,同时在一个全局的"活跃对象列表"里查一下,看它是否还在。不在的话就打印主题名和处理器地址。跑一遍测试流程,果然在日志里抓到了残留项。

根因是订阅者在Destroy里只做了字段清理,忘了调用退订。修复方式是补上退订,并且把退订代码放到Destroy的最前面,确保在任何字段被释放之前摘掉自己。同时我把总线的Unsubscribe改成了不依赖对象的版本——只需要传方法指针,Data相同就清掉,这样即使调用方只知道Data也能干净摘除。

6.2 循环变量捕获:五个按钮全都弹同一个提示

这个坑前面提过原理,这里说说排查过程。现象是界面上动态生成了五个按钮,每个点击应该弹对应编号的提示,实际全都弹最后一个编号。

第一反应是按钮的Tag设错了,检查生成代码,Tag设置是对的,Caption也是对的,说明 UI 层没问题。第二反应是点击事件绑定错了,打印每个按钮的方法指针地址,发现Code完全相同——这很正常,绑的是同一个方法。但Data呢?打印出来发现五个按钮的Data各不相同,也对。

到这里线索断了。后来打印了闭包内部捕获的变量地址,才看到关键信息:五个闭包捕获的是同一个地址。这才想起来自己在循环里直接写了匿名方法,捕获了循环变量。改成在循环体内用var Captured := I;复制一份,问题消失。

这个坑的隐蔽之处在于它不会崩,只是行为不对,而且只在动态生成控件这种场景暴露。后来我在团队规范里加了一条:循环体内创建匿名方法时,必须用内联变量把捕获值复制一份,并且变量名统一加Captured前缀,方便 code review 时一眼看出来。

6.3 闭包持有了 Self,对象却一直不释放

第三个坑跟内存有关。有个模块的窗体关掉之后内存没降下来,反复开关十几次,内存稳步上涨,最后被撑爆。用内存分析工具看,发现每次关闭都留下一个宿主的副本,引用链是:总线 → 闭包对象 → 捕获的变量块 → 窗体。

原因很明确:窗体在注册回调时写了Bus.Subscribe('data', procedure begin Self.Refresh; end);,闭包捕获了Self,闭包对象被总线持有,总线又是全局单例活到进程结束。窗体虽然Free了,但闭包还持有它的引用,引用计数不为零,那块内存就不会释放。

这里有个容易误解的地方:匿名方法捕获的Self是强引用,不是弱引用,而且参与引用计数。窗体是TObject派生,本身没有引用计数机制,但闭包对象持有的是指向窗体的指针并且增加了引用计数——如果窗体同时实现了接口,这个引用计数就会阻止它被释放;如果窗体没实现接口,那引用计数管不到,但闭包对象本身占的内存也不会释放。

修复有两步:一是在窗体关闭时显式调用Bus.Unsubscribe,把所有订阅清干净;二是重构成不捕获Self的写法,比如把需要的数据单独捕获,或者用一个全局的WeakRef包装。后者的方案更彻底,但改动量大,短期还是靠显式退订更实际。

7. 把委托用对地方的几条经验判断

写到这里,我想把判断标准浓缩成几句话,都是踩坑之后总结出来的。

看生命周期是否明确跟随。如果被调方的寿命一定短于或等于调用方,放心用方法指针,比如宿主控件对内部子对象的通知。如果两者寿命关系不确定,先用接口把关系写清楚,或者强制走显式退订流程。

看回调是不是一次性的。只是把一段逻辑传给某个函数临时用一下,比如排序比较、遍历过滤,匿名方法最舒服,写完就扔。要长期挂着的,优先方法指针,内存账面干净。

看调用频率。高频路径上别创建闭包,别在Invoke里做快照分配。低频的事件通知随便用,可读性优先。

看调试需求。需要断点跟到具体处理逻辑的,方法指针的调用栈更接近源码,排查方便。闭包的调用栈多几层框架帧,虽然也能追,但要多花点时间。

最后一点私人感受:委托这个概念在 Object Pascal 里看着散,其实只要把"代码 + 上下文"这两件事想明白了,剩下的都是语法糖的排列组合。真正需要花心思的不是怎么声明,而是谁持有谁、什么时候断。我见过的大部分线上问题,代码本身都写得没问题,坏就坏在对象生命周期和委托持有关系没对齐。写完一个回调注册,先问自己一句"订阅者销毁的时候,这条线断了吗",能省掉后面 80% 的崩溃工单。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询