<源>面向对象 的困境

340 阅读6分钟

面向对象

在深入 Swift 协议的概念之前,我想先重新让大家回顾一下面向对象。相信我们不论在教科书或者是博客等各种地方对这个名词都十分熟悉了。那么有一个很有意思,但是其实并不是每个程序员都想过的问题,面向对象的核心思想究竟是什么?

我们先来看一段面向对象的代码:

class Animal {
    var leg: Int { return 2 }
    func eat() {
        print("eat food.")
    }
    func run() {
        print("run with \(leg) legs")
    }
}

class Tiger: Animal {
    override var leg: Int { return 4 }
    override func eat() {
        print("eat meat.")
    }
}

let tiger = Tiger()
tiger.eat() // "eat meat"
tiger.run() // "run with 4 legs"

父类 Animal 定义了动物的 leg (这里应该使用虚类,但是 Swift 中没有这个概念,所以先请无视这里的 return 2),以及动物的 eatrun 方法,并为它们提供了实现。子类的 Tiger 根据自身情况重写了 leg (4 条腿)和 eat (吃肉),而对于 run,父类的实现已经满足需求,因此不必重写。

我们看到 TigerAnimal 共享了一部分代码,这部分代码被封装到了父类中,而除了 Tiger 的其他的子类也能够使用 Animal 的这些代码。这其实就是 OOP 的核心思想 - 使用封装和继承,将一系列相关的内容放到一起。我们的前辈们为了能够对真实世界的对象进行建模,发展出了面向对象编程的概念,但是这套理念有一些缺陷。虽然我们努力用这套抽象和继承的方法进行建模,但是实际的事物往往是一系列特质的组合,而不单单是以一脉相承并逐渐扩展的方式构建的。所以最近大家越来越发现面向对象很多时候其实不能很好地对事物进行抽象,我们可能需要寻找另一种更好的方式。

面向对象编程的困境

横切关注点

我们再来看一个例子。这次让我们远离动物世界,回到 Cocoa,假设我们有一个 ViewController,它继承自 UIViewController,我们向其中添加一个 myMethod

class ViewCotroller: UIViewController
{
    // 继承
    // view, isFirstResponder()...

    // 新加
    func myMethod() {

    }
}

如果这时候我们又有一个继承自 UITableViewControllerAnotherViewController,我们也想向其中添加同样的 myMethod

class AnotherViewController: UITableViewController
{
    // 继承
    // tableView, isFirstResponder()...

    // 新加
    func myMethod() {

    }
}

这时,我们迎来了 OOP 的第一个大困境,那就是我们很难在不同继承关系的类里共用代码。这里的问题用“行话”来说叫做“横切关注点” (Cross-Cutting Concerns)。我们的关注点 myMethod 位于两条继承链 (UIViewController -> ViewCotrollerUIViewController -> UITableViewController -> AnotherViewController) 的横切面上。面向对象是一种不错的抽象方式,但是肯定不是最好的方式。它无法描述两个不同事物具有某个相同特性这一点。在这里,特性的组合要比继承更贴切事物的本质。

想要解决这个问题,我们有几个方案:

  • Copy & Paste

    这是一个比较糟糕的解决方案,但是演讲现场还是有不少朋友选择了这个方案,特别是在工期很紧,无暇优化的情况下。这诚然可以理解,但是这也是坏代码的开头。我们应该尽量避免这种做法。

  • 引入 BaseViewController

    在一个继承自 UIViewControllerBaseViewController 上添加需要共享的代码,或者干脆在 UIViewController 上添加 extension。看起来这是一个稍微靠谱的做法,但是如果不断这么做,会让所谓的 Base 很快变成垃圾堆。职责不明确,任何东西都能扔进 Base,你完全不知道哪些类走了 Base,而这个“超级类”对代码的影响也会不可预估。

  • 依赖注入

    通过外界传入一个带有 myMethod 的对象,用新的类型来提供这个功能。这是一个稍好的方式,但是引入额外的依赖关系,可能也是我们不太愿意看到的。

  • 多继承

    当然,Swift 是不支持多继承的。不过如果有多继承的话,我们确实可以从多个父类进行继承,并将 myMethod 添加到合适的地方。有一些语言选择了支持多继承 (比如 C++),但是它会带来 OOP 中另一个著名的问题:菱形缺陷。

菱形缺陷

上面的例子中,如果我们有多继承,那么 ViewControllerAnotherViewController 的关系可能会是这样的:

在上面这种拓扑结构中,我们只需要在 ViewController 中实现 myMethod,在 AnotherViewController 中也就可以继承并使用它了。看起来很完美,我们避免了重复。但是多继承有一个无法回避的问题,就是两个父类都实现了同样的方法时,子类该怎么办?我们很难确定应该继承哪一个父类的方法。因为多继承的拓扑结构是一个菱形,所以这个问题又被叫做菱形缺陷 (Diamond Problem)。像是 C++ 这样的语言选择粗暴地将菱形缺陷的问题交给程序员处理,这无疑非常复杂,并且增加了人为错误的可能性。而绝大多数现代语言对多继承这个特性选择避而远之。

动态派发安全性

Objective-C 恰如其名,是一门典型的 OOP 语言,同时它继承了 Small Talk 的消息发送机制。这套机制十分灵活,是 OC 的基础思想,但是有时候相对危险。考虑下面的代码:

ViewController *v1 = ...
[v1 myMethod];

AnotherViewController *v2 = ...
[v2 myMethod];

NSArray *array = @[v1, v2];
for (id obj in array) {
    [obj myMethod];
}

我们如果在 ViewControllerAnotherViewController 中都实现了 myMethod 的话,这段代码是没有问题的。myMethod 将会被动态发送给 array 中的 v1v2。但是,要是我们有一个没有实现 myMethod 的类型,会如何呢?

NSObject *v3 = [NSObject new]
// v3 没有实现 `myMethod`

NSArray *array = @[v1, v2, v3];
for (id obj in array) {
    [obj myMethod];
}

// Runtime error:
// unrecognized selector sent to instance blabla

编译依然可以通过,但是显然,程序将在运行时崩溃。Objective-C 是不安全的,编译器默认你知道某个方法确实有实现,这是消息发送的灵活性所必须付出的代价。而在 app 开发看来,用可能的崩溃来换取灵活性,显然这个代价太大了。虽然这不是 OOP 范式的问题,但它确实在 Objective-C 时代给我们带来了切肤之痛。

三大困境

我们可以总结一下 OOP 面临的这几个问题。

  • 动态派发安全性
  • 横切关注点
  • 菱形缺陷

首先,在 OC 中动态派发让我们承担了在运行时才发现错误的风险,这很有可能是发生在上线产品中的错误。其次,横切关注点让我们难以对对象进行完美的建模,代码的重用也会更加糟糕。