谈谈我认识的函数式编程

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

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

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

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

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

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

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

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

class Dog {
    speak() {
        console.log('wang wang')
    }
    breath() {
        console.log('hu hu')
    }
}

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

class Cat {
    speak() {
        console.log('miao miao')
    }
    breath() {
        console.log('si si')
    }
}

我们还认识树:

class Tree {
    breath() {
        console.log('fu fu')
    }
}

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

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

class Animal {
    speak() {}
}

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

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

class Creature {
    breath() {}
}

class Animal extends Creature {
    speak() {}
}

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 其实在 js 里是一个构造函数, 而构造函数一般情况下需要使用 new 来构造, 我们在定义 C 时,使用了一个小技巧,判断 this 是否为 C 的实例, 不是的话就手动返回一个 new 构造的 C, 这样的话我们就可以通过 C('value') 达到 new C('value') 的效果。

这个函数的类型是:

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 三个函数都是接收一个参数并且返回一个参数的函数。

下面我们来看下 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())
}

有了 compose 我们就能这么操作:

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 时根本不需要声明参数。

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

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

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

大家在仔细观察这个 map 函数,会发现一个很特殊的地方,那就是他的第一个参数是函数,第二个参数是数组。按照正常的思维方式,我们会把这两个参数位置换一下:

const map = function(list, fn) {
    return list.map(fn)
}

因为我们在面向对象的范式中,常规的调用方式就是 对象.方法,在面向对象的思维里,一定是先有对象,才有对象的方法调用,也就是说方法是属于对象的。 那是因为面向对象是以对象为抽象的基本单位的,所有的抽象关系都是建立在对象(类)之上,方法不过是对象所特有的行为而已。 面向对象的抽象过程就是先有各种各样的对象,然后把具有相同行为的对象归为一类,抽象出类的概念。而类与类之间则又把相似性抽象为接口或继承等关系。

但是在函数式的范式中,函数才是抽象的基本单位。函数本身是有类型的,函数类型由入参类型和出参类型共同决定,而如果函数 a 的出参类型和函数 b 的入参类型一致, 那么函数 a 和 函数 b 则是可组合的(compose)。函数式的抽象过程就是抽象出函数的组合方式。我们刚学习的 compose 就算是最基础的组合方式了。

也因此,函数式在抽象的过程中只关心函数而不在乎具体的数据,所以这也是为什么在函数式中定义的 map 函数会把函数当作第一参数,而数据放到最后了。这种定义函数的方式是函数式编程的一大特点。

给大家举了 map 的例子其实并不仅仅是为了阐述函数参数的定义习惯问题,这里面其实还隐藏着一个惊天大秘密,那就是函数式编程里大名顶顶的 functor

functor 中文翻译为函子,咋一听好像和函数有关系,其实他们的关系跟 Java 和 JavaScript 的关系差不多。

functor 是一个特殊的计算结构,你可以把它理解成一个盒子,这个盒子有一个任意类型的值,同时这个盒子还提供了如何把这个值映射成其他值的方法。

数组就算作是一个 functor,数组本身是一个盒子,数组里含有一个元组值(可以把数组[1,2,3]理解成[(1,2,3)]),同时数组还提供了一个 map 方法可以把内部的值映射成其他值。

我们可以定义一个更加通用的 functor

function Box(value) {
    if (!(value instanceof Box)) {
        return new Box(value)
    }
    this.value = value
}
Box.prototype.map = function(f) {
    return Box(f(this.value))
}

Box 可以理解为一个盒子,这个盒子内部保存一个值 value,同时这个盒子提供了 map 方法把盒子内部的值映射成其他值。因此 Box 就是一个标准的 functor

而其实我们之前定义的 map 函数的真正类型则是这样的:

// map :: Functor f => (a -> b) -> f a -> f b
function map(f, functor) {
    return functor.map(f)
}

// 下面是之前的定义
// map :: (a -> b) -> [a] -> [b]
function map(fn, list) {
    return list.map(fn)
}

函数本身的实现没有什么变化,但是函数的入参类型则从数组变成了更加通用的 functor,现在 map 的适用范围则更加广泛了。

只是一个 Box 还看不出 map 的威力,我们再来看看一个新的 functor

const Option = {
    Some(value) {
        if (!(value instanceof Option.Some)) {
            return new Option.Some(value)
        }
        this.value = value
    },
    None() {
        if (!(value instanceof Option.None)) {
            return new Option.None()
        }
        this.value = void 0
    },
}
Option.Some.prototype.map = function(f) {
    return Option.Some(f(this.value))
}
Option.None.prototype.map = function(f) {
    return this
}

Option 是一个特殊的 functor,因为它的值有两种可能,要么是 Some 要么是 NoneSomemapBox 一致,而 Nonemap 则什么也不做。这个 functor 的具体作用我们可以通过一个例子看下:

const {Some, None} = Option

const handler = map(add(1))

