1. 程式人生 > >scala、Java函數語言程式設計比較初步~

scala、Java函數語言程式設計比較初步~

今天我們就先來講一下Java8引入的Lambda表示式,以及由此引入的函數語言程式設計,以及函式式介面。

什麼是函數語言程式設計

函數語言程式設計並不是Java新提出的概念,其與指令程式設計相比,強調函式的計算比指令的計算更重要;與過程化程式設計相比,其中函式的計算可以隨時呼叫。

當然,大家應該都知道面向物件的特性(抽象、封裝、繼承、多型)。其實在Java8出現之前,我們關注的往往是某一類物件應該具有什麼樣的屬性,當然這也是面向物件的核心--對資料進行抽象。但是java8出現以後,這一點開始出現變化,似乎在某種場景下,更加關注某一類共有的行為(這似乎與之前的介面有些類似),這也就是java8提出函數語言程式設計的目的。如圖1-1所示,展示了面向物件程式設計到面向行為程式設計的變化。

圖1-1

為什麼需要Lambda表示式

首先,不得不提增加Lambda的目的,其實就是為了支援函數語言程式設計,而為了支援Lambda表示式,才有了函式式介面。另外,為了在面對大型資料集合時,為了能夠更加高效的開發,編寫的程式碼更加易於維護,更加容易執行在多核CPU上,java在語言層面增加了Lambda表示式。

第一個Lambda表示式

前邊廢話了這麼多,其實Lambda就是Java新增的語法而已。當然,Lambda(我們認為這裡包含了方法引用)確實能夠給我們的開發帶來許多便利。
首先,在java8之前,如果需要建立一個執行緒,很大可能會寫出下面的程式碼:

new Thread(new Runnable()) {
    @Override
    public void run() {
        System.out.println("Hello World!");
    }
}).start();

但是Java8引入Lambda之後,也許這樣寫會更好:

new Thread(
    () -> System.out.println("Hello world!");
);

很明顯,Lambda可以幫助我們減少模板程式碼的書寫,同時減少了要維護的匿名內部類,當然,其作用絕不僅僅這麼一點(關於Lambda的具體使用,讀者可以參考java8函數語言程式設計這本書,作者解析的很詳細)。接下來我們先來看一下java8關於介面的的變動。

Java8中介面的變化

其實Java9中關於介面,又有了進一步的變動,這裡我們暫且侷限於Java8。在Java8中,介面可以包含靜態方法,另外還增加了一個用於修飾方法的關鍵字--default,稱之為預設方法(帶有方法體)。

  • 靜態方法

其實Java8中增加靜態方法,目的完全出於編寫類庫,對某些行為進行抽象(還記得我們之前用類去做嗎?)。但是有一點不同的是:類中的靜態方法可以繼承,並且可以從例項獲得引用(並不建議這麼做);但是介面中的靜態方法不能被繼承。

  • 預設方法

其實,引入預設方法,是不得已而為之,因為Java8引入了函式式介面,許多像Collection這樣的基礎介面中增加了方法,如果還是一個傳統的抽象方法的話,那麼可能很多第三方類庫就會變得完全無法使用。為了實現二進位制的向後相容性,引入了帶有方法體、被default修飾的方法--預設方法。其主要思想就是如果子類中沒有實現,那麼採用父類提供的預設實現。其具體的繼承規則如圖1-2所示。

圖1-2

其中Parent介面中定義了預設方法welcome;
Child介面對預設方法進行了覆蓋;
ParentImpl繼承了Parent介面的方法;
ChildImpl繼承了Child的方法;
OverridingParent覆蓋了父類的welcome;
OverridingChild最終的welcome來自於OverridingParent。

關於繼承規則,可以簡短描述為:類勝於介面;子類勝於父類;如果前兩者都不適用,那麼子類要麼實現該方法,要麼將該方法宣告為抽象方法。

函式式介面

關於介面的變動,Java8中新定義了一種介面型別,函式式介面,與其他介面的區別就是:

  • 函式式介面中只能有一個抽象方法(我們在這裡不包括與Object的方法重名的方法);
  • 可以有從Object繼承過來的抽象方法,因為所有類的最終父類都是Object;
  • 介面中唯一抽象方法的命名並不重要,因為函式式介面就是對某一行為進行抽象,主要目的就是支援Lambda表示式。

