重构:改善既有代码的设计

第 1 章 重构,第一个示例

  1. 提炼函数,函数职责尽可能单一。

  2. 内联临时变量,动机在于:如果临时变量妨碍了其他重构手段,那么可以以查询(比如获取对象的属性)来替代临时变量。

  3. 搬移函数,拆分至不同文件、文件见,便于查找、组织。

  4. 使用多态取代条件表达式。

  5. “小步快走”,永远保持代码处于可工作状态,小步积累的修改更能清晰、有序地完成重构工作。

第 2 章 重构的原则

  1. 何为重构(重构的价值、意义)。

    • 改进软件的设计
    • 是软件更容易理解
    • 提高开发效率
  2. 何时重构。

    • 有计划的重构,软件难以理解、新增功能困难。
    • “捡垃圾”式重构,可以在做相关功能开发时,在理解相关的代码前提下,对小部分代码进行重构。积少成多,同时也能保证代码时刻在可运行、发布状态。
  3. 自测试代码、持续集成、重构,三者协同。

第 3 章 代码的坏味道

  1. 神秘命名(Mysterious Name)。

    不能够清晰表达变量、函数、模块等等的功能,用法。

  2. 重复代码(Duplicated Code)。

  3. 过长的函数(Long Function)。

    函数越长、越难理解,后期也难以维护、扩展。

  4. 过长参数列表(Long Parameter List)。

  5. 全局数据(Global Data)。

    可以创建一个专门的类或者函数等来管理全局数据,全局数据的访问、修改都必须通过这个类或函数,这样全局数据就尽可能在我们的管控之中了。

  6. 可变数据(Mutable Data)。

    数据变动、副作用修改,容易产生难以发现的 bug,数据变动难以追溯。可以设计专门的查询、修改函数来对对象进行操作,更新整个数据,而不是某个值(比如一个对象的某个属性)。

  7. 发散式、散弹式改变(Divergent Change、Shotgun Surgery)。

    在修改程序某一点的时候,需要在多个上下文中切换、修改,每个地方都进行一点修改。可以使用提炼、拆分函数模块来优化。

  8. 循环语句(Loops)。

    拥抱函数式变成,改用 reduce、filte、map 等等,能够减少循环带来的中间变量,更容易进行函数提取、组合、流水线操作等等。

  9. “夸夸其谈通用性”(Speculative Generality)。

    过早的臆想、缺少从实际需求考虑写代码,各种非必要的参数、钩子会让系统更加难以维护、理解。

  10. 过长的消息链(Message Chains)。

    一个对象请求一个对象,然后再去请求另一个对象,函数调用链过长。

  11. 内幕交易(Insider Trading)。

    两个模块之间有大量的特有逻辑交互,增加了耦合度。这时候应该将这些“私密”交互搬到明面上来,可以提取函数、模块来做这个事情。

  12. 过大的类(Large Class)。

    跟过大函数一样,做的事情多了,就不好理解、维护。可以对属性、函数进行功能分类,然后对大类进行拆分。

  13. 异曲同工的类(Alternative Classes with Different Interfaces)。

    两个类做的事情越来越像、重复,此时可以提炼超类。

  14. 注释(Comments)。

    写注释是好事,但是如果需要很长的注释去说明一段代码,或许这段代码很槽糕了。在写注释的时候,如果发现需要很长篇幅、或者自己都说不明白,那也许需要重构了。

第 4 章 构筑测试体系

  1. 测试驱动开发(Test-Driven Development,TDD),“测试,编码,重构”短循环。

第 5 章 重构的记录格式

  1. 作者在介绍重构时,每个重构手法都有以下 5 个部分,分别是:

    • 名称(name),重构手法的名称。

    • 速写(sketch),一个简短的描述,来简单说明此重构手法。

    • 动机(motivation),为什么需要 or 不需要重构。

    • 做法(mechanics),重构的步骤。

    • 范例(examples),简明的例子,来说明此重构手法。

