const names = [];
for (const i of input) {
  if (i.job === "programmer")
    names.push(i.name);
}
 
 
const names = input
  .filter(i => i.job === "programmer")
  .map(i => i.name)
;

动机

与大多数程序员一样,我入行的时候也有人告诉我,迭代一组集合时得使用循环。不过时代在发展,如今越来越多的编程语言都提供了更好的语言结构来处理迭代过程,这种结构就叫作集合管道(collection pipeline)。集合管道[mf-cp]是这样一种技术,它允许我使用一组运算来描述集合的迭代过程,其中每种运算接收的入参和返回值都是一个集合。这类运算有很多种,最常见的则非 map 和 filter 莫属:map 运算是指用一个函数作用于输入集合的每一个元素上,将集合变换成另外一个集合的过程;filter 运算是指用一个函数从输入集合中筛选出符合条件的元素子集的过程。运算得到的集合可以供管道的后续流程使用。我发现一些逻辑如果采用集合管道来编写,代码的可读性会更强——我只消从头到尾阅读一遍代码,就能弄清对象在管道中间的变换过程。

做法

创建一个新变量,用以存放参与循环过程的集合。

也可以简单地复制一个现有的变量赋值给新变量。

从循环顶部开始,将循环里的每一块行为依次搬移出来,在上一步创建的集合变量上用一种管道运算替代之。每次修改后运行测试。

搬移完循环里的全部行为后,将循环整个删除。

如果循环内部通过累加变量来保存结果,那么移除循环后,将管道运算的最终结果赋值给该累加变量。

范例

在这个例子中,我们有一个 CSV 文件,里面存有各个办公室(office)的一些数据。

office, country, telephone
Chicago, USA, +1 312 373 1000
Beijing, China, +86 4008 900 505
Bangalore, India, +91 80 4064 9570
Porto Alegre, Brazil, +55 51 3079 3550
Chennai, India, +91 44 660 44766

... (more data follows)

下面这个 acquireData 函数的作用是从数据中筛选出印度的所有办公室,并返回办公室所在的城市(city)信息和联系电话(telephone number)。

function acquireData(input) {
  const lines = input.split("\n");
  let firstLine = true;
  const result = [];
  for (const line of lines) {
    if (firstLine) {
      firstLine = false;
      continue;
    }
    if (line.trim() === "") continue;
    const record = line.split(",");
    if (record[1].trim() === "India") {
      result.push({ city: record[0].trim(), phone: record[2].trim() });
    }
  }
  return result;
}

这个循环略显复杂,我希望能用一组管道操作来替换它。

第一步是先创建一个独立的变量,用来存放参与循环过程的集合值。

function acquireData(input) {
  const lines = input.split("\n");
  let firstLine = true;
  const result = [];
  const loopItems = lines;
  for (const line of loopItems) {
    if (firstLine) {
      firstLine = false;
      continue;
    }
    if (line.trim() === "") continue;
    const record = line.split(",");
    if (record[1].trim() === "India") {
      result.push({ city: record[0].trim(), phone: record[2].trim() });
    }
  }
  return result;
}

循环第一部分的作用是在忽略 CSV 文件的第一行数据。这其实是一个切片(slice)操作,因此我先从循环中移除这部分代码,并在集合变量的声明后面新增一个对应的 slice 运算来替代它。

function acquireData(input) {
  const lines = input.split("\n");
  let firstLine = true;
  const result = [];
  const loopItems = lines.slice(1);
  for (const line of loopItems) {
    if (firstLine) {
      firstLine = false;
      continue;
    }
    if (line.trim() === "") continue;
    const record = line.split(",");
    if (record[1].trim() === "India") {
      result.push({ city: record[0].trim(), phone: record[2].trim() });
    }
  }
  return result;
}

从循环中删除代码还有一个好处,那就是 firstLine 这个控制变量也可以一并移除了——无论何时,删除控制变量总能使我身心愉悦。

该循环的下一个行为是要移除数据中的所有空行。这同样可用一个过滤(filter)运算替代之。

function acquireData(input) {
 const lines = input.split("\n");
 const result = [];
 const loopItems = lines
    .slice(1)
    .filter(line => line.trim() !== "")
    ;
 for (const line of loopItems) {
  if (line.trim() === "") continue;
  const  record = line.split(",");
  if (record[1].trim() === "India") {
   result.push({city: record[0].trim(), phone: record[2].trim()});
  }
 }
 return result;
}
编写管道运算时,我喜欢让结尾的分号单占一行,这样方便调整管道的结构。

接下来是将数据的一行转换成数组,这明显可以用一个 map 运算替代。然后我们还发现,原来的 record 命名其实有误导性,它没有体现出“转换得到的结果是数组”这个信息,不过既然现在还在做其他重构,先不动它会比较安全,回头再为它改名也不迟。

function acquireData(input) {
 const lines = input.split("\n");
 const result = [];
 const loopItems = lines
    .slice(1)
    .filter(line => line.trim() !== "")
    .map(line => line.split(","))
    ;
 for (const line of loopItems) {
  const record = line;.split(",");
  if (record[1].trim() === "India") {
   result.push({city: record[0].trim(), phone: record[2].trim()});
  }
 }
 return result;
}

然后又是一个过滤(filter)操作,只从结果中筛选出印度办公室的记录。

function acquireData(input) {
 const lines = input.split("\n");
 const result = [];
 const loopItems = lines
    .slice(1)
    .filter(line => line.trim() !== "")
    .map(line => line.split(","))
    .filter(record => record[1].trim() === "India")
    ;
 for (const line of loopItems) {
  const record = line;
  if (record[1].trim() === "India") {
   result.push({city: record[0].trim(), phone: record[2].trim()});
  }
 }
 return result;
}

最后再把结果映射(map)成需要的记录格式:

  function acquireData(input) {
 const lines = input.split("\n");
 const result = [];
 const loopItems = lines
    .slice(1)
    .filter(line => line.trim() !== "")
    .map(line => line.split(","))
    .filter(record => record[1].trim() === "India")
    .map(record => ({city: record[0].trim(), phone: record[2].trim()}))
    ;
 for (const line of loopItems) {
  const record = line;
  result.push(line);
 }
 return result;
}

现在,循环剩余的唯一作用就是对累加变量赋值了。我可以将上面管道产出的结果赋值给该累加变量,然后删除整个循环:

function acquireData(input) {
 const lines = input.split("\n");
 const result = lines
    .slice(1)
    .filter(line => line.trim() !== "")
    .map(line => line.split(","))
    .filter(record => record[1].trim() === "India")
    .map(record => ({city: record[0].trim(), phone: record[2].trim()}))
    ;
 for (const line of loopItems) {
  const record = line;
  result.push(line);
 }
 return result;
}

以上就是本手法的全部精髓所在了。不过后续还有些清理工作可做:我内联了 result 变量,为一些函数变量改名,最后还对代码进行布局,让它读起来更像个表格。

function acquireData(input) {
 const lines = input.split("\n");
 return lines
    .slice (1)
    .filter (line => line.trim() !== "")
    .map   (line => line.split(","))
    .filter (fields => fields[1].trim() === "India")
    .map   (fields => ({city: fields[0].trim(), phone: fields[2].trim()}))
    ;
}

我还想过是否要内联 lines 变量,但我感觉它还算能解释该行代码的意图,因此我还是将它留在了原处。

延伸阅读

如果想了解更多用集合管道替代循环的案例,可以参考我的文章“Refactoring with Loops and Collection Pipelines”[mf-ref-pipe]。