15

我已经在一个名为Ramda的 Javascript FP 库上工作了一段时间,但我在命名事物时遇到了一些小问题。(你听过那句老话,对吧?“计算机科学中只有两个难题:缓存失效、命名和非一个错误。”)

在这个库中,(几乎)每个具有多个参数的函数都会自动进行柯里化。这适用于大多数用例。但是一些非交换二元运算符的函数存在一些问题。问题在于,英文名称往往暗示的含义与应用 currying 时发生的情况不同。例如,

var div10 = divide(10);

听上去应该是一个把参数除以10的函数。但实际上它把它的参数除以10,看定义就很清楚了:

var divide = curry(function(a, b) {
    return a / b;
});

所以改为预期的:

div10(50); //=> 5 // NO!!

事实上,你得到

div10(50); //=> 0.2 // Correct, but surprising!

我们通过记录与人们可能期望的差异并创建divideBy, which is justflip(divide)subtractN, which is 来处理这个问题flip(subtract)。但是我们还没有找到一个很好的等价函数,例如lt

R.lt = curry(function(a, b) { 
    return a < b;
});

或其表亲lte, gt, 和gte.

我自己的直觉是

map(lt(5), [8, 6, 7, 5, 3, 0, 9]); 
//=> [false, false, false, false, true, true, false]

但当然,它实际上返回

//=> [true, true, true, false, false, false, true]

所以我想为它的同类做同样的文档和点到备用名称例程lt。但是一直找不到好名字。唯一真正的候选人是ltVal,并且在使用两个参数调用时都不起作用。我们确实讨论了这个问题,但没有很好的结论。

其他人是否处理过这个问题并提出了好的解决方案?或者即使没有,对于这些功能的翻转版本的名称有什么好的建议吗?


更新

有人建议将其关闭,因为“不清楚你在问什么”,我想这个问题在解释中确实有点丢失了。简单的问题是:

什么是翻转版本的好,直观的名称lt

4

2 回答 2

28

首先,感谢您正在维护的函数式编程库。我一直想自己写一篇,但一直没有时间去做。

考虑到您正在编写函数式编程库这一事实,我假设您了解 Haskell。在 Haskell 中,我们有函数和运算符。函数总是前缀。运算符总是中缀。

Haskell 中的函数可以使用反引号转换为运算符。例如div 6 3可以写成6 `div` 3。类似地,可以使用括号将运算符转换为函数。例如2 < 3可以写成(<) 2 3

运算符也可以使用部分来部分应用。有两种类型的部分:左侧部分(例如(2 <)(6 `div`))和右侧部分(例如(< 3)(`div` 3))。左侧部分翻译如下:(2 <)变为(<) 2。右部分:(< 3)变为flip (<) 3.


在 JavaScript 中,我们只有函数。在 JavaScript 中创建运算符没有“好”的方法。您可以编写类似 的代码(2).lt(3),但在我看来这是不礼貌的,我强烈建议不要编写这样的代码。

因此,我们可以将普通函数和运算符编写为函数:

div(6, 3) // normal function: div 6 3
lt(2, 3) // operator as a function: (<) 2 3

在 JavaScript 中编写和实现中缀运算符是一件痛苦的事情。因此,我们不会有以下内容:

(6).div(3) // function as an operator: 6 `div` 3
(2).lt(3) // normal operator: 2 < 3

但是部分很重要。让我们从正确的部分开始:

div(3) // right section: (`div` 3)
lt(3) // right section: (< 3)

当我看到时,div(3)我希望它是一个正确的部分(即它应该表现为(`div` 3))。因此,根据最小惊讶原则,这是它应该实现的方式。

现在是左部分的问题。如果div(3)是右侧部分,那么左侧部分应该是什么样子?在我的拙见中,它应该是这样的:

div(6, _) // left section: (6 `div`)
lt(2, _) // left section: (2 <)

对我来说,这读作“6除以某物”“2比某物小吗?” 我更喜欢这种方式,因为它是明确的。根据The Zen of Python 的说法,“显式胜于隐式”。


那么这对现有代码有何影响?例如,考虑filter函数。要过滤列表中的奇数,我们将编写filter(odd, list). 对于这样的功能,柯里化是否按预期工作?例如,我们将如何编写一个filterOdd函数?

var filterOdd = filter(odd);    // expected solution
var filterOdd = filter(odd, _); // left section, astonished?

根据最小惊讶的原则,它应该是简单的filter(odd)。该filter函数不打算用作运算符。因此,不应强迫程序员将其用作左侧部分。函数和“函数运算符”之间应该有明显的区别。

幸运的是,区分函数和函数运算符非常直观。例如,filter函数显然不是函数运算符:

filter odd list -- filter the odd numbers from the list; makes sense
odd `filter` list -- odd filter of list? huh?

另一方面,该elem函数显然是一个函数运算符:

list `elem` n -- element n of the list; makes sense
elem list n -- element list, n? huh?

重要的是要注意这种区别是唯一可能的,因为函数和函数运算符是互斥的。按理说,给定一个函数,它可能是普通函数,也可能是函数运算符,但不能同时是两者。


有趣的是,给定一个二元函数,如果flip它的参数是二元运算符,反之亦然。例如,考虑 和 的翻转变filterelem

list `filter` odd -- now filter makes sense an an operator
elem n list -- now elem makes sense as a function

