函数式编程
给大家分享的是函数式编程, 大概分为两个部分 首先会介绍一下什么是函数式编程,以及使用他的意义。 然后呢会着重介绍一下函数式编程中最重要的两个方法:柯里化和函数组合,以及他们的使用方法和实践经验。
什么是函数
首先我们讲函数式编程,得先明确 什么是函数,大家应该都清楚什么是函数
函数是一种描述集合和集合之间的转换关系。f(x)
举一些例子:
const add = (a, b) => a+b // add函数描述了(1, 1)集合 到 2集合之间的关系
// x平方呢为
const square = x => x**2 // square函数描述了x => x**2 之间的关系
生活化一些,比如你与你女朋友或者男朋友要去领证了,到了民政局办手续,出来后你们变为夫妻,男同学有了老婆,女同学有了老公。从法律的层面上民政局这个函数把你吗你们从男女朋友变为夫妻。入参为小哥哥,小姐姐,出参为老公老婆 情侣 => 夫妻
可能单身的同学感触不深(🐶)
再比如,把我们个人当成一个函数,入参呢就是需求,出参不出意外的化就是产品,出意外的化就是bug
产品(天马行空的想法, 客户调研) => 需求
程序员(需求) => 产品 || bug
还有比如说 alfred 工具等等
所以函数实际上是一种关系,或者说呢是映射。
而且这种映射关系是可以组合的,一旦我们知道一个函数的输出类型可以匹配另一个函数的输入,那他们就可以进行组合。
什么是函数式编程
那么,什么是函数式编程呢?
函数式编程是一种编程的范式 与面向过程、面向对象、泛型编程并列的编程范式 在 1950 年 Lisp 语言,函数式编程( Functional Programming )就已经出现了。 最近几年,函数式以其优雅,简单的特点开始重新活跃起来。
像ES6引入的箭头函数,React16.6 开始推出 React.memo,16.8 开始主推 Hook,建议使用纯函数组件来进行编写代码,Vue3的composition API,可见函数式编程的火热程度。
感受函数式编程
我们来举个例子
假设我们有这么个需求,我们登记了一系列人名存在数组中,现在需要对这个结构进行一些修改,需要把字符串数组变成一个对象数组,方便后续的扩展,并且需要把人名做一些转换
let data = ['zhang-san', 'li-si', 'wang-wu']
// 转换成
[{name: 'Zhang San'}, {name: 'Li Si'}, {name: 'Wang Wu'}]
传统的编程思路
首先呢我们基础数据 arr里面有一些人名
const data = ['zhang-san', 'li-si', 'wang-wu'];
// 然后我们需要一个新数组用来保存处理后的数据
const newArr = [];
// 然后呢我们要循环arr这个数组,做数据处理
for (let i = 0; i < data.length; i++) {
// 我们要取出当前的name是arr[i]
let name = data[i];
//然后呢我们需要根据-符号分割为姓和名
let names = name.split('-');
// 然后呢我们要循环遍历姓名数组,将首字母大写
let newName = [];
for (let j = 0; j < names.length; j++) {
let nameItem = names[j][0].toUpperCase() + names[j].slice(1);
newName.push(nameItem);
}
newArr.push({ name : newName.join(' ') });
}
// console.log(newArr)
当然这是完全面向过程在编程。里面建了很多的临时变量,通常一个函数需要从头读到尾才知道做了什么,一旦某个环节出问题不好定位
我们下面来看下函数式编程思维会如何思考这个问题
/**
* let data = ['zhang-san', 'li-si', 'wang-wu']; 转换成 [{name: 'Zhang San'}, {name: 'Li Si'}, {name: 'Wang Wu'}];
*
* 我们只需要一个函数从string数组转化为object数组的转换
* convertNames: [String] -> [Object]
*
* 这里面涉及到String -> object的转换
* convert2Obj: String -> Object
*
* 还需要把名称转换成指定形式
* capitalizeName
*
* 把任意类型转换成对象
* genObj
*
* 在细想一下
*
* capitalizeName
* 其实也是几个方法的组合(split, join, capitalize),剩下的几个函数都是非常容易实现的。
*
* 我们构思完了 现在开始搞事情
*
*/
const {curry, compose, split, map, join} = require('ramda');
const capitalize = x => x[0].toUpperCase() + x.slice(1).toLowerCase();
// const genObj = (key, x) => ({[key]: x});
// 这个函数需要被柯里化下
// 引入 cosnt {curry, compose} = require('lodash/fp');
const genObj = curry((key, x) => ({[key]: x}));
// 首先我们需要split('-')将数据分开,
// 在通过map 将字符编程对应格式,再通过join变为字符串
const capitalizeName = compose(join(' '), map(capitalize), split('-'));
// 我们在把这 capitalizeName genObj两个函数组合起来
const convert2Obj = compose(genObj('name'), capitalizeName);
// 通过map生成最终的函数
const converName = map(convert2Obj);
// console.log(converName(data));
只是看这个编程思路,可以清晰看出,函数式编程的思维过程是完全不同的,它的着眼点是函数,而不是过程,它强调的是如何通过函数的组合变换去解决问题,而不是我通过写什么样的语句去解决问题,当你的代码越来越多的时候,这种函数的拆分和组合就会产生出强大的力量。
函数式编程的特点
- 函数是一等公民
- 声明式编程
- 惰性执行
- 无状态和数据不可变(纯函数)
函数是一等公民
一等公民这个概念由一位英国计算机学者克里斯托弗·斯特雷奇 在1960年提出
- 函数可以赋值给变量
- 函数可以作为参数
- 函数可以作为返回值
- 作为参数和返回值的函数被称为高阶函数,高阶函数是函数式编程的基础
声明式编程
命令式编程(todo somthing),你告诉程序去做什么,比如刚刚声明存储变量、循环数据、处理数据
声明式(going todo somthing) 先声明函数,再用函数做事儿
命令式就是一步步花钱,而声明式可能写一个花钱计划书。然后按照计划怎么花怎么花钱
声明式的有 SQL 无需关系Select怎么实现的 Vue、React也是声明式框架,我们无需关心DOM操作
惰性执行(Lazy Evaluation)
在需要的时候执行,不产生无意义的中间变量,刚刚的例子中,函数式编程几乎没有中间变量,它从头到尾都在写函数,只有在最后的时候才通过调用 convertName 产生实际的结果。
无状态和数据不可变 (Statelessness and Immutable data)
这是函数式编程的核心概念:
数据不可变: 它要求你所有的数据都是不可变的,这意味着如果你想修改一个对象,那你应该创建一个新的对象用来修改,而不是修改已有的对象。
无状态: 同样的输入,同样的输出 不依赖外部任何状态
那么下来我们就引出函数式编程提出函数应该具备的特性:纯函数,无副作用
副作用
副作用是什么呢?
是指:在完成函数主要功能之外完成的其他副要功能。在我们函数中最主要的功能当然是根据输入返回结果,而在函数中我们最常见的副作用就是随意操纵外部变量。
比如我们在map函数中随意修改成员的值
let list = [{num: 1}]
list.map(i => {
i.type = 3;
i.num++
});
// 没有副作用的写法应该是
let newList = list.map(i => ({type: 3, num: i.num++}));
保证函数没有副作用,
- 一是可以保证数据的不可变性,
- 二是可以避免很多因为共享状态带来的问题。 当你一个人维护代码时候可能还不明显,但随着项目的迭代,项目参与人数增加,大家对同一变量的依赖和引用越来越多,这种问题会越来越严重。最终可能连维护者自己都不清楚变量到底是在哪里被改变而产生 Bug。
纯函数
什么是纯函数?
- 无状态的 不依赖外部的状态
- 数据不变,没有副作用
// 不纯的函数
const curUser = {
name: 'zhangsan'
}
const say =
str => curUser.name + ': ' + str; // 引用了外部变量 不是无状态
const changeName = (obj, name) => obj.name = name; 修改了外部变量,产生副作用
const curUser = {
name: 'Peter'
}
const saySth = (user, str) => user.name + ': ' + str; // 不依赖外部变量
const changeName = (user, name) => ({...user, name }); // 未修改外部变量
纯函数有什么意义?
- 便于测试和优化: 由于参数一定,输出也一定,我们可以轻松断言函数的执行结果。我们在后续优化的时候不会影响其他代码的执行
- 可缓存性 还是由于相同输入总是可以返回相同的输出,因此缓存函数的执行结果。很多函数库都有的 memoize方法 我们自己实现一个简化版的memoize方法
function memoize(fn) {
const cache = {};
return function() {
const key = JSON.stringify(arguments);
let value = cache[key];
if (!value) {
value = [fn(...arguments)];
cache[key] = value;
}
return value[0]
}
}
const add = memoize((a, b) => {
console.log('add');
return a + b;
});
add(1,1)
- 更少的Bug: 使用纯函数意味着你的函数中不存在指向不明的 this,不存在对全局变量的引用,不存在对参数的修改,这些共享状态往往是绝大多数 bug 的源头。
一些概念性的内容我们讲完了,接下来是函数式编程中很重要的两个方法
函数组合与柯里化
在上面的例子中看到了函数组合以及柯里化的使用。
柯里化
那什么是柯里化呢?
柯里化的意思是将一个多元函数,转换成一个依次调用的单元函数。是一种关于函数的高阶技术,将一个函数从可调用的f(a,b,c)转化为f(a)(b)(c)的过程称为柯里化。柯里化不会调用函数。它只是对函数进行转换。
function add(a, b, c) {
return a + b + c;
}
let curryAdd = curry(add);
curryAdd(1, 2, 3); // 6
curryAdd(1)(2)(3); // 6
就比如你要娶一个媳妇儿,丈母娘问你要 房子 车子 票子 入参是吧 输出 一个幸福美满的家庭,丈母娘要懂柯里化的化那就好办了。你和丈母娘商量,柯里化一下,一点点来,你这参数暂时拿不到
自己实现一个简单的curry函数
function curry(fn) {
let length = fn.length;
let curried = (...args) => {
if(args.length >= length) {
return fn(...args);
}
return (...rest) => curried(...args, ...rest);
}
return curried;
}
这个单元函数很重要,为什么很重要呢?
因为函数的返回值有且只有一个,如果我们想把函数组合起来,必须保证每个函数的输出刚好是下一个函数的输入,这样柯里化加函数组合能发挥奇效
我们再看一个柯里化运用的例子
const {curry, compose, split, map, join} = require('ramda');
const replace = curry((a, b, str) => str.replace(a, b));
console.log(replace);
const replaceSpaceWith = replace(/\s/g);
const replaceSpaceWithComma = replaceSpaceWith(',');
console.log(replaceSpaceWithComma('法外狂徒 张三'))
函数组合(compose)
函数组合的目的是将多个函数组合成一个函数。
简化版的compose实现
const compose = (f, g) => x => f(g(x))
const f = x => x + 1;
const g = x => x * 2;
const fg = compose(f, g);
fg(1) // 3
我们可以看到 compose后形成了一个全新的函数,而这个函数就是从一条 g -> f 的流水线
我们再来看一个较为高级的函数组合的实现,用reduce来实现
const compose = (...fns) => fns.reduce((a, b) => (...args) => {
console.log(a, b);
return a(b(...args))
});
const add1 = x => x + 6;
const square = x => x**2;
const add1Square = compose(add1,square, add1, square);
console.log(add1Square(2))
是不是很眼熟,redux中间件 koa的洋葱模型 都有用到
函数组合的好处
函数组合的好处显而易见,它让代码变得简单而富有可读性,同时通过不同的组合方式,我们可以轻易组合出其他常用函数,让我们的代码更具表现力
// 组合方式 1
const last = compose(head, reverse);
const shout = compose(log, toUpperCase);
const shoutLast = compose(shout, last);
// 组合方式 2
const lastUppder = compose(toUpperCase, head, reverse);
const logLastUpper = compose(log, lastUppder);
实践经验
在使用柯里化和函数组合的时候,有一些经验可以借鉴一下:
- 柯里化中把要操作的数据放到最后,因为我们的输出通常是需要操作的数据,这样当我们固定了之前的参数(可以称为配置)后,可以变成一个单元函数,直接被函数组合使用,这也是其他的函数式语言遵循的规范:
const split = curry((x, str) => str.split(x));
const join = curry((x, arr) => arr.join(x));
const replaceSpaceWithComma = compose(join(','), split(' '));
const replaceCommaWithDash = compose(join('-'), split(','));
- 函数组合中函数要求单输入 函数组合有个使用要点,就是中间的函数一定是单输入的,这个很好理解,之前也说过了,因为函数的输出都是单个的(数组也只是一个元素)。
- 函数组合的 Debug
当遇到函数出错的时候怎么办?我们想知道在哪个环节出错了,这时候,我们可以借助一个辅助函数 trace,它会临时输出当前阶段的结果。
const trace = curry((tip, x) => { console.log(tip, x); return x; });
const lastUppder = compose(toUpperCase, head, trace('after reverse'), reverse);
参考链接: