编写小而美函数的艺术

1,770 阅读12分钟

原文链接:https://dmitripavlutin.com/the-art-of-writing-small-and-plain-functions/?utm_source=codropscollective

译者:阿里云-也树

随着软件应用的复杂度不断上升,为了确保应用稳定且易拓展,代码质量就变的越来越重要。

不幸的是,包括我在内的几乎每个开发者在职业生涯中都会面对质量很差的代码。这些代码通常有以下特征:

  • 函数冗长,做了太多事情
  • 函数有副作用并且很难理解和调试排错
  • 含糊的函数/变量命名
  • 代码脆弱,一个小改动会意外地破坏应用的其它组件
  • 缺乏测试的覆盖

这些话听起来非常常见:“我不明白这部分代码怎么工作的”,“这代码太烂了”,“这代码太难改了”等等。

有一次我现在的同事因为在之前的团队处理过难以维护的Ruby 编写的 REST API 而辞职,他是接手了之前开发团队的工作。在修复现有的 bug 时会创造新的 bug,添加新的特性也会创造一系列新的 bug,而客户也不想以更好的设计去重构应用,因而我的同事做了辞职这个正确的决定。

这样的场景时有发生,我们能做些什么呢?

需要牢记于心的是:仅仅让应用可以运行和关注代码质量是不同的。一方面你需要满足应用的功能,另一方面你需要花时间确认是否任意的函数没有包含太多职责、是否所有函数都使用了易理解的变量和函数名并且是否避免了函数的副作用。

函数(包括对象的方法)是让应用运行的小齿轮。首先你应该专注于它们的结构和编写,而下面这篇文章阐述了编写清晰易懂且容易测试的函数的最佳实践。

函数需要“小”

要避免编写职责冗杂的庞大函数,而需要将它们分离成很多小函数。庞大的函数就像黑盒子一样,很难理解和修改,尤其在测试时更加捉襟见肘。

想象一个场景:一个函数需要返回一个数组、map 或者普通对象的“重量”。“重量”由属性值计算得到。规则如下:

  • null 或者 undefined 计为 1
  • 基础类型的数据计为 2
  • 对象或者函数类型的数据计为 4

举个例子:数组 [null, 'Hello World', {}] 的重量计算为: 1(null) + 2(字符串类型) + 4(对象) = 7

Step 0: 最初的庞大函数

让我们从最坏的情况开始,所有的逻辑都写在一个庞大的 getCollectionWeight() 函数里。

在 repl.it 中尝试运行

function getCollectionWeight(collection) {  
  let collectionValues;
  if (collection instanceof Array) {
    collectionValues = collection;
  } else if (collection instanceof Map) {
    collectionValues = [...collection.values()];
  } else {
    collectionValues = Object.keys(collection).map(function (key) {
      return collection[key];
    });
  }
  return collectionValues.reduce(function(sum, item) {
    if (item == null) {
      return sum + 1;
    } 
    if (typeof item === 'object' || typeof item === 'function') {
      return sum + 4;
    }
    return sum + 2;
  }, 0);
}
let myArray = [null, { }, 15];  
let myMap = new Map([ ['functionKey', function() {}] ]);  
let myObject = { 'stringKey': 'Hello world' };  
getCollectionWeight(myArray);  // => 7 (1 + 4 + 2)  
getCollectionWeight(myMap);    // => 4  
getCollectionWeight(myObject); // => 2  

问题显而易见。getCollectionWeight() 函数过于庞大,看起来像个装有很多惊喜的黑盒子。你很难第一眼理解它是做什么的,再想象一下你的应用里有一堆这样的函数是什么光景。

当你在和这样的代码打交道时,是在浪费时间和精力。另一方面小而能够自解释的函数读起来也会让人愉悦,方便开展之后的工作。

Step 1: 通过数据类型提取“重量”并且去除魔数

现在我们的目标是把庞大的函数分解成更小的不耦合且可重用的函数。第一步是通过不同的类型,抽象出决定“重量”值的代码。这个新函数是 getWeight()

仅仅看到124 这三个魔数而不了解上下文的情况下根本搞不清楚他们的含义。幸运的是 ES2015 允许我们利用 const 来定义只读的的变量,所以可以创建有含义的常量来取代魔数。

