AboutFE icon indicating copy to clipboard operation
AboutFE copied to clipboard

40、函数式编程 FP

Open CodingMeUp opened this issue 4 years ago • 4 comments

函数式编程(Functional Programming,后面简称FP)

为什么叫函数式编程

函数实际上是一个关系,或者说是一种映射,而这种映射关系是可以组合的,一旦我们知道一个函数的输出类型可以匹配另一个函数的输入,那他们就可以进行组合。

在我们的编程世界中,我们需要处理的其实也只有**“数据”和“关系”,而关系就是函数**。我们所谓的编程工作也不过就是在找一种映射关系,一旦关系找到了,问题就解决了,剩下的事情,就是让数据流过这种关系,然后转换成另一个数据罢了。

我特别喜欢用流水线去形容这种工作,把输入当做原料,把输出当做产品,数据可以不断的从一个函数的输出可以流入另一个函数输入,最后再输出结果,这不就是一套流水线嘛?

现在你明确了函数式编程是什么了吧?它其实就是强调在编程过程中把更多的关注点放在如何去构建关系。通过构建一条高效的建流水线,一次解决所有问题。而不是把精力分散在不同的加工厂中来回奔波传递数据


函数式编程不修改外部变量,所以根本不存在线程锁的问题。多线程并发的时候不用担心使用的变量被其他线程所修改

React就是典型的FP。它不同于Vue这样的MVVM框架,它仅仅是个View层。 ReactView = render(data) 它只关心你的输入,最终给你返回相应视图。所以你休想在react组件中去修改父组件的状态,更没有与dom的双向绑定。

这个是框架上的应用,那么在我们平常书写JavaScript时有哪些应用呢?换句话说,平常书写js时候,遇到什么情况,我们采用FP会更好。

  • 函数是一等公民。就是说函数可以跟其他变量一样,可以作为其他函数的输入输出。喔,回调函数就是典型应用。这是函数式编程得以实现的前提,因为我们基本的操作都是在操作函数。这个特性意味着函数与其他数据类型一样,处于平等地位,可以赋值给其他变量,也可以作为参数,传入另一个函数,或者作为别的函数的返回值.
  • 声明式编程 ,函数式编程大多时候都是在声明我需要做什么,而非怎么去做。这种编程风格称为 声明式编程 ,可读性特别高,SQL 语句就是声明式的,你无需关心 Select 语句是如何实现的,不同的数据库会去实现它自己的方法并且优化。React 也是声明式的,你只要描述你的 UI,接下来状态变化后 UI 如何更新,是 React 在运行时帮你处理的,而不是靠你自己去渲染和优化 diff 算法。
  • 惰性计算。是函数只在需要的时候执行,即不产生无意义的中间变量,大意就是:一个表达式绑定的变量,不是声明的时候就计算出来,而是真正用到它的时候才去计算。_.chain value()
  • 无状态和数据不可变 , 这是函数式编程的核心概念, 数据不可变: 它要求你所有的数据都是不可变的,这意味着如果你想修改一个对象,那你应该创建一个新的对象用来修改,而不是修改已有的对象。无状态: 主要是强调对于一个函数,不管你何时运行,它都应该像第一次运行一样,给定相同的输入,给出相同的输出,完全不依赖外部状态的变化。为了实现这个目标,函数式编程提出函数应该具备的特性:没有副作用和纯函数.
  • 没有副作用, 在完成函数主要功能之外完成的其他副要功能。在我们函数中最主要的功能当然是根据输入返回结果,而在函数中我们最常见的副作用就是随意操纵外部变量. 保证函数没有副作用,一来能保证数据的不可变性,二来能避免很多因为共享状态带来的问题。当你一个人维护代码时候可能还不明显,但随着项目的迭代,项目参与人数增加,大家对同一变量的依赖和引用越来越多,这种问题会越来越严重。最终可能连维护者自己都不清楚变量到底是在哪里被改变而产生 Bug。
  • 纯函数, 纯函数算是在 “没有副作用” 的要求上再进一步了纯函数的概念很简单就是两点: 1:不依赖外部状态(无状态): 函数的的运行结果不依赖全局变量,this 指针,IO 操作等。 2:没有副作用(数据不变): 不修改全局变量,不修改入参。 所以纯函数才是真正意义上的 “函数”, 它意味着相同的输入,永远会得到相同的输出
