<audio id="audio" title="16 | 面向对象之多态：为什么“稀疏平常”的多态，是软件设计的大杀器？" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/yy/2f/yybe932749e9cfdfyy25c02a925ffa2f.mp3"></audio>

你好！我是郑晔。

前面两讲，我们讲了面向对象的两个特点：封装和继承，但真正让面向对象华丽蜕变的是它的第三个特点：多态。

有一次，我在一个C++的开发团队里做了一个小调查。问题很简单：你用过virtual吗？下面坐着几十个C++程序员，只有寥寥数人举起了手。

在C++里，virtual表示这个函数是在父类中声明的，然后在子类中改写（Override）过。或许你已经发现了，这不就是多态吗？没错，这就是多态。这个调查说明了一件事，很多程序员虽然在用支持面向对象的程序设计语言，但根本没有用过多态。

只使用封装和继承的编程方式，我们称之为基于对象（Object Based）编程，而只有把多态加进来，才能称之为面向对象（Object Oriented）编程。也就是说，多态是一个分水岭，将基于对象与面向对象区分开来，可以说，没写过多态的代码，就是没写过面向对象的代码。

对于面向对象而言，多态至关重要，正是因为多态的存在，软件设计才有了更大的弹性，能够更好地适应未来的变化。我们说，软件设计是一门关注长期变化的学问，只有当你开始理解了多态，你才真正踏入应对长期变化的大门。这一讲，我们就谈谈多态。

## 理解多态

多态（Polymorphism），顾名思义，一个接口，多种形态。同样是一个绘图（draw）的方法，如果以正方形调用，则绘制出一个正方形；如果以圆形调用，则画出的是圆形：

```
interface Shape {
  // 绘图接口
  void draw();
}

class Square implements Shape {
  void draw() {
    // 画一个正方形
  }
}

class Circle implements Shape {
  void draw() {
    // 画一个圆形
  }
}

```

上一讲，我们说过，继承有两种，实现继承和接口继承。其中，实现继承尽可能用组合的方式替代继承。而接口继承，主要是给多态用的。

这里面的重点在于，这个继承体系的使用者，主要考虑的是父类，而非子类。就像下面这段代码里，我们不必考虑具体的形状是什么，只要调用它的绘图方法即可。

```
Shape shape = new Squre();
shape.draw();

```

这种做法的好处就在于，一旦有了新的变化，比如，需要将正方形替换成圆形，除了变量初始化，其他的代码并不需要修改。不过，这是任何一本面向对象编程的教科书上都会讲的内容。

那么，问题来了。既然多态这么好，为什么很多程序员不能在自己的代码中很好地运用多态呢？因为多态需要构建出一个抽象。

构建抽象，需要找出不同事物的共同点，而这是最有挑战的部分。而遮住程序员们双眼的，往往就是他们眼里的不同之处。在他们眼中，鸡就是鸡，鸭就是鸭。

**寻找共同点这件事，地基还是在分离关注点上**。只有你能看出来，鸡和鸭都有羽毛，都养在家里，你才有机会识别出一个叫做“家禽”的概念。这里，我们又一次强调了分离关注点的重要性。

我们构建出来的抽象会以接口的方式体现出来，强调一点，这里的接口不一定是一个语法，而是一个类型的约束。所以，在这个关于多态的讨论中，接口、抽象类、父类等几个概念都是等价的，为了叙述方便，我这里统一采用接口的说法。

在构建抽象上，接口扮演着重要的角色。首先，**接口将变的部分和不变的部分隔离开来**。不变的部分就是接口的约定，而变的部分就是子类各自的实现。

在软件开发中，**对系统影响最大的就是变化**。有时候需求一来，你的代码就要跟着改，一个可能的原因就是各种代码混在了一起。比如，一个通信协议的调整需要你改业务逻辑，这明显就是不合理的。**对程序员来说，识别出变与不变，是一种很重要的能力。**

其次，**接口是一个边界**。无论是什么样的系统，清晰界定不同模块的职责是很关键的，而模块之间彼此通信最重要的就是通信协议。这种通信协议对应到代码层面上，就是接口。

