语言设计者的笔记本: 定量语言设计

使用实际数据来驱动语言进化决策

对于任何给定编程语言来说,都不缺乏新功能。语言设计者不仅要执行决定许多可能(而且经常不兼容)语言功能接收优先级这一困难任务,而且他们还必须考虑新语言功能与现有功能令人惊讶的交互。语言的进化通常需要在启用新编码模式的优势与破坏现有 “怪异” 代码潜在成本之间进行权衡。在这种情况下,可以使用实际数据量化 “怪异” 代码不寻常的程度,它可以为决策制定方式提供有价值的线索。

Brian Goetz, Java 语言架构师, Oracle

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



2011 年 7 月 18 日

关于本系列

每一个 Java 开发人员都可能有几个关于如何改进 Java 语言的创意。在本系列中,Java 语言架构师 Brian Goetz 探索了一些语言设计问题,这些问题已对 Java SE 7、Java SE 8 以及后续版本 Java 语言发展提出了挑战。

在我的第一次参加 JavaOne 大会时,我出席了一个由 Java™ 架构师 Graham Hamilton 主持的对话节目,他观察到 “ Java 社区的中的每个开发人员都有一个或两个有关 Java 语言的特性创意”。果然,我发现我也有一些。在此新的系列中,即语言设计者的笔记本,我将探索评估哪些新的创意可以进入 Java 语言的过程,以及在广泛使用的语言中为新功能腾出空间的挑战。在第一部分中,我将谈论实际数据如何用来告知并影响语言进化决策。

发展现有语言

在我们职业生涯中,我们可能都受到过设计新编程语言的诱惑 — 可能是出于无奈。我们描述摆在我们面前的具体问题解决方案的方式,以及我们表达此解决方案的工具,往往并不完全一致。我们用于描述结果代码的词汇唤起了我们的回忆:笨拙臃肿刺鼻。有时,问题归咎于我们自己的缺点 — 我们的解决方案根本就不足够精致或灵敏 — 但有时,用真正的编程语言表达时,甚至最精致的解决方案都感觉不那么精致了。

新语言的语言设计者花费了大量的时间来考虑功能;现有语言的语言设计者花费了大量的时间来考虑兼容性。

虽然显而易见的解决方案 — 设计您梦寐以求的语言 — 可能会很有趣,但是这是一场艰苦的战斗。即使您是建立在现有丰富的平台上(如 JVM) — 其提供如垃圾收集、并发控制、安全性、调试器支持以及丰富的运行时库等功能 — 大量工作仍然存在下来:如设计语言、实现编译器、实现任何不被基础平台支持的运行时功能、设计并实现核心库、开发 IDE 集成,等等。但这一切仍是容易的部分!困难的部分在后面:即让用户采用新的语言,以及处理不可避免的投诉和改进建议。然后才是真正困难的部分:面对您梦寐以求的语言其实并不完美的这样一个事实、它需要改进,而您需要在妥协改进计划和破坏用户现有代码的不兼容更改之间作出选择。您有 10 个用户时,比您有 1000 万个用户时更容易调整不兼容性的变更。新语言的语言设计者花费大量时间考虑功能;现有语言的设计者花费大量时间考虑兼容性。


简单示例:更精确的重新抛出(rethrow)分析

Java SE 7 中的新语言功能之一(作为 Project Coin 的一部分引入(请参考 参考资料))是更精确的异常重新抛出分析。在 Java SE 6 中,语言只使用已声明(静态)类型的异常参数来计算可能从块中抛出的异常类型,这意味着不能编译清单 1 中的程序:

清单 1. 不能在 Java SE 6 下编译的代码,因为 foo() 没有声明抛出 Throwable
void doIo() throws IOException { ... }

void foo() throws IOException { 
    try {
        doIo();
    }
    catch (final Throwable t) {
        log("Exception in foo!", t);
        throw t;
    }
}

