using声明的争议点

119 阅读7分钟

using声明的争议点

`

一般来说,一个对象dispose之后,其关键资源既然已经释放,则对象通常就处于一个无法正常工作的状态。

显然dispose本身并不会使得对象失效,除非代码作者有意保证了这一点 —— 比如簿记一下是否已disposed,然后对象所有方法内部都预先检查一下,如果已经disposed,则直接扔error。

提案的champion认为推荐这样的模式就可以了(这大体也是C#、Java等语言类似特性所持的方式)。但是,ECMA262标准的当前编辑之一bakkot同志强烈认为应该让绑定直接失效(类似TDZ)。

注意,这里的争议焦点并不是「对象本身的生命周期」而是「变量绑定(binding)的生命周期」。

「对象的生命周期」在JS里是没法控制的(由GC控制)。因此前述方式是手动控制「对象行为(方法或属性访问)的生命周期」。而bakkot要求的是「绑定的生命周期」。

我们知道变量绑定由词法作用域决定其可用的范围,似乎一般不讲变量绑定的生命周期,但实际上考虑TDZ,在变量初始化之前访问变量会抛出错误,就可以认为是变量的真正生命周期还没开始。另一方面变量的生命周期也不是在程序跑出scope之后就结束,而是可能被闭包捕捉到,从而被延长至闭包对象的生命周期结束。而using声明意味着,在scope结束之后变量虽然还可能通过闭包所访问到,但绑定的对象就已经被dispose了。所以bakkot同志提出应该让变量绑定在此后也直接无效,即与初始化之前访问一样,抛错误。

从我个人偏好来说,我是比较赞同此种设计的。因为这很大程度上可以解决在生命周期之外误用的问题。前述的手动控制对象行为的方式,虽然对于平台API,或者对于维护水平较高的库来说,是没有问题的,但对于普通开发者来说,比较繁琐,boilerplate代码太多。此外就算已经运用了此种模式,但虽然没法使用对象,却也不能阻止在闭包里进一步地单纯传递引用,这使得错误最后被扔出的地方可能离scope很远。总体而言,我是倾向于语言能通过更早报错给予程序员更好的保护。


引擎厂商(具体说是V8,其他引擎厂商暂时没表达意见)反对这种设计的原因主要是三点。

最核心的是这可能使得相关代码更难优化。我们可以考虑TDZ是怎么实现的?其实就是加个是否初始化的标志,在每次访问变量时先看看是不是已经初始化,如果没有的话就扔错误。显然这略微增加了访问成本。所以一些引擎可能会努力做一点优化,比如通过某些方式来确定一些代码路径肯定已经是初始化之后的,那么在这些代码路径上就不用检查初始化标志。而using声明若有自动绑定无效的语义,相当于有头尾两段TDZ,优化难度就更高一点了。

当然,在我看来这其实还是屁股问题 —— 也就是这潜在地增加了引擎厂商的工作量。

其次一点,是心智模型上,V8的代表认为这提案只是一个在scope退出时执行代码的特性,也就是所谓 scope guard。不过我也不同意他的这个心智模型。实话说,我认为引擎厂商代表(一般就是写C++的引擎工程师)所偏好的心智模型通常过于底层,而和大多数JS工程师并不一致。尤其在这个提案中,如果只是一种 scope guard,那为啥不直接选择类似 Go 的 defer 语法(defer 关键字后面跟一个代码块或函数,自动在scope结束时执行)?实际上我很早就问过提案champion为什么选择现在的设计而不是类似Go的defer。对此问题,提案的champion,TS团队的 Ron Buckton 说,因为 defer 方式仍然需要手动调用各种各样的方法名(close、abort、return等),没法像Disposable那样使用统一接口,而且可组合性差(这点我略存疑,因为可以组合函数嘛)。然而,即使不用defer,另一个可能的设计是,不是基于变量绑定而是基于表达式(即const x = using disposable), 这保留了Disposable的优点,但由于一些考虑(比如 scope 和变量天然具有关联性,但 scope 和一个表达式的结果要建立关联性可能理解起来更困难一点;还有如() => using foo这样可能的误用——虽然没有显式的花括号,但函数本身就是一个scope,所以执行完就直接dispose了),最终还是选择了基于变量绑定。

即使动机确实只是scope guard,但心智模型并不是直接由提案动机所决定,而是更多取决于实际的语法和语义是如何被程序员所感知、学习和使用的

既然该特性最终是一种变量绑定声明,并且提案已经在很多地方对此做了细致的特化处理,包括为了避免误用(和语法简化)而不允许解构;脚本不能有top-level的using声明(因为脚本的top-level产生全局变量,不存在block scope);模块可以有top-level的using声明(因为模块有隐式的block scope对应模块的执行)但不能export(因为export时生命周期已经结束)……那么让变量绑定在scope结束后无效化也是顺理成章的。

再次一点,是某些use case里有重用的需求。比如V8代表所提的递归锁(Reentrant mutex),提案champion所提的可以重新open的数据库连接之类的。但这一点我也是不同意的。这些用例本身比较偏门(像递归锁的主要用例即递归调用,并不会受到影响,只有显式地重入才可能受到影响),而且这些需求往往扩大了「dispose」的范畴,违反了之前说的dispose之后对象应不可用的惯例。当然用途被扩大化也许是不可避免的(参见这个老问题:如何评价王垠的 《讨厌的 C# IDisposable 接口》?,下面的许多回答都质量很高),但如果是回收之后还能重新再用,甚至像递归锁那样似乎只是当一个计数器,至少不应该是语法特性要重点考虑的问题。

更关键的是,这些需求都只是对象本身要重用,而不是变量绑定需要重用。如果一定要满足这些需求,完全可以在using声明前后再单独使用const声明来得到对同一个对象的另一个变量绑定来达成。虽然略有麻烦,但偏门甚至有滥用倾向的用例,需要更繁琐的方式才能达成,难道不是一个好事吗?我一直认为,好的设计应该是:

  • 对于常见的事情,可以简单地达成,并保证安全;
  • 对于不太常见的事情,有办法安全达成,但不必简单;
  • 对于不太常见且无法确保安全的事情,默认不能达成,如果可以达成,必须不简单,最好有显式的标记来确保使用者清楚自己在干什么。