Java 下一代: Groovy、Scala 和 Clojure 中的共同点,第 1 部分

探究这些下一代 JVM 语言如何处理操作符重载

Java 下一代语言(Groovy、Scala 和 Clojure)的共同点多于不同点,主要集中于很多功能和方便性上的共同点。本期文章探究它们各自如何克服 Java™ 语言中长期存在的一个缺点 — 无法重载操作符。还要讨论关联性和优先级等相关概念。

Neal Ford, Director / Software Architect / Meme Wrangler, ThoughtWorks Inc.

Photo of Neal FordNeal Ford 是一家全球性的 IT 咨询公司 ThoughtWorks 的主管、软件架构师和 Meme Wrangler。他还设计并编写了一些应用程序、教材、杂志文章、课件和视频/DVD演示文稿,他是多种技术书籍的作者或编辑,其中包括最近出版的这本 Presentation Patterns。他的工作重点是设计和构建大型企业级应用程序。他还是全球范围开发者大会上的一位国际知名的演讲者。您可以查看 他的网站



2013 年 5 月 13 日

关于本系列

Java 遗产将是平台,而不是语言。200 多种语言运行在 JVM 上,每种语言都带来了一些有趣的超越 Java 语言功能的新功能。本系列探究三种下一代 JVM 语言 — Groovy、Scala 和 Clojure — 比较和对比新的功能和范型。本系列旨在让 Java 开发人员预见自己不久的未来 — 并帮助他们在新语言的学习时间上作出妥当的安排。

编程语言中的好理念可以延续并扩展到其他语言,就像美酒一样历久弥香。因此,不足奇怪的是,Java 下一代语言 — Groovy、Scala 和 Clojure — 具有很多共同的特性。在本期和下一期 Java 下一代 文章中,我将探讨每种语言语法中功能清单的一致性。我从能够重载操作符这个特性说起 — 克服了Java 语言中长期存在的一个缺点。

操作符重载

如果您改造过 Java BigDecimal 类,可能看到过类似于清单 1 的代码:

清单 1. Java 代码中的 Lackluster BigDecimal 支持
BigDecimal op1 = new BigDecimal(1e12);
BigDecimal op2 = new BigDecimal(2.2e9);
// (op1 + (op2 * 2)) / (op1/(op1 + (op2 * 1.5e2))
BigDecimal lhs = op1.add(op2.multiply(BigDecimal.valueOf(2)));
BigDecimal rhs = op1.divide(
        op1.add(op2.multiply(BigDecimal.valueOf(1.5e2))),
            RoundingMode.HALF_UP);
BigDecimal result = lhs.divide(rhs);
System.out.println(String.format("%,.2f", result));

清单 1 中,我试图实现注释中的这个公式。在 Java 编程中,因无法重载数学操作符,使得我只能求助于方法调用。静态导入可以解决问题,但是对于所选择的上下文,显然需要适当的操作符重载。最初的 Java 工程师故意从语言上忽略操作符重载,不过这感觉增加了太大的复杂性。但是经验表明,因缺乏这一特性而强加给开发人员的复杂性更甚于潜在的滥用机会。

用稍微各不相同的方式,所有三种 Java 下一代语言都实现了操作符重载。

Scala 的操作符

Scala 通过放弃操作符与方法之间的区别而允许操作符重载。操作符只不过是具有特殊名称的方法。例如,要重写乘法操作符,可以重写 * 方法。[* 是一个有效的方法名称,这就是 Scala 使用下划线 (_) 符号而不是 Java 星号 (*) 符号来代表导入的原因之一。]

我使用复数来说明重载。复数是一种数学表示,包括实部和虚部,例如通常写作 3 + 4i 这样的形式(参见 参考资料)。复数在很多科学领域都很常见,包括工程学、物理学、电磁学以及其他理论。清单 2 显示了复数的 Scala 实现:

