初识函数式编程

早就听闻函数式编程的大名,却一直没有去了解,最近刚好看见《JS函数式编程指南》这本书,决定翻阅一下。

<!--more-->

参考

1. 函数式编程

1.1. 命令式代码和声明式代码

代码可以分为命令式代码和声明式代码

  • 命令式:程序花费大量代码来描述用来达成期望结果的特定步骤,即"How to do"
  • 声明式:程序抽象了控制流过程,花费大量代码描述的是数据流,即"What to do"

命令式的代码依赖代码的执行流程,下面是典型的面向过程的代码

let list = [1, 2, 3, 4];
let map1 = [];
for (let i = 0; i < list.length; i++) {
  map1.push(list[i] * 2);
}

而声明式的代码指明的是做什么,而不是怎么做

let list = [1, 2, 3, 4];
let map2 = list.map(x => 2 * x);

SQL语句就是一个声明式代码的例子,语句指明了我们想要从数据库取什么的数据的表达式,至于如何取数据则是由数据库引擎自己决定的。

函数式编程遵从声明式范式,程序逻辑不需要通过明确描述控制流程来表达。

看到这里,突然想到了循环和递归的概念,可以粗略地把循环当做的命令式代码,而把递归当做是声明式代码。

1.2. 纯函数

学习函数式编程,首先需要了解纯函数的概念

// js中的纯函数
String.prototype.toUpperCase 
Array.prototype.map
Array.prototype.slice

// js中的非纯函数
Array.prototype.sort
Array.prototype.splice

纯函数是这样一种函数,即相同的输入,永远会得到相同的输出,并且没有任何可观察的副作用

关于纯函数,我的理解是

  • 相同的输出,指的是函数的输出不会依赖外部变量
  • 可观察的副作用,指的是函数只是返回新的值,不会修改外部环境变量(包括参数)的值,即是无破坏性的数据转换,这在参数是一个对象时特别常见

1.3. 纯函数的优点

纯函数不依赖共享状态,有以下几点好处

  • 可缓存性,纯函数可以根据输入来做缓存,这是因为同样的输入绝对会得到同样的输出,一般的实现方式是通过闭包创建局部作用域的持久化变量,需要注意内存释放的问题
  • 可一致性、自文档化,出函数是完全自给自足的,函数的依赖很明确,更易于观察和理解
    • 为了达到这个目的,可以将函数的依赖通过参数进行注入
    • 这在《重构-优化代码设计》中也有提到
  • 可测试性,明确函数的依赖后,我们可以很轻松的传入mock对象进行测试,而无需一些列配置文件,单元测试本身就是以函数为单位进行测试的
  • 合理性,纯函数是“透明的”,带来的分析代码的能力对重构和理解代码都非常重要
  • 并行代码,因为纯函数不需要访问共享的内存(无外部依赖),且不会影响外部变量(无副作用),因此
    • 我们可以并行运行任意的纯函数,
    • 改变函数调用次序函数调用的次序不会改变函数调用的结果

2. 相关的概念

纯函数有很多好处,但如果不理解一些概念或者使用一些工具,直接编写纯函数还是有难度的,下面介绍的就是如何编写纯函数。

2.1. 高阶函数

高阶函数就是参数为函数或返回值为函数的函数(跟闭包的定义基本一致)。有了高阶函数,就可以将复用的粒度降低到函数级别,相对于面向对象语言,复用的粒度更低。

Array.prototype.sort((a, b)=>a-b)

Array.prototype.sort就是一个典型的高阶函数,接收一个指定排序规则的参数,这样就可以通过不同的参数,指定不同的排序规则。这里也可以看见,函数式编程主要是指定干什么(排序),而不是怎么干(排序规则由函数参数处理)

再比如Array.prototype.map函数,是一个比sort函数抽象程度更高的函数

换句话说,高阶函数提供了一种函数级别上的依赖注入(或反转控制)机制,可以很轻松地实现策略模式、装饰者模式等设计模式。

2.2. 科里化

curry的概念:只传递给函数一部分参数来调用他,让他返回一个函数取处理剩下的参数

上面的概念有点晦涩,可以理解为:给定一个函数的部分参数,生成一个接受其他参数的新函数。其原理是通过闭包保存着先传递进来的参数,并在后面函数中使用前面的参数。

