继承和OOP已经死亡了吗?


这是一个关于如何被迫放弃继承和面向对象编程的故事,作者仍然怀念它很长一段时间。为什么五年后的今天,作者还是认为继承在大多数情况下是一个较差的选择。

Rust 和 Go 等新兴语言非常强烈地反对继承,许多工程师似乎也同意这一点。这就是继承的终结吗?

大约五年前,我开始编写 Go。在我职业生涯的早期,我拥有多元化的背景,包括 Java、JavaScript、Python、C#、PHP 等。其中每一个都包含某种程度的面向对象支持,或者通过内置构造,​​或者通过缺乏类型。另一方面,Go 则不然。没有继承,没有抽象类,这些都没有。

经过前四年的广泛 Go 编码,即使在我公司的开发人员经验领先的情况下,我仍然主张继承。我最初会说继承比组合更好,然后我经历了承认它们都是达到目的的有效手段的阶段,但我个人更喜欢继承。最后,在编写 Go 近五年之后,“组合优于继承”震撼了我。

改变想法从来都不是一件容易的事,在各种帖子中公开表达自己的观点后更是如此。我想用有关两种编程范式的一些具体事实来支持它:继承和组合。

组合与继承并不是一个容易讨论的话题。我认为组合的结束和继承的开始并没有明确的界限。两者之间有很多灰色地带。

为什么我们需要组合与继承中的任何一个?
主要是为了避免代码重复。
代码重复会产生容易出错的代码库。
组合和继承是消除它的两种不同工具。

那么两者到底有什么区别呢?
继承是关于层次结构的,而组合是关于组合构建块的。
就像拼图游戏一样。

让我自己用最不准确和最具体的定义:
继承是关于“is-a”关系,而组合是关于“has-a”关系。
继承会声称狗是一种动物,而组合可能会认为狗具有某些动物特征。

让我们举一个示例问题来帮助我们完成整个讨论。我们有三种类型的动物,它们有一些共同的行为,但在其他方面有所不同:

在此示例中,Dog、Cat、 和Human包含非常明显的代码重复:

interface Animal {
    doStuff()
}

class Dog implements Animal {
    doStuff() {
        eat()
        poop()
        println("woof")
        sit()
        sleep()
    }
}

class Cat implements Animal {
    doStuff() {
        eat()
        poop()
        println(
"meow")
        sit()
        sleep()
    }
}

class Human implements Animal {
    doStuff() {
        eat()
        poop()
        println(
"hello")
        sit()
        sleep()
    }
}

继承如何解决代码重复问题:

interface Animal {
    doStuff()
}

abstract class AbstractAnimal implements Animal {
    abstract speak()

    doStuff() {
        eat()
        poop()
        this.speak()
        sit()
        sleep()
    }
}

class Dog extends AbstractAnimal {
    speak() {
        println("woof")
    }
}

class Cat extends AbstractAnimal {
    speak() {
        println(
"meow")
    }
}

class Human extends AbstractAnimal {
    speak() {
        println(
"hello")
    }
}

显然,继承对于这个问题有很多不同的解决方案。然而,如果这看起来很熟悉,那是因为这是一种相当典型的继承方法。

组合将如何解决这个问题:

interface Animal {
    doStuff()
}

class Dog implements Animal {
    doStuff() {
        consumeEnergy()
        println("woof")
        rest()
    }
}

class Cat implements Animal {
    doStuff() {
        consumeEnergy()
        println(
"meow")
        rest()
    }
}

class Human implements Animal {
    doStuff() {
        consumeEnergy()
        println(
"hello")
        rest()
    }
}

function consumeEnergy() {
    eat()
    poop()
}

function rest() {
    sit()
    sleep()
}

由于组合不像继承那样结构化,因此它具有更多种方法。然而,这可能是最简单的组合解决方案。不同的组合解决方案会更复杂一些,但会消除更多的代码重复。它将利用函数组合:

interface Animal {
    doStuff()
}

class Dog implements Animal {
    doStuff() {
        genericDoStuff(function() {
            println("woof")
        })
    }
}

class Cat implements Animal {
    doStuff() {
        genericDoStuff(function() {
            println(
"meow")
        })
    }
}

class Human implements Animal {
    doStuff() {
        genericDoStuff(function() {
            println(
"hello")
        })
    }
}

function genericDoStuff(speak) {
    eat()
    poop()
    speak()
    sit()
    sleep()
}

