在Delphi中,抽象类与接口 - 分离定义和实现

24

使用接口还是抽象类更好的方法来分离定义和实现?

我个人不喜欢将引用计数对象与其他对象混合在一起。我想象在维护大型项目时,这可能会成为噩梦。

但是有时我需要从两个或多个类/接口派生一个类。

你有什么经验吗?


相关问题:https://dev59.com/M3NA5IYBdhLWcg3wjOlS。对于Delphi的具体问题似乎是有道理的。 - mghie
我知道它们之间的区别,但实际上我感觉缺少了些什么 :) 或许可以妥协一下?怎么样新声明一个:TBase = abstract class ... end; 它像接口一样工作,但不进行引用计数。 - markus_ja
缺少的是一个不是 IUnknown 的接口。由于 Delphi 没有多重继承(这很可能是一件好事),因此您的 TBase 是无用的。您已经可以拥有一个抽象基类了。 - mghie
@mghie:我知道这个事实,因此我认为引入一个新的声明,例如“抽象类”,应该像接口一样应用多重继承。这将与没有IUknown的接口相同,正如您所提到的那样。 - markus_ja
抽象类:http://docwiki.embarcadero.com/RADStudio/zh-cn/Classes_and_Objects - Ondrej Kelle
有没有“第三种选择”?除了接口和抽象类之外,还有其他将接口与实现分离的方法吗? - Warren P
6个回答

28

理解这一点的关键是要认识到,它不仅涉及到定义与实现之间的区别。它是有关于用不同的方式描述同一名称的:

  • 类继承 回答了问题: "这是什么类型的对象?"
  • 接口实现 回答了问题: "我可以用这个对象来做什么?"

假设你正在建立一个厨房模型。(为以下食物类比表示歉意,我刚从午餐回来...)你有三种基本的餐具 - 叉子、刀和勺子。 它们都属于餐具类别,所以我们会对其进行建模(省略了一些无聊的东西,如支持字段):

type
    TMaterial = (mtPlastic, mtSteel, mtSilver);

    TUtensil = class
    public
        function GetWeight : Integer; virtual; abstract;
        procedure Wash; virtual; // Yes, it's self-cleaning
    published
        property Material : TMaterial read FMaterial write FMaterial;
    end;

这些都描述了任何餐具都共有的数据和功能,例如它是由什么材料制成的,它的重量(这取决于具体的类型)等等。但是您会注意到抽象类实际上并没有做任何事情。一个TFork和一个TKnife没有太多共同之处可以放在基类中。您可以使用TFork技术上进行Cut操作,但使用TSpoon可能有点勉强,那么如何反映只有一些餐具可以执行特定任务呢?

好吧,我们可以开始扩展层次结构,但这会变得混乱:

type
    TSharpUtensil = class
    public
        procedure Cut(food : TFood); virtual; abstract;
    end;

那样可以处理这些敏锐的问题,但如果我们想以另一种方式进行分组呢?

type
    TLiftingUtensil = class
    public
        procedure Lift(food : TFood); virtual; abstract;
    end;

TForkTKnife都适合放在TSharpUtensil下,但是TKnife不太适合用来举起一块鸡肉。我们最终要么选择这两个层次结构之一,要么将所有功能塞进通用的TUtensil中,并让派生类简单地拒绝实现没有意义的方法。从设计的角度来看,这不是我们想陷入的境地。

当然,这样做的真正问题是,我们使用继承来描述一个对象所做的事情,而不是它是什么。对于前者,我们有接口可以使用。我们可以大大改善这个设计:

type
    IPointy = interface
        procedure Pierce(food : TFood);
    end;

    IScoop = interface
        procedure Scoop(food : TFood);
    end;

现在我们可以梳理一下具体类型的作用:

type
    TFork = class(TUtensil, IPointy, IScoop)
        ...
    end;

    TKnife = class(TUtensil, IPointy)
        ...
    end;

    TSpoon = class(TUtensil, IScoop)
        ...
    end;

    TSkewer = class(TStick, IPointy)
        ...
    end;

    TShovel = class(TGardenTool, IScoop)
        ...
    end;

