你的分享就是我们的动力 ---﹥

语言设计者的笔记:以不伤害为首要原则

时间:2014-12-25 18:35来源:www.chengxuyuans.com 点击:

在从头开始设计一门语言的时候,我们有机会成组地去评估一些语言功能特性,对它们做调整以让它们能够协调交互并避免负面的交互行为。并且,我们还有机会挑选出哪些编程惯用技法(programming idiom)和心智模式是我们想用来促进整个语言功能的挑选过程的。在为现有的语言考虑新的语言功能的时候,我们可做的选择就较少了,我们往往不能(至少是不容易)调整其他的功能来适应新的功能,且某些编程技法就已经融合在了该语言自有的表述方式中。在这些情况下,通常我们能采取的最好做法就是围绕它们来做设计。

尽管某些提议的功能是由天马行空的创造性思维启发出来的,但其中的大部分还是能够在具体的用例中找到根由所在的。它们通常是挫折的产物,在用现有的语言编写一个特定的技法时,代码是这么的笨拙冗长,或是这么的脆弱,于是伴随而来的是这样的想法:“如果我仅需这样写的话......岂不是很好?”不过,把这一酷酷的代码变成一个良好的功能特性,这一过程有着一条很长的路要走。显然,对于一种可取的语言功能来说,其必须能让我们写出一些“好”的、以前是无法表达出来的新的程序——但是新的语言功能也可能会让我们写出一些新的“坏”程序来。且即使新的功能不会带来新的“坏”程序,它也有可能会危害到现有语言的不变量、用户预期或是表现模式特点。在促进现有语言的演化时,需要在增进的表现力带来的好处和降低的安全性、功能的相互影响或是用户的困扰等方面的坏处之间做权衡。

一个简单的例子:在对象之间做选择


Java SE 7中引入的一个语言功能是允许switch语言在类型为String的变量上操作,同样也允许其在原始类型和枚举变量上操作。不仅是把switch语句的范围扩展到字符串上,而且要扩展到其他类型上,这已经是多年来一直反复在提交的一个增强性请求了。(例如,RFE 5012262提出的要求是,不仅是在字符串上做选择切换,还可针对任何对象进行,经由它的equals()方法做比较[参见参考资料]。)另一被频繁提出的相类似的请求是,允许非常量表达式作为switch语句的case标签出现。

初一看,switch语句似乎不过是等价的if...else嵌套语句的语法糖而已。事实上,开发者常常会在switch和嵌套的if...else语句之间做选择,其很大程度上是基于那种语句在代码方面看起来更舒服一些。但它们并非是是等效的。基于性能和安全理由,switch语句存在着一些固有的局限性(case标签必须是常量,switch的操作对象仅限于那些其行为类似常量的类型)。 把标签限制为常量的做法使得分支的计算成为一个O(1)运算而非O(n)运算。在if...else语句嵌套中,到达else块需要执行完所有的比较,因为if...else语句的语义要求顺序的执行。case标签是类似常量的值(原始类型、枚举和串)的这种限制确保了了比较运算不会带来副作用(也因此允许某些否则是不可能会有的优化),如果我们允许任意对象都可以充当case标签的话,对equals()方法的调用可能会带来不可预料的副作用。

如果我们从头开始设计语言的话,我们就会有更多的自由来决定在这个地方是编程者的便利性更重要还是可预见性更重要,然后以此为据来定义switch语句的语义(和约束)。但对于Java语言来说,其就相当于是一艘已经离岸航行的船。扩展swtich,让其不仅只是支持类常量的值,这一做法会破坏掉Java开发者过去多年构建起来的执行模式,因此,允许在switch中使用任意对象的这种外加的表达手段并未能够抵偿其所带来的成本。因为String类是不可变的,且是高度规范的和受控的,因此把它放入到switch语句的圈子中是可行的——但要就此打住,这才是明智的。

一个更具争议的例子


Java SE 8中的最重要的新语言功能是lambda表达式(或说是闭包(closure))。闭包是函数字面量(unction literal)——是包含了一个递延计算的表达式,该计算可被当成值对待,在晚些时候再调用。闭包是词法作用域的(lexically scoped),这意味着闭包内部的符号的含义应该同其在外部的含义是一样的(在闭包内部的以局部变量为模的计算声明有可能会遮盖了来自词法作用域的变量)。从Java SE 1.1开始,Java语言就有了闭包的一种微弱形式——内部类——但其局限性和繁琐的语法阻碍了API的发展,没有真正地利用到这一把代码当作数据(code-as-data)的机制所允许的那种强大的抽象功能。

在语言中加入闭包能够让API表达出更具合作式的——因此是更丰富的——计算,只需客户提供少量的计算就可以了。Collection API支持这一行为的一种有限形式,比如说传递一个Comparator给Collections.sort(),但只限于相对重量级的诸如排序一类的操作。对较简单的操作,比如说“获得一个大小大于10的元素的列表”,我们取而代之的做法是强制客户手动地展开操作,就像这一例子展示的那样:

Collection bigOnes = new ArrayList();
for (Element e : collection)
    if (e.size() > 10)
        bigOnes.add(e);

