编写小而美函数的艺术

简介: 随着软件应用的复杂度不断上升,为了确保应用稳定且易拓展,代码质量就变的越来越重要。
原文链接: https://dmitripavlutin.com/the-art-of-writing-small-and-plain-functions
译者:阿里云-也树

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

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

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

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

mac-sick.jpg

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

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

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

函数需要“小”

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

想象一个场景:一个函数需要返回一个数组、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() 函数过于庞大,看起来像个装有很多惊喜的黑盒子。你很难第一眼理解它是做什么的,再想象一下你的应用里有一堆这样的函数是什么光景。

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

2.png

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 模块标准使代码模块化。把函数抽象成独立的模块,这样会让你的项目文件更轻量和结构化。

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

1.png

有条通用的准则:一个函数不应该超过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)

HappyMac.jpg

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

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

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

译者注

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

目录
相关文章
|
7月前
|
存储 监控 NoSQL
编写可读代码的艺术
编写可读代码的艺术
95 0
|
4月前
|
人工智能 算法 数据可视化
在代码的世界中寻找艺术
【8月更文挑战第30天】 编程,一种看似冰冷的技术活动,其实蕴含着丰富的创造性和美学价值。本文将探索编程与艺术之间的微妙联系,揭示如何通过技术实现创意,以及这一过程中所体现的独特美感。我们将一起走进代码的世界,发现那些被数字和逻辑掩盖的艺术之光。
33 1
|
4月前
|
开发者 Python
Python 模块化方式编程:在编程热潮中找到归属感,让代码更具魅力与活力
【8月更文挑战第22天】Python 以其简洁强大备受青睐。模块化编程将大型程序拆分成独立模块,每个负责特定功能,简化代码结构,提升可读性和维护性。通过创建如“math_utils.py”这样的文件来定义数学运算函数,可在其他文件中轻松导入使用。这种方式提高了代码的可重用性,便于管理和更新。在项目开发中按功能划分模块,如用户、商品和订单管理等,有助于保持清晰的代码结构和减少依赖复杂度。遵循良好的命名规范,可以使模块更易理解与使用。
50 0
|
6月前
|
前端开发 JavaScript 搜索推荐
[初学者必看]JavaScript 15题简单小例子练习,锻炼代码逻辑思维
【6月更文挑战第3天】这是一个JavaScript编程练习集,包含15个题目及答案:计算两数之和、判断偶数、找数组最大值、字符串反转、回文检测、斐波那契数列、数组去重、冒泡排序、阶乘计算、数组元素检查、数组求和、字符计数、数组最值和质数判断以及数组扁平化。每个题目都有相应的代码实现示例。
468 1
|
7月前
|
算法
代码之韵:寻找编程中的艺术与逻辑
【5月更文挑战第18天】 在数字的海洋中,每一行代码都如同音符,编织着复杂而精致的旋律。本文将探讨编程不仅仅是一门科学,更是一种艺术。我们将深入挖掘编程的本质,揭示如何通过技术实现创意,并分享在编程旅程中对技术美学的个人感悟。从算法的精妙到代码的优雅,从问题的解决到系统的构建,每个环节都蕴含着对技术的深刻理解与热爱。
|
7月前
|
设计模式 Java 程序员
代码之韵:探索编程的艺术与实践
【5月更文挑战第11天】 在数字世界的舞台上,每一行代码都如同音符,编织出复杂而和谐的旋律。本文将深入探讨编程的本质,从逻辑思维的构建到技术实践的精进,再到创新思维的培养。我们将一起走进程序员的内心世界,体验在面对问题、解决问题的过程中所涌现的技术感悟。这不仅是一次对编程知识的剖析,更是一段关于持续学习与成长的思考之旅。
|
存储 算法 Python
Python函数编程的艺术:创造简洁优雅的代码
函数是一种重要的编程概念,它可以将一段代码封装起来,实现特定的功能,并且可以被多次调用和复用。函数在Python中具有广泛的应用,可以用于模块化程序、提高代码的可读性和可维护性。本文将引导您从函数的基础知识到高级应用,全面了解Python中函数的使用方法。
125 1
|
7月前
|
人工智能 算法 物联网
代码之韵:探索编程的艺术与逻辑
【2月更文挑战第22天】 在数字化的浪潮中,编程已成为一种现代魔法,它不仅塑造了技术世界的未来,更影响了我们的思维模式。本文将深入探讨编程的核心要素,从语言的精确性到逻辑的构建,再到创造性思维的培养。我们将一同穿梭在代码的森林中,寻找那些令人着迷的模式与结构,揭示编程艺术背后隐藏的智慧和美感。
|
7月前
|
人工智能 算法 物联网
代码之禅:从功能实现到艺术表达
在数字世界的无限编织中,技术并非僵化的工具,而是承载创造力与哲思的容器。本文将探讨编程不仅仅是逻辑与算法的堆砌,更是一场思维与美学的交响。我们将透过编程语言的框架,捕捉那些在字符间跳跃的灵感火花,从而揭示编程艺术的深层价值。
42 0
|
7月前
|
存储 缓存 前端开发
深入理解 JavaScript 函数:提升编程技能的必备知识(上)
深入理解 JavaScript 函数:提升编程技能的必备知识(上)
深入理解 JavaScript 函数:提升编程技能的必备知识(上)