语言设计者的笔记本: 首先,不要造成伤害

有时,新语言功能造成的糟糕代码多于好代码

尽管一些建议的语言功能可以解决遇到的某个问题,但其中大部分功能的存在都有现实环境中的根源,在这些环境中,现有功能无法使程序员轻松、清晰、简洁或安全地表达他们想要的概念。尽管头脑中有一个用例,“此功能使我能够编写我希望能够编写的代码”,但语言设计师还需要评估语言功能可能带来的糟糕代码。

Brian Goetz, Java 语言架构师, Sun Microsystems

Brian Goetz 的照片Brian Goetz 是 Oracle 的 Java 语言架构师和 developerWorks 的资深投稿人。Brian 的文章包括从 2002 年到 2008 年之间在这里发布的 Java 理论和实践 系列以及有关 Java 并发的权威性著作 实践中的 Java 并发(Addison-Wesley,2006 年)。



2011 年 9 月 19 日

关于本系列

每个 Java™ 开发人员可能都有一些关于如何改进 Java 语言的想法。在本系列中,Java 语言架构师 Brian Goetz 探讨一些为 Java 语言在 Java SE 7、Java SE 8 及更高版本中的演化带来挑战的语言设计问题。

当从头设计语言时,我们有机会分组评估语言功能,调整它们以实现协同交互或避免消极交互。而且我们有机会通过选择语言功能来挑选我们想要的编程语言风格和构思模型。在考虑现有 语言的新语言功能时,我们的选择较少。我们常常无法(至少不容易)调整其他功能来适应新功能,而且某些编程语言风格已存在于该语言的方言中。在这些情况下,我们常常只能围绕它们进行设计。

尽管一些建议的功能是由行不通的思路启发得到的,但大部分功能在具体的用例中拥有它们的根源。它们的诞生常常离不开语言中目前表达特定语言风格的非常繁杂、冗长或零散的代码所带来的挫败感,以及 “如果我可以仅编写……该多好” 的想法。但从启用这段很酷的代码良好的功能 是一个漫长的过程。显然,一种语言功能要值得拥有,它必须使我们能够表达某些在之前无法表达的 “良好的” 新程序,但新语言功能也可以使我们能够表达一些 “糟糕” 的程序。而且即使新功能可以避免新的 “糟糕” 程序,它也可能破坏现有的固定语言、用户期望或性能模型特征。改进现有的语言需要权衡更强的表达能力的优势与更低的安全性、功能交互或用户混淆的危害。

一个简单示例:在对象上使用 switch

