第 2 章 代码的坏味道
什么时候该重构?Fowler 的回答是:闻到坏味道(Code Smell)的时候。坏味道不是错误,带着坏味道的代码照样能跑、测试照样能过。它是一种信号,提醒你这里的结构已经开始让人吃亏,再往上堆需求会越来越疼。
给坏味道起名字的用处在于交流。没有名字时,代码评审里只能说“这里感觉怪怪的”,对方说“我觉得挺好”,然后不了了之。有了名字,你可以说“这里是数据泥团,这三个参数总是一起出现”,对方立刻知道你指什么、为什么在意、通常怎么改。本书后面每一章的“问题在哪”一节都会用这些名字,所以这一章值得先读一遍,记不住也没关系,用到时回来查。
下面是 Fowler 列出的 24 种坏味道。每一种给一小段有味道的代码、它具体会让你吃什么亏、先看哪一章。
def calc(d, r): t = d * r return t if t < 100 else 100读这四行你得猜 d 是天数还是距离,r 是费率还是半径,100 是什么的上限。名字没说清楚,每个读者都要自己推一遍,推错了就是 bug。改名是最常用也最值得做的重构,IDE 一键完成。
先看 → 第 6 章 改变函数声明、第 7 章 变量改名、第 31 章 字段改名。
function monthlyReport(orders: Order[]) { const total = orders.filter((o) => o.paid).reduce((s, o) => s + o.amount, 0); // ...}function yearlyReport(orders: Order[]) { const total = orders.filter((o) => o.paid).reduce((s, o) => s + o.amount, 0); // ...}两处一样的代码意味着改规则要改两处,而且没有任何机制保证你记得第二处。“已付款才算”的规则后来加了“且未退款”,只改了月报,年报就对不上了。
先看 → 第 4 章 提炼函数、第 25 章 移动语句、第 51 章 函数上移。
def checkout(cart, user): # 1. 校验库存(20 行) # 2. 算优惠(30 行) # 3. 算运费(15 行) # 4. 生成订单(25 行) # 5. 发通知(10 行) ...一百行的函数靠注释分段。要改优惠规则,得先在一百行里找到第 2 段,还要确认它用的变量没被第 1 段改过。注释分段本身就是在告诉你:这里本来该是五个函数。
先看 → 第 4 章 提炼函数、第 35 章 分解条件表达式、第 37 章 以卫语句取代嵌套条件表达式、第 49 章 以命令取代函数。
过长参数列表
Section titled “过长参数列表”function createUser(name: string, email: string, street: string, city: string, zip: string, country: string, newsletter: boolean) { /* ... */ }调用这个函数要数着位置填七个参数,city 和 street 传反了类型检查也不报错。参数越多,每个调用点越像一行密码。
先看 → 第 9 章 引入参数对象、第 45 章 保持对象完整、第 46 章 以查询取代参数、第 44 章 移除标记参数。
current_user = None
# 任何模块都可以import configconfig.current_user = someone_else全局变量谁都能改,出了问题你不知道是谁在哪一行改的。测试也难写:一个测试改了它,下一个测试就莫名其妙地失败。
先看 → 第 8 章 封装变量。
const settings = { theme: "light" };applyUserPrefs(settings); // 里面悄悄改了 settings.themerender(settings); // 用的是改过的值,但读代码的人看不出来一个值在不同时刻有不同内容,读代码的人必须追踪每一次修改才知道某一行看到的是什么。把能不变的做成不变的,剩下的修改集中到少数几个地方。
先看 → 第 8 章 封装变量、第 30 章 拆分变量、第 42 章 将查询函数和修改函数分离、第 33 章 将引用对象改为值对象、第 47 章 移除设值函数。
class ReportService: def load_from_db(self): ... # 换数据库要改这里 def format_as_html(self): ... # 换模板要改这里 def send_by_email(self): ... # 换邮件服务要改这里一个类因为三种完全不同的原因被修改。每次改其中一种都要读懂另外两种,还要担心碰坏它们。
先看 → 第 11 章 拆分阶段、第 21 章 搬移函数、第 17 章 提炼类。
// 加一个新的会员等级 "platinum",要改:// pricing.ts 里的折扣表// shipping.ts 里的包邮门槛// email.ts 里的称呼// ui.ts 里的徽章颜色和发散式变化相反:一个改动要碰很多处。漏掉一处,新等级的用户就会在某个页面看到错误的信息。该把这些散落的逻辑收到一起。
先看 → 第 21 章 搬移函数、第 22 章 搬移字段、第 10 章 函数组合成类、第 4 章 内联函数。
class Invoice: def total(self, customer): if customer.tier == "vip" and customer.years > 3: return self.amount * customer.vip_discount return self.amount这个方法用 customer 的数据比用 self 的多。它对别的类的内部结构了如指掌,别的类一改字段它就坏。函数应该住在它最常用的数据旁边。
先看 → 第 21 章 搬移函数、第 4 章 提炼函数。
function inRange(value: number, min: number, max: number) { /* ... */ }function clamp(value: number, min: number, max: number) { /* ... */ }function describe(min: number, max: number) { /* ... */ }min 和 max 总是手拉手出现。它们其实是一个东西,叫“区间”,只是还没有名字。给它一个类,三个函数的参数各少一个,而且“min 不能大于 max”这种规则终于有地方放了。
先看 → 第 9 章 引入参数对象、第 17 章 提炼类、第 45 章 保持对象完整。
基本类型偏执
Section titled “基本类型偏执”phone = "13800138000"# 校验、格式化、脱敏的代码散落在十几个地方if len(phone) == 11 and phone.startswith("1"): ...print(phone[:3] + "****" + phone[7:])电话号码不只是一个字符串,它有格式规则、有脱敏方式、有国家区号。用裸字符串存它,这些规则就只能复制到每一个用它的地方。
先看 → 第 15 章 以对象取代基本类型、第 52 章 以子类取代类型码、第 38 章 以多态取代条件表达式。
重复的 switch
Section titled “重复的 switch”function price(item: Item) { switch (item.kind) { case "book": /* ... */ case "food": /* ... */ } }function tax(item: Item) { switch (item.kind) { case "book": /* ... */ case "food": /* ... */ } }function label(item: Item) { switch (item.kind) { case "book": /* ... */ case "food": /* ... */ } }同一个 switch 出现在三个函数里,加一种商品要改三处。多态的作用就是把这三个 switch 收成一个类层次,新类型只加一个类。
先看 → 第 38 章 以多态取代条件表达式。
result = []for order in orders: if order.paid: result.append(order.amount * 1.1)循环本身不是错,但这四行在做的事其实是“筛选已付款,再乘 1.1”,管道写法一行就能表达,并且读者不用在心里模拟循环。
先看 → 第 27 章 以管道取代循环、第 26 章 拆分循环。
class EmailValidator { isValid(email: string): boolean { return email.includes("@"); }}一个类只有一个一行的方法,一个函数只是转发到另一个函数。它们当初可能是为了将来扩展,但将来一直没来,现在只剩下多出来的一层。
先看 → 第 4 章 内联函数、第 17 章 内联类、第 55 章 折叠继承体系。
夸夸其谈通用性
Section titled “夸夸其谈通用性”def export(data, format="csv", encoding="utf-8", compress=False, chunk_size=None, on_progress=None, retry=0): # 目前所有调用都是 export(data) ...“以后可能用到”的参数、抽象类、钩子,现在没有人用。它们让每个读者都要先弄懂这些选项是干什么的,然后发现都不用。
先看 → 第 55 章 折叠继承体系、第 4 章 内联函数、第 6 章 改变函数声明、第 28 章 移除死代码。
class Report { private tmpRows: Row[] = []; // 只在 generate() 期间有值 private tmpTotal = 0; // 同上 generate() { /* 用上面两个字段传递中间结果 */ }}这两个字段只在某个方法执行期间有意义,其他时候是空的。读这个类的人会疑惑它们是什么,要在每个方法里确认它们此时是否有效。
先看 → 第 17 章 提炼类、第 21 章 搬移函数、第 39 章 引入特例。
过长的消息链
Section titled “过长的消息链”city = order.customer.address.city.name调用方穿过四层对象才拿到一个值。address 的结构一变,这一行就坏,而且所有写了这一行的地方都坏。
先看 → 第 18 章 隐藏委托关系、第 4 章 提炼函数、第 21 章 搬移函数。
class Person { constructor(private dept: Department) {} manager() { return this.dept.manager(); } budget() { return this.dept.budget(); } location() { return this.dept.location(); } // 再加十个这样的转发}隐藏委托关系做过头了:一个类的方法全是转发。这时不如让调用方直接和 Department 打交道。
先看 → 第 18 章 移除中间人、第 4 章 内联函数、第 56 章 以委托取代超类。
class Order: def apply_coupon(self, coupon): coupon._used_count += 1 # 直接改别的类的私有字段 self.total -= coupon._value两个类互相摸对方的私有数据。改其中一个的内部实现,另一个就坏。该把它们交换的数据正式化,或者把这些代码搬到一起。
先看 → 第 21 章 搬移函数、第 22 章 搬移字段、第 18 章 隐藏委托关系、第 56 章 以委托取代子类。
class UserService { // 认证相关 12 个方法 // 资料编辑相关 9 个方法 // 通知设置相关 7 个方法 // 积分相关 11 个方法}一个类做太多事,字段多到前缀分组(authToken、authExpiry、profileName、profileAvatar……)。前缀就是在告诉你该拆成几个类。
先看 → 第 17 章 提炼类、第 54 章 提炼超类、第 52 章 以子类取代类型码。
异曲同工的类
Section titled “异曲同工的类”class CsvExporter: def write(self, rows): ...
class JsonExporter: def dump(self, rows): ... # 和 write 做的是同一件事两个类做同样的事,方法名却不同,调用方没法统一对待它们。统一接口之后,往往会发现它们该有个共同的父类,或者干脆是同一个类。
先看 → 第 6 章 改变函数声明、第 21 章 搬移函数、第 54 章 提炼超类。
class Point { x = 0; y = 0; }// 距离、平移、比较都写在用 Point 的地方function distance(a: Point, b: Point) { /* ... */ }一个类只有字段没有行为,操作它的逻辑散落在外面。这些逻辑通常该搬进来。
先看 → 第 13 章 封装记录、第 21 章 搬移函数、第 47 章 移除设值函数。
被拒绝的遗赠
Section titled “被拒绝的遗赠”class Bird: def fly(self): ...
class Penguin(Bird): def fly(self): raise NotImplementedError("企鹅不会飞")子类继承了父类的方法却不想要。继承关系在说谎:企鹅不是“会飞的鸟”的一种。该把不该共享的部分下移,或者干脆不用继承。
先看 → 第 51 章 函数下移、第 56 章 以委托取代超类。
// 先检查库存够不够,不够就报错if (stock.get(item.id) < qty) throw new Error("库存不足");// 然后扣库存stock.set(item.id, stock.get(item.id) - qty);注释本身不是坏味道,但当注释在解释“这段代码在干什么”时,它通常是在替糟糕的命名或过长的函数打补丁。把那段代码提成一个叫 ensure_in_stock 的函数,注释就不需要了。保留那些解释“为什么这么做”的注释。
先看 → 第 4 章 提炼函数、第 6 章 改变函数声明、第 40 章 引入断言。
| 坏味道 | 一句话 | 常用手法 |
|---|---|---|
| 神秘命名 | 名字没说清楚是什么 | 改变函数声明、变量改名、字段改名 |
| 重复代码 | 同样的逻辑写了两遍 | 提炼函数、函数上移 |
| 过长函数 | 靠注释分段的函数 | 提炼函数、分解条件表达式、卫语句 |
| 过长参数列表 | 调用处要数位置 | 引入参数对象、保持对象完整、移除标记参数 |
| 全局数据 | 谁都能改的变量 | 封装变量 |
| 可变数据 | 值在悄悄变化 | 封装变量、拆分变量、查询与修改分离 |
| 发散式变化 | 一个类因多种原因被改 | 拆分阶段、搬移函数、提炼类 |
| 霰弹式修改 | 一个改动碰很多处 | 搬移函数、搬移字段、函数组合成类 |
| 依恋情结 | 函数用别人的数据比自己的多 | 搬移函数 |
| 数据泥团 | 总是一起出现的几个值 | 引入参数对象、提炼类 |
| 基本类型偏执 | 用字符串数字存带规则的值 | 以对象取代基本类型 |
| 重复的 switch | 同一个分支出现在多处 | 以多态取代条件表达式 |
| 循环 | 在做筛选和转换的循环 | 以管道取代循环、拆分循环 |
| 冗赘的元素 | 没有分量的类或函数 | 内联函数、内联类、折叠继承体系 |
| 夸夸其谈通用性 | 为将来准备的、没人用的东西 | 移除死代码、折叠继承体系 |
| 临时字段 | 只在某段时间有效的字段 | 提炼类、引入特例 |
| 过长的消息链 | a.b.c.d |
隐藏委托关系 |
| 中间人 | 全是转发的类 | 移除中间人 |
| 内幕交易 | 两个类互摸私有数据 | 搬移函数、搬移字段 |
| 过大的类 | 字段靠前缀分组 | 提炼类、提炼超类 |
| 异曲同工的类 | 做同样事但接口不同 | 改变函数声明、提炼超类 |
| 纯数据类 | 只有字段没有行为 | 封装记录、搬移函数 |
| 被拒绝的遗赠 | 子类不想要父类的东西 | 函数下移、以委托取代超类 |
| 注释 | 解释“在干什么”的注释 | 提炼函数、改变函数声明 |