为什么组合好于继承?

本文使用亲身案例形象说明了软件设计领域为什么组合Composition要好于继承(包括接口继承),只有需求分析域的问题分解,才有设计编程的组合应用。

来自游戏公司GameSys的Yan Cui发表了博文:
This is why you need Composition over Inheritance

他试图对一个刚刚接触自己还是不太熟悉的系统进行一些旧代码修改,很自然地,第一步首先是了解这些旧代码是做什么的,开始从需要改变的地方查看类代码了。



有过类似经验的人可能不会奇怪,他一无所获,无法发现这些类是干什么的,这个类只包含少数override方法,但是无法知道它们是如何搭配在一起工作的。

于是他进一步深入抽象类的多个层次,直到到达这个类层次的基类,从这个基类开始在顺着继承树形结构来回好几次,终于得到了一个有关业务逻辑分散在各层次之间的模糊概念。


更有甚者,因为与原来树形控制流有点区别,因此他们有得重新做一个新的树形继承结构的控制流,这些使人更加难以理解了。

这些都不是程序员想要,他们需要容易方便地且有信心地思考reason自己的代码。或者说,让自己的代码像文章一样有条理性。

是不是大部分人都有这样类似痛苦经历呢?那么如何应用组合而不是继承呢Com­po­si­tion over Inher­i­tance?

Wiki中Com­po­si­tion over Inher­i­tance定义是领域建模:
使用组合而不是继承是一种设计原则,能够带来设计的更高灵活性,带给业务领域的类代码更长时间的稳定性。

对于Wiki这段结论,作者他说自己不是一个“下结论的粉丝”(a fan of con­clu­sions banq注:应试教育容易导致人们喜欢下结论以及看文章时只有看到下结论才认为自己看明白,因为考试题总是有唯一标准肯定终结的答案的,长期做考题容易被误导成这种思维模式)。

既然我们不直接接受这种结论,那么我们就需要质疑反思,这段话到底是什么意思呢?为什么有这段话呢?能给出高灵活性和业务领域更稳定的证明或经验证据吗?

从作者的角度看,使用组合而不是继承是鼓励更好的问题域的解耦,起初如果你不将一个大问题分解成一个个更小的容易解决的小部分问题,后来你就无法使用组合来组装它们。

Scott Wlaschin的rail­way ori­ented pro­gram­ming意味使用了一个很好的案例来说明在实践中如何使用组合。

EventSourcing/CQRS的倡导者Greg Young还指出,问题域的分解是我们当前软件工业的最大问题。

问题域的分解不只是局限于代码组织,微服务也是一个这方面的典型案例,从巨石monolithic铁板一块哦系统迁移到微服务是另外一种问题域的解耦。

因此,我们需要使用利刀分解前面描述的类层次树形结构,使用更小的、可组合的替换它们,包括使用具有这样特点的语言如F

参考:
Go语言是彻底的面向组合的并发语言
分解和组合的抽象方法
范畴category:组合的本质
[该贴被banq于2015-03-12 19:14修改过]
[该贴被banq于2015-03-13 13:06修改过]

我明白了。可能是因为平面隐藏了信息,如果能是立体的,是能够根据主体的观察需要变成可穿透的(让主体的眼睛能够看到立体图形内部的图形的意思是主体“分神”进入问题空间)的话就好了。

这里见到了这样两张图

“拆分”和“复用”是系统设计中2个永恒的主题,在这两者之间如何达到一种平衡是艺术。从技术角度讲,继承和组合没有好与坏之说。不知道大家是否注意到,“组合比继承好”是一种实战观点,都是经验之谈。其实这很好理解。因为业务越来越复杂,变化越来越快,原来的业务设计思路已经不满足现在的业务需要,继承会限制系统的重构。从业务角度看,如果有人能够综观某个事物(行业)几十年的发展,也就是常说的吃透了业务,在这种假定之下来用继承来设计业务,那一定会成为经典。但现实的场景中,这样的人太少,对于快速发展的中国尤其如此,所以我们才说“组合比继承好”。从本质上说,这是我们总是暗示自己“我们总是目光短浅,组合的风险比继承少”。
[该贴被flyzb于2015-03-14 17:36修改过]

