揭开函数式编程的面纱

272 阅读12分钟

揭开函数式编程的面纱

一、什么是函数式编程

1函数式编程

它并不是一个新的概念,早在20世纪30年代引入的一套用于研究函数定义,函数应用和递归的形成系统。

函数式编程是种编程方式,它将电脑运算视为函数的计算。函数编程语言最重要的基础是λ演算(lambda calculus),而且λ演算的函数可以接受函数当作输入(参数)和输出(返回值)。

“函数式编程”是一种“编程范式”(programming paradigm),也就是如何编写程序的方法论。它属于"结构化编程"的一种,主要思想是把运算过程尽量写成一系列嵌套的函数调用。

2范畴论

2.1概述

  1. 我们可以把”范畴”想象成是一个容器,里面包含两样东西。值(value)、值的变形关系,也就是函数。
  2. 范畴论使用函数,表达范畴之间的关系。
  3. 伴随着范畴论的发展,就发展出一整套函数的运算方法。这套方法起初只用于数学运算,后来有人将它在计算机上实现了,就变成了今天的”函数式编程"。
  4. 本质上,函数式编程只是范畴论的运算方法,跟数理逻辑、微积分、行列式是同一类东西,都是数学方法,只是碰巧它能用来写程序。为什么函数式编程要求函数必须是纯的,不能有副作用?因为它是一种数学运算,原始目的就是求值,不做其他事情,否则就无法满足函数运算法则了。

2.2举例

揭开函数式编程的面纱.png

如上图所示,可以将A,B,C看成不同的范畴,如A -> string,B -> number,C -> boolean,那么A,B,C三者转换的关系可以如下所示:

const A = 'kane';
const B = A.length; // 对应f操作
const C = B > 0; // 对应g操作
// 那么A -> C的转换关系可以如下所示
C = A.length > 0; // 对应h操作

函数式编程不是用简单的函数来编程,也有别于传统的面向过程编程,主要的目的是将简单的函数来合成复杂的函数,运算过程尽量写成一系列嵌套的函数调用。

二、为什么使用函数式编程

2.1特性

函数式编程拥有非常多的优秀的特性,这也是在其他编程范式中做得不够的地方。

  1. 函数为“一等公民”。每一个函数都可以看成是一个变量,函数可以像变量一样传递,同时也可以像变量一样作为函数的返回值,从而形成一个高阶函数,让函数具有更强大的能力。
  2. 不可修改状态。函数式编程要求是无状态的,每一次函数的执行都是一个独立的过程,避免了面向对象编程中有状态的请求,有状态带来的不好的影响就是必须维护好当前这个状态,而随着这个程序越来越大,修改状态的地方越来越多,如果处理得不好的话,就很难追溯是哪一步操作影响了当前的状态。
  3. 引用透明。函数不依赖外部的变量或者状态,即函数的输出只与函数的输入有关,无论何时执行函数,只要参数不变,结果就不变,换句话来说,通过一个具体的变量代替函数执行返回的结果,程序也是能正常运行的。
  4. 易测试性。这一点得益于函数式编程同时注重纯操作和有副作用的操作分离,在测试的时候我们只需要mock有副作用的操作,来测试软件的稳定性,而其他的编程范式,可能存在着有副作用的操作很没有副作用的操作混在一起,从而不能更好的完成单测。
  5. 没有副作用。在函数式编程中,所有的操作并不会对外界的变量造成影响,同时也不会改变输入的值。
  6. 更加灵活。得益于函数可以作为“一等公民”。可以将一个个小的函数组合成更强大的函数,从而处理更复杂的业务逻辑,并且只要保证小的函数是可靠的,那么组合成的函数也一定可靠的。
  7. 惰性求值。函数式编程可以通过传入部分参数,然后返回一个新的函数,而只有在用的时候才会触发函数的执行,这一点也非常重要,它能够更好的利用现有的CPU计算资源,若不使用,是不会出发函数的执行的。
  8. 不可变性。函数式编程也倡导数据的不可变性,即变量从创建的时候就要保证他的值不变,如果需要改变其值,就要改变其对应引用的地址,从而不需要担心会对其他函数使用此变量时会有影响。这波操作其实就是要求我们每一次对引用的值重新赋值时,都需要进行一个深拷贝,但是js每一次都进行一次深拷贝代价太高了,这边建议一个处理不可变性数据的类库**immutable**。

2.2.相关概念

2.2.2纯函数

2.2.2.1概述

纯函数指的是没有副作用的函数,同样的输入对应同样的输出,并对外界环境无影响,既不会修改全局的变量,又不会依赖于全局变量。这句话非常重要,其他大多数的特性都是这一特性的一个衍生。