清单 1 中的代码要做什么显而易见。通过 catch 子句捕获的惟一内容是 IOException 和未选中的异常(RuntimeExceptionError),所有这些都可从 foo() 抛出。但是异常参数 t 被声明为 Throwable,且如果没有修改 throws 子句,则 Throwable 不能从 foo() 中抛出。但这似乎有点苛刻。编译器可以非常清楚地找出哪种类型的异常 t 可以保留并证实 清单 1 不会违反 foo() 的合同 — 而无需程序员赴汤蹈火。更精确的重新抛出功能准确地启用这种排序分析:编译器从可由块(以及由之前的 catch 子句)抛出的异常集确定异常参数可能具有的异常类型集。

事实证明这个更精确的重新抛出分析(显然是非常有用的功能)与 catch 块的现有可访问性交互(其中编译器将拒绝捕获没有从相应 try 块抛出异常的 catch 块或者已经由以前的 catch 块捕获的异常)。这可能会破坏一些现有的代码,如清单 2 中的代码:

清单 2. 在 Java SE 6 下编译的代码,但在建议的更精确的重新抛出分析规则下却遭到拒绝
class Foo extends Exception {}
class SonOfFoo extends Foo {}
class DaughterOfFoo extends Foo {}

class Test {
    void test() {
        try {
            throw new DaughterOfFoo();
        } catch (Foo exception) {
            try {
                throw exception; // ***
            } catch (SonOfFoo anotherException) { 
                // Question: is this block reachable?
            }
        }
    }
}

在旧的分析下,清单 2 中的程序是合法的,因为 *** 处的 throw 语句被假定抛出 Foo,因此嵌套 catch 子句可以捕获 SonOfFoo。但是更精确的分析可显示在 *** 处唯一被捕获的是 DaughterOfFoo(或未选中的异常),嵌套 catch 子句无法被访问,因为知道 SonOfFoo 不能在此处被捕获,因此应视为一个错误而被拒绝。如果我们有严格地立场,“从不破坏现有代码”,那么我们将被迫拒绝这项有用的新功能。但是 清单 2 中的代码看起来很矫造。如果只是因为我们不破坏这种看起来矫造的代码而失去这个不错的新功能,我们将感到羞愧。因此我们应该怎么做呢?

在语言演变中这是一个经典的权衡:即以明显的方式改进语言的优势与破坏一些(可能很奇怪的)程序的未知风险之间进行权衡。虽然很容易一厢情愿地相信没有一个像这样的代码,但是对一些编码支持的网站研究足以证明作出如此归纳的愚蠢。

在异常参数 t 上,更精确重新抛出分析的初始设计还需要 final 修饰符。这种限制的意图是双重的。首先,这是为了防止人为分配,就是所说的针对 tSQLException(这将是一种合法的指定,但是随后将不再是重新抛出的恰当选择)。其次,这是为了减少破坏现有代码的机会,因为相对少的异常参数已经是 final;因此,即使像 清单 2 那样的示例在自然环境下存在,只有异常参数为 final 时才打开更精确的重新抛出分析,这将大大减少破坏现有代码的机会。然而,final 限制是令人烦躁的 — 开发人员常抱怨在编译器弄清楚需要了解的一切时,他们必须声明某些 final。以往这类决策都是根据直觉而定的。但是有一个更好的办法:收集一些数据。


语言库(Corpus )分析

如果发展广泛使用语言的挑战之一是那里有大量我们不想破坏的代码,那么一个优势就是有大量代码,我们可用于分析某些习惯用语出现的频率。通过抓取大量代码(语言库)并运行分析工具(通常是编译器的仪表化版本),您可以了解很多关于某些习惯用语在实现中出现的频率,以及它们是否普遍存在可以影响我们改进语言。

作为评估使用语言库制定语言改进决策效果的起点,我们采用了几个大型的且积极维护的开源代码库:JDK 库、Apache Tomcat 和 NetBeans。然后我们运行分析工具来检测任一 catch 块实例是否在旧的分析下可以编译而在新的规则下却不能编译。然而我们什么也没有发现。

