Scala Tutorial(3)變數與函式 by brianhsu | CodeData
top

Scala Tutorial(3)變數與函式

分享:

Scala Tutorial(2)準備開發環境、Scala 中的四則運算 << 前情

上次我們看到了在 Scala 當中,所有的東西都是物件,而所謂的運算元只是呼叫類別中的方法而已。

有了這些基礎,我們接下來要來看在 Scala 中要如何宣告我們自己的變數以及定義函式,以及利用我們已經熟悉的物件導向概念,來看 Functional Programming 中所謂的 High Order Function 到底是什麼東西。

Scala 中的變數宣告

由於 Scala 是靜態型別的程式語言,因此就如同 Java 一樣,在 Scala 所有的變數在使用前都需要經過宣告,但比較特別的一點是由於 Scala 具有型別推論的功能,因此大多數的時候我們不需要宣告變數的型態。

此外,Scala 中的變數有兩種--valvar,兩者的差別在於 val 變數一但經過宣告之後就不能使用 = 運算子來改變其值,而 var 變數則與 Java 中的變數類似,可以用 = 更新其值。

在下面的例子中我們分別宣告了兩個變數,其中一個使用 val 宣告,另一個則使用 var 宣告,可以看到當我們使用 = 更新 readOnly 變數時 Scala REPL 發出編譯錯誤訊息,告知我們 val 是不能被重新賦值的,但我們可以成功將 changeable 變數指定成其他字串。

scala> val readOnly = "This is read only"
readOnly: String = This is read only

scala> var changeable = "This could be changed"
changeable: String = This could be changed

scala> readOnly = "This is new string"
<console>:8: error: reassignment to val
       readOnly = "This is new string"
                ^
scala> changeable = "This is new string"
changeable: String = This is new string

另一件值得注意的事情,是雖然我們沒有需告 changeable 變數的型別資訊,但這不代表 changeable 沒有型別資訊--由於 Scala 編譯器知道 "This could be changed" 是一個 java.lang.String 的字串,所以自動將 changeable 的型別設成 String

換句話說,如果我們試著將整數指定給 changeable 的話,由於兩者的型別不相容,是無法成功編譯的,就如同在 Java 中我們不能把整數指定給型別是 String 的變數一樣。

scala> var changeable = "This could be changed"
changeable: String = This could be changed

scala> changeable = 123
<console>:8: error: type mismatch;
 found   : Int(123)
 required: String
       changeable = 123
                ^

雖然說大多數的時候 Scala 都能自動從等號後的運算式中推測出正確的型別,但有的時候他推測的型別並不一定是你要的,這個時候我們可以明確地告知 Scala 編譯器該變數的型別。

和 Java 中不同,在 Scala 中型別資訊是放在變數之後,並且以 :(冒號)連接,此外,因為如 int 等在 Java 中屬於基本資料的型別,在 Scala 中都有相對應的物件類別,因此在 Scala 中這些型別的名稱是大寫開頭的。

val x: Int = 30 + 20  // 和 Scala 編譯器推論出的型別相同
val y: Long = 30      // Scala 編譯器推論的是 Int,但指定到相容型別的 Long 變數
val z: Any = new String("Hello World")  // Scala 編譯器推論的是 String 型別,但可以放到父型別的 Any

// 編譯錯誤,String 和 Long 不相容
// val a: Long = new String("Hello World")

上述的例子中,變數 yz 的型別都和 Scala 推論出的型別不同,但因為型別是相容的(其規則與 Java 相同),所以 Scala 可以成功編譯。

這裡值得注意的是 Any 型別,在 Scala 中 Any 的地位類似 Java 中的 java.lang.Object,是所有類別的始袓,但由於在 Scala 中如整數等等在 Java 中屬於基本資料的型別也都是物件,所以 Any 除了可以放一般的物件的 Reference 外,也可以放整數、浮點數等東西。

不過這邊要特別注意的是,在 Scala 中的 val 其語義與 Java 中的 final 關鍵字相等,換句話說,他設定的是變數本身能否被重新賦值,而不是其所指到的物件狀態是否是不能被改變的。

舉例而言,我們知道在 Java 中 StringBuilder 物件被建立之後,其狀態是可以被改變的,所以即便我們使用 val 關鍵字宣告變數,依然可以使用 StringBuilder 的 append 方法來改變 StringBuilder 的狀態,但是不能將變數指定到新的 StringBuilder。

val builder = new StringBuilder

println("Current String:" + builder.toString)
builder.append("I'm not empty")
println("Current String:" + builder.toString)   // 物件狀態被改變了

// 編譯錯誤,builder 不能再被指到其他的物件
// builder = new StringBuilder

