11、搬移特性(下):搬移语句到函数、以函数调用取代内联代码、拆分循环、以管道取代循环
好,咱们接着聊搬移特性。上一章我们讲了搬移函数和搬移字段,这次要聊的四个手法,说白了都是「把代码挪到更合适的地方去」。只不过这次挪的不是整个函数或字段,而是更细粒度的东西——语句、循环、还有那些散落各处的内联代码。
我在项目中见过太多这样的场景:一段逻辑明明可以复用,却因为「当时赶时间」被硬塞在某个函数里;一个循环里塞了三件不相干的事,改一个需求就得把整个循环重读一遍。嗯,这些痛点,今天的手法都能解决。
搬移语句到函数
这个手法特别简单,但特别实用。你想想看,有时候我们调用一个函数,调用方总要在调用前后加几行固定的代码。比如:
// 调用方代码
let result = calculatePrice(order);
applyDiscount(result, order.customerLevel);
logPriceCalculation(order, result);
每次调用 calculatePrice 都要跟着 applyDiscount 和 logPriceCalculation。这明显不合理——这些「配套动作」应该属于函数本身。
搬移之后:
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;
}
注意:拆分成两个循环后,代码确实变长了,但每个循环的职责单一了。你可能会担心性能问题——说实话,除非你的数据量达到百万级,否则两个循环和单个循环的差异可以忽略不计。而代码可读性的提升是实打实的。
我个人习惯是:先拆,等性能分析真的证明这里有瓶颈,再考虑合并。但说实话,我工作十几年,因为拆分循环导致性能问题的场景,一只手数得过来。
以管道取代循环
这个手法,是我最喜欢的之一。循环本质上是在描述「怎么做」,而管道(比如 map、filter、reduce)是在描述「要什么」。后者更声明式,更容易理解。
看个例子:
// 循环版本
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 行,而且每个步骤都可以单独加日志、单独测试。那感觉,就像把一团乱麻理成了一根顺滑的绳子。
不过也要注意:管道不是万能的。如果循环里有 break 或 continue,或者需要同时访问前后元素,管道可能就不太合适了。这时候别硬套,该用循环还是用循环。
知识体系总览
下面这张图,把这四个手法的核心逻辑串起来了:
这四个手法,本质上都是在做同一件事:让代码的归属更合理,让意图更清晰。搬移语句到函数,是把「配套动作」归位;函数调用取代内联,是把「重复逻辑」归位;拆分循环,是把「不同职责」归位;管道取代循环,是把「数据处理步骤」归位。
嗯,说起来简单,做起来需要一点「洁癖」。但相信我,当你习惯了这种思考方式,再看那些又长又乱的代码,你会忍不住想重构的。
我的建议:下次你 review 代码时,可以专门留意一下循环和重复的内联代码。问自己三个问题:
- 这个循环里做了几件事?能不能拆?
- 这段内联逻辑在其他地方出现过吗?能不能提取成函数?
- 这个循环能不能用管道写得更清晰?
养成这个习惯,你的代码会越来越干净。