让我们创建 getWeightByType() 函数并且改善一下 getCollectionWeight() 函数:

在 repl.it 中尝试

function getWeightByType(value) {  
  const WEIGHT_NULL_UNDEFINED  = 1;
  const WEIGHT_PRIMITIVE       = 2;
  const WEIGHT_OBJECT_FUNCTION = 4;
  if (value == null) {
    return WEIGHT_NULL_UNDEFINED;
  } 
  if (typeof value === 'object' || typeof value === 'function') {
    return WEIGHT_OBJECT_FUNCTION;
  }
  return WEIGHT_PRIMITIVE;
}
function getCollectionWeight(collection) {  
  let collectionValues;
  if (collection instanceof Array) {
    collectionValues = collection;
  } else if (collection instanceof Map) {
    collectionValues = [...collection.values()];
  } else {
    collectionValues = Object.keys(collection).map(function (key) {
      return collection[key];
    });
  }
  return collectionValues.reduce(function(sum, item) {
    return sum + getWeightByType(item);
  }, 0);
}
let myArray = [null, { }, 15];  
let myMap = new Map([ ['functionKey', function() {}] ]);  
let myObject = { 'stringKey': 'Hello world' };  
getCollectionWeight(myArray);  // => 7 (1 + 4 + 2)  
getCollectionWeight(myMap);    // => 4  
getCollectionWeight(myObject); // => 2  

是不是看起来好些了?getWeightByType() 函数是无依赖的,仅仅通过数据类型来决定数据的“重量”。你可以在任何一个函数中复用它。getCollectionWeight() 函数也变得简练了一些。

WEIGHT_NULL_UNDEFINED, WEIGHT_PRIMITIVEWEIGHT_OBJECT_FUNCTION 从变量名就可以看出“重量”所描述的数据类型,而不需要再猜 1, 24 代表什么。

Step 2: 继续分割函数并且增加拓展性

上面的改进版仍然有瑕疵。想象一下你想要将“重量”的计算应用在 Set 或者其它定制的数据集合时,由于 getCollectionWeight() 函数包含了收集值的逻辑,它的代码量会快速增长。

让我们从代码中抽象出一些函数,比如获取 map 类型的数据的函数 getMapValues() 和获取普通对象类型数据的函数 getPlainObjectValues()。再看看新的改进版:

在 repl.it 中尝试

function getWeightByType(value) {  
  const WEIGHT_NULL_UNDEFINED = 1;
  const WEIGHT_PRIMITIVE = 2;
  const WEIGHT_OBJECT_FUNCTION = 4;
  if (value == null) {
    return WEIGHT_NULL_UNDEFINED;
  } 
  if (typeof value === 'object' || typeof value === 'function') {
    return WEIGHT_OBJECT_FUNCTION;
  }
  return WEIGHT_PRIMITIVE;
}
function getMapValues(map) {  
  return [...map.values()];
}
function getPlainObjectValues(object) {  
  return Object.keys(object).map(function (key) {
    return object[key];
  });
}
function getCollectionWeight(collection) {  
  let collectionValues;
  if (collection instanceof Array) {
    collectionValues = collection;
  } else if (collection instanceof Map) {
    collectionValues = getMapValues(collection);
  } else {
    collectionValues = getPlainObjectValues(collection);
  }
  return collectionValues.reduce(function(sum, item) {
    return sum + getWeightByType(item);
  }, 0);
}
let myArray = [null, { }, 15];  
let myMap = new Map([ ['functionKey', function() {}] ]);  
let myObject = { 'stringKey': 'Hello world' };  
getCollectionWeight(myArray);  // => 7 (1 + 4 + 2)  
getCollectionWeight(myMap);    // => 4  
getCollectionWeight(myObject); // => 2  

现在再读 getCollectionWeight() 函数,你会很容易的弄清楚它实现的功能,现在的函数看起来像一个有趣的故事。每个函数都很清晰并且直截了当,你不会在思考代码的含义上浪费时间。简洁的代码理应如此。

Step 3: 永远不要停止改进

现在依然有很多可以改进的地方。

你可以创建一个独立的 getCollectionValues() 函数,包含区分数据集合类型的判断逻辑:

