一杯茶的时间,上手函数式编程
最近和一些同学讨论了函数式编程,很多同学总觉得听起来很高大上,但用起来却无从下手。于是我抽时间捋了捋,将平时工作中用到的函数式编程案例和思想整理了出来,相信阅读本文后,大家都能快速上手函数式编程。
函数式编程目前使用范围非常广,常用的框架,语言几乎都能看到它的身影。
前端框架:react、vue 的 hooks 用法。 打包工具:webpack 的 webpack-chain 用法。 工具库:underscore、lodash、ramda。 部署方式:serverless。 后端:java、c# 中的 lamda 表达式。
本文将通过以下 3 个部分来深入函数式编程。
编程范式 函数式编程 函数式编程常见案例
编程范式
编程范式 指的是一种编程风格,它描述了程序员对程序执行的看法。在编程的世界中,同一个问题,可以站在多个角度去分析解决,这些不同的解决方案就对应了不同的编程风格。
常见的编程范式有:
命令式编程 面向过程编程 C 面向对象编程 C++、C#、Java 声明式编程 函数式编程 Haskell
命令式编程
命令式编程 是使用最广的一种编程风格,它是站在计算机的角度去思考问题,主要思想是 关注计算机执行的步骤,即一步一步告诉计算机先做什么再做什么。
由于存在很多需要控制的步骤,所以命令式编程普遍存在以下特点:
控制语句 循环语句:while、for 条件分支语句:if else、switch 无条件分支语句:return、break、continue 变量 赋值语句
根据这些特点,我们来分析一个命令式编程案例:
// 需求:筛选出数组中为奇数的子集合
const array = [1, 2, 3, 4, 5, 6, 7, 8, 9];
// 步骤1:定义执行结果变量
let reult = [];
// 步骤2:控制程序循环调用
for (let i = 0; i < array.length; i++) {
// 步骤3:判断筛选条件
if (array[i] % 2 !== 0) {
// 步骤4:加入执行结果
reult.push(array[i]);
}
}
// 步骤5:得到最终的结果 result
以上代码通过 5 个步骤,实现了数组的筛选,这并没有什么问题,但细心的同学可能会感到疑惑:这样写的代码量太长了,而且并不语义化,只有阅读完每一行的代码,才知道具体执行的是什么逻辑。
没错,这就是命令式编程的典型特点,除此之外,还有以下几点:
命令式编程的每一个步骤都可以由程序员定义,这样可以更精细化、更严谨地控制代码,从而提高程序的性能。 命令式编程的每一个步骤都可以记录中间结果,方便调试代码。 命令式编程需要大量的流程控制语句,在处理多线程状态同步问题时,容易造成逻辑混乱,通常需要加锁来解决。
声明式编程
声明式编程 同样是一种编程风格,它通过定义具体的规则,以便系统底层可以自动实现具体功能。主要思想是 告诉计算机应该做什么,但不指定具体要怎么做。
由于需要定义规则来表达含义,所以声明式编程普遍存在以下特点:
代码更加语义化,便于理解。 代码量更少。 不需要流程控制代码,如:for、while、if 等。
接下来,我们将上文中的数组筛选,用声明式的方式重构一下:
// 筛选出数组中为奇数的子集合
const array = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const reult = array.filter((item) => item % 2 !== 0);
可以看到,声明式编程没有冗余的操作步骤,代码量非常少,并且非常语义化,当我们读到 filter 的时候,自然而然就知道是在做筛选。
我们再看一个案例:
# 使用 sql 语句,查询 id 为 25 的学生
select * from students where id=25
在上述代码中,我们只是告诉计算机,我想查找 id 为 25 的同学,计算机就能给我们返回对应的数据了,至于是怎么查找出来的,我们并不需要关心,只要结果是正确的即可。
除了上述例子之外,还有很多声明式编程的案例:
html 用来声明了网页的内容。 css 用来声明网页中元素的外观。 正则表达式,声明匹配的规则。
有了以上几个案例,我们来总结一下声明式编程的优缺点:
声明式编程不需要编写复杂的操作步骤,可以大大减少开发者的工作量。 声明式编程的具体操作都是底层统一管理,可以降低重复工作。 声明式编程底层实现的逻辑并不可控,不适合做更精细化的优化。
函数式编程
函数式编程 属于声明式编程中的一种,它的主要思想是 将计算机运算看作为函数的计算,也就是把程序问题抽象成数学问题去解决。
函数式编程中,我们可以充分利用数学公式来解决问题。也就是说,任何问题都可以通过函数(加减乘除)和数学定律(交换律、结合律等),一步一步计算,最终得到答案。
函数式编程中,所有的变量都是唯一的值,就像是数学中的代数 x、y,它们要么还未解出来,要么已经被解出为固定值,所以对于:x=x+1
这样的自增是不合法的,因为修改了代数值,不符合数学逻辑。
除此之外,严格意义上的函数式编程也不包括循环、条件判断等控制语句,如果需要条件判断,可以使用三元运算符代替。
文章开头我们提到了 webpack-chain,我们一起来看一下:
// 使用 webpack-chain 来编写 webpack 配置。
const Config = require('webpack-chain');
const config = new Config();
config.
.entry('index')
.add('src/index.js')
.end()
.output
.path('dist')
filename('my-first-webpack.bundle.js');
config.module
.rule('compile')
.test(/\.js$/)
.use('babel')
.loader('babel-loader')
module.exports = config;
可以看到,webpack-chain 可以通过链式的函数 api 来创建和修改 webpack 配置,从而更方便地创建和修改 webpack 配置。试想一下,如果一份 webpack 配置需要用于多个项目,但每个项目又有一些细微的不同配置,这个应该怎么处理呢?
如果使用 webpack-chain 去修改配置,一个函数 api 就搞定了,而使用命令式的编程,则需要去逐步遍历整个 webpack 配置文件,找出需要修改的点,才能进行修改,这无疑就大大减少了我们的工作量。
函数式编程的特点
根据维基百科权威定义,函数式编程有以下几个特点:
函数是一等公民 函数可以和变量一样,可以赋值给其他变量,也可以作为参数,传入一个函数,或者作为别的函数返回值。 只用表达式,不用语句: 表达式是一段单纯的运算过程,总是有返回值。 语句是执行某种操作,没有返回值。 也就是说,函数式编程中的每一步都是单纯的运算,而且都有返回值。 无副作用 不会产生除运算以外的其他结果。 同一个输入永远得到同一个数据。 不可变性 不修改变量,返回一个新的值。 引用透明 函数的运行不依赖于外部变量,只依赖于输入的参数。
以上的特点都是函数式编程的核心,基于这些特点,又衍生出了许多应用场景:
纯函数:同样的输入得到同样的输出,无副作用。 函数组合:将多个依次调用的函数,组合成一个大函数,简化操作步骤。 高阶函数:可以加工函数的函数,接收一个或多个函数作为输入、输出一个函数。 闭包:函数作用域嵌套,实现的不同作用域变量共享。 柯里化:将一个多参数函数转化为多个嵌套的单参数函数。 偏函数:缓存一部分参数,然后让另一些参数在使用时传入。 惰性求值:预先定义多个操作,但不立即求值,在需要使用值时才去求值,可以避免不必要的求值,提升性能。 递归:控制函数循环调用的一种方式。 尾递归:避免多层级函数嵌套导致的内存溢出的优化。 链式调用:让代码更加优雅。
这些应用场景都大量存在于我们的日常工作中,接下来我们通过几个案例来实战一下。
函数式编程常见案例
基于函数式编程的应用场景,我们来实现几个具体的案例。
函数组合 柯里化 偏函数 高阶函数 尾递归 链式调用
1、函数组合,组合多个函数步骤。
function compose(f, g) {
return function () {
return f.call(this, g.apply(this, arguments));
};
}
function toLocaleUpperCase(str) {
return str.toLocaleUpperCase();
}
function toSigh(str) {
return str + "!";
}
// 将多个函数按照先后执行顺序组合成一个函数,简化多个调用步骤。
const composedFn = compose(toSigh, toLocaleUpperCase);
console.log("函数组合:", composedFn("msx"));
// 函数组合:MSX!
2、柯里化,将一个多参数函数转化为多个嵌套的单参数函数。
// 柯里化
function curry(targetfn) {
var numOfArgs = targetfn.length;
return function fn(...rest) {
if (rest.length < numOfArgs) {
return fn.bind(null, ...rest);
} else {
return targetfn.apply(null, rest);
}
};
}
// 加法函数
function add(a, b, c, d) {
return a + b + c + d;
}
// 将一个多参数函数转化为多个嵌套的单参数函数
console.log("柯里化:", curry(add)(1)(2)(3)(4));
// 柯里化:10
3、偏函数,缓存一部分参数,然后让另一些参数在使用时传入。
// 偏函数
function isTypeX(type) {
return function (obj) {
return Object.prototype.toString.call(obj) === `[object ${type}]`;
};
}
// 缓存一部分参数,然后让另一些参数在使用时传入。
const isObject = isTypeX("Object");
const isNumber = isTypeX("Number");
console.log("偏函数测试:", isObject({ a: 1 }, 123)); // true
console.log("偏函数测试:", isNumber(1)); // true
4、惰性求值,预先定义多个操作,但不立即求值,在需要使用值时才去求值,可以避免不必要的求值,提升性能。
// 这里使用 C# 中的 LINQ 来演示
// 假设数据库中有这样一段数据 db.Gems [4,15,20,7,3,13,2,20];
var q =
db.Gems
.Select(c => c < 10)
.Take(3)
// 只要不调用 ToList 就不会求值
// 在具体求值的时候,会将预先定义的方法进行优化整合,以产生一个最优的解决方案,才会去求值。
.ToList();
上述代码中,传统的求值会遍历 2 次,第一次遍历整个数组(8 项),筛选出小于 10 的项,输出 [4,7,3,2]
,第二次遍历这个数组(4 项),输出 [4,7,3]
。
如果使用惰性求值,则会将预先定义的所有操作放在一起进行判断,所以只需要遍历 1 次就可以了。在遍历的同时判断 是否小于 10
和 小于 10 的个数是否为 3
,当遍历到第 5 项时,就能输出 [4,7,3]
。
相比传统求值遍历的 8+4=12 项,使用惰性求值则只需遍历 5 项,程序的运行效率也就自然而然地得到了提升。
5、高阶函数,可以加工函数的函数(接收一个或多个函数作为输入、输出一个函数)。
// React 组件中,将一个组件,封装为带默认背景色的新组件。
// styled-components 就是这个原理
function withBackgroundRedColor (wrapedComponent) {
return class extends Component {
render () {
return (<div style={backgroundColor: 'red} >
<wrapedComponent {...this.props} />
div>)
}
}
}
6、递归和尾递归。
// 普通递归,控制函数循环调用的一种方式。
function fibonacci(n) {
if (n === 0) {
return 0;
}
if (n === 1) {
return 1;
}
return fibonacci(n - 1) + fibonacci(n - 2);
}
console.log("没有使用尾递归,导致栈溢出", fibonacci(100));
// 尾递归,避免多层级函数嵌套导致的内存溢出的优化。
function fibonacci2(n, result, preValue) {
if (n == 0) {
return result;
}
return fibonacci2(n - 1, preValue, result + preValue);
}
// result = 0, preValue = 1
console.log("使用了尾递归,不会栈溢出", fibonacci2(100, 0, 1));
6、链式调用
// lodash 中,一个方法调用完成之后,可以继续链式调用其他的方法。
var users = [
{ user: "barney", age: 36 },
{ user: "fred", age: 40 },
{ user: "pebbles", age: 1 },
];
var youngest = _.chain(users)
.sortBy("age")
.map(function (o) {
return o.user + " is " + o.age;
})
.head()
.value();
// => 'pebbles is 1'
在上面,我们讨论了常用的函数式编程案例,接下来我们来探究一下什么是 Monad?
在函数式编程中,Monad 是一种结构化程序的抽象,我们通过三个部分来理解一下。
Monad 定义 Monad 使用场景 Monad 一句话解释
Monad 定义
根据维基百科的定义,Monad 由以下三个部分组成:
一个类型构造函数(M),可以构建出一元类型 M
。一个类型转换函数(return or unit),能够把一个原始值装进 M 中。 unit(x) : T -> M T
一个组合函数 bind,能够把 M 实例中的值取出来,放入一个函数中去执行,最终得到一个新的 M 实例。 M
执行T-> M
生成M
除此之外,它还遵守一些规则:
单位元规则,通常由 unit 函数去实现。 结合律规则,通常由 bind 函数去实现。
单位元:是集合里的一种特别的元素,与该集合里的二元运算有关。当单位元和其他元素结合时,并不会改变那些元素。
乘法的单位元就是 1,任何数 x 1 = 任何数本身、1 x 任何数 = 任何数本身。
加法的单位元就是 0,任何数 + 0 = 任何数本身、0 + 任何数 = 任何数本身。
这些定义很抽象,我们用一段 js 代码来模拟一下。
class Monad {
value = "";
// 构造函数
constructor(value) {
this.value = value;
}
// unit,把值装入 Monad 构造函数中
unit(value) {
this.value = value;
}
// bind,把值转换成一个新的 Monad
bind(fn) {
return fn(this.value);
}
}
// 满足 x-> M(x) 格式的函数
function add1(x) {
return new Monad(x + 1);
}
// 满足 x-> M(x) 格式的函数
function square(x) {
return new Monad(x * x);
}
// 接下来,我们就能进行链式调用了
const a = new Monad(2)
.bind(square)
.bind(add1);
//...
console.log(a.value === 5); // true
上述代码就是一个最基本的 Monad,它将程序的多个步骤抽离成线性的流,通过 bind 方法对数据流进行加工处理,最终得到我们想要的结果。
Ok,我们已经明白了 Monad 的内部结构,接下来,我们再看一下 Monad 的使用场景。
Monad 使用场景
通过 Monad 的规则,衍生出了许多使用场景。
组装多个函数,实现链式操作。 链式操作可以消除中间状态,实现 Pointfree 风格。 链式操作也能避免多层函数嵌套问题 fn1(fn2(fn3()))
。如果你用过 rxjs,就能体会到链式操作带来的快乐。 处理副作用。 包裹异步 IO 等副作用函数,放在最后一步执行。
还记得 Jquery 时代的 ajax 操作吗?
$.ajax({
type: "get",
url: "request1",
success: function (response1) {
$.ajax({
type: "get",
url: "request2",
success: function (response2) {
$.ajax({
type: "get",
url: "request3",
success: function (response3) {
console.log(response3); // 得到最终结果
},
});
},
});
},
});
上述代码中,我们通过回调函数,串行执行了 3 个 ajax 操作,但同样也生成了 3 层代码嵌套,这样的代码不仅难以阅读,也不利于日后维护。
Promise 的出现,解决了上述问题。
fetch("request1")
.then((response1) => {
return fetch("request2");
})
.then((response2) => {
return fetch("request3");
})
.then((response3) => {
console.log(response3); // 得到最终结果
});
我们通过 Promise,将多个步骤封装到多个 then 方法中去执行,不仅消除了多层代码嵌套问题,而且也让代码划分更加自然,大大提高了代码的可维护性。
想一想,为什么 Promise 可以不断执行 then 方法?
其实,Promise 和 Monad 很类似,它满足了多条 Monad 规则。
Promise 本身就是一个构造函数。 Monad 中的 unit,在 Promise 中可以看为: x => Promise.resolve(x)
Monad 中的 bind,在 Promise 中可以看为: Promise.prototype.then
我们用代码来验证一下。
// 首先定义 2 个异步处理函数。
// 延迟 1s 然后 加一
function delayAdd1(x) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(x + 1);
});
}, 1000);
}
// 延迟 1s 然后 求平方
function delaySquare(x) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(x * x);
});
}, 1000);
}
/****************************************************************************************/
// 单位元 e 规则,满足:e*a = a*e = a
const promiseA = Promise.resolve(2).then(delayAdd1);
const promiseB = delayAdd1(2);
// promiseA === promiseB,故 promise 满足左单位元。
const promiseC = Promise.resolve(2);
const promiseD = a.then(Promise.resolve);
// promiseC === promiseD,故 promise 满足右单位元。
// promise 既满足左单位元,又满足右单位元,故 Promise 满足单位元。
// ps:但一些特殊的情况不满足该定义,下文中会讲到
/****************************************************************************************/
// 结合律规则:(a * b)* c = a *(b * c)
const promiseE = Promise.resolve(2);
const promiseF = promiseE.then(delayAdd1).then(delaySquare);
const promiseG = promiseE.then(function (x) {
return delayAdd1(x).then(g);
});
// promiseF === promiseG,故 Promise 是满足结合律。
// ps:但一些特殊的情况不满足该定义,下文中会讲到
看完上面的代码,不禁感觉很惊讶,Promise 和 Monad 也太像了吧,不仅可以实现链式操作,也满足单位元和结合律,难道 Promise 就是一个 Monad?
其实不然,Promise 并不完全满足 Monad:
Promise.resolve 如果传入一个 Promise 对象,会等待传入的 Promise 执行,并将执行结果作为外层 Promise 的值。 Promise.resolve 在处理 thenable 对象时,同样不会直接返回该对象,会将对象中的 then 方法当做一个 Promise 等待结果,并作为外层 Promise 的值。
如果是这两种情况,那就无法满足 Monad 规则。
// Promise.resolve 传入一个 Promise 对象
const functionA = function (p) {
// 这时 p === 1
return p.then((n) => n * 2);
};
const promiseA = Promise.resolve(1);
Promise.resolve(promiseA).then(functionA);
// RejectedPromise TypeError: p.then is not a function
// 由于 Promise.resolve 对传入的 Promise 进行了处理,导致直接运行报错。违背了单位元和结合律。
// Promise.resolve 传入一个 thenable 对象
const functionB = function (p) {
// 这时 p === 1
alert(p);
return p.then((n) => n * 2);
};
const obj = {
then(r) {
r(1);
},
};
const promiseB = Promise.resolve(obj);
Promise.resolve(promiseB).then(functionB);
// RejectedPromise TypeError: p.then is not a function
// 由于 Promise.resolve 对传入的 thenable 进行了处理,导致直接运行报错。违背了单位元和结合律。
看到这里,相信大家对 Promise 也有了一层新的了解,正是借助了 Monad 一样的链式操作,才使 Promise 广泛应用在了前端异步代码中,你是否也和我一样,对 Monad 充满了好感?
Monad 处理副作用
接下来,我们再看一个常见的问题:为什么 Monad 适合处理副作用?
ps:这里说的副作用,指的是违反纯函数原则的操作,我们应该尽可能避免这些操作,或者把这些操作放在最后去执行。
例如:
var fs = require("fs");
// 纯函数,传入 filename,返回 Monad 对象
var readFile = function (filename) {
// 副作用函数:读取文件
const readFileFn = () => {
return fs.readFileSync(filename, "utf-8");
};
return new Monad(readFileFn);
};
// 纯函数,传入 x,返回 Monad 对象
var print = function (x) {
// 副作用函数:打印日志
const logFn = () => {
console.log(x);
return x;
};
return new Monad(logFn);
};
// 纯函数,传入 x,返回 Monad 对象
var tail = function (x) {
// 副作用函数:返回最后一行的数据
const tailFn = () => {
return x[x.length - 1];
};
return new Monad(tailFn);
};
// 链式操作文件
const monad = readFile("./xxx.txt").bind(tail).bind(print);
// 执行到这里,整个操作都是纯的,因为副作用函数一直被包裹在 Monad 里,并没有执行
monad.value(); // 执行副作用函数
上面代码中,我们将副作用函数封装到 Monad 里,以保证纯函数的优良特性,巧妙地化解了副作用存在的安全隐患。
Ok,到这里为止,本文的主要内容就已经分享完了,但在学习 Monad 中的某一天,突然发现有人用一句话就解释清楚了 Monad,自叹不如,简直太厉害了,我们一起来看一下吧!
Warning:下文的内容偏数学理论,不感兴趣的同学跳过即可。
Monad 一句话解释
早在 10 多年前,Philip Wadler 就对 Monad 做了一句话的总结。
原文:A monad is a monoid in the category of endofunctors。
翻译:Monad 是一个 自函子 范畴 上的 幺半群” 。
这里标注了 3 个重要的概念:自函子、范畴、幺半群,这些都是数学知识,我们分开理解一下。
什么是范畴?
任何事物都是对象,大量的对象结合起来就形成了集合,对象和对象之间存在一个或多个联系,任何一个联系就叫做态射。
一堆对象,以及对象之间的所有态射所构成的一种代数结构,便称之为 范畴。
什么是函子?
我们将范畴与范畴之间的映射称之为 函子。映射是一种特殊的态射,所以函子也是一种态射。
什么是自函子?
自函子就是一个将范畴映射到自身的函子。
什么是幺半群 Monoid?
幺半群是一个存在 单位元 的半群。
什么是半群?
如果一个集合,满足结合律,那么就是一个半群。
什么是单位元?
单位元是集合里的一种特别的元素,与该集合里的二元运算有关。当单位元和其他元素结合时,并不会改变那些元素。
如:
任何一个数 + 0 = 这个数本身。那么 0 就是单位元(加法单位元)
任何一个数 * 1 = 这个数本身。那么 1 就是单位元(乘法单位元)
Ok,我们已经了解了所有应该掌握的专业术语,那就简单串解一下这段解释吧:
一个 自函子 范畴 上的 幺半群 ,可以理解为,在一个满足结合律和单位元规则的集合中,存在一个映射关系,这个映射关系可以把集合中的元素映射成当前集合自身的元素。
相信掌握了这些理论知识,肯定会对 Monad 有一个更加深入的理解。
总结
本文从 Monad 的维基百科开始,逐步介绍了 Monad 的内部结构以及实现原理,并通过 Promise 验证了 Monad 在实战中发挥的重大作用。
文中包含了许多数学定义、函数式编程的理论等知识,大多是参考网络资料和自我经验得出的,如果有错误的地方,还望大家多多指点 ?
最后,如果你对此有任何想法,欢迎留言评论!
·END·
汇聚精彩的免费实战教程
喜欢本文,点个“在看”告诉我