跳转到内容

第 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 章 以命令取代函数。

function createUser(name: string, email: string, street: string, city: string,
zip: string, country: string, newsletter: boolean) { /* ... */ }

调用这个函数要数着位置填七个参数,city 和 street 传反了类型检查也不报错。参数越多,每个调用点越像一行密码。

先看 → 第 9 章 引入参数对象、第 45 章 保持对象完整、第 46 章 以查询取代参数、第 44 章 移除标记参数。

config.py
current_user = None
# 任何模块都可以
import config
config.current_user = someone_else

全局变量谁都能改,出了问题你不知道是谁在哪一行改的。测试也难写:一个测试改了它,下一个测试就莫名其妙地失败。

先看 → 第 8 章 封装变量。

const settings = { theme: "light" };
applyUserPrefs(settings); // 里面悄悄改了 settings.theme
render(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 章 保持对象完整。

phone = "13800138000"
# 校验、格式化、脱敏的代码散落在十几个地方
if len(phone) == 11 and phone.startswith("1"): ...
print(phone[:3] + "****" + phone[7:])

电话号码不只是一个字符串,它有格式规则、有脱敏方式、有国家区号。用裸字符串存它,这些规则就只能复制到每一个用它的地方。

先看 → 第 15 章 以对象取代基本类型、第 52 章 以子类取代类型码、第 38 章 以多态取代条件表达式。

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 章 折叠继承体系。

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 章 引入特例。

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 章 以子类取代类型码。

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 章 移除设值函数。

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 隐藏委托关系
中间人 全是转发的类 移除中间人
内幕交易 两个类互摸私有数据 搬移函数、搬移字段
过大的类 字段靠前缀分组 提炼类、提炼超类
异曲同工的类 做同样事但接口不同 改变函数声明、提炼超类
纯数据类 只有字段没有行为 封装记录、搬移函数
被拒绝的遗赠 子类不想要父类的东西 函数下移、以委托取代超类
注释 解释“在干什么”的注释 提炼函数、改变函数声明