这是非常令人鼓舞的,因为我们可以从主观的 “我不认为很多人会这样编码” 转到更具体的 “数百万行代码分析显示这种模式并不常发生”。虽然这并不意味着这种编码模式永远不会发生,但是它为其罕见性提供了真实的证据,而不是凭感觉。

在 JDK 代码库中,我们的分析显示:

  • catch 块的数量:19,254
  • 有效 final 异常参数的数量(不是 final 的异常参数,但是表现仿佛它们是):19,197 (99.7%)
  • 明确的最终异常参数的数量:16 (0.085%)
  • 无效的 final、非 final 异常参数的数量:41 (0.21%)
  • 编译在精确分析下失败的案例数量:0

有了这些数据,我们愿意放弃将 catch 规范为 final 以便获得更精确分析的要求 — 这使得很多开发人员都很高兴。

我们对于这种有限语言库的经验是如此令人鼓舞,即我们现在使语言库分析成为语言进化过程的关键部分。除了我在早期命名的代码库以外,现在我引入 Qualitas 语言库,这是一个有 100 多个流行开源包的策划集合(请参考 参考资料)。(在此语言库上的分析不会出现任何更精确的分析破坏现有代码的情况。)将来,为了进一步提高语言库分析的预测效率,我们还计划为封闭源代码的 Oracle 产品引入源数据库。


另一个示例:通用类型推断

Java SE 7 中引入的另一个语言功能(还作为 Project Coin 的一部分)是 diamond 或者通用类型参数的类型推断。该功能旨在减少不必要的冗余 — 对于开发人员来说是很常见问题源头 — 在此示例的代码中:

public List<String> list = new ArrayList<String>();

虽然类型参数需要在任务双方进行重复,但是大多数时候重复是多余的。相反,使用类型推断,编译器可以自己处理。有了类型推断,编译器可以通过强制解决计算出变量类型,而不是使程序员明确声明它们。一些语言(如 ML)大量利用类型推断来支持通用方法,但是类型推断只发生在 Java SE 5 中的 Java 语言。虽然类型推断并不完美而且有时以令人困惑的方式失败(这就是为什么我们有时必须使用 foo.<T>bar() 语法明确声明通用方法的类型参数),但是有限的类型推断常常使我们从静输入中受益,那么,输入。

Diamond 功能使用类型推断计算出赋值右侧的类型参数,并允许将前面的示例重新编写为:

public List<String> list = new ArrayList<>();

我们应用 diamond 功能时这个具体的示例只有六个字符的缩短,但是其他示例有很显著的缩短,因为类型参数可以具有更长的名称,且一些类型具有多个类型参数(例如,Map<UserName, List<UserAttribute>>)。

虽然类型推断是一项强大的技术,但是它具有局限性,尤其是在像 Java 那样具有参数多态性(通用)和包含多态性(继承)的语言环境中。从根本上讲,类型推断是约束解决技术,往往对使用哪些约束开始具有多个合理的选择。

作为具体示例,请考虑清单 3,声明了通用类型 Box<T> 并调用了 Box 结构函数:

清单 3. 来自 Java SE 7 的 diamond 功能的典型使用
public class Box<T> {
   private T value;

   public Box(T value) {
      this.value = value;
   }

   T getValue() {
      return value;
   }
}
... = new Box<>(42);

编译器应该为底部赋值中的 T 推断什么类型?肯定可以为 T 推断很多类型,我们知道:IntegerNumberObjectComparable<?>Serializable 以及一些看上去非常奇怪的类型(如 Object & Comparable<? extends Number> & ...)。

虽然设计了此功能,但是社区提出了两个相互竞争的推断架构:

  • 使用正在被赋值的变量的类型(称为简单架构)。
  • 使用赋值上下文,如简单架构中那样,还使用构造函数参数的类型(称为复杂结构)。

