语言设计者的笔记本: 一揽子交易

当添加语言功能会附带其相关功能时

向语言添加某个重要的新功能时,往往会出现以下情况:新功能是必需的,或者至少鼓励添加的,而附加的其他新功能则有好有坏。在这一期的 语言设计人员笔记 中,Brian Goetz 将讨论在添加语言功能时如何附带添加其相关功能。

Brian Goetz, Java 语言架构师, Oracle

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



2011 年 12 月 12 日

关于本系列

每一位 Java™ 开发人员可能都会有一些关于如何才能改进 Java 的想法。在本系列文章中,Java 语言架构师 Brian Goetz 将探讨 Java 语言(其中包括 Java SE 7、Java SE 8 及更高版本)发展过程中出现的一些语言设计问题。

一些语言功能,比如 Java SE 7 中引入的新功能 “二进制整数文字量 (binary integer literals)”,均可独立运行。但是,由于需要额外添加一些功能才能正常运行或与现有功能进行交互,许多重要的语言功能遭到遗弃。这是一个潜在的问题,因为添加重要语言功能能总是具有很大风险,在此基础上添加其他功能只会带来更大的风险。

增加了使用便利功能的压力

一个语言功能携带另一个功能所带来的潜在风险是:新的功能增加了添加无关 “便利功能” 的压力。我们来看一下 Java SE 5 中添加的自动装箱 (autoboxing) 功能。Java 从一开始就拥有针对这些类型的原始类型(比如 int)和对象 "box" 包装类(比如 Integer),还拥有在这些类型之间转换的方法。自动装箱这一新功能是原始类型与其相应包装类之间的隐式转换,是从一开始就已添加的一个功能,在发布 Java SE 5 之前,一些用户就曾调用过它。该功能是泛型的附带品,也是集合的伴生物,这最终为添加便利功能带来了巨大的压力。在以前,您需要在原始类型与其包装器之间进行转换,但泛型集合使这一情景变得更为普遍,因为现在可以很方便地创建密钥或值都被装箱为原始类型的集合。添加自动装箱功能原本只是有点小麻烦,现在变成了大麻烦,添加自动装箱功能的压力在增加。

另一个类似的示例是 enum 与静态导入(也是 Java SE 5 中添加的新功能)之间的关系。静态导入是将要引入的另一个便利功能;有必要提一下的是,不只是 PIMath.PI 也总是令人感到讨厌。不过,正是因为 Java SE 5 中添加了 enum 功能,才会产生证明静态导入的压力。Enum 可以轻松创建指定结构化常数,比如 Color.REDColor.BLUE 等等,当您可以更轻松地创建结构化常数时,就可以节省更多的时间和精力。以前只有几个静态系统定义常数可用,enum 为用户提供了创建静态常数的功能,这使得每次使用时都需要限定名称这一麻烦的事情(而非只是说明是 RED 还是 BLUE)变得更加麻烦。所以,虽然静态导入是一个在添加 enum 之前就已经添加的可独立运行的功能,但是,enum 为验证添加静态导入是否合法带来了压力。

虽然添加这些便利功能不是什么问题,但是它们可能具有副作用。比如,自动装箱功能与三元条件运算符在特定情况下无法进行交互,这会导致因为根本没有用来处理对象引用的代码而抛出 NullPointerException


LinQ:用一个功能的价格购买六个功能

只需添加一个语言功能便可获得它附带的一大堆相关功能,.NET 3.0 中添加的核心增强工具 LinQ (Language-Integrated Query) 可能是这方面的一个最佳示例。LinQ 使开发人员能够将 “对象-值” 查询直接嵌入其代码中。不仅可以对数据库使用这些查询,还可以对数据提供程序(比如 XML 文档或内存集合)使用这些查询。将查询语言嵌入通用编程语言的想法似乎很容易实现,但是一旦深入了解后,您就会发现需要添加其他许多功能才能实现它。

下面的 C# 代码展示了一个针对集合的典型 LinQ 查询。它采用了一个 Person 对象集合,并选择和打印出了年龄小于 18 周岁的人的姓名:

var results = 
    from p in people
    where p.Age < 18
    select new {p.FirstName, p.LastName};   

foreach (var r in results) {           
    Console.WriteLine(r.FirstName + " " + r.LastName); 
}

要完成此项工作必须组合使用一些查询。此项查询的结果类型究竟是什么呢?它不是 Person 对象的集合,因为该查询只询问姓名属性。相反,它是只包含姓名属性的类的集合;编译器基于查询的选定字段生成该类。为此,.NET 引进了匿名类 (anonymous class);否则,开发人员必须为每一个查询的结果创建一个新的指定类类型。

LinQ 还要求支持 lambda 表达式(闭包),虽然这在之前的查询示例中并不明显。LinQ 的实现策略涉及到将查询重写至针对某个提供程序的 API 调用中。(此提供程序 API 提供了将查询用于不同数据源的方式)。编译器将查询重写为如下所示:

var results = 
    people.Where(p => p.Age < 18)
          .Select(p => new {p.FirstName, p.LastName});

