函数式编程的那些事

2,770 阅读10分钟

本篇文章来自团队小伙伴 @以乐之名 的一次学习分享,希望跟大家分享与探讨。

求积硅步以致千里,勇于探享生活之美。

开篇我们先来看看以下两段代码:

// 不使用函数的编程
const upperArr = ['HTML', 'CSS', 'JavaScript'];
const lowerArr = [];
for (let i = 0; i < arr.length; i++) {
    lowerArr.push(arr[i].toLowerCase());
}

// 使用函数的编程
function toLowerArr(arr) {
    const lowerArr = [];
    for (let i = 0; i < arr.length; i++) {
        lowerArr.push(arr[i].toLowerCase());
    }
    return lowerArr;
}

对比两段代码,无疑更推荐第二种方式,即借助函数封装业务逻辑,方便复用维护,且能隔离掉外层作用域影响,避免污染到全局变量。有点像是周末去超市购物,买了一堆东西,如果没有个袋子只能靠双手狼狈地抓包。而函数就像那个得加钱的塑料袋,能帮着装下所有的物品,且便于手提搬运,这差不多就是我们初次认识以及使用它的原因。

对比面向对象

面向对象编程天天挂在嘴边时,但大多数 FE,内心「函数才是真爱」。关于「函数编程」与「面向对象」的区别,我们借思考一个小问题来探究二者间的差异。

「问题:如何把大象放进冰箱?」

面向过程:步骤

  1. 打开冰箱;
  2. 抱起大象;
  3. 塞进冰箱;
  4. 关闭冰箱;
  • 视角:从实现的角度去看待问题,关注具体实现;
  • 优点:比较简单,无须处理对象实例化;
  • 缺点:功能耦合,较难维护,容易产生难以维护的强大函数;

面向对象:对象/接口/类/继承

  1. 创建冰箱对象,赋予属性:尺寸 | 开门 | 关门 | 存储;
  2. 创建大象对象,赋予属性:尺寸;
  3. 调用冰箱方法 -> 开门;
  4. 调用冰箱方法 -> 存储;
  5. 调用冰箱方法 -> 关门;
  • 视角:从对象的角度去看待问题;
  • 优点:易维护与扩展,适用于多人开发的大型项目,有继承/封装/多态,可衍生;
  • 缺点:有一定的性能损耗,对象实例化的内存占用,实例对象的属性并不是每次都需要;

对比上述两种编程思路,你会发现我们日常使用的函数编程,恰恰就是面向过程编程。每个函数就是对每一步骤的封装,聚焦于具体实现,而不是关注对客观事物属性特征的抽象。

对二者的区别有个简单认识后,步入我们今天的主题:函数式编程。

函数式编程

函数式编程:[FP: Functional Programming],描述数据与函数之间的映射关系,或是运算过程的抽象

函数基础

  1. 函数可以存储在变量中;
  2. 函数可作为参数传递;
  3. 函数可作为返回值返回;
  4. 函数可像对象拥有属性;
  5. 函数声明优先赋值(对比变量声明/函数表达式);
  6. 参数按值传递;
  7. JavaScript 中函数不支持重载;

函数执行机制

JavaScript 中函数的执行,是以栈的数据结构来进行的。栈的特点是,只有一个出入口,只能从顶部出入栈,遵循 "先进后出,后进先出" 的原则。

  1. 代码执行时,进入全局环境,将全局上下文入栈;
  2. 调用函数时,进入函数环境,将该函数上下文入栈;
  3. 函数调用结束时,进行出栈操作;
  4. 栈顶存储的是当前正在执行的上下文;

正常的情况下函数的执行就是这样一种出入栈的操作:

function foo() {
    function bar() {
        return 'I am bar';
    }
}
foo();

函数出入栈过程 以上的情况只是理想状态,JavaScript 中 「闭包」 的存在会中断调用结束的函数出栈操作,使得已经结束调用的函数,仍然存在于栈中,占用内存开销。

闭包:函数执行完成后内部变量仍被另一函数所引用

闭包的产生:产生闭包的场景多是函数作为返回值返回,或作为参数使用,且该函数使用了外部作用域的变量。正常函数调用结束时会销毁变量对象,释放内存空间,但闭包的存在,使得父函数内部变量存在引用,因此会将其保留在内存中。

闭包的作用:

  1. 延长变量对象的作用域范围;
  2. 延长变量对象的生命周期;