第 6 章 第一组重构

  1. 提炼函数(Extract Function)。

    只要能够帮助理解函数名长一点没关系(以”做什么“来命名,而不是以”怎么做“命名),但函数体应该尽可能简短、功能单一,能通过简单的注释对函数进行描述。

  2. 内联函数(Inline Function)。

    函数的调用链过长,容易让人晕头转向,如果一个函数没有复用,而且与调用方有较强关联性,可以考虑将函数内联至调用方。

  3. 提炼变量(Extract Variable)。

    表达式可能会非常冗长,难以理解。这时候可以将表达式分割、提炼成一些变量,加之良好的变量名,能够提高代码的表达性、可理解性。如果这些变量在更宽的上下文中也有意义,甚至可以考虑创建专门的函数、类来管理相关变量。

  4. 内联变量(Inline Variable)。

    在一个函数中,可以使用一个变量来替代表达式,但有时候变量并不比表达式更具表现力,比如表达式 user.name,就能很好的表现出 name 这个属性来自 user 对象,表示用户的名字。所以如果表达式比较简单可以考虑使用内联变量。

  5. 改变函数声明(Change Function Declaration)。

    • 一个好的函数名非常重要,能够让我们一眼看出函数的作用,在开发过程中如果发现了更好的命名,可以尝试去替换它。(改进函数命名的一个好方法:先给函数写一段简单的注释,然后把注释变成名字)。

    • 函数的参数也一样,但却没有绝对正确的做法,比如一个函数可以尽量只传基本类型的值,这样能够减少该函数与外部的耦合。如果传一个对象,在多变的需求中也许我们会用到对象的其他参数,这样能够提高函数的封装度,以及参数能够更容易扩展,而不必修改调用方的入参方式。

    • 改变函数声明之后,可以使用渐进式的迁移,使用旧函数调用新函数,同时原有函数声明为 deprecated,在日后保证调用者的调用都迁移成新函数了,再删除旧函数。

  6. 封装变量(Encapsulate Variable)。

    重构就是调整程序中的元素,然而函数相对容易调整,因为函数只有一种使用方式,那就是调用,而且可以通过转发函数(旧函数调用新函数)来实现渐进式调整。然而调整数据就无法这么做,而且比如调整一个对象,该对象的作用范围越大,要找到对象的所有访问、修改点难度就越大。

    所以在面对一个作用域广泛的数据时,以函数形式封装对该数据的访问,这样就可以将“重新组织数据”这一困难任务转化为“重新组织函数”这一相对清晰、简单的任务。

    一言蔽之,数据的作用域越大,那么数据的访问封装就越重要。

    • 封装变量,可以让变量变得可追溯,以及在程序中更容易调试。比如我们熟悉的 vuex 这一状态管理模式,虽然在 vue 中的数据是 mutable(可变化)的,但是在大型应用中,使用 vuex 的时候,应该通过 vuex 的 mutation 和 action 来访问 store 中的全局变量,让变量的访问“明明白白”。

    • 从另一个角度看,这一切的“原罪”很大在于就数据可变,所以如果数据是 immutable(不可变),那么问题也会自然减少。比如主张 immutable 的 react 数据的改变需要通过 setState 方法,而对应生态下的 redux 更好地诠释了数据 immutable 的价值,虽然其繁琐的模板代码也是被不少人所诟病。

    • ES6 中 Reflect 对象提供了一系列操作对象的 API,比如 Reflect.get 和 Reflect.set,也可以看到封装变量的重要以及发展趋势。

  7. 变量改名(Rename Variable)。与 5. 改变函数声明,大同小异,不多做赘述。

  8. 引入参数对象(Introduce Parameter Object)。

    我们经常会就看见一组数据经常一起出现在一个又一个函数之中,这之后可以考虑将这些数据组成一个新的数据结构。

    这样做能让数据之间的关系变得更加清晰,也有可能催生代码中更深层次的改变、提高抽象,有助于代码中的领域划分。

    • 比如一个函数 function comment (id, name) {},id 表示系统中用户的唯一 id,name 表示用户名,在 JavaScript 中可以将这两个属性放在一个 user 对象或者类中,这样能够保证相关函数的入参方式一致,提升了代码的规范、一致性。

    • 在提取参数对象、类的过程中,也让系统的领域划分更为清晰,能够让系统更容易理解,更加清晰的功能边界同时也能降低系统耦合,更容易扩展、维护。

  9. 函数组合成类(Combine Functions into Class)。

    类是面向对象编程的首要构造,如果发现一组函数都在操作同一个数据,那么此时可以考虑将这些函数组合在一个类中。

    • 类能给函数提供一个公共的环境,函数可在实例中获取参数,从而简化了函数调用。

    • 类能让数据与数据操作在空间上有更紧密的联系,让开发者更容易查找阅读。

    • 函数的组织,类的产生,催化了重构,让系统领域更加清晰。

  10. 函数组合成变换(Combine Functions into Transform)。

    与提炼函数异曲同工,关键在于当多个函数操作一个数据的时候,可以考虑将这些函数提炼到一个组合函数中,数据操作统一在这变换函数中进行,这样能够让数据的变换都在一个位置中可找到,减少开发心智负担。

  11. 拆分阶段(Split Phase)。

    是提炼函数的一种实践,比如现在有个函数 shopping,这个函数负责在网购的所有事情,随着事情增多这个函数会越来越大,会变得难以理解、维护。

    比如可以将 shopping 里面要做的事情按阶段进行拆分成多个阶段、函数,比如这里可以拆分成 order(下单),receive(收货),comment(评价)这几个阶段函数,这样我们在维护的时候就只需要按阶段考虑其中一个主题,而不用回顾整个模块 的步骤、细节。