清单 2. Scala 复数
final class Complex(val real:Int, val imaginary:Int) {
  require (real != 0 || imaginary != 0)

  def +(operand:Complex) =
      new Complex(real + operand.real, imaginary + operand.imaginary)

  def +(operand:Int) =
    new Complex(real + operand, imaginary)

  def -(operand:Complex) =
    new Complex(real - operand.real, imaginary - operand.imaginary)

  def -(operand:Int) =
    new Complex(real - operand, imaginary)

  def *(operand:Complex) =
      new Complex(real * operand.real - imaginary * operand.imaginary,
          real * operand.imaginary + imaginary * operand.real)

  override def toString() =
      real + (if (imaginary < 0) "" else "+") + imaginary + "i"

  override def equals(that:Any) = that match {
    case other :Complex => (real == other.real) && (imaginary == other.imaginary)
    case _ => false
  }

  override def hashCode():Int =
    41 * ((41 + real) + imaginary)
}

equals()match 关键字

清单 2 中另一个有趣的特性是在 equals() 方法中使用了模式匹配。尽管 Scala 中支持强制类型转换,但是类型匹配更为常见。that 参数被声明为 Any — Scala 继承层次的顶层。该方法的主体由 match 调用组成,在传递的类型匹配时,该调用检查实部和虚部的值,否则默认为 false

Scala 通过折叠不必要的脚手架代码,大大降低了 Java 语言的啰嗦程度。例如,在 清单 2 中,类中的构造函数参数和字段与类定义一起出现。在本例中,类的主体充当构造函数,所以对 require() 方法的调用在第一次实例化操作过程中验证值的存在。因为 Scala 自动提供字段,所以类的其余部分包含方法定义。对于 +-* 操作符,我都声明了接受 Complex 数作为参数的同名方法。复数的乘法不及加法和减法那么直观。清单 2 中已重载的 * 方法实现公式:

(x + yi)(u + vi) = (xu - yv) + (xv + yu)i

清单 2 中的 toString() 方法例示了 Java 下一代语言之间的另外一个共同点:使用表达式而不是语句。在 toString() 方法中,虚部为正时我必须提供加号 (+),否则,虚部的隐式减号就足够了。在 Scala 中,if 是一个表达式,而不是语句,不再需要 Java 三元操作符 (?:)。

实际上,增加的 +-* 方法都跟标准的操作符没什么区别,如清单 3 中的单元测试所示:

清单 3. 练习 Scala 复数
class ComplexTest extends FunSuite {
  test("addition") {
    val c1 = new Complex(1, 3)
    val c2 = new Complex(4, 5)
    assert(c1 + c2 === new Complex(1+4, 3+5))
  }

  test("subtraction") {
    val c1 = new Complex(1, 3)
    val c2 = new Complex(4, 5)
    assert(c1 - c2 === new Complex(1-4, 3-5))
  }

  test("multiplication") {
    val c1 = new Complex(1, 3)
    val c2 = new Complex(4, 5)
    assert(c1 * c2 === new Complex(
        c1.real * c2.real - c1.imaginary * c2.imaginary,
        c1.real * c2.imaginary + c1.imaginary * c2.real))
  }
}

清单 3 中的测试失败,揭示了一个有趣的不一致性。后面讨论 关联性 时,我指出并解决了这个问题。但是,现在简单介绍一下 Groovy 和 Clojure 中的重载。

Groovy 的映射

通过提供您可以重写的映射方法,Groovy 重载任何 Java 操作符。(例如,要重写 + 操作符,您可在 Integer 类重写 plus() 方法。)在 “函数设计模式,第 3 部分”,即我 函数式思维 系列(探讨函数语言中的可扩展性)中的一期文章,我用同一个复数例子详细介绍了 Groovy 的操作符重载。

在 Groovy 中,您无法创建新的操作符(尽管可以创建新方法)。一些框架(比如 Spock 测试框架;参见 参考资料)重载难以理解却实际存在的操作符,比如 >>>。Scala 和 Clojure 都更加一致地对待操作符和方法,尽管方式有所不同。

