函数式编程(纯函数&函数柯里化&代码组合)

函数

一等公民的函数

函数没什么特殊的,你可以像对待任何其他数据类型一样对待它们——把它们存在数组里,当作参数传递,赋值给变量…等等。

纯函数

什么是纯函数

  • 纯函数是这样一种函数,即相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用
  • 不依赖外部变量,可以自给自足

比如数组的 slice 和 splice:

  • slice 符合纯函数的定义:因为对相同的输入它保证能返回相同的输出;
  • splice 却不同:会产生可观察到的副作用,即这个数组永久地改变了;

不纯的版本:取决于系统状态;因为它引入了外部的环境,从而增加了认知负荷

副作用

副作用是在计算结果的过程中,系统状态的一种变化,或者与外部世界进行的可观察的交互。

副作用可能包含,但不限于:

  1. 更改文件系统;
  2. 往数据库插入记录;
  3. 发送一个 http 请求;
  4. 可变数据;
  5. 打印/log;
  6. 获取用户输入;
  7. DOM 查询;
  8. 访问系统状态…

概括来讲,只要是跟函数外部环境发生的交互就都是副作用——这一点可能会让你怀疑无副作用编程的可行性。函数式编程的哲学就是假定副作用是造成不正当行为的主要原因。
这并不是说,要禁止使用一切副作用,而是说,要让它们在可控的范围内发生。
副作用让一个函数变得不纯是有道理的:从定义上来说,纯函数必须要能够根据相同的输入返回相同的输出;如果函数需要跟外部事物打交道,那么就无法保证这一点了

追求纯函数的原因

1 可缓存性
// 可以借助一个方法:
var memoize = function (f) {
  var cache = {};
  return function () {
    var arg_str = JSON.stringify(arguments);
    cache[arg_str] = cache[arg_str] || f.apply(f, arguments);
    return cache[arg_str];
  };
};

var fibonacci = memoize((n) => {
  return n < 2 ? n : fibonacci(n - 1) + fibonacci(n - 2);
});
fibonacci(6); // 8
2 可移植性/自文档化
  • 纯函数对于其依赖必须要明确,这样我们就能知道它的目的
  • 通过强迫“注入”依赖,或者把它们当作参数传递,我们的应用也更加灵活;
  • 命令式编程中“典型”的方法和过程都深深地根植于它们所在的环境中,通过状态、依赖和有效作用(available effects)达成;纯函数与此相反,它与环境无关,只要我们愿意,可以在任何地方运行它。
3 可测试性

测试更加便捷,可以直接简单地给函数一个输入,然后断言输出就可以

4 合理性
  • 纯函数最大的好处是引用透明性(referential transparency)
  • 由于纯函数总是能够根据相同的输入返回相同的输出,所以它们就能够保证总是返回同一个结果,这也就保证了引用透明性
5 并行代码

最后一点,也是决定性的一点:我们可以并行运行任意纯函数。因为纯函数根本不需要访问共享的内存,而且根据其定义,纯函数也不会因副作用而进入竞争态(race condition)。
并行代码在服务端 js 环境以及使用了 web worker 的浏览器那里是非常容易实现的,因为它们使用了线程(thread)。不过出于对非纯函数复杂度的考虑,当前主流观点还是避免使用这种并行。

柯里化

什么是柯里化?

curry 的概念很简单:只传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数。
你可以一次性地调用 curry 函数,也可以每次只传一个参数分多次调用。

var add = function(x) {
  return function(y) {
    return x + y;
  };
};

var increment = add(1);
var addTen = add(10);

increment(2);
// 3

addTen(2);
// 12

如何封装柯里化工具函数

回想之前我们对于柯里化的定义,接收一部分参数,返回一个函数接收剩余参数,接收足够参数后,执行原函数。

  • 拓展
function foo() {}
foo.length; // 指的是初始化时未被给定默认值的参数个数
  • 实现
function curry(fn) {
  return function returnFn() {
    const args = Array.prototype.slice.call(arguments);
    if (args?.length < fn.length) {
      return function () {
        const argsNext = Array.prototype.slice.call(arguments);
        return returnFn.apply(null, args.concat(argsNext));
      };
    }
    return fn.apply(null, args);
  };
}

var getNumber = function (a, b, c) {
  console.log(a, b, c);
};

var example = curry(getNumber);
example(1)(2)(3);

柯里化的用途

柯里化实际是把简答的问题复杂化了,但是复杂化的同时,我们在使用函数时拥有了更加多的自由度。 而这里对于函数参数的自由处理,正是柯里化的核心所在。