function getCollectionValues(collection) {  
  if (collection instanceof Array) {
    return collection;
  }
  if (collection instanceof Map) {
    return getMapValues(collection);
  }
  return getPlainObjectValues(collection);
}

getCollectionWeight() 函数会变得十分简单,因为它唯一要做的事情就是从 getCollectionValues() 中获取集合的值,然后执行累加操作。

你也可以创建一个独立的 reduce 函数:

function reduceWeightSum(sum, item) {  
  return sum + getWeightByType(item);
}

因为理想情况下 getCollectionWeight() 中不应该定义匿名函数。

最终我们最初的庞大函数被拆分成下面这些函数:

在 repl.it 中尝试

function getWeightByType(value) {  
  const WEIGHT_NULL_UNDEFINED = 1;
  const WEIGHT_PRIMITIVE = 2;
  const WEIGHT_OBJECT_FUNCTION = 4;
  if (value == null) {
    return WEIGHT_NULL_UNDEFINED;
  } 
  if (typeof value === 'object' || typeof value === 'function') {
    return WEIGHT_OBJECT_FUNCTION;
  }
  return WEIGHT_PRIMITIVE;
}
function getMapValues(map) {  
  return [...map.values()];
}
function getPlainObjectValues(object) {  
  return Object.keys(object).map(function (key) {
    return object[key];
  });
}
function getCollectionValues(collection) {  
  if (collection instanceof Array) {
    return collection;
  }
  if (collection instanceof Map) {
    return getMapValues(collection);
  }
  return getPlainObjectValues(collection);
}
function reduceWeightSum(sum, item) {  
  return sum + getWeightByType(item);
}
function getCollectionWeight(collection) {  
  return getCollectionValues(collection).reduce(reduceWeightSum, 0);
}
let myArray = [null, { }, 15];  
let myMap = new Map([ ['functionKey', function() {}] ]);  
let myObject = { 'stringKey': 'Hello world' };  
getCollectionWeight(myArray);  // => 7 (1 + 4 + 2)  
getCollectionWeight(myMap);    // => 4  
getCollectionWeight(myObject); // => 2  

这就是编写小而美的函数的艺术。

经过一系列的代码质量优化,你获得了一连串的好处:

  • 通过自解释的代码增加了 getCollectionWeight() 函数的可读性。
  • 极大地减少了 getCollectionWeight() 函数的代码量。
  • 避免了在你想要增加其它数据集合类型时,getCollectionWeight() 函数代码量会过于迅速地增长。
  • 抽象出的函数是独立可重用的。你的同事可能想要引入你这些实用的函数到另一个项目中,你可以轻易的让他们做到这一点。
  • 如果某个函数意外报错,函数的调用栈信息会更加清晰,因为它包含了函数名称,你立刻就能确定出问题的函数在哪里。
  • 分割开的函数更容易编写测试和实现更高的测试覆盖率。相比于测试一个庞大函数的所有场景,更好的办法是独立构造测试并且独立核对每一个函数。
  • 你可以利用 CommonJS 或者 ES2015 模块标准使代码模块化。把函数抽象成独立的模块,这样会让你的项目文件更轻量和结构化。

这些优势会让你在复杂的应用中如鱼得水。

有条通用的准则:一个函数不应该超过20行,小则优。

你现在可能会问我一个合情合理的问题:“我不想为每一行代码都创建函数,有没有一个标准让我不再继续拆分函数?”这就是下一章节的主题。

函数应该是简练的

让我们稍作休息,思考一个问题:软件应用究竟是什么?

每个应用都是为了完成一系列的需求。作为开发者,需要把这些需求分解为可以正确运行特定任务的小组件(命名空间,类,函数,代码块)。

一个组件包含了其它更小的组件。如果你想要编写一个组件,需要通过抽象程度比它低一层级的组件来创建。

换句话讲:你需要把一个函数分解为多个步骤,这些步骤的抽象程度需要保持在同一层级或者低一层级。这样可以在保证函数简练的同时践行“做一件事,并且做好”的原则。

为什么分解是必要的?因为简练的函数含义更加明确,也就意味着易读和易改。

让我们看一个例子。假设你想要编写函数实现只保存数组中的素数,移除非素数。函数通过以下方式执行:

getOnlyPrime([2, 3, 4, 5, 6, 8, 11]); // => [2, 3, 5, 11]  

