快速而有力的函数式编程概述

97 阅读6分钟

这篇快速而有力的函数式编程概述将向你展示函数式编程世界的主要领域的快速一瞥。用函数式方法编写代码可能会帮助你更好地组织你的代码,并以经得起时间考验的方式控制复杂性。如果你已经准备好在更具表现力和易于维护的代码的祭坛上牺牲一些性能,那么函数式编程将使你掌握你所需要的知识。

声明式和命令式

你可以用两种不同的方式编写你的程序。要么通过编写谈论你想做的事情的代码,要么通过编写对你想做的事情给出逐步指示的代码。
例如,这里是一个声明性的语句:"一个数字X的自然对数是一个数字y,对于它来说e**y = x"。这告诉你什么是自然对数,而不是如何找到它。(在学校里,对数感觉很神秘,部分原因是你不知道如何找到对数,尽管你知道它是什么)。
这里有一个命令式代码的例子,它可以找到边为a和b的三角形的斜边:

const aSquared = a**2;
const bSquared = b**2;
const aPlusBSquared = aSquared + bSquared;
const hypot = aPlusBSquared ** 0.5;

很少看到有人以上面所示的分块和零散的方式来写代码,大多数人会说.NET是一个很好的例子:

const hypot = (a**2 + b**2)**0.5;

而不是向你展示计算斜边的步骤(计算机可以计算出是先加还是先平方等等),只是说 "斜边是两边平方之和的根")。看起来更简明?我也这么认为,
程序员的梦想是能够写出100%声明性的代码,并且在任何地方都非常高效--这个梦想我们还在努力实现。一般来说,声明性会导致效率问题,比如下面的代码:

const fib = (n)=> n<=1 ? 1 : fib(n-1) + fib(n-2);

上面的单行代码为你找到了第n个斐波那契数,在我看来它很美。这简直就是把斐波那契数的定义放到了代码中,代码中说:the first 2 fibonaccis are 1, other fibonacci numbers are the sum of the previous two fibonacci numbers 。非常具有陈述性。但是,做过这个函数的渐进分析(不,你不需要知道它是什么来理解这篇文章)的人知道,它的效率和它的表现力一样可怕。一个程序员渴望成为一个作家,但对效率的实际关注打破了这个梦想--根本不是第一批经历这种困境的人。

函数式编程也渴望实现同样的梦想,虽然它是通过牺牲一点效率来实现的,但我们在使代码看起来漂亮的同时还能以合理的速度运行方面已经取得了很大的进步。简而言之,函数式编程是声明性的。

一些比较

我只是要写一些代码,先用函数式的方式,然后用声明式的方式:

// functional
const sum = (a,b)=>a+b;
console.log(range(1,100).reduce(sum)); // 1 + 2 + 3 ... + 99

// declarative
let sum = 0;
for(let i=1;i<100;i++){
  sum+=i;
}
console.log(sum);

第一段代码说:"sum是你把两个数字加在一起的地方。现在,从1到100的数字范围,把它们加起来,然后用console.log"。第二段代码以循序渐进的方式做事情,很乏味。它是按字面意思来读的--总和最初是0,现在遍历1到100的每个数字(甚至1到100的部分也没有明确表达,它隐含在for-loop中),把它们加到总和中。
如果你的代码中有很多部分在移动,那么函数式代码和命令式代码之间的可读性差异会变得相当大。

纯函数和不可更改性

没有命令式代码意味着你不能让你的函数修改一个变量。这就是命令式:

let x = 4;
function addFive(){
  x+=5;
}

只要使用consts,如果你开始写命令式代码,你就会发现自己,因为解释器会抛出一个错误。
下面这个是声明式的。或者是吗?

const x = 4;
function addFive(){
  return x + 5;
}

这肯定比以前要好得多,因为addFive不再修改x本身,这是向写声明性代码迈出的第一步。如果你是在修改你的值而不是创造新的不同的值,那就不可能写出声明性的代码,因为修改总是意味着你必须先进行赋值,这使得它是逐步的(因此是必须的)。当函数不修改现有的值而创造新的值时,它们被称为纯函数。编写纯函数可以让你最终建立这个更加声明化的版本:

const x = 4;
function addFive(x){
  return x + 5;
}

这不仅仅适用于函数。在真正的函数式编程中,传统的那种数组是不允许的,因为通常你会在程序的过程中修改数组:

const a = [];
a.push(4); // not allowed
a[0] = 9;  // also not allowed

因此,不可变的数据结构(一旦创建就不能改变的结构)是必要的。在命令式程序中,你会修改现有的数据,而在函数式编程中你会创建新的数据。这导致了效率问题,当你每次修改都要创建新的数据时,也会消耗更多的内存,但在大型程序中,你可以做更多的事情,而不会使你的程序复杂化。