由于继承和组合是两种完全不同的编程范例,因此它们创建了不同的代码结构。当你习惯看到其中一个时,另一个就显得很奇怪了。在它们之间转换意味着改变你的心态。尝试将两者混合可能会导致您不满意的结果。

banq:作者以上继承和组合代码是在没有考虑“动物”这个业务含义语义基础上的比较,如果抛开技术代码实现,只从领域语言本身概念上理解:

  1. 狗是一种动物吗?(is-a)
  2. 动物中有狗吗?(has-a)

这两种问法是不同的,主语不同,主语思维 决定了设计者的世界观
当前上下文是围绕什么主语展开的,围绕什么聚合核心展开的?
如果是围绕“狗”这个聚合核心,那么“狗”作为主语,选择第一种。
如果是围绕“动物”这个聚合核心,那么“动物”作为主语,选择第二种。

这种区别类似“分类 vs 分类中实体”,类似“集合 vs. 个体”,这又回到了罗素悖论理发师问题

所以,继承和组合的选择从来不是由编程语言技术决定的,而是由业务领域语言决定的,但是由于很多人没有业务经验,只有用编程语言解决各种平台架构的经验,一叶遮目,觉得继承是多余的。

组合是否优于继承,这种选择也是由业务上下文决定的。


作者最后的总结
随着 Java 和 C# 等经典的面向对象语言显示出已经过了其巅峰期的迹象,而 Rust 和 Go 等语言在强烈反对继承的同时显示出持续增长的兴趣(来源,编程社区似乎已经受够了遗产。

虽然我强烈同意面向​​对象编程,特别是继承多年来被滥用,但我主要觉得完全否认继承有点幼稚,只不过是一种简单的趋势。

我仍然相信在对真实的基于分类的问题进行建模时,继承可以是一个很好的解决方案。
在其他所有情况下,我们可能应该避免它,即使生成的代码可能看起来更丑陋。

事实上,我被教条主义的信念蒙蔽了双眼,认为任何代码重复都是令人讨厌的。
(banq:复用代码从来不是设计的主要目的,GOF设计模式 中没有更多是为了节省代码,相反是增加了很多代码,)

在许多情况下,继承以保持代码简洁的名义降低了代码的可读性和可维护性。虽然我喜欢业务建模和编写继承,但理解和维护它可能会变得越来越糟。

作为提供实际业务价值的专业程序员,我们必须问自己哪个更重要:编写美观优雅的代码,还是编写其他人能够轻松理解和维护的代码。

原文点击标题

Reddit网友讨论
1、我的专业工作主要是 C++。我在高质量代码中看到的 95% 的继承只是接口/实现。Rust 和 Go 只是通过仅保留这种继承风格来简化继承模型。

2、我已经在 SE 工作了 10 多年(Java、C++,现在是 Rust)。我只在两种情况下看到继承:

  1. 多个具体类继承的接口,以允许类型擦除。
  2. 多个具体类继承的公共抽象类以获得一些共享功能。

OOP 教程总是呈现这些令人费解的示例,例如:“圆扩展了形状,但椭圆基本上是一个特殊的圆,因此椭圆扩展了圆,而球体基本上是......”。我从未见过这种做法在实践中发挥作用。

3、继承适用于以下情况:a)有很多繁琐的细节,b)大多数时候您只需要默认行为(有其他方法可以实现此目的,但它们通常很笨拙或不直观)。

图形界面需要继承:您通常有一两个自定义行为,但在大多数情况下,用户应该能够像平常一样单击、突出显示和复制文本等。如果您每次都必须实现所有这些,或者甚至只是列出您想要默认行为的所有内容,那么这会很烦人且耗时。

在我看来,最终没有一个万能的答案。

4、即使 James Gosling 也承认“类”继承对于 Java 来说是一个错误。

5、继承将不需要耦合的事物(数据、接口)耦合在一起。
不要认为这些语言是“反面向对象的”。相反,将它们视为建立在将应该独立的事物解耦的心态之上,并通过选择更好的抽象来做得更好。

6、添加泛型的继承是非常有趣的事情(提示:事实并非如此)。
如果做得正确(理解模式),它们的使用确实会带来巨大的代码可重用性(核心泛型会稍微牺牲可读性)
如果做得不当(花费大量时间) - 会增加巨大的复杂性,使代码更难重构等等。
在我看来,任何超过 2 层的内部/嵌套都会大大增加复杂性。

7、组合优于继承是来自面向对象编程的“四人帮”设计模式