getOnlyPrime() 函数中有哪些低一层级的抽象步骤?接下来系统阐述:

使用 isPrime() 函数过滤数组中的数字。

需要在这个层级提供 isPrime() 函数的细节吗?答案是否定的。因为 getOnlyPrime() 函数会有不同层级的抽象步骤,这个函数会包含许多的职责。

既然脑子里有了最基础的想法,让我们先完成 getOnlyPrime() 函数的内容:

function getOnlyPrime(numbers) {  
  return numbers.filter(isPrime);
}
getOnlyPrime([2, 3, 4, 5, 6, 8, 11]); // => [2, 3, 5, 11]  

此时 getOnlyPrime() 函数非常简洁。它包含了一个独立层级的抽象:数组的 .filter() 方法和 isPrime() 函数。

现在是时候向更低的层级抽象了。

数组方法是 .filter() 直接由 JavaScript 引擎提供的,原样使用即可。ECMA标准中精确地描述了它的功能。

现在我们来研究 isPrime() 函数的具体实现:

为了实现检查一个数字 n 是否为素数的功能,需要确认是否从 2Math.sqrt(n) 的任意数字都可以整除 n

理解了这个算法(效率不高,但简便起见)后,来完成 isPrime() 函数的代码:

在 repl.it 中尝试

function isPrime(number) {  
  if (number === 3 || number === 2) {
    return true;
  }
  if (number === 1) {
    return false;
  }
  for (let divisor = 2; divisor <= Math.sqrt(number); divisor++) {
    if (number % divisor === 0) {
      return false;
    }
  }
  return true;
}
function getOnlyPrime(numbers) {  
  return numbers.filter(isPrime);
}
getOnlyPrime([2, 3, 4, 5, 6, 8, 11]); // => [2, 3, 5, 11]  

getOnlyPrime() 函数小而精炼。它仅仅保留了必需的低一层级的抽象。

如果你遵照让函数简练化的原则,复杂函数的可读性可以大大提升。每一层级的精确抽象和编码可以防止编写出一大堆难以维护的代码。

使用简明扼要的函数名称

函数名称应该简明扼要,不应过于冗长或者简短。理想情况下,函数名称应该在不对代码刨根问底的情况下清楚反映出函数的功能。

函数名称应该使用驼峰式命名法,以小写字母开头:addItem(), saveToStore() 或者 getFirstName()

因为函数代表了动作,函数名称应该至少包含一个动词。比如:deletePage(), verifyCredentials()。获取或者设置属性值时,使用标准的 setget 前缀:getLastName() 或者 setLastName()

避免编写含混的函数名,比如 foo(), bar(), a(), fun() 等等。这些名称没有意义。

如果函数小而清晰,名称简明扼要,代码就可以像散文一样阅读。

结论

当然,上面提供的示例十分简单。真实的应用中会更加复杂。你可能会抱怨仅仅为了抽象出一个层级而编写简练的函数是沉闷乏味的任务。但是如果从项目开始之初就正确实践的话就不会是一件困难的事。

如果应用已经有很多函数拥有太多职责,你会发现很难理解这些代码。在很多情况下,不大可能在合理的时间完成重构的工作。但是至少从点滴做起:尽你所能抽象一些东西。

最好的解决办法当然是从一开始就正确的实现应用。不仅要在实现需求上花费时间,同样应该像我建议的那样:正确组织你的函数,让它们小而简练。

三思而后行。(Measure seven times, cut once)

ES2015 实现了一个很棒的模块系统,清晰地建议出分割函数是好的实践。

记住永远值得投资时间让代码变得简练有组织。在这个过程中,你可能觉得实践起来很难,可能需要很多练习,也可能回过头来修改一个函数很多次。

但没有比一团乱麻的代码更糟的了。

译者注

文章作者提出的 small function 的观点可能会让初学者产生一点误解,在我的理解里,更准确的表述应该是从代码实现功能的逻辑层面抽象出更小的功能点,将抽象出的功能点转化为函数来为最后的业务提供组装的零件。最终的目的依然是通过解耦逻辑来提高代码的拓展性和复用性,而不能仅仅停留在视觉层面的”小“,单纯为了让函数代码行数变少是没有意义的。