首页 » Java程序员修炼之道 » Java程序员修炼之道全文在线阅读

《Java程序员修炼之道》9.4 Scala对象模型:相似但不同

关灯直达底部

Scala有时被称为“纯粹”的面向对象语言。也就是说所有的值都是对象,所以面向对象的概念几乎随处可见。本节一开始,我们会探索一下“一切皆对象”的后果。这个主题会很自然地引导我们去思考Scala的类型层级。

Scala的类型层级跟Java有几个重要差异,包括装箱和拆箱等Scala处理原始类型的方式。之后我们会考虑Scala的构造方法和类定义,以及它们怎么帮你少写代码。接着是关于trait(特质)的话题,然后再讨论Scala的单例、伴侣和包对象。本节最后我们会看一下怎么用case类再进一步减少套路化代码,并以一个有警示意义的Scala语法故事作为结尾。

让我们开始吧。

9.4.1 一切皆对象

Scala的观点是所有类型都是对象类型。包括Java所谓的原始类型。图9-1展示了Scala的类型继承关系,包括所有值类型(即原始类型)和引用类型,并标注了与Java中类型的对应关系。

图9-1 Scala中的继承层级

从图中可以看到,Unit和其他值类型在Scala中都是正确的类型。AnyRef类相当于java.lang.Object。每次见到AnyRef,你都应该在心里把它换成Object。它之所以没叫Object,是因为Scala也要运行在.NET运行时平台上,所以它要给这个概念再起个名字。

Scala用extends关键字表示类的继承关系,而且它的用法跟Java很像:所有的非私有成员都会被继承下来,两种类型之间也会建立起父类/子类的关系。如果类定义中没有显式扩展其他类,则编译器会认定它是AnyRef的直接子类。

“一切皆对象”的原则可以解释使用中缀符号的方法调用。9.3.3节中的obj.meth(param)obj meth param是方法调用的两种方式,其含义是一样的。现在你应该明白了,Java中的表达式1+2是数值原始类型和加法操作符的表达式,而Scala中与之对应的1.+(2)Scala.Int类上的方法调用。

Scala中没有因数值的装箱操作而引起的困扰,而这在Java里很常见。请看下面的Java代码:

Integer one = new Integer(1);Integer uno = new Integer(1);System.out.println(one == uno);  

你可能觉得很奇怪,这段代码的输出结果居然是false。而Scala中对数值装箱及相等判断的方式符合我们的常识,这有以下几个好处。

  • 数值类不能由构造方法实例化。它们是有效的abstractfinal类(Java中不允许这种组合)。

  • 得到数值类实例的唯一办法就是作为字面值。这能确保2总是同一个2。

  • 判断两个值是否相等所用的==方法的定义和equals一样,不是引用相等。

  • ==不能重写,但equals可以。

  • 对于引用相等的判断,Scala中有eq方法。但一般不太会用到它。

现在我们已经讨论了Scala中一些最基本的面向对象概念,还需要再多介绍一点儿Scala的语法。最简单的就是Scala的构造方法。

9.4.2 构造方法

Scala的类必须有个主构造方法来定义该类所需的参数。此外,类还可以有额外的辅助构造方法。这些辅助构造方法都用this表示,但它们比Java的重载构造方法限制更严格。

Scala辅助构造方法的第一条语句必须调用同一个类中的另一个构造方法(或者是主构造方法,或者是另一个辅助构造方法)。这种限制是为了把控制流引导到主构造方法上,因为它是类的唯一真正入口。也就是说,辅助构造方法的真实作用是为主构造方法提供默认参数。

请看CashFlow上的这些辅助构造方法:

class CashFlow(amt : Double, curr : String) {  def this(amt : Double) = this(amt, /"GBP/")  def this(curr : String) = this(0, curr)  def amount = amt  def currency = curr}  

这个例子中有个辅助函数可以只给出金额,CashFlow会假定货币是英镑。另一个辅助函数可以只给出货币,假定金额为0。