function foo() {
    const fooVal = 'VanTopper';
    const bar = function() {
      console.log(fooVal);	// bar 中使用来 foo 环境的变量
    }
    return bar;			// 函数作为返回值返回
}

const getValue = foo();
getValue();			// -> VanTopper

受闭包影响的函数出入栈过程

函数生命周期

创建阶段执行阶段结束阶段
1. 创建变量对象
-- 初始化 Arguments 对象(并赋值)
-- 函数声明(并赋值)
-- 变量声明/函数表达式声明(未赋值)
-- 确定 this 指向
2. 确定作用域
1. 变量对象赋值
-- 变量赋值
-- 函数表达式赋值
2. 调用函数
3. 执行其它代码
变量对象销毁(无闭包场景)

高阶函数

函数作为参数传递,或作为返回值返回

常用高阶函数: forEach / map / filter / every / some / find 

「高阶函数的作用:抽象屏蔽实现细节,只需关注实现目标」

filter 为例,过滤功能的实现需要遍历数组,对每一项进行比对。遍历是固定不变的部分,但比对逻辑是可变的。我们可将不变部分固化,「程序的封装,首先都是从 不变 部分开始」。抽象提取遍历过程,并为比对逻辑提供配置入口, 使得 filter 方法调用者只需关注比对逻辑,无须每次重复写遍历过程。

// 高阶函数用法:简易实现 filter
function filter(arr, fn) {
  const newArr = [];
  for (let i = 0; i < arr.length; i++) {
    const result = fn(arr[i], i);
    if (result) {
      newArr.push(result);
    }
  }
  return newArr;
}

filter([-2, -1, 0, 1, 2], (val) => val > 0);  // -> [1, 2]

纯函数

函数无副作用(数据污染),固定的输入、固定的输出

副作用来源

所有与外部交互都可能带来副作用,一般包括:

  1. 外部配置文件;
  2. 数据库存储的数据;
  3. 用户的输入数据;
  4. 函数内部源数据修改;
/* ----- 纯函数 start ----- */
function sum(a, b) {
    return a + b;
}
sum(1, 2);	// 3
sum(1, 2);	// 3
sum(1, 2);	// 3


/* ----- 非纯函数 start ----- */
// 不遵守固定的输入、固定的输出
function getRandom() {
    // 每次返回值不一样
    return Math.random();
}

// 函数内直接修改了数据
function formatData(arr) {
    for (let item of arr) {
        // 直接在原数组对象修改
        item.visible = item.visible ? 1 : 0
    }
}

优点/作用:(首先我很纯)

  1. 稳定输出,可靠辅助(固定输入、固定输出);
  2. 不抢兵不抢人头(无副作用、不污染数据);
  3. 可作缓存提升性能(以参数为键值缓存计算结果,避免重复运算,相同参数调用仅计算一次);
  4. 可作测试(多次调用,数据不反复横跳);
/*
 实现带缓存计算的功能函数
 1. 借助纯函数固定输入固定输出,保证参数相同时多次调用结果相同
 2. 借助闭包维护缓存对象,延长其作用域范围与生命周期
 */

// 缓存函数
function memoize(fn) {
    const cache = {};
    return function() {
  	const key = JSON.stringify(arguments);
        cache[key] = cache[key] || fn.apply(fn, arguments);
        return cache[key];
    }
}

//功能函数
function getArea(r) {
    console.log('Computed...');
    return Math.PI * r * r;
}

const getAreaWithMemory = memoize(getArea);
getAreaWithMemory(4);	// 'Computed' 参数相同时 log 仅执行一次
getAreaWithMemory(4);   // 
getAreaWithMemory(4);   // 

柯里化

一般函数调用时,都是一次性计算求值。如果形参与实参数量不匹配,且未指定默认值(内部未作参数缺省处理),是无法正常调用。 例如:((a, b) => a + b)(10);

而「函数柯里化」是一种支持 预先传参,可进行 部分计算 的一种函数编程方法。

举个栗子:「存款买房」,假设我们有一个目标是买房,那么在买房前,资金不够时我们可采取存钱的方法作积累。等到钱存够了,再使用存款,进行买房操作。柯里化,就像我们去银行存钱,银行给你一张银行卡,可以让你继续往里面存钱(预先传参),每一次存钱动作都会增加卡里的余额(部分计算),等钱的数量足够支付时,就可进行取款支付了。