柯里化强调的是生成单元函数,部分函数应用的强调的固定任意元参数,而我们平时生活中常用的其实是部分函数应用,这样的好处是可以固定参数,降低函数通用性,提高函数的适合用性。

// 假设一个通用的请求 API
const request = (type, url, options) => ...
// GET 请求
request('GET', 'http://....')
// POST 请求
request('POST', 'http://....')

// 但是通过部分调用后,我们可以抽出特定 type 的 request
const get = request('GET');
get('http://', {..})

实现参数复用

我们工作中会遇到各种需要通过正则检验的需求,比如校验电话号码、校验邮箱、校验身份证号、校验密码等, 这时我们会封装一个通用函数 checkByRegExp ,接收两个参数,校验的正则对象和待校验的字符串。

示例

function curry(fn) {
  return function returnFn() {
    const args = Array.prototype.slice.call(arguments);
    if (args?.length < fn.length) {
      return function () {
        const argsNext = Array.prototype.slice.call(arguments);
        return returnFn.apply(null, args.concat(argsNext));
      };
    }
    return fn.apply(null, args);
  };
}
// 校验函数
function checkByRegExp(regExp, string) {
  return regExp.test(string);
}

checkByRegExp(/^1\d{10}$/, "18642838455"); // 校验电话号码
checkByRegExp(/^1\d{10}$/, "13109840560"); // 校验电话号码
checkByRegExp(/^1\d{10}$/, "13204061212"); // 校验电话号码

checkByRegExp(/^(\w)+(\.\w+)*@(\w)+((\.\w+)+)$/, "test@163.com"); // 校验邮箱
checkByRegExp(/^(\w)+(\.\w+)*@(\w)+((\.\w+)+)$/, "test@qq.com"); // 校验邮箱
checkByRegExp(/^(\w)+(\.\w+)*@(\w)+((\.\w+)+)$/, "test@gmail.com"); // 校验邮箱

我们每次进行校验的时候都需要输入一串正则,再校验同一类型的数据时,相同的正则我们需要写多次, 这就导致我们在使用的时候效率低下,并且由于 checkByRegExp 函数本身是一个工具函数并没有任何意义, 一段时间后我们重新来看这些代码时,如果没有注释,我们必须通过检查正则的内容, 我们才能知道我们校验的是电话号码还是邮箱,还是别的什么。
此时,我们可以借助柯里化对 checkByRegExp 函数进行封装,以简化代码书写,提高代码可读性

柯里化实现

// checkByRegExp进行柯里化
var _check = curry(checkByRegExp);
var checkPhone = _check(/^1\d{10}$/);
var checkMail = _check(/^(\w)+(\.\w+)*@(\w)+((\.\w+)+)$/);

checkPhone("18642838455");
checkPhone("13109840560");
checkPhone("13204061212");

checkMail("test@163.com");
checkMail("test@qq.com");
checkMail("test@gmail.com");

代码组合

什么是代码组合?

var compose = function(f,g) {
  return function(x) {
    return f(g(x));
  };
};

f 和 g 都是函数,x 是在它们之间通过“管道”传输的值(即上一个方法的结果作为下一个函数的入参)。

var toUpperCase = function(x) { return x.toUpperCase(); };
var exclaim = function(x) { return x + '!'; };
var shout = compose(exclaim, toUpperCase);

shout("send in the clowns"); //=> "SEND IN THE CLOWNS!"

在 compose 的定义中,g 将先于 f 执行,因此就创建了一个从右到左的数据流。这样做的可读性远远高于嵌套一大堆的函数调用。

代码组合:

  • 两个函数组合之后返回了一个新函数,也就是组合某种类型的两个元素本就该生成一个该类型的新元素
  • 从右向左运行,而不是由内而外运行

如何实现组合

// 参数不固定、通过reduceRight 从右往左遍历
function compose(...fns) {
  return function (initValue) {
    return fns.reduceRight((preValue, fn) => fn(preValue), initValue);
  };
}

值的一提的是,React中Redux的中间件就是用compose实现的,webpack中loader的加载顺序也是从右往左,这是因为他也是compose实现的。有兴趣可以了解下它compose的实现

import { createStore, applyMiddleware, compose } from 'redux'
import thunk from 'redux-thunk'
import DevTools from './containers/DevTools'
import reducer from '../reducers'

const store = createStore(
  reducer,
  compose(
    applyMiddleware(thunk),
    DevTools.instrument()
  )
)

