注:本文部分代码涉及到一些 JavaScript 新特性,需要提前了解一下:JavaScript 新特性
讲解视频:https://www.bilibili.com/video/BV1vV411a7kH/
1 函数式编程范式 1.1 定义 函数式编程(Functional Programming: FP)是一种编程范式(指计算机中编程中的典范模式或方法,就是一种思维方式),属于结构化编程,用于描述数据(函数)之间的映射关系。
特别需要注意的是,函数式编程中的函数不是指程序中的函数(方法),而是数学中的函数(映射关系),如:y = f(x)
,指x
和y
之间的关系。
常见的编程范式有:过程化(命令式)编程、面向对象编程、声明式编程等。
过程化编程:最原始的传统编程,将问题抽象为一系列步骤,然后通过编程方式将这些步骤转换为程序指令集,这些指令集按照一定顺序排列。人们把支持过程化编程范式的编程语言称为过程化编程语言,常见的有机器语言、汇编语言、BASIC、C、FORTRAN 等。过程化语言特别适合解决线性(或者说按部就班)的算法问题。
面向对象编程:将待解决问题抽象为面向对象的程序中的对象,利用封装使每个对象都拥有个体的身份。程序就是成堆的对象,彼此通过信息的传递,请求其它对象进行工作。面向对象包括三个基本概念:封装性、继承性、多态性。常见的面向对象语言有 Java、C、C++、JavaScript。
声明式编程:以数据结构的形式来表达程序执行的逻辑。它的主要思想是告诉计算机应该做什么,但不指定具体要怎么做。SQL 语句就是最明显的一种声明式编程的例子,我们只需要定义好该如何处理数据,不需要指定具体实现,就可以查询到我们需要的数据。
现代编程语言的发展趋势是支持多种范式,如 C#、Java 8+、Kotlin、ES6+。
编程范式和设计模式的区别 :
编程范式:是指从事软件工程的一类典型的编程风格(此概念好比“战略”),体现编写程序的人如何看待程序设计的“哲学观”;
程序设计模式:设计模式是软件设计中常见问题的典型解决方案(此概念好比“战术”),是解决一系列实际问题的“方法学”。
1.2 特点
代码简洁:函数式编程使用了大量的函数,减少了代码的重复;
接近自然语言,易于理解:
1 2 3 let result = (1 + 2 )* 3 - 4 ; let result = subtract (multiply (add (1 , 2 ), 3 ), 4 );
函数是“第一等公民”:函数与其他数据类型一样,处于平等地位,可以赋值给其它变量,也可以作为参数,传入另一个函数,或者作为别的函数的返回值;
闭包和高阶函数:函数式编程会使用较多的闭包和高阶函数;
没有“副作用”,方便与代码管理和单元测试:副作用
指函数内部与外部互动(最典型的情况,就是修改全局变量量的 值),产⽣运算以外的其他结果。函数式编程强调没有”副作用”,意味着函数要保持独立,所有功能就是返回一个新的值,没有其他⾏为,尤其是不得修改外部变量的值;
引用透明:函数的运行不依赖于外部变量或”状态”,只依赖于输入的参数,任何时候只要参数相同,引用函数所得到的返回值总是相同的。
1.3 基本概念 ① 函数式编程的思维方式:
把现实世界的事物和事物之间的联系(映射关系)抽象到程序世界(对运算过程进行抽象)
1 2 let money = multiply (0.5 , 2 );
② 程序的本质:
根据输入通过某种运算获得相应的输出,程序开发过程中会涉及很多有输入和输出的函数。
③ 函数y = f(x)
:
x → f(映射) → y
图 1 从 x 到 y 的函数关系
图 2 从 x 到 y 不是函数关系
④ 纯函数:相同的输入始终要得到相同的输出
⑤ 函数式编程是用来描述数据(函数)之间的映射
1.4 学习指南 函数式编程范式只是一种对程序编程思维的一种概论,而具体的实现则通过柯里化(第 5 章)、函数组合(第 6 章)、函子等来实现。
在学习如何实现前,需要先了解三个小知识点:头等函数(第 2 章),闭包(第 3 章),纯函数(第 4 章)。
2 头等函数 2.1 函数是一等公民 函数是一等公民?通俗来讲在某些编程语言中,函数是不能够:
函数可以存储在变量中
函数可以作为参数(2.2.1)
函数可以作为返回值(2.2.2)
JavaScript 对待不同的数据结构具有同等级别的支持,函数可以享受以上几种待遇,所以在 JavaScript 中,函数是一等公民。
函数可以存储在变量中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 const fn = ( ) => { console .log ("2.1 函数可以存储在变量中" ); }; fn ();const objController = { getKeys (obj ) { return Object .keys (obj); }, }; const objController = { getKeys : Object .keys , }; console .log (objController);const my = { name : "Patrick Jun" , constellation : "Virgo" };console .log (objController.getKeys (my));
2.2 高阶函数 如果一个函数以下面任一方式使用,那么这个函数就可以称为高阶函数。
Patrick Jun:可以操作函数的函数就是高阶函数。这就跟高数里的求导(二阶及以上的求导称之为高阶求导)一样,可以对已导函数的求导就是高阶求导。
2.2.1 函数作为参数 将函数作为参数最常见的就是咱们经常用的回调函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 function forEach (arr, fn ) { for (let i = 0 ; i < arr.length ; i++) { fn (arr[i], i); } } function filter (arr, fn ) { const results = []; for (const item of arr) { if (fn (item)) { results.push (item); } } return results; } const colors = ["#FF0000" , "#00FF00" , "blue" ];forEach (colors, (item, index ) => { console .log (index + 1 , item); }); console .log (filter (colors, (item ) => item.length === 7 ));
2.2.2 函数作为返回值 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 function makeFn ( ) { const msg = "hello function" ; return function ( ) { console .log (msg); }; } const fn = makeFn (); fn ();function once (func ) { let done = false ; return function ( ) { if (!done) { done = true ; func.apply (this , arguments ); } }; } const pay = once ((money ) => { console .log (`支付${money} 元` ); }); pay (20 ); pay (30 );pay (40 );
2.2.3 使用高阶函数意义
2.2.4 常用高阶函数模拟
map 通过指定函数处理数组的每个元素,并返回处理后的数组。
1 2 3 4 5 6 7 8 9 10 11 function map (arr, fn ) { const res = []; for (const val of arr) { res.push (fn (val)); } return res; } let arr = [1 , 2 , 3 , 4 , 5 ];arr = map (arr, (item ) => item * item); console .log (arr);
every 用于检测数组所有元素是否都符合指定条件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 function every (arr, fn ) { let res = true ; for (const val of arr) { res = fn (val); if (!res) { break ; } } return res; } const arr1 = [1 , 2 , 3 , 4 , 5 ];const arr2 = [4 , 5 , 6 , 7 ];const res1 = every (arr1, (item ) => item > 3 );console .log (res1); const res2 = every (arr2, (item ) => item > 3 );console .log (res2);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 function some (arr, fn ) { let res = false ; for (const val of arr) { res = fn (val); if (res) { break ; } } return res; } const arr1 = [1 , 2 , 3 , 4 , 5 ];const arr2 = [1 , 3 , 5 , 7 ];const res1 = some (arr1, (item ) => item % 2 === 0 );console .log (res1); const res2 = some (arr2, (item ) => item % 2 === 0 );console .log (res2);
find 返回数组中满足提供的测试函数的第一个元素的值,如果未找到,则返回 undefined
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 function find (arr, fn ) { for (const item of arr) { if (fn (item)) { return item; } } return undefined ; } const arr1 = [1 , 2 , 3 , 4 , 5 ];const res1 = find (arr1, (item ) => item % 2 === 0 );console .log (res1); const res2 = find (arr1, (item ) => item === 8 );console .log (res2);
findIndex 找到满足条件的第一个元素,返回其位置,如果未找到,则返回-1
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 function findIndex (arr, fn ) { for (let i = 0 ; i < arr.length ; i++) { if (fn (arr[i])) { return i; } } return -1 ; } const arr1 = [1 , 2 , 3 , 4 , 5 ];const res1 = findIndex (arr1, (item ) => item % 2 === 0 );console .log (res1); const res2 = findIndex (arr1, (item ) => item === 8 );console .log (res2);
3 闭包 3.1 定义 函数和其周围的状态的引用捆绑在一起形成的闭包。
可以在另一个作用域中调用一个函数内部的函数并访问到该函数的作用域中的成员;
闭包的本质:函数在执行的时候会放到一个执行栈上,当函数执行完毕后会从执行栈上删除,但是堆上作用域成员因为被外部引用而不能被释放 ,因此内部函数依然可以访问到作用域的成员;
特性:
函数嵌套函数(高阶函数)
函数内部可以引用函数外部的参数和变量
参数和变量不会被垃圾回收机制回收
3.2 案例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 function makePower (power ) { return function (number ) { return number ** power; }; } const power2 = makePower (2 );const power3 = makePower (3 );console .log (power2 (5 ));console .log (power2 (2 ));console .log (power3 (4 ));function makeSalary (base ) { return function (performance ) { return base + performance; }; } const level1 = makeSalary (1000 );const level2 = makeSalary (10000 );console .log (level1 (100 )); console .log (level1 (120 )); console .log (level2 (30000 ));
打开 Chrome 开发者工具 > Sources :
Call Stack(函数调用栈)
Scope(作用域) : Global(var 全局) 、 Local(局部) 、 Closure(闭包) 、 Script(let 作用域)
仅看一看演示一下,具体细节之后专门分享 ^_^
1 2 3 4 5 6 7 8 9 10 11 12 13 function makeSalary ( ) { let base = 1000 ; return function (performance ) { base += 1 ; return base + performance; }; } const sallary = makeSalary ();console .log (sallary (100 ));console .log (sallary (200 ));
4 纯函数 4.1 概念 相同的输入永远会等到相同的输出 ,没有任何可观察的副作用
slice 和 splice 分别:纯函数和不纯函数
slice 返回数组中的指定部分,不会改变原数组
splice 对数组进行操作返回该数组,会改变原数组
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 const numbers = [1 , 2 , 3 , 4 , 5 ];console .log (numbers.slice (0 , 3 )); console .log (numbers.slice (0 , 3 )); console .log (numbers.slice (0 , 3 )); console .log (numbers.splice (0 , 3 )); console .log (numbers.splice (0 , 3 )); console .log (numbers.splice (0 , 3 )); function getSum (a, b ) { return a + b; } console .log (getSum (1 , 2 )); console .log (getSum (1 , 2 )); console .log (getSum (1 , 2 ));
4.2 lodash 官网:lodash lodash 是一个纯函数的功能库,提供了对数组、数字、对象、字符串、函数等操作的一些方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 const _ = require ("lodash" );const arr = ["Tom" , "Jon" , "Kate" ];console .log (_.first (arr));console .log (_.last (arr));console .log (_.toUpper (_.last (arr)));console .log (_.reverse (arr));console .log (_.first (arr));_.each (arr, (item, index ) => { console .log (item, index); }); const value = [];_.isEmpty (value);
4.3 纯函数的好处
可缓存:因为纯函数相同的输入永远会等到相同的输出,所以可以把纯函数结果缓存
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 const _ = require ("lodash" );function getArea (r ) { console .log (`执行getArea计算,r = ${r} ` ); return Math .PI * r * r; } const getAreaWithMemory = _.memoize (getArea);console .log (getAreaWithMemory (4 ));console .log (getAreaWithMemory (4 )); console .log (getAreaWithMemory (5 ));function memoize (f ) { const cache = {}; return function ( ) { const key = JSON .stringify (arguments ); cache[key] = cache[key] || f.apply (f, arguments ); return cache[key]; }; } const getAreaWithMemory = memoize (getArea);console .log (getAreaWithMemory (4 ));console .log (getAreaWithMemory (4 ));console .log (getAreaWithMemory (5 ));
可测试:纯函数让测试更加方便,对单元化测试很友好
并行处理:在多线程环境下并行操作共享的内存数据很可能会出现意外情况,纯函数不需要访问共享的内存数据,所以在并行环境下可以任意运行纯函数(Web Worker)
4.4 副作用 纯函数:指相同的输入永远会得到相同的输出 ,而且没有可观察的副作用 ,而副作用让一个函数变的不纯,纯函数根据相同的输入返回相同的输出,如果函数依赖于外部的状态就无法保证输出相同,就会带来副作用。
1 2 3 4 5 6 7 8 9 10 11 let min = 18 ;function checkAge (age ) { return age >= min; } function checkAge2 (age ) { let min = 18 ; return age >= min; }
副作用的来源:
5 柯里化 柯里化(curry:咖喱)可以把多元函数转化成一元函数
当一个函数有多个参数的时候,先传递一部分参数调用它(这部分参数以后永远不变)
然后返回一个新的函数接受剩余的参数,直达参数接收完毕才返回结果
5.1 柯里化示例(问题回顾) 解决上面硬编码问题:
1 2 3 4 5 6 7 function checkAge (age, min ) { return age >= min; } console .log (checkAge (20 , 18 )); console .log (checkAge (17 , 18 )); console .log (checkAge (24 , 22 ));
上面代码可以发现当基准值时18
时,18
是重复的 使用之前所学的闭包处理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 function checkAge (min ) { return function (age ) { return age >= min; }; } const checkAge18 = checkAge (18 );const checkAge22 = checkAge (22 );console .log (checkAge18 (17 )); console .log (checkAge18 (20 )); console .log (checkAge22 (20 )); console .log (checkAge22 (30 ));
使用 ES6 改造上面checkAge
函数:
1 let checkAge = (min ) => (age ) => age >= min;
5.2 lodash.curry(fn)
需要注意:传参先后顺序不能变
1 2 3 4 5 6 7 8 9 10 11 const _ = require ("lodash" );function getSum (a, b, c ) { return a + b + c; } const curried = _.curry (getSum);console .log (curried (2 , 3 , 4 )); console .log (curried (2 )(3 )(4 )); console .log (curried (2 )(3 , 4 )); console .log (curried (2 , 3 )(4 ));
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 const _ = require ("lodash" );const match = _.curry ((reg, str ) => { return str.match (reg); }); const hasSpace = match (/\s+/g );const hasNumber = match (/\d+/g );console .log (hasSpace ("helloword" )); console .log (hasNumber ("123213 123" )); console .log (hasNumber ("helloword" )); const filter = _.curry ((func, array ) => { return array.filter (func); }); console .log (filter (hasSpace, ["Patrick Jun" , "Patrick_Jun" ])); const findSpace = filter (hasSpace);console .log (findSpace (["Patrick Jun" , "Patrick_Jun" ]));
模拟 lodash 中的 curry 方法
小知识点:fn = (a, b, c, d, e) => {};
,那么fn.length = 5
;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 function curry (func ) { return function curriedFn (...args ) { if (args.length < func.length ) { return function ( ) { return curriedFn (...args, ...arguments ); }; } return func (...args); }; } function getSum (a, b, c ) { return a + b + c; } const curried = curry (getSum);console .log (curried (2 , 3 )(4 )); console .log (curried (2 )(3 , 4 ));
图解步骤:
5.3 总结
柯里化可以让我们给一个函数传递较少的参数得到一个已经记住了某些固定参数的新函数
这是一种对函数参数的“缓存”(闭包)
让函数变的更灵活,让函数的粒度更小
可以把多元函数转换成一元函数,可以组合使用函数产生强大的功能
6 函数组合 6.1 概念 函数组合(compose):如果一个函数要经过多个函数处理才能得到最终值,这个时候可以把中间过程的函数合并成一个函数。
函数就像是数据的管道,函数组合就是把这些管道连接起来,让数据穿过多个管道形成最终结果
函数组合默认是从右到左执行
函数组合后只接受一个参数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 function reverse (array ) { return array.reverse (); } function first (array ) { return array[0 ]; } function compose (f, g ) { return function (value ) { return f (g (value)); }; } const last = compose (first, reverse);console .log (last ([1 , 2 , 3 , 4 ]));
6.2 lodash 组合函数 lodash 中组合函数flow()
或者flowRight()
,他们都可以组合多个函数
flow
和flowRight
会创建一个函数,返回结果是调用提供函数的结果。提供函数会连续调用,每个提供函数传入的参数都是前一个函数返回的结果。
flow()
是从左到右运行
flowRight()
是从右到左运行,使用的更多一些
1 2 3 4 5 6 7 8 9 const _ = require ("lodash" );const reverse = (arr ) => arr.reverse ();const first = (arr ) => arr[0 ];const toUpper = (s ) => s.toUpperCase ();const f = _.flowRight (toUpper, first, reverse);console .log (f (["one" , "two" , "three" ]));
模拟lodash
中的flowRight()
方法:
数组中的reduce() :对数组中的每个元素执行一个由您提供的 reducer 函数(升序执行),将其结果汇总为单个返回值。
1 2 3 4 5 6 7 8 9 10 function compose (...args ) { return function (val ) { return args.reverse ().reduce ((acc, fn ) => { return fn (acc); }, val); }; }
图解步骤:
6.3 结合律 函数的组合要满足结合律。
例如compose(f,g,h)
,我们既可以先把f
和g
组合在一起,还可以先把g
和h
组合:
1 2 3 4 5 6 7 8 9 10 11 12 console .log (compose (compose (f, g), h) == compose (f, compose (g, h))); console .log (compose (f, g, h) == compose (f, compose (g, h))); const _ = require ("lodash" );const f = _.flowRight (_.flowRight (_.toUpper , _.first ), _.reverse ); const f1 = _.flowRight (_.toUpper , _.flowRight (_.first , _.reverse )); const f2 = _.flowRight (_.toUpper , _.first , _.reverse ); console .log (f (["one" , "two" , "three" ]) === f1 (["one" , "two" , "three" ])); console .log (f (["one" , "two" , "three" ]) === f2 (["one" , "two" , "three" ])); console .log (f1 (["one" , "two" , "three" ]) === f2 (["one" , "two" , "three" ]));
6.4 实战 题目:将NEVER SAY DIE
转换为 never-say-die
;
思路:小写,分割,join
'NEVER SAY DIE'.toLowerCase().split(' ').join('-');
分析:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 const _ = require ("lodash" );const split = _.curry ((symbol, str ) => _.split (str, symbol));const join = _.curry ((symbol, array ) => _.join (array, symbol));const log = (v ) => { console .log (v); return v; }; const f = _.flowRight (join ("-" ), log, split (" " ), log, _.toLower );console .log (f ("NEVER SAY DIE" ));
7 总结
函数式编程是一种强调以函数使用为主的软件开发风格;
纯函数指没有副作用的函数,相同的输入有相同的输出;
在函数式编程里面,将多个不同函数组合是一个非常非常非常重要的思想;
函数式编程将函数视为积木,通过一些高阶函数来提高代码的模块化和可重用性。
理解:柯里化是”因式分解“,将参数分解开;函数组合是”结合律“,函数可以组合使用。
进阶内容:lodash/fp
、函子
;(笔者还没学明白呢,敬请期待)
参考文章:
概念定义特点:https://juejin.im/post/6858129115598635015
函数式编程讲解:https://juejin.im/post/6844903743117361165
函数式编程讲解:https://juejin.im/post/6844903655397654535
拉勾教育大前端训练营笔记: https://zhuanlan.zhihu.com/p/162839608
what are “first class objects”:https://stackoverflow.com/questions/245192/what-are-first-class-objects