函数式思维前端开发
Overview

函数式编程在理论层面上有诸多概念,纯函数式的编程对于日常的业务开发来说也是一种『乌托邦』式的存在,但越来越多的高级语言开始向函数式方向发展,将函数式中的重要理念引入自身语法中。

前端领域也有很多特性、库或者框架来支持和应用函数式编程:

  • 前端麻烦的异步问题,可以由函数式编程中的异步计算来解决
  • 声明式编程基本被业界证明是前端 UI 编程的一种最佳实践方式

学习函数式编程,最重要的是思考如何利用它的设计理念帮助我们写出更好的代码,总结如何将这些理念应用到日常开发中,帮助我们去提升代码的可读性、可维护性等。

函数式编程的收益

函数式编程通过以下设计,可以实现简单、清晰、易维护、可复用的代码

  • 状态不可变、纯函数
  • 更高层次的抽象,丰富的集合操作
  • 强调组合、提高复用性
  • 避免引入状态,Pointfree

纯函数

函数式编程中的『函数』并非我们日常开发中所说的函数(方法),而是数学意义上的函数—映射。

我们知道,数学上的函数对相同的输入必定有相同的输出(映射关系是一一对应的)。

因此,函数式编程中纯函数也要满足同样的特征:

  • 相同的输入,必定得到相同的输出
  • 函数调用没有任何副作用
相同的输入,相同的输出

相同的输入,相同的输出,意味着函数不能依赖除入参以外的任何外部状态

相信大家在平常开发中,也能有这样的感受: 在理解、维护一个函数时,若其依赖了大量的外部状态,必定会造成不小的认知压力。 除了要理解函数本身的逻辑外,还要去关心其引用的外部状态信息。 有时不得不跳出函数本身去查看这些依赖的外部信息,阅读流程也因此被打断。

面向对象中类的成员函数隐式地包含this指针,通过它可以很方便地在成员函数中引用成员变量,这就是纯函数的典型反面教材。

实现了函数级的解耦,除了入参没有复杂的依赖关系,这样的函数可读性、可维护性就变得很高

无副作用

无副作用,意味着除了期望的函数输出值以外。没有任何其他的产出

常见的副作用包括,但不限于:

  • 改变外部数据(如类的成员变量、全局变量)
  • 获取用户交互信息(用户输入)
  • 发送网络请求
  • 读写文件、执行DB操作
  • 读取系统状态信息
  • ...
纯函数的收益

总之,纯函数就是不能与外部有任何的耦合关系,既包括对外界的依赖也包括对外界的影响

很明显,纯函数的收益主要有:

  • 可维护性更高
  • 可测性更强
  • 可复用性更好
  • 高并发更容易,没有多线程问题
  • 可缓存,由于相同的输入,必定有相同的输出,因此对于高频、昂贵的操作可以缓存结果,避免重复计算

在实际开发中,虽然无法做到所有函数都是纯函数,但纯函数意识应该要深植我们脑海中,尽可能地写更多的纯函数。


高阶函数 Higher-order function

函数式编程还有一个重要理念:函数是值,即一等函数(first-class),或者说函数是一等公民身份。 这意味着任何可以使用值的地方都可以使用函数,如参数、返回值等。

所谓高阶函数,就是其参数或返回值至少有一个是函数类型。

  • 高阶函数使得复用粒度降到了函数级别,在面向对象中复用粒度一般在类级别
  • 从另一个角度讲,高阶函数也实现了更高层级的抽象,因为实现细节可以通过参数的形式传入,即在函数级别上实现了依赖注入机制
  • 闭包是高阶函数得以实现的底层支撑能力

柯里化 Currying

简单讲,柯里化就是将『多参数函数』转换成『一系列单参数函数』的过程。

function add(x, y) { return x + y } var addCurrying = function(x) { return function(y) { return x + y } }
  • add是进行加法运算的函数,其接收2个参数,如add(1, 2)
  • addCurrying是经过柯里化处理过的,本质上addCurrying是单参数函数,其返回值也是一个单参数函数。add(1, 2),等价于addCurrying(1)(2)

柯里化有什么作用?

  • 在函数式集合操作上,如:filtermapreduceexpand等只接收单参数函数,如果现有的函数是多参数,可通过柯里化转换成单参数
  • 当某个函数需要多次调用,且部分参数相同时,通过柯里化可以减少重复参数样板代码

