在下函數語言程式設計,有何貴幹?
本文簡單介紹了一下函數語言程式設計的各種基本特性,希望能夠對於準備使用函數語言程式設計的人起到一定入門作用。
函數語言程式設計,一個一直以來都酷,很酷,非常酷的名詞。雖然誕生很早也炒了很多年但是一直都沒有造成很大的水花,不過近幾年來隨著多核,分散式,大資料的發展,函數語言程式設計已經廣泛投入到了實戰中。
然而現實中還是有不少人不太瞭解這種程式設計正規化,覺得這僅僅是一個逼格較高的名詞。我們這裡就來簡單介紹一下這個舉手投足都充滿酷勁的小東西。
本文之後的程式碼主要以 Java 和 Scala 為主,前者說明如何在非函式式語言中實現函式式風格,後者說明在函式式語言中是如何做的。程式碼比較簡單,無論你是否懂這兩門語言,相信都能很容易看懂。此外由於函數語言程式設計這幾個詞太長了,以下都以 FP 進行簡寫。
特性
函式是一等公民
所謂的函式是一等公民指的是在 FP 中,函式可以作為直接作為變數的值。
例
Scala
val add = (x: Int, y: Int) => x + y
add(1, 2)
以上我們定義了一個負責將兩個整型相加的匿名函式並賦值給變數 add,並且直接將這個變數當前函式進行呼叫,這在大部分面向物件的語言中你都是無法直接這樣做的。
Java
interface Adder {
int add(int x, int y);
}
Adder adder = (x, y) -> x + y;
adder.add(1, 2);
由於 Java 並不是函式式語言,所以無法直接將函式賦值給變數,因此以上例子中我們使用 SAM 轉換來實現近似功能。
閉包
閉包是一種帶有自由變數的程式碼塊,其最根本的功能就是能夠擴大區域性變數的生命週期。閉包相信很多人都很熟悉,在 JavaScript 中閉包無處不在,是一種很好用但是一不注意就會掉坑裡的特性。
例
Scala
var factor = 10
factor = factor * 10
val multiplier = (x: Int) => x * factor
以上例子中函式體使用了兩個引數,其中 x 只是很普通的函式引數,而 factor 則是函式體外定義的一個區域性變數,且該變數可以任意進行修改,所以對 factor 的引用使該函式變成了一個閉包。
Java
int factor = 10; // factor = factor * 10; Multiplier multiplier = (x) -> x * factor;
在 Java 中匿名函式只能引用外部的 final 變數,Java 8 雖然可以省略 final 關鍵字,但是實際還是沒有任何變化,所以第二句語句必須註釋掉。這也就是說在 Java 中實際是無法使用自由變數的,因此 Java 是否有真正的閉包一直都是一個爭論點,這裡就不多牽扯了。
惰性求值 Lazy Evaluation
一般而言成員變數在例項建立時就會被初始化,而惰性求值可以將初始化的過程延遲到變數的第一次使用,對於成員變數的值需要經過大量計算的類來說可以有效加快例項的建立過程。
例
Scala
lazy val lazyField = {
var sum = 0
for (i <- 1 to 100) {
sum += i
}
sum
}
在 Scala 中是通過關鍵字 lazy 來宣告惰性求值的。在以上例子中定義了一個從 1 加到 100 的惰性變數,在第一次訪問該變數時這個計算過程才會被執行。
Java
Supplier<Integer> lazyField = () -> {
int sum = 0;
for (int i = 1; i <= 100; i++) {
sum += i;
}
return sum;
};
Java 雖然在語言層面沒有提供該功能,但是可以通過 Java 8 提供的 Supplier 介面來實現同樣的功能。
尾遞迴 Tail Recursion
遞迴大家都知道,就是函式自己呼叫自己。
例
定義一個遞迴函式
def addOne(i: Int) {
if (i > 3) return
println(s"before $i")
addOne(i + 1)
println(s"after $i")
}
呼叫以上函式並傳入引數 3 會列印如下語句
before 1
before 2
before 3
after 3
after 2
after 1
這就是遞迴的基本形式。在每次遞迴呼叫時程式都必須儲存當前的方法呼叫棧,即呼叫 addOne(2) 時程式必須記住之前是如何呼叫 addOne(1) 的,這樣它才能在執行完 addOne(2) 後返回到 addOne(1) 的下一條語句並列印 after 1。因此在 Java 等語言中遞迴一來影響效率,二來消耗記憶體,呼叫次數過多時會引起方法棧溢位。
而尾遞迴指的就是隻在函式的最後一個語句呼叫遞迴。這樣的好處是可以使用很多 FP 語言都支援的尾遞迴優化或者叫尾遞迴消除,即遞迴呼叫時直接將函式的呼叫者傳入到下一個遞迴函式中,並將當前函式彈出棧中,在最後一次遞迴呼叫完畢後直接返回傳入的呼叫者處而不是返回上一次遞迴的呼叫處。
用簡單的示意圖即是由原來的
line xxx, call addOne -> addOne(1) -> addOne(2) -> addOne(3) -> addOne(2) -> addOne(1) -> line xxx
優化為
line xxx, call addOne -> addOne(1) -> addOne(2) -> addOne(3) -> line xxx
純函式 Pure Function
純函式並不是 FP 的特性,而是 FP 中一些特性的集合。所謂的純函式簡單來講就是函式不能有副作用,保證引用透明。即函式本身不會修改引數的值也不會修改函式外的變數,無論執行多少次,同樣的輸入都會有同樣的輸出。
例
定義三個函式
def add(x: Int, y: Int) = x + y
def clear(list: mutable.MutableList): Unit = {
list.clear()
}
def random() = Random.nextInt()
以上程式碼中定義了三個函式,其中 add() 符合純函式的定義;clear() 會清除傳入的 List 的所有元素,所以不是純函式;random() 無法保證每次呼叫都產生同樣的輸入,所以也不是純函式。
高階函式 High-Order Function
高階函式指一個函式的引數是另一個函式,或者一個函式的返回值是另一個函式。
例
引數為函式
def assert(predicate: () => Boolean) =
if (!predicate())
throw new RuntimeException("assert failed")
assert(() => 1 == 2)
以上函式 assert() 接收一個匿名函式 () => 1 == 2 作為引數,本質上是應用了傳名呼叫的特性。
返回值為函式
def create(): Int => Int = {
val factor = 10
(x: Int) => x * factor
}
集合操作 Collection
集合操作可以說是 FP 中最常用的一個特性,激進的 FP 擁護者甚至認為應該使用 foreach 替代所有迴圈語句。這些集合操作本質上就是多個內建的高階函式。
例
Scala
val list = List(1, 2, 3)
list.map(i => {
println(s"before $i")
i * 2
}).map(i => i + 1)
.foreach(i => println(s"after $i"))
以上定義了一個包含三個整形的列表,依次對其中每個元素乘以 2 後再加 1,最後進行列印操作。輸出結果如下:
before 1
before 2
before 3
after 3
after 5
after 7
可以看到 FP 中的集合操作關注的是資料本身,至於如何遍歷資料這一行為則是交給了語言內部機制來實現。相比較 for 迴圈來說這有兩個比較明顯的優點:1. 一定程度上防止了原資料被修改,2. 不用關心遍歷的順序。這樣使用者可以在必要時將操作放到多執行緒中而不用擔心引起一些副作用,編譯器也可以在編譯時自行對遍歷進行深度優化。
Java
List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(3);
list.stream()
.map(i -> {
System.out.println("before " + i);
return i * 2;
}).map(i -> i + 1)
.forEach(i -> System.out.println("after " + i));
輸出
before 1
after 3
before 2
after 5
before 3
after 7
可以從以上輸出看到對於集合操作 Scala 和 Java 的實現完全不一樣。
Scala 中一個操作中的所有資料完成處理後才流向下一個操作,可以看做每個操作都是一個關卡。而 Java 則是預設使用了惰性求值的方式,並且概念非常類似 Spark。其各種集合操作主要分為兩種: transformation 和 action。transformation 即轉換操作,所有返回 Stream 物件的函式都是 transformation 操作,該操作不會立即執行,而是將執行步驟儲存在 Stream 物件中。action 即執行操作,action 沒有返回值,呼叫後會立即執行之前 Stream 物件中儲存的所有操作。 map() 這樣的就是 transformation 操作,forEach() 就是 action 操作。
柯理化 Currying
柯里化指的是將一個接收多個引數的函式分解成多個接收單個引數的函式的一種技術。
比如說有這樣一個普通的函式
def minus(x: Int, y: Int) = x - y
柯理化後就變成以下形式,一個減法操作被分割為兩部分
def minusCurrying(x: Int)(y: Int) = x - y
呼叫以上兩個函式
minus(5, 3)
minusCurrying(5)(3)
部分應用 Function Partial Application
函式的部分應用指的是向一個接收多個引數的函式傳入部分引數從而獲得一個接收剩餘引數的新函式的技術。
比如說有這樣一個包含多個引數的函式
def show(prefix: String, msg: String, postfix: String) = prefix + msg + postfix
獲得部分應用函式
val applyPrefix = show("(", _: String, _: String)
println(applyPrefix("foo", ")")) // (foo)
val applyPostfix = show(_: String, _: String, ")")
println(applyPostfix("(", "bar")) // (bar)
以上 applyPrefix() 是應用了 show() 的第一個引數的新函式,applyPostfix() 是應用了 show() 的最後一個引數的新函式。
偏函式 Partial Function
函式指對於所有給定型別的輸入,總是存在特定型別的輸出。
偏函式指對於某些給定型別的輸入,可能沒有對應的輸出,即偏函式無法處理給定類型範圍內的所有值。
定義一個偏函式
val isEven: PartialFunction[Int, String] = {
case x if x != 0 && x % 2 == 0 => x + " is even"
}
以上 isEven() 只能處理偶數,對於奇數則無法處理,所以是一個偏函式。
偏函式可以用於責任鏈模式,每個偏函式只處理部分型別的資料,其餘型別的資料由下一個偏函式進行處理。
val isOdd: PartialFunction[Int, String] = {
case x if x % 2 != 0 => x + " is odd"
}
val other: PartialFunction[Int, String] = {
case _ => "else"
}
val partial = isEven orElse isOdd orElse other
println(partial(3)) // 3 is odd
println(partial(0)) // else
尾聲
除了以上特性,函數語言程式設計中還有 Monoid,SemiGroup 等比較難以理解的概念,本文暫時不牽扯那麼深,留待有興趣的人自行調查。最後我想說的是使用函數語言程式設計的確很阪本,但是多瞭解一種程式設計正規化對於從碼農進化為碼農++還是很有幫助的。
如果你對以上程式碼有興趣的話可以直接訪問 https://github.com/SidneyXu/JGSK。