如何使你的 JavaScript 更具函数式编程风格

54 阅读14分钟

在本文中,我们将简要介绍函数式编程的概念,然后详细讲解五种使你的 JavaScript 更具函数式编程风格的方法。

函数式编程是一种编程风格,它使用函数及其应用而不是命令列表来实现程序逻辑,与命令式编程语言不同。

它是一种更为抽象的编程风格,它的根源可以追溯到数学领域——特别是一种称为 Lambda Calculus 的数学分支,该分支是由数学家阿隆佐·邱奇于1936年发明的一种可计算性的正式模型。它由表达式和函数组成,这些函数将一个表达式映射到另一个表达式。从根本上讲,这就是我们在函数式编程中所做的:我们使用函数将值转换为不同的值。

近年来,本文作者们深深爱上了函数式编程。我们开始使用鼓励更为函数式编程风格的 JavaScript 库,然后又深入学习了如何使用 Haskell 编程。

Haskell 是一种纯函数式编程语言,于1990年代开发,类似于 Scala 和 Clojure。在使用这些语言时,你必须以函数式风格进行编码。学习 Haskell 让我们真正感受到了函数式编程所提供的所有优势。

JavaScript 是一种多范式语言,它可以用于命令式、面向对象或函数式编程风格。然而,它特别适合使用函数式编程风格,因为函数是一等公民,它们可以被分配给变量。这也意味着函数可以作为其他函数的参数(通常被称为回调函数)传递,也可以是其他函数的返回值。返回其他函数或接受它们作为参数的函数称为高阶函数,它们是函数式编程的基本部分。

近年来,在函数式编程风格中编写 JavaScript 已经变得越来越流行,特别是随着 React 的兴起。React 使用声明式 API,非常适合函数式编程风格,因此,对函数式编程原则的深入理解将有助于改进你的 React 代码。

为什么函数式编程如此好?

简而言之,函数式编程语言通常会导致代码更为简洁、清晰、优雅。代码通常更易于测试,并且可以在多线程环境下使用而不会出现任何问题。

如果你与许多不同的程序员交流,你可能会得到不同的关于函数式编程的意见,从完全厌恶它的人到完全喜欢它的人不等。我们(本文作者)倾向于喜欢它,但我们完全理解并认识到,它并不是每个人的最爱,特别是因为它是与通常的

规则1:净化你的函数

函数式编程的一个关键部分是确保你编写的函数是“纯函数”。如果你对这个术语不熟悉,纯函数基本上满足以下条件:

  1. 它具有引用透明性。这意味着,给定相同的参数,函数将始终返回相同的值。任何函数调用都可以用返回值替换,程序仍然会以相同的方式运行。
  2. 它没有副作用。这意味着函数不会在函数范围外做任何更改。这可以包括更改全局值、记录到控制台或更新DOM。
  3. 纯函数必须至少有一个参数并返回一个值。如果你想一想,如果它们不接受任何参数,它们就没有任何数据可以处理,如果它们不返回任何值,那么函数有什么意义呢?

纯函数一开始可能看起来并不是那么必要,但是拥有不纯的函数会导致整个程序发生变化,从而导致严重的逻辑错误!

//impure
let minimum = 21
const checkAge = age => age >= minimum

//pure
const checkAge = age => {
    const minimum = 21
    return age >= minimum
}

在不纯的函数中,checkAge函数依赖于可变变量minimum。例如,如果minimum变量稍后在程序中更新,checkAge函数可能会返回相同输入的布尔值。

想象一下我们运行以下代码:

checkAge(20) >>  false

现在,让我们想象一下,在代码的后面,一个名为 changeToUK() 的函数将 minimum 的值更新为18。

然后,想象我们运行了以下代码:

checkAge(20) >> true

现在,即使给出相同的输入,函数checkAge的评估结果也不同。

纯函数可以使您的代码更具可移植性,因为它们不依赖于作为参数提供的任何其他值。返回值永远不会改变的事实使得纯函数更容易测试。

始终编写纯函数还可以消除发生突变和副作用的可能性。

在函数式编程中,突变是一个非常严重的问题。如果您想了解更多有关此问题的信息,可以阅读JavaScript变量赋值和突变指南。

为了使函数更具可移植性,请确保您的函数始终保持纯函数。

规则2:保持变量不变。

声明变量是任何程序员学习的第一件事情之一。它变得琐碎,但是在使用函数式编程时它非常重要。

函数式编程的一个关键原则是,一旦变量被设置,它就保持在该状态,直到整个程序结束。

以下是展示代码中如何重新分配/重新声明变量可能会导致灾难的最简单示例:

const n = 10
n = 11
TypeError: "Attempted to assign to readonly property."

如果你仔细想一下,n的值不能同时是10和11,这在逻辑上是不成立的。

