在 Javascript 中使用函数式编程
发布时间:2022-09-21 14:57:18 所属栏目:Unix 来源:
导读: 在过去几年,函数式编程这个话题渐渐火起来了,一大批新兴的语言:Scala、elixir、Swift 等,都是 FP 的忠实粉丝,编程语言中的老大哥 Java、C++,也放下 OOP 的架子,强势引入 lambda,敞开怀抱迎接 FP。加上原
|
在过去几年,函数式编程这个话题渐渐火起来了,一大批新兴的语言:Scala、elixir、Swift 等,都是 FP 的忠实粉丝,编程语言中的老大哥 Java、C++,也放下 OOP 的架子,强势引入 lambda,敞开怀抱迎接 FP。加上原来的 C#、Python,JavaScript 也或多或少对 FP 有一定的支持,所以业界绝大部分编程语言都可以和 FP 扯上关系。 那么什么是函数式编程呢,它有什么优点? 在深入之前,我们先回顾一些基础知识。 函数 首先,我们会大量用到的 箭头函数。 const double = x => x * 2 在进行函数式编程时,我们都会强调使用纯函数,纯函数只有一个思想:相同的输入,永远都会得到相同的输出。 map map 接受一个函数和一个数组,然后将函数作用于数组的每个元素,将其返回值组成一个新数组,并返回。在这里我引入了 Ramda,Ramda 是一个精心设计的库:包含许多 API ,来简洁、优雅进行 JavaScript 函数式编程。 R.map(double, [1, 2, 3])) //=> [2, 4, 6] reduce reduce 接受规约函数(是个二元函数 reducing function)、一个初始值和待处理的数组。归约函数的第一个参数是 "accumulator" (累加值),第二个参数取自待处理的数组中的元素;返回值为一个新的 "accumulator"。 const add = (accum, value) => accum + value R.reduce(add, 4, [1, 2, 3]) //=> 10 find find 将断言函数作用于数组中的每个元素,并返回第一个使断言函数返回真值的元素。 const isEven = x => x % 2 === 0 R.find(isEven, [1, 2, 3, 4]) //=> 2 filter filter 会根据断言函数的返回值,从数组中过滤元素。 const isEven = x => x % 2 === 0 R.filter(isEven, [1, 2, 3, 4]) //=> [2, 4] 一道开胃菜 function incompleteTaskTitles(tasks) { var results = [] for (var i = 0; i < tasks.length; i++) { if (tasks[i] && !tasks[i].complete) { results.push(tasks[i].title) } } return results } 这段代码很简单,采用的是我们很熟悉的命令式编程范,他接受一个 tasks 数组,循环时遍历出没有完成的 task,取出每个元素的 title 域,插入新的数组并返回。你现在应该理解这段代码的意图了,实际上是遍历一个任务列表,找出那些没有完成的任务,并返回他们的标题。我们很多人都在写或者写过这样的代码。但是这里有一些问题: 如果采用 FP,我们大概会这么写: const incompleteTaskTitles = tasks => tasks.filter(item => item && !item.complete).map(item => item.title) 再进行一次抽象: const extract = (filterFn, mapFn, tasks) => tasks.filter(filterFn).map(mapFn) ? const incomplete = item => item && !item.complete const titleForIncomplete = extract.bind(this, incomplete, item => item.title) const titleAndPriorityForIncomplete = extract.bind( this, incomplete, R.pick(['title', 'priority']) ) 相比之前的代码,这段代码结构更清晰、容易扩展,符合 Open-Close 原则。extract 更像是一个生成器,能够生成各种需要调用 filter 和 map 的处理函数。这两段代码其实涉及到两种不同的编程范式。 编程范式 我们常见的编程范式有 命令式编程(Imperative Programming),声明式编程(Declarative Programming),常见的 面向对象编程 是也是一种命令式编程,我们今天的主角 函数式编程(Function Programing)属于声明式编程。 命令式编程是面向计算机硬件的抽象,有变量、赋值语句、控制指令,其实就是通过一系列指令序列,告诉计算机怎么做。 而函数式编程是面向数学的抽象,将计算描述为一种 表达式求值。这里的「函数」指数学中的函数,f: a -> b,即自变量的映射。也就是说一个函数的值仅决定于函数参数的值,不依赖其他状态。因此我们可以看出函数式编程的一些特点: 如果合理的利用这些特点,能给软件带来: 对于我们来说,函数式编程是一种全新的编程范式,提供了另一种编程方式和更高级的抽象方式。近几年,关于函数式编程,社区也有很多分享,但是在前端领域,较少看到项目实践的案例,接下来我们就讨论一下部分概念,以及如何在项目中非入侵式的引入函数式编程。 Ramda Ramda 核心设计理念:不可变变量 和 函数无副作用,帮助开发者使用简洁、优雅的代码来完成工作。 主要特性 在这些特性当中,柯里化是最主要的一个,是函数式编程的基石。 柯里化 curry 是为纪念 Haskell Curry 而命名的,他是第一个研究这种技术的人。 柯里化将多参数函数转化一个新函数:当接受部分参数时,返回等待接受剩余参数的新函数。 首先 Ramda 提供了 R.curry 方法,可以柯里化任意多元函数。如果 f 是三元函数,g = R.curry(f) ,则下列写法是等价的: g(1, 2, 3) g(1)(2, 3) g(1)(2)(3) g(1, 2)(3) 其次,Ramda 中所有函数都是自动柯里化,这种自动柯里化使得 "通过组合函数来创建新函数" 变得非常容易。因为 API 都是函数优先、数据最后(先传函数,最后传数据参数),我们可以不断地组合函数,直到创建出需要的新函数,然后将数据传入其中(这句话不太懂的话,没有关系,后面会有比较大的篇幅来讨论)。 我们知道,任何类型的的编程范式,都会包含控制流、算术、比较和逻辑操作等基本构建块,函数式编程也不例外,Ramda 以语法糖比较优雅的处理了这些问题,下面稍微介绍一下逻辑操作。 逻辑操作 逻辑操作主要是 &&、|| 和 ! ,在 Ramda 中,and、or 和 not 用于处理数值逻辑操作;both、either 和 complement 用于处理函数逻辑操作。下面是一个简单的数值操作: R.and(true, true); //=> true R.and(true, false); //=> false 函数逻辑操作 both:接受两个函数,返回一个新函数:当两个传入函数都返回 truthy 值时,新函数返回 true,否则返回 false。现在我们判断一个任务是否属于高优先级而且是已完成的任务。 const isHighPriority = task => task.priority === HIGH_PRIORITY const wasComplete = task => Boolean(task.complete) ? const isHighPriorityAndComplete = task => R.both(isHighPriority, wasComplete)(task) either:接受两个函数,返回一个新函数:当两个传入函数任意一个返回 truthy 值时,新函数返回 true,否则返回 false。 现在需要判断一个数是非大于10,或者是一个偶数。 const gt10 = x => x > 10 const isEven = x => x % 2 === 0 const f = R.either(gt10, isEven) complement:给它传入一个函数,他返回一个新的函数,取反UNIX Shell函数,即返回原函数的补函数。 前面已经使用 find 来查找列表中的首个偶数。 R.find(isEven, [1, 2, 3, 4]) //=> 2 如果想要实现寻找首个奇数呢,我们可以实现一个 isOdd 。但是我们已经知道,任何非偶整数就是奇数,那么可以重用 isEven 函数,取反就好了。 R.find(R.complement(isEven), [1, 2, 3, 4]) //=> 1 你可能会想,这些方法完全是多余的嘛,直接使用逻辑操作符 &&、|| 和 ! 就好了。先放下这个想法,接受这些多余的方法,后面可以发挥重要作用。 Pipelines(管道) Pipeline(管道)借鉴于 Unix Shell 的管道操作——把若干个命令串起来,前面命令的输出成为后面命令的输入,如此完成一个流式计算。管道绝对是一个伟大的发明,它的设计哲学就是 KISS – 让每个功能就做一件事,并把这件事做到极致,软件或程序的拼装会变得更为简单和直观。这个设计理念影响非常深远,包括今天的 Web Service、云计算,以及大数据的流式计算等。 已 kill 所有 Chrome.app 进程为例: ps -ef | grep Chrome.app | grep -v grep | awk '{print $2}'| xargs kill -9 管道符 "|" 用来隔开两个命令,管道符左边命令的输出会作为管道符右边命令的输入。(注:目前 ECMAScript 有关于函数管道操作符的提案,写法和 Unix Shell 中管道的写法很像 "|>",具体参考) ps -ef 查看所有的进程,检索出来的进程作为下一条命令的输入; grep Chrome.app 过滤出包含关键字 "Chrome.app" 的进程; grep -v grep 排除包含关键字 "grep" 的进程; awk '{print $2}' 输出进程的第二段,也就是进程号 PID; xargs kill -9 上一个命令的输出作为 "kill -9" 命令的参数,并执行。 这是 Unix Shell 的 Pipeline 操作,在编码中,我们同样可以借鉴这个思想。尝试这样一个操作,一个方法接受两个数字,将它们相乘,加 1 ,然后平方。先看一种原始的写法: const square = R.curry(x => x * x) const operate = (x, y) => square(R.inc(R.multiply(x, y))) Ramda 提供了 pipe 函数:接受一系列函数,并返回一个新函数。 新函数的元数与第一个传入参数的元数相同(元数:参数的个数),然后顺序通过管道中的函数对输入参数进行处理。参数首先作用于第一个函数,返回结果作为下一个函数的入参,依次进行下去。因此上面的代码等价于: const operate = R.pipe(R.multiply, R.inc, square) 对应的数学解释: 先回顾一下函数本身,函数 fn 的输入数据是 a,输出的结果是 b。 fn: a -> b 如果运算比较复杂,像上面的这个操作,通常会把函数拆分成多个函数。 f1: a -> m f2: m -> n f3: n -> b 输入数据仍然是 a,输出的结果仍然是 b,但是中间多了两个中间值 m 和 n。 a m n b ---> f1 ---> f2 ---> f3 ---> 我们可以把整个操作想象成一根水管,数据从这头进行,经过很多几个弯道,从另一头出来。他们的关系如下: const fn = R.pipe(f1, f2, f3) Compose compose 的工作方式跟 pipe 基本相同,除了其调用函数的顺序是从右到左,而不是从左到右。下面使用 compose 来重写 operate: const operate = R.compose(square, R.inc, R.multiply) 比对一下原始的实现方式和 compose,可以得到一个公式: f(g(value)) 等价于 compose(f, g)(value) 这是一个非常重要的公式,后面会经常用到,请记住。 compose 非常方便进行函数式组合,这样做真正的意义是:Make it simple(让编程变得简单)。 无参数风格编程 (Pointfree Style) 在前面提到了 Ramda 的两个非常主要的特性: 这两个特性衍生出了 Pointfree Style,这是一个很好玩的特性,有点像变魔术,有时候又会有一点纠结。 我们先看看前面的例子,判断一个任务是否属于高优先级而且是已完成的任务。 const isHighPriority = task => task.priority === HIGH_PRIORITY const wasComplete = task => Boolean(task.complete) ? const isHighPriorityAndComplete = task => R.both(isHighPriority, wasComplete)(task) 对于第三个函数,在一系列参数中,task 出现了两次,一次在参数列表中,一次在函数的最后面。我们将由 R.both 返回的新函数作用于 task,这意味着,有一种方法将这些函数转换成 Pointfree Style。 const isHighPriorityAndComplete = R.both(isHighPriority, wasComplete) 嘭~~~!我们让参数 task 消失在地球。这就是 Pointfree Style!这两个版本所做的事情是一样的,完全返回一个接受 task 的函数,只不过后者没有显示的指定。这里相当于又有一个公式,value => f(value) 等价于 f。 我们可以对 isHighPriority 和 wasComplete 做同样的手脚。 首先,我们使用 equals 让函数更「声明式」一些。 const isHighPriority = task => R.equals(task.priority, HIGH_PRIORITY) const wasComplete = task => Boolean(task.complete) 根据公式: f(g(value)) 等价于 compose(f, g)(value),我们需要构建出函数的参数 task 排在函数参数列表的最后,但是 task.priority 这是属于命令式的写法,所以我们需要将命令式的方式重构为声明式的。 幸运的是, Ramda 为我们提供了访问对象属性的辅助函数:prop。 使用 prop,可以将 task.priority 转换为 prop("priority", task),接着转换为柯里化形式 prop("priority")(task),然后调整一下equals 的参数顺序。 const isHighPriority = task => R.equals(HIGH_PRIORITY, prop('priority')(task)) 接下来,使用 equals 的柯里化特性来创建新函数。 const isHighPriority = task => R.equals(HIGH_PRIORITY)(prop('priority')(task)) 好,我们仔细观察一下这个函数,发现可以套用公式了, f(g(value)) 等价于 compose(f, g)(value)。 const isHighPriority = R.compose(R.equals(HIGH_PRIORITY), R.prop('priority')) 哇喔,蒂丽舍丝。同理,wasComplete 可以重构为 const wasComplete = R.compose(Boolean, R.prop('complete')) 重构为 Pointfree Style 以后,程序的表意清晰了很多,很容易根据字面的意思来理解。大家应该有发现到一点,现在我们所有的函数很少看到有传递参数的情况,我们一直没有看到我们要处理的数据。这根我们的直觉不太相符,以往都是能直接拿到函数的返回值,在这里我们写了很多函数还是没有看到返回值。 有没有强迫症,我们再来加强一下。之所以要加强一下呢,就是希望能加深对 Pointfree Style 的认知,培养意识。 下面我们以常见的 TODO list 为例: 所有未完成的 task: const incomplete = R.filter(R.whereEq({complete: false})) 按照日期对所有 task 进行排序: const sortByDate = R.sortBy(R.prop('dueDate')) 按照日期对所有 task 进行 倒序排序: const sortByDateDescend = R.compose(R.reverse, sortByDate) task 中字段很多,只显示 task 中最重要的两个字段: const importantFields = R.project(['title', 'priority']) 根据 tag 对 task 进行分组: const groupByTag = R.groupBy(R.prop('tag')) 把未完成的 task 根据 tag 进行分组: const activeByTag = R.compose(groupByTag, incomplete) 把未完成的 task 根据 tag 进行分组,然后对该分组进行与之前相同的排序、过滤和提取自己操作: const gloss = R.compose(importantFields, R.take(2), sortByDateDescend) const topData = R.compose(gloss, incomplete) const topDataAllTags = R.compose(R.map(gloss), activeByTag) 你已经再也不知道数据是从哪里来了,每一个函数都是为了用小函数组织成更大的函数,函数的参数也是函数,函数返回的也是函数,最后得到一个超级牛逼的函数,就等着别人用他来写一个 main 函数把数据灌进去了,好有 C 语言的感觉。 也许这真的很酷,但是我们还是没有看到数据 。好吧,下面看一下数据。 让我们回到第一个函数: const incomplete = R.filter(R.whereEq({complete: false})) 如何能拿到数据呢,非常简单,最后一个参数传入数据。 const incompleteTasks = R.filter(R.whereEq({complete: false}), tasks) // or const incompleteTasks = incomplete(tasks) 就这样,在最后一个参数,传入需要处理的数据就好了,所有其他主要的函数也是这样:只需要在调用的最后面添加一个 tasks 参数,就可以返回数据。 但是,这玩意儿能用吗? jsfiddle.net/578734749/0bwp8vu0/9/ 以上可以看出,Pointfree Style 的本质就是使用一些通用的函数,组合出各种复杂运算,上述代码拥有非常强大的表达力。上层运算不要直接操作数据,而是通过底层函数去处理。这就要求,将一些常用的操作封装成函数。 简单说,Pointfree 就是运算过程抽象化,处理一个值,但是不提到这个值,所以有时候就会很纠结,因为总想拿到函数的返回值。其实这样做有很多好处,它能够让代码更清晰和简练(根据我们的经验,逻辑代码至少可以减少六成,极大的减少键盘损耗),更符合语义,更容易复用。 同时,这也是一种高级解耦,将代码的执行过程分成两个阶段,再看一下最开始的例子。 const extract = (filterFn, mapFn) => function process(array) { return array.filter(filterFn).map(mapFn) } 在第一个阶段调用 extract,在第二个阶段调用 process,传入数据,产品真正的结果。两个阶段的上下文关可以毫不相关,这意味我们可以在程序初始化阶段提供所有的操作,而在 Web Request 返回时,调用 process,再提供需要操作的数据(这和我们已有的观念是有很大的区别,以往一般都是有了数据以后,然后再去操作这个数据)。这种时序上的解耦使得代码的威力大大的增强。 实践 先掌握了基础的概念,然后再来学习高级概念。 总结 FP 是给软件开发者提供的另一套工具箱,为我们提供了另外一种抽象和思考的方式。但是也有不太擅长的场合,比如处理可变状态和处理 IO,要么引入可变变量,要么通过 Monad 来进行封装。因此后续还有很多高级概念需要学习,比如范畴论、形式系统、Monad、Functor 等等。 (编辑:百客网 - 百科网) 【声明】本站内容均来自网络,其相关言论仅代表作者个人观点,不代表本站立场。若无意侵犯到您的权利,请及时与联系站长删除相关内容! |
