谈谈我认识的函数式编程

在谈论函数式编程前,我想先聊聊编程的本质。

在我看来编程是指使用计算机解决某个或某类问题的实践过程。

这里面涉及人和机两个概念。人编写程序,计算机执行程序。懂得计算机原理的人应该都知道计算机底层只能表达 0 和 1 两种值而已。而程序却能描绘世间万物。如何把世间万物映射为0/1,亦或者如何使用 0/1 来描述世间万物,这就是编程。

编程是通过什么手段做到呢,答案是抽象

什么是抽象呢,这里引用下 SICP 的描述:

  1. 将若干简单认识组合成一个复杂认识,由此产生出各种复杂的认识
  2. 将两个认识放在一起对照,由此得到有关他们相互关系的认识
  3. 将有关认识与所有其他认识隔离开,这就是抽象

该描述本身就很抽象,我们可以举例具体说明下。

比如我们对狗的认识可以这么描述:

class Dog {
    breath() {}
    walk() {}
}

而对猫的认识则可以这么描述:

class Cat {
    breath() {}
    walk()
}

我们还认识树:

class Tree {
    breath() {}
}

我们把 Dog 和 Cat 放到一起对照,发现他们都能 walk。 由此我们得知 Dog 和 Cat 冥冥中有种关系使得彼此关联。

同时我们把 Tree 也加进来,发现 Dog 和 Cat 所具有的这种关系(都可以 walk ),是 Tree 不具有的。由此我们可以抽象出一种新的认识:Animal,而 Animal 应该可以包含 Dog 和 Cat:

class Animal {
    move() {}
}

class Dog extends Animal {}
class Cat extends Animal {}

以此类推,我们还能发现 Dog,Cat,Tree 都可以 breath,于是我们就又能抽象出一种新的认识:Creature

class Creature {
    breath() {}
}

class Animal extends Creature {
    move() {}
}

class Dog extends Animal {}
class Cat extends Animal {}

class Tree extends Creature {}

有心人可能突然发现,欸?这不是用的面向对象里的继承的思想吗?

没错,面向对象的继承正是实现抽象的手段之一,另外值得注意的是,面向对象本身这个大概念都只是实现抽象的手段之一而已。除了面向对象之外,还有其他手段实现抽象吗?答案是肯定的,那就是函数式。

函数式编程的概念由来已久,但时至今日方才渐渐被工业界广泛接受,甚至是追捧。

函数式和面向对象的区别是啥? 在我看来其最大的区别就是:面向对象早已被业界广泛采用并得到长久的发展,时至今日即使与逐渐兴起的函数式相比也是不落下风,甚至还有点小小的优势。

但俗话说一山不容二虎,函数式和面向对象到底谁才能打败对方成为最后的王者呢?我们在项目中又该如何抉择呢?

拜托,这是小孩子才有的思维,成年人当然是全要了。

二者本质上都是为了抽象,只是使用了两种不同的思路解决相同的问题罢了,而具体哪个好当然要具体问题具体分析了,只要明确了要解决的问题的特点,并且对函数式和面向对象的异同了然于胸,自然就会做出合理的选择。

函数式到底是啥呢?

在给出我对函数式的理解之前,我想先回顾下面向对象的定义,如果能有面向对象来类比着理解函数式,相信会更好理解点。

面向对象是指把对象和类作为组织代码的基本单元,那类比着,函数式则是指把函数作为组织代码的基本单元。

这么说貌似有些笼统了,一般来说并不是所有以对象和类作为组织代码的基本单元就能称之为面向对象了,必须还同时使用了封装,继承,多态三大特性。同样的,如果你的代码都是使用函数组织的,也不能就称之为函数式编程了,必须还具备一些函数式所特有的特性才行。具体是哪些特性,且听我一一道来。

我们上文曾提过,计算机内部都是 0/1 串,为了能够更好的抽象,编程语言一般都会有类型的概念。某种 0/1 串表示的是 number,而另一种 0/1 串则代表着 string。

请注意,类型这个概念则是我们一切编程的基础,也是我们对计算机的第一个抽象。(特指编程语言,忽略硬件抽象)

以 js 为例,我们知道 js 包含 number,string,boolean,undefined,null,object,symbol,bigint 等基础类型。

这些我们都能理解,这里需要着重说的是 Function 这个类型,在 js 里 Fucntion 是属于 object 的范畴的,但本身的确也算一种细化的类型。

在讨论函数式时,函数其实算作一种特殊的类型,它是一个类型的同时,也是一个类型构造器。

我们以 js 为例:

function id(a) {
    return a
}

对于 id 来说它的类型是:

id :: a -> a

用大白话来说,就是给 id 任意类型的输入,都会输出相同类型的值

再来看个稍微复杂点的类型:

function C(a) {
    if (!(this instanceof C)) {
        return new C(a)
    }
    this.value = a
}

这个函数的类型是:

C :: a -> C a

即输入任意类型,都会返回被 C 包裹的相应类型。

let a = C(1) // number -> C<number>
let b = C(false) // boolean -> C<boolean>
let c = C('')  // string -> C<string>

此处可以更明显的看出函数作为类型构造器的作用。

我们再来看一个稍微特殊点的函数:

