提案"Pipe operator (|>
) for JavaScript"(作者:J. S. Choi, James DiGioia, Ron Buckton 和 Tab Atkins)引入了一个新的操作符。这个操作符是一个从函数式编程中借来的想法,在很多情况下使应用函数更加方便。
这篇博文描述了管道操作符的工作原理以及它的使用情况(比你想象的要多!)。
两个相互竞争的建议
最初有两个相互竞争的管道操作符提案,灵感来自其他编程语言:
-
F#微软公司开发的 "C语言 "是一种函数式编程语言,其核心是基于OCaml的。这个管道操作符与咖喱函数配合得很好(我很快会解释那是什么)。
-
HackFacebook是--大致上--静态类型的PHP版本。这个管道操作符专注于库函数以外的语言特性。它包括对部分应用的句法支持。
后者的提议获胜。然而,如果你喜欢F#管道,有一个好消息:它的优点可能会在以后被添加到Hack管道中(细节将在后面解释)。
我们将从探索Hack管道开始。然后,我们将继续研究F#管道,并比较它与Hack管道的利弊。
Hack管道操作符
这是一个使用Hack管道运算符的例子|>
:
assert.equal(
'123.45' |> Number(%), 123.45
);
管道运算符|>
的左边是一个表达式,它被评估后成为特殊变量%
的值。我们可以在右手边使用该变量。The返回其右侧的评估结果。换句话说,前面的例子等同于:
assert.equal(
Number('123.45'), 123.45
);
下面的例子证明了%
真的像其他变量一样工作。
第一个用例
我们以后会看到更多的用例,但现在让我们快速看一下一个核心用例。考虑一下这些嵌套的函数调用。
const y = h(g(f(x)));
这种记号通常不能反映我们对计算步骤的思考方式。直观地说,我们会把它们描述为:
- 从值
x
开始。 - 然后对其应用
f()
。 - 然后将
g()
应用于结果。 - 然后将
h()
应用于结果。 - 然后将结果分配给
y
。
Hack管道操作符可以让我们更好地表达这种直觉。
const y = x |> f(%) |> g(%) |> h(%);
F#管道操作符
F#管道操作符与Hack管道操作符大致相似。然而,它没有特殊的变量%
。相反,它在其右侧期望一个函数,并将该函数应用于其左侧。因此,下面的两个表达式是等价的:
'123.45' |> Number
Number('123.45')
F#管道更擅长链式单参数(one-parameter)函数
下面的三个表述是等价的。
我们可以看到,在这种情况下,Hack pipe比F# pipe更啰嗦。
Currying:对F# pipe操作符很重要,但不太适合于JavaScript
F# pipe在内置支持currying的函数式编程语言中运行良好。
什么是currying?普通的(非卷曲的)函数可以有零个或更多的参数--例如:
const add2 = (x, y) => x + y;
assert.equal(
add2(3, 5), 8
);
Curried函数最多只有一个参数--它们是单数。有更多参数的函数是通过返回函数的单数函数来模拟的:
const addCurry = x => y => x + y;
assert.equal(
addCurry(3)(7), 10
);
Currying使得创建初始参数被部分应用(填入)的函数变得容易。例如,这是定义同一个函数的三种方式:
const f1 = addCurry(1);
const f2 = add2.bind(null, 1);
const f3 = x => add2(1, x);
有了currying,通过管道进入一个有多个参数的函数是很简洁的。
assert.equal(
5 |> addCurry(1), 6
);
唉,currying对于JavaScript来说不是一个好的选择:
-
Currying只能填入初始参数。这在函数式编程语言中效果很好,因为在这种语言中,有数据操作的参数是最后一个(例如
append(elem, list)
)。然而,JavaScript的函数不是这样的结构,方法也经常被使用。 -
我不喜欢在函数调用和部分应用中使用同一个操作符--函数调用。
-
咖喱法不能很好地与命名参数模式配合。
-
咖喱法不能很好地与参数默认值一起工作。
Hack管道表达式中的%
,可以被认为是部分应用的运算符。例如,下面的两个表达式是等价的:
5 |> add2(1, %) // Hack pipe
5 |> $ => add2(1, $) // F# pipe
Hack pipe更擅长:方法调用、运算符、字面意义、await
,yield
为了使用以下结构,我们需要箭头函数。
value |> $ => $.someMethod() // method call
value |> $ => $ + 1 // operator
value |> $ => [$, 'b', 'c'] // Array literal
value |> $ => {someProp: $} // object literal
F#管道更擅长于解构
使用F#管道,我们可以使用一个单数函数来解构一个输入值:
const str = obj |> ({first,last}) => first + ' ' + last;
使用Hack管道,我们必须避免去结构化:
const str = obj |> %.first + ' ' + %.last;
或者我们必须使用一个立即调用的箭头函数:
const str = obj |> (({first,last}) => first + ' ' + last)(%);
如果do-expressions被添加到JavaScript中,我们可以通过一个变量声明来进行结构重组:
const str = obj |> do { const {first,last} = %; first + ' ' + last };
管道操作符的使用情况
管道运算符有三种常见的使用情况:
- 嵌套函数调用的扁平语法
- 后处理值。给定一个值,我们可以通过只在它后面添加代码来应用一个函数--而正常的函数调用需要在值的前后都有代码。
- 非方法语言结构的链式处理
我们将通过Hack管道探索这些用例,但它们也是F#管道的用例。
嵌套函数调用的扁平语法
检索一个对象的原型的原型
所有由JavaScript标准库创建的迭代器都有一个共同的原型。这个原型不能直接访问,但我们可以像这样检索它:
const IteratorPrototype =
Object.getPrototypeOf(
Object.getPrototypeOf(
[][Symbol.iterator]()
)
)
;
有了管道操作符,代码就变得容易理解了:
const IteratorPrototype =
[][Symbol.iterator]()
|> Object.getPrototypeOf(%)
|> Object.getPrototypeOf(%)
;
混合器类
混合类是一种模式,我们用函数作为子类的工厂来模拟多重继承。例如,这是两个混合类。
const myPostProcessedValue = myValue |> myProcessor(%);
两者都返回一个给定类的子类Sup
。如果没有管道操作符,这些混集类的使用方法如下。
后处理值
有了管道操作符,我们就可以写一些函数,比如myProcessor
,以某种方式对值进行后处理:
const myPostProcessedValue = myValue |> myProcessor(%);
我们可以看到,Hack管子在这里比F#管子更加冗长。稍后会有更多关于这个问题的介绍。
易于删除的数值记录
考虑一下下面这个函数:
function myFunc() {
// ···
return someObject.someMethod();
}
我们如何改变这段代码,在返回前记录someObject.someMethod()
的结果?
如果没有管道运算符,我们就必须引入一个临时变量,或者在return
的操作数上包一个函数调用。
有了管道运算符,我们就可以这样做:
function myFunc() {
// ···
return theResult |> (console.log(%), %); // (A)
}
在A行,我们使用了逗号运算符。来评估下面的表达式:
(expr1, expr2)
JavaScript首先评估expr1
,然后评估expr2
,然后返回后者的结果。
后处理函数
在下面的代码中,我们后处理的值是一个函数--我们给它添加一个属性:
const testPlus = () => {
assert.equal(3+4, 7);
} |> Object.assign(%, {
name: 'Test the plus operator',
});
前面的代码相当于:
const testPlus = () => {
assert.equal(3+4, 7);
}
Object.assign(testPlus, {
name: 'Testing +',
});
我们也可以像这样使用管道运算符:
const testPlus = () => {
assert.equal(3+4, 7);
}
|> (%.name = 'Test the plus operator', %)
;
标记模板的替代方法
标签模板是后处理模板字样的一种方式。管道运算符也可以做到这一点:
const str = String.raw`
Text with
${3} indented
lines
` |> dedent(%) |> prefixLines(%, '> ');
请注意,与我们通过管道应用的函数相比,模板字样可以访问更多的数据。因此,它们的功能要强大得多--例如,String.raw
,只能通过模板字面完成。
如果一个管道触发的函数调用就足够了,我们得到的好处是可以同时应用多个后处理操作,甚至可以将这些操作与模板字面结合起来(正如例子中所做的那样)。
非方法语言构造的链式处理
类似方法的链式处理
多亏了管道操作符,我们可以像连锁方法调用一样连锁操作:
const regexOperators =
['*', '+', '[', ']']
.map(ch => escapeForRegExp(ch))
.join('')
|> '[' + % + ']'
|> new RegExp(%)
;
这段代码易于阅读,而且比引入中间变量更不繁琐。
链接函数调用
我们可以通过链式方法,如数组方法.filter()
和.map()
。然而:
- 它们是内置在一个类中的固定操作集。没有办法通过一个库来增加更多的Array方法。(一个库可以创建一个子类,但如果我们从其他地方得到一个数组,这对我们没有帮助。)
- 对于方法来说,树形摇动(消除死代码)是很困难的,如果不是不可能的话。
有了管道操作符,我们可以把函数当做方法一样进行链接--没有上述两个缺点。
import {Iterable} from '@rauschma/iterable/sync';
const {filter, map} = Iterable;
const resultSet = inputSet
|> filter(%, x => x >= 0)
|> map(%, x => x * 2)
|> new Set(%)
;
总结:Hack pipe vs. F# pipe
F#管道的优点。
- 如果我们有使用currying的代码,它就会更好。
- 在处理单数函数时,不那么冗长--例如,在后处理值时。
- 解构稍显简单。
Hack pipe的优点。
- 对典型的(非curried)JavaScript代码和arity大于1的函数有更好的作用。
- 支持
await
和yield
(没有特殊的语法)。
TC39目前只劝说Hack管道。对F#管道的担忧包括。
- 内存性能(由于函数的创建和调用)。
- 难以使
await
和yield
工作 - 可能会鼓励生态系统中使用currying的代码和不使用currying的代码之间的分裂。
JavaScript出现管道操作符的可能性有多大?
Hack管道正在取得进展,但F#不再被说服了(详情请见Hack操作符提案)。
对Hack管道和F#管道的潜在改进
在这一节中,我们研究了可以改进这两个操作符的方法。然而,并不总是清楚增加的复杂性是否值得。
F#管道:更好地支持数位大于1的函数
如果JavaScript有一个部分应用操作符(如这里所提议的),那么使用F#管道看起来就和使用Hack管道几乎一样。
不过,仍有一些缺点。
- 内存性能没有提高(我们仍然需要函数调用)。
- 不能与运算符一起使用,如
+
。 - 不适用于
await
和yield
。
智能管道。Hack管道有可选的%
我们可以让Hack管道在单数情况下不那么冗长。
- 在
|>
运算符的右侧是否有一个%
? - 如果有,那么
|>
,就像Hack pipe一样工作。 - 如果没有,那么
|>
,像F#管道一样工作。
例子。
请注意,A行与正常的Hack管道不同。
关于这种方法的建议已经被放弃了,但原则上可以作为Hack管道的一个附加功能而重新恢复。
我们可以为Hack管道补充一个特殊的运算符|>>
,用于单数函数,其工作方式与F#管道类似(A行)。
我们真的需要一个管道操作符吗?有什么替代方法?
在这一节中,我们看一下管道运算符的替代方案。它们都相当优雅,但都有以下缺点。
- 它们只适合于连锁,而不适合于后处理。
- 即使每一个处理步骤只增加一点口令,也会增加,使内置的管道操作符更加方便。
import {Iterable} from '@rauschma/iterable/sync';
const {filter, map} = Iterable;
const resultSet = inputSet
|> filter(%, x => x >= 0)
|> map(%, x => x * 2)
|> new Set(%)
;
Function.pipe()
一个替代管道操作符的方法是使用一个函数--例如,拟议中的 Function.pipe()
:
const resultSet = Function.pipe(
inputSet,
$ => filter($, x => x >= 0)
$ => map($, x => x * 2)
$ => new Set($)
);
多亏了箭头函数,这种方法比我们预期的要少一些啰嗦。
它的缺点是。
- 我们不能使用
await
和yield
。 - 函数被创建和调用。
我们可以使用中间变量。
const filtered = filter(inputSet, x => x >= 0);
const mapped = map(filtered, x => x * 2);
const resultSet = new Set(mapped);
一方面,这比流水线更省事。另一方面,变量名描述了正在发生的事情--如果一个步骤很复杂,这可能很有用。
下面的技术是前一个技术的变种--它重用了一个简短的变量名,如$
。
let $ = inputSet;
$ = filter($, x => x >= 0);
$ = map($, x => x * 2);
const resultSet = new Set($);
(我不确定这个技术应该归功于谁;我第一次看到它是在这里使用的。)
由于变量名称较短,而且我们不需要每一步都声明一个变量,因此我们可以节省字符。
这种技术的缺点是,它不能在同一范围内多次使用。而且也没有简单的方法将代码块包裹在这样的代码片段中。
不过,我们可以通过一个立即调用的Arrow函数来解决这个问题。
const resultSet = (
($ = inputSet) => {
$ = filter($, x => x >= 0);
$ = map($, x => x * 2);
$ = new Set($);
return $;
}
)();
有什么有趣的管道操作符的用例被我错过了吗?请在评论中告诉我们。