Skip to content

Latest commit

 

History

History
645 lines (508 loc) · 17.9 KB

函数式编程.md

File metadata and controls

645 lines (508 loc) · 17.9 KB

函数式编程

函数式编程的概念

函数式编程(Functional Programming, FP) 是编程范式之一,我们常听说的编程范式还有面向过程编程、面向对象编程。

  • 面向对象编程的思维方式:把现实世界中的事物抽象成程序世界中的类和对象,通过封装、继承和 多态来演示事物事件的联系。
  • 函数式编程的思维方式:把现实世界的事物和事物之间的联系抽象到程序世界,也就是对运算过程进行抽象

一个简单的例子:

/* ----- 非函数式 ----- */
let num1 = 2;
let num2 = 3;
let sum = num1 + num2;

/* ----- 函数式 ----- */
// 先对运算过程进行抽象
function add (n1, n2) {   
  return n1 + n2;
}

// 之后就能复用这个抽象的运算过程
let sum = add(2, 3);

函数式编程的好处

  • 函数式编程随着React的流行受到越来越多的关注, 比如redux就是拥抱了很多函数式编程的思想,reducer就是一个pure function。
  • Vue 3也开始拥抱函数式编程
  • 函数式编程可以抛弃this
  • 打包过程中可以更好的利用tree shaking过滤无用代码
  • 方便测试、方便并行处理
  • 有很多库可以帮助我们进行函数式开发:lodash、underscore、ramda

函数是一等公民

在JS中,函数就是一个普通的对象, 它可以:

  • 存储在变量中
  • 作为参数
  • 作为返回值

高阶函数 (Higher-order function)

  • 可以把函数作为参数传递给另一个函数
  • 可以把函数作为另一个函数的返回结果
/* ----- 函数作为参数 ----- */
function forEach(array, fn) {
  for (let i = 0; i < array.length; i++) {
    fn(array[i])
    }
}

/* ----- 函数作为返回值 ----- */
function once(fn) {
  // 这个高阶函数的思路就是通过闭包存一个done的flag
  // 这个done只有新return的这个function可以访问
  // 不会污染外部环境
  let done = false;

  return function() {
    if (!done) {
      done = true;
      return fn.apply(this, arguments);
      // TODO: 这里为什么不能直接fn(arguments)?
    }
  };
}

const pay = once(money => {
  console.log(`支付:${money} RMB`);
});

pay(5);
pay(5);
pay(5);
// => 只会打印一次:支付:5 RMB

使用高阶函数的意义

  • 抽象可以帮我们屏蔽细节,只需要关注我们的目标
  • 高阶函数是用来抽象通用的问题

例子

let array = [1, 2, 3, 4];

/* ----- 面向过程的方式 ----- */
for (let i = 0; i < array.length; i++) {
  console.log(array[i]);
}

/* ----- 高阶函数的方式 ----- */
forEach(array, item => {
  console.log(item)
});

闭包

闭包 (Closure): 函数和其周围的状态(词法环境)的引用捆绑在一起形成闭包。 可以在另一个作用域中调用一个函数的内部函数并访问到该函数的作用域中的成员。

闭包的本质: 函数在执行的时候会放到一个执行栈上,当函数执行完毕之后会从执行栈上移除。但是,堆上的作用域成员因为被外部引用不能释放,因此内部函数依然可以访问外部函数的成员。

例子:上面的once函数。

纯函数

纯函数:相同的输入永远会得到相同的输出,而且没有任何可观察的副作用

例子:数组的slicesplice分别是纯函数和不纯的函数:

  • slice返回数组中的指定部分,不会改变原数组
  • splice对数组进行操作返回该数组,会改变原数组(副作用)
let numbers = [1, 2, 3, 4, 5];
numbers.slice(0, 3);  // => [1, 2, 3]
numbers.slice(0, 3);  // => [1, 2, 3]
numbers.slice(0, 3);  // => [1, 2, 3]
console.log(numbers); // => [1, 2, 3, 4, 5]

// 不纯的函数
numbers.splice(0, 3); // => [1, 2, 3]
console.log(numbers); // => [4, 5]

函数式编程不会保留计算中间的结果,所以变量是不可变的(无状态的),不像一个class的instance,内部可以有类似state machine的东西。 我们可以把一个函数的执行结果交给另一个函数去处理。

纯函数的好处

  • 可缓存。 因为纯函数对相同的输入始终有相同的结果,所以可以把纯函数的结果缓存起来。