Groovy 也引入了几个方便的新操作符,比如 ?.Elvis 操作符 (?:),—前者是安全导航 操作符,它确保所有调用者都不为空,后者是 Java 三元操作符的简写形式,对于轻松提供默认值非常有用。Groovy 对新操作符没有扩展方法,防止了开发人员重载它们。至于开发人员为什么想要重载它们,原因不是很清楚:操作符重载的一个基本原因在于,以前的操作符使用经验可以增加代码的可读性。您不可能在 Groovy 外面培养这些操作符的使用经验。如果您为方便性使用操作符时破坏了代码可读性,那么操作符重载将变成危险的事情。

Clojure 的操作符

跟 Scala 中一样,Clojure 中的操作符也只是带有符号名称的方法。因此,比如说您可以随便为自己的定制类型创建一个 + 方法。然而,要在 Clojure 中正确重写操作符,您必须理解协议 和一种用于从公共内核生成一组方法的技术。我将在下一期文章中讨论这一内容。


关联性

操作符关联性 是指操作符是等式左侧还是右侧的方法。Scala 对空格的使用不同于大多数其他语言,因为基本上任何 Scala 方法都可以充当操作符。例如,表达式 x + y 实际上就是方法调用 x.+(y),如清单 4 中 Scala REPL(解释器)会话中所示:

清单 4. Scala 中的空格转化
scala> val sum1 = x.+(y)
sum1:Int = 22

scala> val sum2 = (12).+(10)
sum2:Int = 22

清单 4 中可以看到,空格转化也适用于常量。愿意的话,您可以将 Scala 中的所有方法都看作操作符。例如,String 类具有一个 indexOf() 方法,它返回被作为参数传递的字符串中的索引位置。在 Scala 中,您可以用传统方式通过 s.indexOf('a') 调用过它,或者作为操作符 — 像 s indexOf 'a' 中一样。(这个具体的方法很有趣,因为它有一个已重载的版本,接受一个额外的参数来指定搜索开始处的索引位置。您仍然可以使用操作符表示法调用它,但是必须将参数放置在括号中,就像 s indexOf('a', 3) 中一样。)

Groovy 遵循 Java 关联性约定,所以特定操作符的规则由语言定义。Clojure 根本不关注关联性;它的 Lisp 语法不依赖于关联性,因为所有语句都是意义明确的。

由于 Scala 的目标之一就是允许开发人员可以将任何东西都用作操作符,所以它不能依赖于任何关联性规则。该语言如何才能允许特殊的操作符却仍然建立规则?Scala 以一种支持开发人员最大自由度的创新方式解决了这个问题 — 使用操作符命名约定。默认情况下,Scala 中操作符是左关联的:表达式分解为一个对左操作数的方法调用,例如,这意味着表达式 x + y 分解为 x.+(y)。然而,如果方法名称以 : 结尾,则操作符是右关联的。例如,i +: j 调用转化成 j.+:(i)

关联性解释了为什么 清单 3 中的测试无法得到正确的结果。清单 2 中的 Scala Complex 定义中,我实现了 +- 操作符的版本,它们既接受 Complex,也接受 Int 参数类型。这种类型的灵活性允许复数与一般整数(即实部为零的复数)相互操作。清单 5 说明了单元测试中的互操作性:

清单 5. 混合类型的测试
test("mixed addition from Complex") {
  val c1 = new Complex(1, 3)
  assert(new Complex(7, 3) == c1 + 6)
}

test("mixed subtraction from Complex") {
  val c1 = new Complex(10, 3)
  assert(new Complex(5, 3) == c1 - 5)
}

清单 5 中的两个测试都能通过,没有问题 — 操作符方法的 Int 版本开始了。然而,如果我尝试以下测试,它则会失败:

test("mixed subtraction from Int") {
  val c1 = new Complex(10, 3)
  assert(new Complex(15, 3) == 5 + c1)
}