这两种架构的支持者都声称他们的算法 “更好”,且每一个都引用其他架构的失败为证。当然,两者工作的都很好,其中左边具有类似 Box<Integer> 的简单类型,但是在左边类似 Box<? extends Number> 时,事情开始变得棘手。在这种情况下,简单算法产生 Box<Number>(因为其只使用来自左边的类型),而复杂算法产生更精确的推断 Box<Integer>(因为其考虑到参数 42 的类型)。您可能会认为,“那很好,我喜欢复杂算法因为其给出更准确的答案。” 但是这只是一种情况。在这种情况下左边是 Box<Number>,复杂算法产生一个导致编译错误的推断:复杂算法推断 Box<Integer>,其不会分配给 Box<Number>(回想一下,通用型(不同于阵列)不是协变式的,因此分配 Box<Integer>Box<Number> 会产生类型错误),而简单算法产生 Box<Number>,在这种情况下它是更好的选择。更复杂的算法通常不会有完美的答案。

在 diamond 表达式出现在方法调用上下文时会有轻微的不同。请考作为 m() 方法的参数出现的 Box<>

void m(Box<Integer> box){...} 
...
m(new Box<>(42));

因为 Java 语言类型表达式是自下而上 — 以便在执行过载解析以前方法参数的类型可用 — 简单算法在这里什么也做不了。因此必将得出参数的类型是 Box<Object>,其将导致在方法解析以后出现编译错误,因为 Box<Object> 不会分配给 Box<Integer>。另一方面,复杂算法产生 Box<Integer>,因为这是使用构造函数参数可以推断的最具体类型。

因此,很容易找到简单算法产生更好答案的示例,而复杂算法产生更好的答案的另一些示例。我们应该如何选择?从历史上看,这些有关哪种情况更流行或重要,或者哪种错误更令人吃惊的决策都是基于感觉而制定的。但是由于 diamond 是旨在简化现有习惯用语的功能,所有我们可以使用通用方法调用的许多实例来指导我们。因此我们创建了专业版的编译器 — “diamond 查找器” — 其识别在没有导致编译失败的情况下何时可以删除明确的类型参数,且我们列出了两种算法的结果。

结果很有趣。在大约 200,000 个通用构造函数调用的实例上,两种算法大约 90% 的时候都可以预测到结果。这意味着两种架构都有效,且任何一个都不比另一个更有效。因此最起码我们了解到该功能是有用的且足以满足自身的需要 — 其在大多数时间的预测都是正确的,且对于开发人员来说事情变得更容易了。我们还了解到每一个算法在 90% 的时候所预测的都稍有不同 — 且彼此都不是对方的子集。因为任何一个都不比另一个具有更严格的预测,所以我们现在不能挑出一种算法并将其升级为另一种算法。但是由于两种算法都具有大概同等的效果,所以这意味着他们都是合理的选择,因此我们可以在次要考虑的基础上作出决策。最终我们选择了复杂算法,因为其更类似于语言中其他地方的类型推断行为。该决策使语言更加一致、使实现更加易于维护以及提供更多机会以便充分利用将来在类型推断中的改进。


结束语

在设计新语言功能时,很难预测开发人员将用它们做什么、它们将如何变更编码模式或者哪些方法会使开发人员在它们的缺点上受挫。但是,在细化现有语言功能时,我们可以现有代码库使用静态分析来回答有关开发人员实际上用这些功能来做什么的问题 — 并将那些对人们实际编码产生更明显影响的功能优先。此信息对于制定语言进化决策来说是无价的。在 Java SE 7 中,我们使用语言库分析来完善几个关键的语言设计决策,且这有助于指导我们作出更好的决策。语言库分析将是今后我们工具箱中的一个重要组成部分。

鸣谢:感谢 Oracle 的 Joe Darcy 和 Maurizio Cimadamore 对本文的大力支持。

参考资料

学习

获得产品和技术

讨论

  • 加入 developerWorks 中文社区。查看开发人员推动的博客、论坛、组和维基,并与其他 developerWorks 用户交流。

条评论

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=733398
ArticleTitle=语言设计者的笔记本: 定量语言设计
publish-date=07182011