编写小而美函数的艺术

简介: 随着软件应用的复杂度不断上升,为了确保应用稳定且易拓展,代码质量就变的越来越重要。
原文链接: 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
|
5月前
|
开发者 Python
Python元类实战:打造你的专属编程魔法,让代码随心所欲变化
【7月更文挑战第7天】Python的元类是编程的变形师,用于创建类的“类”,赋予代码在构建时的变形能力。
61 1
|
1月前
|
存储 容器
从代码到人生:编程中的哲学思考
【10月更文挑战第23天】在这篇文章中,我们将探索编程不仅仅是一门技术,更是一种生活哲学。通过深入浅出的代码示例和生活故事,我们将一起发现如何将编程的原则应用到日常生活中,以及这些原则如何帮助我们更好地理解世界和自我。文章旨在启发读者,通过编程的视角来审视生活的不同方面,从而获得更加丰富和深刻的人生体验。
|
3月前
|
算法 程序员
从代码到哲学:探索编程中的思考与人生
【9月更文挑战第28天】在编程世界里,我们不仅仅是在编写代码,更是在编织思想。本文将通过编程的视角,探讨如何将技术实践与生活哲理相结合,从而获得对世界的更深理解。我们将从简单的代码示例出发,逐步深入到编程背后的哲学意义,以及它如何影响我们的思维方式和生活选择。让我们一起走进编程的深层次思考,发现代码之外的无限可能。
|
4月前
|
人工智能 算法 数据可视化
在代码的世界中寻找艺术
【8月更文挑战第30天】 编程,一种看似冰冷的技术活动,其实蕴含着丰富的创造性和美学价值。本文将探索编程与艺术之间的微妙联系,揭示如何通过技术实现创意,以及这一过程中所体现的独特美感。我们将一起走进代码的世界,发现那些被数字和逻辑掩盖的艺术之光。
32 1
|
4月前
|
测试技术 Python
解锁Python魔法!装饰器:让你的代码翩翩起舞,简化繁琐逻辑,让编程成为一场戏剧性的华丽变身!
【8月更文挑战第21天】在Python编程中,当需要为函数添加如日志记录、性能测试等功能时,手动重复编写相同代码既冗长又难维护。装饰器提供了解决方案:它是一种特殊函数,包裹目标函数以添加额外功能,而不改变原函数结构。装饰器增强了代码复用性、解耦及灵活性。例如,可通过装饰器轻松记录函数执行时间。更高级用法包括带参数的装饰器、多层装饰器以及使用类作为装饰器。掌握装饰器能显著提升Python代码的质量和效率。
45 5
|
4月前
|
安全 搜索推荐 开发者
"揭秘Python编写的艺术境界:不规范代码的悲剧,规范之美让你事半功倍!"
【8月更文挑战第21天】编写高质量Python代码需遵循规范以提升可读性和可维护性。例如,变量命名应采用小写字母加下划线(如`user_name`而非`uName`),函数命名清晰并避免硬编码(如使用`calculate_circle_area`替代`area_of_circle`并定义精确π值)。此外,添加有意义的注释(如解释冒泡排序逻辑),合理排版(如明确函数参数与返回值),以及适当异常处理(确保文件操作安全),都是良好实践。遵循这些规范能显著提高代码质量和团队协作效率。
44 0
|
5月前
|
程序员 Python
从零到一,彻底掌握Python闭包与装饰器的精髓,成为编程界的隐藏Boss
【7月更文挑战第7天】探索Python编程的两大基石:闭包与装饰器。闭包是内部函数记住外部作用域的变量,如`make_multiplier_of`返回的`multiplier`,它保持对`n`的引用。装饰器则是函数工厂,接收函数并返回新函数,如`my_decorator`,它在不改变原函数代码的情况下添加日志功能。掌握这些,让代码更优雅,效率更高,助你成为编程高手。
34 3
|
7月前
|
算法
代码之韵:寻找编程中的艺术与逻辑
【5月更文挑战第18天】 在数字的海洋中,每一行代码都如同音符,编织着复杂而精致的旋律。本文将探讨编程不仅仅是一门科学,更是一种艺术。我们将深入挖掘编程的本质,揭示如何通过技术实现创意,并分享在编程旅程中对技术美学的个人感悟。从算法的精妙到代码的优雅,从问题的解决到系统的构建,每个环节都蕴含着对技术的深刻理解与热爱。
|
7月前
|
数据采集 搜索推荐 程序员
LabVIEW程序能用ChatGPT编写吗
LabVIEW程序能用ChatGPT编写吗
73 0