handler(Some(1)) // Some(2)
handler(None())  // None()

我们看到,handler 在接收一个 Some 时,会正常处理 add(1) 的逻辑,所以最后返回 Some(2); 而在接收一个 None 时,则什么也不做,继续返回 None

如果使用面向过程的编码方式则大致等价于:

function add(a, b) {
    return a + b
}
function handler(value) {
    if (value !== undefined) {
        return add(1, value)
    } else {
        return undefined
    }
}
handler(1) // 2
handler()  // undefined

我们看到 Option 这个 functor 完美的把条件判断的逻辑封装起来,我们在调用 map 的过程中完全不需要考虑错误的情况如何处理,因为 mapOption 已经帮你处理好了。 更有意思的是,即使是多次联合操作也完全没问题。

const init = v => v >= 0 ? Some(v) : None()
const handler = compose(map(add(1)), map(add(1)), map(add(1)), init)
handler(1)  // Some(4)
handler(-1) // None()

Option 可以处理两种情况,而对第二种情况(None)则是直接忽略。但有些时候我们希望第二种情况也能带上某些信息,比如异常。

异常在编码的过程中是在所难免的,而一般编程语言都会提供 try-catch 等语法结构来捕获异常,在函数式编程的世界里则完全可以使用 functor 来处理。

const Result = {
    Ok() {
        if (!(value instanceof Result.Ok)) {
            return new Result.Ok(value)
        }
        this.value = value
    }
    Err() {
        if (!(value instanceof Result.Err)) {
            return new Result.Err(value)
        }
        this.value = value
    }
}
Result.Ok.prototype.map = function(f) {
    return Result.Ok(f(this.value))
}
Result.Err.prototype.map = function(f) {
    return this
}

Reuslt 这个 functorOption 很像,不过它的第二种情况 Err 会保存错误信息。

const {Ok, Err} = Result
const init = function(v) {
    if (v >= 0) {
        return Ok(v)
    } else {
        return Err('必须大于 0')
    }
}
const handler = compose(map(add(1)), map(add(1)), map(add(1)), init)
handler(1)  // Ok(4)
handler(-1) // Err('必须大于 0')

还记得我们刚刚说的函数式的抽象过程就是抽象出函数的组合方式吗,我们看看上面的handler的定义中, 大家可以考虑下,为什么可以把众多的 map 放到一起 compose? 这是因为,map 函数保证了只要输入了一个 functor 就一定会返回一个 functor。 所以 map 算作是我们认识的第二个抽像组合方式(compose 是第一个)。

map 很强大,但是我们发现 map 并不能改变 functor 的子类型,比如 map Ok 就一定返回 Ok,map Some 也一定返回 Some,而有时候我们需要 map Ok 时能返回 Err,map Some 时能返回 None。 我们希望有一种模式可以自由操控具体的返回类型,如果有个这个函数的话,那么它的类型大概应该是这样的:

// unknown :: Functor f => (a -> f b) -> f a -> f b

我们再拿出 map 的定义看一下:

// map :: Functor f => (a -> b) -> f a -> f b

我们发现唯一的不同就是第一个参数类型,mapa -> b 而我们期望的那个函数则是 a -> f b,其实这个函数在函数式编程中早已有了一席之地,不过他的真正类型是这样的:

// chain :: Monad m => (a -> m b) -> m a -> m b
function chain(f, monad) {
    return monad.chain(f)
}

大家可能会一脸懵逼,这个传说中的函数和 map 的实现貌似差不多啊,而这个 monad 又是什么鬼,和 functor 有啥关系呢?

是的,chain 表示的只是一种概念,而真正的实现在 monadchain 方法里。正如同 map 也只是一个概念,不同的 functor 都会有自己的 map 实现一样。

而这个 monad 其实是和 functor 是一类的东西。它也是代表一个黑盒,内部也保存一个值,但是具有一个 chain 方法,可以把 自己映射成另一个 monad

我们之前介绍的 Option 其实也是一个 monad,它的完整定义如下:

const Option = {
    Some(value) {
        if (!(value instanceof Option.Some)) {
            return new Option.Some(value)
        }
        this.value = value
    },
    None() {
        if (!(value instanceof Option.None)) {
            return new Option.None()
        }
        this.value = void 0
    },
}
Option.Some.prototype.map = function(f) {
    return Option.Some(f(this.value))
}
Option.Some.prototype.chain = function(f) {
    return f(this.value)
}

Option.None.prototype.map = function(f) {
    return this
}
Option.None.prototype.chain = function(f) {
    return this
}

我们不难发现,Somemapchain 唯一的区别就是,map 把映射后的值再次放回 Some 中, 而 chain 却是直接返回映射后的值。

同样的 Result 其实也是 monad

