函数设计(上):短小、只做一件事、每个函数一个抽象层级

好,咱们今天聊函数设计。

说实话,我见过太多代码,一个函数能写两三百行。你想想看,这种函数谁敢动?改一行代码,心里都得哆嗦半天。我自己刚入行那会儿也干过这种事——一个函数里塞了登录验证、数据查询、格式转换、日志记录,甚至还顺带发了封邮件。结果呢?后来需求一变,我盯着那个函数看了整整一下午,愣是不敢下手。

所以今天咱们先聊三个最核心的原则:短小、只做一件事、每个函数一个抽象层级。这三个原则,说白了就是函数设计的基石。

1. 短小:函数该有多短?

我个人习惯,一个函数尽量控制在 20 行以内。超过 30 行,我就开始警觉了。

你可能会问:20 行够用吗?我告诉你,够。而且往往更清晰。

核心观点:函数越短,越容易理解,越容易测试,越容易复用。

来看个例子。这是我在一个老项目中看到的代码:

public void processOrder(Order order) {
    // 验证订单
    if (order == null) {
        throw new IllegalArgumentException("订单不能为空");
    }
    if (order.getItems() == null || order.getItems().isEmpty()) {
        throw new IllegalArgumentException("订单项不能为空");
    }
    for (Item item : order.getItems()) {
        if (item.getPrice() <= 0) {
            throw new IllegalArgumentException("价格不合法");
        }
    }
    
    // 计算总价
    double total = 0;
    for (Item item : order.getItems()) {
        total += item.getPrice() * item.getQuantity();
    }
    
    // 应用折扣
    if (total > 1000) {
        total = total * 0.9;
    }
    
    // 保存订单
    saveToDatabase(order, total);
    
    // 发送通知
    sendEmail(order.getCustomerEmail(), "订单已处理");
}

这个函数干了多少事?验证、计算、折扣、保存、发邮件。五个职责挤在一起。我建议拆成这样:

public void processOrder(Order order) {
    validateOrder(order);
    double total = calculateTotal(order);
    double finalTotal = applyDiscount(total);
    saveOrder(order, finalTotal);
    notifyCustomer(order);
}

private void validateOrder(Order order) {
    if (order == null) throw new IllegalArgumentException("订单不能为空");
    if (order.getItems().isEmpty()) throw new IllegalArgumentException("订单项不能为空");
    for (Item item : order.getItems()) {
        if (item.getPrice() <= 0) throw new IllegalArgumentException("价格不合法");
    }
}

private double calculateTotal(Order order) {
    double total = 0;
    for (Item item : order.getItems()) {
        total += item.getPrice() * item.getQuantity();
    }
    return total;
}

private double applyDiscount(double total) {
    return total > 1000 ? total * 0.9 : total;
}

private void saveOrder(Order order, double total) {
    // 保存逻辑
}

private void notifyCustomer(Order order) {
    // 通知逻辑
}

每个函数都短小精悍。你一眼就能看出它在干什么。测试也好写——每个函数单独测,不用再担心牵一发动全身。

小技巧:写函数的时候,试着用一句话描述它的功能。如果这句话里出现了「并且」、「然后」、「同时」这些词,说明它该拆了。

2. 只做一件事:怎么判断「一件事」?

这个原则听起来简单,做起来其实挺难的。什么叫「一件事」?

我有个判断方法:如果这个函数还能再拆出一个有意义的函数,那它就不止做了一件事。

举个例子。下面这个函数,你觉得它做了几件事?

public void renderPage(PageData pageData) {
    String html = pageData.getHtmlContent();
    html = html.replace("{{title}}", pageData.getTitle());
    html = html.replace("{{body}}", pageData.getBody());
    
    if (pageData.isLoggedIn()) {
        html = html.replace("{{login}}", "欢迎回来," + pageData.getUserName());
    } else {
        html = html.replace("{{login}}", "<a href='/login'>登录</a>");
    }
    
    response.write(html);
}

表面上看,它只做了一件事:渲染页面。但仔细看,它其实包含了模板替换、登录状态判断、HTML 生成。这些完全可以拆开。

我建议这样:

public void renderPage(PageData pageData) {
    String html = pageData.getHtmlContent();
    html = replaceTemplateVariables(html, pageData);
    html = renderLoginSection(html, pageData);
    response.write(html);
}

private String replaceTemplateVariables(String html, PageData pageData) {
    html = html.replace("{{title}}", pageData.getTitle());
    html = html.replace("{{body}}", pageData.getBody());
    return html;
}