注意我们定义的amountcurrency方法,都没有括号或参数列表(甚至连空的都没有)。这是告诉编译器,在调用这个类的amountcurrency方法时不需要括号,像这样:

val wages = new CashFlow(2000.0)println(wages.amount)println(wages.currency)  

Scala对类的定义基本都能对应到Java中。但在面向对象的继承方式上,Scala所采用的方式跟Java有显著的差异。下一节就来讨论它们的差异。

9.4.3 特质

特质是Scala面向对象编程方式的主要组成部分。广义上来说,它们和Java接口一样。但跟Java接口不同的是,特质中可以给出方法的实现,并且这些实现可以由具备该特质的不同类共享。

要理解它所解决的Java问题,请看图9-2中从不同的基础类继承而来的两个Java类。如果这两个类都要具备额外的相同功能,Java中的做法是声明它们实现了相同的接口。

图9-2 Java模型中的实现复制

代码清单9-2是一个简单的Java例子,就是上面这种情况的代码。回忆一下4.3.6节那个兽医诊所的例子。很多带到诊所的动物都会被植入芯片,以便于识别。比如猫和狗几乎肯定会这么处理,但其他物种可能不会。

植入芯片的功能需要提取到单独的接口中。我们来修改一下代码清单4-11中的Java代码,加入这一功能(为了让代码看起来更清晰,我们省略了examine方法)。

代码清单9-2 说明实现代码的复制

public abstract class Pet {  protected final String name;  public Pet(String name_) {    name = name_;  }}public interface Chipped {  String getName;}public class Cat extends Pet implements Chipped {  public Cat(String name_) {    super(name_);  }  public String getName {    return name;  }}public class Dog extends Pet implements Chipped {  public Dog(String name_) {    super(name_);  }  public String getName {    return name;  }}  

DogCat中都有同样的getName代码,因为Java接口中不能有实现代码。代码清单9-3是Scala用特质实现的版本。

代码清单9-3 用Scala实现的宠物类

class Pet(name : String)trait Chipped {  var chipName : String  def getName = chipName}class Cat(name : String) extends Pet(name : String) with Chipped {  var chipName = name}class Dog(name : String) extends Pet(name : String) with Chipped {  var chipName = name}  

Scala要求在子类中必须给父类构造方法中出现的参数赋值。但在特质中声明的方法都会被子类继承。这样就减少了重复实现。你看,CatDog类都要给参数name赋值。两个子类都可以访问Chipped中的实现——在此例中,参数chipName可以用来保存写在芯片上的宠物的名字。

9.4.4 单例和伴生对象

我们来看看Scala中的单例对象(即用关键字object定义的类)是如何实现的。回想一下9.1.1中的HelloWorld

object HelloWorld {    def main(args : Array[String]) {        val hello = /"Hello World!/"        println(hello)    }}  

如果这是Java,你会觉得这段代码应该变成一个HelloWorld.class文件。实际上,Scala会把它编译成两个文件:HelloWorld.class和HelloWorld$.class。

因为这就是普通的类文件,所以你可以用第5章介绍的反编译工具javap看看Scala编译器产生的字节码。这会让你对Scala的类型模型及其实现方式有更多的了解。代码清单9-4是对这两个文件运行javap -c –p产生的结果:

代码清单9-4 反编译Scala的单例对象

