JavaScript 函数式编程是指使用函数来进行编程的一种范式。在函数式编程中,函数被视为一等公民,可以作为变量、参数和返回值来使用。虽然 JavaScript 函数式编程并不是纯粹的函数式编程,但它借鉴了很多函数式编程语言的思想,并使得 JavaScript 也可以使用函数式编程的技巧来编写代码。
以下是 JavaScript 函数式编程的一些主要特点:
- 纯函数:函数式编程的核心思想之一是函数的可重用性。为了实现这一目标,函数式编程要求函数是纯函数,即相同的输入会产生相同的输出,且没有副作用。
- 高阶函数:函数式编程鼓励使用高阶函数,即接受一个或多个函数为参数或者返回一个函数作为结果的函数。使用高阶函数可以提高代码的灵活性和复用性。
- 函数组合:函数式编程支持函数组合,即将多个函数按照一定的顺序和方式组合起来,形成新的函数。函数组合可以让代码更加简洁、清晰、易于测试和复用。
- 惰性计算:函数式编程通常采用惰性计算的方式,即只有在必要的时候才计算结果。这种方式可以提高程序的效率和性能。
- 不可变数据:函数式编程强调使用不可变的数据结构。这种数据结构在被创建之后不能被修改,只能通过复制来创建新的数据结构。使用不可变数据可以提高代码的简洁性、健壮性和安全性。
在 JavaScript 中,可以使用一些库和框架来实现函数式编程,比如 Lodash、Underscore、Ramda 和 RxJS 等。这些库和框架提供了许多高阶函数、函数组合、惰性计算和不可变数据等特性,可以帮助开发者更好地应用函数式编程思想,编写出高效、健壮、清晰和易于维护的代码。
1、Arity
JavaScript 函数式编程中的高端 Arity 是指函数接受参数的个数。Arity 分为三种类型:一元(Unary)
、二元(Binary)
和多元(Variadic)
。这个单词是由 -ary 与 -ity 两个后缀拼接而成。
在 JavaScript
中,函数可以接受任意数量的参数,因此它们实际上都是多元函数。然而,在函数式编程中,我们通常建议使用一元或二元函数,因为这样可以提高代码的可读性、可维护性和复用性。
- 一元函数:一元函数是指只接受一个参数的函数,或者说它的
arity
是1。使用一元函数可以避免函数之间的依赖关系,并减少不必要的引用和副作用。以下是一个例子:
// 一元函数 const increment = x => x + 1; increment(2); // 3
- 二元函数:二元函数是指接受两个参数的函数,或者说它的
arity
是2。使用二元函数可以使代码更加清晰和易于理解,并且可以方便地进行函数组合。以下是一个例子:
// 二元函数 const add = (x, y) => x + y; add(2, 3); // 5
多元函数:多元函数是指接受多个参数的函数。使用多元函数可以提高函数的灵活性和复用性,但也容易导致函数难以理解和维护。以下是一个例子:
// 多元函数 const sum = (...args) => args.reduce((acc, val) => acc + val, 0); sum(1, 2, 3); // 6 sum(1, 2, 3, 4, 5); // 15
可以看出,使用一元或二元函数可以使代码更加简洁、清晰和易于理解。多元函数可能会导致代码难以理解和维护,但是在某些情况下也是必须的。因此,在编写函数式代码时,我们应该根据具体情况选择适当的 Arity 类型。
2、高阶函数(Higher-Order-Function / HOF)
JavaScript 函数式编程中的高阶函数指的是接受一个或多个函数作为参数并返回一个新函数的函数。这种方式可以提高代码的灵活性和复用性,并且使我们能够更好地组合函数。
以下是一个简单的例子,演示如何将两个数字相加:
const add = (a, b) => a + b; add(2, 3); // 输出:5
但是,当我们需要对同一组数字执行多个操作时,代码将变得冗长而难以维护。这时候就可以使用高阶函数来优化代码:
const add = (a, b) => a + b; const subtract = (a, b) => a - b; const calculate = (fn, a, b) => fn(a, b); calculate(add, 2, 3); // 输出:5 calculate(subtract, 5, 2); // 输出:3
在上面的例子中,我们定义了两个函数 add
和 subtract
,然后定义了一个高阶函数 calculate
,它接受一个函数和两个数字作为参数,并返回对这些数字进行指定操作(由传递的函数指定)的结果。
而且,高阶函数不仅限于接受函数作为参数。它们还可以将函数作为返回值返回。以下是一个简单示例,演示如何动态地生成函数:
const multiplyBy = num => { return x => x * num; } const triple = multiplyBy(3); triple(2); // 输出:6
在上面的例子中,我们定义了一个 multiplyBy
的高阶函数,它接受一个数字作为参数并返回一个以该数字为乘数的新函数。然后我们将它用于生成一个 triple
函数,该函数将数字乘以 3。
这是 JavaScript 中的一个非常基本和强大的概念,并且经常出现在函数式编程的代码中。根据需要选择和使用高阶函数可以使我们编写更具表现力、灵活和可维护的代码。
3、闭包(Closure)
JavaScript 函数式编程中的闭包(closure)指的是函数和函数声明时所在的词法作用域之间的组合。这使得函数可以访问其声明时定义的变量,即使在不同的执行环境下执行。
以下是一个简单的示例,演示如何使用闭包:
function counter() { let count = 0; return function() { count++; console.log(count); } } const increment = counter(); increment(); // 输出:1 increment(); // 输出:2 increment(); // 输出:3
在上面的代码中,我们定义了 counter 函数,它返回了一个新函数。该新函数通过闭包访问了在 counter 函数内部定义的变量 count。每次调用新函数时,count 的值都会增加并显示在控制台上。
闭包在 JavaScript 中非常有用,可以帮助我们隐藏数据并控制其公开。例如,在 JavaScript 中,可以使用闭包创建私有变量和方法。以下是另一个示例,演示如何使用闭包来创建一个计数器对象:
function createCounter() { let count = 0; return { increment: function() { count++; }, decrement: function() { count--; }, getCount: function() { return count; } }; } const myCounter = createCounter(); myCounter.increment(); myCounter.increment(); console.log(myCounter.getCount()); // 输出:2
在上述代码中,我们使用闭包定义了一个具有增加、减少和获取计数器值功能的对象。这个对象可以被使用者访问,但是 count
变量却无法被外部直接访问到,从而实现了数据的私有化。
需要注意的是,闭包会带来一些性能问题,因为它允许在内存中保留额外的引用。过多使用闭包可能会导致内存泄漏等问题。因此,在使用闭包时,我们必须小心地管理内存并考虑它们的影响。
Lamda 和 闭包
当谈到lambda和闭包时,有些人会将它们视为同一概念,但实际上它们是不同的。下面是它们之间的区别:
Lambda指的是一种匿名函数,它可以在任何需要函数的地方被使用。Lambda函数通常与高阶函数结合使用,例如map
、filter
和reduce
等。
Lambda函数没有名称,并且通常只在定义处使用,因此它们称为匿名函数。它们可以接受参数,返回值甚至可以被赋值给变量。Lambda函数非常适合于需要使用单次执行的简单功能的情况。
以下是一个使用Lambda函数的示例:
const numbers = [1, 2, 3, 4, 5]; const doubles = numbers.map((num) => num * 2); console.log(doubles); // 输出 [2, 4, 6, 8, 10]
在上面的代码中,我们使用了Lambda函数来将数组中的每个数字翻倍。
而闭包指的是函数与其声明外部的引用环境之间的关系。当一个函数返回另一个函数时,内部函数可以访问父函数的作用域内的变量,并且这些变量在调用完毕后仍然存在。这些变量和函数一起形成了一个闭包。
闭包通常用于封装数据和功能,以便在代码中安全地使用它们。使用闭包可以避免在全局范围内暴露变量和函数,从而减少命名冲突和其他问题。
3、偏函数应用 (Partial Application)
偏函数是一种将多个参数的函数转换为接受部分参数的新函数的技术。它基于现有函数创建一个新函数,该新函数在调用时只需要提供其定义中的一些参数,而不是所有必需的参数。
JavaScript中的偏函数可以使用bind
方法来实现。bind
方法将一个对象绑定到函数上,并返回一个新的函数,该新函数具有指定对象作为this关键字的值,并且函数的参数已经部分应用。
以下是一个使用偏函数的简单示例:
function multiply(a, b) { return a * b; } const double = multiply.bind(null, 2); console.log(double(5)); // 输出 10
在上面的代码中,我们使用bind
方法创建了一个新函数double
,它是原始函数multiply
的偏函数。我们将数字2绑定到multiply
函数上,这意味着我们可以在调用double
函数时省略第一个参数,并且它将自动为我们计算每个操作数的乘积。
偏函数在JavaScript中具有广泛的应用,特别是在函数式编程中。由于函数式编程强调无状态和函数的纯性,因此偏函数允许我们编写通用的、可组合的函数,它们可以轻松地适应各种输入。
以下是一个使用偏函数的更高级示例:
function filter(predicate, list) { return list.filter(predicate); } const isEven = (num) => num % 2 === 0; const filterEvens = filter.bind(null, isEven); console.log(filterEvens([1, 2, 3, 4, 5])); // 输出 [2, 4]
在上面的代码中,我们定义了一个filter
函数,它接受一个谓词函数和一个列表,并返回满足条件的元素的新列表。然后,我们使用bind
方法创建一个新函数filterEvens
,该函数是filter
函数的偏函数,其第一个参数已经被绑定为isEven
函数。这个新函数可以被用于任何需要过滤出偶数的列表。
4、柯里化(Currying)
柯里化是一种将接受多个参数的函数转换为一系列只接受单个参数的函数的技术。这使我们更容易创建可组合、通用和模块化的函数。
JavaScript中的柯里化可以使用函数闭包和递归来实现。下面是一个简单的示例:
function multiply(a) { return function (b) { return a * b; } } const double = multiply(2); console.log(double(5)); // 输出 10
4、柯里化(Currying)
柯里化是一种将接受多个参数的函数转换为一系列只接受单个参数的函数的技术。这使我们更容易创建可组合、通用和模块化的函数。
JavaScript中的柯里化可以使用函数闭包和递归来实现。下面是一个简单的示例:
function multiply(a) { return function (b) { return a * b; } } const double = multiply(2); console.log(double(5)); // 输出 10
在上面的代码中,我们定义了一个multiply
函数,它返回一个新函数,该新函数乘上传入的参数并返回结果。然后,我们使用multiply
函数创建一个新函数double
,该函数将数字2绑定到第一个参数上。当我们调用double
函数时,它返回的是一个新函数,该新函数接收第二个参数并计算它们的乘积。
柯里化在JavaScript中的应用非常广泛,它是函数式编程范式的一个重要组成部分,并且经常被用于编写高阶函数和局部应用程序(Partial Application)。以下是一个使用柯里化的常见应用程序:
4.1 柯里化应用
4.1.1 高阶函数
高阶函数是指可以接受函数作为参数或返回函数的函数。柯里化可以让我们轻松地创建高阶函数,以便更有效地处理输入。以下是一个使用柯里化的高阶函数示例:
function map(fn) { return function (list) { return list.map(fn); } } const double = (num) => num * 2; const doubleList = map(double); console.log(doubleList([1, 2, 3, 4, 5])); // 输出 [2, 4, 6, 8, 10]
在上面的代码中,我们定义了一个map
函数,它接受一个函数和一个列表,并返回新列表。然后,我们使用map
函数创建一个新函数doubleList
,该函数是map
函数的偏函数,其第一个参数已经被绑定为double
函数。
4.1.2 局部应用程序
局部应用程序是指将函数与一组参数“部分应用”(即部分传递)并返回一个新函数的过程。柯里化可以让我们轻松地实现局部应用程序,以便更有效地重用功能。以下是一个使用柯里化的局部应用程序示例:
function filter(predicate, list) { return list.filter(predicate); } const isEven = (num) => num % 2 === 0; const filterEvens = filter.bind(null, isEven); console.log(filterEvens([1, 2, 3, 4, 5])); // 输出 [2, 4]
在上面的代码中,我们定义了一个filter
函数,它接受一个谓词函数和一个列表,并返回满足条件的元素的新列表。然后,我们使用bind
方法创建一个新函数filterEvens
,该函数是filter
函数的偏函数,其第一个参数已经被绑定为isEven
函数。这个新函数可以被用于任何需要过滤出偶数的列表。
4.2 自动柯里化 (Auto Currying)
将一个包含多个参数的函数转换成另一个函数,这个函数如果被给到的参数少于正确的数量,就会返回一个接受剩余参数的函数。
lodash & Ramda 有一个curry
函数可以做到这一点。
const add = (x, y) => x + y const curriedAdd = _.curry(add) curriedAdd(1, 2) // 3 curriedAdd(1)(2) // 3 curriedAdd(1) // (y) => 1 + y
推荐阅读:
5、函数组合(Function Composition)
函数组合是指将两个或多个函数结合在一起以产生一个新函数的技术。这允许我们将较小的、可复用的函数组合成更大的、更具表现力的函数。在函数式编程中,函数组合通常使用两种方法来实现:管道运算符(Pipeline Operator)和compose函数。
5.1 管道运算符
管道运算符|>
是ES2021新增的语法,它可以让我们轻松实现函数组合。它与Unix中的管道符号非常相似,在Unix中,管道符号允许我们将一个命令的输出连接到另一个命令的输入,而在JavaScript中,管道运算符允许我们将一个函数的输出作为下一个函数的输入。
以下是一个使用管道运算符实现函数组合的示例:
const add = (a, b) => a + b; const square = (n) => n * n; const result = 3 |> add(2) |> square; console.log(result); // 输出 25
在上面的代码中,我们定义了两个函数add
和square
,然后使用管道运算符将它们组合成一个新函数,并对数字3进行操作。
5.2 compose函数
compose函数是一个接受两个或多个函数作为参数并返回一个新函数的高阶函数。该新函数将把传入的值作为最右边的函数的输入,并将结果带回到最左边的函数。以下是一个使用compose函数实现函数组合的示例:
const add = (a, b) => a + b; const square = (n) => n * n; const compose = (...fns) => (arg) => fns.reduce((acc, cur) => cur(acc), arg); const result = compose(square, add.bind(null, 2))(3); console.log(result); // 输出 25
在上面的代码中,我们定义了两个函数add
和square
,然后使用bind
方法将add
函数柯里化并绑定数字2到第一个参数上。接下来,我们定义了一个compose
函数,它接受多个函数作为参数,然后返回一个新函数。最后,我们使用compose
函数将add
和square
组合成一个新函数,并对数字3进行处理。
函数组合在函数式编程中非常常见,这是因为它允许我们轻松创建可组合、通用和模块化的函数,从而更好地管理复杂性和提高代码的可读性。
6、纯函数(Purity)
在 JavaScript 函数式编程中,纯函数是不能改变任何外部状态或造成副作用的函数。它们只会接收输入,并返回输出,不会对任何外部环境进行修改。纯函数在函数式编程中非常重要,因为它们可以确保代码的可测试性、可维护性和易读性。
下面是一个简单的例子,展示了纯函数与非纯函数之间的区别:
let count = 0; // 非纯函数 function increment() { count++; return count; } // 纯函数 function add(a, b) { return a + b; }
在上面的代码中,increment
函数增加了全局变量 count
的值,这使得它成为一个非纯函数,因为它对外部环境造成了影响。相反,add
函数完全通过其参数来生成结果,这使它成为一个纯函数。
应用:纯函数具有许多好处,其中一些包括:
- 可缓存性:由于纯函数的输出只取决于其输入,所以我们可以将它们的执行结果缓存起来,以避免重复计算。
- 可测试性:因为纯函数不依赖于外部状态,所以它们更容易被测试和调试,也更容易推理。
- 并行代码:由于纯函数没有任何共享状态,所以它们更容易在并行环境中工作。
下面的例子演示了如何使用纯函数来处理数组。我们可以使用 map()
或 filter()
等高阶函数来操作数组,并返回一个新的数组,而不改变原始数组。
// 非纯函数 let numbers = [1, 2, 3, 4]; function double() { for (let i = 0; i < numbers.length; i++) { numbers[i] *= 2; } } double(); console.log(numbers); // 输出 [2, 4, 6, 8] // 纯函数 let numbers = [1, 2, 3, 4]; function double(num) { return num * 2; } const doubledNumbers = numbers.map(double); console.log(doubledNumbers); // 输出 [2, 4, 6, 8] console.log(numbers); // 输出 [1, 2, 3, 4]
以上代码中,double()
函数是一个非纯函数,它会修改全局变量 numbers
,而 double(num)
是一个纯函数,它只是简单地将传入的参数乘以2并返回结果。通过使用 map()
函数,我们可以避免对原始数组进行修改,并返回一个新数组doubledNumbers
。
总之,在 JavaScript 的函数式编程中,尽可能使用纯函数,可以使你的代码更加维护和可读,并提高代码的可测试性和健壮性。
7、副作用(Side Effects)
在JavaScript的函数式编程中,副作用(Side Effects)是指函数改变了程序外部状态的行为。也就是说,在函数内部执行的操作会影响到函数之外的代码,并且这些影响是不可避免的。
下面是一个简单的例子,展示了副作用的概念:
let count = 0; function increment() { count++; }
在这个例子中,increment()
函数会将 count
变量的值加1,这是一种副作用,因为它改变了程序的外部状态。
应用:虽然我们的目标是尽可能地使用纯函数,但是在某些情况下,副作用是无法避免的,它们仍然有一些应用场景。一些常见的副作用包括:
- I/O 操作:读写文件、网络请求等操作都需要对外部环境产生影响,它们是不可避免的副作用。
- 状态管理:有时候我们需要在函数执行过程中记录一些状态信息,这些状态只能通过副作用来实现。
- DOM 操作:JavaScript通常用于HTML文档的交互,而DOM操作会改变浏览器中的页面元素,所以它们也是一种副作用。
在以上场景下,我们可以使用副作用来实现一些必要的功能。但是,我们需要注意以下几点:
- 尽可能地将副作用隔离到模块的边界内,避免它们对外部造成意料之外的影响。
- 在使用副作用时,应该尽量明确和清晰地标识函数会产生怎样的副作用,并且给出相应的文档说明。
下面的例子演示了如何在JavaScript中处理副作用。假设我们有一个getUser()
函数,它从服务器获取用户信息,并提供了一个回调函数来处理结果。在回调函数内部,我们可以更新页面上的DOM元素,这就是一种副作用。
function getUser(id, callback) { // 假设这里是一个异步请求,返回用户信息 let user = { id, name: 'Alice', age: 26 }; callback(user); } getUser(123, function(user) { document.getElementById('name').innerText = user.name; document.getElementById('age').innerText = user.age; });
以上代码中,getUser()
函数有一个回调函数 callback
,它接收从服务器取回的用户信息并负责更新DOM元素的值。虽然这个函数具有副作用,但是它很明确地标识出会修改 DOM 元素,而且给出了文档注释来说明它的行为,这样就可以使代码更加可读和易于维护。
总之,在JavaScript的函数式编程中,副作用是一种常见的行为,我们应该尽可能地使用纯函数来避免副作用,但是在某些情况下,副作用是无法避免的并且具有必要性,这就需要我们掌握如何正确和明确地使用它们。
8、幂等(Idempotent)
在函数式编程中,幂等(Idempotent)是指对于同一输入值,函数的输出结果总是相同的。换句话说,无论调用函数多少次,返回的结果都是一样的。这个特性非常有用,因为它可以使我们更加自信地应用函数,知道不管什么时候使用它们,它们都会产生相同的结果。
下面是一个简单的例子来说明幂等:
function double(n) { return n * 2; } double(2); // 4 double(2); // 4 double(2); // 4
在上面的代码中,double()
函数以 n
为参数并将其乘以2作为返回值。每次我们调用该函数,结果都是一样的。这就是一个幂等函数。
应用:幂等函数在实际编程中非常有用。以下是一些幂等函数的应用场景:
- 操作数据库:在数据库操作中,幂等函数能够确保我们只执行一次操作,并且不会重复插入、更新或删除数据。这种情况下,我们通常使用唯一标识符来检查是否已经插入或更新了相同的记录。
- 缓存数据:在应用程序中缓存数据时,幂等函数可以保证我们只计算和缓存一次某个值,而不会在每次请求时重新计算。这种情况下,我们可以使用缓存键值作为函数的输入参数,并且如果缓存中已经存在相同的键,则直接返回缓存值。
- 简化网络操作:当我们在网络请求中发送数据时,幂等函数可以确保我们不会重复发送相同的数据。这在处理带有重试机制的请求时非常有用,因为我们可以确保在多次尝试后仅执行一次相同的操作。
下面是一个实际的例子,展示了如何用幂等函数来缓存 HTTP 请求结果:
function fetchWithCache(url, cache) { if (cache.has(url)) { return Promise.resolve(cache.get(url)); } return fetch(url) .then(response => { const clone = response.clone(); cache.set(url, clone); return response; }); }
以上代码中,fetchWithCache()
接收两个参数 - 要获取的 URL 和一个名为 cache
的缓存对象。如果该 URL 的响应已经存在于缓存中,则函数将从缓存中获取结果并立即返回;否则,它将向服务器发出请求。无论调用该函数多少次,对于同一个 URL 的响应都只会进行一次网络请求,并将结果保存在缓存中以供下一次使用。
在JavaScript函数式编程中,幂等性是一种非常有用的特性。幂等函数可以让我们更加自信地使用函数,并确保每次调用都会返回相同的结果。在数据库操作、缓存数据和网络通信等场景中,幂等函数是一种非常有用的工具,可以帮助我们更加有效地处理数据和状态。
9、Point-Free 风格 (Point-Free Style)
在函数式编程中,Point-Free
风格 (Point-Free Style) 是一种编程风格,它的核心思想是将函数组合起来,并通过函数组合实现业务逻辑。在 Point-Free
风格中,我们尽可能地避免使用命名变量(也就是点),而是直接将函数传递给另一个函数,这样可以减少代码中的变量和参数,提高代码的可读性和简洁性。
下面是一个简单的例子来说明 Point-Free
风格:
// 命令式编程(Imperative programming) function add(a, b) { return a + b; } function square(n) { return n * n; } const result = square(add(1, 2)); console.log(result); // 9 // Point-Free 风格(Point-Free style) const add = (a, b) => a + b; const square = n => n * n; const result = [1, 2].reduce(add, 0); const squareResult = square(result); console.log(squareResult); // 9
在上面的代码中,我们首先定义了两个用于计算加法和平方的函数。然后,我们将它们结合在一起,使用第一个函数计算出结果,再将结果传递给第二个函数。在命令式编程风格中,我们使用 add()
函数计算加法并将结果赋值给变量,最后将该变量传递给 square()
函数。而在 Point-Free 风格中,我们直接将 add()
函数传递给 reduce()
函数,并使用 square()
函数计算结果。
应用:Point-Free
风格在函数式编程中非常有用。以下是一些 Point-Free
风格的应用场景:
- 组合函数:通过将多个小函数组合成一个大函数可以轻松实现复杂的业务逻辑。在 Point-Free 风格中,我们不需要为每个变量创建命名变量,而是可以使用组合函数实现代码重用。
const compose = (...fns) => x => fns.reduceRight((y, f) => f(y), x); const add = a => b => a + b; const multiply = a => b => a * b; const square = n => n * n; const calculate = compose(square, add(1), multiply(2)); console.log(calculate(3)); // 64
以上代码中,我们定义了三个简单的函数,然后使用 Point-Free 风格创建了一个更复杂的函数来计算 (2 * (3 + 1))^2
的值。使用 compose()
函数将这三个函数组合在一起,得到了一个新的函数 calculate()
,该函数将其参数传递给 multiply()
、add()
和 square()
,并最终返回结果。
- 避免命名变量: 使用 Point-Free 风格可以避免过多的命名变量,从而使代码更加易读且逻辑更加清晰。在 Point-Free 风格中,每个函数都是一个单独的逻辑单元,它接受参数并返回结果,在传递给其他函数之前不保存任何状态或数据
const double = n => n * 2; const addOne = n => n + 1; const isEven = n => n % 2 === 0; [1, 2, 3, 4, 5] .map(double) .map(addOne) .filter(isEven);
以上代码中,我们定义了三个简单的函数 double()
、addOne()
和 isEven()
,然后将它们结合在一起来处理数组 [1, 2, 3, 4, 5]
。 注意,我们没有为数组中的每个元素创建命名变量,而是直接将每个元素作为参数传递给函数。
总之,Point-Free 风格是 JavaScript 函数式编程中非常有用的编程风格。它可以帮助我们减少代码中的变量和参数数量,提高代码的可读性和简洁性。通过组合小函数成为大函数,我们可以轻松地实现复杂的业务逻辑并避免冗余的代码。
10、断言 (Predicate)
在函数式编程中,谓词(也称为“断言”或“谓词函数”)是一个返回布尔值的函数,用于描述某个条件是否成立。谓词通常采用函数式编程中的 Point-Free 风格,可以接受任意数量的参数,并返回一个布尔值。在 JavaScript 中,谓词通常用于函数式编程中的过滤和查找操作。
下面是一个简单的例子来说明谓词的运用:
const isEven = n => n % 2 === 0; const numbers = [1, 2, 3, 4, 5, 6]; const evenNumbers = numbers.filter(isEven); console.log(evenNumbers); // [2, 4, 6]
在上面的代码中,我们定义了一个谓词 isEven()
,该函数使用模运算符判断一个数是否为偶数,返回 true 或 false。我们可以使用 filter()
函数和这个谓词来过滤数组 numbers
并得到一个包含所有偶数的新数组 evenNumbers
。
以下是一些谓词函数的应用场景:
- 数组过滤: 在函数式编程中,谓词函数经常用于数组过滤操作,通过返回 true 或 false 来决定要保留还是过滤掉某个元素。
const students = [ { name: "Tom", age: 18 }, { name: "Lucy", age: 22 }, { name: "Lily", age: 20 } ]; const isAdult = person => person.age >= 18; const adults = students.filter(isAdult); console.log(adults); // [{name: "Tom", age: 18}, {name: "Lucy", age: 22}, {name: "Lily", age: 20}]
在上面的代码中,我们定义了一个谓词 isAdult()
,该函数将一个人的年龄与 18 进行比较,如果大于或等于 18,则返回 true,否则返回 false。然后使用 filter()
函数和这个谓词来过滤数组 students
中的所有未成年人,并返回包含所有成年人的新数组 adults
。
- 条件分支:谓词函数还可以用于条件分支,例如下面的简单例子中:
const predicate = x => typeof x === "string"; const showType = x => (predicate(x) ? `The type of ${x} is string` : `The type of ${x} is not string`); console.log(showType("hello world")); // The type of hello world is string console.log(showType(5)); // The type of 5 is not string
在上例中,我们定义了一个谓词函数 predicate
,用于检查某个值是否为字符串类型,在 showType
函数中,如果传入的参数是字符串则输出字符串类型的信息,否则输出非字符串类型的信息。
总之,谓词函数在 JavaScript 的函数式编程中扮演着重要角色,它们可以用于数组过滤、条件分支等各种情况,方便代码编写并使逻辑更加清晰。
11、契约 (Contracts)
在函数式编程中,契约(Contracts)是指一种基于先决条件和后置条件的编程范式,它描述了一个函数或模块的行为和输入输出之间的关系。契约可以让我们更加清楚地定义代码的行为,帮助我们编写出更加健壮和可靠的程序。
在 JavaScript 函数式编程中,通常使用第三方库来实现契约,比如 js-contracts
和 type-check
等。下面以 js-contracts
为例说明契约的应用:
const { contract } = require('js-contracts'); const add = contract((a, b) => { contract.assert( contract.any([Number, String], a), 'add(a, b): a should be a number or string' ); contract.assert( contract.any([Number, String], b), 'add(a, b): b should be a number or string' ); return contract.returns(contract.any([Number, String]), (a + b)); }); console.log(add(1, 2)); // 3 console.log(add('hello', 'world')); // helloworld
在上述代码中,我们使用 js-contracts
库的 contract()
方法定义了一个名为 add()
的函数,并且定义了该函数的输入和输出契约,使得这个函数只接受两个参数,且这两个参数必须是数字类型或者字符串类型。函数返回值也需要满足这一条件。
以下是一些契约的应用场景:
- 参数约束:通过契约对函数参数进行约束可以防止非法的输入,有助于减少错误和调试时间。
const sendMessage = contract((message, callback) => { contract.assert( contract.typeOf("string", message), 'sendMessage(message, callback): message should be a string' ); contract.assert( contract.typeOf("function", callback), 'sendMessage(message, callback): callback should be a function' ); // 实际发送逻辑 callback(null, "Message sent successfully!"); }); sendMessage("Hello World", (err, result) => { if (err) { console.error(err); } else { console.log(result); } });
在上面的代码中,我们使用契约来约束 sendMessage()
函数的两个参数必须是字符串和回调函数。这样可以避免传递错误类型到函数中导致程序出错。
- 返回值约束:通过契约可以对函数的返回值进行检查,确保它满足特定的条件。
const createUser = contract(userName => { contract.assert( contract.typeOf("string", userName), 'createUser(userName): argument should be a string' ); const user = { name: userName }; return contract.returns(contract.object({ name: contract.typeOf("string"), age: contract.range(18, 100) }), user); }); const newUser = createUser("John"); console.log(newUser); // { name: "John" } // 下面代码会抛出异常,因为返回值不符合契约中定义的对象格式 createUser(123);
在上面的例子中,我们定义了 createUser()
函数来创建用户对象,并使用契约来确保函数只接受一个参数并且必须是字符串类型,并且返回值为拥有 name 属性和 age 属性的对象。如果函数返回的结果不符合契约,程序会抛出异常。
总之,契约是一种强大的编程范式,可以帮助我们编写出更加健壮可靠的程序。在 JavaScript 函数式编程中,通过第三方库实现契约可以很好地对代码进行约束和检查。
12、范畴 (Category)
在函数式编程中,范畴(Category)是一种基于数学上的抽象概念,它是由对象和箭头组成的一个集合。在 JavaScript 函数式编程中,我们可以将范畴看作是由一些函数和数据类型组成的集合,并通过这些函数和数据类型之间的关系来描述和处理问题。
在 JavaScript 中,我们可以使用一些库来实现范畴论的一些基本概念,比如 ramda
、lodash
等等。下面以 ramda
为例说明范畴论的应用:
12.1 函数构成范畴
在函数式编程中,我们可以将函数看作是一个范畴,每个函数都是该范畴中的对象,函数之间的组合形成了新的函数,就像数学中的复合函数一样。例如,给定两个函数 f 和 g,我们可以定义它们的组合 h(x) = f(g(x))
,使得 h 也是一个函数。
const R = require('ramda'); const add = x => x + 1; const multiply = x => x * 2; // 使用 R.compose() 函数进行函数组合 const addThenMultiply = R.compose(multiply, add); console.log(addThenMultiply(1)); // 4 (先加 1 再乘 2)
在上述代码中,我们使用 ramda
库的 compose()
函数对 add
和 multiply
两个函数进行组合,得到新的函数 addThenMultiply
,它先对输入做加一操作,再对结果做乘二操作。
12.2 Functor 范畴
在函数式编程中,Functor 是一个范畴,它定义了一些特征和操作,使得我们可以对范畴中的对象进行转换和映射。在 JavaScript 中,数组、Promise、IO 等等都是 Functor 的例子,因为它们都可以被看作是容器,里面包含着一些值并且支持一些操作。
const R = require('ramda'); // 定义一个 Functor 对象 const arr = [1, 2, 3]; // 使用 map() 函数对 Functor 进行映射转换 const newArr = R.map(x => x + 1, arr); console.log(newArr); // [2, 3, 4]
在上述代码中,我们使用 ramda
库的 map()
函数对数组 arr
进行映射操作,将其中的每个元素都增加 1,返回新的数组 newArr
。
12.3 Monad 范畴
Monad
是另一个重要的范畴,在 JavaScript 函数式编程中,Promise
和 IO
都是 Monad 的例子。Monad 主要用于异步操作、异常处理等场景。
// Promise Monad 示例 const fetchUrl = url => new Promise((resolve, reject) => { fetch(url).then(response => { if (response.ok) { resolve(response); } else { reject(response.statusText); } }); }); fetchUrl('https://api.github.com/users') .then(response => response.json()) .then(data => console.log(data)) .catch(error => console.error(error));
在上述代码中,我们使用 Promise Monad 来异步请求 Github 的用户数据,并且对 Promise 进行链式调用来处理返回结果和错误。
总之,范畴论是函数式编程的重要理论基础,它通过抽象数学概念来描述问题和解决问题,在 JavaScript 函数式编程中,我们可以使用一些库来实现范畴论的相关概念和操作。
13、值(Value)
在 JavaScript 函数式编程中,值(Value)是指一种数据结构,它主要用于表示状态不可变的数据。值类型包括基本类型(如 number、string、boolean
)以及复合类型(如数组、对象等),值类型的特点是不可变性,即在创建之后它们的值不能被修改。
JavaScript 中的值类型有助于编写无副作用(Pure)的函数,即函数的输入和输出只与输入参数和函数内部代码相关,而不涉及外部状态。这样可以利用不变性和纯函数来减少程序的复杂度和错误率,并且方便测试和调试。
下面以代码实例说明值类型的应用:
const add = (x, y) => x + y; const multiply = (x, y) => x * y; // 使用值类型作为函数的输入参数和返回值,保证了函数的无副作用性 const calculate = (x, y) => { const sum = add(x, y); const product = multiply(x, y); return { sum, product }; }; console.log(calculate(2, 3)); // { sum: 5, product: 6 }
在上述代码中,我们定义了两个纯函数 add()
和 multiply()
,它们的第一个参数 x
和第二个参数 y
都是值类型。另外,我们还定义了 calculate()
函数,它使用了 add()
和 multiply()
函数来计算 x
和 y
的和与积,最终返回一个包含这两个结果的对象。这样做可以保证 calculate()
函数没有副作用,并且输入参数和返回值都是值类型。
另外,JavaScript 中的 immutability(不可变性)库也为值类型的使用提供了便利。例如,immutable.js
库就提供了一系列的数据类型(如 List、Map、Set 等),这些数据类型都是不可变的,只能通过创建新的对象来修改它们的状态。
14、常量(Constant)
一旦被定义之后就不可以被重新赋值。
const five = 5 const person = Object.freeze({name: 'tom', age: 28}) person.name = 'iankevin' console.log(person) person.age + five === ({name: 'tom', age: 28}).age + (5)
常量是 引用透明
的,也就是说,它们可以被它们所代表的值替代而不影响结果。什么是引用透明?如果一个表达式能够被它的值替代而不改变程序的行为,则它是引用透明的。例如我们有 greet
函数:
const greet = () => 'hello, world.'
任何对 greet()
的调用都可以被替换为 Hello World!
, 因此 greet 是引用透明的。
对于以上两个常量,以下语句总会返回 true。
person.age + five === ({name: 'tom', age: 28}).age + (5)
在 JavaScript 函数式编程中,除了常量值外,还可以使用常量对象(Constant Object)来组织相关的常量。常量对象是一个包含若干常量属性的对象,每个属性都是一个常量,具有唯一确定的名称和不变的值。
在 JavaScript 中,我们通常使用 Object.freeze() 方法将一个对象转换为常量对象,使得该对象的值不能被修改。定义常量对象的示例如下:
const COLORS = Object.freeze({ RED: '#ff0000', GREEN: '#00ff00', BLUE: '#0000ff' });
以上代码定义了一个常量对象 COLORS
,其中包含了三个属性:RED
、GREEN
和 BLUE
分别表示红色、绿色和蓝色的十六进制值。通过调用 Object.freeze()
方法,我们确保对象 COLORS
的值不会被修改,从而保证程序的正确性。
下面演示如何在函数式编程中使用常量对象来增强程序的可读性、可维护性和可靠性:
// 使用常量对象定义颜色 const COLORS = Object.freeze({ RED: '#ff0000', GREEN: '#00ff00', BLUE: '#0000ff' }); // 定义一个函数,根据颜色返回不同的处理结果 const processColor = (color) => { switch (color) { case COLORS.RED: return 'Stop'; case COLORS.GREEN: return 'Go'; case COLORS.BLUE: return 'Slow Down'; default: return 'Unknown Color'; } }; console.log(processColor(COLORS.RED)); // Stop console.log(processColor(COLORS.GREEN)); // Go console.log(processColor(COLORS.BLUE)); // Slow Down console.log(processColor('#ffffff')); // Unknown Color
在上述代码中,我们首先定义了一个常量对象 COLORS
,然后编写了一个函数 processColor()
,用来根据输入的颜色值返回不同的处理结果。在函数内部,我们将颜色值和常量对象的属性进行比较,保证程序的正确性,并用常量名代替了直接使用字面量来表示数据的方式,增强了程序的可读性和可维护性。
总之,在 JavaScript 函数式编程中,常量对象是一种非常有用的工具,它可以让我们更好地组织常量、提高程序的可读性和可维护性、减少错误发生的可能性,从而帮助我们编写更加优秀的代码。
15、函子 (Functor)
在 JavaScript 函数式编程中,函子(Functor)是一种特殊的对象类型,它可以看作是一个容器,用于封装一些值,并提供一些方法来操作这些值。函子实际上是一种抽象的概念,它并不限定具体的实现方式,在实际应用中可以使用数组、对象等数据结构来实现。
函子是一个实现了 map
函数的对象。map
函数会遍历对象中的每个值并生成一个新的对象�,遵守两个准则:
15.1 一致性 (Preserves identity)
object.map(x => x) ≍ object
15.2 组合性 (Composable)
object.map(compose(f, g)) ≍ object.map(g).map(f) // f, g 为任意函数
在 javascript 中一个常见的函子是 Array, 因为它遵守因子的两个准则
const f = x => x + 1 const g = x => x * 2 ;[1, 2, 3].map(x => f(g(x))) ;[1, 2, 3].map(g).map(f)
15.3 指向函子 (Pointed Functor)
一个对象,拥有一个of
函数,可以将一个任何值放入它自身。ES2015 添加了 Array.of
,使 Array 成为了 Pointed Functor
。
Array.of(1)
15.4 函子应用
JavaScript 中,我们通常使用一个对象来表示函子,该对象包含了一个值和一些方法。通过定义这些方法,我们可以在处理函子时保证函数式编程的纯洁性,从而提高程序的可靠性。下面是一个示例代码:
// 定义函子对象 Maybe class Maybe { constructor(value) { this.value = value; } // 如果有值就执行传入的处理函数 map(fn) { return this.value ? new Maybe(fn(this.value)) : new Maybe(null); } } // 使用 Maybe 对象进行计算 const result = new Maybe(5) .map(x => x + 1) // 加1 .map(x => x * 2) // 乘2 .map(x => x.toString()); // 转换为字符串 console.log(result.value); // 输出:"12"
在上述代码中,我们首先定义了一个函子对象 Maybe
,它包含了一个值 value
和一个 map()
方法。在 map()
方法中,我们使用传入的处理函数对值进行处理,并返回一个新的 Maybe
对象,以保持函子对象的不可变性。如果原先 Maybe
对象中没有值,我们返回一个新的空的 Maybe
对象以避免出现异常。
在使用函子对象计算时,我们可以链式调用多个 map()
方法,每个方法都会对前一个方法处理后的结果进行进一步的处理。这种方式可以简化代码的编写、降低出错的可能性,并保证程序的可靠性。
总之,在 JavaScript 函数式编程中,函子是一种非常有用的工具,它可以帮助我们更好地组织代码、提高程序的可读性和可维护性、减少错误发生的可能性,从而使得函数式编程成为一种越来越流行的编程范式。
16、抬升 (Lift)
在 JavaScript 函数式编程中,抬升(Lift)是一种将普通函数转换为可用于处理函子对象的高阶函数的技术。通过使用抬升,我们可以将多个函数组合起来,形成一个新的函数,用于操作函子对象。
具体实现方式是,我们首先定义一个抬升函数 lift()
,该函数接受一个或多个普通函数作为参数,并返回一个新的函数,该新函数可以接受一个或多个函子对象,并将这些函子对象传递给原有函数进行处理,最终返回包含处理结果的新的函子对象。下面是一个示例代码:
// 定义函子对象 Maybe class Maybe { constructor(value) { this.value = value; } // 如果有值就执行传入的处理函数 map(fn) { return this.value ? new Maybe(fn(this.value)) : new Maybe(null); } } // 抬升函数 lift() function lift(fn) { return function (...args) { const first = args[0]; if (first instanceof Maybe) { // 如果是 Maybe 对象,则调用 map() 方法处理 return first.map(function (value) { // 调用原始函数处理 return fn(...[value, ...args.slice(1)]); }); } else { // 否则直接调用函数 return fn(...args); } }; } // 定义两个普通函数 const add = (a, b) => a + b; const multiply = (a, b) => a * b; // 使用抬升处理两个函子对象 const liftedAdd = lift(add); const liftedMultiply = lift(multiply); const result = liftedAdd(new Maybe(5), new Maybe(4)).map((res1) => liftedMultiply(res1, new Maybe(10)) ); console.log(result.value); // 输出: "90"
在上述代码中,我们首先定义了一个抬升函数 lift()
,它接受一个普通函数作为参数,并返回一个新的函数。在新的函数中,我们检查第一个参数是否是函子对象(这里使用 instanceof
判断),如果是,则调用函子对象的 map()
方法进行处理;如果不是,则直接调用原始函数进行处理。
接下来,我们定义了两个普通函数 add()
和 multiply()
,并使用 lift()
函数将它们转换为与函子对象兼容的高阶函数 liftedAdd
和 liftedMultiply
。最后,我们演示了如何使用 lift()
函数和两个高阶函数 liftedAdd
和 liftedMultiply
对函子对象进行处理,并获得最终的结果。
在 JavaScript 函数式编程中,抬升是一种很有用的技术,它可以帮助我们更好地组合普通函数、扩展它们的功能,从而实现更加复杂的操作,并大幅提升程序的可读性、可维护性及可靠性。
17、引用透明性 (Referential Transparency)
在 JavaScript 函数式编程中,引用透明性(Referential Transparency)
是指一个函数的输出只与它的输入有关,而不依赖于其他外部状态或变量。也就是说,如果将一个函数的参数替换为其返回值,程序的行为不会受到任何影响。
引用透明性可以带来很多好处,比如函数更加简单、可复用、可测试和可维护。如果我们能够确保一个函数是引用透明的,那么它就可以在程序的任意位置被调用,而不会产生任何意外的副作用。
下面是一个示例代码:
// 引用透明的函数 function add(a, b) { return a + b; } // 非引用透明的函数 let counter = 0; function increment() { counter++; return counter; } // 使用引用透明的函数进行计算 const result1 = add(3, 4); const result2 = add(result1, 5); console.log(result2); // 输出:12 // 使用非引用透明的函数进行计算 const value1 = increment(); const value2 = increment(); const result3 = add(value1, value2); console.log(result3); // 输出:6
在上述代码中,我们定义了两个函数 add() 和 increment()。其中,add() 是引用透明的函数,它只对输入参数进行简单的加法运算,结果只与输入参数有关,不依赖于其他状态。而 increment() 函数则是非引用透明的函数,它会捕获外部状态 counter,并在每次调用时修改 counter 的值。这意味着每次调用 increment() 函数所得到的返回值都与之前的调用有关,而不只是依赖于输入参数。
接下来,我们分别使用 add() 和 increment() 进行计算,并输出计算结果。可以看到,使用引用透明的函数 add() 进行计算时,程序的行为是可预测的、稳定的和容易理解的;而使用非引用透明的函数 increment() 进行计算时,程序的行为就不那么可控了,因为它受到外部状态 counter 的影响,可能会产生意外的副作用。
总之,在 JavaScript 函数式编程中,引用透明性是一个非常重要的概念,它可以帮助我们编写更加简洁、健壮和高效的函数式代码。如果我们能够将每个函数都尽量设计成引用透明的,就可以大幅提升程序的可读性、可测试性及可维护性,使程序更加健壮和灵活。
18、等式推理 (Equational Reasoning)
在 JavaScript 函数式编程中,等式推理(Equational Reasoning)是指使用等式来推导代码的正确性。也就是说,我们可以通过等式关系来证明某个函数的正确性,而不需要运行它。这个过程主要依赖于引用透明性,即一个函数的输出只由输入决定,不受外部状态影响。
等式推理可以帮助我们更好地理解一段代码的含义和作用,并且能够快速地发现可能存在的错误。在函数式编程中,我们把每个函数看做是数学上的一个函数,也就是说,它们只接受输入参数并返回输出结果,不存在副作用,因此可以进行等式推导。
下面是一个简单的示例代码:
// 引用透明的函数 function add(a, b) { return a + b; } // 推导代码的等式关系 const result1 = add(3, 4); const result2 = add(4, 3); console.log(result1 === result2); // 输出:true
在上述代码中,我们定义了一个引用透明的函数 add()
,它接受两个数字作为参数,并返回它们的和。然后,我们分别使用两组参数调用这个函数,并将结果存储在变量 result1
和 result2
中。最后,我们通过比较这两个变量的值来验证它们是否相等。
可以发现,由于 add()
函数是引用透明的,它的输出只由输入参数决定,因此在这个例子中两次调用函数所得到的结果是相等的。由此,我们就可以利用等式推理来证明代码的正确性,而不需要运行它。
在 JavaScript 函数式编程中,等式推理是一个非常重要的概念,它可以帮助我们更好地理解代码的含义和作用,并且能够快速地发现可能存在的错误。如果我们能够将每个函数都尽量设计成引用透明的,并且运用等式推理的方法来验证它们的正确性,就可以大幅提升程序的可读性、可测试性及可维护性,使程序更加健壮和灵活。