function memoize(f) {
  const memo = {};

  return function () {
    const args = JSON.stringify(arguments);
    memo[args] = memo[args] || f.apply(f, arguments);   // TODO: 这里为什么是 f.apply(f, arguments)? f是this?

    return memo[args];
  };
}
  • 可测试 纯函数让测试更方便,方便unit test。

  • 并行处理 在多线程环境下并行操作共享的内存数据很可能会出现意外情况。纯函数不需要访问共享的内存数据,所以在并行环境下可以任意运行纯函数(比如Web Worker)。

副作用

/* ----- 不纯的 ----- */
// 如果函数依赖于外部的状态,就无法保证输出相同。
// 这里的mini有可能被其它东西改变。
let mini = 18;
function checkAge (age) {
  return age >= mini;
}

/* ----- 纯的(有硬编码,后续可以通过柯里化解决)  ----- */
function checkAge (age) {
  let mini = 18;
  return age >= mini
}

副作用来源:

  • 配置文件
  • 数据库
  • 获取用户的输入
  • ......

所有的外部交互都有可能带来副作用,副作用也使得方法通用性下降不适合扩展和可重用性,同时副作用会给程序中带来安全隐患给程序带来不确定性。

但是副作用不可能完全禁止,尽可能控制它们在可控范围内发生。

柯里化 (Haskell Brooks Curry)

柯里化(Currying): 当一个函数有多个参数的时候先传递一部分参数调用它(这部分参数以后永远不变),然后返回一个新的函数接收剩余的参数,返回结果。

解决上面提到的硬编码的问题

/* ----- 原函数 ----- */
function checkAge(age) {
  let min = 18
  return age >= min
}

/* ----- 普通纯函数 ----- */
function checkAge(min, age) {
  return age >= min
}
checkAge(18, 24)
checkAge(18, 20)
checkAge(20, 30)

/* ----- 柯里化 ----- */
let checkAge = min => (age => age >= min)
let checkAge18 = checkAge(18)
let checkAge20 = checkAge(20)
checkAge18(24)
checkAge18(20)

使用柯里化可以方便函数的组合。

const _ = require('lodash');

const _match = (reg, str) => str.match(reg);
const match = _.curry(_match);

const matchSpace = match(/\s+/g);
const matchNumber = match(/\d+/g);

console.log(matchSpace('hello world'));   // => [' ']
console.log(matchNumber('25$'));          // => ['25']

// 组合刚才的函数,生成新的filterSpace函数
const _filter = (func, array) => array.filter(func);
const filter = _.curry(_filter);
const filterSpace = filter(matchSpace);
console.log(filterSpace(['John Connor', 'John_Donne']));  // => ['John Connor']

模拟柯里化的实现

const curry = fn => {
  return function curriedFn(...curArgs) {
    if (curArgs.length === fn.length) {
      return fn(...curArgs);
    }

    return function () {
      // TODO: 这里为什么不能用箭头方程?不然arguments的指向会不对
      const nextArgs = Array.from(arguments);
      const allArgs = [...curArgs, ...nextArgs];
      return curriedFn(...allArgs);
    };
  }
};

柯里化的好处

  • 柯里化可以让我们给一个函数传递较少的参数得到一个已经记住了某些固定参数的新函数, 这是一种对函数参数的'缓存'
  • 让函数变的更灵活,让函数的粒度更小,可以理解为对函数“降维”
  • 可以把多元函数转换成一元函数,可以组合使用函数产生强大的功能

函数组合

纯函数和柯里化很容易写出洋葱代码:h(g(f(x)))。 比如,获取数组的最后一个元素再转换成大写字母:

_.toUpper(_.first(_.reverse(array)))

这个时候就可以用到函数组合来优化,让我们把细粒度的函数重新组合生成一个新的函数。

管道

下面这张图表示程序中使用函数处理数据的过程,给fn函数输入参数a,返回结果b。可以想象a数据通过一个管道得到了b数据。

管道1

fn函数比较复杂的时候,我们可以把函数fn拆分成多个小函数,此时多了中间运算过程产生的mn

下面这张图中可以想象成把fn这个管道拆分成了3个管道f1,f2,f3,数据a通过管道f3得到结果m,m再通过管道f2得到结果n,n通过管道f1得到最终结果b

管道2

换成代码的思路就是:

fn = compose(f1, f2, f3)
b = fn(a)

函数组合

如果一个函数要经过多个函数处理才能得到最终值,这个时候可以把中间过程的函数合并成一个函数。函数就像是数据的管道,函数组合就是把这些管道连接起来,让数据穿过多个管道形成最终结果。

函数组合默认是从右到左执行。

组合函数的简单例子:

const compose = (f, g) => x => f(g(x));
const first = arr => arr[0];
const reverse = arr => arr.reverse();