Java8之前已經存在的函式式介面有:

java.lang.Runnable
java.util.concurrent.Callable
java.security.PrivilegedAction
java.util.Comparator
java.io.FileFilter
java.nio.file.PathMatcher
java.lang.reflect.InvocationHandler
java.beans.PropertyChangeListener
java.awt.event.ActionListener
javax.swing.event.ChangeListener

另外,Java8還提供了@FunctionalInterface註解來幫助我們標識函式式介面。另外需要注意的是函式式介面的目的是對某一個行為進行封裝,某些介面可能只是巧合符合函式式介面的定義。

如圖1-3所示,為java8的Function包的結構(即新引入的函式式介面),圖中綠色表示主要引入的新介面,其他介面基本上都是為了支援基本型別而新增的介面,方法的具體作用圖中有具體說明。

圖1-3

自定義函式式介面支援Lambda表示式

看下如下程式碼,最終輸出應該是兩行"Hello World!",是不是很神奇?

public class Main {
    public static void main(String[] args) {
        Action action = System.out :: println;
        action.execute("Hello World!");
        test(System.out :: println, "Hello World!");
    }

    static void test(Action action, String str) {
        action.execute(str);
    }
}
@FunctionalInterface
interface Action<T> {
    public void execute(T t);
}

小結

本文對Lambda以及函式式介面進行了簡要介紹,目的是激發大家使用Lambda的興趣,步入函數語言程式設計的大門。

-------------------------------------------------------------------------------------------------------------------------------------------------------------------------

scala作為支援函數語言程式設計的語言, scala可以將函式作為物件即所謂"函式是一等公民".

函式定義

scala原始檔中可以定義兩類函式:

  • 類方法: 類宣告時定義, 由類例項進行呼叫

  • 區域性函式: 在函式內部定義, 作用域只限於定義它的函式內部

這裡只關注函式定義相關內容, 關於類的有關內容請參考面向物件的相關內容.

scala使用def關鍵字定義函式:

def test() {
  println("Hello World!");
}

因為是靜態型別語言, 定義含引數和返回值的函式需要指定型別, 語法略有不同:

def add(x:Int, y:Int): Int = {
  return x + y;
}

scala支援預設引數:

def add(x:Int = 0, y:Int = 0):Int = {
    return x + y;
}

可以指定最後一個引數為可變引數, 從而接受數目不定的同類型實參:

scala> def echo (args: String *) { for (arg <- args) println(arg) }

scala> echo("Hello", "World")
Hello
World

String *型別的引數args實際上是一個Array[String]例項, 但是不能將一個Array作為引數傳給args.

若需傳遞Array作為實參,需要使用arr :_*傳遞實參:

scala> val arr= Array("Hello" , "World")
arr: Array[String] = Array(Hello, World)

scala> echo(arr: _*)
Hello
World

命名引數允許以任意順序傳入引數:

scala> def speed(dist:Double, time:Double):Double = {return dist / time}

scala> speed(time=2.0, dist=12.2)
res28: Double = 6.1

引數傳遞

scala的引數傳遞採用傳值的方式, 引數被當做常量val而非變數var傳入.

當我們試圖編寫一個swap函式時,出現錯誤:

scala> def swap(x:Int, y:Int) {var t = x; x = y; y = t;}
<console>: error: reassignment to val
       def swap(x:Int, y:Int) {var t = x; x = y; y = t;}
                                            ^
<console>: error: reassignment to val
       def swap(x:Int, y:Int) {var t = x; x = y; y = t;}
                                                   ^

scala中的識別符號實際是引用而非物件本身, 這一點與Java相同。 類例項中的屬性和容器的元素實際上只儲存了引用, 並非將成員自身儲存在容器中。

不熟悉Java的同學可以將物件和引用類比為C中的變數和指標

val將一個物件設為常量, 使得我們無法修改其中儲存的引用,但是允許我們修改其引用的其它物件.

