在面嚮物件語言中寫純函式!
通常我們說函數語言程式設計時,提到的都是 lambda 表示式,也即函數語言程式設計中的“函式是頭等公民”的特點,然而函式式的另一個重要特點: 無副作用 ,在我看來更為重要。它可以在任何語言中實際應用。今天,我們來談一談面向物件中的“副作用”。
#什麼是副作用
In computer science, a function or expression is said to have a side effect if it modifies some state outside its scope or has an observable interaction with its calling functions or the outside world.
根據維基百科,在計算機中,當一個函式或表示式修改了自己的域之外的狀態或是與函式外的東西有可見的互動,我們就稱該函式或表示式有副作用(side effect)。
說得更直白一些,如果呼叫一個函式,該函式可以(一個或多個)返回值,除此之外,如果函式還修改了引數、全域性變數,或是做了 I/O 操作,都說這個函式有副作用。沒有副作用的函式被稱為 純函式。
為什麼要去討論一個函式有沒有“副作用”呢?這是因為,如果一個函式沒有副作用,那麼可以推出這個函式的結果只依賴於它的引數,這個特性可以給我們帶來一些好處,例如:
- 易於並行,同時多執行緒執行一個純函式肯定是不會產生競爭的。因為函式需要的資源全都由引數提供。
- 容易對它做快取,因為函式的結果只與引數有關,因此可以容易對它做快取。
- 易於 debug 及單元測試。只需要給定引數,檢查結果即可。
- 如果一個純函式的結果沒有被使用,則刪掉這個函式(及對它的呼叫)對程式的結果不影響。
#一些非純函式
Java 中的各種 setter 不是 純函式,因為它修改了函式的引數。
class Account { private int balance; public setBalance(int newBalance) { this.balance = newBalance; }}Account account = new Account(); |
在上例中,執行完 setBalance(account, 100)
後, account
的值發生了變化,因此不是純函式。推而廣之,任何類的方法,只要修改了類的屬性,則該函式不是純函式。
last = 1def nextRand(): global last last = last * 13 % 7 return last |
上例中, nextRand()
函式讀取並寫入全域性變數,因此 不是 純函式。要注意的是隻要讀入 或 寫入全域性變數都屬於副作用。
def func(x): print "x is ", x |
上述函式做了 I/O 操作,也不是純函式。
結合上面的例子,其實有一個特別簡單的判斷,如果用相同的引數呼叫一個函式任意多次,它們返回的結果是一樣的,則這個函式就是 純函式,反之則不是。
#副作用的危害
單看上面的例子,我們看不出“副作用”的巨大危害,但 避免副作用 的思想一定要有!這裡舉一個在工作上被副作用坑害的例子,用以警告大家去避免函式的副作用。
這個例子是真實場景下出現的一個問題,只是這裡簡化了其中的邏輯與需求。
需求是檢查兩個帳號的資訊,判斷它們是否雷同/相似,並給出相同的欄位。於是有了類似下面的程式碼:
class AccountComparator { private Map sameFields = new HashMap(); private Map diffFields = new HashMap(); public Map compare(Account a, Account b) { bool sameName = a.getName().equal(b.getName()); bool sameEmail = a.getEmail().equal(b.getEmail()); bool sameBirthday = a.getBirthday().equal(b.getBirthday()); saveField("name", sameName, a.getName()); saveField("email", sameEmail, a.getEmail()); saveField("birthday", sameEmail, b.getBirthday()); return gatherResult(); } private void saveField(String fieldName, bool isSame, String value) { if (isSame) { sameFields.put(fieldName, value); } else { diffFields.put(fieldName, value); } } private Result gatherResult() { Result ret = new Result(); sameFields.forEach((k, v) -> ret.addSameField(k, v)); diffFields.forEach((k, v) -> ret.addDiffField(k, v)); return ret; }}AccountComparator comparator = new AccountComparator();Result result = comparator.compare(a, b); |
這裡只對比了其中的三項資訊,呼叫 saveField
把該項結果儲存起來,最後呼叫
gatherResult
得到結果。在談如何改進之前,這個類有什麼問題?
相信你已經發現了,問題在於這個 compare
函式並不是純函式!那會有什麼問題呢?考慮下面的程式碼。
Account a = new Account("nameA", "emailA", "BirthdayA");Account b = new Account("nameA", "emailB", "BirthdayB");Account duplication_a = new Account("nameA", "emailA", "BirthdayA");AccountComparator comparator = new AccountComparator();Result result1 = comparator.compare(a, b);Result result2 = comparator.compare(a, duplication_a); |
那麼 result2
中的 diffFields
包含什麼值?我們的預期是空,因為 a
與
duplication_a
是完全一樣的,但實際返回時它卻包含了 email, birthday
。這些欄位是呼叫 compare(a, b)
時留下的!
上例的 bug 是非常難發現的,因為做單元測試時如果沒有測連續的呼叫,或都連續呼叫的引數設定不好,都是觸發不了這個 bug 的。一般也不會特意想到這種例子,否則看程式碼就能發現 bug 了。
下面是其中的一種改法:
class AccountComparator { public static Map compare(Account a, Account b) { Map sameFields = new HashMap(); Map diffFields = new HashMap(); bool sameName = a.getName().equal(b.getName()); bool sameEmail = a.getEmail().equal(b.getEmail()); bool sameBirthday = a.getBirthday().equal(b.getBirthday()); saveField("name", sameName, a.getName()); saveField("email", sameEmail, a.getEmail()); saveField("birthday", sameEmail, b.getBirthday()); return gatherResult(); } public static void saveField(Map sameFields, Map diffFields, String fieldName, bool isSame, String value) { if (isSame) { sameFields.put(fieldName, value); } else { diffFields.put(fieldName, value); } } public static Result gatherResult(Map sameFields, Map diffFields) { Result ret = new Result(); sameFields.forEach((k, v) -> ret.addSameField(k, v)); diffFields.forEach((k, v) -> ret.addDiffField(k, v)); return ret; }} |
要注意的是這裡的 saveField
函式依舊不是純函式,因為它修改了函式的引數
sameFields
與 diffFields
。但這裡這麼做是因為 Java 裡對不可變資料結構
(immutable datastructure) 的支援較差。
這樣一來,函式 compare
就變成了一個純函式,因為它所需要的狀態全部存在於函式內(包括引數)。就樣多次呼叫該函式也不會有問題的。
#純函式的“副作用”
如果寫的函式都是純函式會怎麼樣呢?
首先是沒辦法與外界交流,因為不能用任意的 I/O操作,這在實際的程式設計中是絕不可能的。也因此,我們所能做的是儘量將“副作用”縮小到幾個函式內,而大部分函式依舊是純函式。
另一個問題就是效率。就像上面看到的,任意類的 setter 方法都不是純函式,那麼如果非要把類的各種方法都變成純函式,則每個方法都應該返回一個新的類,例如:
class Account { private int balance; public Account setBalance(int newBalance) { return new Account(newBalance); }}Account account = new Account(10);account = account.setBalance(100); |
這樣就會造成一些效率上的問題。那麼是不是使用純函式就是一個平衡的問題。這又涉及面向物件風格與函式式風格的對比。這裡不想過多討論這種問題,但即使是面向物件的語言,也可以儘量寫成純函式。
#小結
函數語言程式設計的思想包含很多內容,本文介紹了其中的“無副作用”概念,並給出一個例項,試圖說明副作用的壞處,並給出一個“無副作用”的實現。最後說明了純函式的一些弱點。
想要表達的內容其實很簡單:即使在面嚮物件語言中,我們也應該儘量寫出無副作用的函式。
希望大家在平時的工作學習中,能夠應用得上。