看到這裡讀者可能會覺得好奇,為什麼 Scala 中的變數宣告會分成 valvar 兩種,或者不確定自己在寫 Scala 程式的時候到底該用 val 或者是 var 才對。

這是由於 Scala 是融合了物件導向以及 Functional Programming 這兩個編程典範的程式語言,而在這兩個編程典範當中對於「變數」這個東西有不同的概念。

在我們熟悉的物件導向中,變數多數的時候代表該物件的狀態,而隨著程式的流程與時間的經過,物件的狀態會跟著改變,而物件中的變數也會被重新指定。

但是在嚴格的純 Functional Programming 環境中(例如著名的 Haskell 語言),「變數」的概念和數學中的代數比較接近,當變數一經指定過後其值就是固定的,變數只是讓算式更清楚易懂,而不影響到實際運算的結果。

在 Scala 中則是兩種方式都有提供,但鼓勵使用者優先使用 val 變數,因為這會讓程式的分析更容易,同時在多執緒的程式中也較不容易出錯。

但另一方面,如果使用者有合理的理由需要讓變數可以重新被賦值,Scala 也提供了 var 這樣的工具,並且相信使用者的決定。

在往後篇章當中,我們會看到 Scala 裡更多這種「提供各種不同的工具,鼓勵使用某一種特定的方式,但當你真的需要的時候也不會擋路」的設計思維。

Scala 中的函式宣告

註:從這節開始,我們將使用 script 模式來撰寫範例檔案,以方便讀者修改與執行。

了解變數的宣告之後,接下來我們就可以進入函式宣告的部份了。

Scala 中的函式宣告和 Java 的語法有相當大的差異,主要有以下幾點要注意:

  1. 函式的宣告是以 def 關鍵字起頭,而且函式中可以繼續定義函式
  2. 函式所接受的參數的型別和變數一樣,是放在參數名稱的後面,以冒號接壤且不能省略
  3. 若函式的回傳值型態不為 Unit(即 Java 中的 void),則函式標頭與內容需以 = 號連接
  4. 若函式中沒有 return 關鍵字,其返回值則為函式最後一個 expression 的值
  5. 若函式是遞迴函式或其內容有 return 關鍵字,則返回值型別要明確宣告

雖然這些規則看起來好像相當複雜,但實際上使用後會發現是相當值覺的,所以在這一節當中,我們會以舉例為主。

從最簡單的,一個不接受任何參數,也沒有返回值的函式開始:

def helloWorld() {
  println("Hello World")
}

helloWorld() // 呼叫函式的方法和 Java 相同

你可能會注意到這個函式沒有任何的型別資訊,這是因為若函式的標頭與函式的內容若沒有等號連接的話,則 Scala 會視其返回值的型態為 Unit,即沒有返回值。(Scala 官方已不建議使用這樣的語法,詳見[註一])

上面的函式與下面兩個實際上是相同的:

// 讓 Scala 自動使用最後一個 expression 的型態做為函式的返回型別,由於 println 的返回值
// 型別是 Unit,所以 helloWorld 的返回值型別也會是 Unit
def helloWorld() = {
  println("Hello World")
}

// 明確宣告 helloWorld2 這個函式的返回值型別是 Unit
def helloWorld2(): Unit = {
  println("Hello World")
}

接下來,我們可以將 “Hello World" 字串抽出變成這個函式的參數:

// 注意在 Scala 中函式參數的型別是不能省略的
def helloWorld(prompt: String) = {
  println(prompt)
}

helloWorld("Hello World")
helloWorld("Hello World II")

// 編譯錯誤:prompt 沒有型別資訊
/*
 *  def helloWorld(prompt) = {
 *    println(prompt)
 *  }
 */

除了一般的參數定義和呼叫外,Scala 也提供了參數預設值,以及在呼叫函式時使用 named argument 的功能,所以我們可以替參數直接指定預設值,並在在呼叫函式時是以名稱而非位置的方式指定參數的值。

def printOneLine(line: String = "I'm line 1") = {
  println(line)
}

def printTwoLine(line1: String, line2: String) = {
  println(line1)
  println(line2)
}

printOneLine("Hello World")  // 印出 Hello World
printOneLine()               // 印出 "I'm line 1"

// 印出:
//   Line1
//   Line2
printTwoLine("Line1", "Line2")

// 印出:
//   SecondArgument
//   FirstArgument
printTwoLine(line2 = "FirstArgument", line1 = "SecondArgument")

接著我們來看看有返回值的函式要怎麼宣告吧!首先是最簡單的方式:僅宣告參數型別,函式返回值的型別則由 Scala 編譯器推論:

