摘要: JS 函数式编程入门.
作者: 前端小智 https://segmentfault.com/u/minnanitkong
Fundebug 经授权转载, 版权归原作者所有.
本系列的其它篇:
学会使用函数式编程的程序员(第 1 部分) https://segmentfault.com/a/1190000017511211
学会使用函数式编程的程序员(第 2 部分) https://segmentfault.com/a/1190000017526981
引用透明 (Referential Transparency)
引用透明是一个富有想象力的优秀术语, 它是用来描述纯函数可以被它的表达式安全的替换, 通过下例来帮助我们理解.
在代数中, 有一个如下的公式:
y = x + 10
接着:
x = 3
然后带入表达式:
y = 3 + 10
注意这个方程仍然是有效的, 我们可以利用纯函数做一些相同类型的替换.
下面是一个 JavaScript 的方法, 在传入的字符串两边加上单引号:
- function quote (str) {
- retrun "'"+ str +"'"
- }
下面是调用它:
- function findError (key) {
- return "不能找到" + quote(key)
- }
当查询 key 值失败时, findError 返回一个报错信息.
因为 quote 是纯函数, 我们可以简单地将 quote 函数体 (这里仅仅只是个表达式) 替换掉在 findError 中的方法调用:
- function findError (key) {
- return "不能找到" + "'"+ str +"'"
- }
这个就是通常所说的 **"反向重构"**(它对我而言有更多的意义), 可以用来帮程序员或者程序 (例如编译器和测试程序) 推理代码的过程一个很好的方法. 如, 这在推导递归函数时尤其有用的.
执行顺序 (Execution Order)
大多数程序都是单线程的, 即一次只执行一段代码. 即使你有一个多线程程序, 大多数线程都被阻塞等待 I/O 完成, 例如文件, 网络等等.
这也是当我们编写代码的时候, 我们很自然考虑按次序来编写代码:
1. 拿到面包
2. 把 2 片面包放入烤面包机
3. 选择加热时间
4. 按下开始按钮
5. 等待面包片弹出
6. 取出烤面包
7. 拿黄油
8. 拿黄油刀
9. 制作黄油面包
在这个例子中, 有两个独立的操作: 拿黄油以及 加热面包. 它们在 步骤 9 时开始变得相互依赖.
我们可以将 步骤 7 和 步骤 8 与 步骤 1 到 步骤 6 同时执行, 因为它们彼此独立. 当我们开始做的时候, 事情开始复杂了:
线程一
--------------------------
1. 拿到面包
2. 把 2 片面包放入烤面包机
3. 选择加热时间
4. 按下开始按钮
5. 等待面包片弹出
6. 取出烤面包
线程二
-------------------------
1. 拿黄油
2. 拿黄油刀
3. 等待线程 1 完成
4. 取出烤面包
果线程 1 失败, 线程 2 怎么办? 怎么协调这两个线程? 烤面包这一步骤在哪个线程运行: 线程 1, 线程 2 或者两者?
不考虑这些复杂性, 让我们的程序保持单线程会更容易. 但是, 只要能够提升我们程序的效率, 要付出努力来写好多线程程序, 这是值得的.
然而, 多线程有两个主要问题:
多线程程序难于编写, 读取, 解释, 测试和调试.
一些语言, 例如 JavaScript, 并不支持多线程, 就算有些语言支持多线程, 对它的支持也很弱.
但是, 如果顺序无关紧要, 所有事情都是并行执行的呢?
尽管这听起来有些疯狂, 但其实并不像听起来那么混乱. 让我们来看一下 Elm 的代码来形象的理解它:
- buildMessage message value =
- let
- upperMessage =
- String.toUpper message
- quotedValue =
- "'"++ value ++"'"
- in
- upperMessage ++ ":" ++ quotedValue
这里的 buildMessage 接受参数 message 和 value, 然后, 生成大写的 message 和 带有引号的 value .
注意到 upperMessage 和 quotedValue 是独立的. 我们怎么知道的呢?
在上面的代码示例中, upperMessage 和 quotedValue 两者都是纯的并且没有一个需要依赖其它的输出.
如果它们不纯, 我们就永远不知道它们是独立的. 在这种情况下, 我们必须依赖程序中调用它们的顺序来确定它们的执行顺序. 这就是所有命令式语言的工作方式.
第二点必须满足的就是一个函数的输出值不能作为其它函数的输入值. 如果存在这种情况, 那么我们不得不等待其中一个完成才能执行下一个.
在本例中, upperMessage 和 quotedValue 都是纯的并且没有一个需要依赖其它的输出, 因此, 这两个函数可以以任何顺序执行.
编译器可以在不需要程序员帮助的情况下做出这个决定. 这只有在纯函数式语言中才有可能, 因为很难 (如果不是不可能的话) 确定副作用的后果.
在纯函数语言中, 执行的顺序可以由编译器决定.
考虑到 CPU 无法一再的加快速度, 这种做法非常有利的. 别一方面, 生产商也不断增加 CPU 内核芯片的数量, 这意味着代码可以在硬件层面上并行执行. 使用纯函数语言, 就有希望在不改变任何代码的情况下充分地发挥 CPU 芯片的功能并取得良好成效.
类型注释 (Type Annotations)
在静态类型语言中, 类型是内联定义的. 以下是 Java 代码:
- public static String quote(String str) {
- return "'"+ str +"'";
- }
注意类型是如何同函数定义内联在一起的. 当有泛型时, 它变的更糟:
- private final Map<Integer, String> getPerson(Map<String, String> people, Integer personId) {
- // ...
- }
这里使用粗体标出了使它们使用的类型, 但它们仍然会让函数可读性降低, 你必须仔细阅读才能找到变量的名称.
对于动态类型语言, 这不是问题. 在 JavaScript 中, 可以编写如下代码:
- var getPerson = function(people, personId) {
- // ...
- };
这样没有任何的的类型信息更易于阅读, 唯一的问题就是放弃了类型检测的安全特性. 这样能够很简单的传入这些参数, 例如, 一个 Number 类型的 people 以及一个 Objec t 类型的 personId.
动态类型要等到程序执行后才能知道哪里问题, 这可能是在发布的几个月后. 在 Java 中不会出现这种情况, 因为它不能被编译.
但是, 假如我们能同时拥有这两者的优异点呢? JavaScript 的语法简单性以及 Java 的安全性.
事实证明我们可以. 下面是 Elm 中的一个带有类型注释的函数:
- add : Int -> Int -> Int
- add x y =
- x + y
请注意类型信息是在单独的代码行上面的, 而正是这样的分割使得其有所不同.
现在你可能认为类型注释有错训. 第一次见到它的时候. 大都认为第一个 -> 应该是一个逗号. 可以加上隐含的括号, 代码就清晰多了:
add : Int -> (Int -> Int)
上例 add 是一个函数, 它接受类型为 Int 的单个参数, 并返回一个函数, 该函数接受单个参数 Int 类型 并返回一个 Int 类型的结果.
以下是一个带括号类型注释的代码:
- doSomething : String -> (Int -> (String -> String))
- doSomething prefix value suffix =
- prefix ++ (toString value) ++ suffix
这里 doSomething 是一个函数, 它接受 String 类型的单个参数, 然后返回一个函数, 该函数接受 Int 类型的单个参数, 然后返回一个函数, 该函数接受 String 类型的单个参数, 并返回一个字符串.
注意为什么每个方法都只接受一个参数呢? 这是因为每个方法在 Elm 里面都是柯里化.
因为括号总是指向右边, 它们是不必要的, 简写如下:
doSomething : String -> Int -> String -> String
当我们将函数作为参数传递时, 括号是必要的. 如果没有它们, 类型注释将是不明确的. 例如:
- takes2Params : Int -> Int -> String
- takes2Params num1 num2 =
- -- do something
非常不同于:
- takes1Param : (Int -> Int) -> String
- takes1Param f =
- -- do something
takes2Param 函数需要两个参数, 一个 Int 和另一个 Int, 而 takes1Param 函数需要一个参数, 这个参数为函数, 这个函数需要接受两个 Int 类型参数.
下面是 map 的类型注释:
- map : (a -> b) -> List a -> List b
- map f list =
- // ...
这里需要括号, 因为 f 的类型是(a -> b), 也就是说, 函数接受类型 a 的单个参数并返回类型 b 的某个函数.
这里类型 a 是任何类型. 当类型为大写形式时, 它是显式类型, 例如 String. 当一个类型是小写时, 它可以是任何类型. 这里 a 可以是字符串, 也可以是 Int.
如果你看到 (a -> a) 那就是说输入类型和输出类型必须是相同的. 它们是什么并不重要, 但必须匹配.
但在 map 这一示例中, 有这样一段 (a -> b). 这意味着它既能返回一个不同的类型, 也能返回一个相同的类型.
但是一旦 a 的类型确定了, a 在整段代码中就必须为这个类型. 例如, 如果 a 是一个 Int,b 是一个 String, 那么这段代码就相当于:
(Int -> String) -> List Int -> List String
这里所有的 a 都换成了 Int, 所有的 b 都换成了 String.
List Int 类型意味着一个值都为 Int 类型的列表, List String 意味着一个值都为 String 类型的列表. 如果你已经在 Java 或者其他的语言中使用过泛型, 那么这个概念你应该是熟悉的
函数式 JavaScript
JavaScript 拥有很多类函数式的特性但它没有纯性, 但是我们可以设法得到一些不变量和纯函数, 甚至可以借助一些库.
但这并不是理想的解决方法. 如果你不得不使用纯特性, 为何不直接考虑函数式语言?
这并不理想, 但如果你必须使用它, 为什么不从函数式语言中获得一些好处呢?
不可变性(Immutability)
首先要考虑的是不变性. 在 ES2015 或 ES6 中, 有一个新的关键词叫 const, 这意味着一旦一个变量被设置, 它就不能被重置:
- const a = 1;
- a = 2; // 这将在 Chrome,Firefox 或 Node 中抛出一个类型错误, 但在 Safari 中则不会
在这里, a 被定义为一个常量, 因此一旦设置就不能更改. 这就是为什么 a = 2 抛出异常.
const 的缺陷在于它不够严格, 我们来看个例子:
- const a = {
- x: 1,
- y: 2
- };
- a.x = 2; // 没有异常
- a = {}; // 报错
注意到 a.x = 2 没有抛出异常. const 关键字唯一不变的是变量 a, a 所指向的对象是可变的.
那么 JavaScript 中如何获得不变性呢?
不幸的是, 我们只能通过一个名为 Immutable.JS https://facebook.github.io/immutable-js/ 的库来实现. 这可能会给我们带来更好的不变性, 但遗憾的是, 这种不变性使我们的代码看起来更像 Java 而不是 JavaScript.
柯里化与组合 (curring and composition)
在本系列的前面, 我们学习了如何编写柯里化函数, 这里有一个更复杂的例子:
const f = a => b => c => d => a + b + c + d
我们得手写上述柯里化的过程, 如下:
console.log(f(1)(2)(3)(4)); // prints 10
括号如此之多, 但这已经足够让 Lisp 程序员哭了. 有许多库可以简化这个过程, 我最喜欢的是 Ramda http://ramdajs.com/ .
使用 Ramda 简化如下:
- const f = R.curry((a, b, c, d) => a + b + c + d);
- console.log(f(1, 2, 3, 4)); // prints 10
- console.log(f(1, 2)(3, 4)); // also prints 10
- console.log(f(1)(2)(3, 4)); // also prints 10
函数的定义并没有好多少, 但是我们已经消除了对那些括号的需要. 注意, 调用 f 时, 可以指定任意参数.
重写一下之前的 mult5AfterAdd10 函数:
- const add = R.curry((x, y) => x + y);
- const mult5 = value => value * 5;
- const mult5AfterAdd10 = R.compose(mult5, add(10));
事实上 Ramda 提供了很多辅助函数来做些简单常见的运算, 比如 R.add 以及 R.multiply. 以上代码我们还可以简化:
const mult5AfterAdd10 = R.compose(R.multiply(5), R.add(10));
Map, Filter and Reduce
Ramda 也有自己的 map,filter 和 reduce 版本. 虽然这些函数存在于数组中. 这几个函数是在 Array.prototype 对象中的, 而在 Ramda 中它们是柯里化的
- const isOdd = R.flip(R.modulo)(2);
- const onlyOdd = R.filter(isOdd);
- const isEven = R.complement(isOdd);
- const onlyEven = R.filter(isEven);
- const numbers = [1, 2, 3, 4, 5, 6, 7, 8];
- console.log(onlyEven(numbers)); // prints [2, 4, 6, 8]
- console.log(onlyOdd(numbers)); // prints [1, 3, 5, 7]
R.modulo 接受 2 个参数, 被除数和除数.
isOdd 函数表示一个数除 2 的余数. 若余数为 0, 则返回 false, 即不是奇数; 若余数为 1, 则返回 true, 是奇数. 用 R.filp 置换一下 R.modulo 函数两个参数顺序, 使得 2 作为除数.
isEven 函数是 isOdd 函数的补集.
onlyOdd 函数是由 isOdd 函数进行断言的过滤函数. 当它传入最后一个参数, 一个数组, 它就会被执行.
同理, onlyEven 函数是由 isEven 函数进行断言的过滤函数.
当我们给函数 onlyEven 和 onlyOd 传入 numbers,isEven 和 isOdd 获得了最后的参数, 然后执行最终返回我们期望的数字.
JavaScript 的缺点
所有的库和语言增强都已经得到了 JavaScript 的发展, 但它仍然面临着这样一个事实: 它是一种强制性的语言, 它试图为所有人提供所有的东西.
大多数前端开发人员都不得不使用 JavaScript, 因为这旨浏览器也识别的语言. 相反, 它们使用不同的语言编写, 然后编译, 或者更准确地说, 是把其它语言转换成 JavaScript.
CoffeeScript 是这类语言中最早的一批. 目前, TypeScript 已经被 Angular2 采用, Babel 可以将这类语言编译成 JavaScript, 越来越多的开发者在项目中采用这种方式.
但是这些语言都是从 JavaScript 开始的, 并且只稍微改进了一点. 为什么不直接从纯函数语言转换到 JavaScript 呢?
未来期盼
我们不可能知道未来会怎样, 但我们可以做一些有根据的猜测. 以下是作者的一些看法:
能转换成 JavaScript 这类语言会有更加丰富及健壮.
已有 40 多年历史的函数式编程思想将被重新发现, 以解决我们当前的软件复杂性问题.
目前的硬件, 比如廉价的内存, 快速的处理器, 使得函数式技术普及成为可能.
PU 不会变快, 但是内核的数量会持续增加.
可变状态将被认为是复杂系统中最大的问题之一.
希望这系列文章能帮助你更好容易更好帮助你理解函数式编程及优势, 作者相信函数式编程是未来趋势, 大家有时间可以多多了解, 接着提升你们的技能, 然后未来有更好的出路.
来源: https://juejin.im/post/5c26e85e5188257ed57ead13