// 非函数式编程
let a = 1;
function increase() {
  a++;
}
increase();
console.log(a); // 2

// 函数式编程
var a = 1;
function increase(a) { // 通过参数引用,不依赖外部数据
  return a + 1; // 不改变外部数据,返回一个新值
}
console.log(increase(a)); // 2
console.log(a); // 1
// 从users中筛选出年龄大于15岁的人的名字
const users = [
  {
    age: 10,
    name: '张三',
  }, {
    age: 20,
    name: '李四'
  }, {
    age: 30,
    name: '王五'
  }
];

// 过程式
const names = [];
for (let i = 0; i < users.length; i++) {
  if (users[i].age > 15) {
    names.push(users[i].name);
  }
}
// 函数式
const names = users.filter(u => u.age > 15).map(u => u.name); // _.chain 和 value() 优化惰性计算避免中间变量损耗

假如页面有一个按钮button,我们需要求出用户点击了几次,但是一秒钟内重复点击的不算。传统方法会这么写。

var count = 0;
var rate = 1000;
var lastClick = Date.now() - rate;
var button = document.querySelector('button');
button.addEventListener('click', () => {
  if (Date.now() - lastClick >= rate) {
    console.log(`Clicked ${++count} times`);
    lastClick = Date.now();
  }
});
// rxjs 
var button = document.querySelector('button');
Rx.Observable.fromEvent(button, 'click')
  .throttleTime(1000) // 每隔1000毫秒才能触发事件
  .scan(count => count + 1, 0) // 求值,默认值是0
  .subscribe(count => console.log(`Clicked ${count} times`)); // 订阅结果、输出值
['john-reese', 'harold-finch', 'sameen-shaw'] 
// 转换成 
[{name: 'John Reese'}, {name: 'Harold Finch'}, {name: 'Sameen Shaw'}]
// 命令式编程
const arr = ['john-reese', 'harold-finch', 'sameen-shaw'];
const newArr = [];
for (let i = 0, len = arr.length; i < len ; i++) {
  let name = arr[i];
  let names = name.split('-');
  let newName = [];
  for (let j = 0, naemLen = names.length; j < naemLen; j++) {
    let nameItem = names[j][0].toUpperCase() + names[j].slice(1);
    newName.push(nameItem);
  }
  newArr.push({ name : newName.join(' ') });
}
return newArr;
完成,这几乎是所有人下意识的编程思路,完全的面向过程。你会想我需要依次完成:

- 定义一个临时变量 newArr。
- 我需要做一个循环。
- 循环需要做 arr.length 次。
- 每次把名字的首位取出来大写,然后拼接剩下的部分。
- ……
- 最后返回结果。
- 

这样当然能完成任务,最后的结果就是一堆中间临时变量,光想变量名就让人感到崩溃。同时过程中掺杂了大量逻辑,通常一个函数需要从头读到尾才知道它具体做了什么,而且一旦出问题很难定位

// 函数式编程
const capitalize = x => x[0].toUpperCase() + x.slice(1).toLowerCase();

const genObj = curry((key, x) => {
  let obj = {};
  obj[key] = x;
  return obj;
}) 

const capitalizeName = compose(join(' '), map(capitalize), split('-'));
const convert2Obj = compose(genObj('name'), capitalizeName)
const convertName = map(convert2Obj);

convertName(['john-reese', 'harold-finch', 'sameen-shaw'])

看这个编程思路,可以清晰看出,函数式编程的思维过程是完全不同的,它的着眼点是函数,而不是过程,它强调的是如何通过函数的组合变换去解决问题,而不是我通过写什么样的语句去解决问题,当你的代码越来越多的时候,这种函数的拆分和组合就会产生出强大的力量。

CodingMeUp avatar May 18 '20 16:05 CodingMeUp

优势

  1. 更好的管理状态。因为它的宗旨是无状态,或者说更少的状态。而平常DOM的开发中,因为DOM的视觉呈现依托于状态变化,所以不可避免的产生了非常多的状态,而且不同组件可能还相互依赖。以FP来编程,能最大化的减少这些未知、优化代码、减少出错情况。测试很简单,同时函数式编程强调使用纯函数,没有副作用,因此也很少出现奇怪的 Bug。

  2. 更简单的复用。极端的FP代码应该是每一行代码都是一个函数,当然我们不需要这么极端。我们尽量的把过程逻辑以更纯的函数来实现,固定输入->固定输出,没有其他外部变量影响,并且无副作用。这样代码复用时,完全不需要考虑它的内部实现和外部影响。

  3. 更优雅的组合。往大的说,网页是由各个组件组成的。往小的说,一个函数也可能是由多个小函数组成的。参考上面第二点,更强的复用性,带来更强大的组合性。

  4. 隐性好处。减少代码量,提高维护性。

  5. 易于"并发编程":函数式编程没有副作用,所以函数式编程不需要考虑“死锁”(Deadlock),所以根本不存在“锁”线程的问题。