Compiled from /"HelloWorld.scala/"public final class HelloWorld extends java.lang.Object {  public static final void main(java.lang.String);    Code:        0: getstatic #11 // Field HelloWorld$.MODULE$:LHelloWorld$;  ←—取得单例伴生模块        3: aload_0        4: invokevirtual #13 // Method HelloWorld$.main:([Ljava/lang/String;)V ←—调用伴生的main方法        7: return}Compiled from /"HelloWorld.scala/"public final class HelloWorld$ extends java.lang.Object implements scala.ScalaObject {  public static final HelloWorld$ MODULE$; ←—单例伴生实例  public static {};    Code:        0: new #9 // class HelloWorld$        3: invokespecial #12 // Method /"<init>/":V        6: return  public void main(java.lang.String);    Code:      0: getstatic #19 // Field scala/Predef$.MODULE$:Lscala/Predef$;        3: ldc #22 // String Hello World!        5: invokevirtual #26 // Method scala/Predef$.println:(Ljava/lang/Object;)V        8: return  private HelloWorld$; ←—私有构造方法    Code:        0: aload_0        1: invokespecial #33 // Method java/lang/Object./"<init>/":V        4: aload_0        5: putstatic #35 // Field MODULE$:LHelloWorld$;        8: return}  

明白“Scala没有静态方法或域”这话是从何而来的了吗?除了这些结构,Scala编译器还自动生成了单例模式代码(不可变静态实例和私有构造方法),并把它们插到以$结尾的类中。 main方法仍然是常规的实例方法,但是是在单例的HelloWorld$类实例上调用的。

这意味着在这一对.class文件之间有二元性:一个和Scala文件的名字相同,另外一个加了个$。静态方法和域被放在了第二个单例类中。

Scala中名字相同的classobject非常常见。在这种情况下,单例类被当做了伴生对象。Scala源文件和两个VM类(主类和伴生对象)之间的关系如图9-3所示。

图9-3 Scala单例对象

尽管你不知道,但你确实已经遇到过伴生对象了。在HelloWorld中,你没必要指定println方法在哪个类中。它看起来像个静态方法,所以你应该能想到它是伴生对象中的方法。

让我们再看一下代码清单9-2中与main方法对应的字节码:

public void main(java.lang.String);  Code:      0: getstatic #19 // Field scala/Predef$.MODULE$:Lscala/Predef$;      3: ldc #22 // String Hello World!      5: invokevirtual #26 // Method scala/Predef$.println:(Ljava/lang/Object;)V      8: return  

这段代码中的println及其他随时可用的Scala函数都在Scala.Predef类的伴生对象中。

伴生对象在其相关类那里有特权。它能访问该类的私有方法。这使得Scala能以合理的方式定义私有辅助构造方法。Scala定义私有构造方法的语法是在其参数列表之前加上关键字private,像这样:

class CashFlow private (amt : Double, curr : String) {  ...}  

如果私有的构造方法是主方法,那就只有两种办法可以创建该类的实例:或者通过伴生对象里的工厂方法(可以访问私有构造方法),或者调用一个公开的辅助构造方法。

接下来我们要进入下一主题:Scala的case类。你已经遇到过了,但为了刷新一下你的记忆,我们再重复一次,它们是通过自动提供一些基本方法来减少套路化代码的有效办法。

9.4.5 case类和match表达式

我们用Java实现一个简单的实体,比如Point类,如代码清单9-5所示。

代码清单9-5 一个用Java实现的简单类

public class Point {    private final int x;    private final int y;    public Point(int x, int y) { //套路化代码        this.x = x;        this.y = y;    }    public String toString { //套路化代码        return /"Point(x: /" + x + /", y: /" + y + /")/";    }    @Override    public boolean equals(Object obj) { //套路化代码        if (!(obj instanceof Point)) {            return false;        }        Point other = (Point)obj;        return other.x == x && other.y == y;    }    @Override    public int hashCode { //套路化代码        return x * 17 + y;    }}  

这套路化代码简直太多了,而且更糟的是,像hashCodetoStringequals以及所有的获取方法通常都是由IDE自动生成的。如果在语言内核的内部完成这些自动生成的工作,用更简单的语法岂不是更好?

Scala的确支持自动生成,case类就可以。代码清单9-5可以非常简单:

case class Point(x : Int, y : Int)  

这和Java那段长长的代码功能一样,但除了更短,它还有别的好处。

比如说,用Java那个版本,如果要修改代码(假设要加个z坐标),就必须更新toString和其他方法。实际上,应该要把原来那些方法全部删掉,然后让IDE再重新生成一次。

用Scala这些都没必要,因为根本就没显式定义需要跟着更新的方法。这归结为一个非常强的理论:不可能在没出现的源码中弄出bug来。

在创建新的case类实例时,关键字new可以省略。代码可以写成这样:

val pythag = Point(3, 4)  

这样看来case类更像带一个或多个参数的枚举类型了。实际上case类的底层实现机制是提供一个创建新实例的工厂方法。

我们来看一下case类的主要用途:模式和match表达式。case类可以用在叫做构造器(Constructor)模式的Scala模式类型里,请看代码清单9-6。

代码清单9-6 match表达式中的Constructor模式

val xaxis = Point(2, 0)val yaxis = Point(0, 3)val some = Point(5, 12)val whereami = (p : Point) => p match {    case Point(x, 0) => /"On the x-axis/"    case Point(0, y) => /"On the y-axis/"    case _ => /"Out in the plane/"}println(whereami(xaxis))println(whereami(yaxis))println(whereami(some)) 

我们在9.6节讨论actor和Scala的并发观点时会再次拜访Constructor模式和case类。

在结束本节之前,我们要发出一个警告。Scala丰富的语法和聪明的解析器能够用一些非常精炼和优雅的办法来表示复杂的代码。但Scala没有正式的语言规范,并且新特性的增加非常频繁。你应该多加小心——即便是经验丰富的Scala码农有时也会被语言特性出其不意的表现吓到。在语法特性互相结合时尤其如此。

我们来看一个例子:一种在Scala中模拟操作符重载的办法。

9.4.6 警世寓言

我们再想一想刚刚提到的Point case类。你可能想要用一种简单的办法来表示坐标的相加,或者坐标的线性增长。如果你数学好,可能马上就会意识到这是一个平面坐标上的向量空间属性。

代码清单9-7将方法定义得像普通的操作符一样。

代码清单9-7 模拟操作符重载

case class Point(x : Int, y : Int) {  def *(m : Int) = Point(this.x * m, this.y * m)  def +(other : Point) = Point(this.x + other.x, this.y + other.y)}var poin = Point(2, 3)var poin2 = Point(5, 7)println(poin)println(poin 2)println(poin * 2)println(poin + poin2)  

运行这段代码得到的输出应该是:

Point(2,3)Point(5,7)Point(4,6)Point(7,10)  

这下应该能看出Scala的case类跟Java里的等价物相比有多好了吧。只需要很少的代码,就能创造出一个很友好的类,产生合理的输出。定义+*方法后,你已经可以模拟操作符重载了。

但这种方式有问题。请看下面这段代码:

var poin = Point(2, 3)println(2 * poin)  

这会导致编译错误:

error: overloaded method value * with alternatives:  (Double)Double <and>  (Float)Float <and>  (Long)Long <and>  (Int)Int <and>  (Char)Int <and>  (Short)Int <and>  (Byte)Intcannot be applied to (Point)            println(2 * poin)                           ^one error found  

尽管在casePoint上已经定义了方法*(m : Int),但不是Scala要找的那个方法,所以出错了。为了让前面的代码编译成功,需要在Int类上实现*(p : Point)方法。这是不可能的,所以操作符重载只是一个假象。

这带出了Scala中有一个有趣的问题:很多语法特性的限制在某些情况下可能会让人大吃一惊。Scala的语言分析器和运行时环境在底层做了大量工作,但这些隐藏的机制是建立在尽量做正确的事的基础上的。

我们对Scala面向对象实现方式的介绍到这里就结束了。还有很多先进特性没涉及。很多现代化的类型系统和对象思想在Scala中都有实现,所以如果感兴趣,Scala的广阔天地对你来说大有可为。如果前面的那些内容勾起了你对Scala的类型系统和面向对象实现方式的兴趣,你可以去读一读Joshua Suereth的Scala in Depth(Manning,2012),或其他专门介绍Scala的图书。

你可能已经想到了,这些语言理论应用的一个重点是Scala的数据结构和集合,这也是我们下一节的主要内容。