阅读 881

一些关于JavaScript函数式编程的思考

前几天看到掘金上有两篇关于JavaScript函数式编程的争论,有人建议不用for循环,有的人又说太过函数式不好。我自己也是一个喜欢函数式编程的人,所以写了这篇文章想和大家分享一些我个人喜欢的建议,最后也有一些我自己的思考。

第一次在掘金发文章,各位同行手下留情,有错误欢迎指出。

1、给函数清晰的命名,写好函数功能介绍以及函数签名。

好的函数名称能够清晰地让人知道这个函数的功能作用。

函数签名能够让你知道某个函数会对参数做怎样的类型转换,当你使用函数组合时这个功能尤其有用。

不是很清晰的命名:

const a1 = (value) => value.name;
const a2 = (value) => value > 10;
复制代码

清晰一些的函数命名和函数签名:

// 接受一个userObj, 返回userObj.name
// Object -> *
const getName = (userObj) => user.name;

//接受一个Number, 判断该Number是否大于10
// Number -> Boolean
const isGreaterThan10 = (number) => number > 10;

复制代码

2、尝试多使用函数式编程方式,少使用命令式编程方式。

通常函数式编程方式比命令式编程方式更清晰直观,学会抽象和封装一些常用功能函数,减少命令式的代码,多使用声明式的代码。

使用for循环

// 将test里面的值翻倍
const test = [1,2,3,4,5];
// 缓存数组长度
const length = test.length;
// 数组索引
let i = 0;
// 保存翻倍后的新数组到result
let result = [];
for(i; i < length; i++) {
    result[i] = test[i] * 2;
}

console.log(result);

复制代码

使用内置map函数或第三方map函数。

// 将test里面的值翻倍
const test = [1,2,3,4,5];
const result = test.map(item => item * 2);

console.log(result);
复制代码

3、尝试定义函数时采用分散的方式,使用函数时采用组合的方式。

定义函数时采用分散的方式可以把各个单独的功能点拆分出来,在需要时可以复用该功能函数。

使用时再根据需要把各个小功能函数组合起来。

所有逻辑写在一个函数里面。

// 获取user里面的name,大写首字母,然后加上'hello, '前缀。

/*
**接受一个user Object,获取里面的name,大写name首字母,然后添加hello前缀,
**最后再返回改变后的字符串。
** Object -> String
*/
const changeName = (user) => {
    const name = user.name;
    const capitalFirstLetter = name[0].toUpperCase();
    const restName = name.slice(1);
    const changedName = `Hello, ${capitalFirstLetter}${restName}`;
    return changedName;
}
// 测试
const user = {
    name: 'alex',
};
const result = changeName(user);

console.log(result);
复制代码

分开定义不同的功能函数,需要时再组合起来

/*
** 接受一个user Object, 返回user.name
** Object -> *
*/ 
const getName = (user) => user.name;

/*
** 接受一个word String, 返回大写该word首字母后的字符串
** String -> String
*/
const capitalizeFirst = (word) => {
    const capitalFirst = word[0].toUpperCase();
    const rest = word.slice(1);
    return capitalFirst + rest;
};

/*
** 接受一个word String, 返回添加hello前缀后的字符串
** String -> String
*/
const addHello = (word) => `Hello, ${word}`;

/*
**这里定义了一个帮助函数,pipe函数用于帮助组合函数,pipe接受3个一元函数fn1,fn2,fn3,返回一个新函数。
**调用新函数时传递的参数会依次传递给fn1,fn2,fn3,
**每次接受的参数是上一次调用函数后的返回值。
**(((a -> b), (b -> c), …, (x -> y), (y -> z)) → ((a, b, …, n) -> z)
*/
const pipe = (fn1, fn2, fn3) => (value) => fn3(fn2(fn1(value)));

// 测试
const user = {
    name: 'alex',
};