2.2.2举例
// 纯函数
let num1 = 0 ;
function add(num2) {
  return num1 + num2;
}
// 非纯函数
function add(num1, num2) {
  return num1 + num2;
}
2.2.2.3可能产生副作用
  • 改变全局变量
  • 改变参数
  • 查询HTML文档,浏览器的Cookie
  • 网络请求

2.2.3函数的柯里化

2.2.3.1概念

是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。

2.2.3.2实现
function curry(fn, ...arg) {
  if (fn.length <= arg.length) {
    return fn(...arg);
  } else {
    return function(...innerArg) {
      return curry(fn, ...arg, ...innerArg);
    }
  }
}
2.2.3.3优缺点
  • 事实上柯里化是一种“预加载”函数的方法,通过传递较少的参数,得到一个已经记住了这些参数的新函数,某种意义上讲,这是一种对参数的“缓存”,是一种非常高效的编写函数的方法。

2.2.4函数组合

2.2.4.1概念

由多个函数组合而成,返回的是一个新的函数,并且将上一个函数的返回值作为下一个函数的参数,是为了更好地解决函数的嵌套问题而诞生的一种解决方案。

2.2.4.2实现
function compose(...arg) {
  return result => arg.reduceRight((result, fn) => fn(result), result);
}
2.2.4.3优缺点
  • 通过函数组合的方式,将一些小的功能函数,组合成功能更强大的函数,从而可以应对更加复杂的应用场景。
  • 如果拆分的很细,复用性虽然增加了,但是对应的函数也会增加,即复用性提升后带来的额外的开发成本。

2.2.5Point Free

2.2.5.1概念

在函数式编程的世界中,有这样一种很流行的编程风格。这种风格被称为 tacit programming,也被称作为 point-free,point 表示的就是形参,意思大概就是没有形参的编程风格。

2.2.5.2举例
const str2arr = str => str.split('');
const toUpperCase = str => str.toUpperCase();
const fn = compose(str2arr, toUpperCase);
fn('kane');

这种Point Free 的风格就能很大程度的减少多余的代码,并且逻辑清晰可见。

2.2.6容器、函子

  • 任何具有map方法的数据结构,都可以当作函子的实现。

  • Functor(函子)遵守一些特定规则的容器类型。

  • Functor 是一个对于函数调用的抽象,我们赋予容器自己去调用函数的能力。把东西装进一个容器,只留出一个接口 map 给容器外的函数,map 一个函数时,我们让容器自己来运行这个函数,这样容器就可以自由地选择何时何地如何操作这个函数,以致于拥有惰性求值、错误处理、异步调用等等非常牛皮的特性。

2.2.6.1Monad函子实现

这是一个最基本的函子结构,里面有一个静态的of函数,返回monad的一个实例,并且提供一个map的函数,能够对函子内的值进行操作,变形,转换。

class Monad {
  constructor() {
    this.val = val;
  }
  static of(val) {
    return new Monad(val);
  }
  map(fn) {
    return Monad.of(fn(this.val));
  }
}
2.2.6.2Maybe函子实现

maybe函子与monad函子的不同之处在于他内部提供了一个断言函数,用于决定是否能够通过map传入的函数进行转换,只要maybe函子确定,那么其对应的判断条件就已经确定了。

class Maybe {
  constructor(val) {
    this.val = val;
  }
  static of(val) {
    return new Maybe(val);
  }
  map(fn) {
    return this.isNothing() ? Maybe.of(null) : Maybe.of(fn(this.val));
  }
  isNothing() {
    return this.val === null || this.val === undefined;
  }
}
2.2.6.3Either函子实现

either函子用于处理常见的条件判断分支,能够很好的处理程序意外带来的错误。如果程序出错,就会进入Left的函子,并且后续不在进行map的转换,可以有效的阻止程序在错误的情况下依然执行错误的代码的情形,而如果程序正常执行,那么还是会进入Right函子,不影响程序后面的map转换。

class Either {
  constructor(val) {
    this.val = val;
  }
  map() {
    throw new Error('Make sure it\'s overwritten');
  }
}

class Left extends Either {
  constructor(val) {
    super(val);
  }
  static of(val) {
    return new Left(val);
  }
  map(fn) {
    return this;
  }
}

class Right extends Either {
  constructor(val) {
    super(val);
  }
  static of(val) {
    return new Right(val);
  }
  map(fn) {
    return Right.of(fn(this.val));
  }
}

2.2.6.4IO函子

