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个,清晰多了。而且 CustomerBookingPeriod 可以在其他地方复用。

我的经验:拆函数的时候,不要追求一步到位。先拆出最明显的那块逻辑,跑通测试,再拆下一块。每次只做一个小改动,风险最小。

分解的节奏:小步快跑

我见过很多人重构的时候,上来就大刀阔斧地改,结果改到一半发现跑不通了,又回退。这样很打击信心。

我的建议是:每次只提取一个方法,然后立刻编译、测试。确保没问题了,再提取下一个。

你想想看,如果一次提取错了,你只需要回退一步。如果一次改了10个地方,回退都不知道从哪里开始。

什么时候不该拆?

嗯,这里要注意,不是所有长函数都需要拆。我遇到过一些情况,拆了反而更糟:

  • 性能敏感的热点路径:比如一个被调用百万次的循环内部,拆成多个方法会增加调用开销。这时候可以保留内联,但一定要加好注释。
  • 逻辑高度内聚的算法:比如一个加密算法,虽然长,但每一步都紧密依赖前一步的结果。强行拆开反而让代码难以理解。
  • 临时性的脚本代码:比如一次性的数据迁移脚本,用完就扔。这种没必要花时间拆。
避坑指南:我曾经在一个性能敏感模块里强行拆函数,结果上线后接口响应时间增加了30%。后来用 JProfiler 一查,发现就是方法调用次数太多导致的。所以,拆之前先问问自己:这段代码会被调用多少次?

知识体系一览

下面这张图总结了过长函数分解的核心逻辑,你可以把它当作一个检查清单:

过长函数分解知识体系 过长函数 核心原则:单一职责 提取方法 Extract Method 替换临时变量 Replace Temp 分解条件 Decompose Cond 多态取代条件 Replace Cond 参数对象 Param Object 注意事项:性能热点 / 高内聚算法 / 临时脚本 分解节奏:小步快跑,每次只拆一个

写在最后

过长函数的分解,说白了就是「把大问题拆成小问题」。这个道理谁都懂,但真正做的时候,很多人会犹豫:「拆这么多方法,会不会太碎了?」

我的回答是:宁可碎,不要长。一个200行的函数,和10个20行的函数,后者的可读性、可测试性、可维护性都远胜前者。而且现代JIT编译器对短方法的优化效果往往更好,性能损失几乎可以忽略不计。

好了,拿起你的代码,找到那个最长的函数,从提取第一个方法开始吧。

公众号:蓝海资料掘金营,微信deep3321