为什么在迭代之后TObjectList类型的列表会自动释放?

8

我有一个关于 Spring4D 框架中 TObjectList 类的行为问题。在我的代码中,我创建了一个几何图形列表,例如 正方形圆形三角形,每个几何图形都定义为一个独立的类。为了在列表被销毁时自动释放几何图形,我定义了一个类型为 TObjectList 的列表,如下所示:

procedure TForm1.FormCreate(Sender: TObject);
var
  geometricFigures: TObjectList<TGeometricFigure>;
  geometricFigure: TGeometricFigure;
begin
  ReportMemoryLeaksOnShutdown := true;

  geometricFigures := TObjectList<TGeometricFigure>.Create();
  try
    geometricFigures.Add(TCircle.Create(4,2));
    geometricFigures.Add(TCircle.Create(0,4));
    geometricFigures.Add(TRectangle.Create(3,10,4));
    geometricFigures.Add(TSquare.Create(1,5));
    geometricFigures.Add(TTriangle.Create(5,7,4));
    geometricFigures.Add(TTriangle.Create(2,6,3));

    for geometricFigure in geometricFigures do begin
      geometricFigure.ToString();
    end;
  finally
    //geometricFigures.Free(); -> this line is not required (?)
  end;
end;

如果我运行这段代码,即使我没有在列表(注意finally块中被注释的行)上调用Free方法,列表geometricFigures也会自动从内存中释放。我期望有不同的行为,我认为需要显式调用Free()来释放列表,因为局部变量geometricFigures没有使用接口类型。
我进一步注意到,如果没有在for-in循环中迭代列表项(我暂时将其从代码中删除),该列表将不会自动释放,导致内存泄漏。
这引出了以下问题: 当迭代其项时,为什么TObjectList类型的列表(geometricFigures)会自动释放,但是如果从代码中删除for-in循环,则不会自动释放?
更新
我遵循Sebastian的建议并调试了析构函数。以下代码销毁了列表项:
{$REGION 'TList<T>.TEnumerator'}

constructor TList<T>.TEnumerator.Create(const list: TList<T>);
begin
  inherited Create;
  fList := list;
  fList._AddRef;
  fVersion := fList.fVersion;
end;

destructor TList<T>.TEnumerator.Destroy;
begin
  fList._Release;
  inherited Destroy; // items get destroyed here
end;

更新

我不得不重新考虑我的接受答案并得出以下结论:

在我看来,Rudy的答案是正确的,尽管所描述的行为可能不是框架中的错误。我认为Rudy指出了一个好的观点,即框架应该按预期工作。当我使用for-in循环时,我希望它是只读操作。清空列表并不是我预期发生的事情。

另一方面,Fritzw和David Heffernan指出Spring4D框架的设计是基于接口的,因此应该以那种方式使用。只要这种行为被记录下来(也许Fritzw可以给我们提供文档参考),我同意David的观点,即我对框架的使用方式不正确,尽管我仍然认为框架的行为是误导性的。

对于Delphi开发经验不足的我来说,评估所描述的行为是否实际上是一个bug还是不正确的,因此撤销了我的接受答案,非常抱歉。

5个回答

9
要使用for ... do迭代,类必须具有GetEnumerator方法。显然,此方法将自身(即TObjectList<>)作为IEnumerator<TGeometricFigure>接口返回。迭代完成后,IEnumerator<>被释放,其引用计数达到0,对象列表被释放。

在C#中,您经常会看到这种模式,但是由于类实例仍在被引用,因此垃圾收集器不会介入。

然而,在Delphi中,这是一个问题,就像您所看到的那样。我想解决方案可能是让TObjectList<>有一个单独的(可能是嵌套的)类或记录来进行枚举,而不是返回Self(作为IEnumerator<>)。但这取决于Spring4D的作者。您可以将此问题带到Stefan Glienke的注意下。

更新

您的补充说明显示,情况并非如此。 TObjectList<>(或更准确地说,其祖先TList<>)返回一个单独的枚举器,但它执行了一个(在我看来完全不必要的,即使列表从一开始就被用作接口)_AddRef/_Release,后者是罪魁祸首。

注意

我看到有多个声明称,在Spring4D中,不应将类用作类。然后,这样的类不应在interface部分公开,而应在单元的implementation部分公开。如果公开了这样的类,则作者应该期望用户使用它们。如果它们可用作类,则for-in循环不应释放容器。其中一个是设计问题:要么是作为类的公开,要么是自动释放。所以,在我看来存在一个错误。


