函数作为程序中最小的、最重要的逻辑单元。好的函数能够大大降低阅读代码的困难度,提升代码的可读性。
1.什么是函数
函数是指一个量随着另一个量的变化而变化,或者说一个量中包含另一个量
以 f(x) = 2x + 1 为例,x是自变量,当 x = 2 时,f(x) = 5 ,f(x)是x的函数
2.软件中的函数
在英语中,Function一般代表函数式语言中的函数,而Method代表面向对象语言中的函数。
计算机编程中,函数的作用和数学中的定义类似
函数是一组代码的集合,是程序中最小的功能模块 。一次函数调用包括接收参数输入、数据处理、返回结果。
同一个函数可以被一个或多个函数调用多次
3.封装判断
好的函数应该是清晰易懂的,如果没有上下文,if和while语句中的布尔逻辑就难以理解。如果把解释条件意图作为函数抽离出来,用函数名把判断条件的语义显性化地表达出来,就能提升代码的可读性和可理解性
// 定义一个函数,把解释条件意图作为函数抽离 function canPickUpToPrivateSea(customer) { if (customer.getCrmUserId() === NIL_VALUE || customer.getCustomerGroup() === CustomerGroup.CANCEL_GROUP) { return false; } return true; } if (canPickUpToPrivateSea(customer)) { privateSea.pickUp(customer); }
4.函数参数
最理想的参数数量是零,其次是一元函数,再次是二元函数,尽量避免三元函数。这不是绝对的,有足够理由的时候也可以使用三元函数。
在程序设计中,一大忌讳就是教条。在某些场景下,两个参数可能比一个参数好。
例如:
// 创建 Point 对象 class Point { constructor(x, y) { this.x = x; this.y = y; } } // 实例化一个 Point 对象 const p = new Point(0, 0);
上述代码中,两个参数就比一个参数要合理,坐标系中的点就应该有两个参数。总体上来说,参数越少,越容易理解,函数也越容易使用和测试,因为各种参数的不同组合的测试用例是一个笛卡儿积。
如果函数需要3个以上参数,一般情况下就可以考虑将参数封装成类了。
例如,要绘制一条直线,可以用如下函数声明:
// 定义 makeLine 函数,接收起始点和结束点的坐标作为参数 function makeLine(startX, startY, endX, endY) { // 在这里执行绘制直线的操作 // 可以使用 startX, startY, endX, endY 来获取坐标值 // 返回 Line 对象或者其他操作 } // 使用示例 makeLine(0, 0, 100, 100);
上述代码中的X和Y是作为一组概念被共同传递的,我们应该为这一组概念提供一个新的抽象。
这样将参数对象化之后,参数的个数减少了,表达上也更加清晰。
// 定义 Point 类 class Point { constructor(x, y) { this.x = x; this.y = y; } } // 定义 makeLine 函数,接收两个 Point 对象作为参数来表示直线的起始点和结束点 function makeLine(start, end) { // 在这里执行绘制直线的操作 // 可以使用 start.x, start.y, end.x, end.y 来获取坐标值 // 返回 Line 对象或者其他操作 } // 使用示例 const startPoint = new Point(0, 0); //起始点坐标 const endPoint = new Point(100, 100); //结束点坐标 makeLine(startPoint, endPoint);
5.短小的函数
对超长方法的结构化分解是提升代码可读性最有效的方式之一,短小的函数更易于理解和维护。
大部分情况下,只需要把长方法改成多个短方法,代码的可读性就能大大提高,通常分解后的短方法可以显性化的解释代码的含义,一目了然。
函数的代码行数,没有绝对值,但最好不超过20行,这样代码可读性将大大提高。
6.职责单一
按照行数规定函数的长度是定量的做法,实际上,作者更喜欢另一种定性的衡量方法,即一个方法只做一件事情,也就是函数级别的单一职责原则(Single Responsibility Principle,SRP)。
遵循SRP不仅可以提升代码的可读性,还能提升代码的可复用性。
因为职责越单一,功能越内聚,就越有可能被复用,这和代码的行数没有直接的关联性,但是有间接的关联性。
然而短小的函数并不一定就意味着就不需要拆分,只要不满足SRP,就值得进一步分解。
哪怕分解后的子函数只有一行代码,只要有助于业务语义显性化的表达,就是值得的。
eg.给员工发工资
function pay(employees) { for (let i = 0; i < employees.length; i++) { const e = employees[i]; if (e.isPayDay()) { //检查是否该发工资 const pay = e.calculateDay(); //支付薪水 e.deliverDay(pay); } } }
这段代码非常短小,但实际上做了3件事情:遍历所有雇员,检查是否该发工资,然后支付薪水。
按照SRP的原则,以下面的方式改写更好:
//入口方法 function pay(employees) { for (let i = 0; i < employees.length; i++) { const e = employees[i]; payIfNecessary(e); } } //检查是否该发工资 function payIfNecessary(e) { if (e.isPayDay()) { calculateAndDeliverDay(e); } } //支付薪水 function calculateAndDeliverDay(e) { const pay = e.calculateDay(); e.deliverDay(pay); }
虽然原来的方法并不复杂,但按照SRP分解后的代码显然更加容易让人读懂,这种拆分是有积极意义的。
基本上,遵循SRP的函数都不会太长,再配上合理的命名,就不难得到我们想要的短小的函数。
7.精简辅助代码
所谓的辅助代码(Assistant Code),是程序运行中必不可少的代码,但又不是处理业务逻辑的核心代码,比如判空、打印日志、鉴权、降级和缓存检查等。
这些代码往往会在多个函数中重复冗余,减少辅助代码可以让代码显得更加干净整洁,易于维护。
模板设计模式,回调函数写logger能避免这个问题,让函数中的代码能直观地体现业务逻辑,而不是让业务代码淹没在辅助代码中。
如:优化判空代码、优化缓存代码、通过注解优雅降级、异常处理
如果辅助代码太多,会极大干扰代码的可读性,应该尽量减少辅助代码对业务代码的干扰,让函数中的代码更直观的体现业务逻辑
优化判空
下面来看一个简单的示例,假如我们要获取一个如下的稍有一定嵌套深度的属性值。
if (user !== null) { const address = user.getAddress(); if (address !== null) { const country = address.getCountry(); if (country !== null) { let isocode = country.getIsocode(); if (isocode !== null) { isocode = isocode.toUpperCase(); } } } }
我们可以使用Optional改造,来简化多层嵌套的属性访问和空值检查
const isocode = user?.getAddress()?.getCountry()?.getIsocode()?.toUpperCase();
上述代码,使用Optional Chaining 运算符 ?. 来检查每个属性是否存在。如果属性存在,则继续访问下一个属性。如果属性不存在,则返回 undefined。这样,整个属性链的访问只需要一行代码即可完成。
可以看到,新的写法比旧的判空方式在复杂度和简洁性上都提升了很多,简洁也是一种美。
优化缓存判断
我们可以使用缓存判断的方式来避免重复计算或请求。
如,通过使用 Promise.all 进行并行处理和优化缓存判断,可以有效地提高程序的性能和响应速度,特别是在面对大量数据或密集的异步操作时,这种优化显得尤为重要。 由于涉及到对多个无效数字进行异步处理,使用 Promise.all 可以让这些异步操作并行执行,而不需要等待前一个操作完成后再执行下一个操作。
async function getNumbers(numbers) { // 初始化存放有效数字和无效数字的数组 let validNumbers = []; let invalidNumbers = []; // 遍历传入的数字数组 for (let number of numbers) { try { // 检查当前数字是否有效 if (isValidNumber(number)) { validNumbers.push(number); // 将有效数字加入到 validNumbers 数组中 } else { invalidNumbers.push(number); // 将无效数字加入到 invalidNumbers 数组中 } } catch (error) { console.error(`Error processing number ${number}: ${error.message}`); invalidNumbers.push(number); // 处理异常情况,将数字加入到 invalidNumbers 数组中 } } // 处理无效数字并存放在 processedNumbers 数组中 let processedNumbers = []; for (let invalidNumber of invalidNumbers) { let processedNumber = await processInvalidNumber(invalidNumber); processedNumbers.push(processedNumber); } // 返回包含有效数字和处理后的无效数字的对象 return { validNumbers, processedNumbers }; }
使用 Promise.all 进行优化:
async function getNumbers(numbers) { // 初始化存放有效数字和无效数字的数组 let validNumbers = []; let invalidNumbers = []; // 遍历传入的数字数组 for (let number of numbers) { try { // 检查当前数字是否有效 if (isValidNumber(number)) { validNumbers.push(number); // 将有效数字加入到 validNumbers 数组中 } else { invalidNumbers.push(number); // 将无效数字加入到 invalidNumbers 数组中 } } catch (error) { console.error(`Error processing number ${number}: ${error.message}`); invalidNumbers.push(number); // 处理异常情况,将数字加入到 invalidNumbers 数组中 } } // 使用 Promise.all 对处理无效数字的异步操作进行并行处理 let processedNumbers = await Promise.all( invalidNumbers.map(async (invalidNumber) => { return await processInvalidNumber(invalidNumber); }) ); // 返回包含有效数字和处理后的无效数字的对象 return { validNumbers, processedNumbers }; }
优雅降级
在分布式环境下,一个功能往往需要多个服务的协作才能完成。对于那些可用性非常高的场景,有必要制定一个服务降级的策略,以便当其中一个服务不可用的时候,我们仍能够对外提供服务
在前端开发中,体现优雅降级的方式通常是在服务不可用或请求失败时提供一种合理的替代方案,而不是简单地抛出错误或停止服务。以下是一个示例,演示如何在服务不可用时优雅降级并提供默认的用户数据
// 模拟 UserService 类的前端实现 class UserService { async getUserById(id) { try { const response = await fetch(`http://USER-SERVICE/users/${id}`); const user = await response.json(); return user; } catch (error) { return this.defaultUser(); } } defaultUser() { return { id: -1, name: 'Default User' }; } } // 创建 UserService 实例 const userService = new UserService(); // 模拟调用 getUserById 方法 const userId = 123; userService.getUserById(userId) .then(user => { console.log('User:', user); }) .catch(error => { console.error('Error fetching user data:', error); });
8.组合函数模式
组合函数模式(Composed Method Pattern)出自Kent Beck的Smalltalk Best Practice Patterns一书,是一个非常容易理解上手、实用,对代码可读性和可维护性起到立竿见影效果的编程原则。
组合函数要求所有的公有函数(入口函数)读起来像一系列执行步骤的概要,而这些步骤的真正实现细节是在私有函数里面。组合函数有助于代码保持精炼并易于复用。阅读这样的代码就像在看一本书,入口函数是目录,目录的内容指向各自的私有函数,而具体的内容是在私有函数里实现的。
比如我们现在需要对某一个数据进行函数的调用,执行两个函数 fn1 和 fn2,这两个函数是依次进行的
那么如果每次我们都需要进行两个函数的调用,操作上就会显得很重复
我们可以将这两个函数组合起来,自动依次调用
这个过程就是对函数的组合,我们称之为“组合函数”
例1 (简单)
// 普通情况下: function double (num) { return num * 2 } function square (num) { return num * num } var count = 10 var result = square(double(count)) console.log(result) //400 // 组合函数情况下: function composeFn (d, s) { return function (count) { return s(d(count)) } } var newFn = composeFn(double, square) var result2 = newFn(10) console.log(result2)
例2 (进阶)
实现步骤:
- 首先创建一个myCompose函数,接收多个函数类型的参数使用…fns表示;
- 判断传入参数的类型是否全部都为函数类型,不是则抛出异常报错;
- myCompose函数内部返回一个函数compose,接收多个参数,使用…args表示;
- 函数compose内部定义一个result,之后判断传入参数的数量,如果是多个,则result为数组类型,如果为一个则result为一个值;
- 之后根据参数的数量选择if或else中执行,分别将参数传入fns的函数中执行调用,最后将result值返回
function myCompose (...fns) { // 1.首先看一下传入函数的个数,并判断传入的全部参数的类型都是函数类型,不是的话就抛出异常 var length = fns.length for (let i = 0; i < length; i++) { if (typeof fns[i] !== 'function') { throw new TypeError('Expected arguments are functions') } } // 2.返回一个compose函数 return function compose (...args) { // 定义一个空数组result,判断参数的数量,如果为空则置为undefined const result = args.length > 1 ? [] : undefined // 如果args参数的数量大于1,执行if中的语句 if (args.length > 1) { for (let i = 0; i < args.length; i++) { let index = 0 let r = fns[index].call(this, args[i]) while (++index < length) { //使用 while 循环依次将每一步的处理结果传递给下一个函数处理,最终得到最终的处理结果 r = fns[index].call(this, r) result.push(r) } } } else { let index = 0 // 此时的result为第一个函数执行的结果 result = length ? fns[index].apply(this, args) : args // 使用while将第一个函数执行的结果传递给第二个函数,获取到结果,以此传递下去,最终返回result while (++index < length) { result = fns[index].call(this, result) } } return result } } function double (num) { return num * 2 } function square (num) { return num * num } // 传入两个函数参数 var newFn = myCompose(double, square) // 传入一个参数调用newFn函数 var result1 = newFn(10) // 传入多个参数调用newFn函数 var result2 = newFn(10, 20, 30) console.log(result1)// 400 console.log(result2)// [ 400, 1600, 3600 ]
9.抽象层次一致性 SLAP
组合函数要求将一个大函数拆分成多个子函数组合,SLAP要求函数体中的内容必须在同一个抽象层次上。
如果高层次抽象和底层细节杂糅在一起,就会显得凌乱,难以理解。
举个例子,假如有一个冲泡咖啡的原始需求,其制作咖啡的过程分为3步。
(1)倒入咖啡粉。
(2)加入沸水。
(3)搅拌。
function makeCoffee() { pourCoffeePower(); pourWater(); stir(); }
如果要加入新的需求,比如需要允许选择不同的咖啡粉,以及选择不同的风味,那么代码就会变成这样
function makeCoffe(isMikCoffee, isSweetTooth, type){ //选择咖啡粉 if(type === 'CAPPUCCTION'{ pourCappuccionPowder(); } else if(type === 'BLACK'){ pourBlackPower(); } …… //加入沸水 pourWater(); //选择口味 if(isMikCoffee){ pourMik(); } if(isSweetTooth){ addSugar(); } //搅拌 Stir(); }
如果继续有更多的需求加入,那么代码会进一步恶化,最后变成一个谁也看不懂且难以维护的逻辑迷宫。
再回看上面的代码,新需求的引入当然是根本原因。
但除此之外,另一个原因是新代码已经不再满足SLAP了。
具体选择用什么样的咖啡粉是倒入咖啡粉这个步骤应该去考虑的实现细节,和主流程步骤不在一个抽象层次上。
按照组合函数和SLAP原则,我们要在入口函数中只显示业务处理的主要步骤。具体的实现细节通过私有方法进行封装,并通过抽象层次 一致性来保证,一个函数中的抽象在同一个水平上,而不是高层抽象和 实现细节混杂在一起。
根据SLAP原则,我们可以将代码重构为:
//入口函数 function makeCoffe(isMikCoffee, isSweetTooth, type){ //选择咖啡粉 pourCoffeePowder(type); //加入沸水 pourWater(); //选择口味 flavor(isMilkCoffee,isSweetTooth); //搅拌 Stir(); } //选择咖啡粉 pourCoffeePowder(type){ if(type === 'CAPPUCCTION'){ pourCappuccionPowder(); } else if(type === 'BLACK'){ pourBlackPower(); } …… } //选择口味 function flavor(isMilkCoffee, isSweetTooth) { if (isMilkCoffee) { pourMilk(); } if (isSweetTooth) { addSugar(); } } ... // 示例调用 makeCoffee(true, true, 'CAPPUCCTION'); // 调用函数开始制作卡布奇诺咖啡,加牛奶和糖 makeCoffee(false, false, 'BLACK'); // 调用函数开始制作黑咖啡,不加牛奶和糖
重构后的makeCoffee()又重新变得整洁如初了,满足了SLAP,实际上是构筑了代码结构的金字塔。金字塔结构是一种自上而下的,符合人类思维逻辑的表达方式。关于金字塔原理的更多内容,请参考8.5.3节。在构筑金字塔的过程中,要求金字塔的每一层要属于同一个逻辑范畴、同一个抽象层次。在这一点上,金字塔原理和SLAP是相通的,世界就是如此奇妙,很多道理在不同的领域同样适用
10.函数式编程
函数式编程和面向对象编程并没有本质上的区别。在函数式编程中,函数不仅可以调用函数,而且还可以作为参数被其它函数调用。它和传递对象的唯一区别就是只有方法(函数)没有属性(数据)
函数式编程中最重要的特征之一,就是你可以把函数(你的代码)作为参数传递给另一个函数。为什么这个功能很重要呢?主要有以下两个原因。减少冗余代码,让代码更简洁、可读性更好。函数是“无副作用”的,即没有对共享的可变数据操作,可以利用多核并行处理,而不用担心线程安全问题。
四种不同方式的代码实现:
- 经典类实现
- 匿名类实现
- Lamda实现
- 方法引用实现
例子:实现 String 转换 Integer 的功能。
经典类实现
// 定义经典类实现 class StrToIntClass { apply(s) { return parseInt(s); } } // 创建实例并赋值给函数变量 let strToIntClass = new StrToIntClass(); let result = strToIntClass.apply("42"); console.log(result); // 输出 42,将字符串 "42" 转换为整数 42
匿名类实现
这个对象没有明确的名称,而是直接定义在变量 strToIntClass 的赋值语句中。这种方式适合简单的对象定义,不需要额外的类声明,更加简洁和灵活。
匿名对象字面量的使用有几个优点:
- 简洁性: 通过匿名对象字面量可以直接定义对象的属性和方法,不需要单独定义一个类或函数,使得代码更加简洁。
- 局部性: 在这种情况下,只需要一个用途简单的对象来实现特定功能,没有必要为其创建一个具名的类。
- 直接性: 将对象字面量赋值给变量后,可以直接通过变量使用对象的属性和方法,代码更加直接易懂。
// 使用匿名对象字面量来进行字符串转换 let strToIntClass = { apply: function(s) { return parseInt(s); } }; let result = strToIntClass.apply("42"); console.log(result); // 输出 42,将字符串 "42" 转换为整数 42
上述代码,匿名对象字面量 strToIntClass中的 apply 方法简洁地完成了字符串转换为整数的操作
Lamdba实现
Lambda 表达式是一种匿名函数,它可以在编程语言中作为值进行传递和使用。Lambda 表达式通常用于函数式编程中,它可以简洁地表示一个函数,并且可以作为参数传递给其他函数或方法。
在 JavaScript 中,箭头函数就是一种 Lambda 表达式的形式。它使用箭头符号 =>
来分隔参数列表和函数体,例如 x => x * 2
Lambda 表达式的使用可以让代码更加简洁和易读。
// 使用箭头函数来进行字符串转换 let strToIntClass = s => parseInt(s); let result = strToIntClass("42"); console.log(result); // 输出 42,将字符串 "42" 转换为整数 42
一个系统容易腐化的部分正是函数,不解决函数的复杂性,就很难解决系统的复杂性。