vlambda博客
学习文章列表

9 个每个人都应该知道的函数式编程概念

  • 原文地址:https://hackernoon.com/9-functional-programming-concepts-everyone-should-know-uy503u21
  • 原文作者:Victor Cordova
  • 译者:Breword

9 个每个人都应该知道的函数式编程概念

本文将介绍每个程序员都应该知道的函数式编程概念。让我们首先定义什么是函数式编程(从现在开始是 FP)。FP 是一种编程范式,通过应用和组合函数来编写软件。范式 是一种 “任何类型的哲学或理论框架”。换句话说,FP 让我们将问题的解法视为一系列互相连接的函数。

在这里,我将对 FP 中的基本概念及其能够解决的一些问题做一个基本的介绍。

注意:出于实用性考虑,我将省略定义这些概念的特定数学属性。你不必在你的应用程序中强制使用这些概念。

1. 不变性(Immutability)

突变是对对象的值或结构的修改。不变性意味着某些内容无法修改。考虑以下示例:

const cartProducts = [
  {
    "name""Nintendo Switch",
    "price"320.0,
    "currency""EUR"
  },
  {
    "name""Play station 4",
    "price"350.0,
    "currency""USD"
  }
]

// Let's format the price field so it includes the currency e.g. 320 €
cartProducts.forEach((product) => {
  const currencySign = product.currency === 'EUR' ? '€' : '$'
  // Alert! We're mutating the original object
  product.price = `${product.price} ${currencyName}`
})

// Calculate total
let total = 0
cartProducts.forEach((product) => {
  total += product.price
})

// Now let's print the total
console.log(total) // Prints '0320 €350 $' 😟

发生了什么?由于我们修改了 cartProducts 对象,因此我们 覆盖了 price 的原始值 。

突变可能会造成问题,因为它使跟踪应用程序中的状态变化变得困难甚至不可能。 你不想调用第三方库中的函数,因为你不知道它是否会修改你传递给它的对象。

让我们看一个更好的做法:

const cartProducts = [...]

const productsWithCurrencySign = cartProducts.map((product) => {
  const currencyName = product.currency === 'EUR' ? 'euros' : 'dollars'
  // Copy the original data and then add priceWithCurrency
  return {
    ...product,
    priceWithCurrency`${product.price} ${currencyName}`
  }
})

let total = 0
cartProducts.forEach((product) => {
  total += product.price
})

console.log(total) // Prints 670 as expected 😎

现在,我们无需修改原始对象,而是使用对象拓展操作符将数据克隆到原始 cartProducts

return {
  ...product,
  priceWithCurrency`${product.price} ${currencyName}`
}

使用第二种方法,我们通过创建一个包含 priceWithCurrency 属性的新对象来避免对原始对象进行修改。

不变性实际上可以由语言本身来强制约束。JavaScript 原生包括一个 Object.freeze 函数可以用来做这件事,但也可以使用成熟的库,例如 Immutable.js 。不过,在项目中大规模添加不变性约束之前,请评估是添加新的第三方库还是使用额外语法来做这件事;也许仅仅只是需要在团队中规定:尽可能不要修改对象本身即可。

2. 函数组合

这是指将一个函数应用于另一个函数的输出。这是一个小例子:

const deductTaxes = (grossSalary) => grossSalary * 0.8
const addBonus = (grossSalary) => grossSalary + 500

const netSalary = addBonus(deductTaxes(2000))

实际上,这意味着我们可以将算法分成较小的部分,在整个应用程序中重复使用它们,并分别测试每个部分。

3. 确定性函数

如果给定相同的输入,该函数始终返回相同的输出,则该函数是确定性的。例如:

const joinWithComma = (names) => names.join(', ')

console.log(joinWithComma(["Shrek""Donkey"])) // Prints Shrek, Donkey
console.log(joinWithComma(["Shrek""Donkey"])) // Prints Shrek, Donkey again!

一个常见的不确定函数是 Math.random

console.log(Math.random()) // Maybe we get 0.6924493472043922
console.log(Math.random()) // Maybe we get 0.4146573369082662

确定性函数可以让你的软件行为更加可预测,并减少错误发生的概率。

