1 函数上移(Pull Up Method)
避免重复代码是很重要的。重复的两个函数现在也许能够正常工作,但假以时日却只会成为滋生bug的温床。无论何时,只要系统内出现重复,你就会面临"修改其中一个却未能修改另一个"的风险。
通常,找出重复也有一定的难度。 如果某个函数在各个子类中的函数体都相同(它们很可能是通过复制粘贴得到的),这就是最显而易见的函数上移适用场合。当然,情况并不总是如此明显。我也可以只管放心地重构,再看看测试程序会不会发牢骚,但这就需要对我的测试有充分的信心。我发现,观察这些可能重复的函数之间的差异往往大有收获:它们经常会向我展示那些我忘记测试的行为。
函数上移常常紧随其他重构而被使用。也许我能找出若干个身处不同子类内的函数,而它们又可以通过某种形式的参数调整成为相同的函数。这时候,最简单的办法就是先分别对这些函数应用函数参数化,然后应用函数上移。
函数上移过程中最麻烦的一点就是,被提升的函数可能会引用只出现于子类而不出现于超类的特性。此时,我就得用字段上移和函数上移先将这些特性(类或者函数)提升到超类。如果两个函数工作流程大体相似,但实现细节略有差异,那么我会考虑先借助塑造模板函数构造出相同的函数,然后再提升它们。
2 字段上移(Pull Up Field)
如果各子类是分别开发的,或者是在重构过程中组合起来的,你常会发现它们拥有重复特性,特别是字段更容易重复。这样的字段有时拥有近似的名字,但也并非绝对如此。
判断若干字段是否重复,唯一的办法就是观察函数如何使用它们。如果它们被使用的方式很相似,我就可以将它们提升到超类中去。
本项重构可从两方面减少重复:首先它去除了重复的数据声明;其次它使我可以将使用该字段的行为从子类移至超类,从而去除重复的行为。许多动态语言不需要在类定义中定义字段,相反,字段是在第一次被赋值的同时元成声明。在这种情况下,字段上移基本上是应用构造函数本体上移后的必然结果。
3 构造函数本体上移(Pull Up Constructor Body)
构造函数是很奇妙的东西。它们不是普通函数,使用它们比使用普通函数受到更多的限制。如果我看见各个子类中的函数有共同行为,我的第一个念头就是使用提炼函数将它们提炼到一个独立函数中,后使用函数上移将这个函数提升至超类。但构造函数的出现打乱了我的算盘,因为它们附加了特殊的规则,对一些做法与函数的调用次序有所限制。要对付它们,我需要略微不同的做法。如果重构过程过于复杂,我会考虑转而使用以工厂函数取代构造函数。
4 函数下移(Push Down Method)
如果超类中的某个函数只与一个(或少数几个)子类有关,那么最好将其从超类中挪走,放到真正关心它的子类中去。这项重构手法只有在超类明确知道哪些子类需要这个函数时适用。如果超类不知晓这个信息,那我就得用以多态取代条件表达式,只留些共用的行为在超类。
5 字段下移(Push Down Field)
如果某个字段只被一个子类(或者一小部分子类)用到,就将其搬移到需要该字段的子类中。
6 以子类取代类型码(Replace Type Code with Subclasses)
软件系统经常需要表现"相似但又不同的东西",比如员工可以按职位分类(工程师、经理、销售),订单可以按优先级分类(加急、常规)。
表现分类关系的第一种工具是类型码字段---根据具体的编程语言,可能实现为枚举、符号、字符串或者数字。类型码的取值经常来自给系统提供数据的外部服务。大多数时候,有这样的类型码就够了。但也有些时候,我可以再多往前一步,引入子类。
继承有两个诱人之处。首先,你可以用多态来处理条件逻辑。如果有几个函数都在根据类型码的取值采取不同的行为,多态就显得特别有用。引入子类之后,我可以用以多态取代条件表达式来处理这些函数。另外,有些字段或函数只对特定的类型码取值才有意义,例如"销售目标"只对"销售"这类员工才有意义。此时我可以创建子类,然后用字段下移把这样的子段放到合适的子类中去。当然,我也可以加入验证逻辑,确保只有当类型码取值正自时才使用该字段,不过子类的形式能更明确地表达数据与类型之间的关系。
在使用以子类取代类型码时,我需要考虑一个问题:应该直接处理携带类型码的这个类,还是应该处理类型码本身呢?以前面的例子来说,我是应该让"工程师"成为"员工"的子类,还是应该在"员工"类包含"员工类别"属性、从后者继承出"工程师"和"经理"等子类型呢?直接的子类继承(前一种方案)比较简单,但职位类别就不能用在其他场合了。另外,如果员工的类别是可变的,那么也不能使用直接继承的方案。如果想在"员工类别"之下创建子类,可以运用以对象取代类型把类型码包装成"员工类别"类,然后对其使用以子类取代类型码。
7 移除子类(Remove Subclass)
子类很有用,它们为数据结构的多样和行为的多态提供支持,它们是针对差异编程的好工具。但随着软件的演化,子类所支持的变化可能会被搬移到别处,甚至完全除,这时子类就失去了价值。有时添加子类是为了应对未来的功能,结果构想中的功能压根没被构造出来,或者用了另一种方式构造,使该子类不再被需要了。
子类存在着就有成本,阅读者要花心思去理解它的用意,所以如果子类的用处太少,就不值得存在了。此时,最好的选择就是移除子类,将其替换为超类中的一个字段。
8 提炼超类(Extract Superclass)
如果我看见两个类在做相似的事,可以利用基本的继承机制把它们的相似之处提炼到超类。我可以用字段上移把相同的数据搬到超类,用函数上移搬移相同的行为。
很多技术作家在谈到面向对象时,认为继承必须预先仔细计划,应该根据"真实世界"的分类结构建立对象模型。直实世界的分类结构可以作为设计继承关系的提示,但还有很多时候,合理的继承关系是在程序演化的过程中才浮现出来的:我发现了一些共同元素,希望把它们抽取到一处,于是就有了继承关系。另一种选择就是提炼类。
这两种方案之间的选择,其实就是继承和委托之间的选择,总之目的都是把重复的行为收拢一处。提炼超类通常是比较简单的做法,所以我会首选这个方案。即便选错了,也总有以委托取代超类这瓶后悔药可吃。
9 折叠继承体系(Collapse Hierarchy)
在重构类继承体系时,我经常把函数和字段上下移动。随着继承体系的演化,我有时会发现一个类与其超类已经没多大差别,不值得再作为独立的类存在。此时我就会把超类和子类合并起来。
10 以委托取代子类(Replace Subclass with Delegate)
如果一个对象的行为有明显的类别之分,继承是很自然的表达方式。我可以把共用的数据和行为放在超类中,每个子类根据需要覆写部分特性。在面向对象语言中,继承很容易实现,因此也是程序员熟悉的机制。
但继承也有其短板。最明显的是,继承这张牌只能打一次。导致行为不同的原因可能有多种,但继承只能用于处理一个方向上的变化。比如说,我可能希望"人"的行为根据"年龄段"不同,并且根据"收入水平"不同。使用继承的话,子类可以是"年轻人"和"老人",也可以是"富人"和"穷人",但不能同时采用两种继承方式。
更大的问题在于,继承给类之间引入了非常紧密的关系。在超类上做任何修改,都很可能破坏子类,所以我必须非常小心,并且充分理解子类如何从超类派生。如果两个类的逻辑分处不同的模块、由不同的团队负责,问题就会更麻烦。
这两个问题用委托都能解决。对于不同的变化原因,我可以委托给不同的类。委托是对象之间常规的关系。与继承关系相比,使用委托关系时接口更清晰、耦合更少。因此,继承关系遇到问题时运用以委托取代子类是常见的情况。
有一条流行的原则:"对象组合优于类继承"("组合"跟"委托"是同一回事)。很多人把这句话解读为"继承有害",并因此声称绝不应该使用继承。我经常使用继承,部分是因为我知道,如果稍后需要改变,我总可以使用以委托取代子类。继承是一种很有价值的机制,大部分时候能达到效果,不会带来问题。所以我会从继承开始,如果开始出现问题,再转而使用委托。
这种用法与前面说的原则实际上是一致的------这条出自名著《设计模式》的原则解释了如何让继承和组合协同工作。这条原则之所以强调"组合优于继承",其实是对彼时继承常被滥用的回应。熟悉《设计模式》一书的读者可以这样来理解本重构手法,就是用状态(State)模式或者策略(Strategy) 模式取代子类。这两个模式在结构上是相同的,都是由宿主对象把责任委托给另一个继承体系。以委托取代子类并非总会需要建立一个继承体系来接受委托(下面第一个例子就没有),不过建立一个状态或策略的继承体系经常都是有用的。
11 以委托取代超类(Replace Superclass with Delegate)
在面向对象程序中,通过继承来复用现有功能,是一种既强大又便捷的手段。我要继承一个已有的类,覆写一些功能,再添加一些功能,就能达成目的。但继承也可能造成困扰和混乱。在对象技术发展早期,有一个经典的误用继承的例子:让栈(stack)继承列表list)。这个想法的出发点是想复用列表类的数据存储和操作能力。虽说复用是一件好事,但这个继承关系有问题:列表类的所有操作都会出现在栈类的接口上,然而其中大部分操作对一个栈来说并不适用。更好的做法应该是把列表作为栈的字段,把必的操作委派给列表就行了。这就是一个用得上以委托取代超类手法的例子------如果超类的一些函数对子类并不适用,就说明我不应该通过继承来获得超类的功能。
除了"子类用得上超类的所有函数"之外,合理的继承关系还有一个重要特征:子类的所有实例都应该是超类的实例,通过超类的接口来使用子类的实例应该完全不出问题。假如我有一个车模(car model)类,其中有名称、引擎大小等属性,我可能想复用这些特性来表示真正的汽车(car),并在子类上添加VIN编号、制造日期等属性。然而汽车终归不是模型。这是一种常见而又经常不易察觉的建模错误,我称之为"类型与实例名不符实" (type-instance homonym) 。
在这两个例子中,有问题的继承招致了混乱和错误------如果把继承关系改为将部分职能委托给另一个对象,这些混乱和错误本是可以轻松避免的。使用委托关系能更清晰地表达"这是另一个东西,我只是需要用到其中携带的一些功能"这层意思。
即便在子类继承是合理的建模方式的情况下,如果子类与超类之间的耦合过强,超类的变化很容易破坏子类的功能,我还是会使用以委托取代超类。这样做的缺点就是,对于宿主类(也就是原来的子类)和委托类(也就是原来的超类)中原本一样的函数,现在我必须在宿主类中挨个编写转发函数。不过还好,这种转发函数虽然写起来乏味,但它们都非常简单,几乎不可能出错。有些人在这个方向上走得更远,他们建议完全避免使用继承,但我不同意这种观点。如果符合继承关系的语义条件(超类的所有方法都适用于子类,子类的所有实例都是超类的实例),那么继承是一种简洁又高效的复用机制。如果情况发生变化,继承不再是最好的选择,我也可以比较容易地运用以委托取代超类。所以我的建议是,首先(尽量)使用继承,如果发现继承有问题,再使用以委托取代超类。