我认为每个人都能理解。关键(没打趣)是我们对整个过程有非常细致的控制,而且不需要做出任何折衷。我们在这里同时使用了继承和接口,选择并不互斥,只是在抽象类中包含那些真正普遍适用于所有派生类型的功能。

你是否选择使用抽象类或下游的一个或多个接口,实际上取决于你需要用它做什么:

type
    TDishwasher = class
        procedure Wash(utensils : Array of TUtensil);
    end;

这很有道理,因为只有餐具才会放在洗碗机里,至少在我们非常简陋的厨房里是这样,没有像盘子或杯子这样的奢侈品。即使 TSkewerTShovel 在技术上可以参与进食过程,它们可能也不适合放在洗碗机里。

另一方面:

type
    THungryMan = class
        procedure EatChicken(food : TFood; utensil : TUtensil);
    end;

这可能不是很好。他不能只用一把 TKnife 来吃饭(至少不容易)。同时要求使用 TForkTKnife 也没有意义;如果只是鸡翅膀呢?

这样做更加合理:

type
    THungryMan = class
        procedure EatPudding(food : TFood; scoop : IScoop);
    end;

现在我们可以给他TForkTSpoon或者TShovel,他会很高兴,但是不要给他TKnife,虽然它也是餐具,但在这里并没有什么用处。

你也会注意到,第二个版本对类层次结构的变化更加不敏感。如果我们决定将TFork改为继承自TWeapon,只要它仍然实现了IScoop,我们的人还是很高兴的。


在这里,我也有点简略地概述了引用计数问题,我认为@Deltics说得最好。只是因为你有那个AddRef并不意味着你需要像TInterfacedObject一样使用它。接口引用计数是一种附带功能,它是一个有用的工具,当你需要它的时候,但如果你要混合接口和类语义(很多情况下都是这样),使用引用计数特性作为内存管理形式并不总是合理的。

实际上,我甚至可以说,大多数时间,你可能不想要引用计数语义。是的,在这里,我说了。我一直觉得整个引用计数的事情只是为了支持OLE自动化之类的(IDispatch)。除非你有一个很好的理由想要自动销毁你的接口,否则就不要使用TInterfacedObject。当你需要时,你总是可以改变它——这就是使用接口的目的!从高层次设计的角度考虑接口,而不是从内存/生存期管理的角度考虑。


所以故事的寓意是:

  • 当你需要一个对象来支持某些特定功能时,请尝试使用接口。

  • 当对象属于同一族,并且你希望它们共享公共特性时,请继承自一个公共基类。

  • 如果两种情况都适用,则同时使用!


8
我怀疑这不是一个“更好的方法”的问题 - 它们只是有不同的使用情况。
如果您没有类层次结构,并且不想建立一个,而且强制将不相关的类放入同一层次结构中甚至没有意义 - 但是您仍然希望处理某些类时不必知道类的特定名称,则接口是正确的选择(例如Java中的Comparable或Iterateable,如果您必须从这些类派生(假设它们是类=),那么它们将是完全无用的。
如果您有合理的类层次结构,则可以使用抽象类为该层次结构中的所有类提供统一的访问点,其好处是您甚至可以实现默认行为等。

+1。如果使用情况重叠,将类放入类层次结构中实现接口也是完全合理的。 - mghie
我也考虑过如何使类可测试。一个方法或类只使用接口/抽象类来调用传入对象上的适当函数。这样,你就可以在单元测试中使用不同的实现(依赖倒置原则)。 - markus_ja
越是开始实践TDD,你就会发现自己越来越多地使用接口。接口使得编写测试用例变得更加简单。优先选择组合而非继承 开始变得有意义。我们在尝试获得完全掌握的过程中经历了不同的阶段。我现在处于 让所有东西都成为接口 的阶段 :) 以下可能是一篇有趣的阅读材料 http://bsix12.com/shu-ha-ri/ - Lieven Keersmaekers