列举两个公式,方便对比下与普通函数不同的调用方式:

// 公式类型一 左边:普通函数 | 右边:柯里化
fn(a, b, c, d) => fn(a)(b)(c)(d);
fn(a, b, c, d) => fn(a, b)(c)(d);
fn(a, b, b, d) => fn(a)(b, c, d);

// 公式类型二
fn(a, b, c, d) => fn(a)(b)(c)(d)();
fn(a, b, c, d) => fn(a);fn(b);fn(c);fn(d);fn();

通过上述公式的转化,我们可以得出柯里化的几个特性:

  • 部分传参
  • 提前返回
  • 延迟执行
// 柯里化方法实现
function curry(fn) {
    return function curriedFn(...args) {
        if (args.length < fn.length) {
            return function() {
                return curriedFn(...args.concat(Array.from(arguments)))
            }
        }
        return fn(...args)
    }
}

柯里化应用

  • 改变普通函数足额传参的调用方式;
  • 预传参的方式,缓存参数;
  • 衍生出更具语义,更方便调用的函数;
// 验证函数
function checkValid(regex, value) {
    return regex.test(value);
}

// 验证手机号码
checkValid(/^1[3456789]\d{9}$/, value);
// 验证电话号码
checkValid(/^([0-9]{3,4}-)?[0-9]{7,8}$/, value);

虽然 checkValid 基本实现功能,但调用方式很不友好,使用者每次调用都需要输入「记不住且复杂」 的正则表达式,有点反人类。针对这种场景,我们可借助柯里化,进行如下改造:

// 柯里化处理后 (借助 lodash 的 _.curry 方法,跟上文自实现的 curry 功能一样)
const curryCheckValid = _.curry(checkValid);
const checkMobile = curryCheckValid(/^1[3456789]\d{9}$/);
const checkPhone = curryCheckValid(/^([0-9]{3,4}-)?[0-9]{7,8}$/);

checkMobile(value);
checkPhone(value);

经过柯里化处理后衍生出的 checkMobile 与 checkPhone 对比 checkValid ,明显对调用者更友好,更符合最小知识原则,降低使用成本。

柯里化总结

优点:

  1. 允许部分传参,固定易变因素(提前计算,可作缓存);
  2. 返回可处理剩余参数的函数(留好接班人);
  3. 可将多元函数转化为一元或少元函数(结合函数组合发挥实力);
  4. 衍生出粒度更小,单一职责的函数(使用者学习成本降低);

缺点:

  1. 闭包的存在,带来的内存占用开销;
  2. 嵌套作用域带来的开销、影响作用域链查找速度;

函数组合 f(g(v))

组合的思想:「管道组装」

函数组合

如图,一条大管道包含了所需功能,但是这种大管道,因尺寸问题需做切割时会造成很多边角料浪费,如果中间出现问题,只能全部更换。而拆分成多节小管道,不仅方便长度定制化,能节约浪费成本,且降低了问题排查和维修替换的成本。

函数组合就是引用这种小管道拼接的理念,每一个小管道都是一个小函数,然后组合成一个大函数。调用者不用关心中间这些小函数的处理结果,只关注头部入参和尾部处理结果,「重结果,轻过程」

一般函数组合中函数执行过程分为两种:「从左往右」「从右往左」,默认是 「从右往左」,请看以下代码的实现:

// 简易组合函数(这里的实现就是简单的套函数)
function compose(f, g) {
    return function(value) {
        return f(g(value));
    };
}

const first = arr => arr[0];
const reverse = arr => arr.reverse();

// 获取数组最后一个元素
const getLast = compose(first, reverse);
getLast('Angular', 'React', 'Vue'];	// -> Vue

函数组合看似把简单的问题复杂化,针对上述业务要求,完全可以在一个函数中去实现。拆分函数反而增加了工作量,但存在必有其合理性。

日常开发中为实现业务功能,不少伙伴经常写出这种一个个「功能很强大」的函数,融合所有逻辑一起解决。但是这种函数复用性却非常低,因为该函数耦合了太多功能依赖,如果需要复用其中部分功能时,可能选择自行再创建一个新函数会更加稳妥。而这种不必要的函数增加,就会存在很多功能类似又区别的代码存在,无形增加冗余。而且复杂函数往往参数都非单一,需要使用者有更多的参数学习成本。

