编程中的继承问题
编程中的继承问题
狗应该继承动物吗?
对于通过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