65.9K
CodeProject 正在变化。 阅读更多。
Home

修复 Delphi 的接口限制

starIconstarIconstarIconstarIconstarIcon

5.00/5 (8投票s)

2018 年 7 月 12 日

CPOL

8分钟阅读

viewsIcon

37766

Delphi 在接口方面存在一些明显的陷阱。了解如何绕过它们。

Delphi 的接口根植于 COM 互操作,但在大多数情况下用于非 COM 目的。不幸的是,Delphi 接口存在一些重大限制,并且在接口最能发挥作用的关键领域。

例外,而非规则

我很少使用接口。我发现很多情况下,它们会增加复杂性,而不是解决它。然而,接口也有合理的用途,例如 IEnumerable、数据绑定、IList 等。接口允许许多通常通过多重继承来实现的功能,而不会带来多重继承的麻烦。

对象接口支持

使用接口的目的是允许即使对象没有除了 TObject 之外的共同祖先,也能拥有一个通用的 API。接口在允许有限地暴露 private 成员方面也很有用。

然而,在 Delphi 中,并非所有对象都可以与接口一起使用。要将一个对象与接口一起使用,必须添加特殊的引用计数代码,或者继承(直接或间接)一个已经支持接口的专用类。这些类是

  • TInterfacedObject
  • TInterfacedPersistent
  • TComponent

可能还有其他类,但这些是主要的。这样做很可能是出于对给整个对象树增加额外开销的考虑。不幸的是,这也导致了一些问题。

它们的声明如下

  • TObject
    • TInterfacedObject
    • TPersistent
      • TInterfacedPersistent
      • TComponent

如果所有你想在上面使用特定接口的对象都继承自同一个添加了接口支持的类,一切都会好起来。然而,如果你的对象从不同的类继承了接口支持,那么就会有一个大问题。它不起作用。

例如,假设我们有一个接口 ILife,有两个类。一个类继承自 TComponent,另一个继承自 TInterfacedPersistent。每个类都可以实现 ILife,但我们不能简单地使用通用祖先 TPersistent 来获取 ILife 接口。

使用 TComponent

首先,我将向您展示一个简单的示例,其中三个类都继承自 TComponent

  • TComponent
    • TComponentA
    • TComponentB
      • TComponentC
unit UnitA;

interface

uses
  System.SysUtils, System.Classes;

type
  ILife = interface
    ['{BDC73295-9F45-4BEC-B726-77DEC9B9EAAC}']
    function GetAnswer: Integer;
  end;

  TComponentA = class(TComponent, ILife)
    function GetAnswer: Integer;
  end;
  TComponentB = class(TComponent, ILife)
    function GetAnswer: Integer;
  end;
  TComponentC = class(TComponentB, ILife)
  end;

procedure TestA;

implementation

procedure TestIntf(aComp: TComponent);
var
  i: integer;
  xILife: ILife;
begin
  xILife := aComp as ILife;
  i := xILife.GetAnswer;
  WriteLn('Answer: ' + i.ToString);
end;

procedure TestA;
var
  xCompA, xCompB, xCompC: TComponent;
begin
  WriteLn('TestA');

  xCompA := TComponentA.Create(nil); try
    TestIntf(xCompA);
  finally xCompA.Free; end;

  xCompB := TComponentB.Create(nil); try
    TestIntf(xCompB);
  finally xCompB.Free; end;

  xCompC := TComponentC.Create(nil); try
    TestIntf(xCompC);
  finally xCompC.Free; end;

  WriteLn;
end;

function TComponentA.GetAnswer: Integer;
begin
  Result := 42;
end;

function TComponentB.GetAnswer: Integer;
begin
  Result := 22;
end;

end.

这段代码运行正常,并产生预期的输出

TestA
Answer: 42
Answer: 22
Answer: 22

问题

问题在于,这仅在所有使用 ILifeA 的类都继承自 TComponent 时才有效。这个限制在很大程度上削弱了接口的主要目的之一。让我们将其中一个类更改为继承自 TInterfacedObjectTInterfacedObjectTObject 的直接子类,它只用于添加接口支持。

例如,这不起作用

type
  ILife = interface
    ['{CED2DFC6-4E8E-42F2-A724-2E3D8539192F}']
    function GetAnswer: Integer;
  end;

  TObjectB1 = class(TObject, ILifeB)
    function GetAnswer: Integer;
  end;

如果您尝试编译此代码,将出现以下错误

[dcc32 Error] UnitB.pas(14): E2291 Missing implementation of interface method IInterface.QueryInterface
[dcc32 Error] UnitB.pas(14): E2291 Missing implementation of interface method IInterface._AddRef
[dcc32 Error] UnitB.pas(14): E2291 Missing implementation of interface method IInterface._Release
[dcc32 Error] UnitB.pas(27): E2015 Operator not applicable to this operand type
[dcc32 Error] UnitB.pas(38): E2034 Too many actual parameters
[dcc32 Fatal Error] Project1.dpr(9): F2063 Could not compile used unit 'UnitB.pas'

