是否有某种语言或设计模式允许“删除”类层次结构中的对象行为或属性?


28

传统类层次结构的一个众所周知的缺点是,在对现实世界建模时,它们是不好的。例如,尝试用类来表示动物的种类。这样做确实存在几个问题,但是我从未见过的解决方案是,当子类“丢失”超类中定义的行为或属性时,例如企鹅无法飞行(有也许是更好的例子,但这是我想到的第一个)。

一方面,您不想为每个属性和行为定义一些标志来指定它们是否全部存在,并在访问该行为或属性之前每次对其进行检查。您只想说在Bird类中,鸟类可以简单,清晰地飞行。但是,如果以后可以定义“例外”而不必到处使用一些可怕的骇客,那就太好了。当系统生产一段时间后,通常会发生这种情况。您突然发现一个根本不适合原始设计的“例外”,并且您不想更改代码的很大一部分来容纳它。

因此,是否有一些语言或设计模式可以干净地处理此问题,而无需对“超类”以及使用它的所有代码进行重大更改?即使解决方案仅处理特定情况,也可以将多个解决方案一起形成一个完整的策略。

经过更多思考,我意识到我忘记了《里斯科夫替代原则》。这就是为什么你不能这样做。假设您为所有主要的“功能组”定义了“特征/接口”,则可以在层次结构的不同分支中自由实现特征,例如“飞翔”特征可以由Birds以及某些特殊的松鼠和鱼来实现。

因此,我的问题可能是“我如何取消实施特质?” 如果您的超类是Java可序列化的Java,则即使您无法对状态进行序列化(例如,如果您包含“ Socket”),您也必须也是一个。

一种方法是始终从一开始就成对定义所有特征:Flying和NotFlying(如果不进行检查,则会抛出UnsupportedOperationException)。非特征不会定义任何新接口,可以简单地进行检查。听起来像是“便宜”的解决方案,特别是如果从一开始就使用它。