第 7 章 封装

  1. 封装记录(Encapsulate Record)。

    在 JavaScript 中可以通过一个对象表示一个记录,如果该记录的属性操作较多、使用范围较广可以将该对象转换成一个类。这样可以隐藏结构细节,使用者不用关心属性的存储、计算细节(比如提供属性的读取函数,而不是让使用者直接读取属性) 。 让数据操作变得更加直观、可追溯、易维护。

  2. 封装集合(Encapsulate Collection)。

    在封装记录的基础上,虽然属性通过取值函数访问,但取值函数有可能返回的是该数据本身,如果该数据属于引用类型,那么使用者仍可以直接修改该数据,而封装类对此全然不知。为此:

    • 首先取值函数可以返回该数据的深克隆,这样能保证数据在外部不可变,然后为了满足使用者的需求。
    • 可以提供专门的“增加“、”删除“等操作方法来操作数据,让数据变动都在类的管控之中,这样当程序越变越大的时候,也能够比较轻松的找出修改点。
  3. 以对象取代基本类型(Replace Primitive with Object)。

    开发初期,往往以简单的数据表示简单的情况,比如通过两个变量 const user = 0; const admin = 1 分别表示一个系统中的普通用户、管理员,往往在开发过程中会发现这些简单数据变得不再简单了,比如现在系统又多一个超级管理员的角色,随之出现了判断权限优先级,对应权限在系统中都有哪些功能等逻辑,这一切很快会增加数据的使用成本。

    当简单数据衍生出一系列的操作时,可以将这些数据、操作都提炼到一个类中,随着业务发展,这个类也许会变成很有用的工具,帮助我们更容易进行数据管理、系统抽象。

  4. 以查询取代临时变量(Replace Temp with Query)。

    临时变量(比如:const total = price * count - discount)能够解释在当前环境下的作用,以及避免了表达式过于复杂、重复计算等,但有时候,还是值得我们把他们抽取成函数。

    • 如果当我们正在分解、重构一段冗长的函数,那么变量抽取到函数中能让我们的工作更容易进行,因为我们不用再考虑这里面的临时变量该如何处理,比如额外传参之类的。

    • 能建立清晰的边界,减少副作用。

    • 较少重复表达式、计算逻辑。

  5. 提炼类(Extract Class)。

    跟提炼函数相似,当一个类数据、方法越来越多,会变成一团乱麻,如果这个类中的部分数据、方法总是一起出现、彼此依赖,那么可以将这些分离、提炼成一个新的类。

  6. 内联类(Inline Class)。

    与提炼类相反,当一个类比较简单、没有太多变化、调用方单一等,这时候可以将该类内联进使用类中。

  7. 隐藏委托关系(Hide Delegate)。

    “封装”是一个模块的重要特征之一,模块应该尽可能满足最小知识原则,隐藏自己的实现,减少耦合。比如一个实例 aPerson,调用者通过 aPerson.department.manager 能够查询该用户的经理是谁。但这就相当于暴露了类的实现细节,调用者都需要知道 manager 属性需要在实例的 department 属性中获取。

    更好的做法是在类中加上一个简单的委托函数来进行查询,比如 get manager() {return this._department.manager;},这样能就简化调用,而且及时将来委托关系改变,也只需要调整委托函数,而不会影响到调用者。

  8. 移除中间人(Remove Middle Man)。

    与隐藏委托关系相反,因为过长的委托、转发函数,反而会让代码阅读、维护变得更加困难。

  9. 替换算法(Substitute Algorithm)。

    重构就是将巨大复杂化为小巧简单,在对一个函数有充分理解的情况下,如发现了更简单、清晰的实现方式,可考虑替换掉原有的实现方式。