以二維陣列val arr = Array(1,2,3)為例。 因為arr為常量,我們無法修改arr使其為其它值, 但我們可以修改arr引用的物件arr(0)使其為其它值:

scala> val arr = Array(1,2,3)
arr: Array[Int] = Array(1, 2, 3)

scala> arr = Array(2,3,4)
<console>:12: error: reassignment to val
       arr = Array(2,3,4)
           ^
scala> arr(0) = 2
arr: Array[Int] = Array(2, 2, 3)

引數傳遞過程同樣滿足這個性質:

scala> var arr = Array(1,2,3)
arr: Array[Int] = Array(1, 2, 3)

scala> def fun(arr:Array[Int]):Array[Int] = {arr(0) += 1; return arr;}
fun: (arr: Array[Int])Array[Int]

scala> fun(arr)
res: Array[Int] = Array(3, 2, 3)

scala> arr
arr: Array[Int] = Array(3, 2, 3)

換名傳遞

上述引數傳遞採用傳值的方式傳遞: 在函式呼叫時實參值被傳入函式執行過程中引數值不會因為實參值改變而發生改變。

換名傳遞則不立即進行引數傳遞, 只有引數被訪問時才會去取實參值, 即形參成為了實參的別名.

換名傳遞可以用於實現惰性取值的效果.

換名傳遞引數用: =>代替:宣告, 注意空格不能省略.

def work():Int = {
  println("generating data");
  return (System.nanoTime % 1000).toInt
}

def delay(t: => Int) {
  println(t);
  println(t);
}

scala> delay(work())
generating data
247
generating data
143

從結果中可以注意到work()函式被呼叫了兩次, 並且換名引數t的值發生了改變.

換名引數只是傳遞時機不同,仍然採用val的方式進行傳遞.

函式字面量

函式字面量又稱為lambda表示式, 使用=>符號定義:

scala> var fun = (x:Int) => x + 1
fun: Int => Int = $$Lambda$1422/[email protected]

函式字面量是一個物件, 可以作為引數和返回值進行傳遞.

使用_逐一替換普通函式中的引數 可以得到函式對應的字面量:

scala> def add(x:Int, y:Int):Int = {return x + y}
add: (x: Int, y: Int)Int

scala> var fun = add(_,_)
fun: (Int, Int) => Int = $$Lambda$1423/[email protected]

部分應用函式與偏函式

使用_代替函式引數的過程中,如果只替換部分引數的話則會得到一個新函式, 稱為部分應用函式(Partial Applied Function):

scala> val increase = add(_:Int, 1)
increase: Int => Int = $$Lambda$1453/[email protected]

偏函式是一個數學概念, 是指對定義域中部分值沒有定義返回值的函式:

def pos = (x:Int) => x match {
        case x if x > 0 => 1
}

高階函式

函式字面量可以作為引數或返回值, 接受函式字面量作為引數的函式稱為高階函式.

scala內建一些高階函式, 用於定義集合操作:

collection.map(func)將集合中每一個元素傳入func並將返回值組成一個新的集合作為map函式的返回值:

scala> var arr = Array(1,2,3)
arr: Array[Int] = Array(1, 2, 3)

scala> arr.map(x=>x+1)
res: Array[Int] = Array(2, 3, 4)

上述示例將arr中每個元素執行了x=>x+1操作, 結果組成了一個新的集合返回.

collection.flatMap(func)類似於map, 只不過func返回一個集合, 它們的並集作為flatMap的返回值:

scala> var arr = Array(1,2,3)
arr: Array[Int] = Array(1, 2, 3)

scala> arr.flatMap(x=>Array(x,-x))
res: Array[Int] = Array(1, -1, 2, -2, 3, -3)

上述示例將arr中每個元素執行x=>Array(x, -x)得到元素本身和它相反陣列成的陣列,最終得到所有元素及其相反陣列成的陣列.

collection.reduce(func)中的func接受兩個引數, 首先將集合中的兩個引數傳入func,得到的返回值作為一個引數和另一個元素再次傳入func, 直到處理完整個集合.

scala> var arr = Array(1,2,3)
arr: Array[Int] = Array(1, 2, 3)

scala> arr.reduce((x,y)=>x+y)
res: Int = 6