3
“不必到处使用一些可怕的骇客”:禁用行为是一个可怕的骇客:这意味着事情function save_yourself_from_crashing_airplane(Bird b) { f.fly() }将变得更加复杂。(正如
PeterTörök

策略模式和继承的组合可能使您可以“构成”特定超级类型的继承行为?当您说:" it would be nice if one could define "exceptions" afterward, without having to use some horrible hacks everywhere"您是否认为控制行为怪异的工厂方法?
StuperUser 2011年

1
一个当然可以随便扔一个NotSupportedExceptionPenguin.fly()
Felix Dombek

就语言而言,您当然可以在子类中取消实现方法。例如,在Ruby中:class Penguin < Bird; undef fly; end;。是否应该是另一个问题。
内森·朗

这将打破liskov原则,甚至可以打破OOP的整个观点。
deadalnix

Answers:


17

正如其他人提到的那样,您将不得不违反LSP。

但是,可以说,子类仅仅是超类的任意扩展。它本身就是一个新对象,与超类的唯一关系是它使用了基础。

这可能合乎逻辑,而不是说企鹅鸟。您所说的企鹅继承了Bird的某些行为子集。

通常,动态语言使您可以轻松地表达这一点,下面是一个使用JavaScript的示例:

var Penguin = Object.create(Bird);
Penguin.fly = undefined;
Penguin.swim = function () { ... };

在这种特殊情况下,Penguin正在Bird.fly通过将fly具有值的属性写入undefined对象来主动隐藏其继承的方法。

现在您可能会说Penguin不能再视为普通人Bird了。但是如上所述,在现实世界中它根本无法做到。因为我们将模型建模Bird为飞行中的实体。

另一种选择是不做一个大的假设,即伯德会飞。拥有一个Bird允许所有鸟类从其继承而不会失败的抽象是明智的。这意味着仅假设所有子类都可以容纳。

通常,Mixin的想法在这里很好地适用。有一个非常瘦的基类,并将所有其他行为混在一起。

例:

// for some value of Object.make
var Penguin = Object.make(
  /* base class: */ Bird,
  /* mixins: */ Swimmer, ...
);
var Hawk = Object.make(
  /* base class: */ Bird,
  /* mixins: */ Flyer, Carnivore, ...
);

如果您好奇,我可以执行Object.make

加成:

因此,我的问题可能是“我如何取消实施特质?” 如果您的超类是Java可序列化的Java,则即使您无法对状态进行序列化(例如,如果您包含“ Socket”),您也必须也是一个。

您不会“取消实施”特质。您只需修复继承层次结构即可。您可以履行超级班级合同,或者不应该假装自己属于这种类型。

这是对象合成的亮点。

顺便说一句,Serializable并不意味着所有内容都应该被序列化,它仅意味着“您关心的状态”应该被序列化。

您不应该使用“ NotX”特征。那只是可怕的代码膨胀。如果某个功能需要飞行的物体,则当给它一个猛mm象时,它应该会崩溃并燃烧。


10
“在现实世界中,它根本无法做到。” 是的,它可以。企鹅是鸟。飞行的能力不是鸟类的特性,它只是大多数鸟类的巧合特性。定义鸟类的属性是“羽毛,有翅,双足,吸热,产卵,脊椎动物”(维基百科)-与在那儿飞行无关。
pdr

2
@pdr再次取决于您对bird的定义。当我使用“ Bird”一词时,是指我们用来表示鸟类的类抽象,包括fly方法。我还提到过,您可以使类抽象的具体性降低。企鹅也没有羽毛。
雷诺斯2011年

2
@Raynos:企鹅确实长着羽毛。当然,它们的羽毛很短而且很密。
乔恩·普迪

@JonPurdy很公平,我一直以为他们有皮草。
雷诺斯2011年

一般为+1,特别是对于“猛mm”。大声笑!
Sebastien Diot

28

AFAIK所有基于继承的语言都是基于Liskov替换原理构建的。删除/禁用子类中的基类属性显然会违反LSP,因此我认为在任何地方都不会实现这种可能性。现实世界确实是一团糟,无法用数学抽象精确地建模。

某些语言提供特征或混合,正是为了以更灵活的方式处理此类问题。


1
LSP适用于类型,而不适用于
约尔格W¯¯米塔格

2
@PéterTörök:否则,这个问题将不存在:-)我可以想到Ruby中的两个例子。Class是的子类Module,即使ClassIS-NOT-A Module。但是作为子类仍然有意义,因为它可以重用许多代码。OTOH,StringIOIS-A IO,但是两者没有任何继承关系(Object当然,除了显然两者都继承自之外),因为它们没有共享任何代码。类用于代码共享,类型用于描述协议。IO并且StringIO具有相同的协议,因此具有相同的类型,但是它们的类无关。
约尔格W¯¯米塔格

1
@JörgWMittag,好,现在我明白了你的意思。但是,对我而言,您的第一个示例听起来更像是对继承的滥用,而不是您似乎暗示的一些基本问题的表达。公共继承IMO不应用于重用实现,而只能用于表示子类型关系(is-a)。而且它可以被滥用的事实并不意味着它不合格-我无法想象任何领域都可以滥用的任何可用工具。
彼得Török

2
对于支持此答案的人们:请注意,这并不能真正回答问题,尤其是在澄清说明后。我认为这个答案不应该被否决,因为他说的话非常真实且重要,但他并没有真正回答这个问题。
2011年

1
想象一下一个Java,其中只有接口是类型,类不是,子类能够“取消实现”其超类的接口,我想您的想法很粗略。
约尔格W¯¯米塔格

15

Fly()在第一个例子:Head First设计模式策略模式,这是一个很好的局面,为什么你应该“在继承青睐组成。”

你可以通过具有超类型混合组成和继承FlyingBirdFlightlessBird有一个工厂注入正确的行为,使相关亚型如Penguin : FlightlessBird自动获取,和其他任何真正具体得到由工厂作为理所当然的处理。


1
我在回答中提到了Decorator模式,但是Strategy模式也很好用。
2011年

