41、代码坏味道:过长函数 - 实战分解技巧
说实话,过长函数是我在代码评审里看到最多的坏味道,没有之一。
我见过一个函数写了800行,里面塞了三个业务逻辑、两个异常处理、一段SQL拼接,还顺带做了个日志格式化。你问我怎么知道的?因为我就是那个接手的人。嗯,当时的心情,怎么说呢,就像打开一个塞满杂物的衣柜,想找件衬衫,结果掉出来一堆袜子。
今天我们就来聊聊,怎么把这种「大泥球」函数拆干净。
为什么函数会变长?
我总结了一下,原因无非这么几个:
- 需求叠加:一开始只是一个简单的查询,后来加了过滤、排序、分页、权限校验……全塞在一个函数里。
- 怕麻烦:觉得抽一个新函数还要传参数、起名字,不如直接往下写省事。
- 缺乏抽象意识:没意识到「这段逻辑其实可以独立出来」。说白了,就是缺少拆分的习惯。
但你想过没有?一个函数超过20行,阅读成本就开始指数级上升。超过50行,基本没人愿意细看。超过100行……嗯,我建议你直接重构,别犹豫。
分解的核心原则:单一职责
我个人习惯用一个很简单的判断标准:一个函数只做一件事。如果这个函数的名字里出现了「和」字,比如「校验参数并保存订单」,那基本就超标了。
举个例子,假设我们有这么一段代码:
public void processOrder(Order order) {
// 1. 校验订单
if (order.getAmount() <= 0) {
throw new IllegalArgumentException("金额无效");
}
if (order.getItems().isEmpty()) {
throw new IllegalArgumentException("订单项为空");
}
// 2. 计算折扣
double discount = 0;
if (order.getAmount() > 1000) {
discount = 0.1;
} else if (order.getAmount() > 500) {
discount = 0.05;
}
// 3. 保存订单
double finalAmount = order.getAmount() * (1 - discount);
order.setFinalAmount(finalAmount);
orderDao.save(order);
// 4. 发送通知
emailService.sendOrderConfirmation(order);
}
这个函数做了四件事。一眼看过去,你很难快速定位问题。如果保存订单失败了,是校验的问题?还是折扣计算的问题?还是DAO层的问题?
我们把它拆开:
public void processOrder(Order order) {
validateOrder(order);
double discount = calculateDiscount(order);
saveOrder(order, discount);
sendNotification(order);
}
private void validateOrder(Order order) {
if (order.getAmount() <= 0) {
throw new IllegalArgumentException("金额无效");
}
if (order.getItems().isEmpty()) {
throw new IllegalArgumentException("订单项为空");
}
}
private double calculateDiscount(Order order) {
if (order.getAmount() > 1000) return 0.1;
if (order.getAmount() > 500) return 0.05;
return 0;
}
private void saveOrder(Order order, double discount) {
double finalAmount = order.getAmount() * (1 - discount);
order.setFinalAmount(finalAmount);
orderDao.save(order);
}
private void sendNotification(Order order) {
emailService.sendOrderConfirmation(order);
}
你看,现在每个函数的名字就是它的注释。阅读的时候,你只需要看 processOrder 里的四行调用,就知道整个流程。想深入看某个环节,再点进去。这就是「自文档化」的效果。
实战分解技巧:五个实用方法
光说原则不够,我给你几个可以直接上手的技巧。这些是我在项目中反复用过的,很管用。
1. 提取方法(Extract Method)
这是最基础、最常用的手法。看到一段逻辑相对独立的代码,选中它,右键 -> Extract Method。就这么简单。
但要注意:提取出来的方法参数不要太多。我见过有人提取出一个方法,传了7个参数。那还不如不拆。参数超过3个,你就该考虑是不是需要引入一个参数对象了。
2. 替换临时变量(Replace Temp with Query)
有时候函数里有很多临时变量,比如:
double basePrice = quantity * itemPrice;
double discount = basePrice * 0.1;
double finalPrice = basePrice - discount;
这些临时变量其实可以变成独立的方法:
double getBasePrice() {
return quantity * itemPrice;
}
double getDiscount() {
return getBasePrice() * 0.1;
}
double getFinalPrice() {
return getBasePrice() - getDiscount();
}
这样做的好处是:逻辑被「命名」了。以后改折扣规则,你只需要改 getDiscount 一个地方。
3. 分解条件表达式(Decompose Conditional)
条件逻辑是函数变长的重灾区。我见过一个 if-else 嵌套了五层,看得人头皮发麻。
我的做法是:把每个分支提取成独立的方法。
// 重构前
if (date.before(SUMMER_START) || date.after(SUMMER_END)) {
charge = quantity * winterRate + winterServiceCharge;
} else {
charge = quantity * summerRate;
}
// 重构后
if (isWinter(date)) {
charge = winterCharge(quantity);
} else {
charge = summerCharge(quantity);
}
你看,条件本身变成了一个方法调用,每个分支也变成了方法。代码读起来就像在讲故事。
4. 以多态取代条件(Replace Conditional with Polymorphism)
这个技巧稍微高级一点,但对付那种「根据类型做不同处理」的长函数特别有效。
我曾经接手过一个订单处理系统,里面有一个 calculatePrice 函数,根据订单类型(普通、会员、VIP、企业)做了四种不同的价格计算。整个函数200多行,全是 switch-case。
我的方案是:把每种类型的计算逻辑放到各自的子类中。
// 重构前
public double calculatePrice(Order order) {
switch (order.getType()) {
case NORMAL: return order.getAmount();
case MEMBER: return order.getAmount() * 0.9;
case VIP: return order.getAmount() * 0.8;
case ENTERPRISE: return order.getAmount() * 0.7;
}
}
// 重构后
// 每个子类实现自己的 calculatePrice
class NormalOrder extends Order {
public double calculatePrice() { return getAmount(); }
}
class MemberOrder extends Order {
public double calculatePrice() { return getAmount() * 0.9; }
}
// ... 以此类推
这样,以后新增一种订单类型,你只需要新增一个子类,不用改任何现有代码。开闭原则,就这么实现了。
5. 引入参数对象(Introduce Parameter Object)
当一个函数需要传递多个相关参数时,把它们封装成一个对象。
// 重构前
public void bookRoom(String customerName, String phone, String email,
Date checkIn, Date checkOut, int roomType) { ... }
// 重构后
public void bookRoom(Customer customer, BookingPeriod period, int roomType) { ... }
参数从6个变成了3个,清晰多了。而且 Customer 和 BookingPeriod 可以在其他地方复用。
分解的节奏:小步快跑
我见过很多人重构的时候,上来就大刀阔斧地改,结果改到一半发现跑不通了,又回退。这样很打击信心。
我的建议是:每次只提取一个方法,然后立刻编译、测试。确保没问题了,再提取下一个。
你想想看,如果一次提取错了,你只需要回退一步。如果一次改了10个地方,回退都不知道从哪里开始。
什么时候不该拆?
嗯,这里要注意,不是所有长函数都需要拆。我遇到过一些情况,拆了反而更糟:
- 性能敏感的热点路径:比如一个被调用百万次的循环内部,拆成多个方法会增加调用开销。这时候可以保留内联,但一定要加好注释。
- 逻辑高度内聚的算法:比如一个加密算法,虽然长,但每一步都紧密依赖前一步的结果。强行拆开反而让代码难以理解。
- 临时性的脚本代码:比如一次性的数据迁移脚本,用完就扔。这种没必要花时间拆。
知识体系一览
下面这张图总结了过长函数分解的核心逻辑,你可以把它当作一个检查清单:
写在最后
过长函数的分解,说白了就是「把大问题拆成小问题」。这个道理谁都懂,但真正做的时候,很多人会犹豫:「拆这么多方法,会不会太碎了?」
我的回答是:宁可碎,不要长。一个200行的函数,和10个20行的函数,后者的可读性、可测试性、可维护性都远胜前者。而且现代JIT编译器对短方法的优化效果往往更好,性能损失几乎可以忽略不计。
好了,拿起你的代码,找到那个最长的函数,从提取第一个方法开始吧。