5
您可以拥有没有引用计数的接口。编译器会为所有接口添加AddRef和Release调用,但这些对象的生命周期管理完全取决于IUnknown实现的方式。
如果您从TInterfacedObject派生,则对象生命周期确实将被引用计数,但如果您从TObject派生自己的类,并实现IUnknown而不实际计算引用并且在Release的实现中不释放“self”,则您将获得一个支持接口但具有显式管理生命周期的基类,就像普通的TObject一样。
由于编译器注入了自动生成的AddRef()和Release()调用,因此您仍然需要小心处理这些接口引用,但这与处理常规TObject的“悬空引用”并没有太大区别。
这是我过去在复杂和大型项目中成功使用的东西,甚至混合支持接口的引用计数和非引用计数对象。

从TComponent派生还会禁用引用计数(以防您懒惰)。与您自己的实现相比的劣势当然是它增加了一些开销。 - dummzeuch

3
在Delphi中,有三种将定义与实现分离的方法。
1. 每个单元中都可以将公共类放在接口部分,将其实现放在实现部分中进行分离。代码仍然驻留在同一单元中,但是使用您的代码的“用户”只需要阅读接口而不是实现细节。
2. 在类中使用虚拟或动态声明的函数时,可以在子类中重写这些函数。这是大多数类库使用的方式。请查看TStream及其派生类,如THandleStream、TFileStream等。
3. 当您需要不止类派生的不同层次结构时,可以使用接口。接口始终派生自IInterface,该接口被建模为基于COM的IUnknown:您获得引用计数和查询类型信息。
对于第3点: - 如果您从TInterfacedObject派生,则引用计数确实会处理对象的生命周期,但这并非必需。 - 例如,TComponent也实现了IInterface,但没有引用计数。这带来了一个巨大的警告:在销毁对象之前,请确保将接口引用设置为nil。编译器仍然会向您的接口插入减少引用计数的调用,这看起来仍然有效,但实际上不是。第二点:人们不会预料到这种行为。
在2和3之间进行选择有时相当主观。我倾向于使用以下方法: - 如果可能,请使用虚拟和动态并在派生类中重写它们。 - 在使用接口时,请创建一个接受接口实例引用作为变量的基类,并尽可能简化您的接口;对于每个方面,请尝试创建单独的接口变量。在未指定接口时,请确保有默认实现。 - 如果上述方法太过限制:开始使用TInterfacedObject,但要非常注意可能的循环引用和内存泄漏问题。

我喜欢这篇文章。非常详细。请注意,FPC具有Corba接口来解决“for 3”问题,但它从未得到充分发展。 - Marco van de Voort
简单的接口加一分。当你只需要使用其中一个方法时,被迫实现15个接口方法是很无趣的。 - Incredulous Monk

2

根据我的经验,在极大型项目中,这两种模式都能够很好地运作,甚至可以在不产生任何问题的情况下共存。接口比类继承具有更多优势,因为您可以将特定接口添加到多个不从共同祖先继承的类中,或者至少不会在已经被证明可用的代码层次结构中引入新错误的风险。


1

我非常讨厌COM接口,除非别人已经创建了一个,否则我永远不会使用它们。也许这是因为我不信任COM和类型库的东西。我甚至会将接口伪装成带有回调插件的类,而不是使用接口。我想知道是否还有其他人像我一样感到痛苦,并避免使用接口,就好像它们是一种瘟疫一样。

我知道有些人会认为我避免使用接口是一种弱点。但我认为所有使用接口的Delphi代码都有一种“代码气味”。我喜欢使用委托和任何其他机制,将我的代码分成几个部分,并尝试尽可能多地使用类,永远不使用接口。我并不是说这是好的,我只是说我有我的理由,我有一个规则(有时可能是错误的,对于某些人来说总是错误的):我避免使用接口。


1
如果您能详细说明原因,那将会很有帮助。关于COM接口有哪些令人痛苦的地方? - reinierpost
有些开发者甚至梦想着像ISmellVeryBadly = interface (...) :)这样的代码行。 - mjn
是的,我也对COM过敏。可能是接触DirectX让我对它们失去了兴趣。 - Incredulous Monk
主要是DCOM和DCOMConfig让我讨厌DCOM,因此也讨厌COM。直到它们试图跨越进程和网络边界(从COM技术到DCOM),我才开始讨厌整个混乱局面。 - Warren P

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