likes
comments
collection
share

编程中的继承问题

作者站长头像
站长
· 阅读数 33

编程中的继承问题

狗应该继承动物吗?

编程中的继承问题

对于通过C++或Java等语言学习面向对象编程(OOP)的人来说,可能会感到惊讶:继承并不是OOP的必要条件。

有些编程语言不支持继承,但仍然被认为比那些具有继承功能的语言更面向对象。

Erlang编程语言的创造者Joe Armstrong曾经说过,Erlang可能是唯一真正的面向对象语言。

然后,Smalltalk的发明者Alan Kay(Smalltalk是最早的纯OOP语言之一)对此表示同意。

他可能是对的。Erlang比我最初关于“对象”的想法以及如何使用它们更加接近。——Alan Kay

Erlang是一种基于函数、基于Actor模型的语言,与Java或C++等主流OOP语言有很大不同。它没有类和继承,但它支持消息传递和封装。

考虑到类本身对OOP来说并不是必要的,接受继承不是必需的这一点也不难。

有一类没有类的面向对象语言被称为基于原型的语言,Self就是这一领域的早期先驱。

根据我的经验,继承是面向对象语言中使用过度的语言特性之一。

在本文中,我将通过一些例子来展示继承存在的问题。

假设我们正在编写一款坦克战斗游戏。许多人会按照以下方式使用继承来建模:

Vehicle ⬅ ArmoredVehicle ⬅ Tank

坦克继承自装甲车辆,装甲车辆继承自车辆。

从分类学的角度来看,这种做法是有意义的,但我们的目标不是创建分类法。

更重要的是,这种做法是否从游戏引擎的角度来看是合理的,而不是从我们的角度来看。

游戏引擎需要在画布上绘制坦克,检查坦克与其他障碍物之间的碰撞,并为坦克制作动画。

坦克是一个具有可见性、动画性和物理性的对象。

如果我们在单继承语言(例如Java)中考虑这些特性,那么我们可以将这些特性表示为接口,该语言允许实现多个接口,但不允许多重继承。

多重继承有其自身的问题,这里不详细讨论,但如果我们使用接口,则可以避免这些问题。

interface Visible { 
  void draw(Canvas c);
}

interface Animated {
  void tick();
}

interface Physical { 
  Shape hitBox();
  Shape hurtBox();
}

class Tank implements Visible, Animated, Physical { .. }

class Screen {
  [..]
  void draw() {
    for (Visible each : obj)
      each.draw(canvas);
  }
}

[..]

// game loop
animations.tick();
screen.draw();
collisionDetector.check();

仅仅因为坦克在现实生活中是一种车辆,并不意味着我们必须在程序中有一个Vehicle类。

游戏可能并不关心“车辆”;相反,它关心的是哪些对象可以被渲染、动画化并与其他对象发生碰撞。

现在考虑以下情况:我们需要表示具有不同类型炮塔、引擎和无线电的坦克。

而且我们可能还需要在游戏过程中动态地更换其中的任何一个。

继承是静态的,而且许多语言只支持单继承。

class Tanks { .. }

class TankWith120mmTurrent extends Tank { .. }
class TankWithV12TwinTurboEngine extends Tank { .. }
class TankWithBoth extends ??? { .. }

因此,上述方法将无法奏效。

我们可以使用各种组件将坦克组合在一起,而不是用子类表示每种组合。

Tank tank = new Tank(
  new Turrent105mm(),
  new V12TwinTurboEngine(),
  new ShortRangedRadio()
);

tank.upgradeWeapon(new Turrent150mm());

通过这种方式,你可以混合和匹配不同的坦克与不同的装备,甚至可以动态地更改它们。

继承的另一个问题是我们的子类可能会与基类的实现细节耦合。

假设你有以下基类,其中addAll方法的实现方式是内部使用了add方法。这是一个实现细节。

class MyList<T> {

  void add(T item) { .. }

  void addAll(T... items) {
    for (T each : items)
      add(each);
  }
}

我们想创建一个子类,该子类维护关于我们添加了多少次元素的统计信息。

class MyListWithStatistics<T> extends MyList<T> {
  int timesAdded = 0;
  
  @Override
  void add(T item) {
    timesAdded++;
    super.add(item);
  }

  @Override
  void addAll(T... items) {
    timesAdded += items.length;  
    super.addAll(items);
  }  
}

我们只是简单地增加了一个计数器,并调用了超类的方法。

然而,在向列表中添加5个元素后,计数器将增加到8,因为我们在add和addAll中都增加了它,而后者使用了前者。

var list = new MyListWithStatistics<Integer>();
list.add(1);
list.add(2);
list.addAll(3, 4, 5);
System.out.println(list.timesAdded);

我们可以通过不重写addAll来修复这个问题;然而,我的观点是,子类仍然与其超类的实现细节耦合在一起。

每当有人更改超类的实现时,子类也可能会受到影响。

如果我们使用组合(比如装饰器),我们就可以避免这个问题。

组合也不是完美的;它可能会导致与身份和等价性相关的问题,但这是另一个故事了。

使用组合时,我们只依赖于对象的外部接口,因此耦合将会减弱。

编程中的继承问题

回到最初的问题,狗应该继承动物吗?

这取决于。从程序的角度来看,这有用吗?仅仅因为狗在现实生活中是一种动物,并不意味着我们在程序中也应该这样表示它。

我们是否有包含狗和猫等不同类型对象的异构集合?

如果答案是肯定的,那么我们可能需要引入一些抽象来捕捉不同类型动物之间的共性。我们可以通过接口或超类来实现这一点。

这并不一定意味着这个抽象应该是Animal。例如,在一个狗和猫作为玩家敌人的游戏中,使用表示敌人的抽象可能会更好。

这个决定应该来自程序应该如何使用这些对象,而不是来自生物类。

转载自:https://juejin.cn/post/7400192724933443618
评论
请登录