Scala 的函数编程特性非常引人注目,但这并非 Java 开发人员应该对这门语言感兴趣的惟一原因。实际上,Scala 融合了函数概念和面向对象概念。为了让 Java 和 Scala 程序员感到得心应手,可以了解一下 Scala 的对象特性,看看它们是如何在语言方面与 Java 对应的。记住,其中的一些特性并不是直接对应,或者说,在某些情况下,“对应” 更像是一种类比,而不是直接的对应。不过,遇到重要区别时,我会指出来。
Scala 和 Java 一样使用类
我们不对 Scala 支持的类特性作冗长而抽象的讨论,而是着眼于一个类的定义,这个类可用于为 Scala 平台引入对有理数的支持(主要借鉴自 “Scala By Example”,参见 参考资料):
清单 1. rational.scala
class Rational(n:Int, d:Int) { private def gcd(x:Int, y:Int): Int = { if (x==0) y else if (x<0) gcd(-x, y) else if (y<0) -gcd(x, -y) else gcd(y%x, x) } private val g = gcd(n,d) val numer:Int = n/g val denom:Int = d/g def +(that:Rational) = new Rational(numer*that.denom + that.numer*denom, denom * that.denom) def -(that:Rational) = new Rational(numer * that.denom - that.numer * denom, denom * that.denom) def *(that:Rational) = new Rational(numer * that.numer, denom * that.denom) def /(that:Rational) = new Rational(numer * that.denom, denom * that.numer) override def toString() = "Rational: [" + numer + " / " + denom + "]" }
从词汇上看,清单 1 的整体结构与 Java 代码类似,但是,这里显然还有一些新的元素。在详细讨论这个定义之前,先看一段使用这个新Rational
类的代码:
清单 2. RunRational
class Rational(n:Int, d:Int) { // ... as before } object RunRational extends Application { val r1 = new Rational(1, 3) val r2 = new Rational(2, 5) val r3 = r1 - r2 val r4 = r1 + r2 Console.println("r1 = " + r1) Console.println("r2 = " + r2) Console.println("r3 = r1 - r2 = " + r3) Console.println("r4 = r1 + r2 = " + r4) }
清单 2 中的内容平淡无奇:先创建两个有理数,然后再创建两个 Rational
,作为前面两个有理数的和与差,最后将这几个数回传到控制台上(注意, Console.println()
来自 Scala 核心库,位于 scala.*
中,它被隐式地导入每个 Scala 程序中,就像 Java 编程中的 java.lang
一样)。
用多少种方法构造类?
现在,回顾一下 Rational
类定义中的第一行:
清单 3. Scala 的默认构造函数
class Rational(n:Int, d:Int) { // ...
您也许会认为清单 3 中使用了某种类似于泛型的语法,这其实是 Rational
类的默认的、首选的构造函数:n 和 d 是构造函数的参数。
Scala 优先使用单个构造函数,这具有一定的意义 —— 大多数类只有一个构造函数,或者通过一个构造函数将一组构造函数 “链接” 起来。如果需要,可以在一个 Rational
上定义更多的构造函数,例如:
清单 4. 构造函数链
class Rational(n:Int, d:Int) { def this(d:Int) = { this(0, d) }
注意,Scala 的构造函数链通过调用首选构造函数(Int,Int
版本)实现 Java 构造函数链的功能。
实现细节
在处理有理数时,采取一点数值技巧将会有所帮助:也就是说,找到公分母,使某些操作变得更容易。如果要将 1/2 与 2/4 相加,那么Rational
类应该足够聪明,能够认识到 2/4 和 1/2 是相等的,并在将这两个数相加之前进行相应的转换。
嵌套的私有 gcd()
函数和 Rational
类中的 g 值可以实现这样的功能。在 Scala 中调用构造函数时,将对整个类进行计算,这意味着将 g 初始化为 n 和 d 的最大公分母,然后用它依次设置 n 和 d。
回顾一下 清单 1 就会发现,我创建了一个覆盖的 toString
方法来返回 Rational
的值,在 RunRational
驱动程序代码中使用 toString
时,这样做非常有用。
然而,请注意 toString
的语法:定义前面的 override
关键字是必需的,这样 Scala 才能确认基类中存在相应的定义。这有助于预防因意外的输入错误导致难于觉察的 bug(Java 5 中创建 @Override
注释的动机也在于此)。还应注意,这里没有指定返回类型 —— 从方法体的定义很容易看出 —— 返回值没有用 return
关键字显式地标注,而在 Java 中则必须这样做。相反,函数中的最后一个值将被隐式地当作返回值(但是,如果您更喜欢 Java 语法,也可以使用 return
关键字)。
一些重要值
接下来分别是 numer
和 denom
的定义。这里涉及的语法可能让 Java 程序员认为 numer
和 denom
是公共的 Int
字段,它们分别被初始化为 n-over-g 和 d-over-g;但这种想法是不对的。
在形式上,Scala 调用无参数的 numer
和 denom
方法,这种方法用于创建快捷的语法以定义 accessor。Rational
类仍然有 3 个私有字段:n、d 和 g,但是,其中的 n 和 d 被默认定义为私有访问,而 g 则被显式地定义为私有访问,它们对于外部都是隐藏的。
此时,Java 程序员可能会问:“n 和 d 各自的 ‘setter’ 在哪里?” Scala 中不存在这样的 setter。Scala 的一个强大之处就在于,它鼓励开发人员以默认方式创建不可改变的对象。但是,也可使用语法创建修改 Rational
内部结构的方法,但是这样做会破坏该类固有的线程安全性。因此,至少对于这个例子而言,我将保持 Rational
不变。
当然还有一个问题,如何操纵 Rational
呢?与 java.lang.String
一样,不能直接修改现有的 Rational
的值,所以惟一的办法是根据现有类的值创建一个新的 Rational
,或者从头创建。这涉及到 4 个名称比较古怪的方法:+
、 -
、*
和 /
。
与其外表相反,这并非操作符重载。
操作符
记住,在 Scala 中一切都是对象。在上一篇 文章 中, 您看到了函数本身也是对象这一原则的应用,这使 Scala 程序员可以将函数赋予变量,将函数作为对象参数传递等等。另一个同样重要的原则是,一切都是函数;也就是说,在此处,命名为 add
的函数与命名为 +
的函数没有区别。在 Scala 中,所有操作符都是类的函数。只不过它们的名称比较古怪罢了。
在 Rational
类中,为有理数定义了 4 种操作。它们是规范的数学操作:加、减、乘、除。每种操作以它的数学符号命名:+
、-
、 *
和 /
。
但是请注意,这些操作符每次操作时都构造一个新的 Rational
对象。同样,这与 java.lang.String
非常相似,这是默认的实现,因为这样可以产生线程安全的代码(如果线程没有修改共享状态 —— 默认情况下,跨线程共享的对象的内部状态也属于共享状态 —— 则不会影响对那个状态的并发访问)。
有什么变化?
一切都是函数,这一规则产生两个重要影响:
首先,您已经看到,函数可以作为对象进行操纵和存储。这使函数具有强大的可重用性,本系列 第一篇文章 对此作了探讨。
第二个影响是,Scala 语言设计者提供的操作符与 Scala 程序员认为应该 提供的操作符之间没有特别的差异。例如,假设提供一个 “求倒数” 操作符,这个操作符会将分子和分母调换,返回一个新的 Rational
(即对于 Rational(2,5)
将返回 Rational(5,2)
)。如果您认为 ~
符号最适合表示这个概念,那么可以使用此符号作为名称定义一个新方法,该方法将和 Java 代码中任何其他操作符一样,如清单 5 所示:
清单 5. 求倒数
val r6 = ~r1 Console.println(r6) // should print [3 / 1], since r1 = [1 / 3]
在 Scala 中定义这种一元 “操作符” 需要一点技巧,但这只是语法上的问题而已:
清单 6. 如何求倒数
class Rational(n:Int, d:Int) { // ... as before ... def unary_~ : Rational = new Rational(denom, numer) }
当然,需要注意的地方是,必须在名称 ~
之前加上前缀 “unary_
”,告诉 Scala 编译器它属于一元操作符。因此,该语法将颠覆大多数对象语言中常见的传统 reference-then-method 语法。
这条规则与 “一切都是对象” 规则结合起来,可以实现功能强大(但很简单)的代码:
清单 7. 求和
1 + 2 + 3 // same as 1.+(2.+(3)) r1 + r2 + r3 // same as r1.+(r2.+(r3))
当然,对于简单的整数加法,Scala 编译器也会 “得到正确的结果”,它们在语法上是完全一样的。这意味着您可以开发与 Scala 语言 “内置” 的类型完全相同的类型。
Scala 编译器甚至会尝试推断具有某种预定含义的 “操作符” 的其他含义,例如 +=
操作符。注意,虽然 Rational
类并没有显式地定义 +=
,下面的代码仍然会正常运行:
清单 8. Scala 推断
var r5 = new Rational(3,4) r5 += r1 Console.println(r5)
打印结果时,r5
的值为 [13 / 12]
,结果是正确的。
Scala 内幕
记住,Scala 将被编译为 Java 字节码,这意味着它在 JVM 上运行。如果您需要证据,那么只需注意编译器生成以 0xCAFEBABE
开头的 .class文件,就像 javac
一样。另外请注意,如果启动 JDK 自带的 Java 字节码反编译器(javap
),并将它指向生成的 Rational
类,将会出现什么情况,如清单 9 所示:
清单 9. 从 rational.scala 编译的类
C:\Projects\scala-classes\code>javap -private -classpath classes Rational Compiled from "rational.scala" public class Rational extends java.lang.Object implements scala.ScalaObject{ private int denom; private int numer; private int g; public Rational(int, int); public Rational unary_$tilde(); public java.lang.String toString(); public Rational $div(Rational); public Rational $times(Rational); public Rational $minus(Rational); public Rational $plus(Rational); public int denom(); public int numer(); private int g(); private int gcd(int, int); public Rational(int); public int $tag(); } C:\Projects\scala-classes\code>
Scala 类中定义的 “操作符” 被转换成传统 Java 编程中的方法调用,不过它们仍使用看上去有些古怪的名称。类中定义了两个构造函数:一个构造函数带有一个 int
参数,另一个带有两个 int
参数。您可能会注意到,大写的 Int
类型与 java.lang.Integer
有点相似,Scala 编译器非常聪明,会在类定义中将它们转换成常规的 Java 原语 int
。
测试 Rational 类
一种著名的观点认为,优秀的程序员编写代码,伟大的程序员编写测试;到目前为止,我还没有对我的 Scala 代码严格地实践这一规则,那么现在看看将这个 Rational
类放入一个传统的 JUnit 测试套件中会怎样,如清单 10 所示:
清单 10. RationalTest.java
import org.junit.*; import static org.junit.Assert.*; public class RationalTest { @Test public void test2ArgRationalConstructor() { Rational r = new Rational(2, 5); assertTrue(r.numer() == 2); assertTrue(r.denom() == 5); } @Test public void test1ArgRationalConstructor() { Rational r = new Rational(5); assertTrue(r.numer() == 0); assertTrue(r.denom() == 1); // 1 because of gcd() invocation during construction; // 0-over-5 is the same as 0-over-1 } @Test public void testAddRationals() { Rational r1 = new Rational(2, 5); Rational r2 = new Rational(1, 3); Rational r3 = (Rational) reflectInvoke(r1, "$plus", r2); //r1.$plus(r2); assertTrue(r3.numer() == 11); assertTrue(r3.denom() == 15); } // ... some details omitted }
除了确认 Rational
类运行正常之外,上面的测试套件还证明可以从 Java 代码中调用 Scala 代码(尽管在操作符方面有点不匹配)。当然,令人高兴的是,您可以将 Java 类迁移至 Scala 类,同时不必更改支持这些类的测试,然后慢慢尝试 Scala。
您惟一可能觉得古怪的地方是操作符调用,在本例中就是 Rational
类中的 +
方法。回顾一下 javap
的输出,Scala 显然已经将 +
函数转换为 JVM 方法 $plus
,但是 Java 语言规范并不允许标识符中出现 $
字符(这正是它被用于嵌套和匿名嵌套类名称中的原因)。
为了调用那些方法,需要用 Groovy 或 JRuby(或者其他对 $
字符没有限制的语言)编写测试,或者编写 Reflection
代码来调用它。我采用后一种方法,从 Scala 的角度看这不是那么有趣,但是如果您有兴趣的话,可以看看本文的代码中包含的结果(参见 下载)。
注意,只有当函数名称不是合法的 Java 标识符时才需要用这类方法。
“更好的” Java
我学习 C++ 的时候,Bjarne Stroustrup 建议,学习 C++ 的一种方法是将它看作 “更好的 C 语言”(参见 参考资料)。在某些方面,如今的 Java 开发人员也可以将 Scala 看作是 “更好的 Java”,因为它提供了一种编写传统 Java POJO 的更简洁的方式。考虑清单 11 中显示的传统 Person
POJO:
清单 11. JavaPerson.java(原始 POJO)
public class JavaPerson { public JavaPerson(String firstName, String lastName, int age) { this.firstName = firstName; this.lastName = lastName; this.age = age; } public String getFirstName() { return this.firstName; } public void setFirstName(String value) { this.firstName = value; } public String getLastName() { return this.lastName; } public void setLastName(String value) { this.lastName = value; } public int getAge() { return this.age; } public void setAge(int value) { this.age = value; } public String toString() { return "[Person: firstName" + firstName + " lastName:" + lastName + " age:" + age + " ]"; } private String firstName; private String lastName; private int age; }
现在考虑用 Scala 编写的对等物:
清单 12. person.scala(线程安全的 POJO)
class Person(firstName:String, lastName:String, age:Int) { def getFirstName = firstName def getLastName = lastName def getAge = age override def toString = "[Person firstName:" + firstName + " lastName:" + lastName + " age:" + age + " ]" }
这不是一个完全匹配的替换,因为原始的 Person
包含一些可变的 setter。但是,由于原始的 Person
没有与这些可变 setter 相关的同步代码,所以 Scala 版本使用起来更安全。而且,如果目标是减少 Person
中的代码行数,那么可以删除整个 getFoo
属性方法,因为 Scala 将为每个构造函数参数生成 accessor 方法 —— firstName()
返回一个 String
,lastName()
返回一个 String
,age()
返回一个 int
。
即使必须包含这些可变的 setter 方法,Scala 版本仍然更加简单,如清单 13 所示:
清单 13. person.scala(完整的 POJO)
class Person(var firstName:String, var lastName:String, var age:Int) { def getFirstName = firstName def getLastName = lastName def getAge = age def setFirstName(value:String):Unit = firstName = value def setLastName(value:String) = lastName = value def setAge(value:Int) = age = value override def toString = "[Person firstName:" + firstName + " lastName:" + lastName + " age:" + age + " ]" }
注意,构造函数参数引入了 var
关键字。简单来说, var
告诉编译器这个值是可变的。因此,Scala 同时生成 accessor( String firstName(void)
)和 mutator(void firstName_$eq(String)
)方法。然后,就可以方便地创建 setFoo
属性 mutator 方法,它在幕后使用生成的 mutator 方法。