第 8 章 搬移特性

  1. 搬移函数(Move Method)。

    在“整理”代码时候根据函数的自身以及调用处上下文、函数的作用范围等将函数移动到合适的位置(提炼成公共函数、内联函数等等)。

  2. 搬移字段(Move Field)。

    数据结构是一个健壮程序的根基,适用于问题域的良好数据结构可让代码变得更加简单明了。

    领域驱动设计能帮助我们更好的设计数据结构,但往往在开发设计过程中会发现更合适的数据结构设计,一旦发现数据结构难以完成越来越复杂的需求了(比如一组数据总是一同作为函数的参数,参与路基计算,这时候可以规划到同以条记录中,以体现其关联性),应该马上修缮它。

    这时候也体现了“封装记录、集合”的好处,因为访问者都是通过函数去访问数据的,所以我们可以放心修改字段,对访问函数进行调整即可,对访问者的影响降到最小。

  3. 搬移语句到函数(Move Statements into Function)。

    某些语句与一个函数看起来更像一个整体,那么尝试将语句逻辑抽象,搬移到函数里面。

  4. 搬移语句到调用者(Move Statements to Callers)。

    随着需求发展,系统中原先设计的边界开始渐渐偏移。对于函数来说,就是曾经关注的一个整体已经分化成多个不同的关注点。为了保证函数通用性、系统边界清晰,将函数内的不同行为分别移动至调用处。

    当然这方法仅适用于出现少许偏移的情况,否则只能重新设计函数了。

  5. 以函数调用取代内联代码(Replace Inline Code with Function Call)。

  6. 移动语句(Slide Statements)。

    让存在关联的语句一起出现,可以使代码更容易理解。如果有几行代码使用了同一个数据,那么最好让几行代码一起出现。

  7. 拆分循环(Split Loop)。

    常常可见一个循环里面做了多个事情,很多时候只是为了能够“一次循环”,以“提高性能”,但在数据有限的情况下,循环很少成为性能的瓶颈。

    这样在修改循环的时候就需要理解多个事情,增加了这段代码的阅读、维护成本,为此可以将不同的事情拆分至不同的循环,遵循先重构、再进行性能优化的原则,因为代码清晰,是系统优化的一大前提。

  8. 以管道取代循环(Replace Loop with Pipeline)。

    现今越来越多的变成语言都提供更好的语言结构来处理迭代,这种集合就称为集合管道(collection pipeline),比如 JavaScript 中 map、filter、reduce 等方法。

    使用集合管道能够增强代码可读性,顺着“管道”就能弄清楚集合中间变换的过程。

  9. 移除死代码(Remove Dead Code)。

    随着需求变动、系统中会产生许多用不上的代码,在确认这些代码用不上了,应该立马删除它,这样能减少阅读代码时额外的思维负担。

第 9 章 重新组织数据

  1. 拆分变量(Split Variable)。

    一个变量常会被多次赋值,比如循环变量(如循环中的 i++)、结果收集变量(JavaScript 中 reduce 中用于缓存结果的变量),这些变量虽然在不断改变,但其作用、责任都是唯一的。

    与此相反的是有些变量,被多次赋值,而且每次的职责都不同,这时候要根据不同职责分解为多个变量(可以的话都声明为不可修改的数据,比如 JavaScript 中的 const),不然会让阅读者感到糊涂。

  2. 字段改名(Rename Field)。

    数据结构是理解程序行为的关键,数据字段的命名,规范、易懂是良好数据结构的前提。

  3. 以查询取代派生变量(Replace Derived Variable with Query)。

    可变数据是程序错误的源头之一,应该把可变数据的范围尽可能缩小。

    有些变量很容易计算(比如通过函数调用、类的 get 取值函数)出来,计算更能清晰表达数据的含义,而且也能够避免“源数据变化时派生数据忘了修改”的问题,同时计算也能减少可变数据的产生。

    当然,如果源数据不会改变,或者派生数据用完及弃,那么派生数据也许是更好的选择。

  4. 将引用对象改为值对象(Change Reference to Value)。

    值对象通常更容易理解,因为它们不可变,我们不用担心对象属性被偷偷改掉。

  5. 将值对象改为引用对象(Change Value to Reference)。

    有些时候,程序中需要共享同一对象,最好的做法就是使用同一引用对象(比如可通过单例模式创建一个对象、或者保存对象在全局变量中),这样能保证数据访问的全局唯一,让数据更新更简单。

