談論到引用透明(Referential Transparency),我們都會聊函數式編程(FP),會聊Effect和Side Effect,會聊純函數(Pure Function)等,這些概念相互關聯,有時甚至彼此引用定義,能夠真正理解他們的含義非常重要。
基本概念
Referential Transparency
引用Wikipedia的定義: An expression is called referentially transparent if it can be replaced with its corresponding value (and vice-versa) without changing the program's behavior. 即表達式和值可以互相替換,而對程序不產生任何影響。
Side Effect
引用Wikipedia的定義: An operation, function or expression is said to have a side effect if it modifies some state variable value(s) outside its local environment, which is to say if it has any observable effect other than its primary effect of returning a value to the invoker of the operation.
常見的Side Effect例子:
- 修改變量
- 拋出異常
- 打印日志
- 讀取寫入文件
Pure Function
Wikipedia的定義較長,這里總結一下,滿足以下兩個條件即為純函數:
- 對所有的輸入,相同的輸入都有相同的輸出;
- 該Function沒有Side Effect;
這三個概念都是在描述不同Scope的東西,但當我們同在“函數”這一Scope內認為三個概念是等同的,即:
- 純函數
- 沒有Side Effect的函數
- 對任何入參表達式都引用透明的函數
這三個概念是等同的。由此可得,理解并能夠正確判斷引用透明非常重要。
用幾個例子來理解引用透明
本文都以Scala進行舉例。
1. 判斷 method 是否引用透明
def method(): Int = 1
// One
val value = method()
someFunc(value)
// Two
someFunc(method())
是的。這是一個最基本最簡單的例子,還記得上面對引用透明的定義嗎,其中有三個比較重要的概念:
- expression:表達式,即這里的 method()
- value: 值,即這里的 value
- program:即這里的 someFunc(method())
表達式method()和值value可以相互替換,且對程序someFunc(method())不產生任何影響,因此這里是引用透明的。在對后續較為復雜的場景進行判斷時,我們也可以用這種方式首先清晰的分辨expression,value和program,然后進一步分析。
2. 判斷 method 是否引用透明
def method(): Int = {
println("evil logging >_<")
1
}
// One
val value = method()
someFunc(value) + someFunc(value)
// Two
someFunc(method()) + someFunc(method())
不透明。這里expression為method(),value為value,program為 someFunc(method())+someFunc(method())
兩個program雖然返回值都是1,但program1打印了一次日志,program2打印了兩次日志。即表達式和值如果相互替換,會對程序產生行為影響,故引用不透明。
3. 判斷 method 是否引用透明
def method(): Int = {
println("evil logging >_<")
1
}
// One
val value = method()
someFunc(value)
// Two
someFunc(method())
引用透明嗎?這里expression為method(),value為value,program為 someFunc(method())
根據定義表達式method()和值value可以互相替換,而對程序someFunc(method())不產生任何影響,那這里就是引用透明了。是嗎?對嗎?例子3和例子2使用了相同的表達式和值,為什么在例子2中不是引用透明的,但例子3中就是引用透明的了呢?
這是一個比較容易混淆的地方,實際上,引用透明只跟expression自己是如何實現的有關,而program只是一個抽象概念,不是某一個具體的例子。如果認為某一個表達式expression是引用透明的,那它應當在任何情況下都是透明的,如果能找到任何一個反例證明其不是引用透明的,那就是引用不透明。正如這里的例子3,我們不能只用例子中給出的program即someFunc(method())來判斷,還需要思考其他program中是否也是如此,使用例子2中的program來判斷就無法滿足條件,因此結論是引用不透明。
用幾個例子來測試是否理解引用透明
根據上面的學習結果來判斷一下下面兩個測試是否引用透明?答案在后面。
測試1: 判斷 method 是否引用透明
def method(input: Int): Int = input
// One
val value = method(1)
someFunc(value)
// Two
someFunc(method(1))
測試2: 判斷 method 是否引用透明
def method(input: Int): Int = input
// One
val value = method({ println("more evil"); 1 })
someFunc(value)
someFunc(value)
// Two
someFunc(method({ println("more evil"); 1 }))
someFunc(method({ println("more evil"); 1 }))
--------------------------------------------------答案分割線-------------------------------------------------
測試1: 引用透明。比較簡單直接,不用解釋。
測試2: 引用透明。但看起來可能有點奇怪,如果這里套用上面的判斷方式expression是method({println(“more evil”); 1}),value是value,program是someFunc(method({println(“more evil”);1})),那么看起來是不透明的,因為執行結果不同,program1只打印一次log,program2打印了兩次log。這里要注意,Scala中代碼塊是可以作為參數的,這里執行結果不同,是因為另一個expression不透明,這里有一個“匿名”表達式{ println("more evil"); 1 },任何一個expression的不透明都會導致program執行結果發生變化。
因此在函數式編程中,使expression純很難,函數時的最終目的是compose所有的表達式,在入口處執行唯一最終組裝出來的內容,要讓大expression是純的,就需要保證每一個子expression都是純的,因此要將其有Side Effect的地方變純,如何變純有很多方式,是另一個話題,最簡單粗暴的方式是包在一個大Monad中,讓所有的Side Effect都被Monad Track住。
如何更好的設計引用透明的表達式
針對測試2的代碼,method本身是引用透明的,但由于Scala代碼能夠將代碼塊作為參數,反而無意中引入了一個新的表達式,從而導致整個代碼不純,如何改進呢?
在FP的開發過程中,在做函數定義時首先要設計時使自己是引用透明的,同時注意不能相信其他部分例如入參是引用透明的,所以需要某種方式限制入參是引用透明的。
=> 改進first round:將入參變lazy,同時保證自己是引用透明的
def method(input: () => Int): () => Int = input
// One
val value = method(() => { println("more evil"); 1 })
someFunc(value)
someFunc(value)
// Two
someFunc(method(() => { println("more evil"); 1 }))
someFunc(method(() => { println("more evil"); 1 }))
這里通過限制入參必須是lazy的方式,限制method引用透明,但注意到,Lazy的入參只能保證正常流程,如果expression執行過程中發生異常呢?
=> 改進second round:引入Either類型
def method(input: () => Either[Error, Int]): () => Either[Error, Int] = input
// One
val value = method(() => {println("more evil"); Right(1)})
someFunc(value)
someFunc(value)
// Two
someFunc(() => {println("more evil"); Right(1)})
someFunc(() => {println("more evil"); Right(1)})
用Either track,保證異常流程返回Left類型,并保證每一個expression的引用透明,這也是為什么我們常見的Scala repo中會大量使用各種Monad的原因之一。
引用透明的好處
這里使用Scala來舉例,因為彷佛在FP的世界會更多的提及引用透明,但實際在代碼設計的過程中,不論OO還是FP,引用透明的設計都能幫助我們得到更多的好處。
Benefit 1: 更易測試
如果被測試的expression是引用透明的,那么輸出只依賴于輸入,編寫測試case時也更加簡單直接,我們只需要傳入已知的入參并進行斷言即可,而Mock Side Effect其實是寫測試過程中很難得一個部分。比如寫一段測試代碼來斷言在console中輸出一段文字。
Benefit 2: 更易重構
如果能夠判斷某個expression是引用透明的,那么我們能夠快速決策該表達式能夠被其value替代,反之亦然。這也是很多大型遺留系統中常見的通病,系統一開始開發時,當然你好我好大家好,怎么寫都無所謂,但隨著時間的推移,代碼量的迭代,當有相關上下文的人員逐漸離開項目,很多代碼都有極大的風險變得難以維護。而如果代碼庫中的expression都是引用透明的,那后續開發人員也可以輕易的進行重構改動。
Benefit 3: 更易理解
如果能夠判斷某個函數是引用透明的,那么我們能夠通過該表達式的輸入輸出以及少量的幾行實現快速理解該expression的設計目的和想要做的事情,一共程度上與“單一設計原則”相呼應。
如果函數不是引用透明的,那么開發人員需要非常注意程序的執行順序,同時需要復雜的各種debugger,inspection等復雜工具來檢查代碼,因為表達式是不透明的,所以整個代碼庫的任何地方任何狀態都有可能發生bug。
Benefit 4: 更好設計
想象一下,如果代碼中的expression都是引用透明的,那么一旦成型后續我們不需要反復多次太多的關注該expression內部的邏輯,我們可以有更多的時間來關于更重要的事情,比如系統架構、代碼質量等。理論上,我們可以直接通過函數簽名的命名、返回類型等快速了解其做了什么功能。因此更多的引用透明能夠時開發者更加高效,并更樂于提高軟件質量。**