const Result = {
    Ok() {
        if (!(value instanceof Result.Ok)) {
            return new Result.Ok(value)
        }
        this.value = value
    }
    Err() {
        if (!(value instanceof Result.Err)) {
            return new Result.Err(value)
        }
        this.value = value
    }
}
Result.Ok.prototype.map = function(f) {
    return Result.Ok(f(this.value))
}
Result.Ok.prototype.chain = function(f) {
    return f(this.value)
}
Result.Err.prototype.map = function(f) {
    return this
}
Result.Err.prototype.chain = function(f) {
    return this
}

我们将通过一个实际的例子展示 chain 的巨大威力:

假如我们要实现一个四则运算:

const lit = a => Ok(a)
const add = curry((a, b) => {
    return OK(a + b)
})
const sub = curry((a, b) => {
    return OK(a - b)
})
const mul = curry((a, b) => {
    return Ok(a * b)
})
const div = curry((a, b) => {
    if (b === 0) return Err('除数不能为 0')
    return Ok(a / b)
})

const expr = compose(chain(div(1)), chain(add(1)))(lit(1)) // Ok(2)
const expr2 = compose(chain(div(0)), chain(add(1)))(lit(1)) // Err('除数不能为 0')

我们发现,compose 可以完美的把 chain 串联起来,就和串联 map 一样,这是因为 chain 一定接收一个 monad 也一定会返回一个 monad。 其实我们也可以使用链式调用:

const expr = lit(1).chain(add(1)).chain(div(1)) // Ok(2)
const expr2 = lit(1).chain(add(1)).chain(div(0)) // Err('除数不能为 0')

即使是异步也不在话下:

const validate = curry(keyword => {
    if (!keyword) return Err('请输入关键词')
    else return Ok(keyword)
})
const query = curry(keyword => {
    return new Promise
})

假如我们要实现四则运算:

// 我们首先定义一下四则运算类型:
const Expr = {
    Lit(value) {
        if (!(value instanceof Expr.Lit)) {
            return new Expr.Lit(value)
        }
        this.value = value
    }
    Add(expr1, expr2) {
        if (!(value instanceof Expr.Lit)) {
            return new Expr.Add(value)
        }
        this.value = [expr1, expr2]
    }
    Sub(expr1, expr2) {
        if (!(value instanceof Expr.Lit)) {
            return new Expr.Sub(value)
        }
        this.value = [expr1, expr2]
    }
    Mul(expr1, expr2) {
        if (!(value instanceof Expr.Lit)) {
            return new Expr.Mul(value)
        }
        this.value = [expr1, expr2]
    }
    Div(expr1, expr2) {
        if (!(value instanceof Expr.Lit)) {
            return new Expr.Div(value)
        }
        this.value = [expr1, expr2]
    }
}
// 我们可以这样表达一个四则运算:
const {Lit, Add, Sub, Mul, Div} = Expr
const expr = Div(Lit(4), Lit(2)) // expr 即表示计算 4 / 2

// 下面我们要定义一个常规的 exec 函数来解析 expr
const exec = (expr) => {
    if (expr instanceof Lit) {
        return expr.value
    } else if (expr instanceof Add) {
        return exec(expr.value[0]) + exec(expr.value[1])
    } else if (expr instanceof Sub) {
        return exec(expr.value[0]) - exec(expr.value[1])
    } else if (expr instanceof Mul) {
        return exec(expr.value[0]) * exec(expr.value[1])
    } else if (expr instanceof Div) {
        return exec(expr.value[0]) / exec(expr.value[1])
    }
}

exec(expr) // 2

// 上面的 exec 即是我们使用面向过程的方式来定义的,看似好像也是很简洁的
// 但是我们忽略一个问题,那就是被除数不能为 0。我们需要额外处理这个异常
// 所以要定义一个安全版本的 exec

const safeExec = (expr) => {
    if (expr instanceof Lit) {
        return expr.value
    } else if (expr instanceof Add) {
        return safeExec(expr.value[0]) + safeExec(expr.value[1])
    } else if (expr instanceof Sub) {
        return safeExec(expr.value[0]) - safeExec(expr.value[1])
    } else if (expr instanceof Mul) {
        return safeExec(expr.value[0]) * safeExec(expr.value[1])
    } else if (expr instanceof Div) {
        if safeExec(expr.value[1] === 0) {
            throw new Error('除数不能为 0')
        } else {
            return safeExec(expr.value[0]) / safeExec(expr.value[1])
        }
    }
}

// 下面我们通过函数式的方式来定义:
const safeExec2 = (expr) => {
    if (expr instanceof Lit) {
        return Ok(expr.value)
    } else if (expr instanceof Add) {
        return chain(
            v1 => {
                chain(

                    safeExec2(expr.value[1])
                )
            }
            safeExec2(expr.value[0]),
        )
        return Ok(add)
            .ap(safeExec2(expr.value[0]))
            .ap(safeExec2(expr.value[1]))
    }
}

未完待续。。。

  • curry
  • compose
  • functor
  • monad
  • applicative