function add(x){
    return function(y){
        return x + y
    }
}
var res1 = add(10)(20) // 30
var res2 = add(10)(add(1)(2)) // 13

柯里化可以使我们只关心函数的部分参数,使函数的用途更加清晰,调用更加简单。

柯里化最主要的作用就是可以生成不同的处理函数,策略性地把要操作的数据放在最后一个参数中

var curry = require('lodash').curry
// 模板匹配函数
var match = curry(function(re, str){
    return str.match(re)
})

// 根据模板生成不同的策略函数
var hasSpaces = match(/s+/g)
var hasVowles = match(/[aeiou]/ig)

// 调用策略函数
console.log(hasSpaces("hello world"));
console.log(hasVowles("hello world"));

这种只传给函数一部分参数通常称作局部调用,能够大量减少样板文件的代码。上面这种通过指定部分参数来产生一个新定制函数的形式也称为偏函数

2.3. 组合

函数组合:结合两个或多个函数,从而产生一个新函数或进行某些计算的过程。

下面是一个函数组合的例子

// 函数组合
var compose = function(f, g){
    return function(x){
        return f(g(x))
    }
}

让函数从右向左运行,而不是由内而外运行,这样做的可读性远远高于嵌套一大堆的函数调用

// 不太好的做法,这样需要深入函数内部实现才知道其含义
var shout = function(x){
    return exclaim(toUpperCase(x))
}

函数的组合也符合结合律

// 纯函数的执行顺序不互相依赖
compose(f, compose(g, h)) == compose(compose(f, g), h);

运用结合律能为我们带来强大的灵活性,任何一个函数分组都可以被拆开来,然后再以他们自己的组合方式打包在一起,当然,最佳的组合实践时让组合可重用~

实现

compose函数的实现,可以参考ramda文档:compose

((y → z), (x → y), …, (o → p), ((a, b, …, n) → o)) → ((a, b, …, n) → z)

R.compose的文档解释是:从右往左执行函数组合(右侧函数的输出作为左侧函数的输入)。最右侧函数可以是任意元函数(参数个数不限),其余函数必须是一元函数。

pointfree

这里了解到了一个pointfree的概念,pointfree指的是函数无需提及需要操作的数据时怎样的。通过curry和组合,可以比较容易地实现

  • 利用curry,我们能够做到让每个函数都先接受数据,然后操作数据,最后再把数据传递到下一个函数那里去。

  • 利用组合,我们可以忽略参数的存在,函数不需要关注参数的信息,就可以构建出对应的功能出来

// 函数依赖于word参数才能构建
var snakeCase = function (word) {
      return word.toLowerCase().replace(/\s+/ig, '_');
}

// 避免了word参数的命名和相关信息,
// 这里需要额外构建replace 和 toLowerCase 功能函数
var snakeCase = compose(replace(/\s+/ig, '_'), toLowerCase);

2.4. 范畴学

彼此之间存在某种关系的概念、事物、对象等等,都构成"范畴"。

范畴的数学模型简单理解就是:"集合 + 函数"。在阅读《JS函数式编程》时,对于范畴学的概念确实不清楚,这里先马一个范畴论-维基百科的链接~

范畴自身亦为数学结构的一种,因此可以寻找在某一意义下会保持其结构的“过程”;此一过程即称之为函子。函子将一个范畴的每个物件和另一个范畴的物件相关连起来,并将第一个范畴的每个态射和第二个范畴的态射相关连起来。

实际上,即是定义了一个“范畴和函子”的范畴,其元件为范畴,(范畴间的)态射为函子。

可见学习范畴学需要理解映射的概念

  • 容器(Container):可以把"范畴"想象成是一个容器,里面包含:值和值的变形(函数)
  • 函子(Functor):是一个有接口的容器,能够遍历其中的值。能够将容器里面的每一个值,映射到另一个容器。

函数式的代码是“对映射的描述”,它不仅可以描述二叉树这样的数据结构之间的对应关系,任何能在计算机中体现的东西之间的对应关系都可以描述——比如函数和函数之间的映射(比如 functor);比如外部操作到 GUI 之间的映射。

函数式代码抽象程度可以很高,这就意味着函数式的代码可以更方便的复用。

3. 两个小例子

写这篇博客时我正在阅读《JS函数式编程》,途中还查阅了不少资料,代码能理解,但是对于整个函数式编程,还是一头雾水。下面通过几个例子,使用函数式编程的思维动手实现一些具体功能。

