讀古今文學網 > Java程序員修煉之道 > 9.4 Scala對像模型:相似但不同 >

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)Int
cannot 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的數據結構和集合,這也是我們下一節的主要內容。