如,有多次调用加法运算的需求,且每次都是加10时,用普通add函数实现:

add(10, 1) add(10, 2) add(10, 3) // 柯里化之后 var addTen = addCurrying(10) addTen(1) addTen(2) addTen(3)

著名的第三方库lodash提供了curry封装函数,使得柯里化更加方便,如上面的addCurryinglodash#curry函数实现:

var curry = lodash.curry var addCurrying = curry(function(a, b) { return a + b })

对函数式编程来说,柯里化是一项不可或缺的技能。 对我们而言,即使不写函数式的代码,在解决重复参数等问题上柯里化也提供了一种全新的思路。


集合操作三板斧

函数式编程语言和面向对象语言对待代码重用的方式不一样:

  • 面向对象语言喜欢大量地建立有很多操作的各种数据结构
  • 函数式语言也有很多的操作,但对应的数据结构却很少
  • 面向对象语言鼓励我们建立专门针对某个类的方法,我们从类的关系中发现重复出现的模式并加以重用
  • 函数式语言的重用表现在函数的通用性上,它们鼓励在数据结构上使用各种共通的变换,并通过高阶函数来调整操作以满足具体事项的要求
在面向对象的命令式编程语言里面,重用的单元是类和用作类间通信的消息,通常可以表述成一幅类图(class diagram)。例如这个领域的开拓性著作《设计模式:可复用面向对象软件的基础》就给每一个模式都至少绘制了一幅类图。 在 OOP 的世界里,开发者被鼓励针对具体的问题建立专门的数据结构,并以方法的形式,将专门的操作关联在数据结构上。函数式编程语言选择了另一种重用思路。它们用很少的一组关键数据结构(如 list 、set 、map )来搭配专为这些数据结构深度优化过的操作。我们在这些关键数据结构和操作组成的一套运转机构上面,按需要“插入”另外的数据结构和高阶函数来调整机器,以适应具体的问题。例如我们已经在几种语言中操练过的 filter 函数,传给它的代码块就是这么一个“插入”的部件,筛选的条件由传入的高阶函数确定,而运转机构则负责高效率地实施筛选,并返回筛选后的列表。 —摘录来自 “《函数式编程思维》福特(Neal Ford)”

正如上述摘录,函数式编程的又一重要理念:在有限的集合(Collection)上提供丰富的操作

现在,很多高级语言都提供了大量对集合操作的支持,通过这些高度抽象的操作,可以写出非常简洁、易读的代码。

映射 map

map 是日常开发中使用频率最高的操作之一,map 就是将集合中的每个元素进行一次转换,得到一个新的值,其类型可以相同也可以不同。