很多程序员在接口中添加方法显得很随意，因为在他们心目中，并不存在实现者和使用者之间的角色差异。这也就造成了边界意识的欠缺，没有一个清晰的边界，其结果就是模块定义的随意，彼此之间互相影响也就在所难免。后面谈到Liskov替换法则的时候，我们还会再谈到这一点。

所以，**要想理解多态，首先要理解接口的价值，而理解接口，最关键的就是在于谨慎地选择接口中的方法**。

至此，你已经对多态和接口有了一个基本的认识。你就能很好地理解一个编程原则了：面向接口编程。面向接口编程的价值就根植于多态，也正是因为有了多态，一些设计原则，比如，开闭原则、接口隔离原则才得以成立，相应地，设计模式才有了立足之本。

这些原则你可能都听说过，但在编码的细节上，你可能会有一些忽略的细节，比如，下面这段代码是很多人经常写的：

```
ArrayList&lt;&gt; list = new ArrayList&lt;String&gt;();

```

这么简单的代码也有问题，是的，因为它没有面向接口编程，一个更好的写法应该是这样：

```
List&lt;&gt; list = new ArrayList&lt;String&gt;();

```

二者之间的差别就在于变量的类型，是面向一个接口，还是面向一个具体的实现类。

相对于封装和继承而言，多态对程序员的要求更高，需要你有长远的眼光，看到未来的变化，而理解好多态，也是程序员进阶的必经之路。

## 实现多态

还记得我们在编程范式那一讲留下的一个问题吗？面向对象编程，会限制使用函数指针，它是对程序控制权的间接转移施加了约束。理解这一点，就要理解多态是怎么实现的。

讲多范式编程时，我举了Linux文件系统的例子，它是用C实现了面向对象编程，而它的做法就是用了函数指针。再来回顾一下：

```
struct file_operations {
  loff_t (*llseek) (struct file *, loff_t, int);
  ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
  ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
  int (*open) (struct inode *, struct file *);
  int (*flush) (struct file *, fl_owner_t id);
  int (*release) (struct inode *, struct file *);
  ...
}

```

假设你写一个HelloFS，那你可以这样给它赋值：

```
const struct file_operations hellofs_file_operations = {
    .read = hellofs_read,
    .write = hellofs_write,
};

```

只要给这个结构体赋上不同的值，就可以实现不同的文件系统。但是，这种做法有一个非常不安全的地方。既然是一个结构体的字段，那我就有可能改写了它，像下面这样：

```
void silly_operation(struct file_operations* operations) {
  operations.read = sillyfs_read;
}

```

如此一来，本来应该在hellofs_read运行的代码，就跑到了sillyfs_read里，程序很容易就崩溃了。对于C这种非常灵活的语言来说，你根本禁止不了这种操作，只能靠人为的规定和代码检查。

到了面向对象程序设计语言这里，这种做法由一种编程结构变成了一种语法。给函数指针赋值的操作下沉到了运行时去实现。如果你了解运行时的实现，它就是一个查表的过程，如下图所示：

<img src="https://static001.geekbang.org/resource/image/81/c4/811965ea831b3df14c2165e3804b3ec4.jpg" alt="">

一个类在编译时，会给其中的函数在虚拟函数表中找到一个位置，把函数指针地址写进去，不同的子类对应不同的虚拟表。当我们用接口去调用对应的函数时，实际上完成的就是在对应的虚拟函数表的一个偏移，不管现在面对的是哪个子类，都可以找到相应的实现函数。

还记得我在开头提的那个问题吗？问C++程序员是否用过virtual。在C++这种比较注重运行时消耗的语言中，只有virtual的函数会出现在虚拟函数表里，而普通函数就是直接的函数调用，以此减少消耗。对于Java程序员而言，你可以通过给无需改写的方法添加final帮助运行时做优化。

当多态成了一种语法，函数指针的使用就得到了限制，犯错误的几率就大大降低了，程序行为的可预期性就大大提高了。

## 没有继承的多态

回到Alan Kay关于面向对象的思考中，他考虑过封装，考虑过多态。至于继承，却不是一个必然的选项。只要能够遵循相同的接口，就可以表现出来多态，所以，多态并不一定要依赖于继承。