第 10 章 简化条件逻辑

  1. 分解条件表达式(Decompose Conditional)。

    复杂的条件逻辑是导致代码复杂度上升的因素之一,一个函数中条件逻辑越多、越复杂。

    为此可以对条件逻辑采用提炼函数的手段,这样能够降低单个函数复杂度,新函数的生成也让程序更容易维护、复用。

  2. 合并条件表达式(Consolidate Conditional Expression)。

    有时会发现代码中存在一连串的条件检查,条件各有不同,但是最后的行为都是一致的。

    这时候可以考虑将这多个条件合并为一个条件,表示“这些条件都是做一样的事情”,能够减少重复代码,让用意更清晰。

    当然如果认为这些条件有各自特殊、完整用意,应该彼此独立,那么将不会采用此重构手段。

  3. 以卫语句取代嵌套条件表达式(Replace Nested Conditional with Guard Clauses)。

    在一连串条件表达式中,如果某个条件比较罕见、特殊,那么可以使用“卫语句”(guard clauses),当这个条件成立时立刻从函数返回。

    如下一个用于买单的函数pay,不同金额商家会回赠不同的礼品,当 price 为负数的时候,这是比较特殊的情况,那么可以采用“卫语句”单独检查、立刻返回,避免与正常的业务逻辑混淆。

    const pay = (price) => {
      if (price < 0) return null
      if (price < 100) return 'candy'
      if (price < 200) return 'flower'
    }
    

    使用“卫语句”能减少if-else结构的使用,使函数尽可能“扁平化”,更清晰易读。

    很多时候因条件逻辑限制,无法直接使用“卫语句”,那么此时可以考虑将条件“反转”,也许就能顺利使用“卫语句”了。

  4. 以多态取代条件表达式(Replace Conditional with Polymorphism)。

    使用类和多态能把复杂逻辑的拆、分表述得更加清晰。基础步骤如下:

    1. 抽象出超类,把基础逻辑放入超类,不同逻辑声明为 abstract 接口、属性。JavaScript 中没有抽象的概念,可用在函数中抛出错误来模拟抽象接口。

    2. 划分、实现不同的子类,都继承同一超类,分别实现超类的抽象接口来完成不同的逻辑差异。

    3. 创建一个工厂函数,该函数在不同逻辑下能够生成、获取不同的子类实例。

    4. 客户端的复杂逻辑调整为获取工厂函数产出的不同对象,调用同名、不同实现的接口来完成不同功能。

    5. 在使用多态后,如果后续维护过程中出现if-else等条件逻辑判断了,要尽可能抽象为超类的抽象接口、属性,然后子类分别不同实现,避免出现“坏味道”。

  5. 引入特例(Introduce Special Case)。

    当程序在不同的地方检查、处理同一个数据结构的特殊值时,容易产生重复的代码。

    处理这种情况的一种方式就是引入“特例”,让特殊值的结构与普通值的结构一致,这样就可以用一个函数调用取代大部分特例检查代码,或者让特例也同样适配了普通数据的逻辑代码。

  6. 引入断言(Introduce Assertion)。

    经常能看到这么一段代码,只有当某个条件为真,该段代码才能正常运行。这通常在代码中没有表现出来,那么可以使用断言来标明这些假设。

    断言是一个条件表达式,应该总是为真,比如 nodejs 的assert。如果断言失败了,那表明此处代码不符合预期,存在 bug。

    断言能够预防开发者自身的错误,但是当一个数据源来自外部,那么对数据进行校验才是首要任务,而不是断言。