map(function callback(currentValue[, index[, array]])
过滤 filter

过滤就是将列表中不满足指定条件的元素过滤掉,满足条件的元素以新列表的形式返回。

filter(callback(element[, index[, array]])[, thisArg])

本质就是为filter注入一个回调,用于判断其中的元素是否满足指定条件。

// 将年龄未满18的过滤掉: const ages = [19, 2, 8, 30, 11, 18] const result = ages.filter(age => age >= 18) console.log(result) // 19, 30, 18
化约、折叠 reduce

简单讲就是将指定操作依次作用于集合每个元素上,操作结果按操作规则依次叠加,并最终返回该叠加结果。

结果类型一般是一个具体的值,而不是 Iterable,因此经常出现在链式调用的末端。

reduce( callback(accumulator, currentValue[, index[, array]]) [, initialValue]) // 有的语言(JS)还提供了从右往左折叠的版本 reduceRight( callback(accumulator, currentValue[, index[, array]]) [, initialValue])
const array = [1, 3, 5, 7, 9] const sum = array.reduce((value, element) => value + element, 0) console.log(sum) // 输出:25
不可变性 immutable

集合上的操作还有一个重要特性:不可变性(immutable),即这些操作不会改变它们正在作用的集合,而是生成新集合来提供操作结果。

不可变性很好地避免了中间状态、状态不同步等问题。 同时,不变性语义使得代码可读性、维护推理性变得更好。因为,通过filtermapreduce等操作,而不是forwhile循环语句操作集合,可以清楚地表达将会生成一个新集合,而不是修改现有集合的意图,代码更加简洁明了。

有很多框架都有类似的思想,如:flux、redux 等,它们都强调(强制)任何操作都不能直接修改现有数据,而是在现有数据的基础上生成新数据,最终整体替换掉老数据。

另外,由于集合上的这些操作的返回值类型大都是集合,因此,当有多个操作作用于集合时,就可以以链式调用的方式实现。这也进一步简化了代码。

// 将从后台获取的用户头像url转换成头像widget显示在界面上(最多显示4个,同时过滤掉无效url) // 函数式 memberIconURLs.filter(_isValidURL) .slice(0, 4) .map(_memberWidgetBuilder) .reduce(_addMemberWidget2Stack, stack); // for 循环(命令式) let count = 0 for (let i = 0; i < memberIconURLs.length; i++) { const url = memberIconURLs[i]; if (_isValidURL(url)) { const memberWidget = _memberWidgetBuilder(url); _addMemberWidget2Stack(stack, memberWidget); count++; } if (count >= 4) { break; } }

再看个例子,进一步感受一下两者的差异:

// 获取成绩 >=90 分学生的 email // 函数式 const smart_student_emails_func = students => students .filter(_ => _.score >= 90) .map(_ => _.email) // 命令式 const smart_student_emails_command = function(students) { const emails = [] students.forEach(function(item, index, array) { if (item.score >= 90) { emails.push(item.email) } }) return emails }

很明显,函数式实现的代码简洁、易读、逻辑清晰、不易出错。 for 循环版本需要很小心地维护实现上的细节问题,还引入了不必要的中间状态:counturlmemberWidgetemails等,这些都是滋生 bug 的温床!

说到减少中间状态就不得不提 Pointfree。


Pointfree

Pointfree 最直接的定义就是没有中间状态,没有参数,数据直接在组合的函数间流动

从本质上说,Pointfree 就是通过一系列『通用函数的组合』来完成更复杂的任务

其设计理念:

  • 鼓励写高内聚、可复用的『小』函数
  • 强调『组合』,而非『耦合』,复杂任务通过小任务组合完成,而不是将所有操作耦合在一个『大』函数里

组合后的函数就像是用管道连接的一样,数据在其中自由流动,无须外界干预:

分析上面获取成绩>=90分学生 email 的函数式版本,发现整个过程其实可以分为 2 个独立的步骤:

  1. 过滤出成绩>=90分的学生
  2. 取学生的 email

将这两个步骤独立成2个小函数:

const smartStudents = (students) => students.filter(_ => _.score >= 90) const emails = (students) => students.map(_ => _.email)

这时smartStudentEmails就可以写成下面这样:

// 嵌套版 smart_student_emails_nest const smart_student_emails_nest= students => emails(smartStudents(students))

这种嵌套调用的写法好像看不出有什么优势,但有一点可以明确:

一个函数smartStudents的输出,直接成为另一个函数emails的输入。

const compose = (f, g) => x => f(g(x)) // 其入参为两个单参数函数 (f、g) // 输出还是一个单参数函数 x => f(g(x))

我们通过引入compose 函数来实现一个组合版本的smartStudentEmails

// 组合版 const smart_student_emails_compose = compose(emails, smartStudents)

相比嵌套调用版本 smart_student_emails_nest,组合版本 smart_student_emails_compose 具有以下两点优势:

  • 可读性更好,从右往左而不是由内而外的阅读顺序更符合我们的思维习惯
  • 自始至终从未提及要操作的数据,减少了中间状态信息(状态越多越容易出错)

注意:

对于日常开发,实现 smartStudentEmails 来说,excellentStudentEmails_Func 版本是更好的写法,smart_student_emails_compose 只是用于解说 Pointfree 的概念。


总结

开发中不要奢求,也没办法做到纯函数式的编程。

但函数式编程中很多优秀的设计理念都值得我们去学习和借鉴:

  • 纯函数
  • 做好抽象,屏蔽细节
  • 状态不可变,避免过多的中间状态
  • 高内聚的小函数
  • 多用组合
  • ...
参考资料

JS 函数式编程指南

函数式编程思维

什么是函数式编程思维

Collection Pipeline

Functional-Light JavaScript

前端中的函数式编程

平时开发应该怎么选择 FP 还是 OOP?