def add(x: Int, y: Int) = x + y
val sum = add(30, 40)
println("sum = " + sum)

// 以下這幾種宣告方式都是合法的:
// def add(x: Int, y: Int) = { x + y}
// def add(x: Int, y: Int) = x + y
// def add(x: Int, y: Int): Int= { x + y}
// def add(x: Int, y: Int): Int = x + y

在這個例子中,函式 add 僅宣告了自己接受兩個 Int 型別的參數,而其返回值的型態則由 Scala 編譯器自行由函式的最後一個 expression 推論。這裡另外一件值得注意的事情,是若函式的內容只有一行的話,大括號是可以省略的。

此外,若我們不確定 Scala 推論出的函式返回型別是什麼,可以透過 Scala REPL 的幫助來檢視,舉例而言,若我們不確定一個讓 Double 與 Int 相加的函式的返回型別,那麼可以直接在 REPL 中定義這個函式:

scala> def add(x: Int, y: Double) = x + y
add: (x: Int, y: Double)Double

這樣子 Scala REPL 就會告訴我們,add 是一個接受一個名為 xInt 和名為 yDouble 參數,並返回一個 Double 的函式。

接下來我們來看一下若函式的內容中有 return 這個關鍵字會發生什麼事:

// test.scala
def max(a: Int, b: Int) = {
  if (a > b) {
    return a
  } else {
    return b
  }
}

println(max(5, 10))

若執行上述的程式的話,Scala 會發出抱怨,告訴我們因為 max 這個函式中有 return 關鍵字,所以需要明確宣告返回型別:

brianhsu@VaioPro13 ~ $ scala test.scala 
/home/brianhsu/test.scala:4: error: method max has return statement; needs result type
    return a
    ^
/home/brianhsu/test.scala:6: error: method max has return statement; needs result type
    return b
    ^
two errors found

如果我們要讓上面這段程式正確執行的話,那麼就要明確告知 Scala 編譯器這個函式的返回值:

// test.scala
def max(a: Int, b: Int): Int = {
  if (a > b) {
    return a
  } else {
    return b
  }
}

println(max(5, 10))

但即便如此,這個程式碼仍然不是 Scala 風格的程式碼,在 Scala 的程式中我們相當少用到 return 關鍵字,這是因為 Scala 中大部份的敘述都是有返回值的,而大部份的情況下,最終都可以把 return 的邏輯簡化成函式的最後一個敘述句。

在上面的例子中,由於 Scala 的 if / else 敘述句是有返回值的,其返回值就是被選取到的分支的最後一個敘述句:

scala> val resultOfTrueBranch = if (true) { 
     |   println("First expression inside true branch")
     |   1
     | } else {
     |   println("First expression inside false branch")
     |   2
     | }
First expression inside true branch
resultOfTrueBranch: Int = 1

scala> val resultOfFalseBranch = if (false) {
     |   println("First expression inside true branch")
     |   1
     | } else {
     |   println("First expression inside false branch")
     |   2
     | }
First expression inside false branch
resultOfFalseBranch: Int = 2

由於 Scala 的 if / else 敘述句具有這樣的特性,所以我們的 max 函式可以被簡化成沒有 return 的版本,也因為沒有 return 關鍵字,所以可以讓 Scala 自行推測其返回值的型別。

def max(a: Int, b: Int) = if (a > b) { a } else { b }

// 也可以明確標示返回值型別
// def max(a: Int, b: Int): Int = if (a > b) { a } else { b }

println(max(5, 10))  // 出出 10

另外一個要注意的,就是如果該函式是遞迴函式,則其返回值也是要被名確地宣告的,否則 Scala 會發出編譯錯誤。舉例來說,若我們撰寫了一個用遞回方式加總 1 到 n 的函式,那麼該函式的返回值型別一定要被明確地宣告出來:

// 編譯錯誤:recursive method sum needs result type
// def sum(n: Int) = if (n == 0) { 0 } else { n + sum(n-1) }

def sum(n: Int): Int = if (n == 0) { 0 } else { n + sum(n - 1) }
println(sum(10)) // 印出 55

最後,在 Scala 當中函式內是可以繼續再定義函式的,舉例來說我們可以用累加器的方式來實作上述的遞回函式:

def sum(n: Int, accumulator: Int): Int = {
  if (n == 0) { 
    accumulator  
  }
  else { 
    sum(n-1, accumulator + n)
  }
}

println (sum(10, 0))  // 印出 55

但這樣一來,使用者在呼叫的時候就必須傳遞兩個參數,而且第二個參數理論上一定要是零,把它曝露給使用者似乎不太合理。在這個時候,我們就可以藉助巢狀函式,將第二個參數封裝起來,不讓使用者接觸到:

def sum(n: Int) = {
  def sumImpl(n: Int, accumulator: Int): Int = {
    if (n == 0) { 
      accumulator  
    }
    else { 
      sumImpl(n-1, accumulator + n)
    }
  }

  sumImpl(n, 0)
}

println (sum(10))  // 印出 55

到底什麼是 Higher-Order Function

前面囉囉嗦嗦了那麼多,我們終於進入到 Scala 程式語言中比較有趣,可能也是讀者可能比較不熟悉的部份--用來支援 Functional Programming 編程典範的工具。

若讀者曾經稍微接觸過 Functional Programming 或看到相關的介紹的話,那麼可能已經聽過「first-class function」和「Higher-order function」這些東西,而這一小節我們要介紹的就是 Higher-order function。

話說筆者第一次接觸 Functional Programming 時,看的是其他語言的介紹(例如 Haskell 或 Ruby),但對於這些概念始終都無法掌握和理解,直到學習了 Scala 之後,才發現原來 Functional Programming 中的東西,也都是可以從物件導向的概念去理解的。

所以如果您和筆者一樣,對於 Functional Programming 有興趣,但卻始終格格不入,那麼從 Scala 開始學習是一個不錯的方式--因為 Scala 實際上就是用物件導向的方式來實作 Functional Programming 的,我們可以用比較熟悉的角度來切入。

回到正題,當我們提到 first-class function 和 Higher-order function 是,其實代表的是這樣一件事--函式要求的參數可以是另一個函式,而函式可以被當作參數傳遞。

啥?參數是函式,函式可以傳來傳去?這是什麼東西?!別緊張,其實這種概念我們在物件導向的程式裡也是常常用的,特別是在 GUI 介面的程式裡。

舉例來說,在 Android 裡面我們要讓一個按鈕被按下去後執行特定的工作,可以用 setOnClickListener 這個函式,並傳入一個 View.OnClickListener 物件,而按鈕被按下去之後就會執行這個物件裡的 onClick 這個函式。

final Button button = (Button) findViewById(R.id.button_id);

 button.setOnClickListener(new View.OnClickListener() {
     public void onClick(View v) {
         // Perform action on click
     }
 });

但不知道你有沒有想過,為什麼這個 OnClickListener 裡面明明就只有一個 onClick 的方法,我們卻要寫一堆 new ViewOnClickListener() 什麼的,並且覆寫 onClick 這個方法呢?

沒錯,這裡就是 First-class function 和 Higer-order function 出場的地方了!既然這個物件裡只有一個方法,乾脆就省略 new 這些物件和覆寫的步驟,直接把函式傳進去吧!

所以在 Scala 中我們可以定義一個函式,他的參數是另一個函式:

def onClick(callback: (Int, Long) => Any) = {
  val buttonID = 1    // 假定這個 buttonID 會設定成按鈕的 ID
  val currentTime = System.currentTimeMillis // onClick 函式被呼叫的時間
  callback(buttonID, currentTime)
}

如果先不去看 (Int, Long) => Any 這個奇怪的型別,那麼這個函式看起來應該相當簡單,我們呼叫 callback 這個函式,並且把 buttonID 和 currentTime 傳進去給 callback 這個函式。

至於呼叫 callback 執行後會發生什麼事情,在這個時點我們不曉得,因為就如同在 GUI 中按鈕被按下去的行為是由 Listener 所控制,onClick 的行為也是由 callback 這個參數所決定的。

接下來我們再來看 callback 這個參數的型別:(Int, Long) => Any,在 Scala 中這是代表 first-class function 型別的語法,在 => 符號左邊的是函式的參數型別列表,而右邊則是這個函式的返回值型態。

如果我們要使用 onClick 這個函式的話,那麼就需要提供一個 first-class function 給他,而要建立一個 first-class function,我們使用與宣告 first-class function 型別類似的語法,但加上參數的名稱,並且提供其實作的內容。

// FirstClassFunction.scala
val myCallback = (buttonID: Int, time: Long) => { 
  println("buttonID:" + buttonID)
  println("time:" + time)
}

val myCallback2 = (buttonID: Int, time: Long) => {
  println("I will do something...")
}

def onClick(callback: (Int, Long) => Any) = {
  val buttonID = 1    // 假定這個 buttonID 會設定成按鈕的 ID
  val currentTime = System.currentTimeMillis // onClick 函式被呼叫的時間
  callback(buttonID, currentTime)
}

onClick(myCallback)
onClick(myCallback2)

