6. 函数重构(下):引入参数对象、保持对象完整、以查询取代参数

好,咱们接着聊函数重构的下半部分。上一节我们讲了怎么拆函数、怎么给参数改名,那都是基础功夫。这一节要聊的三个手法,说实话,是我在实际项目中用得最多的。尤其是当你面对一个函数,参数列表长得像购物清单一样的时候——嗯,这时候就该动手了。

6.1 引入参数对象

先说说「引入参数对象」。这名字听着有点绕,其实说白了就是:把一群经常一起出现的参数,打包成一个结构体或者对象

我在项目中遇到过这样一个场景:有个函数负责计算订单的运费,参数有发货城市、收货城市、货物重量、运输方式、是否加急……一共七八个参数。每次调用都要写一大串,而且调用方自己都搞不清顺序。

你想想看,这些参数里,「发货城市」和「收货城市」是不是经常一起出现?「货物重量」和「运输方式」是不是也经常绑定?这就是信号——它们应该被封装起来。

重构前:

// 参数太多,调用方容易搞混
double calculateFreight(const char* fromCity, const char* toCity,
                        double weight, int transportType,
                        int isUrgent, int insuranceLevel);

重构后:

// 把相关参数打包成结构体
typedef struct {
    const char* fromCity;
    const char* toCity;
    double weight;
    int transportType;
} FreightInfo;

typedef struct {
    int isUrgent;
    int insuranceLevel;
} ServiceOption;

double calculateFreight(const FreightInfo* info,
                        const ServiceOption* option);

我个人习惯是:一旦发现某个函数有超过3个参数,而且其中几个参数总是成对出现,我就会考虑引入参数对象。这不是死规矩,但确实能帮你少踩很多坑。

小技巧:引入参数对象后,后续如果要加新参数,只需要在结构体里加字段就行,函数签名不用改。这对维护老代码特别友好。

6.2 保持对象完整

这个手法跟上一个正好相反。引入参数对象是把散落的参数打包,而「保持对象完整」是——如果调用方已经有一个对象了,就别拆开传,直接把整个对象传进去

为什么会这样?我见过不少代码,明明调用方手里有一个完整的结构体,却非要拆成一个个字段传进去。比如:

// 调用方已经有一个 Customer 对象
Customer cust = getCustomerById(101);

// 却要拆开传
double discount = calcDiscount(cust.age, cust.memberLevel, cust.totalSpent);

这有什么问题?问题大了。首先,调用方得知道 calcDiscount 需要哪些字段,耦合度变高了。其次,如果 calcDiscount 以后需要用到 Customer 的其他字段,所有调用方都得改。

重构后:

double discount = calcDiscount(&cust);

// 函数内部按需取字段
double calcDiscount(const Customer* cust) {
    double base = 0.0;
    if (cust->memberLevel >= 3) {
        base = cust->totalSpent * 0.15;
    }
    return base;
}

我曾经在一个支付系统里吃过这个亏。当时有个函数叫 processPayment,参数是 userIdamountcurrencypayMethod……后来需求变更,要加一个 discountCode,结果所有调用处都得改。如果当初传的是整个 Order 对象,加个字段就完事了。

注意:保持对象完整不是让你无脑传整个对象。如果函数只需要对象里的一个字段,那传字段就够了。这个手法的核心是:当函数需要多个字段,而且这些字段天然属于同一个对象时,就传对象

6.3 以查询取代参数

最后一个手法,「以查询取代参数」。这个稍微有点反直觉——我们通常觉得参数越多越灵活,但有时候,参数其实是多余的

举个例子:

// 调用方每次都要传一个自己能算出来的值
double calculateTax(double income, double taxRate) {
    return income * taxRate;
}

// 调用
double tax = calculateTax(50000, 0.15);

这里的 taxRate 参数,其实调用方根本不需要传——因为税率是固定的,或者可以从其他地方查出来。那为什么不直接在函数内部查呢?

double calculateTax(double income) {
    double taxRate = getCurrentTaxRate();  // 内部查询
    return income * taxRate;
}

你看,参数少了一个,调用方也轻松了。这就是「以查询取代参数」——如果函数能自己算出来某个参数的值,就别让调用方传

判断标准:

  • 这个参数的值,调用方是不是从某个全局变量、配置文件、或者另一个函数调用得来的?
  • 如果是,那这个参数就可以被「查询」取代。

我记得有一次重构一个日志模块,有个函数叫 logMessage,参数里有个 logLevel。但实际调用的时候,90% 的调用方传的都是 LOG_INFO。我就把 logLevel 改成默认值,只在需要特殊级别时才显式传。嗯,代码清爽多了。

6.4 三种手法的关系

这三种手法其实是一条思路的不同侧面:

  • 引入参数对象:把散落的参数打包,减少参数个数
  • 保持对象完整:别拆开传,直接传整个对象
  • 以查询取代参数:能算出来的参数就别传

它们的目标都一样:让函数的接口更简洁,让调用方更省心

下面这张图能帮你理清它们的关系:

函数参数重构三大手法 函数参数过多 参数列表冗长、调用复杂 引入参数对象 把成对出现的参数打包 减少参数个数,提高内聚性 保持对象完整 直接传整个对象 避免拆字段,降低耦合 以查询取代参数 函数内部自行查询 能算出来的参数就别传 共同目标 让函数接口更简洁,调用方更省心,维护更容易

6.5 实战建议

这三种手法不是孤立的,你可以组合使用。比如:

  1. 先用「引入参数对象」把散落的参数打包
  2. 再用「保持对象完整」确保调用方不拆开传
  3. 最后用「以查询取代参数」去掉那些多余的参数

我个人的经验是:别一次性全改完。先挑一个最明显的参数组下手,改完跑一遍测试,确认没问题再继续。重构最忌讳的就是「大跃进」——改得越多,出错的概率越大。

避坑指南:我曾经在一个项目里,一口气把五个函数的参数都改成了结构体。结果有个调用处忘了更新,编译过了但运行时传了空指针……嗯,从那以后我每次重构都只改一个函数,改完就编译+测试。

好了,这一节的内容就到这里。记住:参数是函数的接口,接口越干净,代码越容易维护。下一节我们会聊另一个重要的重构方向——如何用多态取代条件表达式。到时候见。


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