Java SE 7 中引入的一种语言功能是允许 switch 语句操作 String 类型的变量以及原语类型和枚举。扩展 switch 语句的应用范围,不仅扩展到字符串,也扩展到其他类型,这已成为多年来反复的增强请求的主题。(例如,RFE 5012262 请求不仅可在字符串上使用 switch,也可在任何对象上使用 switch,通过其 equals() 方法进行比较(参见 参考资料)。另一个类似的频繁请求是允许非常量表达式显示为 switch 语句的 case 标签。

乍看起来,switch 语句似乎就是等效的 if ... else 语句嵌套在句法上的改进。实际上,开发人员在 switchif...else 语句嵌套之间的选择常常主要基于哪个在代码中更美观。但它们并不相同。switch 语句的内在限制(case 标签必须是常量值,switch 操作符仅限于行为类似于常量的类型)的存在具有性能和安全两方面的原因。常量标签的限制使分支计算成为了一种 O(1) 运算,而不是 O(n) 运算。在 if...else 语句嵌套中,到达 else 块需要执行所有比较,因为 if...else 语句的语义需要顺序执行。case 标签被限制为类似常量的值(原语、枚举和字符串),这确保了比较运算没有副作用(因此可以实现在其他情况下无法实现的优化)。如果我们允许将任意对象用作 case 标签,那么调用 equals() 方法可能具有意外的副作用。

如果我们从头设计语言,我们可能有更大的自由来决定程序员便捷性是否比这里的可预测性更重要,并相应地定义 switch 语句的语义(和限制)。但对于 Java 语言,时机已过。将 switch 扩展到类似常量的值以外,可能破坏 Java 开发人员多年建立起来的性能模型,所以 switch 中允许任意对象的更高表达能力无法抵消成本。因为 String 类是不变的,并且是高度具体化和受控的,所以将它放入 switch 中很实用,但最好不要止步于此。


一个具有更大争议的示例

Java SE 8 中最重要的新语言功能是 lambda 表达式(或闭包)。闭包是一种函数字面量,包含可视为一个值并在以后调用的延迟计算的表达式。而且它们具有词法作用域,这意味着闭包内的符号的含义应该与它们的外部含义相同(在闭包内对局部变量求模,这种求模可从词法作用域投影变量)。自 Java SE 1.1 开始,Java 语言就拥有闭包的一种简单形式,即内部类,但它们的限制和繁杂的语法阻碍了真正探索这类代码即数据机制所允许的抽象能力的 API 开发。

在语言中拥有闭包,使 API 能够表达更具协作性的(进而更丰富的)计算,允许客户端提供部分计算。Collections API 支持这种行为的一种有限形式,比如将一个 Comparator 传递到 Collections.sort(),但仅用于相对重量级的运算,比如排序。对于像 “创建一个大小大于 10 的元素列表”,我们会强制客户端手动公开该运算,如以下示例所示:

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

尽管此代码非常紧凑且具可读性,但 Collections API 对我们没有太大帮助,我们被迫进入了一种基本的顺序执行模式(因为 for 循环的语义是顺序的,所以这是我们迭代 Collections 元素的惟一方式)。此运算从 Collections 提取想要的元素子集 ,是一种常见的运算。如果可以将所有控制逻辑(串行或并行的)转移到一个库例程中,仅使用关于我们想要哪些元素的谓词来进行参数化。那么代码可精减为以下形式:

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

我们可以使用内部类实现此目的,但它们的使用非常繁杂,以至于有时似乎解决办法比问题更严重。内部类在开发出集合框架时就已诞生,但内部类的句法开销使支持使用它们的 Collections API 的创建令人不太满意。(这里的 lambda 表达式的语法,以及集合 API 的改进,是暂时的,而且仅能作为可使用 Java SE 8 编写哪些代码的建议。)

上一个例子中的 lambda 表达式是一种具有特别良好的行为的 lambda 表达式,是一种不从其词法作用域捕获任何值的表达式。但表达一种相对于作用域内已有的其他值的计算常常很有用,比如以下方法中对局部变量 n 的捕获:

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

内部类(以及 lambda 表达式)的一个限制是,它们仅能从其词法作用域引用 final 的局部变量。Java SE 8 中的 lambda 表达式使这一限制更受欢迎,它们还允许捕获有效的 final 变量,即那些没有声明为 final、但在最初赋值后不会修改的变量。(如果实例上下文中存在内部类表达式,那么内部类可引用易变的实例字段,但这不是一回事。可以将此情况视为,内部类中对来自闭包类的字段 x 的引用实际上是 Outer.this.x 的简写,其中 Outer.this 是一个隐式声明的 final 局部变量。)在此限制的多种动机中,最大的动机是,将局部变量捕获限制到 final 字段就会允许闭包复制引用,进而保留这样一种行为:局部变量的生命周期就是声明它的代码块的生命周期。

毫无疑问,仅从词法上下文捕获不变的状态这一限制,令程序员非常不满意。他们可能不满意的是,似乎尽管 Java 语言 final 会获得闭包,但闭包的这一方面似乎没有用武之地。

希望捕获易变的局部变量的典型代码示例可能类似于清单 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、从而易变函数可以并行应用到集合的不同元素时,问题会变得更糟。现在,我们在局部变量 sum 上创造了一场数据竞争,因为多个线程可能同时希望更新它。局部变量上的数据竞争将是一种新的危险,因为我们目前始终期望局部变量访问没有数据竞争。没有直观的途径来使 清单 1 中的代码是线程安全的,这使这种语言风格成为了一种等待时机发生的事故。

在这一点上,明智的方法是规避它。在并发 Java 程序中避免数据争用比我们想象的困难得多。一个远离此危险的安全情形是局部变量不受数据竞争影响,因为它们只能从单一线程访问。通过允许 lambda 表达式捕获易变的局部变量,会使它们的行为类似于字段,而不是不可见的局部变量,进而将它们暴露在数据竞争的危险之中。在 2011 年对语言进行让并发和并行运算更加危险的更改是很愚蠢的。

这种语言风格有可能得到补救,比如,通过在可捕获的易变局部变量上定义一个修饰符(进而明确区分它们与普通局部变量),该修饰符将捕获这些变量的 lambda 的语义限制到定义该变量的线程和词法作用域内。这样一种功能有利有弊,它增加了语言保留特定编程语言风格(以及一种在本质上串行的过时语言风格)的复杂性。

一种更好的解决方案

此刻不增加额外的复杂性来支持此语言风格的原因在于,有一种更好的方法来获得相同结果。此语言风格是映射(mapping) 运算与减(reduction)折叠(folding) 运算相结合的一个示例,其中将一个联接运算符(比如 summax)成对应用到了一个值序列。得益于联接性,这种减运算支持并行化。我们可以直接在集合上公开一个 mapReduce() 方法,如下所示:

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

这里,第一个 lambda 表达式是映射器(将每个元素映射到它的大小),第二个 lambda 表达式是一个减法器,它获取两个大小并相加。此代码计算的结果与 清单 1 中的示例相同,但采用并行友好的方式。(并行性不是没有代价的,库必须提供并行化,但至少在使用此方式表达语言风格时,库可以 并行实现该运算。不仅映射和减法支持并行化,映射和减法运算也可结合到单个并行循环中,这样效率更高。(而且这无需在客户端代码中包含易变状态即可完成。)

事实上,对于映射器和为整数求和而预定义的减法器,我们可以使用 size() 方法的方法引用,更紧凑地表达此过程:

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:首先,不要造成伤害。

参考资料

学习

获得产品和技术

讨论

条评论

developerWorks: 登录

标有星(*)号的字段是必填字段。


需要一个 IBM ID?
忘记 IBM ID?


忘记密码?
更改您的密码

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件

 


在您首次登录 developerWorks 时,会为您创建一份个人概要。您的个人概要中的信息(您的姓名、国家/地区,以及公司名称)是公开显示的,而且会随着您发布的任何内容一起显示,除非您选择隐藏您的公司名称。您可以随时更新您的 IBM 帐户。

所有提交的信息确保安全。

选择您的昵称



当您初次登录到 developerWorks 时,将会为您创建一份概要信息,您需要指定一个昵称。您的昵称将和您在 developerWorks 发布的内容显示在一起。

昵称长度在 3 至 31 个字符之间。 您的昵称在 developerWorks 社区中必须是唯一的,并且出于隐私保护的原因,不能是您的电子邮件地址。

标有星(*)号的字段是必填字段。

(昵称长度在 3 至 31 个字符之间)

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件.

 


所有提交的信息确保安全。


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Java technology
ArticleID=758500
ArticleTitle=语言设计者的笔记本: 首先,不要造成伤害
publish-date=09192011