整理 Martin Fowler《重构》一书中的 24 种代码坏味道及各自对应的重构手法。
- 神秘的命名(Mysterious Name)
- 重复的代码(Duplicated Code)
- 同一个类中含有相同的表达式:提炼函数。
- 如果重复代码只是相似,而不是完全相同:则首先尝试移动语句,把相似的部分放在一起方便提炼。
- 如果重复代码段位于一个超类的两个不同的子类中,可以使函数上移。
- 过长函数(Long Function)
- 函数越长越难理解,好的函数应该被"充分提炼":只要一段代码可以被赋予一个好名字,就该被提炼成独立函数,哪怕函数体只有一行。
- 判断该提炼哪一段的经验法则:如果需要写注释才能说明这段代码在干什么,就先把它提炼成一个函数,用注释内容当函数名。
- 如果函数用了大量参数和临时变量,妨碍提炼:先用以查询取代临时变量、引入参数对象、保持对象完整等手法精简参数列表,再动手提炼。
- 如果函数中有大量条件逻辑:用分解条件表达式把每个分支拆开,必要时用以多态取代条件表达式。
- 如果函数中有循环:把循环体连同其中的代码提炼成独立函数。
- 过长参数列表(Long Parameter List)
- 如果调用时能从某个参数对象身上取得另一个参数:用以查询取代参数去掉冗余参数。
- 如果多个参数总是一起出现:用引入参数对象把它们收拢成一个对象。
- 如果是用标记参数来选择函数内部执行路径:移除标记参数,拆成多个函数。
- 如果多个函数共享同一组参数:考虑将函数组合成类,把这些参数变成类的字段。
- 全局数据(Global Data)
- 全局数据(包括类的静态变量、全局变量)可以被任何代码不受限制地修改,一旦出问题很难定位是谁改的。
- 最常用的应对手法是封装变量:把裸露的全局数据封装到一个函数(或类)背后,把读写都收窄到统一的出入口,必要时还能加上校验或日志。
- 可变数据(Mutable Data)
- 数据被到处修改,容易在某处改了值之后,在遥远的另一处引发意外的错误结果。
- 用封装变量约束数据的读写范围。
- 用拆分变量,把"只被赋值一次"和"反复被重新赋值"的变量区分开,避免一个变量身兼多职。
- 尽量用移动语句、提炼函数把带副作用的赋值语句和纯粹的计算逻辑分开。
- 如果一个字段只在特定场景下才有意义,考虑以查询取代派生变量,把它变成实时计算的结果,而不是一份需要手动维护同步的缓存值。
- 发散式变化(Divergent Change)
- 指一个模块经常因为多个不同的原因(多种不同类型的需求)而被修改,比如一个类里既有数据库相关的改动,又有界面相关的改动。
- 用拆分阶段把处理过程拆成互不相关的几个步骤。
- 用提炼函数、搬移函数把因同一种原因而变化的代码集中到同一个模块,使每个模块只因一种原因而变化——这正是"单一职责原则"的体现。
- 霰弹式修改(Shotgun Surgery)
- 与发散式变化相反:每次改动某一个需求,都要同时修改散落在许多不同模块里的一堆代码,很容易漏改。
- 用搬移函数、搬移字段把需要一起修改的代码集中到同一个模块,让"一种改动只需要改一个地方"。
- 如果搬移后某个模块的职责过多,再考虑用提炼类进一步拆分。
- 依恋情结(Feature Envy)
- 一个函数跟其他模块的数据、函数打交道的次数,比跟自己所在模块打交道还频繁——通常意味着该函数应该搬到它更感兴趣的那个模块中去。
- 用搬移函数把函数整体搬到它真正该待的地方。
- 如果函数中只有一部分逻辑对别处的数据感兴趣,先用提炼函数拆出这部分,再对这部分做搬移函数。
- 数据泥团(Data Clumps)
- 几个数据项总是成群结队地出现在一堆函数的参数里、或一组类的字段里,说明它们该被合并成一个独立的对象。
- 用提炼类把它们变成一个类;如果只是作为参数传递,用引入参数对象。
- 合并之后,很多函数可以借助搬移函数搬到这个新类身上,往往还能顺带消除"依恋情结"。
- 基本类型偏执(Primitive Obsession)
- 该用对象表达的概念(比如货币、坐标区间、电话号码),却始终用字符串、数字这样的基本类型表示,导致相关的校验和格式化逻辑散落各处。
- 用以对象取代基本类型,把值和它相关的行为封装到一起。
- 如果一组基本类型值合在一起才有意义,先用提炼类或引入参数对象。
- 如果代码里出现了根据类型码做条件判断,考虑用以子类取代类型码或以多态取代条件表达式。
- 重复的switch(Repeated Switches)
- 同一组 switch/if-else 分支逻辑(根据同一个条件做选择)在代码库中反复出现在多个地方,一旦新增一个分支,就得把所有地方都找出来同步修改一遍。
- 用以多态取代条件表达式,把每个分支实现成对应子类的一个重写方法,新增分支时只需新增一个子类。
- 循环语句(Loops)
- 传统的 for/while 循环把"如何遍历"和"要做什么"混在一起,现代语言大多提供了 filter、map、reduce 等管道式的集合操作。
- 用以管道取代循环,把循环改写成一连串描述"做什么"的管道操作,可读性通常更好。
- 冗赘的元素(Lazy Element)
- 有些函数、类原本是为了应对某种预期中的扩展而设立的,但这种扩展从未真正发生,留下了一层没有实际价值、只会增加认知成本的间接层。
- 用内联函数、内联类拆掉它,直接用其内部实现替换掉这层多余的包装。
- 夸夸其谈通用性(Speculative Generality)
- 为了"以防将来用得上"而设计了各种钩子、抽象层、可配置参数,但目前完全用不到,只会让代码更难理解。
- 用折叠继承体系合并不必要的父子类,用内联函数/内联类去掉用不上的间接层,用移除死代码清理没人调用的分支和参数。
- 唯一的例外是正在实现某个测试驱动的接口,这种"超前设计"有明确的落地场景,不算这个坏味道。
- 临时字段(Temporary Field)
- 类里有些字段只在某些特定场景下才会被赋值,其余时间一直是空的或无意义的默认值,读代码的人很难判断这个字段何时该用。
- 如果这些临时字段总是一起出现,用提炼类把它们和相关逻辑一起搬到一个新类里,让"何时该有值"这件事名正言顺。
- 如果某个分支逻辑专门用来处理"字段为空"的情况,可以用引入特例去掉这些防御性判断。
- 过长的消息链(Message Chains)
- 代码里出现了
a.getB().getC().getD() 这种一长串的连续调用,调用者被迫了解链路上每一环的内部结构,中间任何一环变化都要跟着改。
- 用隐藏委托关系,让中间对象包一层方法,替调用者去访问链路更深处的数据。
- 也可以把链条上一部分逻辑提炼函数后用搬移函数移到链路更靠前的对象上。
- 中间人(Middle Man)
- 跟"过长的消息链"相反:一个类里一大半方法都只是简单地把调用转发给另一个对象,自己本身没什么价值。
- 用移除中间人,让调用者直接找真正干活的那个对象,只留下确有必要的委托方法。
- 内幕交易(Insider Trading)
- 两个模块过分了解彼此的内部细节,经常互相访问对方的私有数据,说明它们之间的边界划得不够清楚,耦合过重。
- 用搬移函数、搬移字段减少两者互相打探的次数。
- 如果两个模块确实需要频繁协作,用隐藏委托关系或提炼一个新的中间模块,把这层"内幕"关系变成一个公开、受控的接口。
- 过大的类(Large Class)
- 一个类里塞进太多字段、太多方法,通常意味着它其实在同时扮演好几个不同的角色。
- 先看这些字段和方法能不能按用途分组,能分组的话用提炼类(或提炼超类、提炼子类)把每一组拆成独立的类。
- 异曲同工的类(Alternative Classes with Different Interfaces)
- 两个类做的事情本质上相似,却各自定义了一套不同名字、不同参数的方法,用的人不得不分别记住两套用法。
- 用改变函数声明统一它们的接口命名和参数形式;如果构造逻辑不同,考虑用搬移函数把构造相关代码提出来单独处理。
- 接口统一之后,视情况用提炼超类,把公共部分收拢到一个共同的父类里。
- 纯数据类(Data Class)
- 类里只有一堆字段和对应的取值/设值方法,没有任何自己的行为,通常意味着操作这些数据的逻辑本该属于这个类、却被写到了别的地方。
- 找出所有只读取该类数据、自己却完全不属于这个类的函数,用搬移函数把它们搬进来。
- 如果字段被外部直接修改,考虑用封装变量收紧访问权限,必要时改成只读。
- 被拒绝的遗赠(Refused Bequest)
- 子类只想用父类的一部分方法和数据,却被迫连带继承了一大堆自己根本用不上、甚至不希望暴露给外界的接口,说明这层继承关系一开始就没设计对。
- 如果子类不需要共享父类的行为,只是想复用一部分实现,用以委托取代继承,把"继承"换成"组合"。
- 如果确实存在共性但父类里也有一部分子类用不上的东西,用提炼超类,把真正公共的部分单独提炼成一个更小的父类。
- 夸夸其谈的注释(Comments)
- 注释本身不是坏味道,一段写得好的注释常常很有价值;但如果一段代码需要大段注释才能让人看懂,往往说明代码本身写得不够清楚。
- 先尝试用提炼函数把需要解释的代码提炼出来,再用改变函数声明给它起一个足够清楚的名字,很多时候名字本身就能替代掉原来的注释。
- 如果注释是用来说明某种前提假设,可以考虑用引入断言把这份假设写进代码里,变成可以在运行时验证的东西,而不是一段容易过时、没人维护的文字。