JavaScript函数式编程:Ramda在数独开发中的使用问题咨询
实操背景
我在读完大量JavaScript函数式编程相关内容、看完JS领域FP技术分享后,开始动手实操练习。我清楚接下来要实现的是非常基础的功能,还是写出了第一版实现:
const easy = [ 0,0,4,6,7,2,0,0,0, 5,0,0,8,0,0,0,9,6, 0,6,3,0,4,0,0,0,8, 3,8,2,1,0,0,9,6,0, 4,7,5,0,0,0,1,0,0, 9,1,0,2,0,4,5,0,0, 0,0,0,0,0,0,0,2,9, 0,0,1,0,0,0,7,4,3, 2,0,0,0,6,3,8,0,1 ]; const expandSudoku = R.map( R.ifElse( R.equals(0), R.always(R.range(1,10)); R.of ) ); console.log(expandSudoku(easy));
这段逻辑的目标是把输入的一维数独数组转换为候选值嵌套数组:
- 值为0的单元格,转换为
[1,2,3,4,5,6,7,8,9](代表所有可能的候选数) - 值为非0数字n的单元格,转换为
[n](代表已确定的固定值)
关于R.equals的TypeScript类型报错问题
我原本觉得下面这种带匿名包装函数的写法,和上面的基础实现逻辑完全等价:
const easy = [ 0,0,4,6,7,2,0,0,0, 5,0,0,8,0,0,0,9,6, 0,6,3,0,4,0,0,0,8, 3,8,2,1,0,0,9,6,0, 4,7,5,0,0,0,1,0,0, 9,1,0,2,0,4,5,0,0, 0,0,0,0,0,0,0,2,9, 0,0,1,0,0,0,7,4,3, 2,0,0,0,6,3,8,0,1 ]; const expandSudoku = R.map(v => R.ifElse( x => R.equals(0)(x), x => R.range(1,10), x => R.of(x) )(v) ); console.log(expandSudoku(easy));
按照Ramda的柯里化设计约定,R.equals(0)(x)和R.equals(0, x)的运行结果应该完全等价。但把前者替换成后者时,TypeScript会抛出报错:
Argument of type '(x: any) => (a: any) => boolean' is not assignable to parameter of type 'Pred'
但编译输出的JavaScript代码实际运行完全正常,这是不是纯粹的TypeScript类型定义层面的问题?
问题解答
这个报错完全是Ramda TypeScript类型定义的推导偏差导致的,和运行逻辑无关:
- 从运行逻辑上讲,
R.equals(0, x)和R.equals(0)(x)确实结果完全一致,没有任何区别。 - 从类型推导的角度讲,Ramda给
equals设计的类型签名是“传入单参数时返回柯里化谓词,传入双参数时直接返回布尔值”。但因为你把这个表达式写在匿名箭头函数的形参位置,TS的类型推导会优先把双参数的写法识别为“先传入第一个参数,返回待接收第二个参数的柯里化函数”,而不是“一次性传入两个参数返回布尔值”,因此才会出现类型不匹配的报错。 - 实际开发中根本不需要在
R.equals(0)外面包一层箭头函数,直接把R.equals(0)当谓词传入R.ifElse即可,既不会有运行问题,也不会触发TS类型报错。
关于自定义参数透传辅助函数的合理性问题
我自己写了一个工具函数:const pass2nd = R.curry((fn, arg1, arg2) => fn(arg2));
并在带索引的数独单元格映射逻辑中使用:
const indexToCoord = R.applySpec({ x: R.modulo(R.__,9), y: R.compose(Math.floor, R.divide(R.__,9)) }); const makeCell = R.applySpec({ coord: pass2nd(indexToCoord), // 只取map回调传入的第二个参数(索引) options: R.unary(R.ifElse( // 只取map回调传入的第一个参数(单元格值) R.equals(0), R.always(R.range(1,10)), R.of )) }); const mapIndexed = R.addIndex(R.map); const expandSudoku = mapIndexed(makeCell); console.log(expandSudoku(easy));
这里的疑问是:Ramda是否内置了类似的、用于透传指定位置参数的辅助函数?这种自定义实现算不算代码坏味道?
问题解答
- Ramda 已经内置了完全通用的参数位置提取函数,就是
R.nthArg(n)。这个函数会接收任意数量的传入参数,直接返回索引位置为n的参数值。你之前自定义的pass2nd完全可以用原生能力实现:只需要写成coord: R.pipe(R.nthArg(1), indexToCoord)即可,不需要自己手写柯里化的透传函数。 - 这种自定义实现算不算坏味道,要看场景:
- 如果只在当前这一个地方临时用一次,写自定义函数没有性能问题,也不算严重的坏味道。
- 但从可读性和维护性的角度,因为Ramda已经提供了通用的
nthArg,熟悉Ramda的开发者看到这个API就能立刻理解逻辑,而看到自定义的pass2nd还要停下来判断功能,反而会增加认知成本。因此完全可以替换成内置API,不需要重复造轮子。
关于函数组合的使用边界问题
现在不清楚函数组合在什么场景下会变成过度设计。比如实现索引到坐标的转换逻辑时,对比两种写法:
第一种是Ramda组合式写法:
const indexToCoord = R.applySpec({ x: R.modulo(R.__,9), y: R.compose(Math.floor, R.divide(R.__,9)) });
第二种是原生JavaScript普通箭头函数写法:
const indexToCoord = v => ({ x: v % 9, y: Math.floor(v/9) });
这两种写法哪种是更通用的推荐写法?
问题解答
两种写法没有绝对的优劣,核心判断标准是可读性和复用需求,而不是“是否符合FP写法”:
- 如果这个
indexToCoord函数只在当前文件内使用一次,逻辑非常简单,原生箭头函数的写法反而更推荐:因为它没有Ramda特有的占位符、组合符语法门槛,没有接触过Ramda的开发者也能一眼看懂逻辑,不需要额外的学习成本。 - 如果后续需要把这个函数拆出来跨模块复用,或者要和其他Ramda管道函数拼接成更复杂的逻辑,那Ramda的组合式写法会更灵活:因为所有内部的转换逻辑都是柯里化的,不需要调整函数签名就能随时拼接复用。
- 函数组合属于过度设计的判断标准非常简单:如果为了使用组合式写法,把本来2行就能写完的简单逻辑,拆成了5、6层Ramda函数的嵌套拼接,还需要加注释才能让人看懂执行流程,那就是过度设计。Ramda的核心设计目标是减少样板代码,从来不是为了写FP而写FP。
内容的提问来源于stack exchange,提问作者Mrk Sef