2015-03-14 17:34 "@flyzb"的内容
从本质上说,这是我们总是暗示自己“我们总是目光短浅,组合的风险比继承少” ...

写得很好,理解透彻。

我想追加的是:函数式编程FP也是基于这样的暗示,这里面再延伸一下,由于组合的碎块很多,我们就不再进入每个碎块内部研究了,假设每个碎块是一个黑盒子,我们只要关心这些碎块之间的组合关系,而这背后有一门专门的严谨的理论指导:范畴论,或者称为群论。

现在就比较好玩了,有两个对比,一个是继承系统需要依赖人的经验;一个是组合,可以凭借范畴论这个拐杖在黑暗中摸索。哪个更符合科学一些,当然后者,但是哪个更可具操作性,当然是前者。

所以,当我们还在纠结Java的继承为什么是单继承,而不是多继承时,其实我们已经除了继承这条充满争议的方向,有了另外一个更可靠的方向,以范畴论指导组合。

以上只是个人想法,仅供参考。
[该贴被banq于2015-03-14 18:27修改过]

两种比较常用的思考方法(或解决方法),用哪一种,还是取决于问题域。

一般说来,“继承”适用于“算法”(业务过程)的分解;“组合”适用于“数据结构”(业务对象)的分解。

比如一个服务类,要验证输入、处理业务、输出结果,那么定义一个父类,把这个过程的整体描述出来,然后,子类去处理细节。(一般/特殊的思维方式)

再比如一个订单涉及用户、商户、商品等,那么显而易见,也自然而然,订单就该划分出这么多对象。(整体/局部的思维方式)

“继承”在代码中的使用相对要求高一些,因为抽象的角度如果不准确或有点晦涩,那么维护代码的人(可能还不了解业务过程)就不能不抓狂了。

看“继承”关系,了解业务过程的划分;看“组合”关系,了解业务对象的划分。如果要从“继承”关系,了解业务对象,从“组合”关系,了解业务过程,那当然比较麻烦了,因为方向错了,无异于缘木求鱼。

此外,有个建议(苦口婆心,大多人未必听的进去),如果目前只有一种实现的可能,就不必使用提炼接口、抽象类之类的,这往往是画蛇添足,因为感性认识不足的前提下,你是很难清晰、准确抽象出业务过程的。(吐槽一下:Java中的这样的代码太多太多太多了)

感觉可能存在class的继承和object的继承这两种继承。object的继承是空间的正继承和行为的倒继承。



class的组合可能对应的是object的继承。class A由class B和class C两个class组合而成。
class A{
B b {get;private set;}
C c {get;private set;}
}
class B{ }
class C{ }
class的组合对应的是object的空间和行为的继承。
运行时为object1分配空间的时候会计算object1内部所有子对象所占的空间,为什么要计算子对象的空间呢?正是因为子对象要从父对象继承空间所以不得不计算。当然如果空间无限大或者可以动态分配的话是不需要提前计算子对象的空间的。对象树对应的是空间集合,空间幂集中没有图。我的电脑桌处在我的房间里,它不可能同时也处在别的空间中,它只可能有一个父空间。空间中没有图,只有当空间需要变换起来的时候才会有图,变换=运动=时间。把我的电脑桌搬到阳台去,在t1时刻它处在我的房间中,t2时刻它跑到客厅阳台去了。这个时候它出现在两个空间中去了,它是在不同时刻出现在不同的空间中去的,这个图是由于运动(运动=变换=时间)导致的。
[该贴被anycmd于2015-03-15 21:41修改过]

[img index=1]
这个图不好画,大致是这个意思。下面那条线是内存上的0 1线。
纯粹的只有基本类型的字段成员的class是空间模型。使用这个模型构建出来的空间单元会具有一样的形状,这些空间单元会在一致的相对位置具有宏观上一致的凸凹槽,不同出现在微观上。比如Person class上的bool类型的Gender凸凹槽。jinping在Gender那是凸起的,而liyuan是凹陷的。
而Person class的string类型的Name字段那,我们认为无论是jinping还是liyuan的Name,他们的Name的宏观长度都是一样的(在数据库中确实近似是这样处理的,只是在更活跃的应用程序内存空间中会做优化,数据库的varchar也是会做这样的优化),jinping和liyuan的Name在那个位置的差异也是微观上的,比如用Name可以是128个0或1空间。是凸还是凹,对应于是1还是0。