这是一个常见的用于处理脏操作的一个函子,此时传入val的值不在是一个普通的变量,而是一个可执行的函数,包裹着脏操作,脏操作所带来的脏数据只有在最终需要使用的时候才会才会污染后续的纯操作,是一种处理脏数据比较好的方式。

class IO extends Monad {
  contructor(val) {
    super(val);
  }
  static of(val) {
    return new IO(val);
  }
  map(fn) {
    return IO.of(compose(fn, this.val));
  }
}

三、函数式编程的应用

3.1函数合成案例

接下来讲解一下函数的合成在函数式编程中的应用。

现在有个需求,给你一个字符串,将这个字符串转化为大写,然后逆序。

function multiLine(str) {
  const upperStr = str.toUpperCase();
  const arr = upperStr.split('');
  const reverseArr = arr.reverse();
  const toStr = reverseArr.join('');
  return toStr;
}
// 当然这个题你也可以这样写,这里只是为了方便举例,你应该将每一步操作看作是一个纯的操作。
function oneLine(str) {
  return str.toUpperCase().split('').reverse().join('');
}

现在改了一下需求,把字符串转化为大写之后,再反转字符串,再把字符串拆开之后组装成一个数组,比如'abc'最终会变成['C', B'', 'A']。

function stringToUpper(str) {
  return str.toUpperCase();
}
function stringReverse(str) {
  return str.split('').reverse().join('');
}
function strToArr(str) {
  return str.split('');
}
const strToUpperArr = compose(strToArr, stringReverse, stringToUpper);
console.log(stringToUpper('abc'));

可以看出有一些应用场景只是函数调用的顺序不同,通过函数不同的组合从而实现不同的功能,能够最大限度的复用现有的代码,这也是函数式编程受欢迎的重要原因。

3.2柯里化案例

比如你有一间店并且你想给你店里面的会员给一个优惠,将商品打九折再出售。

function discountFun(price, discount) {
  return price * discount;
}
const price = discountFun(500, 0.9);
function disCountCurry(discount) {
  return price => {
    return discount * price;
  }
}
const tenPercentDiscount = disCountCurry(0.9);
const twentyPercentDiscount = disCounyCurry(0.8);

console.log(tenPercentDiscount(500));

3.3Maybe函子案例

拿到一个user对象,过滤对象拿到年龄,并继续后续的操作。

const oper1 = x => {
  return { age: x. age };
}
const oper2 = x => x.age;
const oper3 = x => {
  console.log('I\'m ' + (x + 1) + 'next year');
}
const composedOper = compose(oper3, oper2, oper1);
const mapCurryed = curry((f, functor) => functor.map(f));
const needOper = mapCurryed(composedOper);
needOper(Maybe.of({name: 'kane', age: 18}));

3.4Either函子案例

const getAge = user => (user.age === undefined ? Left.of('Error') : Right.of(user.age));
getAge({name: 'kane'}).map(x => x + 1);
// Left('Error');
getAge({name: 'kane', age: 18}).map(x => x + 1);
// Right(19);

从案例中也可以看出Either函子比Maybe函子更加具有优势,在使用的时候更加的灵活,因为他的断言是在程序运行时指定的。

3.5IO函子案例

const readFile = filePath => {
  return IO.of(() => fs.readFileSync(path.resolve(__dirname, filePath)).toString());
}

const print = content => console.log(content);
const getLastChar = str => str[str.length - 1];
const ioCon = readFile('./test.txt').map(getLastChar).map(print);
ioCon.val();

只有最后调用val这个函数,程序才会去读取文件,再进行后续的操作,这样就可以保证print,getLastChar是纯的,能够更好的进行测试。

四、总结

4.1FP和OOP对比

4.1.1FP

  • 代码简洁,开发快速

  • 接近于自然语言,易于理解

  • 更方便的代码管理

  • 易于“并发编程”

4.1.2OOP

  • 对象唯一性
  • 抽象性
  • 继承性
  • 多态性

面向对象是通过函数的封装使代码更加易于理解,函数式编程通过最小的变化让代码更易理解,更改需求后,并没有去修改内部的逻辑,只是重组了一下函数而已

4.2结语

  • 函数式编程正变得越来越火,在前端这个领域使用起来更加的方便,更有利于高效的开发
  • 函数式编程使代码更加的简单,代码更加的精简,更加可靠
  • 函数式编程是一种思想,不受编程语言的限制
  • 在一个程序中不可能全是按照函数式编程范式开发的,即不可能整个程序都为纯操作

五、参考

  1. zhuanlan.zhihu.com/p/21926955