函数组合还有一个作用是「分治」,将函数功能拆成单一化,粒度更细的小函数。采用乐高积木的理念,去组装成更强大的函数。而且这些小功能函数往往只有一次性创建成本,复用性极强。

另一个优势就是开源第三方工具函数,像 lodash/underscore 早已提供了这些基础函数,无须我们再自行维护。

// 使用 lodash 改写 getLast
// 这里 _.flowRight 实现的就是我们上面自建 componse 函数功能
const getLast = _.flowRight(_.first, _.reverse);
getLast(['Angular', 'React', 'Vue');	// -> Vue

组合规则

  1. 将多个函数组合成新的函数,忽略中间部分,只看最后结果;
  2. 满足结合律,结合顺序不同,结果相同(既可以把 f1 和 f2 组合,也可把 f1 和 f3 组合);
  3. 函数组合中每个函数需为一元函数,且最后组合成的函数也是一元函数(细粒度、小体量);
  4. 执行顺序:从右到左(默认)/ 从左到右;

函数组合调试

函数组合的特点是将处理的结果层层传递,逐步传给下一个函数。依据它的原理,我们可实现一个 log 函数作为中间函数插入,添加本身打印调试功能同时,需要将处理值透传下去。

const getLast = _.flowRight(_.first, log, _.reverse);
getLast(['Angular', 'React', 'Vue']);	// 我们就能打印出 _.reverse 处理结果

// 调试函数
function log(value) {
    console.log(value);
    return value;
}

lodash/fp

lodash/fp 模块是 lodash 提供的支持函数式编程的友好实践

先看个小问题,有类似的面试题,考点有 map 的参数与 parseInt  进制转换。

// lodash
_.map(['10', '10', '10'], parseInt);
// -> [10, NaN, 2]

// lodash/fp
fp.map(parseInt, ['10', '10', '10']);
// -> [10, 10, 10]

lodash 默认方法是数据优先,也就是数据先行,所以上述 _.map 执行如下:

_.map(['10', '10', '10'], (val, index) => parseInt(val, index));

parseInt('10', 0);  // -> 10	
parseInt('10', 1);  // -> NaN
parseInt('10', 2);  // -> 2

MSDN -> parseInt 转化规则

  • lodash 默认方法(数据优先,函数置后)
  • lodash/fp 模块方法(函数优先,数据置后,自动柯里化)

三种实现方式比对

const str = 'NEVER SAY GOODBYE';

/* ---- 原生实现 ---- */
const transByDefault = (str) => str.toLowerCase().split(' ').join('-');

/* ---- lodash ---- */
const join = _.curry((seq, str) => _.join(str, seq));
const split = _.curry((seq, str) => _.split(str, seq));

const transByLodash = _.flowRight(join('-'), split(' '), _.lowerCase);

/* ---- lodash/fp ---- */
const fp = _.noConflict();
const transByFp = fp.flowRight(fp.join('-'), fp.slit(' '), fp.lowerCase);


// 调用
transByDefault(str);	// -> never-say-goodbye
transByLodash(str);	// -> never-say-goodbye
transByFp(str);		// -> never-say-goodbye
原生实现lodash实现lodash/fp实现
功能耦合,函数复用性低
- 支持函数组合
- 数据优先,函数置后
- 多元函数需自行柯里化处理

- 多元函数自动柯里化处理
- 函数优先,数据置后,无需额外处理

函数组合总结

优点:

  1. 合并预算过程,组成强大的功能函数(灵活组合);
  2. 忽略中间处理过程,只关注最终运算结果(只要结果);
  3. 增强函数复用性,减少强功能函数;

缺点:

  1. 需要一些辅助的基本函数(可借助第三方工具库提供的常用工具函数);
  2. 需定义插入 log 函数进行中间结果调试;

本篇文章仅是「函数式编程」的管中窥豹,还有诸如偏函数、函子等函数编程的知识并未涉及到,感兴趣的小伙伴可继续再深入学习。

以上便是本次分享的全部内容,希望对你有所帮助 ^_^

喜欢的话别忘了动动手指,点赞、收藏、关注三连一波带走。


关于我们

我们是万拓科创前端团队,左手组件库,右手工具库,各种技术野蛮生长。

一个人跑得快,不如一群人跑得远。欢迎加入我们的小分队,牛年牛气轰轰往前冲。

VANTOP前端团队