事实上,如果 n 大于 1,这可以推广到任何 n 元函数。你看,每个函数都有一个主参数。简单地说,对于一元函数,这种区别是无关紧要的。然而,对于非一元函数,这种区别很重要。

  1. 如果函数的主要参数出现在参数列表的末尾,则该函数是一个普通函数(例如filter odd listlist主要参数在哪里)。将主要参数放在列表末尾是函数组合所必需的。
  2. 如果函数的主要参数出现在参数列表的开头,则该函数是一个函数运算符(例如list `elem` nlist主要参数在哪里)。
  3. 运算符类似于 OOP 中的方法,主要参数类似于方法的对象。例如list `elem` n,将按照list.elem(n)OOP 编写。OOP 中的链接方法类似于 FP [1]中的函数组合链。
  4. 函数的主要参数只能在参数列表的开头或结尾。在其他任何地方都没有意义。这个属性对于二元函数来说是完全正确的。因此,翻转二进制函数使它们成为运算符,反之亦然。
  5. 其余参数与函数一起形成一个不可分割的原子,称为参数列表的主干。例如在filter odd list词干是filter odd。在list `elem` n茎是(`elem` n)
  6. 词干的顺序和元素必须保持不变,表达式才有意义。这就是为什么odd `filter` list而且elem list n没有任何意义。然而list `filter` odd并且elem n list有意义,因为词干没有改变。

回到主题,因为函数和函数运算符是互斥的,你可以简单地对待函数运算符,而不是对待普通函数。

我们希望运营商具有以下行为:

div(6, 3) // normal operator: 6 `div` 3
div(6, _) // left section: (6 `div`)
div(3)    // right section: (`div` 3)

我们要定义运算符如下:

var div = op(function (a, b) {
    return a / b;
});

函数的定义op很简单:

function op(f) {
    var length = f.length, _; // we want underscore to be undefined
    if (length < 2) throw new Error("Expected binary function.");
    var left = R.curry(f), right = R.curry(R.flip(f));

    return function (a, b) {
        switch (arguments.length) {
        case 0: throw new Error("No arguments.");
        case 1: return right(a);
        case 2: if (b === _) return left(a);
        default: return left.apply(null, arguments);
        }
    };
}

op函数类似于使用反引号将函数转换为 Haskell 中的运算符。因此,您可以将其添加为 Ramda 的标准库函数。在文档中还提到,运算符的主要参数应该是第一个参数(即它应该看起来像 OOP,而不是 FP)。


[1]顺便说一句,如果 Ramda 允许您像在常规 JavaScript 中链接方法一样编写函数(例如foo(a, b).bar(c),而不是compose(bar(c), foo(a, b))),那将是很棒的。这很困难,但可行。

于 2014-09-08T09:17:11.910 回答
2

我们都知道编程中的命名是一件严肃的事情,尤其是涉及到柯里化形式的函数时。正如 Aadit 在他的回复中所做的那样,使用程序化方法处理此问题是一种有效的解决方案。但是,我看到他的实现存在两个问题:

  • 它在 Javascript 中引入了带有左/右部分的函数运算符,这不是语言的一部分
  • 它需要一个可怕的占位符或undefined黑客来实现

Javascript 没有柯里化运算符,因此没有左右部分。一个惯用的 Javascript 解决方案应该考虑到这一点。

问题的原因

柯里化函数没有元数的概念,因为每个函数调用都需要一个参数。您可以部分或完全应用 curried 函数而无需任何帮助:

const add = y => x => x + y;

const add2 = add(2); // partial application

add(2)(3); // complete application

通常函数的最后一个参数是它的主要参数,因为它是通过函数组合传递的(类似于允许方法链接的对象)。因此,当您部分应用函数时,您希望传递其初始参数:

const comp = f => g => x => f(g(x));

const map = f => xs => xs.map(x => f(x));

const inc = x => x + 1;

const sqr = x => x * x;


comp(map(inc)) (map(sqr)) ([1,2,3]); // [2,5,10]

运算符函数在这方面是特殊的。它们是二进制函数,将它们的两个参数简化为一个返回值。由于并非每个运算符都是可交换的(a - b !== b - a),因此参数顺序很重要。因此,运算符函数没有主要参数。但是人们习惯于根据应用程序的类型以某种方式阅读表达式:

const concat = y => xs => xs.concat(y);

const sub = y => x => x - y;


// partial application:

const concat4 = concat(4);

const sub4 = sub(4);

concat4([1,2,3]); // [1,2,3,4] - OK

sub4(3); // -1 - OK


// complete application:

concat([1,2,3]) (4); // [4,1,2,3] - ouch!

sub(4) (3); // -1 - ouch!

我们定义concatsub使用翻转参数,以便部分应用程序按预期工作。这显然不适用于完整的应用程序。

手动解决方案

const flip = f => y => x => f(x) (y);

const concat_ = flip(concat);

const sub_ = flip(sub);


concat_(xs) (4); // [1,2,3,4] - OK

sub_(4) (3); // 1 - OK

concat_sub_对应于 Haskell 中的左侧部分。请注意,函数运算符喜欢addlt不需要左部分版本,因为前者是可交换的,而后者是谓词,它们具有逻辑对应物:

const comp2 = f => g => x => y => f(g(x) (y));

const map = f => xs => xs.map(x => f(x));

const flip = f => y => x => f(x) (y);

const not = x => !x;

const notf2 = comp2(not);


const lt = y => x => x < y;

const gt = flip(lt);

const lte = notf2(gt);

const gte = notf2(lt);


map(lt(5)) ([8, 6, 7, 5, 3, 0, 9]);
// [false, false, false, false, true, true, false]

map(gte(5)) ([8, 6, 7, 5, 3, 0, 9]);
// [true, true, true, true, false, false, true]

结论

我们应该解决这个命名问题,而不是使用命名约定,然后使用以非惯用方式扩展 Javascript 的编程解决方案。命名约定并不理想……嗯,就像 Javascript 一样。

于 2016-08-26T13:08:13.450 回答