[Scala] 我為何鍾情於用 Scala 做為自己的兵刃(四)--選擇工具的自由。

選擇的自由

繼讀來說說 Scala 為什麼吸引我唄,在這一篇裡面與其他語言比較的部份沒那麼多,大部份是在講 Scala 和 Java 的一些不同,以及舉例說明我喜歡 Scala 的另一理由--它給你選擇的自由,並且相信你會正確使用手上的工具。

變數有兩種

在純 Functional Programming (FP) 的語言中,嚴格的限定了變數在執行時期是不可變動的,舉例來說,如果你在 Haskell 用了如下的宣告,那麼 x 永遠就是 5,你不能再將他重新指定成 10。

let x = 5 -- x 初始化為 5
x = 10 -- 這是不允許滴--parse error on input `='

而相較之下,在 C / C++ / Java 這類程序導向或物件導向的程式語言中,我們非常習慣經常性地修改變數的內容。

public class Test
{
    public static void main (String [] args) {
        int sum = 0;

        for (int i = 0; i <= 10; i++) {
            sum = sum + i;
            System.out.println("i:" + i);    // i 一直變
            System.out.println("sum:" + sum) // sum 也一直變
        }

    }
}

至於 Scala 呢?Scala 兩個都給你,一個叫做 val,是不可以重新指定的變數,一個叫做 var,是可以重新指定值的變數,但是在眾多的教學的文件裡面,都會告訴你偏好使用 val。事實上,寫出來後也常常會發現,一隻程式中根本完全沒出現過 var 變數。

舉例來說,Scala 允許你寫出和上面 Java 版相似的程式碼:

var sum = 0; // 我是可以被更動滴!
for (i <- 0 to 10) {
    sum = sum + i;
    println ("i: " + i)     // i 一直變
    println ("sum:" + sum)  // sum 也一直變
}

但是如果你實際上去翻大家寫的 Scala 程式碼,幾乎沒有人會用上面的寫法,而是偏好採用 Function Programming 的方式來寫:

val xs   = (1 to 10).toList // List(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
val sum1 = xs.sum // Scala 2.8 起提供的作弊函式
val sum2 = xs.reduceLeft(_ + _) // 標準的 Function Programming 做法

// sum1 = 20 // 編譯錯誤
// sum2 = 30 // 編譯錯誤

/**
 * 到底 xs.reduceLeft(_+_) 是啥呢?
 * 展開後會變這樣:
 *
 * Step 1:
 *  先將第一個元素和第二個元素放到 (_ + _) 的底線部份,成為 (1+2),
 *  再把 (1+2) 的結果取代掉第一二個元素,所以現在整個數列變成:
 *  List(3, 3, 4, 5, 6, 7, 8, 9, 10)
 *
 * Step 2:
 *  再將上面的 List(3, 3, 4, 5, 6, 7, 8, 9, 10) 繼續做同樣的動作,變成:
 *  List(6, 4, 5, 6, 7, 8, 9, 10)
 *
 * Step 3, 4, 5, 6, 7, 8, 9:
 *  List (10, 5, 6, 7, 8, 9, 10)
 *  List (15, 6, 7, 8, 9, 19)
 *  List (21, 7, 8, 9, 10)
 *  List (28, 8, 9, 10)
 *  List (36, 9, 10)
 *  List (45, 10)
 *  List (55)
 *
 * Step 10: 將 55 取出,所以 sum2 = 55
 *
 */

這大概就是 Scala 對於許多事情的中心思想--許多的功能與典範都有自己擅長的地方,我們不應該強迫寫程式的人用什麼方式思考,而是儘可能地給你合適的工具,並且信任你不會在需要螺絲起子的時候去拿刀片,而也不會限制你只能用螺絲起子卻不能用刀片。

我必須很誠實的講,我喜歡這樣想法,這或許也是我一直很難真正認真去學習 Haskell 之類的 Pure Functional 程式語言的原因之一吧--我比較喜歡在我覺得刀片比較順手的時候去用刀片,而不是被迫只能用螺絲起子來完成所有的事情。

偏好 Immutable 物件,但也給你選擇的自由

同樣的,由於 Scala 將 FP 做為預設偏好的編程典範,所以也提倡所謂的 immutable 物件,至於什麼是 immutable 物件呢?簡單地來說,就是不論你對他做什麼樣的操作,他都永遠不會改變。

其中一個著名的例子是 Java 當中的 String 物件,不論你呼叫 String 裡的哪一個函式,原來的 String 物件是不會被更動的,只是產生了一份新的 String 物件而已。

例如:

public class Test {
    public static void main (String [] args) {
        String str1 = "Hello World";
        String str2 = str1.substring(0, 5);
        System.out.println (str1);  // "Hello World"
        System.out.println (str2);  // "World"
    }
}

同樣的,在 Scala 當中,幾乎大部份預設的資料結構都是 immutable 的,特別明顯的就是 Collection 的相關類別,像是 List、Map 這些東西,如果沒有特別指明的話,所使用的都是 immutable 的版本。

例如:

val xs1 = List(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
val xs2 = xs1.filter(_ % 2 == 0)
println (xs1) // List(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
println (xs2) // List(2, 4, 6, 8, 10)

至於 immutable 物件的好處,諸如 Thread-safe 等等,其他地方都已經把他講到爛了,所以這邊就不再贅述。

不過要特別提的一點,就是 Scala 給你選擇的自由,他把一整套工具都給你了,所以當你發現真的需要 mutable 物件的 Collection 時候,也沒有問題,只要選用 scala.collection.mutable 裡的東西就可以了,幾乎所有的容器都有一份 immutable 和 mutable 版本,所以你可以選擇適合自己需求的東西。

在 Scala 中的 == 運算子

Java 當中的 == 運算子在不同的狀況下有不同的語意:

  • 當比較的變數是基本資料型態(如 int 或 boolean 這些),比的是『值』
  • 當比較的變數是 Reference,比的是該 Reference 是不是指到同一個物件

一個常常用來示範 Java 中 == 運算子的範例程式如下:

public class Test {
    public static void main (String [] args) {
        int x1 = 123;
        int x2 = 100 + 20 + 3;
        String strPool1 = "Hello World";
        String strPool2 = "Hello World";
        String str1 = new String("Hello World");
        String str2 = new String("Hello World");

        // true,因為值相等,都是 123
        System.out.println (x1 == x2);  

        // true,因為指到 String Pool 裡的同一個物件
        System.out.println (strPool1 == strPool2);      

        // false,雖然值都是 "Hello World",但是是不同物件
        System.out.println (str1 == str2);      
    }
}

相較之下,由於 Scala 是個純物件導向語言,已經沒有基本資料型態和 Reference 資料型態的概念,所以對於 == 的處理也有一點不同--在 Scala 當中,== 用以比較值(在物件有正確實作 hashCode 與 equals 時)。

簡單的來說,在 Scala 當中的 x == y 約相當於在 Java 中呼叫 x.equals(y) 這樣子,這是和 Java 差別滿多的一點。也因為如此,如果真的需要比較兩個變數是不是指到同一份物件,Scala 另外提供了一個叫做 eq 的運算子。

範例如下:

val x1 = 123
val x2 = 100 + 20 + 3
val strPool1 = "Hello World"
val strPool2 = "Hello World"
val str1 = new String("Hello World")
val str2 = new String("Hello World")
val xs1 = List(1, 2, 3)
val xs2 = List(1, 2) + List(3)

println (x1 == x2) // true
println (strPool1 == strPool2) // true, 內容都是 "Hello World"
println (strPool1 eq strPool2) // true, 同一個物件
println (str1 == str2) // true, 內容都是 "Hello World"
println (str1 eq str2) // false, 不同的物件
println (xs1 == xs2) // true, 內容都是 List(1, 2, 3)
println (xs1 eq xs2) // false, 不同的物件

但相較之下,如果你沒有自己實作 equals 這個 method 的話,預設會去比較兩個變數是不是指到相同的物件,算是比較要注意的地方。

像是如果我們設計了一個 Complex 類別來表示數學上的複數(一個實數 r 加上一個虛數 i),但沒有實作 equals 方法,就會發生像下面的慘況(?)。

class Complex(val r: Double, val i: Double)
val c1 = new Complex(3, 4)
val c2 = new Complex(3, 4)
println (c1 == c2) // false,因為沒有實作 equals
println (c1 eq c2) // false,因為是不同的物件

Operator?不,是 Method!

剛剛看到了 == 這個運算子的運作方式,但其實這邊有個問題--== 這個符號並不是運算子,而是 Method!如果去翻 Scala 的標準函式庫的 ScalaDoc 文件,就會發現 == 這個東西被定義在 Any 和 AnyRef 這兩個類別上,而且是 Method(用 def 定義)!

在對於運算子的處理上,Scala 和 Haskell 的做法比較接近--所謂的運算子只是一種語法糖衣,本質上是 method (in Scala) 或是 function (in Haskell)。

也就是說,在 Scala 中你看到的 + 其實不是 + 這個運算子,而是一個叫做 + 的 method,你看到的 - 不是 -,而是一個叫做 - 的運算子。而唯一的規則是,如果你的 method 只接收一個參數,那你可以將 . 和括號去掉。所在在 Scala 中,1 + 2 其實是等價於 1.+(2) 的,只是 . 和 + 號可以被省略罷了。

舉例來說,我們知道在數學上如果一個複數 a+bi 加上另一個複數 c+di 會等於 (a+c)+(b+d)i,如此一來,我們可以擴充上面我們剛剛寫出來的 Complex 類別,幫他加上一個 add 的 method,來做複數的加法。


class Complex(val r: Double, val i: Double) {

    def add (other: Complex) = new Complex(r+other.r, i + other.i)
    override def toString() = "Complex(%.2f, %.2f)" format(r, i)
}

val c1 = new Complex(4, 3)
val c2 = new Complex(2, 4)
val c3 = c1.add(c2) // Complex(6.00, 7.00)
val c4 = c1 add c2  // Complex(6.00, 7.00)

看見了嗎?c1.add(c2),因為在這邊 add 只接受了一個參數,所以可以改寫為 infix operator 的型式,變成 c1 add c2 這個樣子。

但是等等,如果都已經寫成了 c1 add c2,那為什麼不乾脆使用數學上習慣使用的 + 號來當做 add 這個 method 的名稱呢?沒問題,Scala 允許你這麼做!

我們只要將上面的 add 改成 + 號,你就多出了一個 + 號運算子。


class Complex(val r: Double, val i: Double) {

    def + (other: Complex) = new Complex(r+other.r, i + other.i)
    override def toString() = "Complex(%.2f, %.2f)" format(r, i)
}

val c1 = new Complex(4, 3)
val c2 = new Complex(2, 4)
val c3 = c1 + c2  // Complex(6.00, 7.00)

至於 Operator 中最重要的優先權(例如先做 * / 再做 + -)和左結合右結合這些概念,Scala 則是依照你所使用的 method 名稱來定義(像是 * 這個名字的 method 比 + 這個名稱的 method 優先權高,以冒號結尾的 method 是右結合),詳細的說明請參照 Scala Reference - 6.12.3 Infix Operator 這份文件。

其實 Operator Overloading 一直以來都具有諍議性的話題,甚至已經有點像是 Python 該不該用縮排做 Token 之類的諍議了。

但在這方面,我和 James Iry 的看法比較接近,就算把 Java 把 Operator Overloading 拿掉了,還是會看到一大堆名稱與內容不符的函數名稱(淦,當你看到有函數名稱叫做 checkPathExists 但是卻是去做 mkdir 的時候你就會想哭了)。

只因為怕被人誤用,就把 Operator Overloading 拿掉,是有點因噎廢食了。而且 Scala 在這邊的處理我也覺得還好,至少處理上都還滿一致和自由的,不像 C++ 只能選擇固定的幾個符號來做 Operator。

畢竟在 Scala 中就算不使用符號,還是可以使用 infix notation,所以沒必要特定把 method 名稱取成奇怪的符號,例如我覺得 myList filter someCondition 這樣的寫法就很方便和清楚了。

此外,就算有人發現你在 Scala 裡面用了 Operator Overloading 而開始對你說教的時候,你也可以大聲的說:『我沒有用 Operator,我只是呼叫了 Method 而已啊!』你看看,Scala 真是支 Operator Overloading 的人的好朋友啊,連退路都幫你想好了!(大誤)

(謎之音:你這個根本是在狡辯吧!=_=)

回響