Where() 方法用了一个谓语来确定是否选择给定元素,并且生成一些可通过过滤器的元素。然后,对于每个选定元素,Select() 方法都会将该元素映像到只包含姓名属性的一个新的匿名类实例。

但是我们的工作还没结束。如果数据提供程序是一个 SQL 数据库,则必须将 WHERE 子句应用于每条记录。实现此操作的一个方法是将数据库中的所有记录都拖至应用程序中,然后测试每条记录的 Age 属性。但是此操作的效率可能很低,我们宁可采用 WHERE 子句评估相近的数据。明智的做法是将 WHERE 子句发送至数据库,但是这意味着我们必须将谓语 p.Age < 18 转换成 SQL,并将其发送给数据库。

使用 LinQ 解决此问题的解决方法是使用表达树,这是一种类似反射的机制,您不仅可以用它反射类的成员,还可以用它反射某个方法的代码。这充许 SQL LinQ 提供程序分析传递给 Where() 方法的闭包,并将它转换成 SQL。

将查询转换成对 API 的调用也需要添加扩展方法Where() 方法是在集合对象上调用的,但它不是该集合框架的成员。相反,它是一个由 LinQ 子系统定义的静态方法,并被注入 IEnumerable(Java Iterable 的 .NET 对等物)。否则,用户无法轻松表达针对集合的 LinQ 查询。

最后,我们需要使用隐式类型化变量,这样就可以为变量分配查询,不必显式声明其类型。因为查询的结果是某个匿名类型的 IEnumerable,编译器可以识别这种结果的类型,但无法用 C# 表达出来(此外,对于某些查询,结果类型是可表达的,但是叙述冗长,不便写下来)。这就是 C# 支持使用 var 来声明变量的原因,这样做可以让编译器识别出变量类型,无需将它拼写出来,这样做不是鼓励程序员偷懒,而是因为有时无法写下变量类型。

我们还有很长的路要走!最初的时候,似乎目标很容易实现,只需将查询嵌入通用语言即可,结果,需要使用匿名类、隐式类型化变量、闭包、扩展方法以及表达式的反射机制。对于 LinQ 的关键目标而言,这些功能中的每个功能都是至关重要的。

作为用户,您可能会作出这样的结论:用一个功能的价格即可购买六大功能,这是一个不错的想法。但是向现有语言添加一项新的语言功能总是需要付出一定的成本。当您添加语言功能 B 来支持语言功能 A 之后,并不要求只能将功能 B 用于功能 A。功能 B 本身可能并不是很理想,或者无法与其他语言功能进行很好的交互。明显,添加功能 A 是有一个特定目标的,比如使语言更安全、表达更清楚。但要评估功能 B 成功与否的话,不是相对于功能 A 来评估功能 B,二是需要相对于涉及的新语言(包括与功能 A 相关的所有功能)进行评估。如果所涉及的语言不是您最终想要的语言,那么您可能需要重新考虑最初要添加的功能。


Java 中的 Lambda 表达式

Java SE 8 中的核心语言增强功能是 lambda 表达式,或称为闭包。但是,正如 .NET 中的 LinQ 一样,lambda 表达式会拖入一大堆其他功能,其中包括 SAM 转换、增强的类型推断、方法引用和扩展方法,以便为用户提供 lambda 的全部效益。

因为 lambda 表达式(表达式表示函数)是 Java 中一种新值,我们需要一种方法来写下其类型。对于 Java 中的 lambda 表达式,早期的建议是要求将函数类型添加至类型系统,如 “function from int to int"。函数类型确实是表现 lambda 表达式类型的一种自然方法,但遗憾的是它们无法与现有语言功能 “擦除 (erasure)” 很好地进行交换。因为在底层字节码中,表现函数类型的自然方法是使用泛型,并且应该将函数签名中的原始类型进行装箱,一种类型不能让采用函数类型的多个方法过载,即使它们的参数完全不同。函数类型可能是表达 lambda 类型的一种自然方法,但不是表达已擦除函数类型的方法。

因此,Java SE 8 中的 lambda 表达式会带来另一种不同的相关功能 SAM 转换,而不是函数类型。SAM(单一抽象方法)类型一直是我们在 Java 语言中表达函数的一种方式,它与某个方法有关系,比如 RunnableComparatorActionListener 方法。如果我们构建 SAM 类型(大多数类型已经存在于库中)的 API,编译器可以在 lambda 表达式(类似于函数字面量)与 SAM 类型(其参数类型、返回类型和异常类型均与 lambda 表达式相符)之间进行转换。例如,下列的代码声明了一个 Comparator<String>,该表达式对字符串的长度进行比较,并使用一个 lambda 表达式来定义 Comparator:

Comparator<String> c 
    = (String a, String b) -> a.length() — b.length();

因为 lambda 表达式拥有正确的参数和返回类型,编译器将证实可以把这些类型转换成 Comparator<String>,并生成完成此操作的相应代码。这个转换就叫做 SAM 转换