举例

// 需求:我们登记了一系列人名存在数组中,现在需要对这个结构进行一些修改,需要把字符串数组变成一个对象数组,方便后续的扩展,并且需要把人名做一些转换
['john-reese', 'harold-finch', 'sameen-shaw'] 
// 转换成 
[{name: 'John Reese'}, {name: 'Harold Finch'}, {name: 'Sameen Shaw'}]

步骤:

  1. 通过split - 合成数组
  2. 首字母大写,其他小写
  3. 通过’ '空格组合成数组
  4. 设置给name
// 柯里化
function curry(fn) {
  return function returnFn() {
    const args = Array.prototype.slice.call(arguments);
    if (args?.length < fn.length) {
      return function () {
        const argsNext = Array.prototype.slice.call(arguments);
        return returnFn.apply(null, args.concat(argsNext));
      };
    }
    return fn.apply(null, args);
  };
}
// 代码组合处理
function compose(...fns) {
  return function (initValue) {
    return fns.reduceRight((preValue, fn) => fn(preValue), initValue);
  };
}
// 首字母大写,其他小写
const capitalize = (strSplit) =>
  strSplit?.map((str) => str[0].toUpperCase() + str.slice(1).toLowerCase());
// 切割字符串
const splitFn = curry((splitSign, arr) => arr.split(splitSign));
// 合并字符串
const joinFn = curry((joinSign, str) => str.join(joinSign));
// 组合成对象 key:name
const genObj = curry((key, val) => {
  let obj = {};
  obj[key] = val;
  return obj;
});
// 调用组合
const getNameObj = compose(
  genObj("name"),
  joinFn(" "),
  capitalize,
  splitFn("-")
);

const convertName = (strArr) => strArr.map((str) => getNameObj(str));
var combineNames = convertName(["john-reese", "harold-finch", "sameen-shaw"]);
console.log(combineNames); // [{ name: "John Reese" }, { name: "Harold Finch" }, { name: "Sameen Shaw" }];

函数组合的 Debug

可以通过自定义一个方法就能在任何阶段后获取到值

const trace = curry((tip, v) => {
  console.log(tip, v);
  return x;
});
const getNameObj = compose(
  genObj("name"),
  trace("tip"),
  joinFn(" "),
  capitalize,
  splitFn("-")
);

pointfree

pointfree 模式指的是,永远不必说出你的数据。它的意思是说,函数无须提及将要操作的数据是什么样的。一等公民的函数、柯里化(curry)以及组合协作起来非常有助于实现这种模式。

// 非 pointfree,因为提到了数据:word
var snakeCase = function (word) {
  return word.toLowerCase().replace(/\s+/ig, '_');
};

// pointfree
var snakeCase = compose(replace(/\s+/ig, '_'), toLowerCase);

总结:

优点:

  • 代码简洁,开发快速:函数式编程大量使用函数的组合,函数的复用率很高,减少了代码的重复,因此程序比较短,开发速度较快。
  • 接近自然语言,易于理解:函数式编程大量使用声明式代码,基本都是接近自然语言的,加上它没有乱七八糟的循环,判断的嵌套,因此特别易于理解。
  • 易于"并发编程":函数式编程没有副作用,所以函数式编程不需要考虑“死锁”(Deadlock),所以根本不存在“锁”线程的问题。
  • 更少的出错概率:因为每个函数都很小,而且相同输入永远可以得到相同的输出,因此测试很简单,同时函数式编程强调使用纯函数,没有副作用,因此也很少出现奇怪的 Bug。

缺点:

  • 性能:函数式编程相对于指令式编程,性能绝对是一个短板,因为它往往会对一个方法进行过度包装,从而产生上下文切换的性能开销。同时,在 JS 这种非函数式语言中,函数式的方式必然会比直接写语句指令慢(引擎会针对很多指令做特别优化)。就拿原生方法 map 来说,它就要比纯循环语句实现迭代慢 8 倍。
  • 资源占用:在 JS 中为了实现对象状态的不可变,往往会创建新的对象,因此,它对垃圾回收(Garbage Collection)所产生的压力远远超过其他编程方式。这在某些场合会产生十分严重的问题。
  • 递归陷阱:在函数式编程中,为了实现迭代,通常会采用递归操作,为了减少递归的性能开销,我们往往会把递归写成尾递归形式,以便让解析器进行优化。但是众所周知,JS 是不支持尾递归优化的(虽然 ES6 中将尾递归优化作为了一个规范,但是真正实现的少之又少)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值