3
Spring4d旨在使用接口而不是类。这不是一个错误,而是按设计和文档实现的。 - Fritzw
3
问题在代码中存在缺陷,框架没有问题,其表现符合设计。 - David Heffernan
5
抱歉,但我不同意。_AddRef和_Release是导致问题的原因,这不应该发生。对容器进行for-in循环不应该触发释放容器,因此显然框架的行为与预期不符(无论它是否按设计行为)。它违反了最小惊讶原则,甚至更多。 - Rudy Velthuis
2
@Deltics TInterfacedObject有相同的行为。公开暴露。除此之外,你还能从中继承什么呢?如果你认为库设计可以阻止消费者犯错,那么你是天真的。 - David Heffernan
1
我也遇到了同样的问题。如果在使用类中插入spring.collections.lists,在以前与generics.collections一起良好工作的枚举上,所有继承自TList<T>的类都会出现问题。 - Benedikt
显示剩余29条评论

5
为了明白为什么列表会被释放,我们需要了解幕后发生的事情。 TObjectList<T> 被设计为一个接口,并且有引用计数。每当引用计数达到0时,该实例将被释放。
procedure foo;
var
  olist: TObjectList<TFoo>;
  o: TFoo;
begin
  olist := TObjectList<TFoo>.Create();
< p > olist的引用计数现在为0

  try
    olist.Add( TFoo.Create() );
    olist.Add( TFoo.Create() );

    for o in olist do 

枚举器将 olist 的引用计数增加到1

    begin
      o.ToString();
    end;

枚举器超出范围时,枚举器的析构函数被调用,这将使olist的引用计数减少到0,并意味着olist实例被释放。
  finally
    //olist.Free(); -> this line is not required (?)
  end;
end;

使用接口变量有什么区别?

procedure foo;
var
  olist: TObjectList<TFoo>;
  olisti: IList<TFoo>;
  o: TFoo;
begin
  olist := TObjectList<TFoo>.Create();
< p > olist 引用计数为0

  olisti := olist;

olist引用分配给接口变量olisti将在内部调用_AddRef,并将refcount增加1。

  try
    olist.Add( TFoo.Create() );
    olist.Add( TFoo.Create() );

    for o in olist do 

枚举器将 olist 的引用计数增加到2。
    begin
      o.ToString();
    end;

枚举器超出范围后,枚举器的析构函数被调用,这将使 olist 的引用计数减少到1。

  finally
    //olist.Free(); -> this line is not required (?)
  end;
end;

在该过程结束时,接口变量olisti将被设置为nil,这将在olist上内部调用_Release并将引用计数减少到0,这意味着olist实例已被释放。
当我们直接从构造函数分配引用到接口变量时,同样会发生这种情况。
procedure foo;
var
  olist: IList<TFoo>;
  o: TFoo;
begin
  olist := TObjectList<TFoo>.Create();

将引用分配给接口变量olist将在内部调用_AddRef并将引用计数增加到1。

  olist.Add( TFoo.Create() );
  olist.Add( TFoo.Create() );

  for o in olist do 

枚举器会将 olist 的引用计数增加到 2。
  begin
    o.ToString();
  end;

枚举器超出范围后,枚举器的析构函数会被调用,这将会使得 olist 的引用计数减少到1。
end;

在该过程的结尾,接口变量olist将被设置为nil,这将在olist上内部调用_Release并将引用计数减少到0,这意味着olist实例将被释放。

3
您正在使用 for in loop 来遍历集合;这种循环会在类中查找名为 GetEnumerator 的方法。在 Spring4D 中,对于 TObjectList<T>,您最终会调用继承的 TList<T>.GetEnumerator,其实现如下:
function TList<T>.GetEnumerator: IEnumerator<T>;
begin
  Result := TEnumerator.Create(Self);
end;

TEnumerator 的构造函数实现如下:

constructor TList<T>.TEnumerator.Create(const list: TList<T>);
begin
  inherited Create;
  fList := list;
  fList._AddRef;
  fVersion := fList.fVersion;
end;

请注意,它将调用列表上的"_AddRef"方法。此时,您的TObjetList RefCount将变为1。
由于"GetEnumerator"调用返回一个接口,当您完成循环时,它将被释放。析构函数实现如下:
destructor TList<T>.TEnumerator.Destroy;
begin
  fList._Release;
  inherited Destroy;
end;