劣势

  1. JavaScript不能算是严格意义上的函数式语言,很多函数式编程的特性并没有。比如上文说的数组的惰性链求值。为了实现它就得上工具库,或者自己封装实现,提高了代码编写成本。

  2. 跟过程式相比,性能和资源占用绝对是一个短板,它并没有提高性能。有些地方,如果强制用FP去写,由于没有中间变量,还可能会降低性能。因为它往往会对一个方法进行过度包装,从而产生上下文切换的性能开销。同时,在 JS 这种非函数式语言中,函数式的方式必然会比直接写语句指令慢(引擎会针对很多指令做特别优化)。就拿原生方法 map 来说,它就要比纯循环语句实现迭代慢 8 倍。资源占用:在 JS 中为了实现对象状态的不可变,往往会创建新的对象,因此,它对垃圾回收(Garbage Collection)所产生的压力远远超过其他编程方式。这在某些场合会产生十分严重的问题。

  3. 代码不易读。这个因人而异,因码而已。特别熟悉FP的人可能会觉得这段代码一目了然。而不熟悉的人,遇到写的晦涩的代码,看着一堆堆lambda演算跟匿名函数 () => () => () 瞬间就懵逼了。看懂代码,得脑子里先演算半小时。

  4. 学习成本高。一方面继承于上一点。另一方面,很多前端coder,就是因为相对不喜欢一些底层的抽象的编程语言,才来踏入前端坑,你现在又让他们一头扎入FP,显得手足无措。

  5. 递归陷阱:在函数式编程中,为了实现迭代,通常会采用递归操作,为了减少递归的性能开销,我们往往会把递归写成尾递归形式,以便让解析器进行优化。但是众所周知,JS 是不支持尾递归优化的(虽然 ES6 中将尾递归优化作为了一个规范,但是真正实现的少之又少,传送门

CodingMeUp avatar May 18 '20 16:05 CodingMeUp

https://juejin.im/post/5d70e25de51d453c11684cc4

CodingMeUp avatar May 18 '20 17:05 CodingMeUp

加工站——柯里化

柯里化的意思是将一个多元函数,转换成一个依次调用的单元函数。

f(a,b,c) → f(a)(b)(c)

为什么这个单元函数很重要?还记得我们之前说过的,函数的返回值,有且只有一个嘛? 如果我们想顺利的组装流水线,那我就必须保证我每个加工站的输出刚好能流向下个工作站的输入。因此,在流水线上的加工站必须都是单元函数。

流水线——函数组合

函数组合的目的是将多个函数组合成一个函数

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

实践经验

  • 柯里化中把要操作的数据放到最后

因为我们的输出通常是需要操作的数据,这样当我们固定了之前的参数(我们可以称为配置)后,可以变成一个单元函数,直接被函数组合使用,这也是其他的函数式语言遵循的规范:

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);

  • 多参考 Ramda 现有的函数式编程工具库很多,Lodash/fp 也提供了,但是不是很推荐使用 Lodash/fp 的函数库,因为它的很多函数把需要处理的参数放在了首位( 例如 map )这不符合我们之前说的最佳实践。 这里推荐使用 Ramda,它应该是目前最符合函数式编程的工具库,它里面的所有函数都是 curry 的,而且需要操作的参数都是放在最后的。上述的 split,join,replace 这些基本的都在 Ramda 中可以直接使用,它一共提供了 200 多个超实用的函数,合理使用可以大大提高你的编程效率(目前我的个人经验来说,我需要的功能它 90%都提供了)。

CodingMeUp avatar May 18 '20 17:05 CodingMeUp

举例

const data = [
  {
    name: 'Peter',
    sex: 'M',
    age: 18,
    grade: 99
  },
  ……
]