// add :: (number, number)-> number
function add(a, b) {
    return a + b
}

这种函数需要一次性传入两个类型,才能返回结果,我们再来看看下面的这个函数:

// add :: number -> number -> number
function add(a) {
    return b => a + b
}

这个函数比较有意思,输入一个 number 类型后,返回的是另一个函数:number -> number, 只有给返回的函数再输入一个 number 类型,才能得到最终的 number 结果:

add(1)(2) // 3

这种函数,被称之为柯里化函数。我们可以简单实现一个把任意多参数的函数转换成柯里化函数的工具函数:

function curry(fn) {
    return (...args) => _curry(fn, ...args)
}
function _curry(fn, ...args) {
    if (args.length >= fn.length) {
        return fn(...args)
    } else {
        return (...restArgs) => _curry(fn, ...args, ...restArgs)
    }
}

function add(a, b, c) {
    return a + b + c
}
const $add = curry(add)
$add(1, 2, 3) // 6
$add(1, 2)(3) // 6
$add(1)(2)(3) // 6

柯里化函数是函数式编程里的一个重要概念。

你可能会很奇怪,curry 看似很牛逼,实际上并没有啥卵用吧,谁会闲着没事把参数拆开传呢?

别急,好戏还在后头。

跟 curry 搭配干活的是一个叫做 compose 的工具函数。compose 的作用是把形如:d => a(b(c(d))) 这样的函数转变成: d => compose(a, b, c)(d) 的调用形式。

这里值得注意的是,a/b/c 三个函数都是接收一个参数并且返回一个参数的函数,因为只有这样他们才能如此调用 a(b(c(d))): c 函数接收参数 d , 返回的结果又马上被当成 b 的参数,a 亦是如此。

下面我们来看下 compose 的实现:

function pipe(...fns) {
    return (...args) => {
        return fns.slice(1).reduce((prev, curr) => curr(prev), fns[0](...args))
    }
}
function compose(...fns) {
    return pipe(...fns.reverse())
}

如此我们就能如此操作:

const add = curry((a, b) => a + b)
const multi = curry((a, b) => a * b)

const addAndMulti = compose(multi(2), add(1))
addAndMulti(1) // 4

上面使用 compose 时,是从右往左算的,也就是 先 add(1)multi(2),这与我们从左往右的思维是相背的,因此我们还可以使用 pipe 函数实现从左往右的调用顺序(之所以起名为 pipe 是因为,这样数据就会像流水一样从管道左侧流向右侧):

const addAndMulti = pipe(add(1), multi(2))

此时是不是有点明白 curyy 的作用了呢,它可以把多参数的函数转成单参数的函数。而 compose 则可以很方便的复用这些单参数函数,组合成各种不同的函数。

目前为止我们已经实现了很多的函数,但我们好像还没有仔细的想过函数的本质是什么?在我看来,在类型系统中,函数本身是一种类型,所以它可以当作参数传递给其他函数,也可以当作结果返回。同时函数又是一个类型构造器,它可以把一种类型映射成另一种类型。对于多参数的函数来说,我们可以把它柯里化,这样就变成单参数函数。

接下来我们再来看一个例子,我们首先定义一个 map 函数:

// map :: (a -> b) -> [a] -> [b]
function map(fn, list) {
    return list.map(fn)
}

map 函数接受两个类型,第一个是函数 a -> b: 能够把类型 a 映射成 类型 b 的函数;第二个是一个包含 a 类型元素的数组。而输出则是经过第一个函数映射后的新数组 [b]

这个函数貌似也没啥特殊的地方,而且内部实现也是相当简略,还不如直接调用岂不更简单,为啥还要非套一层函数呢?

别急,下面就是见证奇迹的时刻:

我们先用之前的 curry 函数把 map 柯里化一下,并引入 add 函数

const map = curry((fn, list) => list.map(fn))
const add = curry((a, b) => a + b)

现在,假设我们需要实现一个函数,该函数输入一个数组,把数组中每个值都 +1 之后再返回。

此时我们就可以这么实现:

const mapAdd = map(add(1))

mapAdd([1, 2, 3]) // [2, 3, 4]

还是继续上面的例子,这次我们不但要 +1 还要把数组反转。

const reverse = arr => arr.reverse()
const reverseMapAdd = arr => reverse( map(add(1))(arr) )

我们发现在定义 reverseMapAdd 时,必须要声明参数 arr,而定义 mapAdd 时却不需要声明参数。其实 mapAdd 的完整定义应该是这样的:

const mapAdd = arr => map(add(1))(arr)

如果我们把 map(add(1)) 单独抽出来:

const _mapAdd = map(add(1))
const mapAdd = arr => _mapAdd(arr)

我们不难发现,mapAdd 其实就是 _mapAdd 本身,我们在定义 mapAdd 时根本不需要声明参数。

这种形式的函数被称为是 point free 的(point 即代表参数的意思),不引入参数可以使得函数更简洁明了。

这时候我们再来看下 reverseMapAdd 能不能定义成符合 point free 的呢?答案当然是可以的,那就是用上我们刚认识的 compose 函数:

const reverseMapAdd = compose(reverse, map(add(1)))

未完待续。。。

  • curry
  • compose
  • functor
  • monad
  • applicative