请注意,它在列表上调用了_Release。如果您使用调试器进行步进,您会注意到它将列表的RefCount递减为0,然后调用_Release,这就是为什么您的列表被释放的原因。
如果您在原始代码中删除for循环,您最终将面临内存泄漏:
意外的内存泄漏
发生了意外的内存泄漏。意外的小块泄漏如下:
1-12字节:TGeometricFigure x 6,TMoveArrayManager x 1,未知x 1
21-28字节:TList x 1
29-36字节:TCriticalSection x 1
53-60字节:TCollectionChangedEventImpl x 1,未知x 1
77-84字节:TObjectList x 1
编辑:刚看到Rudy Velthuis的答案。这不是Spring4D的错误。您不应该使用框架的基于类的集合。您必须使用基于接口的集合。另外,与Spring4D无关,但在Delphi中,建议您不要混合使用接口引用和对象引用。

1
枚举器不会释放容器。当收到容器时,枚举器会增加 RefCount,然后在释放容器时再将其递减。枚举器期望容器被正确初始化:不接收 RefCount 为零的接口对象。此外,如果将类放在实现部分中,如何能够创建其中一个并公开接口?Spring.Collections.TCollections.CreateList 如何访问 TObjectList? - Agustin Ortu
2
@rudy,你有使用Delphi接口的实际经验吗?我是指真正的实践经验。 - David Heffernan
2
@rudy 在实现部分隐藏接口实现类解决不了任何问题,只会带来麻烦(对于测试、继承等)。此外,它也无法阻止程序员将接口引用转换为 TObject 并释放该实例。因此,这毫无价值。 - Sir Rufo
1
@RudyVelthuis 如果它不是公共的,你怎么继承它?你怎么确切地测试那个实现?如果隐藏了,你必须依赖于可能返回该类进行测试的某些东西。我不喜欢那种隐藏,因为它带来的麻烦比真正帮助更多。 - Sir Rufo
2
在这里跟Rudy争论也没有多大的意义,他显然不理解这些问题。 - David Heffernan
显示剩余6条评论

3

Spring4D的集合类是为与接口一起使用而设计的,TObjectList实现了IList接口,因此如果您使用该接口引用它,则可以按预期工作。

procedure TForm1.FormCreate(Sender: TObject);
var
  geometricFigures: IList<TGeometricFigure>;
  geometricFigure: TGeometricFigure;
begin
  ReportMemoryLeaksOnShutdown := true;

  geometricFigures := TCollections.CreateObjectList<TGeometricFigure>(true);
  geometricFigures.Add(TCircle.Create(4,2));
  geometricFigures.Add(TCircle.Create(0,4));
  geometricFigures.Add(TRectangle.Create(3,10,4));
  geometricFigures.Add(TSquare.Create(1,5));
  geometricFigures.Add(TTriangle.Create(5,7,4));
  geometricFigures.Add(TTriangle.Create(2,6,3));

  for geometricFigure in geometricFigures do 
  begin
    geometricFigure.ToString();
  end;
end;

1
创建自己的TGemoetricFigures列表,覆盖析构函数。这样你就可以很快地知道谁在调用析构函数了。
type
  TGeometricFigures = class(TObjectList<TGeometricFigure>)
  public
    destructor Destroy; override;
  end;

implementation

{ TGeometricFigures }

destructor TGeometricFigures.Destroy;
begin
  ShowMessage('TGeometricFigures.Destroy was called');
  inherited;
end;

procedure FormCreate(Sender: TObject);
var
  geometricFigures: TGeometricFigures;
  geometricFigure: TGeometricFigure;
begin
  ReportMemoryLeaksOnShutdown := true;

  geometricFigures := TGeometricFigures.Create;
  try
    geometricFigures.Add(TCircle.Create(4,2));
    geometricFigures.Add(TCircle.Create(0,4));
    geometricFigures.Add(TRectangle.Create(3,10,4));
    geometricFigures.Add(TSquare.Create(1,5));
    geometricFigures.Add(TTriangle.Create(5,7,4));
    geometricFigures.Add(TTriangle.Create(2,6,3));

    for geometricFigure in geometricFigures do begin
      geometricFigure.ToString();
    end;
  finally
    //geometricFigures.Free(); -> this line is not required (?)
  end;
end;

我猜测在 geometricFigure.ToString() 内部进行了某些操作,导致意外销毁了 geometricFigures。使用 FastMM4 FullDebugMode,可能会获得更多信息。

ToString() 不是问题(如果它是问题的话,那倒是出乎我的意料)。问题在于枚举器的构造函数中的 _AddRef 和析构函数中的 _Release。据我所知,两者都是不必要的,但 _Release 将引用计数降至 0,然后释放整个类。 - Rudy Velthuis

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