/*
**这个组合函数接受一个对象,然后通过组合的所有中间函数
**把数据转化成我们希望的结果
** 注意 pipe(getName, capitalizeFirst, addHello)(user) 
** 等于 addHello(capitalizeFirst(getName(user)))
** Object -> String
*/
const changeName = pipe(
    // 获取name
    getName,
    // 大写首字母
    capitalizeFirst,
    // 添加hello前缀
    addHello
);
const result = changeName(user);

console.log(result);

复制代码

4、多使用纯函数,纯函数意味着你传入相同的参数,总会返回相同的结果,不会依赖外部变量。

纯函数有很多好处,方便测试,可以并发调用,方便做缓存功能。建议在可能的情况下多使用纯函数。

// addPrefix是不纯的函数,依赖了外部变量prefix,传入相同的name也可能因为外部prefix的值而返回不同的结果
const prefix = 'hello, ';

// 接受一个name字符串,返回添加前缀后的字符串
// String -> String
const addPrefix = (name) => prefix + name;

/* 纯函数,传入相同的prefix 和 name肯定会返回相同的结果
** 接受一个name字符串,和一个字符串前缀prefix,返回添加前缀后的字符串
** String -> String
*/
const addPrefix = (prefix, name) => prefix + name;

复制代码

5、学会使用函数柯里化(currying)和部分应用(partial application)技术减少重复代码和组合代码。

在上面例子中,有几个地方用的固定参数,比如getName就是接受一个user对象,然后返回这个对象的name值,如果我们想获取user的年龄age值,我们需要重新写一个getAge,如果需要获取用户性别sex,我们可能又需要写一个getSex函数。

下面是可能的代码:

const getName = (user) => user.name;
const getAge = (user) => user.age;
const getSex = (user) => user.sex;

复制代码

这样的3个函数都是类似的,都是获取一个对象里面的某个属性值。

这种情况你可能会写一个二元函数,这个函数会接受2个参数,第一个是一个Object,第二个是想获取的key值的名称,下面是可能的代码:

const getProp = (obj, key) => obj[key];

const name = getProp(user, 'name');
const age = getProp(user, 'age');
const sex = getProp(user, 'sex');
复制代码

这几个函数还是有一些共同点,都是从user中获取属性,而且,2元函数没办法和pipe函数很好的结合起来,因为在组合中我们pipe函数每次只会返回和传递单个值到下一个函数。

那么有没有什么更好的办法呢?我们可以使用到函数柯里化(currying)和部分应用(partial application)技术。

函数柯里化(currying):

函数柯里化的意思是把一个多元函数转化成每次接受一个参数,但是需要多次调用才能获取结果的函数,下面是2个简单例子:

// 没有柯里化的add函数
const add = (a, b, c) => a + b +c;
const result = add(1, 2, 3);
console.log(result) // 6
// 柯里化后的curryAdd函数
const curryAdd = (a) => (b) => (c) => a + b + c;
const result = curryAdd(1)(2)(3); 
console.log(result); // 6
//

复制代码

使用没有柯里化的add函数时,你必须一次性传递3个参数,只调用一次函数,而使用curryAdd函数时,你每次传递1个参数,但是调用了3次函数,curryAdd就是柯里化后的函数。

回头看看getProp函数:

const getProp = (obj, key) => obj[key];
复制代码

这里的getProp需要一次性接受2个参数,我们现在实现一个柯里化的getProp函数,让它每次接受一个参数:

const curryGetProp = (key) => (obj) => obj[key];
复制代码

下面是2个函数的不同调用方式

const user = {
    name: 'alex',
    age: 22,
    sex: 'boy',
};

// 没有柯里化的getProp
const name = getProp(user, 'name');

// 柯里化的curryGetProp
const name2 = curryGetProp('name')(obj);
复制代码

柯里化后curryGetProp需要调用两次,总共分别传递2个参数才能获取最终结果,但是我们并不一定要一次性调用2次,我们可以分开调用:

//连续调用时的情况
const name = curryGetProp('name')(user);
const age = curryGetProp('age')(user);
const sex = curryGetProp('sex')(user);