这是因为 TObject 没有接口所需的必要脚手架。我们可以通过将祖先更改为 TInterfacedObject 而不是 TObject 来轻松解决这个问题

type
  ILife = interface
    ['{26621188-5BE3-42B4-A906-2963B480C1F4}']
    function GetAnswer: Integer;
  end;

  TObjectC1 = class(TInterfacedObject, ILife)
  private
    function GetAnswer: Integer;
  public
    function GetFoo: integer;
  end;

现在一切都应该没问题了吧?嗯,不对。还有更多问题。看看这段代码

procedure TestIntf(aObj: TInterfacedObject);
var
  i: integer;
  xILife: ILifeC;
begin
  xILife := aObj as ILife;
  i := xILife.GetAnswer;
  WriteLn('Answer: ' + i.ToString);
end;

procedure TestC_1;
var
  xObjC1: TInterfacedObject;
begin
  WriteLn('TestC_1');

  xObjC1 := TObjectC1.Create; try
    TestIntf(xObjC1);
  finally xObjC1.Free; end;

  WriteLn;
end;

乍一看,人们会期望这会起作用。然而,事实并非如此,因为 TInterfacedObject 在实际使用接口时会改变对象的行为,但在不使用接口时则不会。这段代码将在以下语句处崩溃

xObjC1.Free;

为什么?因为当我们使用 ILife 接口并完成它时,编译器会使用引用计数并释放整个对象。您可以通过向 TObjectC1 添加一个虚拟析构函数来看到这一点。然后设置一个断点并查看调用堆栈。在调用 TestIntf 之后,析构函数将被调用。

新问题

接口与继承自 TComponent 的类一起使用时,不会表现出这种行为,这种行为对于 Delphi 在非 ARC 编译器(Windows)中是非标准的。*所以现在对象在使用接口时行为不同,取决于它们的祖先*。

那么,我们现在是否只需要考虑有时销毁,有时不销毁?嗯,事情也没那么简单。现在我们不必释放对象,除了有时!如果未使用接口,那么我们不能释放它。如果未使用接口,我们必须释放它。问题是,当对象被传递到其他方法时,我们如何知道是否有任何代码为其获取了接口?

如果接口被使用,*在任何地方*...

xObjC1 := TObjectC1.Create;
TestIntf(xObjC1);
// Do NOT free xObjC1. Delphi freed it for us.

我们应该释放它还是不释放?

xObjC1 := TObjectC1.Create; try
  // GetFoo is not on interface, must call from object.
  // But if GetFoo or anything it calls uses an interface.. then we don't free it.
  // How do we know?
  i := xObjC1.GetFoo;
// We didnt use interface, we have to free it.
finally xObjC1.Free; end;

新的难以发现的 bug

xObjC1 := TObjectC1.Create;
TestIntf(xObjC1);
// Runs, but runs on "left over memory" and could crash if memory gets modified
// as xObjC1 has already been freed.
i := xObjC1.GetFoo;

当你的代码逻辑变得更深入,并在一个类中添加多个接口时,问题会变得更糟。

其中一些可以通过使用 不安全和/或弱指令 来解决。然而,这不仅仅是在声明处指定,而必须在用户代码引用中使用。在我看来,这也是一个糟糕的解决方案。

解决方案?

所谓的解决方案是到处使用接口引用而不是对象引用。但这削弱了接口的许多用途,并且当一个对象上有多个接口时,问题只会变得更糟。

树形问题

接口的一个主要用途是从不同的对象中暴露一个通用的接口。然而,在很多情况下,在 Delphi 中这是不可行的。

type
  ILife = interface
    ['{7C8E0C18-F8A5-43DF-8999-BF17D6EC961C}']
    function GetAnswer: Integer;
  end;

  TComponentA = class(TComponent, ILife)
    function GetAnswer: Integer;
  end;
  TObjectA = class(TInterfacedObject, ILife)
    function GetAnswer: Integer;
  end;
  TPersistentA = class(TInterfacedPersistent, ILife)
    function GetAnswer: Integer;
  end;

不幸的是,这些很大程度上无法以通用的方式获得接口。这将无法编译

procedure TestIntf(aObj: TObject);
var
  i: integer;
  xILife: ILife;
begin
  xILife := aObj as ILife;
  i := xILife.GetAnswer;
  WriteLn('Answer: ' + i.ToString);
end;

