我在之前的一篇文章精通 Rust 宏 — 第一个宏中提到过,我讨厌继承。
第二天中午我同事跑过来问我,这东西现在语言都有,你有什么讨厌的?
我问他,你还记得面向对象的三要素吗?
他说:封装,继承...,然后...,多态!
我上大学学习 Java 的时候,很多相关书籍都会讲解这三要素,以现在的我看来,封装和多态是必要的。
但是继承...
继承有什么问题
在我刚参加工作的第三年,我参与到了中国移动和地图 App 的开发工作中,当时我负责改造该 App 的核心网络请求模块。
那个时候还没有流行使用 OkHttp ,更没有 Retrofit 这种优秀的网络开源库,整体还是使用原始的 HttpConnection 去完成网络请求,自己封装里面的实现逻辑。
我当时理解完需求之后,整理了网络库需要满足的功能,大概如下:
- 完成对数据的序列化和反序列化。
- 完成对数据的加密解密。
- 返回纯文本的数据结构。
- 返回 JSONObject 的数据结构。
- 返回基于 Java 对象的数据结构。
自然而然,我想到了这种方案,
- 首先,
BaseHttp作为基类,负责序列化和反序列化和加解密,因为从目前看来,一个请求一定会有这两个需求。 TextHttp继承BaseHttp,将其字节码转换成文本。JsonHttp继承TextHttp,将文本转换成JSONObject结构。- 以此类推。。。
一开始,这样做非常方便,例如:如果一个请求只需要获取文本内容,那么就直接继承 TextHttp。
也就是说,一个请求需要实现哪些能力,那么就继承那个基类就好了。
而使用这个网络库的人,只需要按照 API 文档中的 API 名称,用这个请求就行了,例如 Sell -> SellHttp,Login -> LoginHttp,用起来会非常方便!
但是后续改动,让我越来越 Hold 不住这种继承链了:有的 API 需要鉴权,有的不需要;部分 API 会有不同的 Header;有的 API 返回结果加密方式和之前的不一样。
慢慢的,我发现我的继承链越来越长,为了让我的 API 使用保持简洁,甚至出现了类似菱形继承的情况(这里不是说 Java 可以菱形继承):
实际上 SHACryptJsonHttp 包括其基类有大量相似的代码,只是鉴权方式问题,导致我不得不重新改写整个继承链(这里只是一个示例,真实情况比这个复杂的多)。
后面这种问题越来越多,虽然同事们用起来非常方便(对照文档直接 new 即可),但是对我的维护造成了巨大负担,同时也会犯一些非常低级的错误,例如搞错了鉴权方式!
为什么会这样呢?
一句话总结
“继承”这个特性,本来想干三件事,结果三件事互相打架,最后搞得谁都不开心。
好的,我来换个思路解释,以 Java 这种语言为例:
比如你写一个 Animal 类,再写一个 Dog 类继承它,这样 Dog 就自动有了 Animal 的所有方法和属性。
听起来很美好,对吧?(当时我就是这么认为的)
但问题来了——我们其实用“继承”做了三件完全不同的事,而这三件事根本不是一回事!
第一件事:分类 —— 这东西属于哪一类
比如:“正方形是一种矩形”,“狗是一种动物”。
这是从现实世界逻辑出发的,叫“本体论”(别被名字吓到,就是“分类”的意思)。
这合理啊,正方形确实是矩形的一种特殊情况。
第二件事: 能替换吗 —— 程序能不能当同一种东西用
比如:如果一个函数要求传入一个 Rectangle(矩形),那我能传一个 Square(正方形)进去吗?
如果可以,就叫“可替换”,这是程序正确性的问题。
但现实中不行!因为矩形可以改宽高,而正方形一改宽高就不是正方形了。
所以:虽然“正方形是矩形”在数学上成立,但在代码里不能随便替换!
这就是著名的 “正方形-矩形悖论” —— 看似合理,实则坑人。
第三件事: 省代码 —— 别重复写一样的代码
比如:Dog 和 Cat 都有 eat() 方法,于是让它们都继承 Animal,把 eat() 写在父类里。
这纯粹是为了偷懒/复用代码,跟“是不是动物”没关系。
好处:少写代码。
风险:万一以后某个“动物”不吃东西(比如机器人宠物),你就得硬改,或者搞一个新的继承链,破坏设计。
问题来了
Java 只给了你一个“继承”关键字,却让你同时干这三件事。
结果:
- 你以为你在“分类”(正方形是矩形),
- 但程序要求你“能替换”(正方形必须能当矩形用),
- 而你实际只是为了“省代码”(不想重复写宽高设置)。
三件事混在一起,必然出问题!
就像公司里面的团队,虽然大家都属于同一个组,但是如果目标不一致,这必然导致开发问题!
那怎么办
这是我后来,才学到的知识。
别用“继承”干所有事!分开处理:
| 你想干啥 | 正确做法 |
|---|---|
| 想分类? | 用领域模型画图就行,别非塞进代码继承树里。 |
| 想保证能替换? | 用接口(Interface) 或 协议(Protocol) ,明确约定行为。 |
| 想省代码? | 用组合(Composition) + 委托(Delegation) ,比如让 Dog 里面“包含”一个 Eater 对象,而不是继承 Animal。 |
还记得那句经典的建议吗:“优先使用组合,而不是继承。 ”
来,举个例子,假设你要做“交通工具”系统:
-
错误做法(用继承干三件事):
class Vehicle { void move() {} } class Car extends Vehicle { } // 是Vehicle,能替换,还复用了move() class Airplane extends Vehicle { } // 同上→ 这就是我早期开发 Http 框架时候的做法。问题来了,如果以后加个
Hovercraft(气垫船),它既能走陆地又能走水,继承哪个? -
正确做法(分开处理):
interface Drivable { void drive(); } interface Flyable { void fly(); } class Car implements Drivable { ... } class Airplane implements Flyable { ... } class Hovercraft implements Drivable, Floatable { ... } // 多实现,灵活!→ 分类归分类(你是车还是飞机),行为归行为(你会开还是会飞),代码复用靠组合
Java 实现委派会有些麻烦,如果是 Kotlin,就方便多了!
总结
“继承”就像一把瑞士军刀,本来想切菜、开瓶、剪线都行,结果发现:切菜时刀片会弹出来割手,开瓶时螺丝刀又碍事。
所以聪明人怎么做?
切菜用菜刀,开瓶用开瓶器,剪线用剪刀。各干各的,互不干扰。
编程也一样:
别指望“继承”解决所有问题,该用接口用接口,该用组合用组合。
这样,代码才清爽,bug 才少,而你,我的朋友,才睡得着觉!