实现以下几个常用功能:

  1. 获取所有年龄小于 18 岁的对象,并返回他们的名称和年龄。
  2. 查找所有男性用户。
  3. 更新一个指定名称用户的成绩(不影响原数组)。
  4. 取出成绩最高的 10 名,并返回他们的名称和分数。

我这边提供以下 Ramda 库中的参考函数:

// 对象操作(最后一个参数是对象),均会返回新的对象拷贝
R.prop('name')    // 获取对象 name 字段的值
R.propEq('name', '123')   // 判断对象 name 字段是否等于‘123’
R.assoc('name', '123')   // 更新对象的'name'的值为'123'
R.pick(['a', 'd']); //=> {a: 1, d: 4}  // 获取对象某些属性,如果对应属性不存在则不返回
R.pickAll(['a', 'd']); //=> {a: 1, d: 4}  // 获取对象某些属性,如果对应属性不存在则返回`key : undefined`

// 数组操作
R.map(func)  // 传统的 map 操作
R.filter(func)  // 传统的 filter 操作
R.reject(func)  // filter 的补集
R.take(n)    // 取出数组前 n 个元素

// 比较操作
R.equals(a, b)  // 判断 b 是否等于 a 
R.gt(2, 1) => true  // 判断第一个参数是否大于第二个参数
R.lt(2, 1) => false // 判断第一个参数是否小于第二个参数

// 排序操作
R.sort(func)    // 根据某个排序函数排序
R.ascend(func)    // 根据 func 转换后的值,生成一个升序比较函数
R.descend(func)    // 根据 func 转换后的值,生成一个降序比较函数
// 例子:
R.sort(R.ascend(R.prop('age')))  // 根据 age 进行升序排序 

// 必备函数
R.pipe()   //compose 的反向,从前往后组合
R.compose()  // 从后到前组合
R.curry()  // 柯里化
// 答案
const expectData = {
  ageLt18: [
    { name: "Uktg", age: 17 },
    { name: "3U5", age: 17 },
    { name: "Hu8M", age: 17 },
    { name: "Z2d", age: 17 },
    { name: "n0ap", age: 17 },
    { name: "ptTc", age: 17 },
    { name: "Hqn", age: 17 },
    { name: "QC3", age: 17 }
  ],
  males: [
    { sex: "M", name: "vyo", age: 18, grade: 72 },
    { sex: "M", name: "Uktg", age: 17, grade: 86 },
    { sex: "M", name: "3U5", age: 17, grade: 82 },
    { sex: "M", name: "OVfZ", age: 19, grade: 54 },
    { sex: "M", name: "5Rx", age: 18, grade: 92 },
    { sex: "M", name: "8GPC", age: 18, grade: 79 },
    { sex: "M", name: "Z2d", age: 17, grade: 82 },
    { sex: "M", name: "QyKY", age: 19, grade: 59 },
    { sex: "M", name: "VT1", age: 19, grade: 89 },
    { sex: "M", name: "ptTc", age: 17, grade: 65 },
    { sex: "M", name: "Xvhf", age: 18, grade: 65 },
    { sex: "M", name: "wOR", age: 19, grade: 78 },
    { sex: "M", name: "Hqn", age: 17, grade: 74 }
  ],
  top10: [
    { name: "5Rx", grade: 92 },
    { name: "VT1", grade: 89 },
    { name: "gZW", grade: 87 },
    { name: "Uktg", grade: 86 },
    { name: "3U5", grade: 82 },
    { name: "Z2d", grade: 82 },
    { name: "NqXw", grade: 79 },
    { name: "8GPC", grade: 79 },
    { name: "wOR", grade: 78 },
    { name: "jXgh", grade: 77 }
  ]
};