//分2次调用,中间保存到一个变量
const getName = curryGetProp('name');
const name = getName(user);

const getAge = curryGetProp('age');
const age = getAge(user);

const getSex = curryGetProp('sex');
const sex = getSex(user);

复制代码

你可以发现我们把原来必须一次性处理的步骤分成了2个小部分,而且我们可以更灵活地组合:

// 生成一个获取对象name的函数
const getName = curryGetProp('name');
// 生成一个获取对象age的函数
const getAge = curryGetProp('age');
// 生成一个获取对象sex的函数
const getSex = curryGetProp('sex');

// 获取user的name属性
const name = getName(user);
// 获取user的age属性
const age = getAge(user);
// 获取user的sex属性
const sex = getSex(user);

复制代码

这样做的好处:

// 可以很方便地复用curryGetProp添加获取其他属性的函数
const getHobby = curryGetProp('hobby');
const getWeight = curryGetProp('weight');

// 可以获取不同对象(user1,user2,user3)的相同属性('name')
const getName = curryGetProp('name');

const name1 = getName(user1);
const name2 = getName(user2);
const name3 = getName(user3);

// 可以很方便和其他高阶函数结合,如map
const userList = [
{
    name: 'alex',
    age: 22,
},
{
    name: 'irina',
    age: 18,
},
];
const getName = curryGetProp('name');
const nameList = userList.map(getName);
console.log(nameList); // ['alex', 'irina']

// 可以和pipe结合,配合上面我们提到的其他函数
const getName = curryGetProp('name');
const changeName = pipe(
    getName,
    capitalizeFirst,
    addHello
);

复制代码

使用柯里化,你可以有更多的方式去拆分和组合你的函数,你也有更多的选择去组织和复用你的代码。

部分应用(partial application)

上面getProp就是一种部分应用,我们预先传递了部分参数,然后再需要时复用它,你可以看到部分应用其实是和柯里化结合使用的,这里,我们再换种方式,首先重新来看看上面的curryGetProp。

const curryGetProp = (key) => (obj) => obj[key];
复制代码

在这里,第一次调用函数时首先传递了key,然后第二次调用时再传递的obj。

这里可以看作我们是在部分应用key,我们先传递一个key,过后传递不同的obj来获取相同的key的属性。

// 接受不同obj(user1,user2,user3),获取相同属性('name')
const getName = getProp('name');

// 这里分别获取了user1,user2,user3里面的name属性
const name1 = getName(user1);
const name2 = getName(user2);
const name3 = getName(user3);
复制代码

现在我们反过来,我们部分应用obj,然后传递不同的key值获取不同的属性。

const getPropFrom = (obj) => (key) => obj[key];

// 接受相同obj(user1),获取不同属性('name','age','sex')
const getPropFromUser1 = getPropFrom(user1);

const user1Name = getPropFromUser1('name');
const user1Age = getPropFromUser1('age');
const user1Sex = getPropFromUser1('sex');
复制代码

同样地,getPropFromUser1也可以和其他函数结合:

// 和map结合
const user1 = {
    name: 'alex',
    age: 22,
    sex: 'boy',
};
const keyList = ['name', 'age', 'sex'];

const getPropFrom = (obj) => (key) => obj[key];
const getPropFromUser1 = getPropFrom(user1);
const user1Info = keyList.map(getPropFromUser1);

console.log(user1Info); // ['alex', '22', 'boy']

// 和pipe结合
const user1 = {
    name: 'alex',
    age: 22,
    sex: 'boy',
};
const getPropFrom = (obj) => (key) => obj[key];
const getPropFromUser1 = getPropFrom(user1);
const changeName = pipe(
    getPropFromUser1,
    capitalizeFirst,
    addHello
);

const result = changeName('name');
console.log(result); // 'Hello, Alex'
复制代码

6、纯函数与函数缓存。