1
+1表示“偏好组合超过继承”。但是,必须有特殊的设计模式才能以静态类型的语言实现合成,这加剧了我对Ruby等动态语言的偏见。
罗伊·廷克

11

您假设的真正问题不是BirdFly方法吗?为什么不:

class Bird
{
    // features that all birds have
}

class BirdThatCanSwim : Bird
{
    public void Swim() {...};
}

class BirdThatCanFly : Bird
{
    public void Fly() {...};
}


class Penguin : BirdThatCanSwim { }
class Sparrow : BirdThatCanFly { }

现在明显的问题是多重继承(Duck),因此您真正需要的是接口:

interface IBird { }
interface IBirdThatCanSwim : IBird { public void Swim(); }
interface IBirdThatCanFly : IBird { public void Fly(); }
interface IBirdThatCanQuack : IBird { public void Quack(); }

class Duck : BirdThatCanFly, IBirdThatCanSwim, IBirdThatCanQuack
{
    public void Swim() {...};
    public void Quack() {...};
}

3
问题在于进化不遵循Liskov替代原理,而是通过特征移除实现继承。
Donal Fellows

7

首先,是的,任何允许轻松进行对象动态修改的语言都可以使您做到这一点。例如,在Ruby中,您可以轻松删除方法。

但是正如PéterTörök所说,这将违反LSP


在这一部分中,我将忽略LSP,并假设:

  • Bird是带有fly()方法的类
  • 企鹅必须继承伯德
  • 企鹅不会飞()
  • 我不在乎它是不是一个好的设计或它是否与现实世界相匹配,因为这是此问题中提供的示例。

你说 :

一方面,您不想为每个属性和行为定义一些标志来指定它们是否存在,并在每次访问该行为或属性之前对其进行检查

看起来您想要的是Python的“ 请求宽恕而不是许可

只需让您的企鹅抛出异常或从抛出异常的NonFlyingBird类继承即可(伪代码):

class Penguin extends Bird {
     function fly():void {
          throw new Exception("Hey, I'm a penguin, I can't fly !");
     }
}

顺便说一下,无论您选择什么:引发异常或删除方法,最后,下面的代码(假设您的语言支持方法删除):

var bird:Bird = new Penguin();
bird.fly();

将抛出运行时异常。


“只是让您的企鹅抛出异常,或者从引发异常的NonFlyingBird类继承”,这仍然违反LSP。仍然暗示企鹅可以飞翔,即使它的飞翔执行失败。企鹅上永远不会有飞行方法。
PDR

@pdr:它不是这表明企鹅飞,但它应该飞(这是一个合同)。异常会告诉您它不能。顺便说一句,我并不是说这是一种良好的OOP做法,我只是回答了一部分问题
David

重点是不应仅仅因为企鹅是鸟而期望其飞行。如果我想编写这样的代码:“如果x可以飞行,则执行此操作;否则执行该操作。” 我必须在您的版本中使用try / catch,在这里我应该能够询问对象是否可以飞行(存在广播或检查方法)。它可能只是措辞,但您的答案暗示抛出异常符合LSP。
PDR

@pdr“我必须在您的版本中使用try / catch”->这就是要求宽恕而不是获得许可的全部要点(因为即使Duck也可能折断了翅膀,无法飞行)。我会修正措辞。
大卫,

“这就是要求宽恕而不是允许的全部内容。” 是的,除了它允许框架为任何缺少的方法抛出相同类型的异常之外,因此Python的“ try:except AttributeError:”与C#的“ if(X is Y){} else {}”完全等效,并且可以立即识别因此。但是,如果您故意抛出CannotFlyException来覆盖Bird中的默认fly()功能,那么它将变得难以识别。
pdr

7

正如上面的评论中指出的那样,企鹅是鸟类,企鹅不会飞,因此并不是所有的鸟类都能飞。

因此,Bird.fly()不应该存在或不允许运行。我更喜欢前者。

当然,使用FlyingBird扩展Bird的.fly()方法是正确的。