尽管这一代码是适度紧凑且具可读性的,不过集合的API还是没有太多地帮到我们,我们被迫进入到一个很基本的串行化的执行中(因为for循环的语义是串行的,而这就是我们遍历集合中的元素的唯一手段)。这一操作——从一个集合中提取出一个所需的元素子集——这是一种常见的操作,如果我们把所有的控制逻辑(串行的或者并行的)移入到一个库例程中,该例程仅使用哪些元素是我们想要的这一断言来参数化,这会是一种不错的做法,那么代码会减少到如以下这个样子:

Collection bigOnes
    = collection.filter(#{ Element e -> e.size() > 10 });

我们可以使用内部类来做到这一点,但是它们的用法是如此笨拙,以致于有时候其所带来的效果看起来似乎比要解决的问题还糟糕。Collection框架还处在开发阶段时,内部类就是可用的了,Collections API的创建可促进它们的使用,但是内部类的语法开销使得这一做法变得不太合意了。(这里的lambda表达式语法,以及Collections API的改进,都是临时给出的,只是用来示意我们可能会写到Java SE 8中的是什么样的代码。)

前面例子中的lambda表达式是特别循规蹈矩的那种lambda表达式——该表达式没有从它的词法作用域中捕获什么值。但表达一个与已经存在于作用域中的其他值相关的计算通常来说是很有用的,比如说在下面的方法中捕获一个局部变量:

public static Collection biggerThan(Collection coll, int n) {
    return coll.filter(#{ Element e -> e.size() > n });
}

内部类以及lambda表达式一个局限性是——它们只引用来自它们的词法作用域的最终final)局部变量。Java SE 8中的lambda表达式让这一限制变得稍微的松泛了一些,其做法是也允许捕获有效的最终(effectively final)变量——这些变量没有声明成final的,但在完成最初的赋值之后就不再做改变了。(如果内部类表达式出现在某个实例上下文中的话,内部类可以引用可变的实例域(instance field),但这不是同一回事。关于这一问题的一种很好的理解方式是,如果内部类内部的某个引用指向的是包含它的类的某个域X,则该引用实际上是Outer.this.x的一个缩写,这里的Outer.this是一个隐式声明的最终局部变量。)就这一约束的几样动机中,很重要的一点是限制局部变量只能捕获最终域,这就允许了闭包拷贝引用,从而维持了这样的一种行为,即局部变量的生存期就是声明了该局部变量的块的生存期。

毫无疑问,只捕获词法上下文中不可改变的状态的这一约束相当刺激了编程者,这有可能是双重的刺激,因为情况看起来是这样的,就在Java语言最终把闭包纳进来时,闭包这一方面却似乎要错过这一班船了。

想要捕获一个可变局部变量的代码的典型例子如清单1所示:

清单1. 通过闭包来捕获一个可变的局部变量(在Java SE 8中是非法的)

int sum = 0;
collection.forEach(#{ Element e -> sum += e.size() });
System.out.printf(\"The sum is %d%n\", sum);

这毫无疑问看起来是一件合理的——甚至是显而易见的——希望完成的事情,在其他一些支持闭包的语言中,这毫无疑问是一种常见的做法。但为什么我们不想在Java中支持这一代码呢?

首先,尽管最初看起来不是这一回事,但这确实是对局部变量语义的一个重大改变。局部变量的生存期被限制为定义它的块的生存期。但lambda表达式被视为值,因此可被保存在变量中,并且可在声明被捕获变量的块已经脱离作用域后执行。如果捕获可变局部变量是允许的行为的话,平台就需要扩展局部变量的生存期,使之与任何捕获该变量的lambda表达式的动态生存期一样长。就编程者对局部变量的预期来说,这是一个较大的变化,特别是在对这些新的奇怪的长命局部变量不做任何特殊的声明方面。

在考虑到forEach()方法可能希望调用来自其他线程中的lambda表达式这一情况时,问题变得更糟了起来,因为这样的话该函数就有可能会以并行的方式应用到集合中的不同元素上。现在,我们可能已经在局部变量的计算上制造出了一个数据竞争(data race),因为多个线程有可能要同时更新该变量。局部变量上的数据竞争会是一种新的危害类型,因为目前我们总是可以认定局部变量的访问是不存在数据竞争的。没有什么简单的办法可用来把清单1中的代码变成线程安全的,这使得这一惯用技法变成了一个在等待时机发生的事故。

考虑到这里之后,谨慎的态度就要求我们有所回退了。在并发的Java程序中避免数据竞争已经是比我们所想的要困难得多了,一个躲开这一危害的安全的避风港就是:局部变量是不受数据竞争影响的,因为它们永远只会被单个线程访问。但是允许可变的局部变量被lambda表达式捕获这种做法提升了局部变量,它们的行为变得类似域而不是局部变量——这种提升是隐蔽的——因此就把它们暴露在了数据竞争的危害中。在2011年的今天,以一种会把并发和并行操作变得更加危险的方式来促进语言的发展,这是很愚蠢的做法。

一种可能可以拯救这种惯用技法的做法是——比如说,在可捕获的可变局部变量上定义一个修饰符(这样就把它们同普通的局部变量区分开来),这一方式约束了lambdas捕获这些可变局部变量的语义,仅限于作用在定义了变量的线程和词法作用域上。这样的一种功能可算是一种取舍——增加语言的复杂性来来维护一种特定编程技法(也因此是一种过时的——内在串行化的技法)的可行性。

一种更好的解决方案

现在不再欢迎通过附加的复杂性来支持这一惯用技法的一个原因是因为,还有更好的方法可用来达到同样的效果。该做法是与化简(reduce)或是折叠(fold)操作结合在一起的映射(map)操作的一个例子,某个关联的运算符(比如说sum或是max)凭此来成对地应用一系列的值。这样的化简操作,借助关联性带来的好处,很适用于并行。我们可以直接如此来把集合的一个mapReduce()方法暴露出来:

int sum = collection.mapReduce(0, #{ Element e -> e.size() },
                               #{ int left, int right -> left + right });

这里的第一个lambda表达是是一个mapper(映射器)(把每个元素和它的大小做映射),第二个lambda表达是是一个reducer(化简器),其取得两个大小数值并相加它们。这一代码计算出与清单1中的例子一样的结果,除了是以一种并行友好的方式来进行的这点不同之外。(这种并行性并不是白白得来的——库必须要提供并行处理——不过至少在这一惯用技法以这种方式来表达的时候,库是能够以并行的方式来实现该操作的。)映射和化简操作不仅要遵从并行处理,并且要能够被结合到单个的并行传递中,这会带来更高的效率。(在客户代码中,这些都可以在不使用可变状态的情况下就能做到。)

在现实情况中,我们的表达方式可能会更紧凑一些,我们的mapper会使用一个size()方法的方法引用,以及整数的求和会用到一个预先定义的reducer:

int sum = collection.mapReduce(0, #Element.size, Reducers.INT_SUM);

一旦你习惯了以这种方式来指定计算的想法之后,这一代码读起来就像是问题的陈述:把整数的加法应用到集合中的每个元素的size()方法的结果上。

别对此做什么抗争

大部分的开发者可能不需花费太长时间就会找出一种“绕过”对捕获可变的局部变量的限制:使用一个到只有一个元素的数组的最终引用,如清单2所示:

清单2. 使用指向只有一个元素的数组的最终引用来骗过编译器。不要这样做!

int[] sumH = new int[1];
collection.forEach(#{ Element e -> sumH[0] += e.size() });
System.out.printf(\"The sum is %d%n\", sumH[0]);

这一代码通过了编译器的检查——因此也许会有一种“骗过了系统”的这种短暂的满足感——但是其重新带来了数据竞争的可能性。这不是什么好主意,你应该抵制这种诱惑。这就像是移去了台锯上的刀片防护罩,增加了事故的风险;但与台锯不同的是,其他人而不是你更有可能会被锯断手指。

鉴于此问题有这样一个更安全的(且可能是更快的)惯用解决方法的存在——映射-化简——那么就没有理由去写这样不安全的代码了,即使“在这种情况下”它似乎是安全的也不行。

结论


在考虑一项新的语言功能的时候,很容易只看到其所能带来的好代码。我们应该始终如一地去找出更好地完成事情的做法,但新的语言功能也可能会让某些非常糟糕的事情发生。由于引入一项坏的语言功能的风险是如此之高,因此语言的设计者在进行好的方面是否大于坏的方面这样的成本-效益分析时,需要持有一种保守的心态。如果有疑问的话,那么我们应该听从这样的一句格言:以不伤害为首要原则(Primum non nocere: first, do no harm)。

参考资料


学习资料

1. Using Strings and Objects in Switch case statements:从Java的bug数据库中导出RFE 5012262。

2. Project Coin home page:Project Coin的目标是确定哪一种小语言的带来的变化应该加入到JDK7中。Coin的功能作为JSR 334的一部分由JCP来标准化。

3. Java Concurrency in Practice(Brian Goetz,Addison-Wesley,2006):Brian的书是关于Java并发编程的权威著作。

4. Articles and tutorials by Brian Goetz:看一看Brian的在developerWorks上的其他文章。

5. 浏览technology bookstore获取一些关于这些和其他技术主题的书籍。

6. developerWorks Java technology zone:可找到数百篇关于Java编程的各个方面的文章。

获取产品和技术

1. Open beta program for IBM SDK for Java 7:这一公开的测试版程序提供了最新的IBM SDK for Java 7的访问许可。

讨论

1. 阅读developerWorks的博客文章,并加入developerWorks社区。

关于作者

\"\"Brian Goetz是Oracle的一名Java语言架构师(Java Language Architect),并且是developerWorks的一名资深贡献者。Brian的著作包括了从2002年到2008发表在这里的Java原理和实践(Java theory and practice)专栏系列,以及关于Java并发性的这一权威作品:Java Concurrency in Practice(Addison-Wesley,2006)

转载注明地址:http://www.chengxuyuans.com/software_design/86263.html