上面提到过,给纯函数相同的参数,总会获得相同的结果,如果有一个消耗比较大的纯函数,那么如果我们连续调用几次都传递相同的参数,我们可以缓存下第一次调用后的结果,后面每次调用都直接返回缓存的数据。

/*
** memoize 帮助函数接受一个函数,返回一个具有缓存功能的函数。
** Function -> Function
*/
const memoize = (fn) => {
  // 用于缓存数据
  let cache = {};
  // 缓存Array原型链上slice方法
  const slice = Array.prototype.slice;

  return function() {
    // 获取函数的所有参数并放在一个数组里面。
    const args = slice.call(arguments);
    // 转换成字符串
    const str = JSON.stringify(args);
    // 检测是否已经有缓存了,有的话直接返回缓存的数据,否则调用函数获取
    cache[str] = cache[str] || fn.apply(fn, arguments);
    //返回数据
    return cache[str];
  }
}

// hugeArray是一个非常大的数组,真的很大。
const hugeArray = [1,2,3, ...];

/*
**翻倍一个数字数组里面的数字
** Array -> Array
*/
const doubleHugeArray = (numberArray) => {
  return numberArray.map(number => number * 2)
}

// 这里3次调用每次都会重新遍历数组
const result1 = doubleHugeArray(hugeArray);
const result2 = doubleHugeArray(hugeArray);
const result3 = doubleHugeArray(hugeArray);

const memoizedDoubleHugeArray = memoize(doubleHugeArray);

//这里只有第一次会遍历,后面2次调用都是直接返回缓存的结果
const result1 = memoizedDoubleHugeArray(hugeArray);
const result2 = memoizedDoubleHugeArray(hugeArray);
const result3 = memoizedDoubleHugeArray(hugeArray);
复制代码

下面是一个结合了上面技巧的例子,注意这个例子为了演示作用,刻意使用了函数式编程方式,真实情况中可根据可读性等适当调整,例子中使用了lodash类库的一些现成函数:

// 获取test数据里面的answer为'是'的所有元素的id,用逗号拼接成字符串。
var test = [
  [
    {
      answer: '是',
      id: 1,
    },
    {
      answer: '是',
      id: 2,
    },
    {
      answer: '否',
      id: 3,
    },
  ],
    [
    {
      answer: '是',
      id: 4
    },
    {
      answer: '否',
      id: 5
    },
    {
      answer: '否',
      id: 6
    },
  ]
];

// 接受一个Object,检测该对象Object.answer属性是否等于“是”。
// Object -> Boolean
const getAnswerYes = (item) => (_.get(item,'answer') === '是');

// 接受一个Object数组,返回Object answer属性为“是”的所有元素组成的一个新数组
// [Object] -> [Object]
const filterAnswerYes = _.partialRight(_.filter, getAnswerYes);

// 接受一个Object,返回Object.id
// { id: value} -> value | undefined
const getIdProp = _.property('id');

// 接受一个Object数组, 返回每个Object的Id组成的一个新数组
// [Object] -> [String | Number]
const mapIdProp = _.partialRight(_.map, getIdProp);

// 接受一个包含id的数组[id], 用“,”拼接成字符串,返回拼接后的字符串。
// [id] -> String
const joinId = _.partialRight(_.join, ',');

// 这个组合函数用于把数据转化成所需结果
// [a] -> String
const getSelectedIdString = _.flow([
  // 展开数据成一维数组
  _.flattenDeep,
  // 过滤出选项为“是”的元素组成的数组
  filterAnswerYes,
  // 获取所有元素中的id组成的数组
  mapIdProp,
  // 将所有id用逗号连接成字符串
  joinId
]);

var result = getSelectedIdString(test);
console.log(result); // "1,2,4"

复制代码

一些可能对你有用的思考:

当我写这篇文章时,我是想表达什么?

首先,你可以完全不用甚至不认同上面的观点和方式,也许在某些场景下上面的观点并不是好的方式,甚至是比较坏的方式,你的观点应该由你自己做决定。这里我只是想说下我自己的观点:

在我看来,学习函数式编程最大的好处反而不是你掌握的关于函数式编程技巧本身,而是在学习过程中的思考过程。

比如说用类似map,foreach的方式替换for循环,重点是哪种方式更好吗?在我看来不是,重点是你在用map等高阶函数替换for循环过程中,你其实是在把一些常用功能抽象整理出来,而抽象这个能力是很重要的,因为在处理越来越复杂的事物中,你没办法确保你总能从最开始一步一步做到结尾,这个时候你需要把一些细小的思想抽象成一个整体,然后在以后需要时候再把各个整体抽象成更大的整体,不用每次都从零开始考虑细节,而这种能力,在你用其他编程范式如面向对象时也是很重要的。 就好像数学一样,你肯定不会每次算直角三角形斜边都想自己证明一下勾股定理。

而对于拆分和组合函数,比起你不思考就直接编写代码,在拆分和组合函数过程会强迫你先思考,思考各个事物之间的联系,这个能力也是很重要的,在现实中,错误的判断了事物之间的联系可能会导致严重的后果,就像上了不该上的车,等了不该等的人。在编程中也是一样,准确分析判断事物的联系在我看来也是比较重要的,我需要把这个逻辑拆分成几个小函数吗?我应该根据什么依据来拆分?不拆分会对以后修改造成严重影响吗?这种解决问题前的分析思考是很重要的。

更重要的是,我发现学习函数式编程能激发你的思考,会带给你一些超越编程本身的东西,比如你了解了函数组合,你知道每个中间的组合函数都必须是一元函数,因为每个函数只能返回一个参数给下一个函数,那么你可能会想如果遇见多元函数了怎么办?然后你发现了函数柯里化和部分应用好像可以解决这个问题,于是你可能就会去了解它们,同样地,你在手动柯里化一个函数时,你又可能会想,难道我需要每次都手动柯里化吗?能不能实现一个自动柯里化任意参数函数的帮助函数?最后你可能会手动去实现它,这个过程中你可能又会产生新的想法,发现重点没有?顺着这种方式你会思考很多,也会收获很多,而这种思考的过程,也会对你以后学习其他东西有很大帮助。

随便举几个可以思考的例子:

1、柯里化一个函数中,我们需要保存对每个参数的引用,并在最后时刻使用它们,那么这个引用是怎么保存的?然后你思考后可能就发现是因为我们返回的函数可以读取到定义它时外部函数的变量,你再一观察思考,这好像不就是困扰我比较久的闭包知识点吗?如果不使用闭包,能用其他方式保存变量吗?

2、上面我们提到了纯函数和缓存,你知道我们可以缓存一个纯函数,你也学习了函数组合,那么你想一下,一堆纯函数组合的函数是不是也是纯函数?那么这个组合出来的纯函数是不是也可以被缓存?假如你组合了一堆函数,使用缓存后的组合函数关键时刻能不能帮你节省性能?

你可以发现,其实从一些看似简单的东西,如果你多思考,就能收获很多东西。到这里,我不知道你有没有发现一些比编程本身更重要的东西,思考和不要自我设限

你可能还没有意识到,在你太偏向于某种固定方式时,你就已经在给自己设限了,如果太过于在于某件事物的坏处,你就很难从它上面获取到有利的东西,如果你太偏向于函数式,那么你可能比较直接地否定一些面向对象的技巧,反过来也一样,你有没有这样想过,我喜欢函数式编程,如果某天我用的语言不支持函数式编程怎么办?为什么不两种方式都了解一下?或者其他更多的方式,甚至你自己也可以试着总结出你自己的方式,说不定就是下一个流行的编程范式。

生活给了我们很多限制,编程语言给了我们很多限制,希望大家不要再给自己设限,保留一种开放的心态,求同存异,共谋发展。

加油哥么!

关注下面的标签,发现更多相似文章
评论

查看更多 >