整个系统的空间结构就是这样拼装起来的,而行为是一个和空间正交的维度,行为在这个空间中理论上是可以随意穿行的,行为无影无踪,我们只能通过观察空间的变化来发现行为的踪迹。但那样不好管理行为维度,所以我们得想办法把行为管控起来。怎么管控行为维度呢,跟管控空间维度时的办法是完全一样的,管控空间维度时我们从一条笔直的0 1线开始,我们建立了基本类型,我们映射到集合,我们又变形为树。我们处理行为维度时也是完全一样的,只是最终行为维度被处理成了栈魂,但是如果把栈魂形化的话,栈也是树形的,也是一层套一层的集合,也是一条线,但是这条线和空间线正交,这条线上的点可以随意的离散到空间线上。时空是一体的,行为线需要借助空间来形化。人的左脑擅长处理行为线,但右脑擅长处理空间线。

2015-03-15 16:05 "@jdon007"的内容
两种比较常用的思考方法(或解决方法),用哪一种,还是取决于问题域 ...

讲得很好。
我这几天思考发现组合和继承实际可能代表两种完全不同的思维方式。这两种思维体现在Unix和Windows的不同上。

Unix是典型的组合思维,包括Go语言,我们使用Unix命令行,比如“ps -ax | grep java”,这种管道式过滤能力非常强大和灵活,缺点是初学者不容易掌握,不形象。

而Windows是典型的继承思维,大窗口里有小窗口,不断打开小窗口,就进行树结构的深入层次,这种方式非常形象,适合初学者,但是问题也是显然,不够灵活强大,以至于我们经常借助于DOS的bat来完成各种特定的要求,以至于需要编程,使用编程语言组合成我们希望得到的功能。

Windows这种窗口方式非常对象化,也是微软长期对操作系统这个业务掌握后的抽象,它是一种形象化的树形结构,带领初学者进入深处。

Unix的命令组合是一种动态的树形结构,类似函数编程,通过通过函数碎块组合成使用者想达到的强大功能。

很多人思维如果局限在Windows这种继承思维中,会觉得Unix好像不人性化,对程序员不够尊重,这其实是身在庐山中不识庐山真面貌。

什么是人性化?让程序员想做他想做的任何事,给予程序员最大的自由如同Unix风格才是对程序员最大尊重,将自由选择权交给了程序员自己,而Windows是对初学者最大尊重,限制一定自由,但是能够循循善诱。所以,是否对程序员尊重是分层次的,不同层次程序员的需求不同,就像人的层次不同,需求不同一样,没有吃饱饭认为吃饱饭是最大幸福,而吃饱饭的认为精神自由才是人生最高境界。

总结,两种比较常用的思考方法(或解决方法),用哪一种,还是取决于问题域 ,我们千万不能厚此薄彼。对于复杂问题域,使用组合无疑强大,对于熟悉的简单的问题域,使用继承比较形象,一目了然。


“我们总是目光短浅,组合的风险比继承少。”这样的命题出来了,是否歪曲了什么?组合是跟继承是一个层次的?可作如下考虑:组合是否能表达出继承,或反过来继承能否表达出组合。组合是在定义关系对象及其关系(即事实)后才进行的,而继承只是单纯地指定一个关系,而其后可以使用函数、方法或特殊语法进行组合,也就是说。

关系a:A 继承 B ,关系b:B 继承 C

关系c:a 传递组合 b = A继承C

实际上就是 a:f b:g c:f.g

倘若承认“继承”也是一种运算,那么a:f=Af'B=(f') A B。这样的状况下,即继承也只是一种组合。(猜想很多人也是这样的思维)。

所以得出“组合的风险比继承少”这样的想法是有问题的。(java在语法上,继承只是定义一种简单关系,而且组合能力较差,而在底层上,是一种组合过程,想要控制组合过程,只能通过反射,而不是自然的语法支持,甚至有些反射不出来。所以我建议使用函数重新定义面向对象,让面向对象成为DSL)。