干净代码应用于 JavaScript — 第三部分:函数
介绍
使用默认参数而不是短路或条件
函数参数(理想情况下为 2 个或更少)
避免副作用——全局变量
避免副作用 - 对象可变
函数应该只做一件事
函数应该只有一个抽象级别
函数式编程优于命令式编程
使用方法链
结论
介绍
在这篇文章中,我们将介绍生成干净代码的基本技巧和建议,重点关注允许我们重用代码的元素:函数。
我们所有的示例都用 JavaScript 来演示,但这些良好实践应该适用于任何编程语言,包括“最接近金属”的编程语言。我之所以这么说,是因为我曾与一些使用 C 或 Go 等语言的同事讨论过,他们不喜欢应用这些实践,并认为在他们的编程语言中“没有人”这样做。我总是回答说,只要是为了改进,就必须有人先打破常规。然而,这条评论促成了同事之间长时间愉快的对话,讨论这些实践的优缺点。
因此,我们从生成专门应用于变量的良好代码的技巧开始。
使用默认参数而不是短路或条件
在大多数编程语言中,你可以在函数中定义默认参数。这样一来,我们就不用在代码主体中使用条件语句或短路语句了。
以下代码说明了这个例子。
function setName(name) {
const newName = name || 'Juan Palomo';
}
function setName(name = 'Juan Palomo') {
// ...
}
函数参数(理想情况下为 2 个或更少)
这个概念对于提高代码质量至关重要。
你应该减少函数参数的数量。合适的参数数量可以是 2 个或更少,但不要纠结于参数数量,因为这些参数取决于我们使用的具体编程语言。
这条建议非常重要,因为尽管我们并不这么认为,但当我们有很多参数时,通常会将多个参数组合在一起组成一个对象。我们必须摆脱使用原语(例如字符串、数字、布尔值等类型)的束缚,转而使用更高抽象层次的对象。事实上,这样我们就能更接近业务逻辑,而远离底层逻辑。
在下面的第一个示例中,我们将创建一个汉堡包函数,该函数接收 4 个参数。这些参数是固定的,并且按照这个顺序,这给我们带来了很大的限制。实际上,它又回到了非常僵化的函数。
一个相当显著的改进是使用汉堡之类的对象来创建一个新的汉堡包。这样,我们就把所有属性都集中到了一个对象中(在这种情况下,它将是一个扁平的对象,没有原型)。
在第三个例子中,我们可以对发送的对象进行解构,并且可以使函数主体可以访问其属性,但实际上我们使用的是单个参数,这为我们提供了更大的灵活性。
function newBurger(name, price, ingredients, vegan) {
// ...
}
function newBurger(burger) {
// ...
}
function newBurger({ name, price, ingredients, vegan }) {
// ...
}
const burger = {
name: 'Chicken',
price: 1.25,
ingredients: ['chicken'],
vegan: false,
};
newBurger(burger);
避免副作用——全局变量
副作用是未来麻烦的根源。从定义上来说,没有副作用本身就是有害的,但随着副作用的增多,代码出错的几率也会急剧上升。
因此,本节的建议是,不惜一切代价避免副作用,以便能够生成可测试的函数,应用记忆等技术以及我们无法在本文中描述的其他优势。
下面的例子是一个典型的副作用:一个函数修改了其作用域之外的变量或对象。这个函数无法测试,因为它没有参数可供测试;事实上,它修改的变量的状态不受修改它的函数本身控制或充分管理。
避免这种副作用的最简单方法是将此函数范围内的变量(这是显而易见的,但当我们必须随着时间的推移记住它时就不那么明显了)作为参数传递。
let fruits = 'Banana Apple';
function splitFruits() {
fruits = fruits.split(' ');
}
splitFruits();
console.log(fruits); // ['Banana', 'Apple'];
function splitFruits(fruits) {
return fruits.split(' ');
}
const fruits = 'Banana Apple';
const newFruits = splitFruits(fruits);
console.log(fruits); // 'Banana Apple';
console.log(newFruits); // ['Banana', 'Apple'];
避免副作用 - 对象可变
另一个副作用是初级程序员甚至资深程序员需要花费更多精力去理解,因为他们曾在代码的不同部分使用同一个对象来修改数据。
避免使用此功能的软件开发,其主要副作用之一是对象的修改,这会导致我们失去许多优势。如果您身处 Web 领域,您就会知道 JavaScript 自诞生以来就一直是对象“修改器”,并且有许多库旨在避免修改对象(创建新对象)。
如果前面那个当变量超出范围时通过函数修改变量的例子对你来说似乎合乎逻辑,那么通过函数对对象进行变异也应该具有相同的效果。
在 JavaScript 中,处理数组数据结构的方法分为对对象进行修改的方法和不修改的方法。例如,push、pop 或 sort 操作作用于相同的数据结构,而 filter、reduce 或 map 操作则会生成新的数据结构,并且不会改变主数据结构。
如果您希望开发团队创建干净且可维护的代码,那么您必须习惯于寻找协议和工作模式,以提高所有团队成员对代码和业务逻辑的理解,即使我们的软件效率略低或甚至更加冗长。
我给你两个清晰的例子,其中一个对购物车数据结构进行了变异,另一个则没有进行这样的变异。
const addItemToCart = (cart, item) => {
cart.push({ item, date: Date.now() });
};
const addItemToCart = (cart, item) => {
return [...cart, {
item,
date: Date.now(),
}];
};
函数应该只做一件事
这是所有编程学校每天都会听到的编程原则之一,但在实践中由于缺乏将理论付诸实践,这些原则并未得到实现。
每个函数只能执行一个概念性任务。混合概念或任务是没有意义的。当然,一组小任务组合在一起会构成一个更大的任务,但这些任务不应该混杂在一起,这被称为耦合。
因此,一个函数应该只做一件事。在下面的例子中,我们建模了一个通过接收活跃客户列表来向客户发送电子邮件的函数。从概念上讲,这是一条简单的业务规则,但在实现时,它们是两个截然不同的任务。
function emailCustomers(customers) {
customers.forEach((customer) => {
const customerRecord = database.find(customer);
if (customerRecord.isActive()) {
email(client);
}
});
}
首先,我们必须筛选活跃用户,这是一个独立于前一个函数的功能。在代码中写“if”语句时,你应该谨慎。这并不意味着“if”语句就等同于糟糕的做法,滥用“if”语句当然是错误的。
一旦我们过滤了活跃的客户端,我们就需要另一个负责向每个客户端发送电子邮件的功能。
function emailActiveCustomers(customers) {
customers
.filter(isActiveCustomer)
.forEach(email);
}
function isActiveCustomer(customer) {
const customerRecord = database.find(customer);
return customerRecord.isActive();
}
请记住,您应该集中精力,每个函数只做一件事。
函数应该只有一个抽象级别
我们在设计函数时必须满足的另一个要求是每个函数应该只有一个抽象级别。
以下示例展示了一个在 JavaScript 中解析的函数。在这个函数中,你可以看到不同级别的抽象。
function parseBetterJSAlternative(code) {
const REGEXES = [
// ...
];
const statements = code.split(' ');
const tokens = [];
REGEXES.forEach((REGEX) => {
statements.forEach((statement) => {
// ...
});
});
const ast = [];
tokens.forEach((token) => {
// lex...
});
ast.forEach((node) => {
// parse...
});
}
解决这个问题的技术非常简单,我们只需要确定不同的抽象级别,并创建满足本文所述要求的函数即可。因此,应用重构后的函数如下所示:
const REGEXES = [ // ...];
function tokenize(code) {
const statements = code.split(' ');
const tokens = [];
REGEXES.forEach((REGEX) => {
statements.forEach((statement) => {
tokens.push( /* ... */ );
});
});
return tokens;
}
function lexer(tokens) {
const ast = [];
tokens.forEach((token) => ast.push( /* */ ));
return ast;
}
function parseBetterJSAlternative(code) {
const tokens = tokenize(code);
const ast = lexer(tokens);
ast.forEach((node) => // parse...);
}
函数式编程优于命令式编程
由于这不是本文的目的,因此我不想参与编程范式之间的争论,您应该尝试学习函数范式并将其用于命令范式。
我建议阅读Alvin Alexander 的博客,特别是他描述函数式编程的好处的帖子。
下面,我总结一下在命令式上使用函数式编程的主要优点。
- 纯函数更容易推理
- 测试更容易,纯函数非常适合基于属性的测试等技术
- 调试更容易
- 程序更加安全
- 程序以更高级别编写,因此更容易理解
- 函数签名更有意义
- 并行/并发编程更容易
函数式编程相对于命令式编程的另一个特点是代码更易读。如果你读过本系列文章的第一篇,你就会发现,高质量代码相较于其他代码的一个特征就是其可读性。
因此,函数式编程拥有无穷的优势;然而,对于那些学习过某种范式并开始解决问题的初级程序员来说,他们很难适应这种编程范式,因为它会改变他们的工作习惯。如果你的情况如此,那么或许你选错了职业。
在这个行业中,我们必须适应变化,最重要的是拥有大量的工具,使我们能够在任何情况下使用它。
观察一下简单计数器的代码,你需要记住几个变量:total、i、items、items.length、price;而在函数式实现中,我们只需要:total、price和items。如果你熟悉函数式运算符,那么它的阅读速度会非常快,而且很友好。
const items = [{
name: 'Coffe',
price: 500
}, {
name: 'Ham',
price: 1500
}, {
name: 'Bread',
price: 150
}, {
name: 'Donuts',
price: 1000
}
];
let total = 0;
for (let i = 0; i < items.length; i++) {
total += items[i].price;
}
const total = items
.map(({ price }) => price)
.reduce((total, price) => total + price);
使用方法链
当我们设计操作对象或数据流(在本例中为对象)的函数时,它们通常只执行单一任务,具有单一抽象级别,且没有副作用。然而,为了执行复杂的任务,我们需要将多个函数组合起来。因此,链式方法应运而生,因为它们可以让代码更易读,而这正是我们在设计函数时出色地完成先前“任务”所带来的副作用。
如果你了解 Linux,你就会明白所有命令都只负责完成一件事,并且要把这件事做好。然而,我们却拥有一个功能简单却运行复杂的操作系统。这得益于使用管道符来组合不同的命令。
在我们的具体案例中,无论是使用对象还是函数,我们都需要构建类似的功能。在下面的示例中,我们将演示 Car 类,其中链式方法与传统方法的区别。
class Car {
constructor({ make, model, color } = car) {
/* */
}
setMake(make) {
this.make = make;
}
setModel(model) {
this.model = model;
}
setColor(color) {
this.color = color;
}
save() {
console.log(this.make, this.model, this.color);
}
}
const car = new Car('WV','Jetta','gray');
car.setColor('red');
car.save();
class Car {
constructor({ make, model, color } = car){}
setMake(make) {
this.make = make;
return this;
}
setModel(model) {
this.model = model;
return this;
}
setColor(color) {
this.color = color;
return this;
}
save() {
console.log(this.make, this.model, this.color);
return this;
}
}
const car = new Car('WV','Jetta','gray')
.setColor('red')
.save();
结论
在这篇文章中,我们讨论了如何将干净的代码应用于开发人员的基本部分,我们在所有编程语言中都可以找到它:函数。
函数设计必须遵循简洁的代码规范,因为函数是解耦代码的基本元素。然而,函数设计中的不良实践可能会导致代码保持与没有函数时一样的耦合度,但引入函数却增加了复杂性。此外,糟糕的函数设计还会导致难以发现的严重错误。随着软件抽象级别的提升,定位错误发生点将变得更加困难。
因此,本文提出的建议或许能帮助你提升代码质量,但切勿在未经过充分思考的情况下贸然应用。记住,没有万能的秘诀或灵丹妙药,但有一套技巧可以帮助你解决更广泛的问题。
最后,我们讨论的要点如下:
- 使用默认参数而不是短路或条件。
- 函数参数(理想情况下为 2 个或更少)。
- 避免副作用——全局变量。
- 避免副作用——对象可变。
- 函数应该做一件事。
- 函数应该只有一个抽象级别。
- 更倾向于函数式编程而不是命令式编程。