第 11 章 重构 API

  1. 将查询函数和修改函数分离(Separate Query from Modifier)。

    明确表现出“有副作用”和“无副作用”两种函数之间的差异是好习惯。任何有返回值的函数,都不应该有“看得到”的副作用,即命令与查询分离(Command-Query Separation)。

    这里的“看得见”,指的是比如显式地修改了传入函数的引用值。相反“看不见”指的是,比如每次调用函数都会将结果缓存起来,从而加快下次相同的查询速度,这种副作用就是“看不见”的,因为查询总是能获取到一样的结果。

    如遇到“即又返回值,又有可见副作用”的函数,可试着将查询动作与修改动作分离。

  2. 函数参数化(Parameterize Function)。

    两个函数非常相似,只有一些字面量值不同,可以将其合并成一个函数,以参数形式传入不同的值,从而消除重复代码,提高复用。

  3. 移除标记参数(Remove Flag Argument)。

    “标记参数”是这样一种参数,调用者用它来指示被调函数应该使用哪一块的逻辑。如下的useNewLogic参数。

    const setTime = (time, useNewLogic) => {
      if (useNewLogic) {
        // 新版处理时间逻辑
      } else {
        // 旧版处理时间逻辑
      }
    }
    
    setTime(time1, true)
    setTime(time2, false)
    

    标记参数往往让人难以理解一个函数该怎么用,因为仅从调用处看来setTime(time1, true)setTime(time2, false),无法知道第二个参数的含义是什么,除非我们去看setTime函数的定义。

    如果标记参数是布尔值那就更糟糕了,因为它比传如字符串类型的标记参数更让人摸不着头脑。

    为此,我们可以尝试移除标记表变量,让函数调用更能体现出调用者的意图。具体有以下做法:

    1. 修改标记参数对象类型。下方代码中useNewLogic调整为了一个对象中的属性,这样在调用方就可以感知到不同参数的差异了。

      const setTime = (time, { useNewLogic = true }) => {
        if (useNewLogic) {
          // 新版处理时间逻辑
        } else {
          // 旧版处理时间逻辑
        }
      }
      
      setTime(time1, { useNewLogic: true })
      setTime(time2, { useNewLogic: false })
      
    2. 针对每一个可能值,新建一个明确函数。如下提炼出了setTimeWithNewLogicsetTimeWithOldLogic两个函数,函数的的重点就在于遵循调用者的“指令”,所以更好地突出了调用者的意图。

      const setTime = (time, useNewLogic) => {
        if (useNewLogic) {
          // 新版处理时间逻辑
        } else {
          // 旧版处理时间逻辑
        }
      }
      
      const setTimeWithNewLogic = (time) => setTime(time, true)
      const setTimeWithOldLogic = (time) => setTime(time, false)
      
      setTimeWithNewLogic(time1)
      setTimeWithOldLogic(time2)
      
  4. 保持对象完整(Preserve Whole Object)。

    如果在代码中从一个记录值(比如 JavaScript 的对象)中导出一个值,然后把这几个值传给一个函数,也许把整个记录值传给函数,带函数内部一致处理导等是更好的做法。

    如下:

    const { start, end } = time
    setTime(start, end)
    

    可转换为:

    setTime(time)
    

    这样将来如果需要time对象的更多参数,不再需要修改参数列表,直接从函数中取即可。而且参数列表也变短了,减少了不同调用方导出值等产生的重复代码。

    如果多处代码都在使用同一对象、同一函数,可以考虑使用提炼类。将数据、行为放在一起,帮助系统抽象、领域划分。

  5. 以查询取代参数(Replace Parameter with Query)。

    参数列表应该尽量避免重复,越简短越容易理解。如果调用一个函数时,传入了一个值,这个值由被调用函数获取也是“同样容易”的,那这就是重复,增加了调用难度。

    如下:

    const setTime = (time, end) => {
      console.log(time, end)
    }
    setTime(time, time.end)
    

    可转换为:

    const setTime = (time) => {
      console.log(time, time.end)
    }
    setTime(time)
    

    这样做能简化调用方,减少重复代码。当然这里的“同样容易”是一个必要前提,因为移除参数,使用查询会增加函数对对象的依赖,如果影响了函数原有逻辑或阻碍了函数解耦,那么应该慎重考虑使用此手段。

  6. 以参数取代查询(Replace Query with Parameter)。

    以查询取代参数的反向手段。在浏览一个函数的时候,会发现该函数可能依赖了上层或全局的变量,这引用关系,阻碍了我对函数解耦、重构。

    为了让函数不再依赖该变量,可将依赖关系变成参数,这样函数变得更加“透明”,行为更容易理解、预测,测试更加简单。

    当然,这也带来了一定“代价”,增加了调用复杂度,如果调用链较长,也会存在冗余、重复的参数列表,反而导致其它函数耦合增加了。

  7. 移除设值函数(Remove Setting Method)。

    此手段的核心理念是尽可能保证数据不可变,如果我希望一个数据不被修改,那么可从根源出发,去掉设值函数。

    对于 JavaScript,可以使用Object.definePropertyObject.freeze,来让对象属性不可变。

  8. 以工厂函数取代构造函数(Replace Constructor with Factory Function)。

    实例化一个对象,客户端通常会直接调用对应类的构造函数,当然这存在一定局限性。

    如果把在两者中间增加一个工厂函数,客户端通过工厂函数获取实例对象,能够减少客户端与具体类的耦合,在工厂函数中能自由进行扩展,比如不同环境返回不同对象、实现缓存等等,而不必修改客户端代码。

  9. 以命令取代函数(Replace Function with Command)。

    对象上附着着函数,这样的对象可称之为“命令对象”(command object),或者简称“命令”(command)。与普通函数相比,命令对象更灵活,表达能力更强,可通过继承实、钩子函数定制函数行为。

    如下:

    class Person {
      constructor(name) {
        this.name = name
      }
      walk() {
        console.log(this.name, 'walking.')
        return this
      }
      sayHi() {
        console.log('Hi, i am', this.name)
        return this
      }
    }
    new Person('Tony').walk().sayHi()
    

    使用命令对象能更实现链式调用,加之数据与行为的聚合,能更清晰表达代码语义。而且函数之间共享上下文,尽管有着复杂、冗长的调用链,也能够通过上下文(如 JavaScript 中的 this)轻松获得参数。

    但这也会提高代码复杂度,在函数作为一等公民的 JavaScript 中,如果普通函数已经能够清晰表达、完成其功能了,那么应该优先使用普通函数。

  10. 以函数取代命令(Replace Command with Function)。

    以命令取代函数的反向手段,如上所说,命令对象提供了强大的机制,强语义、函数间共享数据简便等。

    但很多时候我们只需要一个简单的函数,完成一项简单的任务,此时命令对象需要获取类、然后实例化后才能调用,这就显得费力不讨好了,使用普通函数或许是更好的选择。

