JavaScript的管道操作符:介绍和案例

642 阅读7分钟

提案"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的函数有更好的作用。
  • 支持awaityield (没有特殊的语法)。

TC39目前只劝说Hack管道。对F#管道的担忧包括。

  • 内存性能(由于函数的创建和调用)。
  • 难以使awaityield 工作
  • 可能会鼓励生态系统中使用currying的代码和不使用currying的代码之间的分裂。

JavaScript出现管道操作符的可能性有多大?

Hack管道正在取得进展,但F#不再被说服了(详情请见Hack操作符提案)。

对Hack管道和F#管道的潜在改进

在这一节中,我们研究了可以改进这两个操作符的方法。然而,并不总是清楚增加的复杂性是否值得。

F#管道:更好地支持数位大于1的函数

如果JavaScript有一个部分应用操作符(如这里所提议的),那么使用F#管道看起来就和使用Hack管道几乎一样。

不过,仍有一些缺点。

  • 内存性能没有提高(我们仍然需要函数调用)。
  • 不能与运算符一起使用,如+
  • 不适用于awaityield

智能管道。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($)
);

多亏了箭头函数,这种方法比我们预期的要少一些啰嗦。

它的缺点是。

  • 我们不能使用awaityield
  • 函数被创建和调用。

我们可以使用中间变量。

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 $;
  }
)();

有什么有趣的管道操作符的用例被我错过了吗?请在评论中告诉我们。