我同意,Fly应该是Bird 可以实现的接口。它也可以实现为具有默认行为的方法,可以将其覆盖,但是更干净的方法是使用接口。
乔恩·雷诺

6

fly()示例的真正问题是操作的输入和输出未正确定义。一只鸟飞需要什么?飞行成功后会发生什么?fly()函数的参数类型和返回类型必须具有该信息。否则,您的设计将取决于随机的副作用,任何事情都可能发生。在什么部分是什么原因造成的整个问题,接口没有正确定义和各种实施的是允许的。

因此,代替此:

class Bird {
public:
   virtual void fly()=0;
};

您应该具有以下内容:

   class Bird {
   public:
      virtual float fly(float x) const=0;
   };

现在,它明确定义了功能的限制-您的飞行行为只有一个浮动值可以决定-在给定位置的情况下与地面的距离。现在,整个问题自动解决了。不能飞行的Bird只会从该函数返回0.0,而不会离开地面。这样做是正确的,一旦确定了一个浮动,就知道您已经完全实现了该接口。

真实的行为很难编码为类型,但这是正确指定接口的唯一方法。

编辑:我想澄清一个方面。fly()函数的float-> float版本也很重要,因为它定义了路径。这个版本意味着一只鸟在飞行时无法神奇地自我复制。这就是为什么该参数是单个float的原因-它是鸟在路径中的位置。如果您想要更复杂的路径,请使用Point2d posinpath(float x); 使用与fly()函数相同的x。


1
我很喜欢你的回答。我认为应该得到更多的选票。
塞巴斯蒂安·迪奥

2
极好的答案。问题是问题只是对fly()的实际作用挥之不去。fly的任何真正实现都至少应有一个目的地-fly(坐标目的地),在企鹅的情况下,可以重写以实现{return currentPosition)}
Chris Cudmore

4

从技术上讲,您几乎可以使用任何动态/鸭子类型的语言(JavaScript,Ruby,Lua等)来执行此操作,但这几乎总是一个非常糟糕的主意。从类中删除方法是维护方面的噩梦,类似于使用全局变量(即,您无法在一个模块中告知全局状态尚未在其他地方修改)。

针对您描述的问题的好的模式是Decorator或Strategy,设计组件体系结构。基本上,不是从子类中删除不需要的行为,而是通过添加所需的行为来构建对象。因此,要建造大多数鸟类,您需要添加飞行组件,但不要将该组件添加到企鹅中。


3

彼得提到了《里斯科夫替代原则》,但我认为需要解释。

令q(x)是关于T类型的对象x的一个可证明性质。然后q(y)对于S类型的对象y应当是可证明的,其中S是T的子类型。

因此,根据定义,如果Bird(T类型的对象x)可以飞行(q(x)),则Pen(S类型的对象y)可以飞行(q(y))。但这显然不是事实。还有其他一些会飞的生物,但不是鸟类。

如何处理取决于语言。如果一种语言支持多重继承,那么应该为可以飞行的生物使用一个抽象类。如果一种语言偏爱接口,那就是解决方案(fly的实现应该封装而不是继承);或者,如果一种语言支持Duck Typing(不要求双关语),那么您可以仅在可以的类上实现fly方法,如果有的话就调用它。

但是,超类的每个属性都应适用于其所有子类。

[回应编辑]

将CanFly的“特征”应用于Bird并没有更好。仍然建议调用代码以使所有鸟类都能飞翔。

您定义的术语中的一个特质正是Liskov所说的“财产”的含义。


2

让我首先提到(与其他所有人一样)《里斯科夫替代原则》,这解释了为什么您不应该这样做。但是,您应该做什么的问题是设计之一。在某些情况下,企鹅实际上不能飞行并不重要。只要您在Bird :: fly()的文档中明确指出,企鹅可能会向不能飞行的鸟抛出InsufficientWingsException,但您可能会要求它让它飞。进行测试以查看它是否真的可以飞行,尽管这会使界面肿。