在命令式编程中一个常见的编码实践是使用以下代码来递增值:

let x = 5
x = x + 1

在数学中,语句x = x + 1是不合逻辑的,因为如果你从两边都减去x,你会得到0 = 1,显然这是不正确的。

出于这个原因,在Haskell中你不能把一个变量赋值为一个值,然后重新分配为另一个值。为了在JavaScript中实现这一点,您应该遵循始终使用const声明变量的规则。

规则3:使用箭头函数。

在数学中,函数的概念是将一个值集合映射到另一个值集合。下面的图示展示了通过平方将左侧的值集合映射到右侧值集合的函数:

这是使用箭头符号表示的数学函数表示法:f: x → x²。这意味着函数f将值x映射到x²。

我们可以使用箭头函数几乎相同的方式编写此函数:

const f = x => x**2

在JavaScript中使用函数式风格的一个关键特征是使用箭头函数而不是常规函数。当然,这确实归结于风格问题,使用箭头函数而不是常规函数并不会影响你的代码的“函数式”程度。

然而,当使用函数式编程风格时,最难适应的之一是将每个函数都视为一个将输入映射到输出的过程。不存在过程这样的概念。我们发现使用箭头函数有助于更好地理解函数的过程。

箭头函数具有隐式返回值,这确实有助于可视化这种映射过程。

箭头函数的结构——特别是它们的隐式返回值——有助于鼓励编写纯函数,因为它们字面上是“输入映射到输出”这样的结构:

args => returnValue

另一个我们喜欢强调的事情,特别是在编写箭头函数时,是使用三元运算符。如果你不熟悉三元运算符,它们是一种内联的if...else语句,形式为condition ? value if true : value if false。

你可以在Quick Tip: How to Use the Ternary Operator in JavaScript中了解更多关于它们的信息。

在函数式编程中使用三元运算符的主要原因之一是必须使用else语句。如果原始条件未被满足,程序必须知道该做什么。例如,Haskell强制执行else语句,如果没有给出else语句,它会返回一个错误。

使用三元运算符的另一个原因是它们是表达式,始终返回一个值,而不是if-else语句,可用于执行具有潜在副作用的操作。这对于箭头函数尤其有用,因为它意味着你可以确保有一个返回值,并保持将输入映射到输出的形象。如果你不确定语句和表达式之间微妙的差别,这篇关于语句与表达式的指南很值得一读。

为了说明这两个条件,这里有一个简单的箭头函数示例,它使用了三元运算符:

const action = state => state === "hungry" ? "eat cake" : "sleep"

根据状态参数的值,action函数将返回“eat”或“sleep”的值。

因此,总的来说,当使您的代码更加函数化时,您应遵循以下两个规则:

  1. 使用箭头符号编写您的函数
  2. 将if...else语句替换为三元运算符

规则4: 避免使用 for 循环

考虑到在编程中使用 for 循环来编写迭代代码是如此常见,因此避免使用它们似乎相当奇怪。实际上,当我们第一次发现 Haskell 甚至没有任何类型的 for 循环操作时,我们曾经努力理解一些标准操作如何才能被实现。然而,不使用 for 循环有一些非常好的理由,我们很快发现,每种类型的迭代过程都可以在不使用 for 循环的情况下实现。

不使用 for 循环最重要的原因是它们依赖可变状态。让我们来看一个简单的求和函数:

function sum(n){
    let k = 0
    for(let i = 1; i < n+1; i++){
        k = k + i
    }
    return k
}
sum(5) = 15 // 1 + 2 + 3 + 4 + 5

正如您所看到的,在 for 循环本身中,我们必须使用 let,还要为在 for 循环中更新的变量使用 let。

正如先前解释的那样,在函数式编程中,这通常是不好的做法,因为函数式编程中的所有变量都应该是不可变的。

如果我们想编写所有变量都是不可变的代码,我们可以使用递归:

const sum = n => n === 1 ? 1 : n + sum(n-1)

正如您所看到的,没有任何变量被更新。

我们中的数学家显然知道所有这些代码都是不必要的,因为我们可以使用 0.5n(n+1) 的妙招求和公式。但这是说明 for 循环与递归的可变性差异的绝佳方式。

然而,递归并不是解决可变性问题的唯一方法,特别是当我们处理数组时。JavaScript 有很多内置的高阶数组方法,可以在不改变任何变量的情况下循环遍历数组中的值。

例如,假设我们想在数组中的每个值上加 1。使用命令式的方法和 for 循环,我们的函数可能如下所示:

function addOne(array){
    for (let i = 0; i < array.length; i++){
        array[i] = array[i] + 1
}
    return array
}

addOne([1,2,3]) === [2,3,4]

然而,我们可以使用 JavaScript 的内置 map 方法,而不是使用 for 循环,编写一个类似这样的函数:

const addOne = array => array.map(x => x + 1)

如果您以前从未遇到过 map 函数,那么学习它们肯定是值得的——以及 JavaScript 的所有内置高阶数组方法,例如 filter,特别是如果您真的对 JavaScript 中的函数式编程感兴趣的话。您可以在《Immutable Array Methods: How to Write Remarkably Clean JavaScript Code》中找到有关它们的更多信息。

Haskell 根本没有 for 循环。为了使您的 JavaScript 更具函数式,尽量避免使用 for 循环,而是使用递归和内置的高阶数组方法。

规则 5:避免类型强制转换

在像 JavaScript 这样不需要类型声明的语言中编程时,很容易忘记数据类型的重要性。JavaScript 中使用的七种原始数据类型包括:

  • Number
  • String
  • Boolean
  • Symbol
  • BigInt
  • Undefined
  • Null

Haskell 是一种强类型语言,需要类型声明。这意味着,在任何函数之前,您需要使用 Hindley-Milner 系统指定输入和输出的数据类型。 比如:

add :: Integer -> Integer -> Integer
add x y = x + y

以下是一个非常简单的函数,将两个数字(x 和 y)相加。为每个函数都解释数据类型可能看起来有点荒谬,包括像这样非常简单的函数,但最终它有助于说明函数的预期行为和返回值。这使得代码更容易调试,特别是当它变得更加复杂时。

一个类型声明遵循以下结构:

functionName :: inputType(s) -> outputType

在使用JavaScript编程时,类型转换可能是一个很大的问题,因为它有各种各样的技巧可以用于(甚至滥用)绕过数据类型不一致的限制。以下是最常见的类型转换方法以及如何避免它们:

  1. 字符串连接。"Hello" + 5 的结果是"Hello5",这并不一致。如果你想将一个字符串与数值连接起来,你应该写成 "Hello" + String(5)。
  2. 布尔运算和0。在JavaScript中,if语句中的0相当于false。这可能会导致懒惰的编程技巧,忽略了对数字数据是否等于0的检查。

例如:

const even = n => !(n%2)

以下是正确的函数实现方式:

function isEven(n) {
  return n % 2 === 0;
}

这个实现方式不仅清晰易懂,而且保持了数据类型的一致性。避免类型转换,可以避免出现难以调试的错误。如果要将数据类型转换为不同的类型,则最好明确地进行类型转换,而不是依靠JavaScript的隐式类型转换规则。

const even = n => n%2 === 0

另一个重要的概念是确保数组中的所有数据值都是相同类型的。虽然 JavaScript 并不强制要求这样做,但是如果类型不同,可能会在使用高阶数组方法时出现问题。

例如,一个将数组中所有数字相乘并返回结果的函数可以使用以下类型声明注释编写:

// product :: [ Number ] -> Number
const product = numbers => numbers.reduce((s,x) => x * s,1)

另一个重要的概念是确保数组中的所有数据值具有相同的类型。虽然 JavaScript 不强制执行此操作,但如果数据类型不同,则使用高阶数组方法时可能会出现问题。

例如,一个将数组中所有数字相乘并返回结果的函数可以使用以下类型声明注释来编写:

在这里,类型声明清楚地表明函数的输入是一个包含 Number 类型元素的数组,但它仅返回一个单独的数字。类型声明清楚地说明了此函数所需的输入和输出。很明显,如果数组不仅由数字组成,这个函数是行不通的。

Haskell 是一种强类型语言,而 JavaScript 是弱类型语言,但为了使 JavaScript 更具功能性,您应该在声明函数之前编写类型声明注释,并确保避免类型转换的快捷方式。

此外,我们还应该在此提到,如果您想要 JavaScript 的强类型替代方案,以便强制执行类型一致性,您显然可以转向 TypeScript。

结论

总结一下,以下是帮助你实现函数式代码的五个规则:

  1. 保持函数的纯洁性。
  2. 使用 const 声明变量和函数。
  3. 使用箭头符号表示函数。
  4. 避免使用 for 循环。
  5. 使用类型声明注释并避免类型转换快捷方式。

虽然这些规则不能保证你的代码完全是函数式的,但它们可以大大地帮助你使代码更加函数式,更加简洁、清晰且易于测试。

我们真的希望这些规则能像它们帮助我们一样,帮助你!我们都是函数式编程的铁杆粉丝,我们极力推荐任何程序员使用它。

如果你想深入了解函数式 JavaScript,我们强烈推荐阅读免费在线提供的《函数式编程指南》(Professor Frisby's Mostly Adequate Guide to Functional Programming)。如果你想全面学习 Haskell,我们建议使用 Try Haskell 交互式教程,并阅读免费在线提供的优秀书籍《Learn You A Haskell For Greater Good》