在這個例子中,我們呼叫了 onClick 兩次,並且傳入不同的 first-class function 給他,所以兩次 onClick 的執行結果自然也不同:

brianhsu@VaioPro13 ~ $ scala FirstClassFunction.scala 
buttonID:1
time:1402291789247
I will do something...

當然,我們也可以不將 first-class function 放入變數中,而是直接將其當做參數傳入函式中。其中要注意的是當使用這種方式的時候,由於 Scala 已經知道 onClick 中的 callback 需要的是一個接受一個 Int 與一個 Long 的函式,所以我們可以省略 buttonID 與 currentTime 的型別。

def onClick(callback: (Int, Long) => Any) = {
  val buttonID: Int = 1
  val currentTime = System.currentTimeMillis
  callback(buttonID, currentTime)
}

onClick((buttonID: Int, time: Long) => println("buttonID:" + buttonID) )
onClick((buttonID, time) => println("buttonID:" + buttonID) )

看到這邊讀者可能會有疑惑,為什麼筆者之前會說這樣的概念可以用物件導向的方式理解,而 Scala 實際上也是用物件導向的方式來實作呢?

這是因為下面這兩點:

  1. 當 Scala 看到 first-class function 的型別宣告時,其實是把其型別代換為 scala.FunctionN[T1, T2, ... ,TN, TR]
  2. 當 Scala 看到 first-class function 的實作時,其實是 new 了一個 scala.FunctionN 的物件
  3. 當我們使用 callback(buttonID, currentTime) 的時候,其實是被轉換成 callback.apply(buttonID, currentTime)

由於 Scala 編譯器只是做了一些語法上的轉換,所以我們上面的程式碼可以改寫成下面這樣,其中 myCallback 使用 Scala 提供的簡便語法,而 myCallback2 和 onClick 則直接使用 Function2 來實作。

// FirstClassFunction2.scala

val myCallback = (buttonID: Int, time: Long) => {
  println("buttonID:" + buttonID)
  println("time:" + time)
}

val myCallback2 = new Function2[Int, Long, Any] {
  def apply(buttonID: Int, time: Long): Any = {
    println("I will do something...")
  }
}

def onClick(callback: Function2[Int, Long, Any] ) = {
  val buttonID = 1    // 假定這個 buttonID 會設定成按鈕的 ID
  val currentTime = System.currentTimeMillis // onClick 函式被呼叫的時間
  callback.apply(buttonID, currentTime)
}

onClick(myCallback)
onClick(myCallback2)

此外,Scala 也提供了一個工具,讓我們可以觀察 Scala 是怎麼樣實作出 Java 沒有的功能的,只要在編譯的時候使用 -print 選項,Scala 就會印出只使用 Java 有的功能的實作版本,當我們不確定 Scala 的程式碼到底是怎麼實作的時候,可以使用這個選項來確認。

除了使用 first-class function 的語法之外,我們也可以先定義一般的函式,再使用其名稱加上底線符號 _ 將其轉為 first-class function 來使用:

def myCallback(buttonID: Int, currentTime: Long) = { println("ButtonID:" + buttonID) }

def onClick(callback: Function2[Int, Long, Any] ) = {
  val buttonID = 1    // 假定這個 buttonID 會設定成按鈕的 ID
  val currentTime = System.currentTimeMillis // onClick 函式被呼叫的時間
  callback.apply(buttonID, currentTime)
}

onClick(myCallback _) // 直接使用提升一般的函式成為 first-class function
val myCallback2 = myCallback _ // 提升過後的函式和 first-class function 一樣可以指定給變數
onClick(myCallback2)

小結

在這一篇中,我們介紹了在 Scala 中要如何宣告變數與函式,並且也看到了 Scala 是如何使用物件導向再加上一點編譯器的語法取代功能,來實作出在其他 Functional Programming 語言中可以將函式做為參數傳遞的功能。

不過到目前為止,我們看到的都還只是基本的語法部份,不過別擔心,從下一篇開始我們將進入 Higher-order function 的實戰,來看看到底要如何在程式碼當中使用這些功能,而這些功能又會為我們帶來什麼好處。

[註一] 這個行為即將在未來版本的 Scala 中 Deprecated 掉,Scala 官方已不建議再使用這樣的語法,而是明確地在每個函式宣告後加上等號,這裡僅是為了讓讀者可以了解目前 Scala 程式碼常出現的函式定議方式而提及。

後續 >> Scala Tutorial(4)實戰 Higher-Order Function

分享:
按讚!加入 CodeData Facebook 粉絲群

留言

留言請先。還沒帳號註冊也可以使用FacebookGoogle+登錄留言

熱門論壇文章

熱門技術文章