另一种方法是重组您的类。让我们创建类“ FlyingCreature”(如果要使用允许的语言,则最好是一个接口)。“ Bird”不是从FlyingCreature继承的,但是您可以创建包含此内容的“ FlyingBird”。云雀,秃鹰和鹰都继承自FlyingBird。企鹅没有。它只是继承自Bird。

它比朴素的结构要复杂一些,但是它具有准确的优点。您会注意到,所有预期的类都在那里(鸟),如果您的生物是否会飞行并不重要,则用户通常可以忽略“发明的”类(FlyingCreature)。


0

处理这种情况的典型方法是抛出类似UnsupportedOperationException(Java)的响应。NotImplementedException(C#)。


只要您在Bird中记录了这种可能性。
DJClayworth 2011年

0

许多好的答案都带有很多评论,但他们并不完全同意,我只能选择一个,所以在这里我将总结所有我同意的观点。

0)不要假设“静态类型”(我问的时候就这么做了,因为我几乎只做Java)。基本上,问题很大程度上取决于人们使用的语言类型。

1)即使在设计和头脑中,类型层次结构与代码重用层次结构也应分开,即使它们大部分重叠。通常,将类用于重用,将接口用于类型。

2)之所以通常说“鸟IS-A飞”是因为大多数鸟都可以飞,所以从代码重用的角度来看是可行的,但是说“鸟IS-A飞”实际上是错误的,因为至少有一个例外(企鹅)。

3)在静态和动态语言中,您都可以引发异常。但是,仅当在声明该功能的类/接口的“合同”中明确声明了它时,才应使用它,否则它是“违反合同”。这也意味着您现在必须准备随时随地捕获异常,因此您需要在调用站点编写更多代码,而这是丑陋的代码。

4)在某些动态语言中,实际上有可能“删除/隐藏”超类的功能。如果检查功能是否存在就是您使用该语言检查“ IS-A”的方式,那么这是一个适当而明智的解决方案。另一方面,如果“ IS-A”操作仍然表明您的对象“应该”实现现在缺少的功能,那么您的调用代码将假定该功能存在并调用并崩溃,因此引发异常的数量很大。

5)更好的选择是将Fly特性与Bird特性分开。因此,飞鸟必须明确地扩展/实现“鸟”和“飞/飞”。这可能是最干净的设计,因为您不必“删除”任何内容。现在的一个缺点是几乎每只鸟都必须同时实现Bird和Fly,因此您需要编写更多代码。解决此问题的方法是拥有一个中间类FlyingBird,该类同时实现Bird和Fly,并且代表常见情况,但是这种变通办法在没有多重继承的情况下可能用途有限。

6)另一种不需要多重继承的替代方法是使用合成代替继承。动物的每个方面都由一个独立的类建模,而具体的Bird是Bird的组成部分,可能是Fly或Swim,……您可以重复使用全部代码,但必须执行一个或多个其他步骤才能获得引用具体Bird时使用“飞行”功能。同样,自然的“对象IS-A Fly”和“对象AS-A(cast)Fly”语言将不再起作用,因此您必须发明自己的语法(某些动态语言可能对此有所帮助)。这可能会使您的代码更加繁琐。

7)定义您的飞行特质,为无法飞行的物体提供清晰的出路。Fly.getNumberOfWings()可能返回0。如果Fly.fly(direction,currentPotinion)应该在飞行后返回新位置,则Penguin.fly()可以仅返回currentPosition而不更改它。您可能会得到技术上可行的代码,但是有一些警告。首先,某些代码可能没有明显的“不执行任何操作”行为。另外,如果有人调用x.fly(),即使注释中指出fly()不能执行任何操作,他们也会希望它能够执行某些操作。最后,企鹅IS-A Flying仍会返回true,这可能会使程序员感到困惑。

8)按照5)进行操作,但是使用组合来避开需要多重继承的情况。对于静态语言,这是我更喜欢的选项,因为6)看起来比较麻烦(并且可能需要更多的内存,因为我们有更多的对象)。动态语言可能会减少6)的麻烦,但是我怀疑它会不会比5)麻烦。