第 12 章 处理继承关系

  1. 函数上移(Pull Up Method)。

    如各个子类中都有相似、甚至相同的函数体,尝试将其从子类移除、提升至超类。以减少重复代码,避免需要“处处修改”的问题。

    函数体内的些许不同,可应用函数参数化,来简单处理不同的逻辑。

  2. 字段上移(Pull Up Field)。

    如各个子类拥有重复字段,如果他们的含义以及使用方式一致,将其上移至超类。

  3. 构造函数本体上移(Pull Up Constructor Body)。

    如各个子类的构造函数中有相同行为,将其上移至超类,其中的变量也可以传递给超类的构造函数。

    如下:

    class Person {
      constructor() {}
    }
    
    class Man extends Person {
      constructor(name) {
        super()
        this.name = name
      }
    }
    
    class Woman extends Person {
      constructor(name) {
        super()
        this.name = name
      }
    }
    

    可转换为:

    class Person {
      constructor(name) {
        this.name = name
      }
    }
    
    class Man extends Person {
      constructor(name) {
        super(name)
      }
    }
    
    class Woman extends Person {
      constructor(name) {
        super(name)
      }
    }
    
  4. 函数下移(Push Down Method) & 字段下移(Push Down Field)。

    如超类的某个函数、字段只与一个(或少数几个)有关,那么最好将其从超类中移除,放到真正关系他们的子类中,以减少其它子类的负担。

  5. 以子类取代类型码(Replace Type Code with Subclasses)。

    系统中经常需要表现“相似但又不相同的东西”,比如一个后台管理系统中,有三种不同权限角色,分别是普通用户、管理员、超级管理员。最直观就是增一个类型字段比如type来区分不同的角色。

    大多数时候,有类型字段就足够了,但是随着系统升级,各个角色的不同数据、功能出现越来越多的条件分支,让系统变得难以维护。为此,可尝试引入子类,通过子类将不同类型用户这一概念显性化

    这样我们就可以用多台来取代复杂的条件分支语句,同时子类的出现也能更明确地表达数据、行为的关系,让系统领域更清晰、易扩展。

    比如:

    enum UserTypes {
      normal,
      admin,
      superAdmin
    }
    
    class User {
      type: UserTypes = UserTypes.normal
      constructor(type: UserTypes) {
        this.type = type
      }
      login() {
        if (this.type === UserTypes.normal) {
          // do someting
        } else if (this.type === UserTypes.admin) {
          // do someting
        } else if (this.type === UserTypes.superAdmin) {
          // do someting
        }
      }
    }
    

    可转化为:

    enum UserTypes {
      normal,
      admin,
      superAdmin
    }
    
    abstract class User {
      type: UserTypes = UserTypes.normal
      abstract login(): void
    }
    
    class Normal extends User {
      type = UserTypes.normal
      login() {
        // do someting
      }
    }
    
    class Admin extends User {
      type = UserTypes.admin
      login() {
        // do someting
      }
    }
    
    class SuperAdmin extends User {
      type = UserTypes.superAdmin
      login() {
        // do someting
      }
    }
    
    const createUser = (type: UserTypes) => {
      if (type === UserTypes.normal) return new Normal()
      if (type === UserTypes.admin) return new Admin()
      if (type === UserTypes.superAdmin) return new SuperAdmin()
    }
    
  6. 移除子类(Remove Subclass)。

    子类为数据、行为的多态提供了支持,是针对差异编程的好工具。

    但是随着系统演化,子类所支持的差异化数据、行为可能被移动到别处,甚至去除,这时候应该将子类去除,避免无效类带来系统的复杂度提升。

  7. 提炼超类(Extract Superclass)。

    如发现两个类在做相似、甚至相同的事情,可利用继承机制,将它们的相似之处提炼到超类之中。

  8. 折叠继承体系(Collapse Hierarchy)。

    在重构继承体系的过程中,如发现一个类与超类没有多大区别,这时候可将超类和子类合并,以降低系统复杂度。

  9. 以委托取代子类(Replace Subclass with Delegate)。

    如果一个对象的行为有明显的区分,那么继承、多态是很自然的表达方式。但继承也有其缺点,主要有以下两点:

    1. 一个类只能进行一次继承,系统中导致行为不同的原因有很多,从不同维度能有多种提炼超类的方向,但是却不能同时继承多个超类。

    2. 继承给类之间引入了非常紧密的联系,在超类上做任何修改都有可能对子类造成意外的影响,所以得非常小心。

    上述两个问题,都可以通过委托解决。不同的行为委托给不同的类,与继承相比,委托的接口更清晰,耦合更低,这也是为什么有那么一句话“组合优先于继承”。

    比如有这么一个机器人售卖系统,不同机械人有不同的攻击方式,那么起初设计很自然的以不同的机器人类型为纬度,来对进行系统实现,有以下代码:

    // 机器人超类
    abstract class Robot {
      abstract fight(): void
    }
    // 使用刀攻击的机器人
    class RobotWithKnife extends Robot {
      fight() {
        console.log('Fight with knife.')
      }
    }
    // 使用枪攻击的机器人
    class RobotWithGun extends Robot {
      fight() {
        console.log('Fight with gun.')
      }
    }
    

    但是随着科技研发,系统中增加了更多不同款式机器人,攻击fight甚至不是机器人的必备因素了,有的机器人用于运输、医疗等等。为此需要通过新的纬度比如,不同世代generation来对机器人进行基准分类。 所以我们将原有的继承转化为委托,代码如下:

    enum RobotTypes {
      knife,
      gun
    }
    
    class RobotWithKnife {
      fight() {
        console.log('Fight with knife.')
      }
    }
    
    class RobotWithGun {
      fight() {
        console.log('Fight with gun.')
      }
    }
    
    class Robot {
      typeDelegate
      constructor(type: RobotTypes) {
        this.typeDelegate = this.selectTypeDelegate(type)
      }
      selectTypeDelegate(type: RobotTypes) {
        if (type === RobotTypes.knife) return new RobotWithKnife()
        if (type === RobotTypes.gun) return new RobotWithGun()
        return this
      }
      fight() {
        return this.typeDelegate ? this.typeDelegate.fight() : undefined
      }
    }
    

    这样不同类型机器人不再与机器人这一超类耦合,同时也允许系统能够以世代这一中更要的纬度来对系统进行重构。

  10. 以委托取代超类(Replace Superclass with Delegate)。

    在面向对象编程中,通过继承来复用现有功能,是一种既强大又便捷的手段。

    但继承也有可能造成困扰和混乱,比如超类的所有方法都会出现在子类中,如果超类中过多的属性、方法不应该出现在子类中,说明使用继承也许是一个错误的选择。

    如果把这错误的继承关系改为委托,子类需要的行为委托给另一个类去执行,这样就能在复用代码的同时避免不必要的混乱。

    当然继承是一种简洁、高效的复用机制,多数情况可以先考虑继承,不合适再转换成委托。

    如一个系统中,有一个成员(Member)类,新增需求,成员需要加入部门,为了使用加入部门的方法(addMembers)继承了部门(Department)类。代码如下:

    class Department {
      addMembers() {}
      transferMembers() {}
      deleteMembers() {}
    }
    
    class Member extends Department {}
    

    这里就是典型的错误继承,成员同时拥有了部门的其它属性、方法,模糊了系统边界,让系统变得难以理解维护。更好的做法应该是 Member 类将加入行为委托转发由 Department 类来执行,如下:

    class Department {
      addMembers(members: Member[]) {}
      transferMembers() {}
      deleteMembers() {}
    }
    
    class Member {
      department: Department
      constructor() {
        this.department = new Department()
      }
      joinDepartment() {
        this.department.addMembers([this])
      }
    }
    
上次更新: 1/25/2021, 11:49:56 PM