关于如何以及为什么需要函数式编程? - Merrick


让我们从一个例子开始:在 Advent of Code 2022 day 1 中,我们有一组数字的字符串,看起来像:

1000
2000
3000

4000

5000
6000

7000
8000
9000

10000

我们想写一个程序,对每组进行求和,并返回最大的一组。本例中最大的一组是第4组,24000。

下面是一个命令式的解决方案:

const input = $('pre').innerText
let runningMax = 0
for (let group of input.split("\n\n")) {
    let runningSum = 0
    for (let num of group.split(
"\n"))
        runningSum += Number(num)
    runningMax = Math.max(runningMax, runningSum)
}
console.log(runningMax)

而这里是一个函数性的解决方案:

const input = $('pre').innerText
function sumGroup(group) {
    return group
        .split("\n")
        .map(Number)
        .reduce((a,b)=>a+b)
}
const sortedGroups = input
    .split(
"\n\n")
    .map(sumGroup)
    .sort((a,b)=>a-b)
console.log(sortedGroups.at(-1))

命令式的解决方案:

  • 我们声明了名为runningMax和runningSum的变量,并在整个程序中对其进行变异。
  • 我们使用for循环来进行迭代

函数式解决方案:
  • 我们避免声明易变的变量
  • 我们不使用for循环,而是使用map、reduce和sort等高阶函数进行迭代

高阶函数是指对其他函数进行操作的函数。map、reduce和sort以一个函数作为参数。

究竟如何进行函数式编程?

避免:

  • 可变状态
  • 迭代步骤
  • 循环语句
  • 数据和行为交织在一起 

偏爱:

  • 函数参数和不可变的数据
  • 用传送数据替代迭代步骤
  • 用递归或高阶函数替代循环
  • 数据和行为分离

看了上面的两个例子,你可能认为函数式编程就是简单地使用高级函数。
也许你会想,"map只是一个for循环!"。
如果map并不存在呢?"一个函数式程序员会自己用递归来写map。
任何用循环语句写的东西也可以用递归来写。

想象一下,我们想要一个范围函数,range(5)返回[1,2,3,4,5]。

命令式:

function range(end) {
    let nums = []
    for (let i=1; i<=end; i++)
        nums.push(i)
    return nums
}

函数式:
function range(end, cur=[]) {
    if (cur.length >= end) return cur
    return range(end, [...cur, cur.length+1])
}

在我开始探索函数式编程之前,我从未想过以递归方式实现这个函数。
但现在我比较这两种解决方案,递归的解决方案实际上感觉更自然。

为什么使用函数式编程
函数式编程有很多惊人的好处。让我们来探讨一下最大的东西。

1、避免与易变状态有关的bug:
就像静态类型消除了由动态类型引起的错误一样,函数式编程也消除了由可变状态引起的错误。每个程序员都曾复制过一个数组,对其进行了修改,然后花了太多的时间去弄清楚原来的数组是如何被修改的(又称浅层拷贝与深层拷贝)。当可改变的状态在代码中被共享时,出现这样的错误的可能性就会增加。

2、编写声明性代码:
命令式代码描述了如何做事情,而声明式代码则描述了我们正在做什么。这意味着声明性代码通常更容易被理解。想一想原来的命令式解决方案中的变量是如何命名runningMax和runningSum的。我们很想把这些变量命名为maxGroup和sum,但这是在撒谎。它们从0开始,在程序的过程中不断变化。我们被困在描述我们如何做事情的变量名称中。在函数式解决方案中,sortedGroups准确描述了我们正在做的事情。

3、编写更容易测试的代码:
如果你习惯于写命令式代码,那么写无效函数可能是很诱人的,因为它在函数的范围之外突变变量。做游戏的一个常见方法是将状态声明为变量,然后写一个无效函数,如update(),在每个tick上突变这些变量。函数式编程倾向于将游戏状态作为参数进行更新并返回新的状态,而不是变异变量。现在我们的代码更容易测试了。我们只需用一些游戏状态调用update,并断言其结果是我们所期望的。