值得注意的是,我们并不总是需要确定性函数。例如,当我们要为数据库行生成新的 ID 或获取以毫秒为单位的当前日期时,我们需要在每次调用时都返回一个新值。

4. 纯函数

纯函数是具有 确定性 且 没有副作用 的函数。我们已经了解了确定性的含义。副作用是指函数体外的状态的修改。

让我们看一个带有讨厌的副作用的函数:

let sessionState = 'ACTIVE'

const sessionIsActive = (lastLogin, expirationDate) => {
  if (lastLogin > expirationDate) {
    // Modify state outside of this function 😟
    sessionState = 'EXPIRED'
    return false
  }
  return true
}

const expirationDate = new Date(20201001)
const currentDate = new Date()
const isActive = sessionIsActive(currentDate, expirationDate)

// This condition will always evaluate to false 🐛
if (!isActive && sessionState === 'ACTIVE') {
  logout()
}

如你所见, sessionIsActive 会在其作用域之外修改变量,这会导致函数调用程序出现问题。

现在,这是一个没有副作用的替代方法:

let sessionState = 'ACTIVE'

function sessionIsActive(lastLogin, expirationDate{
  if (lastLogin > expirationDate) {
    return false
  }
  return true
}

function getSessionState(currentState, isActive{
  if (currentState === 'ACTIVE' && !isActive) {
    return 'EXPIRED'
  }
  return currentState
}

const expirationDate = new Date(20201001)
const currentDate = new Date()
const isActive = sessionIsActive(currentDate, expirationDate)
const newState = getSessionState(sessionState, isActive)

// Now, this function will only logout when necessary 😎
if (!isActive && sessionState === 'ACTIVE') {
  logout()
}

重要的是要了解我们不想消除所有副作用,因为所有程序都需要做某种副作用,例如调用 API 或打印到某些标准输出。我们想要的是最大程度地减少副作用,这样我们的程序的行为将更易于预测和测试。

5. 高阶函数

尽管名称令人生畏,但高阶函数只是以下函数中的一种:以一个或多个函数作为参数,或者返回一个函数作为其输出。

这是一个将函数作为参数并返回另一个函数的示例:

const simpleProfile = (longRunningTask) => {
  return () => {
    console.log(`Started running at: ${new Date().getTime()}`)
    longRunningTask()
    console.log(`Finished running at: ${new Date().getTime()}`)
  }
}

const calculateBigSum = () => {
  let total = 0
  for (let counter = 0; counter < 100000000; counter += 1) {
    total += counter
  }
  return total
}


const runCalculationWithProfile = simpleProfile(calculateBigSum)

runCalculationWithProfile()

如你所见,我们可以做一些很酷的事情,例如在执行原始函数时添加函数。我们将在科里化函数中看到高阶的其他用法。

6. Arity

Arity 是函数采用的参数数量。

// This function has an arity of 1. Also called unary
const stringify = x => `Current number is ${x}`

// This function has an arity of 2. Also called binary
const sum => (x, y) => x + y

因此,在编程中有时会听到一元运算符,例如 ++ 或!

7. 函数科里化(Curried Functions)

函数科里化是指这样一类函数:它们需要用到多个参数,但每次只消费一个参数,每次返回新的函数消费剩余的参数。可以通过 JavaScript 中的高阶函数创建它们。

这是使用 ES6 箭头函数语法编写的科里化函数:

const generateGreeting = (ocassion) => (relationship) => (name) => {
  console.log(`My dear ${relationship} ${name}. Hope you have a great ${ocassion}`)
}

const greeter = generateGreeting('birthday')

// Specialized greeter for cousin birthday
const greeterCousin = greeter('cousin')
const cousins = ['Jamie''Tyrion''Cersei']

cousins.forEach((cousin) => {
  greeterCousin(cousin)
})
/* Prints:
  My dear cousin Jamie. Hope you have a great birthday
  My dear cousin Tyrion. Hope you have a great birthday
  My dear cousin Cersei. Hope you have a great birthday
*/


// Specialized greeter for friends birthday
const greeterFriend = greeter('friend')
const friends = ['Ned''John''Rob']
friends.forEach((friend) => {
  greeterFriend(friend)
})
/* Prints:
  My dear friend Ned. Hope you have a great birthday
  My dear friend John. Hope you have a great birthday
  My dear friend Rob. Hope you have a great birthday
*/

很棒,对吗?通过一次传递一个参数,我们能够自定义函数的功能。

更一般而言,函数科里化对于赋予函数多态行为并简化其组合非常有用。

8. Functors

不要被这个名字吓到。Functors 只是将值包装到上下文中并允许在该值上进行映射的抽象。映射意味着将一个函数应用于一个值以获得另一个值。这是一个非常简单的 Functor:

const Identity = value => ({
  mapfn => Identity(fn(value)),
  valueOf() => value
})

本来编写一个函数就解决问题了,为什么要如此麻烦去创建一个 Functor ?目的是为了更容易的组合函数。Functors 与其中的类型无关,因此可以顺序应用转换函数。让我们来看一个例子:

const double = (x) => {
  return x * 2
}

const plusTen = (x) => {
  return x + 10
}

const num = 10
const doubledPlus10 = Identity(num)
  .map(double)
  .map(plusTen)

console.log(doubledPlus10.valueOf()) // Prints 30

这项技术非常强大,因为你可以将程序分解成较小的可重用部分,并分别测试每个程序,确保它们不会出现问题。如果你确实好奇,JavaScript 的 Array 对象其实也是一个 Functor。

9. Monads

Monad 是一个 Functor,它也提供 flatMap 操作。这种结构有助于组成类型提升函数。现在,我们将逐步解释该定义的每个部分,以及为什么要使用它。

什么是类型提升函数?

类型提升函数是将值包装在某些上下文中的函数。让我们看一些例子:

// Here we lift x into an Array data structure and also repeat the value twice.
const repeatTwice = x => [x, x]

// Here we lift x into a Set data structure and also square it.
const setWithSquared = x => new Set(x ** 2)

类型提升函数可能很常见,因此我们希望将它们组成是有意义的。

什么是平面函数?

flat 函数(也称为联接)是从某些上下文中提取值的函数。你可以借助 JavaScript 的 Array.prototype.flat 函数来更好的理解此操作。

// Notice the [2, 3] inside the following array. 2 and 3 are inside the context of an Array
const favouriteNumbers = [1, [23], 4]

// JavaScript's Array.prototype.flat method will go over each of its element, and if the value is itself an array, its values will be extracted and concatenated with the outermost array.
console.log(favouriteNumbers.flat()) // Will print [1, 2, 3, 4]

什么是 flatMap 函数?

该函数首先应用一个映射函数(map),然后删除其周围的上下文(平面)。是的,该函数的实际操作与函数名称所暗示的顺序不同非常容易让人困惑。

Monads 怎么用?

想象一下,我们想组合两个类型的提升函数,它们在上下文中平方和除以二。首先让我们尝试使用 map 和一个非常简单的称为 Identity 的 functor。

const Identity = value => ({
  // flatMap: f => f(value),
  map: f => Identity.of(f(value)),
  valueOf() => value
})

// The `of` method is a common type lifting functions to create a Monad object.
Identity.of = value => Identity(value)

const squareIdentity = x => Identity.of(x ** 2)
const divideByTwoIdentity = x => Identity.of(x / 2)

const result = Identity(3)
  .map(squareIdentity)
  .map(divideByTwoIdentity) // 💣 This will fail because will receive an Identity.of(9) which cannot be divided by 2
  .valueOf()

我们不能只使用 map 函数,而需要首先提取 Identity 内的值。这就是 flatMap 函数大展身手的地方了。

const Identity = value => ({
  flatMapf => f(value),
  valueOf() => value
})

const result = Identity(3)
  .flatMap(squareIdentity)
  .flatMap(divideByTwoIdentity)
  .valueOf()

console.log(result); // Logs out 4.5

多亏了 monads,我们终于能够任意组合类型提升函数了。

结论

我希望本文能使你对函数式编程中的一些基本概念有一个基本的了解,并鼓励你深入研究此范例,以便你可以编写更多可重用,可维护且易于测试的软件。