闭包

函数可以记住它们的包围范围,甚至在它们从包围范围返回并生活在不同的环境中很久之后。例如 :

function doWith(x, y){
  return function(){
    y(x);
  }
}
const sqrtTwo = doWith(2, Math.sqrt);
sqrtTwo(); // -> 1.41....

上面,sqrtTwo记住了它被调用的两个参数2和Math.sqrt,尽管它们只被传递给了父函数doWith,而没有明确保存。用华丽的语言来说,我们说 "sqrtTwo对x和y有封闭性"。这个概念可以总结为:即使在返回函数执行完毕后,返回函数也会记住返回函数的范围内的值。

第一类函数

在函数式编程中,函数就像其他值一样(这是一种赞美)。这意味着我们可以将函数传递给其他函数,也可以从其他函数返回函数。当我们使用map时,我们会广泛地这样做,因为它需要一个函数作为参数,在filter和reduce中也是如此。第一类函数构成了函数式编程的核心,使我们有可能以极其有趣的方式组合函数。事实上,如果函数不是函数式编程语言中的一等公民,这里提到的许多技巧都是不可能的。

递归

递归并不是函数式编程所独有的,如果你有过任何像样的编程经历,你可能已经面对面地接触过递归。递归是指某一事物以其自身为单位进行表达,
例如,我们可以以自身为单位定义自然数:一个自然数要么是0,要么是一个自然数的继承者。只要继任者定义良好,就可以从0和继任者函数中建立起自然数--一个术语的定义包含其本身并不是一种谬误,相反,很多美丽的东西都是由此而来的。

卷曲

Currying允许你将部分参数应用于一个函数,并将其余部分留待以后使用。例如,假设你有一个函数pow,它将数字a提高到b的幂,你可以用咖喱法将某个幂x应用于b:

function curryPow(pow, exp){
  return function(x){
    pow(x, exp);
   }
 }
 
 const square = curryPow(pow, 2);
 const cube = curryPow(pow, 3);
 square(4); // -> 16
 cube(4);   // -> 64

这里有一个一般的咖喱程序:

function curry(f, ...args){  // first argument : f. The rest are stored in args[]
  return function(...rest){  // return a function which takes some arguments rest[]
    return f(...args, ...rest); // and apply to f, the arguments which were supplied
                                // earlier in args[] and the arguments rest[]
   }
 }

看起来相当抽象。尽管我已经提供了注释,但我看到... 操作符可能在这里造成问题,因为它意味着两件事。在函数参数列表中,... 意味着 "其余的参数作为一个数组",而在代码的其他地方,它意味着 "传播/打开这个数组"。有趣的是,你也可以用它来咖喱多次。
这里有一个例子。我在这里假设我已经写好了一个类似于python的范围函数:

const fromOne = curry(range, 1)
const oneToHundred = curry(fromOne, 100)
const everyOtherFromOneToHundred = oneToHundred(2); // -> [1,3,5...99]

Pipelining

通常,在进行函数式编程时,你会对一个值应用一系列的函数。例如,下面的代码找到了直到1000的素数的总和:

range(1,1000).filter(isPrime).reduce(sum)

我们可以将这些函数管道化为一个单一的函数:

function pipe(...functions){
    return function(...args){
        return functions.reduce((result, f)=>{
            return f(...result);
        }, args);
    }
}

管道函数接收一系列的函数,并返回一个函数,该函数将对其参数逐一执行给定的一系列函数,并返回结果。这句话很拗口,但函数式编程的世界往往充满了这样的脑筋急转弯的句子,一旦你明白了,就会觉得豁然开朗。
先不花太多时间讨论这个函数是如何工作的,让我们在一个例子中使用它:

const squareElements = (array)=>array.map(x=>x**2)
const hypotenuse = pipe(squareElements, sum, Math.sqrt);
hypotenuse([3,4]); // -> 5

我必须在铺设管道之前创建一个 squareElements 函数,因为否则我就必须写一个单独的从右向右策反的策反函数。如果我们有这样一个函数,我们可以直接写 :

const hypotenuse = pipe(reverseCurry(Array.prototype.map.apply, x=>x**2), sum, Math.sqrt);

上面这行用reverseCurry说:"取数组.map而不取数组,其中的函数对每个元素进行平方。返回一个将采取数组的函数"。它等同于我们上面的 squareElements 函数。

就这样了?

函数式编程还有比上述概念更多的内容。然而,上面所写的东西,当它被充分应用时,会给你带来很大的力量,并形成大部分的函数式代码。本文未涉及的其他概念,如漏斗和单体,可以为函数式程序员的油箱提供更多的燃料。