private String renderLoginSection(String html, PageData pageData) {
    String loginHtml = pageData.isLoggedIn() 
        ? "欢迎回来," + pageData.getUserName()
        : "<a href='/login'>登录</a>";
    return html.replace("{{login}}", loginHtml);
}

你看,每个函数只关心自己的事。renderLoginSection 只负责生成登录区域的 HTML,它不需要知道模板里还有 title 和 body。这就是「只做一件事」。

注意:「一件事」不是指一行代码。一个函数里可以有多个步骤,只要这些步骤都在同一个抽象层级上,并且共同完成一个目标,那就算「一件事」。

3. 每个函数一个抽象层级

这个原则,我是在重构一个支付系统时才真正理解的。

当时有个函数,里面既有高层逻辑(比如「验证支付信息」),又有底层细节(比如「把字符串转成 BigDecimal」)。读起来特别累,因为你得在高层和底层之间来回切换思维。

抽象层级,说白了就是:一个函数里的代码,应该处于同一个「距离细节的远近」上。

来看个例子:

// 不好的写法:抽象层级混乱
public void processPayment(Payment payment) {
    // 高层:验证支付
    if (payment.getAmount() <= 0) {
        throw new IllegalArgumentException("金额必须大于0");
    }
    
    // 底层:字符串处理
    String amountStr = String.format("%.2f", payment.getAmount());
    String maskedCard = payment.getCardNumber().replaceAll("\\d(?=\\d{4})", "*");
    
    // 高层:调用支付网关
    PaymentGateway gateway = new PaymentGateway();
    gateway.charge(payment);
    
    // 底层:日志格式
    System.out.println("支付完成:" + maskedCard + " 金额:" + amountStr);
}

这个函数里,验证支付是高层,字符串格式化是底层,调用支付网关又是高层,打印日志又是底层。读起来像在坐过山车。

正确的做法是:

public void processPayment(Payment payment) {
    validatePayment(payment);
    chargePayment(payment);
    logPayment(payment);
}

private void validatePayment(Payment payment) {
    if (payment.getAmount() <= 0) {
        throw new IllegalArgumentException("金额必须大于0");
    }
}

private void chargePayment(Payment payment) {
    PaymentGateway gateway = new PaymentGateway();
    gateway.charge(payment);
}

private void logPayment(Payment payment) {
    String amountStr = String.format("%.2f", payment.getAmount());
    String maskedCard = payment.getCardNumber().replaceAll("\\d(?=\\d{4})", "*");
    System.out.println("支付完成:" + maskedCard + " 金额:" + amountStr);
}

现在,processPayment 只包含高层逻辑。底层细节被隐藏到了各自的函数里。你读 processPayment 的时候,只需要关心「验证、支付、记录」这三个步骤,不用去想字符串怎么格式化、卡号怎么脱敏。

判断方法:读一个函数时,如果某行代码让你想「这行代码在干什么?」,那它很可能处于不同的抽象层级。

知识体系总览

下面这张图,把今天讲的三个原则串起来了。你可以看到它们之间的关系:

函数设计三大原则 高质量函数 原则一:短小 20行以内,一眼看懂 原则二:只做一件事 不能再拆出有意义的函数 原则三:一个抽象层级 代码处于同一细节层次 易于理解 易于测试 易于复用 三者相辅相成,共同构建可维护的代码

避坑指南

聊了这么多原则,我再分享几个实际中容易踩的坑:

  • 过度拆分:我曾经见过有人把一行代码也拆成一个函数。没必要。拆分的粒度应该是「有意义的逻辑单元」,不是「每行一个函数」。
  • 命名掩盖复杂:函数名叫 processAndValidateAndSave,一看就知道它做了三件事。别用这种名字来掩盖函数过长的本质。
  • 抽象层级跳跃:我见过一个函数,前半部分在拼 SQL,后半部分在渲染 HTML。这种代码读起来特别累,因为你的思维要在数据库和前端之间来回切换。

我的习惯:写函数的时候,先写注释,再写代码。注释写的是「这个函数要做什么」,代码写的是「怎么做」。如果注释里出现了「并且」,那就拆。

嗯,今天咱们聊了三个原则。短小让你一眼看懂,只做一件事让你不用猜,一个抽象层级让你不用来回切换思维。这三个原则,说白了就是让读代码的人舒服。你想想看,代码写出来是给人读的,顺便让机器执行一下。所以,让读代码的人舒服,才是真正的目标。

下次写函数的时候,不妨试试:写完之后,数一数行数,看看能不能再拆,检查一下抽象层级是否一致。养成习惯之后,你会发现代码质量提升得特别快。


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