const data = [
  {
    sex: "M",
    name: "vyo",
    age: 18,
    grade: 72
  },
  {
    sex: "M",
    name: "Uktg",
    age: 17,
    grade: 86
  },
  {
    sex: "F",
    name: "gZW",
    age: 18,
    grade: 87
  },
  {
    sex: "F",
    name: "NqXw",
    age: 19,
    grade: 79
  },
  {
    sex: "M",
    name: "3U5",
    age: 17,
    grade: 82
  },
  {
    sex: "M",
    name: "OVfZ",
    age: 19,
    grade: 54
  },
  {
    sex: "F",
    name: "Hu8M",
    age: 17,
    grade: 62
  },
  {
    sex: "M",
    name: "5Rx",
    age: 18,
    grade: 92
  },
  {
    sex: "M",
    name: "8GPC",
    age: 18,
    grade: 79
  },
  {
    sex: "M",
    name: "Z2d",
    age: 17,
    grade: 82
  },
  {
    sex: "M",
    name: "QyKY",
    age: 19,
    grade: 59
  },
  {
    sex: "M",
    name: "VT1",
    age: 19,
    grade: 89
  },
  {
    sex: "F",
    name: "n0ap",
    age: 17,
    grade: 58
  },
  {
    sex: "M",
    name: "ptTc",
    age: 17,
    grade: 65
  },
  {
    sex: "M",
    name: "Xvhf",
    age: 18,
    grade: 65
  },
  {
    sex: "F",
    name: "uMe1",
    age: 18,
    grade: 56
  },
  {
    sex: "F",
    name: "jXgh",
    age: 18,
    grade: 77
  },
  {
    sex: "M",
    name: "wOR",
    age: 19,
    grade: 78
  },
  {
    sex: "M",
    name: "Hqn",
    age: 17,
    grade: 74
  },
  {
    sex: "F",
    name: "QC3",
    age: 17,
    grade: 59
  }
];
const { curry, pipe, filter, prop } = R;
// code here
/*
 *  ex1 
 */
// :: String -> Number -> Object -> Boolean
const propLt = curry((p, c) => R.pipe(R.prop(p), R.lt(R.__, c)));

// :: Object ->  Boolean
const ageUnder18 = propLt("age", 18);

// :: [a] -> b 
const getAgeUnder18 = R.pipe(
  R.filter(ageUnder18),
  R.map(R.pickAll(["name", "age"]))
);

/*
 *  ex2
 */

// :: String -> [a] -> [a]
const getSex = sex => R.filter((R.propEq("sex", sex)));
// :: [a] -> [a]
const getMales = getSex("M");
const getFemales = getSex("F");

/*
 *  ex3 
 */

// :: String -> (String -> String -> a -> Object -> Object)
const updatePropBy = curry((byProp, updateProp, match, newValue, obj) => 
                           R.when(R.propEq(byProp, match), R.assoc(updateProp, newValue))(obj));

// 如果上面的理解有困难,可以类比到下面这个函数:
const updatePropBy_2 = curry((byProp, updateProp, match, newValue, obj) => {
  if (obj[byProp] === match) {
    let newObj = {...obj };
    newObj[updateProp] = newValue;
    return newObj;
  }
  return ({...obj})
});

                           
// :: String -> (String -> a -> Object -> Object)
const updatePropByName = updatePropBy('name');

// :: String -> (a -> Object -> Object)
const updateGradeByName = updatePropByName('grade');
const updateAgeByName = updatePropByName('age');

// :: String -> Number -> [Object] -> [Object]
const updateUsersGradeByName = curry((name, value, arr) => R.map(updateGradeByName(name, value), arr));
// :: String -> Number -> [Object] -> [Object]
const updateUsersAgeByName = curry((name, age, arr) => R.map(updateAgeByName(name, age), arr));


/*
 *  ex4
 */

// :: String -> [a] -> [a]
const desc = x => R.sort(R.descend(R.prop(x)));

// :: [a] -> [b]
const getGradeTop10 = R.pipe(
  desc("grade"),
  R.take(10),
  R.map(R.pickAll(["name", "grade"]))
);



mocha.setup("bdd");
var assert = chai.assert;

describe("Test", function() {
  it("1. 获取所有年龄小于18岁的对象,并返回他们的名称和年龄", function() {
    const result = getAgeUnder18(data);
    assert.deepEqual(result, expectData.ageLt18);
  });

  it("2. 查找所有男性用户", function() {
    const result = getMales(data);
    assert.deepEqual(result, expectData.males);
  });
  
  
  it("3. 更新一个指定名称用户的成绩", function() {
    const result = updateUsersGradeByName('QC3', 100, data);
    const getGrade = R.pipe(R.find(R.propEq('name', 'QC3')), R.prop('grade'));
    assert.deepEqual(getGrade(result), 100);
  });
  

  it("4. 取出成绩最高的10名,并返回他们的名称和分数", function() {
    const result = getGradeTop10(data);
    assert.deepEqual(result, expectData.top10);
  });

});

mocha.run();

CodingMeUp avatar May 18 '20 17:05 CodingMeUp