EnriqueMark
设计模式与架构

组合和继承

2026-03 简体中文

组合优于继承的关键因素是去耦合,或者说,就像积木一样的模块,而状态则通过外部因素给入——大部分情况下采用静态方法就足够了,但少数情况下可能需要的是一个组合类,下面许多方法共享同样的状态——但是,我为什么不采用完全的DI,让状态从外部注入呢?也就是说,更精确的来讲:只有所有组合方法都需要共享同样的单一状态时,我才需要用类——哪怕这个状态本身也还是从类外面注入进来的,区别就是一个是DI方法,一个是DI类。对于一般的情况来说,DI方法就够了。

而组合本身也一直强调,什么时候用它?组合 里面提到「有一个」和「是一个」的区别,类本身是字面意义上的「一类」,换言之,他们是一个物种——共享结构上固定的(就像上面所说的)、提前定义好的父类状态(属性)或方法。子类确实可以写,但那也是父类允许的情况,子类本身的范围和边界被父类限制。这里和接口不同的点是,接口虽然也是对结构的约束,但它更专注于结构本身的抽象,而父类是更底一层的抽象,它除了结构之外,还有具体的行为——有具体内容的方法。而这些具体正是保证里氏替换原则(LSP)的基础——任何子类都可以替代父类,因为他们本身就完全包含(继承)了父类的一切内容。多余的那部分只是没被使用而已。

但是,标准的LSP不是硬约束,假设底层以来了父类的「可覆写」方法,而子类完全覆写了,那就会炸。这也是为啥滥用继承反而容易耦合——不符合LSP原则的继承就不该出现——或者说,父类应该约定好,其可覆写方法是「扩展性」的——而且这些扩展部分,本身就可以作为一个约定而存在了,比如叫什么名字、输入输出是什么,只是逻辑内部可以让你随便写。从这里看,就会发下辖,哪怕是扩展,设计良好的父类也能保证LSP能运作,因为结构(输入-输出)是类型固定的,正常来说哪怕是扩展,用的时候都不会出问题。从一开始就应该保证子类再继承的时候不会破坏核心功能,这些就是保证LSP的核心骨架逻辑。但是,即便如此,这也是结构约束,万一子类就是胡搞,导致方法直接出错,返回null(返回类型系统默认不管这个),或者搞了预期的副作用,那还是会出问题。毕竟你不可能穷尽一切,只要开了扩展点,那什么牛鬼蛇神都可能出来。最大化保证LSP是可以做到的,但这也是继承滥用做不到的地方。

如果我需要专注于结构和这些结构的具体实现,且希望后续的依赖方「继承」(字面意义上)这些内容,那这种情况下用基类和继承的模式是标准做法,没什么冗余的。毕竟我不能让人类当一个佛兰肯斯坦,用一大堆不相干的「积木」去拼凑,就算这样最终能跑起来,它也会像科学怪人那样是让人困惑的——虽然就像上述说的一样,能动,但我们的印象就是人类有其固有的状态,这些状态必须是继承的而不是拼凑的,人类必须得是「是一个」,所以根本不是标准做法了,而是成了「反模式」。

但有些情况下我们就需要组合:积木、去耦合化的模块。它面向的是「有一个」。我只是想要一个复用的功能,就跟乐高一样,可以在任何地方拼,除了要求必须有接口外,也不依赖什么必然的固定状态,那组合就再合适不过了。你拼成房子还是直升机我不感兴趣,只要你能拼,那你就能用。

而且这么来说,组合本身也是可以和继承结合使用的——比如一个大型的工具模块——就算是乐高也分各种类型,你是砖块我是小人,小人之见可以随便拼,但它(一般而言)也不会和砖块结合。那这时,功能上的一系列的「有一个」,就要遵循「是一个」的模式来构建自身了。哪怕他们整体仍然是「有一个」的模块,但自成一脉。