比如，在动态语言中，有一个常见的说法，叫Duck Typing，就是说，如果走起来像鸭子，叫起来像鸭子，那它就是鸭子。两个类可以不在同一个继承体系之下，但是，只要有同样的方法接口，就是一种多态。

像下面这段代码，Duck和FakeDuck并不在一棵继承树上，但make_quack调用的时候，它们俩都可以传进去。

```
class Duck
  def quack
    # 鸭子叫
  end
end

class FakeDuck
  def quack
    # 模拟鸭子叫
  end
end

def make_quack(quackable)
  quackable.quack
end

make_quack(Duck.new)
make_quack(FakeDuck.new)

```

我们都知道，很多软件都有插件能力，而插件结构本身就是一种多态的表现。比如，著名的开源图形处理软件[GIMP](https://www.gimp.org/)，它自身是用C开发的，为它编写插件就需要按照它规定的结构去编写代码：

```
struct GimpPlugInInfo
{
  /* GIMP 应用初始启动时调用 */
  GimpInitProc  init_proc;

  /* GIMP 应用退出时调用 */
  GimpQuitProc  quit_proc;

  /* GIMP 查询插件能力时调用 */
  GimpQueryProc query_proc;

  /* 插件安装之后，开始运行时调用*/
  GimpRunProc   run_proc;
};

```

我们所需做的就是按照这个结构声明出PLUG_IN_INFO，这是隐藏的名字，将插件的能力注册给GIMP这个应用：

```
GimpPlugInInfo PLUG_IN_INFO = {
  init,
  quit,
  query,
  run
};

```

你看，这里用到的是C语言，一种连面向对象都不支持的语言，但它依然能够很好地表现出多态。

现在你应该理解了，多态依赖于继承，这只是某些程序设计语言自身的特点。你也看出来了，在面向对象本身的体系之中，封装和多态才是重中之重，而继承则处于一个很尴尬的位置。

我们花了三讲的篇幅讲了面向对象编程的特点，在这三讲中，我们不仅仅以Java为基础讲了传统的面向对象实现的一些方法，也讲到了不同语言在解决同样问题上的不同做法。正如我们在讲程序设计语言时所说，一定要跳出单一语言的局限，这样，才能对各种编程思想有更本质的认识。

在这里，你也看到了面向对象编程的三个特点也有不同的地位：

- 封装是面向对象的根基，软件就是靠各种封装好的对象逐步组合出来的；
- 继承给了继承体系内的所有对象一个约束，让它们有了统一的行为；
- 多态让整个体系能够更好地应对未来的变化。

后面我们还会讲到面向对象的设计原则，而这些原则的出发点就是面向对象的这些特点，所以，理解面向对象的这些特点，是我们后面把设计做好的基础。

## 总结时刻

今天，我们讲到了面向对象的第三个特点：多态，它是基于对象和面向对象的分水岭。多态，需要找出不同事物的共同点，建立起抽象，这也是很多程序员更好地运用多态的阻碍。而我们找出共同点，前提是要分离关注点。

理解多态，还要理解好接口。它是将变的部分和不变的部分隔离开来，在二者之间建立起一个边界。一个重要的编程原则就是**面向接口编程**，这是很多设计原则的基础。

我们今天还讨论了多态的实现，它通过将一种常见的编程结构升华为语法，降低程序员犯错的几率。最后，我们说了，多态不一定要依赖于继承实现。在面向对象编程中，更重要的是封装和多态。

结构化编程也好，面向对象编程也罢，这些都是大多数程序员都还是比较熟悉的，而下面我们要讲到的编程范式已经成为一股不可忽视的力量。然而，很多人却对它无知无觉，这就是函数式编程。下一讲，我们就来说说函数式编程。

如果今天的内容你只能记住一件事，那请记住：**建立起恰当的抽象，面向接口编程。**

<img src="https://static001.geekbang.org/resource/image/d8/e4/d85fa1220e55fe7291b480b335d0c5e4.jpg" alt="">

## 思考题

最后，我想请你去了解一下Go语言或Rust语言是如何支持多态的，欢迎在留言区分享你的想法。

感谢阅读，如果你觉得这一讲的内容对你有帮助的话，也欢迎把它分享给你的朋友。