现在显而易见的解决方案是传递接口。然而,这并不总是可行的,并且再次消除了接口的一个主要优点。有一些解决方法,但然后我们就会遇到这样的问题:如果一个类继承自

  • TComponent - 我们必须释放它或使用 Owner 来释放它。
  • TInterfacedPersistent,我们必须释放它。
  • TInterfacedObject
    • 如果任何代码使用过接口,我们不能释放它。
    • 如果没有代码使用过接口,我们必须释放它。

认真的吗?有人认为这是个好主意?

TInterfacedObject 误称

如果 TInterfacedObject 真的必须这样工作,它应该被称为 TARCObject 或其他独特名称。TInterfacedPersistentTPersistent 加上接口支持,而 TInterfacedObjectTObject 加上接口支持和非标准的生命周期管理?TInterfacedObject 文档引用了接口对象的内存管理,但这个主题只有两段简短的文字,几乎没有暗示它引入的问题。

但就是用接口吧!

是的。我明白了。如前所述,通过仅使用接口引用,可以使用 TInterfacedObject 的“解决方法”。但如果您仍然认为这是一个“解决方案”,那么您就完全错过了重点。

仅使用接口引用会在访问非接口成员时带来复杂性。此外,关于这个问题也缺乏文档。然后,如果执行的代码路径不使用接口,我们就会遇到生命周期问题,并且多个接口也需要额外的代码。

一个持久存在的问题

TInterfacedPersistent 没有 TInterfacedObject 那样存在释放/可能释放的问题。

TComponent 继承自 TPersistent,但不继承自 TInterfacedPersistent

TComponent = class(TPersistent, IInterface, IInterfaceComponentReference)

VCL 的声明如下

  • TPersistent
    • TInterfacedPersistent
    • TComponent

这意味着,如果我们有

type
  ILife = interface
    ['{26621188-5BE3-42B4-A906-2963B480C1F4}']
    function GetAnswer: Integer;
  end;

  TObjectC1 = class(TInterfacedObject, ILife)
  private
    function GetAnswer: Integer;
  public
    function GetFoo: integer;
  end;

  TPersistentC1 = class(TInterfacedPersistent, ILife)
  private
    function GetAnswer: Integer;
  public
    destructor Destroy; override;
  end;

我们仍然无法从 TPersistent 引用中使用接口,即使它们都以 TPersistent 为基类。如果它们这样声明

  • TPersistent
    • TInterfacedPersistent
      • TComponent

至少我们可以在 TComponentTInterfacedPersistent 之间正确地使用接口。但两者之间的接口脚手架略有不同,这阻止了这种情况。接口和 TComponent 也存在问题。尽管 TInterfacedPersistentTComponent 具有不同的接口脚手架,但 TComponent 仍然可以更改为继承自 TInterfacedPersistent,并且接口脚手架(实现为方法)可以被覆盖。这将为这个问题提供一个简单的解决方案。

您也可以创建自己的接口脚手架在您的对象或一个基类中使用,但这并不能解决树形问题。

hack 解决方案

有一个 hacky 的解决方案——一个本不该需要的解决方案。这也可以使用 RTTI(对于非 Delphi 开发人员来说是运行时反射)来完成,但也不是最理想的。为了在树中的不同接口脚手架入口点之间获取一个接口,可以这样做
function GetALife(aObject: TPersistent): ILife;
begin
  if aObject is TInterfacedPersistent then begin
    Result := TInterfacedPersistent(aObject) as ILife;
  end else if aObject is TComponent then begin
    Result := TComponent(aObject) as ILife;
  end else begin
    raise Exception.Create('Cannot obtain interface.');
  end;
end;
这提供了一个相当可用的解决方案,尽管它根本不应该需要。此方法需要为每个接口添加一个函数。我避免实现 TInterfacedObject,因为它会让我避之不及,但它也可以工作,当然也会引入生命周期管理问题,这些问题与从 TInterfacedPersistentTComponent 返回 ILife 时不同。

结论

好像我没有足够的理由来避免接口,Delphi 实现它们的方式只会增加负担,并且使接口对我来说几乎完全无用,因为它们增加了比它们帮助更多的代码和风险。对我的净收益是强烈的负面,除了少数例外。

在不给 TObject 添加沉重负担的情况下,最好将其内置支持,以便即使存在单独的脚手架实现也能获得单个接口。接口就应该是接口,*不应该依赖于类本身的具体细节来确定兼容性以及生命周期管理*。此外,TInterfacedObjects 的生命周期管理方式使其成为一个非常危险的类,并且“正确使用”它们会严重限制其有用性。

除非你绝对需要 TInterfacedObject,否则我建议完全避免它,而是使用 TInterfacedPersistentTInterfacedPersistent 添加了 RTTI 支持。如果您不想有那个开销,您可以创建自己的 TInterfacedObjectThatIsntDrunk,通过克隆 TInterfacedPersistent,但将其从 TPersistent 改为继承自 TObject,并进行微小的调整。

© . All rights reserved.