适合所有人的简单 TypeScript 函数式编程技术
最初发表于deepu.tech。
函数式编程 (FP) 被大肆炒作,很多酷炫的年轻人都在尝试,但它并非灵丹妙药。与其他编程范式/风格一样,函数式编程也有其优缺点,人们可能更喜欢其中一种范式。如果您是 TypeScript/JavaScript 开发人员,并且想要尝试函数式编程,不用担心,您无需学习 Haskell 或 Clojure 等面向函数式编程的语言,因为 JavaScript 和 TypeScript 已经涵盖了您的需求,这篇文章正是为您准备的。
如果您正在寻找 Java 或 Golang 中的函数式编程,请查看该系列的其他帖子。
我不会深入探讨所有函数式编程的概念,而是会重点介绍 TypeScript 中符合函数式编程概念的操作。我也不会泛泛地讨论函数式编程的优缺点。
请记住,虽然这篇文章是关于 TypeScript 的,但您也可以在 JavaScript 中轻松地执行相同的操作,因为 TypeScript 只是 JavaScript 的类型超集。
什么是函数式编程?
根据维基百科,
函数式编程是一种编程范式——一种构建计算机程序结构和元素的风格——它将计算视为数学函数的评估,并避免改变状态和可变数据。
因此,在函数式编程中,有两个非常重要的规则
- 无数据突变:这意味着数据对象在创建后不应更改。
- 无隐式状态:应避免使用隐藏/隐式状态。在函数式编程中,状态不会被消除,而是会使其变得可见且显式。
这意味着:
- 无副作用:函数或操作不应改变其功能范围之外的任何状态。也就是说,函数应该只向调用者返回一个值,并且不应影响任何外部状态。这意味着程序更容易理解。
- 仅限纯函数:函数式代码是幂等的。函数应该仅根据传入的参数返回值,并且不应影响(副作用)或依赖于全局状态。这样的函数对于相同的参数始终产生相同的结果。
除此之外,下面还有一些可以在 TypeScript 中应用的函数式编程概念,我们将在下文中讨论这些概念。
使用函数式编程并不意味着全有或全无,你始终可以使用函数式编程概念来补充 TypeScript 中的面向对象概念。无论你使用哪种范式或语言,都可以尽可能地利用函数式编程的优势。这正是我们将要看到的。
TypeScript 中的函数式编程
TypeScript 不是纯粹的函数式语言,但它提供了许多与函数式语言相符的概念,所以让我们看看如何在 TypeScript 中应用上面的一些函数式编程概念。
一等函数和高阶函数
一等函数(函数是一等公民)意味着你可以将函数赋值给变量,将函数作为参数传递给另一个函数,或者从另一个函数返回一个函数。TypeScript 支持这种特性,因此闭包、柯里化和高阶函数等概念更容易编写。
只有当一个函数以一个或多个函数作为参数,或者返回另一个函数作为结果时,它才被视为高阶函数。
在 TypeScript 中,这很容易做到
type mapFn = (it: string) => number;
// The higher-order-function takes an array and a function as arguments
function mapForEach(arr: string[], fn: mapFn): number[] {
const newArray: number[] = [];
arr.forEach(it => {
// We are executing the method passed
newArray.push(fn(it));
});
return newArray;
}
const list = ["Orange", "Apple", "Banana", "Grape"];
// we are passing the array and a function as arguments to mapForEach method.
const out = mapForEach(list, (it: string): number => it.length);
console.log(out); // [6, 5, 6, 5]
但是在 JavaScript/TypeScript 中,我们也可以使用内置函数方法(如 map、reduce 等)来简单地做到这一点。
const list = ["Orange", "Apple", "Banana", "Grape"];
// we are passing a function as arguments to the built-in map method.
const out = list.map(it => it.length);
console.log(out); // [6, 5, 6, 5]
TypeScript 中也可以使用闭包和柯里化
// this is a higher-order-function that returns a function
function add(x: number): (y: number) => number {
// A function is returned here as closure
// variable x is obtained from the outer scope of this method and memorized in the closure
return (y: number): number => x + y;
}
// we are currying the add method to create more variations
var add10 = add(10);
var add20 = add(20);
var add30 = add(30);
console.log(add10(5)); // 15
console.log(add20(5)); // 25
console.log(add30(5)); // 35
TypeScript/JavaScript 中也内置了许多声明式高阶函数,例如、、map
等等。此外,还有许多库提供了可在 TypeScript/JavaScript 中使用的函数式接口。reduce
forEach
filter
纯函数
正如我们已经看到的,纯函数应该仅根据传递的参数返回值,并且不应影响或依赖于全局状态。在 TypeScript 中可以轻松做到这一点。
这很简单,如下所示,这是一个纯函数。对于给定的输入,它总是返回相同的输出,并且其行为高度可预测。如果需要,我们可以安全地缓存该方法。
function sum(a: number, b: number): number {
return a + b;
}
如果我们在这个函数中添加一行额外的代码,行为就会变得不可预测,因为它现在会产生影响外部状态的副作用。
const holder = {};
function sum(a: number, b: number): number {
let c = a + b;
holder[`${a}+${b}`] = c;
return c;
}
所以尽量保持函数的简洁和纯粹。使用ESLint和typescript-eslint等工具可以实现这一点。
递归
函数式编程更倾向于使用递归而不是循环。让我们看一个计算数字阶乘的例子。
在传统的迭代方法中:
function factorial(num: number): number {
let result = 1;
for (; num > 0; num--) {
result *= num;
}
return result;
}
console.log(factorial(20)); // 2432902008176640000
可以使用如下所示的递归来完成相同的操作,这在函数式编程中很受欢迎。
const factorial = (num: number): number =>
num == 0 ? 1 : num * factorial(num - 1);
console.log(factorial(20)); // 2432902008176640000
递归方法的缺点是,它在大多数情况下比迭代方法更慢(我们追求的优势是代码简单性和可读性),并且可能导致堆栈溢出错误,因为每个函数调用都需要作为堆栈的帧保存。为了避免这种情况,最好使用尾递归,特别是当递归执行次数过多时。在尾递归中,递归调用是函数执行的最后操作,因此编译器不需要保存函数堆栈帧。大多数编译器可以像优化迭代代码一样优化尾递归代码,从而避免性能损失。尾调用优化是 ECMAScript 规范的一部分,但不幸的是,大多数 JavaScript 引擎尚不支持此功能。
现在使用尾递归,可以按如下所示编写相同的函数,但根据引擎的不同,这可能不会被优化,尽管有解决方法,但在基准测试中它仍然表现更好。
const factorialTailRec = (num: number): number => factorial(1, num);
const factorial = (accumulator: number, val: number): number =>
val == 1 ? accumulator : factorial(accumulator * val, val - 1);
console.log(factorialTailRec(20)); // 2432902008176640000
在编写 TypeScript 代码时,请考虑使用递归以实现可读性和不变性,但如果性能至关重要或者迭代次数巨大,请使用标准循环。
惰性求值
惰性求值或非严格求值是指将表达式的求值延迟到需要时才进行的过程。通常,TypeScript 会进行严格/急切求值,但对于像 这样的操作数&&
,||
它?:
会进行惰性求值。我们可以利用短路、高阶函数、闭包和记忆化技术来进行惰性求值。
以这个 TypeScript 急切地评估一切的例子来说。
function add(x: number): number {
console.log("executing add"); // this is printed since the functions are evaluated first
return x + x;
}
function multiply(x: number): number {
console.log("executing multiply"); // this is printed since the functions are evaluated first
return x * x;
}
function addOrMultiply(
add: boolean,
onAdd: number,
onMultiply: number
): number {
return add ? onAdd : onMultiply;
}
console.log(addOrMultiply(true, add(4), multiply(4))); // 8
console.log(addOrMultiply(false, add(4), multiply(4))); // 16
这将产生以下输出,我们可以看到这两个函数始终执行
executing add
executing multiply
8
executing add
executing multiply
16
我们可以使用高阶函数将其重写为惰性求值版本
function add(x: number): number {
console.log("executing add");
return x + x;
}
function multiply(x: number): number {
console.log("executing multiply");
return x * x;
}
type fnType = (t: number) => number;
// This is now a higher-order-function hence evaluation of the functions are delayed in if-else
function addOrMultiply(
add: boolean,
onAdd: fnType,
onMultiply: fnType,
t: number
): number {
return add ? onAdd(t) : onMultiply(t);
}
console.log(addOrMultiply(true, add, multiply, 4));
console.log(addOrMultiply(false, add, multiply, 4));
输出如下,我们可以看到只执行了必需的函数
executing add
8
executing multiply
16
或者像这样通过记忆
const cachedAdded = {};
function add(x: number): number {
if (cachedAdded[x]) {
return cachedAdded[x];
}
console.log("executing add");
const out = x + x;
cachedAdded[x] = out;
return out;
}
const cachedMultiplied = {};
function multiply(x: number): number {
if (cachedMultiplied[x]) {
return cachedMultiplied[x];
}
console.log("executing multiply");
const out = x * x;
cachedMultiplied[x] = out;
return out;
}
function addOrMultiply(
add: boolean,
onAdd: number,
onMultiply: number
): number {
return add ? onAdd : onMultiply;
}
console.log(addOrMultiply(true, add(4), multiply(4))); // 8
console.log(addOrMultiply(false, add(4), multiply(4))); // 16
输出如下,我们可以看到函数对于相同的值只执行了一次
executing add
executing multiply
8
16
请注意,只有当您的函数纯粹且引用透明时,记忆技术才会起作用。
还有其他方法可以进行像这样的惰性求值。有时,在 TypeScript 中进行惰性求值可能不值得增加代码复杂性,但如果所涉及的函数在处理方面很繁重,那么绝对值得对其进行惰性求值。
类型系统
TypeScript 拥有强大的类型系统和强大的类型推断能力。虽然底层 JavaScript 本身是弱类型的,但 TypeScript 和兼容的 IDE 可以弥补这一缺陷。
引用透明度
来自维基百科:
函数式程序没有赋值语句,也就是说,函数式程序中变量的值一旦定义就不会改变。这消除了任何副作用的可能性,因为任何变量都可以在执行的任何时刻被替换为其实际值。因此,函数式程序是引用透明的。
遗憾的是,在 JavaScript 中,严格限制数据变异的方法并不多,但通过使用纯函数,并明确避免数据变异和重新赋值(我们之前提到的其他概念),可以实现这一点。JavaScript 默认按值传递原始变量,按引用传递对象,因此我们需要注意不要在函数内部修改数据。也可以考虑使用Immutable JSconst
之类的库。尽可能避免重新赋值。
例如,下面将产生一个错误
const list = ["Apple", "Orange", "Banana", "Grape"];
list = ["Earth", "Saturn"];
但是当变量保存对其他对象的引用时,这将无济于事,例如,下面的突变将无论const
关键字如何都会起作用。
const list = ["Apple", "Orange", "Banana", "Grape"];
list.push("Earth"); // will mutate the list
list.push("Saturn"); // will mutate the list
const
关键字允许引用变量的内部状态发生变异,因此从函数式编程的角度来看,const
关键字仅对原始常量和捕获重新分配有用。
然而,使用 TypeScript,我们可以使用特殊的映射类型将对象设置为只读,从而避免在编译时捕获的意外数据突变。感谢@stereobooster和@juliang指出这一点。阅读我关于映射类型和条件类型的文章了解更多信息。
const list: Readonly<string[]> = ["Apple", "Orange", "Banana", "Grape"];
list.push("Earth"); // will cause compilation error
或者
const list: ReadonlyArray<string> = ["Apple", "Orange", "Banana", "Grape"];
list.push("Earth"); // will cause compilation error
其他可以遵循的技术包括使用Object.freeze或内置方法(例如 map、reduce、filter 等),因为它们不会改变数据。我们也可以使用这个 ESlint 插件来限制数据改变。
数据结构
使用函数式编程技巧时,建议使用类似 Stacks、Maps 和 Queues 等同样具有函数式实现的数据类型。
因此,在函数式编程中,Map 作为数据存储方式比数组或哈希集更佳。
结论
对于那些尝试在 TypeScript 中应用一些函数式编程技术的人来说,这只是一个入门介绍。TypeScript 可以实现更多功能,而且随着 ECMAScript 的不断发展,这应该会更加容易。正如我之前所说,函数式编程并非万能的灵丹妙药,但它提供了许多有用的技术,使代码更易于理解、维护和测试。它可以与命令式和面向对象的编程风格完美共存。事实上,我们都应该充分利用所有优点。
希望本文对您有所帮助。如果您有任何疑问,或者觉得我遗漏了什么,请留言。
如果您喜欢这篇文章,请点赞或留言。
文章来源:https://dev.to/deepu105/easy-functioning-programming-techniques-in-typescript-for-everyone-1bl2