3.1. 计算数组偶数和

假设我们需要计算1到5之间的偶数之和的两倍,这个需求来自于 https://my.oschina.net/dogstar/blog/791051

记下来我们使用两种不同的方式进行实现。

首先使用命令式编程

let sum = 0
for(let i = 0; i < arr.length; ++i){
    let item = arr[i]
    if(item % 2 === 0){
        sum += item
    }
}
sum *= 2

根据上面实现我们可见看见,在循环中我们需要处理的需求包括判奇偶数、将偶数结果相加,最后还需要将之和乘以2,可以,这很命令式~

下面是函数式代码的实现

let res = arr.filter(item => item % 2 === 0)
            .map(item => item * 2)
            .reduce((acc, item) => {
                return acc + item
            }, 0)

上面的代码看不见循环的逻辑,代码十分简洁。这可以理解为函数式编程的“三板斧”:过滤、映射、归约。

3.2. 前端数据展示

这里直接把《JS函数式编程》中的例子搬过来了,这是一个常见的前端需求,

  • 根据请求参数构造url
  • 请求远程服务接口,获取响应数据
  • 处理返回数据,展示在页面上

下面是完整代码,其中_使用的是ramda

var Impure = {
    getJSON: _.curry(function(cb, url){
        $.getJSON(url, cb)
    }),
    setHTML: _.curry(function(sel, html){
        $(sel).html(html)
    })
}
var img = function (src){
    return  $(`<img />`, {src})
}

var url = function(t){
    return 'https://api.flickr.com/services/feeds/photos_public.gne?tags=' + t + '&format=json&jsoncallback=?';
}

// 解析接口返回,获取对应的字段
var mediaUrl = _.compose(_.prop('m'), _.prop('meida'))
var srcs = _.compose(_.map(mediaUrl), _.prop('items')); 
// 将数据映射成img标签
var images = _.compose(_.map(img), srcs); 
// 将html文件渲染到页面上
var renderImages = _.compose(Impure.setHtml("body"), images);
// 调用接口,并传入数据处理函数
var app = _.compose(Impure.getJSON(renderImages), url);

// 启动程序
app("cats");

上面代码大量使用了_.compose组合的形式,将数据通过管道的方式进行传递,逻辑十分清晰。

4. 注意事项

4.1. 不可避免的副作用

函数式编程中,函数是不同数值之间的特殊关系,每一个输入值返回且只返回一个输出值。当然,在某些特殊的需求下(IO请求、网络请求),副作用是必不可少的,函数式编程可以通过一些手段来把副作用的影响限制到最小,如Monad等。

关于Monad,这里有一篇阮一峰的博客:图解Monad,不妨移步阅读

简单说,Monad就是一种设计模式,表示将一个运算过程,通过函数拆解成互相连接的多个步骤。你只要提供下一步运算所需的函数,整个运算就会自动进行下去

暂时就不深入去了解了。

4.2. 避免间接层

JS中函数是一等公民,我们可以把函数当做普通的变量导出传递,但是需要避免出现下面的劣质代码

var getServerStuff = function(callback){
    return ajaxCall(function(json){
        return callback(json);
    })
}

上面的代码添加了间接层来进行封装,却等价于

var getServerStuff = ajaxCall

这样徒劳地增加了代码量、提高检索和维护成本,没有实际用处。如果函数的参数修改了,则同样需要修改间接层

4.3. 正确的函数和参数命名

命名是一个老生常谈的问题。

项目中一种常见的疑惑在于针对同一概念使用不同的命名,造成代码无法共用。

在命名的时候,不要把自己限定在特定的数据上。

5. 小结

命令式代码写久了,刚学习函数式编程还是有点困难的。现在前端的一些框架Reduxkoa-composelodash等,都或多或少的使用到了函数式编程,有时间可以去看一下源码。

在目前的项目中,出于代码规范和团队协作,应该不会大规模用到函数式编程,不过里面有些思想感觉还是很好的,比如纯函数,通过函数签名声明函数的依赖,保证函数无副作用,不修改外部变量等,对于代码的可理解和可维护性,都是很有帮助的。

对于写代码,我向来不是一个有特殊偏好和信仰的人,多学习不同的思维方式,不要把自己局限在某一个标签、某一个领域上,希望自己能称为一个真正的程序员。