lambda 表达式的基本原理是提供了一种将代码表达为数据的方式,从而可以将代码字面量传递至代码库中,以便在方便的时候随时调用。另一个动机是减少内部类的冗余性,目前 lambda 表达式是达到此效果的最简便的方法。当您踏上了消除冗余句法结构的道路之后,通常会顺着这条道越走越远。所以,lambda 表达式带来了另一个相关功能,即通过目标类型化(target typing) 实现的扩展类型推断。因为之前的 lambda 表达式将分配给 Comparator<String>,所以关于 ab 的类型均为 String 的显式声明就显得有些多余,因为编译器通常会为我们指明这一点。通过使用赋值上下文的类型来推断 ab 的类型,我们可以将示例代码精简为:

Comparator<String> c 
    = (a, b) -> a.length() — b.length();

如果我们拥有一个 Person 对象集合,并且想根据 Person 的姓氏对列表进行分类,那么我们立即将代码改写如下:

Collections.sort(people, new Comparator<Person>() { 
    Public int compare(Person a, Person b) { 
        return a.getLastName().compareTo(b.getLastName());
    }
}

在使用 lambda 表达式时,可以使代码更加简洁:

Collections.sort(people, (a, b) -> 
    a.getLastName().compareTo(b.getLastName());

这在减少冗余性的路上已经跨进了一大步,但仍然不是很抽象,因为它还是会要求用户以命令方式来计算比较函数。因为在库中做了一些小小的更改,所以我们能够更好地利用 lambda 表达式来分离分类的核心部分,即分类关键字的选择。因为 StringComparable,所以分类方法早就应该知道如何在提取分类关键字后执行比较操作:

Collections.sortBy(people, p -> p.getLastName());

这个代码的确好了很多,它开始读取更多像 “按姓氏对人们进行分类” 之类的问题语句。但是,由于删除了样板文件,我们意识到,用来提取分类关键字的词语(在这里是姓氏)本身有点让人晦涩难懂。上面的 lambda 表达式什么都没做,只是提取其参数(在本例中为 none),将该参数传递给现有方法 getLastName(),并使用将在其上调用该方法的对象充当第一个参数。虽然在本例中这样做看起来不是太糟糕,因为没有其他必须采用给定名称的参数(以及重复两次的名称),但是直接为方法命名看起来似乎更好一些。相关功能方法引用 允许我们这样做,它通过名称引用某个方法,并将它作为类似 lambda 表达式的 “函数-值” 数据对待:

Collections.sortBy(people, Person::getLastName);

最后,由于已经删除了样板文件,sortBy() 方法实际上不应该是某实用类中的静态方法,而应该是集合上的实例方法,这一事实表现得比以往更为明显。但是,接口的不良属性之一是:在我们指定该属性之后,就无法在不破坏现有实现的情况下添加新方法。将要引入 lambda 表达式的最终功能是虚拟扩展方法,它充许我们以适当的方式向接口添加新方法,实现此操作的方法是随方法声明提供一个(可重写的)默认实现。这样我们就可以添加 lambda 友好(和潜在并行友好)方法,比如 ListforEach()。通过向针对 sortBy()List 添加一个扩展方法,我们的示例现在如下所示:

people.sortBy(Person::getLastName);

奇怪的是,我们最终版本根本没有使用 lambdas!但是它体现了向语言添加 lambda 表达式的核心目标,即捕抓部分计算并像传递数据一样传递它们的功能,这使我们能够以更丰富的方式对库的功能(比如分类)进行参数化。在这个特殊的示例中,与 lambda 表达式相比,方法引用更清楚地表达出了我们想要表达的东西,但表达的意思是相同的。

在不使用 SAM 转换、类型推断、方法引用和扩展方法的情况下,将 lambda 表达式添加至 Java,这是完全有可能的。然而,缺少这些功能也有可能最终成为痛点,甚至我们可能都没有意识到痛苦的真正来源在哪儿。


有时恰恰相反

不向 Java 添加函数类型的理由可能是不想要(或支付不起)不太实用的额外语言功能。虽然函数类型会是表示 lambda 表达式类型的最自然的一种表达方法,并且会减少对大量名义上的类型(比如 PredicateMapper)的需要,但它与擦除功能之间的互动表现得不太令人满意。人们对此的直接响应是:通过引入另一个相关功能,即具体化,来消除糟糕的互动功能。向 Java 语言添加具体化泛型有利也有弊,但事实上,在添加 lambda 表达式的同时,添加大型的、意义深远(影响语言、编译器和库)的功能,比如具体化功能,这样做有些不太切合实际。因此,鉴于我们无法承受与函数类型相关的附带功能,所以我们只能跟函数类型说再见(至少现在是这样)。


结束语

大多数大型的语言功能都无法完全独立运行,它们通常需要其他相关功能的支持,然后才能实现它们期望为我们提供的全部利益。在考虑添加具有其他附带功能的功能时,必须认真考虑是否真的想要这些不太实用的相关功能,因为我们可能会被这些功能绊住。如果我们无法接受某项功能的其他相关功能,则有可能必须放弃该功能。

参考资料

学习

获得产品和技术

讨论

  • 加入 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=780612
ArticleTitle=语言设计者的笔记本: 一揽子交易
publish-date=12122011