const last = compose(first, reverse);   // 从右到左执行
console.log(last([1, 2, 3, 4]));        // => 4

lodash 中的组合函数

  • flow() 是从左到右运行
  • flowRight() 是从右到左运行,使用的更多一些
const _ = require('lodash');

const toUpper = s => s.toUpperCase();
const reverse = arr => arr.reverse();
const first = arr => arr[0];
const f = _.flowRight(toUpper, first, reverse);
console.log(f(['one', 'two', 'three']));  // => THREE

模拟实现 lodash 的 flowRight 方法

const compose = (...fns) => value => (
  fns.reverse().reduce((acc, fn) => fn(acc), value)
);

函数的组合的结合律

我们既可以把 g 和 h 组合,还可以把 f 和 g 组合,结果都是一样的。

const f = compose(f, g, h);
const associative = compose(compose(f, g), h) == compose(f, compose(g, h));   // => true

注意这个是结合律,不是交换律!函数不能交换位置。

compose(a, b) == compose(b, a);   // => 不一定!!

调试组合函数

const _ = require('lodash');

const trace = _.curry((tag, v) => {
  console.log(tag, v);
  return v;
});

const split = _.curry((sep, str) => _.split(str, sep));
const join = _.curry((sep, array) => _.join(array, sep));
const map = _.curry((fn, array) => _.map(array, fn));
const f = _.flowRight(join('-'), trace('map 之后'), map(_.toLower),
trace('split 之后'), split(' '));

console.log(f('Bitcoin To Da Moon!'));
/* ==>
  split 之后 [ 'Bitcoin', 'To', 'Da', 'Moon!' ]
  map 之后 [ 'bitcoin', 'to', 'da', 'moon!' ]
  bitcoin-to-da-moon!
*/

lodash的fp模块

lodash的fp模块提供了实用的对函数式编程友好的方法,提供了不可变auto-curried iteratee-first, data-last的方法。

一般格式是: f(iteratee, data)。这样的好处是,可以先传进iteratee,创建很多curried的函数,这些函数就等着data,很方便组合。

const _ = require('lodash');
const fp = require('lodash/fp');

const f = fp.flowRight(
  fp.join('-'),
  fp.map(fp.toLower),
  fp.split(' '),
);

console.log(f('Bitcoin To Da Moon!'));

Point Free

这个概念的意思是:我们可以把数据处理的过程定义成与数据无关的合成运算,这一步不需要用到真正的数据,只需要定义一些辅助的基本运算函数。

const str = 'Hello World';

/* ----- 非 Point Free 模式 ----- */
const f1 = word => word.toLowerCase().replace(/\s+/g, '_');
console.log(f1(str));   // => hello_world

/* ----- Point Free 模式 ----- */
const fp = require('lodash/fp');
const f2 = fp.flowRight(fp.replace(/\s+/g, '_'), fp.toLower);
console.log(f2(str));   // => hello_world

Functor (函子)

函子的作用

在函数式编程中,把副作用控制在可控的范围内、异常处理、异步操作等。

什么是函子

什么是容器:容器包含值和值的变形关系,这个变形关系就是一个函数。

函子是一个特殊的容器,通过一个普通的对象来实现,该对象具有map方法map方法可以运行一个函数对值进行处理(变形关系)。

一些特征:

  • 我们可以把函子想象成一个盒子,这个盒子里封装了一个值
  • 函数式编程的运算不直接操作值,而是由函子完成
  • 想要处理盒子中的值,我们需要给盒子的map方法传递一个处理值的函数(纯函数),由这 个函数来对值进行处理
  • 最终 map 方法返回一个包含新值的盒子(函子)

Pointed 函子

  • Pointed 函子是实现了of()静态方法的函子
  • of()方法是为了避免使用 new 来创建对象。更深层的含义是of()方法用来把值放到上下文(Context)中(把值放到容器中,使用 map 来处理值)。
class Container {
  static of(value) {
    return new Container(value);
  }

  // ……
}

Container.of(2)
  .map(x => x + 5);

函子基本的例子

// 一个容器,包裹一个值
class Container {
  // of 静态方法,可以省略 new 关键字创建对象
  static of(value) {
    return new Container(value);
  }

  constructor(value) {
    this._value = value;
  }

  // map 方法,传入变形关系,将容器里的每一个值映射到另一个容器
  map(fn) {
    const nextValue = fn(this._value);
    return Container.of(nextValue);
  }
}

const c = Container.of(3)
  .map(x => x + 2)
  .map(x => x * x);

console.log(c);   // => Container { _value: 25 }

问题 这样的无法处理异常

