服务粉丝

我们一直在努力
当前位置:首页 > 财经 >

浅谈JS函数式编程

日期: 来源:冰岩作坊收集编辑:仓鼠

高效搬砖——拆分、组合、复用

我们不妨以一个栗子开始:这是Scott Sauyet所著《Favoring Curry》中一个在项目实战里遇到的问题。

var data = {
    result: "SUCCESS",
    interfaceVersion: "1.0.3",
    requested: "10/17/2013 15:31:20",
    lastUpdated: "10/16/2013 10:52:39",
    tasks: [
        {id: 104, complete: false,            priority: "high",
                  dueDate: "2013-11-29",      username: "Scott",
                  title: "Do something",      created: "9/22/2013"},
        {id: 105, complete: false,            priority: "medium",
                  dueDate: "2013-11-22",      username: "Lena",
                  title: "Do something else", created: "9/22/2013"},
        {id: 107, complete: true,             priority: "high",
                  dueDate: "2013-11-22",      username: "Mike",
                  title: "Fix the foo",       created: "9/22/2013"},
        {id: 108, complete: false,            priority: "low",
                  dueDate: "2013-11-15",      username: "Punam",
                  title: "Adjust the bar",    created: "9/25/2013"},
        {id: 110, complete: false,            priority: "medium",
                  dueDate: "2013-11-15",      username: "Scott",
                  title: "Rename everything", created: "10/2/2013"},
        {id: 112, complete: true,             priority: "high",
                  dueDate: "2013-11-27",      username: "Lena",
                  title: "Alter all quuxes",  created: "10/5/2013"}
    ]
};

这是一段服务器返回的(parse过的)JSON数据,现在要求是,找到用户Scott的所有未完成任务,并按到期日期升序排列。如果我们用命令式代码来写,大概是这样的画风:

getIncompleteTaskSummaries = function(membername) {
    return fetchData()
        .then(function(data) {
            return data.tasks;
        })
        .then(function(tasks) {
            var results = [];
            for (var i = 0, len = tasks.length; i < len; i++) {
                if (tasks[i].username == membername) {
                    results.push(tasks[i]);
                }
            }
            return results;
        })
        .then(function(tasks) {
            var results = [];
            for (var i = 0, len = tasks.length; i < len; i++) {
                if (!tasks[i].complete) {
                    results.push(tasks[i]);
                }
            }
            return results;
        })
        .then(function(tasks) {
            var results = [], task;
            for (var i = 0, len = tasks.length; i < len; i++) {
                task = tasks[i];
                results.push({
                    id: task.id,
                    dueDate: task.dueDate,
                    title: task.title,
                    priority: task.priority
                })
            }
            return results;
        })
        .then(function(tasks) {
            tasks.sort(function(first, second) {
                var a = first.dueDate, b = second.dueDate;
                return a < b ? -1 : a > b ? 1 : 0;
            });
            return tasks;
        });
};

可能你和我一样,看到这段代码的第一反应是:诶呀好复杂>_<感觉脑子转不过来了(翻译:这个人是用脚写的代码?)。也有可能,你天赋异禀,可以一瞬间理解这个函数每一步都是在干什么。还有可能的是,你也写过这样的代码。这都没关系,但我们至少可以达成一个共识:这段代码是存在问题的。

那么,问题出在哪里?

其一,我们注意到,getIncompleteTaskSummaries是一个具有较高抽象层次的函数。如果是我来实现,会遵循这样的原则:

而这里,整个函数体都充满了底层实现。也就是说,上述代码缺失了低层次抽象这个环节。其实,所有调用在then方法中传入的回调其实都可以单独取个名字,然后拿出来复用。

甚至,我们可以把一个回调再拆成多个可复用的函数,这样就有更大的灵活性。比如:最容易想到的是,循环这个结构,一共使用了三次,我们能否把循环也做成一个函数呢?

其二,getIncompleteTaskSummaries这个函数本身缺乏复用性,如果我把需求从“未完成任务”改成了“已完成任务”,或者把“升序”改为“降序”,就会导致整个getIncompleteTaskSummaries重写。

