11、搬移特性(下):搬移语句到函数、以函数调用取代内联代码、拆分循环、以管道取代循环

好,咱们接着聊搬移特性。上一章我们讲了搬移函数和搬移字段,这次要聊的四个手法,说白了都是「把代码挪到更合适的地方去」。只不过这次挪的不是整个函数或字段,而是更细粒度的东西——语句、循环、还有那些散落各处的内联代码。

我在项目中见过太多这样的场景:一段逻辑明明可以复用,却因为「当时赶时间」被硬塞在某个函数里;一个循环里塞了三件不相干的事,改一个需求就得把整个循环重读一遍。嗯,这些痛点,今天的手法都能解决。

搬移语句到函数

这个手法特别简单,但特别实用。你想想看,有时候我们调用一个函数,调用方总要在调用前后加几行固定的代码。比如:

// 调用方代码
let result = calculatePrice(order);
applyDiscount(result, order.customerLevel);
logPriceCalculation(order, result);

每次调用 calculatePrice 都要跟着 applyDiscountlogPriceCalculation。这明显不合理——这些「配套动作」应该属于函数本身。

搬移之后:

function calculatePrice(order) {
    let result = basePrice(order);
    applyDiscount(result, order.customerLevel);
    logPriceCalculation(order, result);
    return result;
}

// 调用方就干净了
let result = calculatePrice(order);

核心原则:如果调用某个函数时,总要在它前后做同样的几件事,就把这些事搬进函数里。别让调用方替函数「擦屁股」。

我个人习惯是,先观察调用点是否超过两处。如果只有一处,我可能会犹豫一下——但有时候即使只有一处,搬进去也能让代码意图更清晰。比如那段「前后处理」本身就是函数职责的一部分,那就搬。

以函数调用取代内联代码

这个手法,说白了就是「别重复造轮子」。我在项目中遇到过这样的代码:

let hasHighPriority = false;
if (orders.length > 0) {
    for (let order of orders) {
        if (order.priority === 'high' || order.priority === 'urgent') {
            hasHighPriority = true;
            break;
        }
    }
}

这段逻辑其实就是在问「有没有高优先级订单」。但写得太啰嗦了。更糟糕的是,如果项目里还有别的地方也要判断「高优先级」,大家各写各的,风格还不一样。

改成函数调用:

function hasHighPriorityOrder(orders) {
    return orders.some(o => o.priority === 'high' || o.priority === 'urgent');
}

// 调用方
let hasHighPriority = hasHighPriorityOrder(orders);

小技巧:当你看到一段内联代码,先问自己「这段代码在做什么?」。如果能用一个简短的名字概括它的意图,那就应该把它提取成函数。名字就是最好的文档。

我曾经在一个遗留系统里看到一段 30 行的内联代码,用来计算「用户是否在活跃期内」。那段代码被复制粘贴了 7 次,每次还略有不同。我把它提取成一个函数后,不仅代码量减半,还发现其中两个复制版有 bug——因为粘贴时改漏了一个变量名。嗯,这就是内联代码的典型危害。

拆分循环

这个手法,我特别想多说两句。很多程序员喜欢在一个循环里做所有事,觉得这样「性能好」。但代价是什么?代码可读性急剧下降。

看个例子:

let youngest = people[0] ? people[0].age : Infinity;
let totalSalary = 0;
for (const p of people) {
    totalSalary += p.salary;
    if (p.age < youngest) youngest = p.age;
}

这个循环同时做了两件事:计算总薪资,找最年轻的人。每次改需求,你都得重新理解这个循环。而且如果将来要改「找最年轻的人」的逻辑,你还得小心别碰坏「计算总薪资」的部分。

拆开之后:

let totalSalary = 0;
for (const p of people) {
    totalSalary += p.salary;
}

let youngest = people[0] ? people[0].age : Infinity;
for (const p of people) {
    if (p.age < youngest) youngest = p.age;
}

注意:拆分成两个循环后,代码确实变长了,但每个循环的职责单一了。你可能会担心性能问题——说实话,除非你的数据量达到百万级,否则两个循环和单个循环的差异可以忽略不计。而代码可读性的提升是实打实的。

我个人习惯是:先拆,等性能分析真的证明这里有瓶颈,再考虑合并。但说实话,我工作十几年,因为拆分循环导致性能问题的场景,一只手数得过来。

以管道取代循环

这个手法,是我最喜欢的之一。循环本质上是在描述「怎么做」,而管道(比如 mapfilterreduce)是在描述「要什么」。后者更声明式,更容易理解。

看个例子:

// 循环版本
const names = [];
for (const person of people) {
    if (person.age > 18) {
        names.push(person.name);
    }
}
// 管道版本
const names = people
    .filter(p => p.age > 18)
    .map(p => p.name);

管道版本读起来就像在说:「从 people 里过滤出成年的人,然后取出他们的名字」。意图一目了然。

再复杂一点的例子:

// 循环版本
let totalSalary = 0;
for (const department of company.departments) {
    for (const employee of department.employees) {
        if (employee.active) {
            totalSalary += employee.salary;
        }
    }
}
// 管道版本
const totalSalary = company.departments
    .flatMap(d => d.employees)
    .filter(e => e.active)
    .reduce((sum, e) => sum + e.salary, 0);

核心优势:管道操作让数据处理流程变得线性、可组合。每个步骤独立,方便调试、测试和复用。而且,当你需要调整处理逻辑时,只需要增删某个管道步骤,不用动整个循环结构。

我曾经在一个数据分析模块里,把 5 个嵌套循环全部改成了管道链。改完之后,代码从 80 行缩到了 20 行,而且每个步骤都可以单独加日志、单独测试。那感觉,就像把一团乱麻理成了一根顺滑的绳子。

不过也要注意:管道不是万能的。如果循环里有 breakcontinue,或者需要同时访问前后元素,管道可能就不太合适了。这时候别硬套,该用循环还是用循环。

知识体系总览

下面这张图,把这四个手法的核心逻辑串起来了:

搬移特性(下)核心手法 搬移语句到函数 消除重复的配套代码 函数调用取代内联 提取意图,消除重复 拆分循环 单一职责,可读优先 管道取代循环 声明式数据处理 共同目标:提升代码可读性与可维护性 手法对比 手法 适用场景 核心收益 搬移语句到函数 调用前后总有固定配套代码 消除重复,封装职责 函数调用取代内联 内联代码有明确意图 命名即文档,消除复制 拆分循环 / 管道取代 循环内做多件事 单一职责,声明式表达

这四个手法,本质上都是在做同一件事:让代码的归属更合理,让意图更清晰。搬移语句到函数,是把「配套动作」归位;函数调用取代内联,是把「重复逻辑」归位;拆分循环,是把「不同职责」归位;管道取代循环,是把「数据处理步骤」归位。

嗯,说起来简单,做起来需要一点「洁癖」。但相信我,当你习惯了这种思考方式,再看那些又长又乱的代码,你会忍不住想重构的。

我的建议:下次你 review 代码时,可以专门留意一下循环和重复的内联代码。问自己三个问题:

  • 这个循环里做了几件事?能不能拆?
  • 这段内联逻辑在其他地方出现过吗?能不能提取成函数?
  • 这个循环能不能用管道写得更清晰?

养成这个习惯,你的代码会越来越干净。

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