Container.of(null)
  .map(x => x.toUpperCase())  // => TypeError: Cannot read property 'toUpperCase' of null

MayBe 函子

我们在编程的过程中可能会遇到很多错误,需要对这些错误做相应的处理。MayBe 函子的作用就是可以对外部的空值情况做处理(控制副作用在允许的范围)。

class MayBe {
  static of(value) {
    return new MayBe(value);
  }

  constructor(value) {
    this._value = value;
  }

  // 如果对空值变形的话直接返回 值为 null 的函子
  map(fn) {
    return this.isNothing()
      ? MayBe.of(null)
      : MayBe.of(fn(this._value));
  }

  isNothing() {
    return this._value === null || this._value === undefined;
  }
}

const valid = MayBe.of('Hello World')
  .map(x => x.toUpperCase());

const invalid = MayBe.of(null)
  .map(x => x.toUpperCase());

console.log(valid);     // => MayBe { _value: 'HELLO WORLD' }
console.log(invalid);   // => MayBe { _value: null }

问题MayBe 函子中,我们很难确认是哪一步产生的空值问题。

MayBe.of('hello world')
  .map(x => x.toUpperCase())
  .map(x => null)
  .map(x => x.split(' '))

// => MayBe { _value: null }
// 不知道是哪一步的问题

Either 函子

Either 函子可以用来做异常处理,思路是类似于if...else...的处理。

class Left {
  static of(value) {
    return new Left(value);
  }

  constructor(value) {
    this._value = value;
  }

  map(fn) {
    return this;
  }
}

class Right {
  static of(value) {
    return new Right(value);
  }

  constructor(value) {
    this._value = value;
  }

  map(fn) {
    return Right.of(fn(this._value));
  }
}

// 用来处理异常
function parseJSON(json) {
  try {
    return Right.of(JSON.parse(json));
  } catch (e) {
    return Left.of({ error: e.message });
  }
}

const valid = parseJSON('{ "name": "zs" }');
const invalid = parseJSON('{{{{{"zs"');

console.log(valid);     // => Right { _value: { name: 'zs' } }
console.log(invalid);   // => Left { _value: { error: 'Unexpected token { in JSON at position 1' } }

IO 函子

IO 函子中的_value是一个函数,而不是一个值。这样的目的是,可以把不纯的动作存储到_value中这个函数中,延迟执行这个不纯的操作(惰性执行),把不纯的操作交给调用者来处理。

const fp = require('lodash/fp');

class IO {
  static of(x) {
    return new IO((() => x));
  }

  constructor(fn) {
    this._value = fn;
  }

  map(fn) {
    // 把当前的value函数和传入的fn组合成一个新的函数
    const newFn = fp.flowRight(fn, this._value);
    return new IO(newFn);
  }
}

const io = IO.of(process).map(p => p.execPath);
const res = io._value();    // 这一步可能出错,但是是调用者在执行,函子还是纯的,永远都是返回同样的函数
console.log(res);           // => /usr/local/bin/node

问题 如果需要组合IO函子,代码非常不简洁,需要重复调用._value()

const fs = require('fs');
const fp = require('lodash/fp');

class IO {
  // ...
}

const readFile = filename => new IO(
  () => fs.readFileSync(filename, 'utf-8')
);

const print = x => new IO(() => {
  console.log(x);
  return x;
});

// 组合成:IO(IO(x))
const cat = fp.flowRight(print, readFile);

// 需要重复调用_value()
const res = cat('package.json')._value()._value();
console.log(res);   // => some JSON content...

这个问题可以用Monad 函子解决。

Monad 函子

  • Monad 函子是可以变扁的Pointed 函子,方便这样的调用:IO(IO(IO(x)))...
  • 一个函子如果具有joinof两个方法并遵守一些定律,就是一个Monad
const fp = require('lodash/fp');
const fs = require('fs');

class IOMonad {
  static of(x) {
    return new IOMonad((() => x));
  }

  constructor(fn) {
    this._value = fn;
  }

  map(fn) {
    return new IOMonad(fp.flowRight(fn, this._value));
  }

  flatMap(fn) {
    return this.map(fn).join();
  }

  join() {
    // actually call the function
    return this._value();
  }
}

const readFile = filename => new IOMonad(
  () => fs.readFileSync(filename, 'utf-8'),
);

const print = x => new IOMonad(() => {
  console.log(x);
  return x;
});

const res = readFile('package.json')
  .map(fp.toUpper)
  .flatMap(print)
  .join();

console.log(res);   // => some JSON content...

Reference

以上内容主要来源是拉钩教育的课程和学习资料,加上自己的理解和增查删改。