近日,TC39 中有 5 个提案正在设想着改进在 ECMAScript 中调用函数(方法)的方式,以让 ECMAScript 代码拥有更好的可读性与表达能力。这 5 个提案分别是:
- Pipeline Operator,proposal-pipeline-operator;
- Function Pipe/Flow,proposal-function-pipe-flow;
- Partial Application,proposal-partial-application;
- Call This,proposal-call-this;
- Extensions,proposal-extensions;
这些提案基本上都引入了不少新语法,而 ECMAScript 作为 Web 的基石,我们几乎无法废弃一个已经成为 Web Reality 的语法,在 ECMAScript 中引入新的语法必然会挤压未来 ECMAScript 在语法上的扩展空间。因此,在 TC39 中引入一个新语法通常都会有较高的门槛。
另外,这些提案尝试解决的问题或多或少有些许重叠,所以 TC39 中也有非常强烈的声音希望能够从这几个提案中最终竞争出一个能满足绝大多数场景的提案,而不是吸纳这些提案的全部语法。那么,这些提案都有哪些设计目标,分别有哪些区别?我们接下来就来细细了解一下。
我们从提案的设计思路出发,将以上提案分为 3 个类别:
- 数据流编程:包含 Pipeline Operator 与 Function Pipe/Flow 提案;
- 类方法扩展:包含 Call This 与 Extensions 提案;
- 偏函数应用:即 Partial Function Application 提案;
数据流编程
Dataflow Programming (数据流编程)将程序拆分为数个独立的操作单元,而数据在操作单元间以有向图的形式流转,程序设计关注重点在于动态的数据。想象流水线上的玩具,经过一个个工人手中后,一个木头架子依次被装上了四肢、脑袋、眼睛、开关...,这其实就是数据流在一个个独立单元之间的流动。而在数据流编程中,实际上我们关注的也是如何建立这个数据流转关系,包括需要存在的程序单元(函数)与程序执行的先后次序等。
数据流编程通常会有部分函数式编程的特点。在 Verilog 与 Lucid 等编程语言,以及 Apache Flink、TensorFlow 等框架中都重度使用了数据流编程的范式。在函数式编程中,函数实际上即是描述了一种集合到集合的映射关系。它接收入参并返回一个(有且只有一个)映射后的值,即这个函数在入参与结果之间建立了映射关系。在任意时间任意状态调用一个函数,都能获得同样的结果,也就是说它不会被任何可变状态影响、不会产生副作用。对于函数式编程,其实大部分前端同学或多或少都有接触过。ECMAScript 本身也将函数视为一等公民,这是函数式编程的前提。如 Redux 中的 compose,常见的函数柯里化,ImmutableJS 等等,都可以视为对于函数式编程范式的一种实现。
当然,Pipeline Operator 提案与 Function Pipe/Flow 提案并不会在 ECMAScript 引入任何与数据流编程相关的概念。他们的设计主要聚焦在如何以一种更具有表达能力的方式描述在函数之间传递输入的数据,让开发者可以将程序拆分成独立的黑箱操作单元、更多地聚焦在数据的流转上,而非处理过程。
Pipeline Operator / 管道操作符
提案地址:https://github.com/tc39/proposal-pipeline-operator
Pipeline Operator(管道操作符)在过去几年中的社区调查中都获得了非常高的关注度。它在 2020 年的 “What do you feel is currently missing from JavaScript?” 中关注度排名第4,2021 年 排名第7。
在管道操作符出现前,我们要实现一组连续的操作(如函数调用)有两种常见的方式:
- 嵌套调用,将前序操作的调用作为后序操作的参数,如
f3(f2(f1(val)))
; - 链式调用,将函数作为对象方法链式调用,如
val.f1().f2().f3()
;
这两种方式都有非常明显的缺点。
嵌套调用的可读性较差,我们需要反直觉的从右到左进行阅读。尤其是在流程中间的操作单元存在额外的入参时,简直就是噩梦,如 f3(f2(f1(value), arg2), arg3)
,更不要说对中间环节进行修改了。但这一使用方式的通用性最强,我们可以在中间环节插入来自不同提供方的调用单元,或者使用 await
,yeild
关键字等等。
链式调用则有着较多的限制,实现这种模式需要每一个操作都作为对象的方法调用,同时还需要每一级调用此对象实例作为返回值(最常使用的方式是直接 return this
)。但优势也非常明显,我们的调用链可以按照我们的阅读习惯从左至右阅读,添加新的操作单元也非常容易。这也是为什么在数年前 JQuery 能够一统天下的重要原因之一。而即使是到了现在,我们也常在 Query Builder、Webpack Chain 等这一类库中享受着它带来的便利。
对于链式调用,实际上有专门的一种设计模式:Fluent Interface。
当然,我们可能还有第三种,那就是最朴素的,为每一次操作声明一个临时变量再传入给下一操作单元:
const result1 = f1(value, arg1); const result2 = f2(result1, arg2); const result3 = f3(result2);
对于视变量命名为猛虎的程序员来说,这的确不是非常优雅的方式。而如果使用单个临时变量声明,又很可能在某些情况下错误的修改了值(尤其是异步与闭包场景),如:
function one () { return 1; } function double (x) { return x * 2; } let _; _ = one(); // _ 现在是 1. _ = double(_); // _ 现在是 2. _ = Promise.resolve().then(() => // 这里不会打印 2! // 实际上会打印 1, 因为 `_` 在下面被赋值了. console.log(_)); // _ 在 Promise callback 之前就变成 1 了 _ = one(_);
而有了管道操作符以后,以上的代码就能够被改写为:
let _; _ = one() |> double(%) |> Promise.resolve().then(() => // 打印 2. console.log(%)); _ = one();
这里闭包中不再直接捕获变量 _
,而是通过一个间接变量占位符 %
来表达操作间的临时值。
其中:
%
意为着上一次操作的返回值(值得注意的是,提案并没有与这个占位符符号绑定,它也可能在未来变成^
/_
或者是别的符号)。|>
操作符的右侧可以是任意合法的表达式,如|> await %
,|> %.foo()
等等。
当然,管道操作符也引入了一些使用限制。但比起它拥有的同时结合了嵌套调用和链式调用的优点:可读性、无需原型支持来看,基本可以忽略不计。
- 首先,每个管道操作符的操作单元都必须至少消费一次上一环节的生产值(即
|>
后的表达式中必须存在%
)。这是因为当我们使用管道操作符进行级联操作时,如果某一个环节完全没消费到,那基本可以认为这个环节是不必要的。如果我们有副作用需要执行,可以通过|> (sideEffect(), %)
的形式,即括号表达式来确保左边的副作用被执行,同时直接返回上一环节的生产值。 - 由于
|>
和其他一些操作符有相近的优先级(如箭头函数的=>
,三元表达式的? :
),因此结合使用时需要显式的使用括号来区分优先级,如a |> (b ? % : c) |> %.d
。 - 不允许将占位符
%
使用在一些动态执行的语法中。如|> eval(' % + 1')
将会抛出语法错误,因为eval
内的脚本在运行时才被解析执行,对于外部的语法上下文并没有依赖。
Function Pipe/Flow
提案地址:https://github.com/js-choi/proposal-function-pipe-flow
这一提案为顶级对象 Function
的内置对象上新增了两个系列方法:pipe
/pipeAsync
与 flow
/flowAsync
。
其中,Function.pipe
接受一个输入值与一系列的一元函数,并从第一个一元函数开始,将上一次的调用结果传给下一个一元函数作为参数。Function.pipe
会在原地执行这些一元函数:
const { pipe } = Function; pipe(5, f0, f1, f2); // 等同于 f2(f1(f0(5))) pipe(5); // 等同于 5 pipe(); // 等同于 undefined
目前,在 ECMAScript 中,对于 Promise 我们都已经非常了解,并且广泛使用了。为了处理实际生产中的异步操作单元,这个提案还引入了 Function.pipeAsync
来实现异步的处理链:
const { pipeAsync } = Function; pipeAsync(5, f0, f1, f2); // 等同于 Promise.resolve(5).then(f0).then(f1).then(f2) pipeAsync(Promise.resolve(5)); // 等同于 Promise.resolve(Promise.resolve(5)) pipeAsync(); // 等同于 Promise.resolve(undefined)
其中,第一个参数为任意值,并会通过 Promise.resolve
过程将其转换为一个 Promise 供后续流程使用。
而另一个系列 Function.flow
,它接受一系列函数并组合成一个新的高阶函数,同时仍然保持传入的调用顺序。Function.flow
并不会立即执行这些一元函数,而是返回一个新的函数。
对于首个函数,它可以是任意元函数(有任意个参数),而对于余下的函数都必须是一元函数。
const { flow } = Function; const f = flow(f0, f1, f2); // 等同于 f = (...args) => f2(f1(f0(...args))) f(5, 7); const g = flow(g0); // 等同于 g = (...args) => g0(...args) g(5, 7);
Lodash 也提供了类似的方法,见 Lodash.flow。
类似 Function.pipeAsync
,此提案也提供了 Function.flowAsync
方法,来组装一系列异步函数:
const { flowAsync } = Function; const f = flowAsync(f0, f1, f2); // 等同于 f = async (...args) => await f2(await f1(await f0(...args))) await f(5, 7); const g = flowAsync(g0); // 等同于 g = async (...args) => await g0(...args) await g(5, 7);
不过因为 Function.flowAsync
返回的并不是一个一元函数,它不会将输入参数像 Function.pipeAsync
一样通过 Promise.resolve
转换成 Promise。
Function Pipe/Flow 提案的提出的初衷是为了解决在 Pipeline Operator 提案选择了占位符样式的管道操作符方式后,提供针对现存一元函数的便捷写法:
f2(f1(f0(5))) // 等同于 Pipeline Operator 5 |> f0(%) |> f1(%) |> f2(%) // 等同于 Function.pipe Function.pipe(5, f0, f1, f2);
而 Function Pipe/Flow 提案的一系列方法,其实都可以视为 Pipeline Operator 所期望解决的问题的针对一元函数的一种简化写法。
如对于 Function.pipeAsync
:
Promise.resolve(5).then(f0).then(f1).then(f2) // 等同于 5 |> await f0(%) |> await f1(%) |> await f2(%) // 等同于 pipeAsync(5, f0, f1, f2);
对于 Function.flow
:
const f = (...args) => f2(f1(f0(...args))) // 等同于 const f = (...args) => f0(...args) |> f1(%) |> f2(%) // 等同于 const f = Function.flow(f0, f1, f2);
对于 Function.flowAsync
:
const f = async (...args) => await f2(await f1(await f0(...args))) // 等同于 const f = (...args) => (await f0(...args)) |> await f1(%) |> await f2(%) // 等同于 const f = Function.flowAsync(f0, f1, f2);
这两个提案解决的问题非常近似,只不过 Function Pipe/Flow 提案提供了一个场景的简化方案。对于后续的演进方案,如是否需要在 ECMAScript 中提供对于解决同一个问题的两个类似的方案,将继续在 TC39 全员会议中讨论。
面向对象的方法扩展
从 ECMAScript 2015 引入 class 语法开始,TC39 就不断地持续在给 class 语法增加新的特性,如近期广受关注的 class fields 和 class private fields (#字段
)等等。而面向对象编程(OOP)中的类与对象的概念,在 ECMAScript 的实际生产代码中从早期 ECMAScript 开始就从未缺席。
既然有类的概念,我们就会有类方法的概念。类方法为一个类抽象出了各种通用的操作,对于我们用 ECMAScript 来描述各种生产关系中的抽象概念有非常大的帮助。
class Dog { bark() { console.log("bark!"); } }
但是目前的 class 语法限制了我们必需将一个类的方法都书写在同一个文件中的同一个类结构内。而现实生产过程中,我们通常会碰到一个类不是由我们自己实现的,而它的实现者可能并不能完整地描述所有可能的这个类的抽象场景。此时,如果我们想给一个类增加一个类方法的话,常见的方法会有:
- 直接在这个对象的
prototype
上增加我们想要的方法实现; - 将这个方法写为普通函数,以普通参数的形式接收对象并完成操作;
而这两种方式都有他们各自的缺陷。对于直接在 prototype
上添加我们自己实现的方法,这个行为可能会污染 prototype
上的命名,后续原作者可能会增加同名的方法(甚至同名而实现不一致),增加维护难度。而对于将我们自己定义的抽象方法写为普通函数,我们知道普通函数的写法与类方法写法并不同,如 this
参数等等,他们的调用方式也不同,嵌套的普通函数调用会有上文中 Pipeline Operator 期望解决的嵌套函数调用问题。当然对于普通函数我们可以通过上文中的 Pipeline Operator 来实现优雅的嵌套传参,不过难道我们就没有一个更好的方案了吗?
实际上在许多编程语言中都有 Method Extension 的概念,如 Swift:
// Swift extension Int { var simpleDescription: String { return "The number \(self)" } } print(7.simpleDescription) // Prints "The number 7"
甚至在早期 TypeScript 的讨论中也出现过这一语法,见 #9,示例语法如:
class Shape { // ... } extension class Shape { getArea() { return /* ... */; } } var x = new Shape(); console.log(x.getArea()); // OK
Call This
提案地址:https://github.com/tc39/proposal-call-this
这一提案是在原 proposal-bind-operator 提案基础上的演进,实际上下面我们即将介绍的 Extensions 提案同样来自于对 bind-operator 提案的重新设计。因此 Call This 提案与 Extensions 属于竞争性提案。
这个提案内容非常简单,它使用了 :>
来表示 Call This 操作符,这个操作符所蕴含的含义也非常直观:
receiver :> fn(arg0, arg1) // 等同于 fn.call(receiver, arg0, arg1) receiver :> ns.fn(arg0, arg1) // 等同于 ns.fn.call(receiver, arg0, arg1) receiver :> (expr)(arg0, arg1) // 等同于 (expr).call(receiver, arg0, arg1)
Call This 操作符只能用在 Call Expression 中,而无法用于生成一个绑定 this
参数的场景(对应 Function.prototype.bind
)。这一限制使得 Call This 操作符非常简单、直白,但是可以覆盖实际生产环境中的绝大多数方法扩展的场景,如:
class Shape { // ... } const ShapeExt = { getArea() { return /* ... */ } } var x = new Shape(); // 等同于 ShapeExt.getArea.call(x) console.log(x:>ShapeExt.getArea()); // OK
另外,值得注意的是,:>
操作符只支持对于扩展对象的简单属性访问,更多样式的扩展方法的获取则必需通过括号来正确声明期望的优先级:
x:>(ShapeExt['getArea'])()
提案目标于解决的问题非常清晰,而解决方案又十分简洁,更多的语义细节可以查看提案的 Spec 文本 。
Extensions
提案地址:https://github.com/tc39/proposal-extensions,这一提案的发起人是 贺师俊 老师。
Extensions 提案期望引入新的 ::
操作符,来实现“扩展方法”的能力,本质上它简化了 this
绑定的能力,直接看例子:
// 必需通过 :: 前缀修饰符标明这是一个扩展方法 const ::toArray = function () { return [...this] } const ::toSet = function () { return new Set(this) } // [1, 2, 3] const result1 = new Set([1, 2, 3])::toArray(); // new Set([1, 2, 3]) const result2 = new Array([1, 2, 3, 3])::toArray();
在 Extensions 提案中,函数、或者是属性描述符都需要先通过赋值到一个独立的 ::name
命名空间中,才能被用于扩展操作符 ::
。这是因为提案不希望一个设计上用来作为扩展的变量值被意外用于普通的代码中,而限制这些扩展必需实际只用于扩展之中,因此一个独立的命名空间可以避免两种用途的变量有任何的命名冲突。
我们可以将上面的代码片段理解为隐藏了 this
指向绑定的调用,上面的代码等同于:
// 实际上是一个独立的命名空间,不与普通的 toArray 冲突 const $toArray = function () { return [...this] } const $toSet = function () { return new Set(this) } const result1 = $toArray.call(new Set([1, 2, 3])) const result1 = $toSet.call(new Array([1, 2, 3, 3]))
Extensions 语法语法也可以适用于 ECMAScript 的内置方法:
const ::flatMap = Array.prototype.flatMap; let classCount = document.querySelectorAll('div') ::flatMap(e => e.classList::toArray());
除了方法之外,在 ECMAScript 代码中,我们也常常可以看到属性访问器用来提供便捷的属性封装,而 Extensions 语法同样可以用来描述扩展的属性访问器:
// ::last 是一个适用于 Object.getOwnPropertyDescriptor/Object.defineProperty 的属性描述符 const ::last = { get() { return this[this.length - 1] }, set(v) { this[this.length - 1] = v }, } let a = [1, 2, 3] a::last // 3 // 调用 setter,并修改了数组元素 a::last++ a // [1, 2, 4]
这一提案的重要意义在于使得我们能够在各种第三方对象上扩展原先并不存在的方法,而不污染他们各自的命名空间。
另外有趣的是,通过这个提案的语法,我们同样可以实现便捷的链式调用,而不需要原始作者支持:
const ::pipe = function (f) { return f(this) } let result = "hello" ::pipe(doubleSay) ::pipe(capitalize) ::pipe(exclaim)
另外,值得注意的是,扩展操作符 ::
与属性访问操作符 .
优先级一致。为了解决常见的如 Lodash 的用法,提案还提供了 ::Namespace:Extension
的方式,来调用外部命名空间下的扩展实现,如:
import * as lodash from 'lodash' [1, 2, -3] ::lodash.last() // <= 存在歧义,这里实际的效果是 lodash.get.call([1, 2, -3]).last() [1, 2, -3] ::lodash:last() // -3
而提案在这里其实又分两种不同的情况。当扩展命名空间对象是一个构造函数时,会取构造函数的实例 prototype
上的方法调用,否则直接调用这个对象上的同名方法:
o::Ext:method(...args) // 等同于 IsConstructor(Ext) ? Ext.prototype.method.call(o, ...args) : Ext.method(o, ...args)
关于命名空间的细节可以阅读贺师俊老师的 Slide 了解更多。可以看到,Extensions 提案除了希望解决面向对象的方法扩展之外,还有更多、更大的愿景(如变量命名 Shadowing 等),期望为 ECMAScript 开发者们带来一个完善的解决方案。当然,更大的问题范畴也代表着更多的质疑与顾虑,如何在 TC39 中让来自全球公司的代表都信服于提案所提出的方案将是这个提案后续的主要挑战。
偏函数应用 Partial Function Application
提案地址:https://github.com/tc39/proposal-partial-application
Partial Function Application 也称 Partial Application(下文简称 PFA),它指将一个函数的数个入参固定后,产生一个需要更少参数的新函数,如通过 argFixedFoo(arg1)
调用了 rawFoo(arg1, 2, 3)
。
PFA 很容易被和柯里化这一更常见的概念混淆,但二者其实有本质上的差别。柯里化本质上是将一个接受 n 个参数的函数,转换为一组一元函数,并可以在后续连续调用,如 foo(1, 2, 3)
可以转换为 curried(1)(2)(3)
这种形式,转换后的每一个函数都会返回一个接收下一个参数的函数,而 PFA 则更侧重于固定函数的一个或多个入参,并只返回一个转换后的函数。
另外,PFA 可能也会和 偏函数 Partial Function 的概念进行混淆。偏函数的概念相对于全函数(即我们最常见的函数),全函数意味着从入参集合到结果集合的完全映射关系,即每一个入参都在结果集合中有一个确定的结果。而偏函数的结果集合是其对应的全函数结果的子集,如偏函数 f(x) = x/3
的结果集合仅包括正整数,则 f(1) = 1/3
、f(2) = 2/3
就不在这个偏函数的映射关系中。
而 PFA 则是新的对函数的调用方式(相对于常规调用),它引入新的调用语法 ~()
,来标记本次调用为 PFA 调用,使用 ?
标记一个入参为未应用(即留给转换后的函数传入的参数)的,产生一个部分已应用(即已提前固定好的参数)的函数。对于这一种“提前固定参数”的效果,我们此前常通过 Function.prototype.bind 来实现:
const add = (x, y) => x + y; const addOne = add.bind(null, 1); addOne(2); // 3 addOne(6); // 7
而使用 PFA 调用,我们可以把上面的例子改写成这样:
const add = (x, y) => x + y; const addOne = add~(1, ?); addOne(2); // 3 addOne(2); // 3
add~
标记了本次调用为 PFA 调用,?
标记了这里的参数为后续传入addOne(2)
的调用会将2
应用到原?
占位参数的位置
除了简单的固定参数,PFA 调用还支持通过使用 ?0
这种形式,来标记占位参数的顺序,如:
const identity = x => x; const numbers = ["1", "2", "3"].map(parseInt~(?, 10)); // [1, 2, 3] const indices = [1, 2, 3].map(identity~(?1)); // [0, 1, 2]
?1
意为在调用时使用其传入的第二个参数(在这里即为 index)。
对于不定参数,你也可以使用 ...
来标识所有传入的参数:
const logger = console.log~("[service]", ...); logger("foo", "bar"); // prints: [service] foo bar
如果你在使用 PFA 调用时,没有通过 ?
标记可选应用函数的参数位,那么最终产生的函数接受的参数将再也无法被传入,可以理解为这个函数无法再接受参数:
const add = (x, y) => x + y; const addOne = add~(1, 2); addOne(100); // 无论使用什么值调用,结果都为 3
PFA 在参数标记上支持非常多的场景,其默认的无序参数标记 ?
与有序参数标记 ?1
计算规则,以及混用下的计算规则在提案的仓库中有非常详细的说明,欢迎有兴趣的同学自行探索。
简单来说,PFA 提案的出现主要是为了取代(增强)原 JavaScript 中的 Function.prototype.bind 方法,它提供了更直观与更强大的“参数固定”能力。同时,PFA 调用也是对 ECMAScript 中函数调用表达式的一次扩展,因此它支持所有合法的函数表达式调用,包括调用模板标签(如styled.div~`styles`)以及可选调用如 a?.~()
或 a?.b~()
。
PFA 在 Class 与 this 相关的语法中也有着部分新增,如 new Foo~(?)
就是一个 PFA 调用的实例化(本质上是对构造函数的 PFA 调用),其结果是一个返回 Foo 的实例的函数。对于f~(?)
,最终产生的 PFA 调用函数的 this 会被固定为 undefined,而 o.f~(?)
会立刻计算 o.f
的值,最终产生的 PFA 调用函数的 this 将会绑定到 o
上。或者,你也可以直接在 PFA 调用中传递显式的 this,如(f~(this, ?)
中的 this 将基于词法作用域)。
PFA 与上述的几个提案也能够进行互补的协作 ,如你可以与 pipeline operator 一同使用:
const add = (x, y) => x + y; const greaterThan = (x, y) => x > y; elements |> map(%, add~(?, 1)) |> filter(%, greaterThan~(?, 5));
不过遗憾的是,在 TC39 2021 年 10 月会议中,这个提案希望进入 Stage 2 的请求被委员会拒绝了,原因之一则是其在 TC39 委员会中形成了截然不同的意见。其主导者 Ron Buckton 表示,会在 pipeline operator 取得进一步进展后再次推进 PFA 语法。
总结
实际上,如果我们将以上的提案和已有的 JavaScript 语法进行比较,不难发现其实它们的共同点在于,想要通过新的语法/方法/关键字等来取代并进一步增强原有的能力,如:
- pipeline operator 提案与 Function.pipe、Function.flow 提案,其目的在于通过引入数据流编程的范式,在函数连续调用的场景中摒弃嵌套调用与链式调用。
- call this 提案与 Extension 提案,通过引入新的语法来替换 Function.prototype.call 方法,同时使得调用表达式更加符合直觉。
- 以及 Partial Function Application 提案,其替换并增强了 Function.prototype.bind 的使用场景。
我们暂时还不能直观的预知在数年后哪些提案能历经坎坷的一步步推进到 Stage 4,成为 ECMAScript Next 的新成员。但可以确定的是,在未来的 ECMAScript 语法中,函数调用将获得更好的可读性与更丰富的表达能力。它们或许不会像可选链与空值合并那样迅速被开发者接受并广泛使用,或许不会像装饰器那样带来全新风格的开发方式,但一定会或多或少改变我们对函数的思考方式。