以上提到的两点,违背了程序设计中的同一个原则:DRY(Don't Repeat Yourself)

其三,如果你是一个和我一样的普通人,恐怕无法在10秒内读懂这个getIncompleteTaskSummaries到底是在干什么(虽然它取了个这么长的名字,但光看名字还是语焉不详)。这段代码不易理解,归根结底是因为它每一步都缺乏语义化的说明,因此更接近计算机语言而非自然语言。换言之,它是命令式的,而非声明式的

这又违背了程序设计中的一个基本原则:KISS(Keep It Simple, Stupid)

那么,要怎么做,才能更优雅地搬砖呢?其实根据上面的讨论,不难看出,我们需要这样一种编程范式,它具有如下特性:

  • 抽象的目标是过程,抽象的工具是函数
  • 有分明的层次,高层次抽象的函数由低层次抽象的函数组合起来
  • 声明式

这就是函数式编程的基本思想。带着这些思想,我们来看看我们要做哪些事。

  • data中取出tasks
  • 筛选出usernameScott,且completefalse的对象
    • 循环tasks数组
    • 对每个元素判断是否为真,并加入新数组
    • 返回新数组
  • 根据dueDate升序排序

这其中每一步都是我之前提到的低层次抽象。

进一步观察,我们发现第二步是可以进一步推广的,因为我们不仅需要一个“筛选出usernameScott,且completefalse的对象”的函数,很可能我们之后会多次用到一个“筛选出满足一个布尔表达式为真的元素”的函数。而这就是ES6中filter的来历。

再进一步,我们发现在实现filter函数的时候会用到循环,而正如我之前提到的,循环是必然会被无数次重用的,那就把循环也写成函数吧!也即,forEach的来历。

第三步中也有一些可说的,比如“根据dueDate升序排序”也可以被推广为“根据任意属性升序排序”,产生一个可复用的函数sortBy

function at (obj, prop) {
    return obj[prop]
}
function atTasks (obj) {
    return at(obj, 'tasks')
}
function forEach (arr, callback) {
    for(let i = 0; i < at(arr, 'length'); i++) 
        callback(at(arr, i), i, arr)
}
function isIncomplete (obj) {
    return at(obj, 'complete') === false
}
function isAssignedTo (username) {
    return function (obj) {
         return at(obj, 'username') === username
    }
}
function satisfy (...restrictions) {
  return function(elem) {
    return restrictions.every(restriction => restriction(elem))
  }
}
function filter (arr, callback) {
    const res = []
    forEach(arr, val => {
        if(callback(val)) res.push(val)
    })
    return res
}
function sortBy (arr, prop) {
    return arr.sort((a, b) => {
        if(at(a, prop) > at(b, prop)) return 1
        if(at(a, prop) < at(b, prop)) return -1
        return 0
    })
}
function sortByDueDate (arr) {
    return sortBy(arr, 'dueDate')
}
function getIncompleteTaskSummaries(membername) {
    return fetchData()
        .then(data => {
      sortByDueDate(filter(atTasks(data), satisfy(isIncomplete, isAssignedTo(membername))))
     })
}

可以看到,这样一段代码是有着无限扩展性和灵活性的。完美解决了第一和第二个问题。

再看第三个问题。如果接手这段代码的人(或者写完这段代码一个月后的我)想要看懂整个程序在干什么,那么只需要看最后的函数体就可以了,而最后的函数体已经接近自然语言,在十秒内理解完全不成问题。另一方面,如果ta想要了解其中一个函数的底层实现,那么可以跳读到对应的函数体,注意到,最长的函数体不超过五行。至此,第三个问题也得到了解决。

但是,经过分析,我发现这样的代码依然美中不足。

其一,如atforEachfiltersortBy这样的函数,不属于业务逻辑,而且不止我这个项目中需要,而是应用非常广泛的util,这时我会想,如果有一个现成的库已经实现了这些轮子,那就好了。

其二,虽然最后一行“已经接近自然语言”,但假设我只有一颗仓鼠脑,觉得杂乱无章的括号还是妨碍了我理解,怎么办?其实,就像我之前列的表一样,要做的事情其实无非是:按顺序完成一系列任务,然后把它们连缀起来。进一步说,就是实现一个函数,使得:

其三,为了语义通顺,我定义了很多名字很长但是不能复用的函数,如atTaskssortByDueDate等,造成了命名空间的浪费甚至污染。既然这些函数都是一次性的,如果我们能够写成at('tasks')()或者sortBy('dueDate')()这样的柯里化函数,就可以在不定义新函数的前提下达到相同的效果,但如果我们用闭包进行手动柯里化未免过于麻烦了。

正好有这样一个库lodash.js为我们改进了这三点。我们引入lodash/fp模块,将以上代码改写如下。

const _ = require('lodash/fp')
const at = _.curry(function (prop, obj) {
  return obj[prop]
})
const isIncomplete = function (obj) {
  return at('complete')(obj) === false
}
const isAssignedTo = _.curry(function (username, obj) {
  return at('username')(obj) === username
})
const satisfy = function (...restrictions) {
  return function(elem) {
    return _.every(restriction => restriction(elem))(restrictions)
  }
}
const tasksIncompleteAndAssignedToScottSortedByDueDate = 
      _.flow(
       at('tasks'),
        _.filter(satisfy(isIncomplete, isAssignedTo('Scott'))),
        _.sortBy('dueDate')
      )
const getIncompleteTaskSummaries = function () {
    return fetchData()
        .then(tasksIncompleteAndAssignedToScottSortedByDueDate)
}

这样的代码,即使是从来没学过编程的人也可以大致看懂它在做什么。甚至可以说,阅读这样的代码是一件极度舒适(bushi)的事情。

优雅的异常处理——Maybe函子

在处理服务器返回的数据时,有可能会遇到空值null,当访问null的属性时会引起Javascript错误。这就意味着代码中必须充斥大量判空语句,干扰业务逻辑,并且严重降低可读性。下面只是一个简单的示例,实际情况可能会复杂得多。

function getCountry(student) {
    let school = student.school
    if(school !== null) {
        let addr = school.address
        if(addr !== null){
            return addr.country
        }
    }
    return 'Country does not exist'
}

可以看到,随着if嵌套加深,代码可读性会大幅度下降。

为解决这一问题,我们引入一种叫做Maybe函子的结构,你可以把Maybe理解为一个包裹着值的容器。Maybe分为两种,JustNothingJust类和Nothing类都实现了map方法,Just类的map方法接受一个函数f,会执行这样的操作:将值val从容器中拿出来,计算得到f(val),返回一个包裹着f(val)的新容器(这是为了隔离副作用)。如果f(val)不为空,则新容器类型也为Just,否则为NothingNothing类的map方法只会返回Nothing容器本身。

上面说得可能还是略抽象,但其实代码很简短。以下是一个Maybe的简易实现。

const isInvalid = function (val) {
    return val === null || val === undefined
}
class Maybe {
  static of (val) {
    if(isInvalid(val)) return new Nothing()
    return new Just(val)
  }
}
class Just extends Maybe {
  constructor (val) {
    this.val = val
  }
  map (f) {
    return of(f(this.val))
  }
  getOr () {
    return this.val
  }
}
class Nothing extends Maybe {
  map () {
    return this
  }
  getOr (other) {
    return other
  }
}

现在使用Maybe这一结构,重写之前的getCountry函数。

const getCountry = function (student) {
    return Maybe
        .of(student)
        .map(at('school'))
        .map(at('address'))
        .map(at('country'))
        .getOr('Country does not exist')
}

可以看到,Maybe的原理是:静默处理空值,不打断链式调用,把错误统一留到最后处理。

这里仅仅介绍了用Maybe判空的方法。如果要处理其他错误,其实只要改isInvalid函数即可。

无懈可击——单元测试

单元测试,顾名思义,就是把大项目拆成一个个单元,分别测试它们的功能。这些单元各自具有相对独立的功能,而且与外部代码隔离。一般来说,一个单元就是一个函数。

可以想见,如果对命令式代码进行单元测试,会举步维艰。原因主要是:

  • 不可重复性:因为命令式代码依赖大量共享状态,如果一次单元测试改变了共享状态,重复测试结果就会不同。(最简单的例子:给计数器+1后返回)

  • 不满足交换律:Clean Code的作者Robert C. Martin在一次演讲中曾问过观众这样一个问题:“你们有没有这样的经历?程序报错了,你百思不得其解,机缘巧合,你改变了两个函数的运行顺序,发现它居然跑起来了,但你至今都不知道为什么。”台下有过半的人都举起了手。这也是因为:一个函数f1需要依赖另一个函数f2修改共享状态后才能执行,交换运行顺序会导致不可预测的后果。

反过来,函数式编程则完全为单元测试铺平了道路。纯函数不依赖外部状态、无副作用的特性使得一个函数就是一个沙箱。

下面,简单介绍一下借助Qunit进行单元测试的方法。

$ npm i qunit -D

在项目根目录建立如下文件夹:

$ ls
node_modules/ package-lock.json package.json script/ test/

script目录下编写源文件(这里假设文件名为index.js):

const add = function (a, b) {
  return a + b
}
module.exports = {add}

test目录下编写测试文件:

const MyModule = require('../script/index')

QUnit.module('MyModule')

QUnit.test('test add', assert => {
  assert.equal(MyModule.add(1, 2), 19260817)
})

回到项目根目录,在package.json中添加一项scripts

{
  "devDependencies": {
    "qunit": "^2.19.3"
  },
  "scripts": {
    "test": "qunit"
  }
}

打开命令行,输入测试命令:

$ npm test

结果是not ok,得到详细信息如下:

> test
> qunit

TAP version 13
not ok 1 MyModule > test add
  ---
  message: failed
  severity: failed
  actual  : 3
  expected: 19260817
  stack: |
        at Object.<anonymous> (C:\Users\Leslie\Desktop\test\qunit\test\test.js:4:10)
  ...
1..1
# pass 0
# skip 0
# todo 0
# fail 1

参考文献:

[1]: Functional Programming in Javascript (2016), Luis Atencio

[2]: Favoring Curry, Scott Sauyet

[3]: Pointfree编程风格指南, 阮一峰


相关阅读

  • 读库二十二条好汉,奉上二十二份推荐

  • 春节将至,读库也将开启放假模式。作为这一年的总结,我们向同事们发起了一份年度书单征集。为顾全大局,避免出现大家只谈论自家产品这种其乐融融的场面,我们做了小小的限定,只能推
  • 梵高的秋 | 跟着名画吃水果

  • 水果时节水果是大自然最甜蜜的语言。在这个丰收的季节,水果以其甘甜滋润着万物,也启迪了人们。荷兰中部小镇蒂尔盛产苹果、梨、覆盆子、樱桃等水果,享有“荷兰果园”的美称。这
  • 梵高活动 | 博物馆之夜,越夜越精彩

  • 2022博物馆之夜AMSTERDAM11月5日周六晚,又是一年一度的阿姆斯特丹博物馆之夜。夜幕降临之后,全城50多座大小博物馆焕发出全新活力。只需一张通票,你就可以进入所有博物馆,尽情体
  • 梵高征集 | 年底了,让梵高艺术壁纸陪伴你

  • 近期,梵高博物馆后台收到了不少想要高清壁纸的请求。宠粉的我们怎么能不听呢?今天我们就来发福利!我们从博物馆最受欢迎的作品中,选出十张关于自然的画作,裁剪成高清手机壁纸尺寸
  • 梵高季节 | 跟着画作谈恋爱

  • 浪漫季节法国印象派画家雷诺阿认为,一幅画应该是美丽的、可爱的、欢快的,因为生活中令人烦恼的事情已经够多了,不需要再费心去制造更多麻烦。因此,雷诺阿的每一幅画都洋溢着幸福
  • 敬请期待|2023年维也纳新年音乐会

  • 2023年1月1日,维也纳爱乐乐团新年音乐会,这场古典音乐界的盛宴,将由奥地利指挥家 弗兰兹·韦尔瑟·穆斯特 担任,于维也纳金色大厅举行。新年第一天的傍晚,坐在电视前看一场新年音

热门文章

  • “复活”半年后 京东拍拍二手杀入公益事业

  • 京东拍拍二手“复活”半年后,杀入公益事业,试图让企业捐的赠品、家庭闲置品变成实实在在的“爱心”。 把“闲置品”变爱心 6月12日,“益心一益·守护梦想每一步”2018年四

最新文章

  • 浅谈JS函数式编程

  • 高效搬砖——拆分、组合、复用我们不妨以一个栗子开始:这是Scott Sauyet所著《Favoring Curry》中一个在项目实战里遇到的问题。var data = { result: "SUCCESS", inte
  • 浅析B站用户体验

  • 文章着重从用户体验五要素分析“哔哩哔哩"。什么是“用户体验五要素”?在《用户体验要素》这本书中,作者加瑞特把影响用户体验的因素分为5个层次,即a.战略存在层;b.能力范围层;
  • 穿越沙丘 —— 2022 冰岩年刊

  • 亲爱的冰岩人们,你们好!时间来到2022年底,互联网进入深水区的趋势似乎仍在继续。作为一个互联网学生团队,冰岩作坊也和现在的互联网行业一样遇上了前所未有的发展瓶颈,努力寻找着
  • 光影&构图 | 摄影大师何藩 ​​​​

  • 光影&构图的艺术,摄影大师何藩(Fan Ho)作品选 相关阅读(点击链接直接查看↓↓↓)极简主义 | 摄影师Alessandro Calvi 极简主义黑白 | Nina Papiorek极简| Alessandro Calvi干净
  • Blossom

  • Blossom摄影:春心未书 出镜:燕子| CNU原创摄影作品推荐 |倘若春天暖过我的身体,那我便可以枯萎在这茂盛而热烈的夏日,不留遗憾…原创作品推荐阅读(点击链接直接查看↓↓↓)·春
  • 狐姬

  • 狐姬摄影:長嘴-| CNU原创摄影作品推荐 |原创作品推荐阅读(点击链接直接查看↓↓↓)自由 热烈 坦荡浪漫里住着一个你乌龙茶入暮Blossom 摄影作品投搞请上传至(cnu.cc) 下载视觉