跳转到内容

拆分循环

拆分循环(Split Loop)把一个同时做几件事的循环拆成几个循环,每个只做一件事。 它解决的痛是:一个循环里交织着几种计算,想单独复用或修改其中一种,得先把它从别的计算里剥出来。

人事报表要算两个数:最年轻员工的年龄,和全体工资总额。写的人想着“反正都要遍历一遍”,就在一个循环里一起算了。

"""人事报表:拆分前。"""
def report(people: list[dict]) -> str:
youngest = people[0]["age"] if people else 0
total_salary = 0
for p in people:
if p["age"] < youngest:
youngest = p["age"]
total_salary += p["salary"]
return f"最年轻 {youngest} 岁,工资总额 {total_salary}"
PEOPLE = [{"age": 31, "salary": 12000}, {"age": 24, "salary": 9000}, {"age": 45, "salary": 20000}]
if __name__ == "__main__":
print(report(PEOPLE))
# 输出:
# 最年轻 24 岁,工资总额 41000
// 人事报表:拆分前。
interface Person {
age: number;
salary: number;
}
function report(people: Person[]): string {
let youngest = people.length > 0 ? people[0].age : 0;
let totalSalary = 0;
for (const p of people) {
if (p.age < youngest) youngest = p.age;
totalSalary += p.salary;
}
return `最年轻 ${youngest} 岁,工资总额 ${totalSalary}`;
}
const PEOPLE: Person[] = [
{ age: 31, salary: 12000 },
{ age: 24, salary: 9000 },
{ age: 45, salary: 20000 },
];
console.log(report(PEOPLE));
// 输出:
// 最年轻 24 岁,工资总额 41000

如果现在财务模块也要“工资总额”,却不需要年龄,没法直接复用:工资的累加和年龄的比较缠在同一个循环里,要拿出一个就得复制整个循环再删掉另一半。想把年龄这部分提炼成函数也很别扭,因为循环体里两件事共用一个 p。这是第 2 章说的循环这种坏味道的一种:循环本身把不相干的计算绑在了一起。

"""人事报表:拆分后,再各自提炼成函数。"""
def youngest_age(people: list[dict]) -> int:
return min((p["age"] for p in people), default=0)
def total_salary(people: list[dict]) -> int:
return sum(p["salary"] for p in people)
def report(people: list[dict]) -> str:
return f"最年轻 {youngest_age(people)} 岁,工资总额 {total_salary(people)}"
PEOPLE = [{"age": 31, "salary": 12000}, {"age": 24, "salary": 9000}, {"age": 45, "salary": 20000}]
if __name__ == "__main__":
print(report(PEOPLE))
# 输出:
# 最年轻 24 岁,工资总额 41000
// 人事报表:拆分后,再各自提炼成函数。
interface Person {
age: number;
salary: number;
}
function youngestAge(people: Person[]): number {
return people.length > 0 ? Math.min(...people.map((p) => p.age)) : 0;
}
function totalSalary(people: Person[]): number {
return people.reduce((sum, p) => sum + p.salary, 0);
}
function report(people: Person[]): string {
return `最年轻 ${youngestAge(people)} 岁,工资总额 ${totalSalary(people)}`;
}
const PEOPLE: Person[] = [
{ age: 31, salary: 12000 },
{ age: 24, salary: 9000 },
{ age: 45, salary: 20000 },
];
console.log(report(PEOPLE));
// 输出:
// 最年轻 24 岁,工资总额 41000

拆分前一个循环里交织着年龄比较和工资累加;拆分后两件事各一个循环,各自提炼成函数,财务模块只调 total_salary

财务模块直接调 total_salary。两个函数各一行,因为拆开之后每个循环都变成了标准库里现成的操作(min、sum),这是拆分循环之后常见的下一步:以管道取代循环(第 27 章)。

  1. 把循环复制一份。
  2. 在每一份里删掉不属于它的那部分计算。跑测试。
  3. 如果两个循环之前共享了变量的初始化,确认每个循环都有自己需要的初始化。
  4. 用提炼函数(第 4 章)把每个循环提成一个函数。跑测试。
  5. 看看每个函数能否换成管道或标准库函数。

拆之前要确认两件事之间真的没有依赖。如果第二件事要用到第一件事在循环中途算出的值,比如“工资总额只算年龄大于最小年龄的人”,那它们不是两件独立的事,拆开之前得先把这个依赖理清楚:要么先跑完第一个循环拿到最小年龄,再跑第二个循环,要么承认它们确实要在一个循环里完成。拆分循环处理的是“碰巧放在一起”的计算,不是“互相依赖”的计算。

拆分循环:把一个做了多件事的循环复制成多个循环,每个只做其中一件。它几乎总是提炼函数的前奏:一个只做一件事的循环可以整个提出去、起个名字;一个做两件事的循环提不干净。

拆分循环还常常顺带暴露边界情况。拆之前,空列表的处理藏在循环的初始化里(people[0]["age"] if people else 0),读循环的人很容易忽略它;拆成 youngest_age 之后,空列表该返回什么就成了这个函数自己要回答的问题,这里用 min(..., default=0) 明确给出了答案。一个只做一件事的函数,它的边界情况更容易被想到、被测到。

后台的订单统计面板常有一个大循环同时算订单数、退款数、总金额。拆开之后,三个指标各自是一个函数,可以分别给不同的面板用,也可以分别加缓存。

// 订单统计:三个指标各一个函数。
interface Order {
amount: number;
refunded: boolean;
}
const orderCount = (orders: Order[]) => orders.length;
const refundCount = (orders: Order[]) => orders.filter((o) => o.refunded).length;
const netRevenue = (orders: Order[]) => orders.filter((o) => !o.refunded).reduce((s, o) => s + o.amount, 0);
const orders: Order[] = [
{ amount: 100, refunded: false },
{ amount: 60, refunded: true },
{ amount: 40, refunded: false },
];
console.log(orderCount(orders), refundCount(orders), netRevenue(orders));
// 输出:
// 3 1 140

担心性能是最常见的反对理由:遍历两次比遍历一次慢。在绝大多数情况下这个差别测不出来,一个几千条数据的列表多遍历一次是微秒级的。先拆开,让代码清楚,如果性能测试真的显示这里是瓶颈,再合并回去,而且合并的时候你已经有了两个清楚的函数,合并也更容易。只有当遍历本身很贵(比如每次遍历都要读一遍文件或查一次数据库)时,才值得一开始就保持一个循环。

拆分循环和第 25 章的移动语句经常配合:拆开之后,每个循环相关的初始化语句要挪到它自己的循环旁边,这样每个循环连同它的准备工作是一个完整的块,下一步可以整块提炼成函数,不用担心漏带一个变量。

  • 一个循环只做一件事;做两件事的先复制一份,各删掉一半。
  • 拆完之后把每个循环提炼成有名字的函数。
  • 拆开的循环常常能换成标准库函数或管道。
  • 性能先测再说,多遍历一次通常测不出差别。