两个测试之间的细微差别就在于关联性上。记住,在本例中,Scala 调用左操作符的方法,这意味着它试图开始一个为 Int 定义的方法(它知道如何处理复数)。

为了解决这个问题,我在 IntComplex 之间定义了一个隐式强制类型转换。有多种方式展示这种转换,我将在以后几期文章中更加详细地介绍。在本例中,我创建了一个伴生对象,即 Complex,这是一个用于放置 Java 语言中声明为 static 的方法的地方:

final object Complex {
  implicit def intToComplex(x:Int) = new Complex(x, 0)
}

该定义包含单个方法,此方法接受一个 Int 并将之返回为 Complex。将这个声明作为 Complex 类放置在相同的源文件中,然后我通过 import nealford.javaNext.complexnumbers.Complex.intToComplex 命令在我的测试案例中导入该方法,可以支持隐式转换。有了转换之后,测试案例成功通过,因为测试知道如何处理通过操作符发出的方法调用。


优先级

操作符优先级(或者操作顺序)是指规定潜在存在歧义的情况下操作发生顺序的语言规则。对于公共操作符,Groovy 依赖于 Java 优先级规则;对于自己的定制操作符,它定义自己的规则。Clojure 不具有或不需要优先级规则;因为所有代码都以括号形式编写,不再会出现中缀表示法中固有的歧义性。

Scala 使用操作符名称的第一个字符来确定操作顺序,优先层次是:

  • 所有其他特殊符号
  • * / %
  • + -
  • :
  • = !
  • < >
  • &
  • ^
  • |
  • 所有字母
  • 所有分配操作符

以较高级别字符开始的操作符具有较高的优先级。例如,表达式 x *** y ||| z 将分解为 (x.***(y)).|||(z)。该规则惟一的例外是分配语句,或者任何以等号 (=) 结尾的操作符,它们自动具有最低优先级。


结束语

Java 下一代语言的一个共同目标是,简化那些影响着 Java 语言的繁琐限制。操作符重载是每种语言解决这个问题的一个重要途径。所有三种语言都允许操作符重载,只是实现的方式有所不同。处理关联性和优先级这类问题的方式的细微差别表明了,各个语言部分是如何紧密联系的。Clojure 的有趣方面之一是它的语法 — 因为每个表达式都是括号形式的 — 消除了优先级和关联性中的歧义。

在下一期文章中,我将探究 “一切都是对象” 这一说法在 Java 下一代语言中的深层含义。

参考资料

学习

  • 复数:在 Wikipedia 了解复数(用于本文中的例子)。
  • Groovy:Groovy 是一种 JVM 动态语言。阅读 Groovy 操作符重载 文档。
  • Scala:Scala 是一种基于 JVM 的现代函数语言。
  • Clojure:Clojure 是一种运行在 JVM 上的现代函数 Lisp。
  • 探究 Java 平台的各种可选语言:按照这一知识路线,查看跟各种可选 JVM 语言有关的 developerWorks 内容。
  • 语言设计者笔记:在这个 developerWorks 系列中,Java 语言架构师 Brian Goetz 探讨了一些语言设计问题,这些问题为 Java SE 7、Java SE 8 及更高版本中 Java 语言的发展带来了困难。
  • 函数式思维:在 developerWorks 上 Neal Ford 的专栏系列中探究函数编程。
  • 此作者的更多文章(Neal Ford,developerWorks,2005 年 6 月至今):了解 Groovy、Scala、Clojure、函数编程、架构、设计Ruby、Eclipse和其他 Java 相关技术。
  • developerWorks Java 技术专区:找到 Java 编程各个方面的数百篇文章。

获得产品和技术

讨论

条评论

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, Open source
ArticleID=928791
ArticleTitle=Java 下一代: Groovy、Scala 和 Clojure 中的共同点,第 1 部分
publish-date=05132013