上述示例使用reduce實現了集合求值. 實際上, reduce並不保證遍歷的順序, 若要求特定順序請使用reduceLeftreduceRight.

zip函式雖然不是高階函式,但是常和上述函式配合使用, 這裡順帶一提:

scala> var arr1 = Array(1,2,3)
arr1: Array[Int] = Array(1, 2, 3)

scala> var arr2 = Array('a', 'b', 'c')
arr2: Array[Char] = Array(a, b, c)

scala> arr1.zip(arr2)
res: Array[(Int, Char)] = Array((1,a), (2,b), (3,c))

高階函式實際上是自定義了控制結構:

scala> def twice(func: Int=>Int, x: Int):Int = func(func(x))
twice: (func: Int => Int, x: Int)Int

scala> twice(x=>x*x, 2)
res: Int = 16

twice函式定義了將函式呼叫兩次的控制結構, 因此實參2被應用了兩次x=>x*x得到16.

柯里化

函式的柯里化(currying)是指將一個接受n個引數的函式變成n個接受一個引數的函式.

以接受兩個引數的函式為例,第一個函式接受一個引數 並返回一個接受一個引數的函式.

原函式:

scala> def add(x:Int, y:Int):Int = {return x+y}
add: (x: Int, y: Int)Int

進行柯里化:

scala> def add(x:Int)= (y:Int)=>x*y
add: (x: Int)Int => Int

這裡沒有指明返回值型別, 交由scala的型別推斷來決定. 呼叫柯里化函式:

scala> add(2)(3)
res10: Int = 6

scala> add(2)
res11: Int => Int = $$Lambda$1343/[email protected]

可以注意到add(2)返回的仍是函式.

scala提供了柯里化函式的簡化寫法:

scala> def add(x:Int)(y:Int)={x+y}
add: (x: Int)(y: Int)Int

如上是關於scala函數語言程式設計(functional programming, FP)的特性,這裡再談談函數語言程式設計正規化:

函數語言程式設計中, 函式是從引數到返回值的對映而非帶有返回值的子程式; 變數(常量)也只是一個量的別名而非記憶體中的儲存單元.

也就是說函數語言程式設計關心從輸入到輸出的對映, 不關心具體執行過程. 比如使用map對集合中的每個元素進行操作, 可以使用for迴圈進行迭代, 也可以將元素分發到多個worker程序中處理.

函數語言程式設計可理解為將函式(對映)組合為大的函式, 最終整個程式即為一個函式(對映). 只要將資料輸入程式, 程式就會將其對映為結果.

這種設計理念需要滿足兩個特性. 一是高階函式, 它允許函式進行復合; 另一個是函式的引用透明性, 它使得結果不依賴於具體執行步驟只依賴於對映關係.

結果只依賴輸入不依賴上下文的特性稱為引用透明性; 函式對外部變數的修改被稱為副作用.只通過引數和返回值與外界互動的函式稱為純函式,純函式擁有引用透明性和無副作用性.

不可變物件並非必須, 但使用不可變物件可以強制函式不修改上下文. 從而避免包括執行緒安全在內很多問題.

函數語言程式設計的特性使得它擁有很多優勢:

  • 函式結果只依賴輸入不依賴於上下文, 使得每個函式都是一個高度獨立的單元, 便於進行單元測試和除錯.

  • 函式結果不依賴於上下文也不修改上下文, 從而在併發程式設計中不需要考慮執行緒安全問題, 也就避免了執行緒安全問題帶來的風險和開銷. 這一特性使得函式式程式很容易部署於平行計算和分散式計算平臺上.

函數語言程式設計在很多技術社群都是有著廣泛爭議的話題, 筆者認為"什麼是函式程式設計","函數語言程式設計的精髓是什麼"這類問題並不重要。

作為程式設計師應該考慮的是"函數語言程式設計適合解決什麼問題?它有何有缺?"以及"何時適合應用函數語言程式設計?這個問題中如何應用函數語言程式設計?".

函數語言程式設計並非"函式式語言"的專利. 目前包括Java,Python在內的, 越來越多的語言開始支援函式式特性, 我們同樣可以在Java或Python專案上發揮函數語言程式設計的長處.