JavaScript内核与高级编程之:`JavaScript`的`Ramda.js`:其`point-free`编程风格。

各位靓仔靓女,早上好/下午好/晚上好!我是你们的老朋友,今天咱们聊点儿高级的—— Ramda.js 的 point-free 编程风格。

准备好了吗?咱们发车!

第一部分:什么是 Point-Free?别告诉我你不知道!

Point-free,也叫 Tacit programming,中文翻译过来就是“无点编程”。 听起来玄乎,其实很简单。

核心思想: 函数定义不显式地指定参数。 换句话说,你写的函数里,看不到 x => x + 1 这种显式的参数定义,看到的都是函数组合。

举个栗子:

假设我们要写一个函数,把一个数字加 1,然后再乘以 2。

  • 传统写法:

    const addOneThenMultiplyByTwo = (x) => {
      const addOne = x + 1;
      return addOne * 2;
    };
    
    console.log(addOneThenMultiplyByTwo(3)); // 输出 8

    这里,我们看到了 x 这个参数,这就是“点”。

  • Point-free 写法:

    import * as R from 'ramda';
    
    const addOneThenMultiplyByTwo = R.pipe(
      R.add(1),
      R.multiply(2)
    );
    
    console.log(addOneThenMultiplyByTwo(3)); // 输出 8

    这里,我们完全看不到 x 这个参数。R.add(1) 并没有接收参数,而是返回了一个等待接收参数的函数R.pipe 负责把这些函数串联起来,形成最终的函数。

看到了吗? 这就是 Point-Free 的精髓:化简为繁,哦不,是化繁为简! 把一堆琐碎的、显式的参数传递隐藏起来,专注于函数的组合和逻辑。

第二部分:为什么要用 Point-Free? 难道是为了装X?

当然不是! 虽然看起来有点“装”,但 Point-Free 编程风格有它的实际好处:

  1. 可读性更高: 当你习惯了这种风格后,你会发现代码更简洁、更易读。 代码不再被显式的参数传递所干扰,而是清晰地表达了函数的组合逻辑。

  2. 可维护性更好: Point-Free 代码更模块化,每个函数只做一件事情。 这使得代码更容易测试、更容易重用、更容易维护。

  3. 更容易进行函数组合: Point-Free 代码天生就适合函数组合。 你可以像搭积木一样,把小的函数组合成大的函数。

  4. 减少错误: 减少了显式的参数传递,也就减少了因参数错误而导致的 bug。

用表格总结一下:

特性 传统写法 Point-Free 写法
参数 显式参数传递 隐式参数传递
可读性 一般 较高
可维护性 一般 较高
函数组合 相对复杂 简单
错误 容易因参数错误导致 bug 减少参数错误导致的 bug

第三部分:Ramda.js:Point-Free 的好基友

Ramda.js 是一个专门为了函数式编程而设计的 JavaScript 库。 它提供了大量的函数,可以帮助你轻松地编写 Point-Free 代码。

Ramda 的特点:

  • 自动柯里化(Automatic Currying): Ramda 的所有函数都支持自动柯里化。 柯里化就是把一个多参数的函数转换成一系列单参数的函数。 这使得函数更容易进行组合。
  • 数据不变性(Data Immutability): Ramda 的所有函数都不会修改原始数据,而是返回一个新的数据。 这使得代码更安全、更可预测。
  • 函数优先,数据后置: Ramda 的函数设计遵循“函数优先,数据后置”的原则。 也就是说,函数总是先接收参数,最后才接收数据。 这使得函数更容易进行 Point-Free 编程。

常用的 Ramda 函数:

函数 描述 例子
R.add 加法 R.add(1)(2) // 返回 3
R.multiply 乘法 R.multiply(2)(3) // 返回 6
R.divide 除法 R.divide(10)(2) // 返回 5
R.subtract 减法 R.subtract(5)(2) // 返回 3
R.map 遍历数组,对每个元素应用函数 R.map(R.add(1), [1, 2, 3]) // 返回 [2, 3, 4]
R.filter 过滤数组,保留符合条件的元素 R.filter(R.gt(2), [1, 2, 3, 4]) // 返回 [3, 4] ( R.gt(2) 返回一个函数,判断是否大于 2 )
R.reduce 归约数组,将数组中的元素聚合成一个值 R.reduce(R.add, 0, [1, 2, 3]) // 返回 6
R.pipe 从左到右依次执行函数,将前一个函数的返回值作为后一个函数的参数 R.pipe(R.add(1), R.multiply(2))(3) // 返回 8
R.compose 从右到左依次执行函数,将后一个函数的返回值作为前一个函数的参数 (与 R.pipe 相反) R.compose(R.multiply(2), R.add(1))(3) // 返回 8
R.prop 获取对象的属性值 R.prop('name', { name: '张三', age: 18 }) // 返回 ‘张三’
R.path 获取对象深层嵌套的属性值 R.path(['address', 'city'], { name: '张三', address: { city: '北京' } }) // 返回 ‘北京’
R.pluck 从对象数组中提取指定属性的值,返回一个新数组 R.pluck('name', [{ name: '张三', age: 18 }, { name: '李四', age: 20 }]) // 返回 [‘张三’, ‘李四’]
R.groupBy 根据指定条件对数组进行分组,返回一个对象,对象的 key 是分组的条件, value 是符合条件的元素组成的数组 R.groupBy(R.prop('age'), [{ name: '张三', age: 18 }, { name: '李四', age: 20 }, { name: '王五', age: 18 }]) // 返回 { ’18’: [ { name: ‘张三’, age: 18 }, { name: ‘王五’, age: 18 } ], ’20’: [ { name: ‘李四’, age: 20 } ] }
R.sortBy 根据指定条件对数组进行排序,返回一个新数组 R.sortBy(R.prop('age'), [{ name: '张三', age: 18 }, { name: '李四', age: 20 }, { name: '王五', age: 18 }]) // 返回 [ { name: ‘张三’, age: 18 }, { name: ‘王五’, age: 18 }, { name: ‘李四’, age: 20 } ]
R.identity 返回接收到的参数本身 R.identity(123) // 返回 123
R.always 返回一个函数,该函数总是返回指定的值 R.always('hello')() // 返回 ‘hello’
R.T 返回 true R.T() // 返回 true
R.F 返回 false R.F() // 返回 false
R.not 对布尔值取反 R.not(true) // 返回 false
R.isNil 判断一个值是否为 nullundefined R.isNil(null) // 返回 true, R.isNil(undefined) // 返回 true, R.isNil(0) // 返回 false
R.isEmpty 判断一个值是否为空,适用于字符串、数组、对象等 R.isEmpty('') // 返回 true, R.isEmpty([]) // 返回 true, R.isEmpty({}) // 返回 true
R.equals 判断两个值是否相等(深度比较) R.equals(1, 1) // 返回 true, R.equals({a: 1}, {a: 1}) // 返回 true
R.clone 深拷贝一个值 `const obj = {a: 1}; const clonedObj = R.clone(obj); clonedObj.a = 2; console.log(obj.a); // 输出 1 (因为是深拷贝)
R.mergeDeepLeft 深度合并两个对象,左边的对象优先 R.mergeDeepLeft({a: 1, b: {c: 2}}, {a: 2, b: {d: 3}}) // 返回 {a: 1, b: {c: 2, d: 3}}
R.mergeDeepRight 深度合并两个对象,右边的对象优先 R.mergeDeepRight({a: 1, b: {c: 2}}, {a: 2, b: {d: 3}}) // 返回 {a: 2, b: {c: 2, d: 3}}
R.omit 从对象中移除指定的属性,返回一个新对象 R.omit(['age'], { name: '张三', age: 18 }) // 返回 { name: ‘张三’ }
R.pick 从对象中选取指定的属性,返回一个新对象 R.pick(['name'], { name: '张三', age: 18 }) // 返回 { name: ‘张三’ }
R.tap 执行一个函数,并将原始值作为参数传递给该函数,然后返回原始值 (用于调试) R.tap(console.log)(123) // 输出 123, 并返回 123
R.tryCatch 尝试执行一个函数,如果发生错误,则执行另一个函数 R.tryCatch(R.divide(10), R.always(0))(0) // 如果除数为 0,则返回 0
R.unless 如果条件不成立,则执行一个函数 R.unless(R.isNil, R.add(1))(null) // 返回 null, R.unless(R.isNil, R.add(1))(1) // 返回 2
R.when 如果条件成立,则执行一个函数 R.when(R.isNil, R.always(0))(null) // 返回 0, R.when(R.isNil, R.always(0))(1) // 返回 1
R.range 生成一个指定范围的数字数组 R.range(0, 5) // 返回 [0, 1, 2, 3, 4]
R.repeat 重复一个值指定次数,返回一个新数组 R.repeat('hello', 3) // 返回 [‘hello’, ‘hello’, ‘hello’]
R.times 执行一个函数指定次数,返回一个新数组,数组的元素是函数的返回值 R.times(R.identity, 5) // 返回 [0, 1, 2, 3, 4]
R.zip 将两个数组按照索引一一对应地组合成一个新数组,新数组的元素是包含两个元素的数组 R.zip([1, 2, 3], ['a', 'b', 'c']) // 返回 [[1, ‘a’], [2, ‘b’], [3, ‘c’]]
R.zipObj 将一个 key 数组和一个 value 数组组合成一个对象 R.zipObj(['name', 'age'], ['张三', 18]) // 返回 { name: ‘张三’, age: 18 }
R.zipWith 将两个数组按照索引一一对应地组合,并对每一对元素应用一个函数,返回一个新数组 R.zipWith(R.add, [1, 2, 3], [4, 5, 6]) // 返回 [5, 7, 9]

第四部分:实战演练: 让我们来写一些 Point-Free 代码

案例 1: 过滤出年龄大于 18 岁的人,并提取他们的姓名。

import * as R from 'ramda';

const users = [
  { name: '张三', age: 18 },
  { name: '李四', age: 20 },
  { name: '王五', age: 16 },
];

// 传统写法:
const getAdultNames = (users) => {
  const adults = users.filter(user => user.age > 18);
  return adults.map(user => user.name);
};

console.log(getAdultNames(users)); // 输出 ['李四']

// Point-Free 写法:
const getAdultNamesPointFree = R.pipe(
  R.filter(R.propSatisfies(R.gt(18), 'age')),
  R.pluck('name')
);

console.log(getAdultNamesPointFree(users)); // 输出 ['李四']

解释:

  • R.propSatisfies(R.gt(18), 'age')R.propSatisfies 接受一个函数和一个属性名, 返回一个函数。 这个返回的函数接收一个对象,并判断该对象的指定属性是否满足给定的函数。 在这里, R.gt(18) 返回一个函数,判断一个值是否大于 18。 R.propSatisfies(R.gt(18), 'age') 最终返回一个函数,判断一个对象的 age 属性是否大于 18。
  • R.pluck('name'): 从对象数组中提取 name 属性的值,返回一个新数组。
  • R.pipe: 把这两个函数串联起来。

案例 2: 计算数组中所有偶数的平方和。

import * as R from 'ramda';

const numbers = [1, 2, 3, 4, 5, 6];

// 传统写法:
const sumOfSquaresOfEvenNumbers = (numbers) => {
  const evenNumbers = numbers.filter(number => number % 2 === 0);
  const squares = evenNumbers.map(number => number * number);
  return squares.reduce((sum, square) => sum + square, 0);
};

console.log(sumOfSquaresOfEvenNumbers(numbers)); // 输出 56

// Point-Free 写法:
const sumOfSquaresOfEvenNumbersPointFree = R.pipe(
  R.filter(R.compose(R.equals(0), R.modulo(R.__, 2))),
  R.map(R.multiply(R.__, R.__)),
  R.reduce(R.add, 0)
);

console.log(sumOfSquaresOfEvenNumbersPointFree(numbers)); // 输出 56

解释:

  • R.compose(R.equals(0), R.modulo(R.__, 2)): 判断一个数是否为偶数。 R.modulo(R.__, 2) 计算一个数除以 2 的余数。 R.equals(0) 判断一个数是否等于 0。 R.compose 把这两个函数组合起来,形成一个判断一个数是否为偶数的函数。 R.__ 是 Ramda 的占位符,表示需要稍后传入的参数。
  • R.map(R.multiply(R.__, R.__)): 计算一个数的平方。 R.multiply(R.__, R.__) 计算一个数乘以它自身。
  • R.reduce(R.add, 0): 把数组中的所有数加起来。

案例 3: 将一个字符串数组转换为一个对象,对象的 key 是字符串, value 是字符串的长度。

import * as R from 'ramda';

const strings = ['hello', 'world', 'ramda'];

// 传统写法:
const stringLengthsToObject = (strings) => {
  const obj = {};
  strings.forEach(str => {
    obj[str] = str.length;
  });
  return obj;
};

console.log(stringLengthsToObject(strings)); // 输出 { hello: 5, world: 5, ramda: 5 }

// Point-Free 写法:
const stringLengthsToObjectPointFree = R.pipe(
  R.map(R.converge(R.pair, [R.identity, R.length])),
  R.fromPairs
);

console.log(stringLengthsToObjectPointFree(strings)); // 输出 { hello: 5, world: 5, ramda: 5 }

解释:

  • R.converge(R.pair, [R.identity, R.length])R.converge 接受一个合并函数和一个函数数组。 它会把每个函数应用到同一个输入值上,然后把结果传递给合并函数。 在这里, R.identity 返回输入值本身, R.length 返回字符串的长度。 R.pair 把两个值组合成一个数组。 所以, R.converge(R.pair, [R.identity, R.length]) 最终返回一个函数,它接受一个字符串,并返回一个包含字符串本身和字符串长度的数组。
  • R.fromPairs: 把一个包含键值对的数组转换为一个对象。

第五部分:Point-Free 的局限性: 别把它当成万金油!

虽然 Point-Free 编程风格有很多好处,但它也有一些局限性:

  1. 学习曲线陡峭: Point-Free 编程需要你熟悉 Ramda.js 等函数式编程库,并理解柯里化、函数组合等概念。 这需要一定的学习成本。

  2. 调试困难: Point-Free 代码通常比较简洁,但也比较抽象。 当出现 bug 时,可能难以追踪错误。

  3. 过度使用会降低可读性: 过度使用 Point-Free 可能会导致代码过于晦涩难懂。 有时候,显式的参数传递反而更清晰易懂。

记住: Point-Free 是一种工具,而不是目的。 你应该根据实际情况选择合适的编程风格。 不要为了 Point-Free 而 Point-Free。

什么时候不适合使用 Point-Free?

  • 当函数逻辑过于复杂时。
  • 当需要频繁访问外部变量时。
  • 当团队成员不熟悉函数式编程时。

第六部分:总结: Point-Free,让你的代码更优雅!

Point-Free 编程风格是一种优雅、简洁、可维护的编程方式。 通过 Ramda.js 等函数式编程库,你可以轻松地编写 Point-Free 代码。

但是,不要盲目追求 Point-Free。 你应该根据实际情况选择合适的编程风格。

希望今天的讲座对你有所帮助。 下次再见! 拜拜!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注