当T是事件处理程序时,IList<T>会崩溃?

10

我发现IList无法将事件处理程序作为其元素。程序在退出时出现访问冲突$C00000005。

如果我使用Delphi RTL的TList,一切都正常。

无论是32位还是64位版本构建,在发生此问题时,它似乎会停留在Spring4D的以下行中:

procedure TCollectionBase<T>.Changed(const item: T; action:      
   TCollectionChangedAction);
begin
   if fOnChanged.CanInvoke then
       fOnChanged.Invoke(Self, item, action);
end;

以下是一个示例程序,可以在Windows上使用RAD Studio Tokyo 10.2.3复制访问冲突。
program Test_Spring_IList_With_Event_Handler;

{$APPTYPE CONSOLE}

{$R *.res}

uses
  System.SysUtils,
  Spring.Collections;

type
  TSomeEvent = procedure of object;

  TMyEventHandlerClass = class
    procedure SomeProcedure;
  end;

  TMyClass = class
  private
    FEventList: IList<TSomeEvent>;
  public
    constructor Create;
    destructor Destroy; override;
    procedure AddEvent(aEvent: TSomeEvent);
  end;

procedure TMyEventHandlerClass.SomeProcedure;
begin
  // Nothing to do.
end;

constructor TMyClass.Create;
begin
  inherited;
  FEventList := TCollections.CreateList<TSomeEvent>;
end;

destructor TMyClass.Destroy;
begin
  FEventList := nil;
  inherited;
end;

procedure TMyClass.AddEvent(aEvent: TSomeEvent);
begin
  FEventList.Add(aEvent);
end;

var
  MyEventHandlerObj: TMyEventHandlerClass;
  MyObj: TMyClass;
begin
  MyObj := TMyClass.Create;
  MyEventHandlerObj := TMyEventHandlerClass.Create;

  try
    MyObj.AddEvent(MyEventHandlerObj.SomeProcedure);
  finally
    MyObj.Free;
    MyEventHandlerObj.Free;
  end;
end.

1
事件处理程序由方法和实例组成。如果您销毁了该实例,则事件处理程序将不再有效。 - Uwe Raabe
事件类型(过程或对象)被特殊处理 - 它们实际上有两个指针。编译器在幕后进行了一些魔法处理。 - Jerry Dodge
请注意,所示代码仅在非基于ARC的平台上运行时才会崩溃,并且TSomeClass.SomeProcedure()实际上会对其Self参数执行某些操作。 - Remy Lebeau
1
无法重现,我相信 Spring 的这部分没有缺陷。请提供 [mcve]。更有可能的是,在释放对象后,您的实际代码调用了列表中存储的方法。 - David Heffernan
2
使用上述代码(加上方法的空实现),我在程序最终化期间(在我的笔记本电脑上使用Delphi 10.2.2,尚未更新到版本3)从System.UnsetExceptionHandler中得到了一个AV错误。我认为这可能是RTL而不是Spring4D的缺陷。该问题尚未解决,我将进行调查。 - Stefan Glienke
显示剩余4条评论
1个回答

12
这是一个影响泛型的编译器缺陷。实际上,TMyClass 实例的生命周期并不重要。编译器无法处理的代码位于 Spring.Collections.Lists 中的 TList<T>.DeleteRangeInternal。具体代码如下:
if doClear then
  Changed(Default(T), caReseted);

请记住,T是一个方法指针,即一个带有两个指针的类型。因此,它比寄存器大。编译器将对Changed的调用转换为以下内容:

Spring.Collections.Lists.pas.641: Changed(Default(T), caReseted);
00504727 B105             mov cl,$05
00504729 33D2             xor edx,edx
0050472B 8B45FC           mov eax,[ebp-$04]
0050472E 8B18             mov ebx,[eax]
00504730 FF5374           call dword ptr [ebx+$74]

请注意,编译器仅将4个字节清零,然后将这四个字节传递给Changed

然而,在另一方面,实现Changed的代码访问它传递的item的代码如下所示:

Spring.Collections.Base.pas.1583: fOnChanged.Invoke(Self, item, action);
00502E58 FF750C           push dword ptr [ebp+$0c]
00502E5B FF7508           push dword ptr [ebp+$08]
00502E5E 8D55F0           lea edx,[ebp-$10]
00502E61 8B45FC           mov eax,[ebp-$04]
00502E64 8B4024           mov eax,[eax+$24]
00502E67 8B08             mov ecx,[eax]
00502E69 FF513C           call dword ptr [ecx+$3c]

asm代码的前两行从堆栈中读取方法指针。因此,方法指针参数的ABI是它们在堆栈上传递。文档如下:

方法指针作为两个32位指针在堆栈上传递。实例指针在方法指针之前被推入,以便方法指针占用最低地址。

回到调用此函数的代码。它将参数传递给一个寄存器。这种不匹配是实际发生异常的原因,而异常要晚得多。但这就是一切变得糟糕的地方。

让我们看一个解决方法。我们将TList<T>.DeleteRangeInternal中的代码更改如下:

var
  defaultItem: T;
....
if doClear then
begin
  defaultItem := Default(T);
  Changed(defaultItem, caReseted);
end;

现在生成的代码如下:
Spring.Collections.Lists.pas.643: defaultItem := Default(T);
0050472B 33C0             xor eax,eax
0050472D 8945E0           mov [ebp-$20],eax
00504730 8945E4           mov [ebp-$1c],eax
Spring.Collections.Lists.pas.644: Changed(defaultItem, caReseted);
00504733 FF75E4           push dword ptr [ebp-$1c]
00504736 FF75E0           push dword ptr [ebp-$20]
00504739 B205             mov dl,$05
0050473B 8B45FC           mov eax,[ebp-$04]
0050473E 8B08             mov ecx,[eax]
00504740 FF5174           call dword ptr [ecx+$74]
请注意,这次生成的代码将方法指针中的两个指针都清零,然后通过堆栈传递它们。调用代码与被调用者的代码匹配。一切正常。
我将提交此解决方法到我的个人 Spring4D 存储库,并且 Stefan 将把它合并到主存储库的 1.2.2 热修复分支中。
我已经提交了一个错误报告:RSP-20683

你可能有一个 Delphi 版本的数组来检查解决方法,直到它破坏了另一个编译器?:-D 供将来参考,第二个问题在 https://bitbucket.org/sglienke/spring4d/issues/338。 - Arioch 'The

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接