0

在基类中定义默认行为(将其标记为虚拟),并根据需要覆盖它。这样,每只鸟都可以“飞翔”。

甚至企鹅也会飞,它会在零海拔高度滑过冰面!

可以根据需要覆盖飞行行为。

另一种可能性是具有飞行接口。并非所有的鸟都会实现该接口。

class eagle : bird, IFly
class penguin : bird

属性无法删除,因此,了解所有鸟类共有哪些属性很重要。我认为,这是一个设计问题,要确保在基本级别上实现通用属性。


-1

我认为您正在寻找的模式是好的旧多态性。虽然您可以使用某些语言从类中删除接口,但出于PéterTörök的原因,这可能不是一个好主意。但是,在任何OO语言中,您都可以覆盖一种方法来更改其行为,这包括什么也不做。要借用您的示例,您可以提供执行以下任何操作的Penguin :: fly()方法:

  • 没有
  • 引发异常
  • 而是调用Penguin :: swim()方法
  • 断言企鹅在水下(它们确实在水中“飞行”)

如果您提前计划,则属性添加和删除起来会更容易一些。您可以将属性存储在地图/字典/关联数组中,而不使用实例变量。您可以使用Factory模式来生成此类结构的标准实例,因此,来自BirdFactory的Bird将始终以相同的属性集开始。Objective-C的键值编码就是这类事情的一个很好的例子。

注意:从下面的评论中可以得出的重要教训是,尽管重写可以消除某种行为是可行的,但这并不总是最好的解决方案。如果您发现自己需要以任何重要方式进行操作,则应考虑一个强有力的信号,表明您的继承图存在缺陷。并非总是可以重构从中继承的类,但是当是这样时,通常是更好的解决方案。

使用您的Penguin示例,重构的一种方法是将飞行能力与Bird类分开。由于并不是所有的鸟都能飞,所以在Bird中包括fly()方法是不合适的,并直接导致您要问的那种问题。因此,将fly()方法(可能还有takeoff()和land())移动到Aviator类或接口(取决于语言)。这使您可以创建一个从Bird和Aviator继承(或从Bird继承并实现Aviator)的FlyingBird类。企鹅可以继续直接从伯德那里继承,但不能继续从飞行员那里继承,从而避免了这个问题。这样的安排还可以使为其他飞行物创建类变得容易,这些类包括:FlyingFish,FlyingMammal,FlyingMachine,AnnoyingInsect等。


2
-1甚至建议调用Penguin :: swim()。这违反了最少惊讶原则,将导致各地的维护程序员诅咒您的名字。
DJClayworth 2011年

1
@DJClayworth因为该示例首先是荒谬的,所以对fly()和swim()的推断行为的违反进行了低票。但是,如果您真的想认真研究这个问题,我同意您更有可能采用另一种方法,并在fly()方面实现swim()。鸭子通过划脚游泳。企鹅拍打翅膀。
Caleb

1
我同意这个问题很愚蠢,但是麻烦是我已经看到人们在现实生活中这样做-使用现有的“不做任何事情”的调用来实现稀有功能。它确实弄糟了代码,并且通常以编写“ if(!(myBird instanceof Penguin))fly();”结尾。在很多地方,希望没有人创建鸵鸟类。
DJClayworth 2011年

断言更糟。如果我有一个Birds数组,它们都具有fly()方法,那么当我在它们上调用fly()时,我不希望断言失败。
DJClayworth 2011年

1
我没有读过企鹅文档,因为我被递给了一系列“鸟”,而且我不知道企鹅会出现在阵列中。我确实阅读了Bird文件,该文件说当我调用fly()时,鸟会飞。如果那个文件清楚地表明如果那只鸟不会飞,可能会抛出一个例外,我会允许的。如果它说调用fly()有时会使它游动,那我将改为使用其他类库。或去喝了一大杯。
DJClayworth 2011年
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.