談?wù)摰揭猛该?Referential Transparency),我們都會(huì)聊函數(shù)式編程(FP),會(huì)聊Effect和Side Effect,會(huì)聊純函數(shù)(Pure Function)等,這些概念相互關(guān)聯(lián),有時(shí)甚至彼此引用定義,能夠真正理解他們的含義非常重要。
基本概念
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. 即表達(dá)式和值可以互相替換,而對(duì)程序不產(chǎn)生任何影響。
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.
常見(jiàn)的Side Effect例子:
- 修改變量
- 拋出異常
- 打印日志
- 讀取寫(xiě)入文件
Pure Function
Wikipedia的定義較長(zhǎng),這里總結(jié)一下,滿足以下兩個(gè)條件即為純函數(shù):
- 對(duì)所有的輸入,相同的輸入都有相同的輸出;
- 該Function沒(méi)有Side Effect;
這三個(gè)概念都是在描述不同Scope的東西,但當(dāng)我們同在“函數(shù)”這一Scope內(nèi)認(rèn)為三個(gè)概念是等同的,即:
- 純函數(shù)
- 沒(méi)有Side Effect的函數(shù)
- 對(duì)任何入?yún)⒈磉_(dá)式都引用透明的函數(shù)
這三個(gè)概念是等同的。由此可得,理解并能夠正確判斷引用透明非常重要。
用幾個(gè)例子來(lái)理解引用透明
本文都以Scala進(jìn)行舉例。
1. 判斷 method 是否引用透明
def method(): Int = 1
// One
val value = method()
someFunc(value)
// Two
someFunc(method())
是的。這是一個(gè)最基本最簡(jiǎn)單的例子,還記得上面對(duì)引用透明的定義嗎,其中有三個(gè)比較重要的概念:
- expression:表達(dá)式,即這里的 method()
- value: 值,即這里的 value
- program:即這里的 someFunc(method())
表達(dá)式method()和值value可以相互替換,且對(duì)程序someFunc(method())不產(chǎn)生任何影響,因此這里是引用透明的。在對(duì)后續(xù)較為復(fù)雜的場(chǎng)景進(jìn)行判斷時(shí),我們也可以用這種方式首先清晰的分辨expression,value和program,然后進(jìn)一步分析。
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())
兩個(gè)program雖然返回值都是1,但program1打印了一次日志,program2打印了兩次日志。即表達(dá)式和值如果相互替換,會(huì)對(duì)程序產(chǎn)生行為影響,故引用不透明。
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())
根據(jù)定義表達(dá)式method()和值value可以互相替換,而對(duì)程序someFunc(method())不產(chǎn)生任何影響,那這里就是引用透明了。是嗎?對(duì)嗎?例子3和例子2使用了相同的表達(dá)式和值,為什么在例子2中不是引用透明的,但例子3中就是引用透明的了呢?
這是一個(gè)比較容易混淆的地方,實(shí)際上,引用透明只跟expression自己是如何實(shí)現(xiàn)的有關(guān),而program只是一個(gè)抽象概念,不是某一個(gè)具體的例子。如果認(rèn)為某一個(gè)表達(dá)式expression是引用透明的,那它應(yīng)當(dāng)在任何情況下都是透明的,如果能找到任何一個(gè)反例證明其不是引用透明的,那就是引用不透明。正如這里的例子3,我們不能只用例子中給出的program即someFunc(method())來(lái)判斷,還需要思考其他program中是否也是如此,使用例子2中的program來(lái)判斷就無(wú)法滿足條件,因此結(jié)論是引用不透明。
用幾個(gè)例子來(lái)測(cè)試是否理解引用透明
根據(jù)上面的學(xué)習(xí)結(jié)果來(lái)判斷一下下面兩個(gè)測(cè)試是否引用透明?答案在后面。
測(cè)試1: 判斷 method 是否引用透明
def method(input: Int): Int = input
// One
val value = method(1)
someFunc(value)
// Two
someFunc(method(1))
測(cè)試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 }))
--------------------------------------------------答案分割線-------------------------------------------------
測(cè)試1: 引用透明。比較簡(jiǎn)單直接,不用解釋。
測(cè)試2: 引用透明。但看起來(lái)可能有點(diǎn)奇怪,如果這里套用上面的判斷方式expression是method({println(“more evil”); 1}),value是value,program是someFunc(method({println(“more evil”);1})),那么看起來(lái)是不透明的,因?yàn)閳?zhí)行結(jié)果不同,program1只打印一次log,program2打印了兩次log。這里要注意,Scala中代碼塊是可以作為參數(shù)的,這里執(zhí)行結(jié)果不同,是因?yàn)榱硪粋€(gè)expression不透明,這里有一個(gè)“匿名”表達(dá)式{ println("more evil"); 1 },任何一個(gè)expression的不透明都會(huì)導(dǎo)致program執(zhí)行結(jié)果發(fā)生變化。
因此在函數(shù)式編程中,使expression純很難,函數(shù)時(shí)的最終目的是compose所有的表達(dá)式,在入口處執(zhí)行唯一最終組裝出來(lái)的內(nèi)容,要讓大expression是純的,就需要保證每一個(gè)子expression都是純的,因此要將其有Side Effect的地方變純,如何變純有很多方式,是另一個(gè)話題,最簡(jiǎn)單粗暴的方式是包在一個(gè)大Monad中,讓所有的Side Effect都被Monad Track住。
如何更好的設(shè)計(jì)引用透明的表達(dá)式
針對(duì)測(cè)試2的代碼,method本身是引用透明的,但由于Scala代碼能夠?qū)⒋a塊作為參數(shù),反而無(wú)意中引入了一個(gè)新的表達(dá)式,從而導(dǎo)致整個(gè)代碼不純,如何改進(jìn)呢?
在FP的開(kāi)發(fā)過(guò)程中,在做函數(shù)定義時(shí)首先要設(shè)計(jì)時(shí)使自己是引用透明的,同時(shí)注意不能相信其他部分例如入?yún)⑹且猛该鞯模孕枰撤N方式限制入?yún)⑹且猛该鞯摹?/p>
=> 改進(jìn)first round:將入?yún)⒆僱azy,同時(shí)保證自己是引用透明的
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 }))
這里通過(guò)限制入?yún)⒈仨毷莑azy的方式,限制method引用透明,但注意到,Lazy的入?yún)⒅荒鼙WC正常流程,如果expression執(zhí)行過(guò)程中發(fā)生異常呢?
=> 改進(jìn)second round:引入Either類(lèi)型
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類(lèi)型,并保證每一個(gè)expression的引用透明,這也是為什么我們常見(jiàn)的Scala repo中會(huì)大量使用各種Monad的原因之一。
引用透明的好處
這里使用Scala來(lái)舉例,因?yàn)獒莘鹪贔P的世界會(huì)更多的提及引用透明,但實(shí)際在代碼設(shè)計(jì)的過(guò)程中,不論OO還是FP,引用透明的設(shè)計(jì)都能幫助我們得到更多的好處。
Benefit 1: 更易測(cè)試
如果被測(cè)試的expression是引用透明的,那么輸出只依賴(lài)于輸入,編寫(xiě)測(cè)試case時(shí)也更加簡(jiǎn)單直接,我們只需要傳入已知的入?yún)⒉⑦M(jìn)行斷言即可,而Mock Side Effect其實(shí)是寫(xiě)測(cè)試過(guò)程中很難得一個(gè)部分。比如寫(xiě)一段測(cè)試代碼來(lái)斷言在console中輸出一段文字。
Benefit 2: 更易重構(gòu)
如果能夠判斷某個(gè)expression是引用透明的,那么我們能夠快速?zèng)Q策該表達(dá)式能夠被其value替代,反之亦然。這也是很多大型遺留系統(tǒng)中常見(jiàn)的通病,系統(tǒng)一開(kāi)始開(kāi)發(fā)時(shí),當(dāng)然你好我好大家好,怎么寫(xiě)都無(wú)所謂,但隨著時(shí)間的推移,代碼量的迭代,當(dāng)有相關(guān)上下文的人員逐漸離開(kāi)項(xiàng)目,很多代碼都有極大的風(fēng)險(xiǎn)變得難以維護(hù)。而如果代碼庫(kù)中的expression都是引用透明的,那后續(xù)開(kāi)發(fā)人員也可以輕易的進(jìn)行重構(gòu)改動(dòng)。
Benefit 3: 更易理解
如果能夠判斷某個(gè)函數(shù)是引用透明的,那么我們能夠通過(guò)該表達(dá)式的輸入輸出以及少量的幾行實(shí)現(xiàn)快速理解該expression的設(shè)計(jì)目的和想要做的事情,一共程度上與“單一設(shè)計(jì)原則”相呼應(yīng)。
如果函數(shù)不是引用透明的,那么開(kāi)發(fā)人員需要非常注意程序的執(zhí)行順序,同時(shí)需要復(fù)雜的各種debugger,inspection等復(fù)雜工具來(lái)檢查代碼,因?yàn)楸磉_(dá)式是不透明的,所以整個(gè)代碼庫(kù)的任何地方任何狀態(tài)都有可能發(fā)生bug。
Benefit 4: 更好設(shè)計(jì)
想象一下,如果代碼中的expression都是引用透明的,那么一旦成型后續(xù)我們不需要反復(fù)多次太多的關(guān)注該expression內(nèi)部的邏輯,我們可以有更多的時(shí)間來(lái)關(guān)于更重要的事情,比如系統(tǒng)架構(gòu)、代碼質(zhì)量等。理論上,我們可以直接通過(guò)函數(shù)簽名的命名、返回類(lèi)型等快速了解其做了什么功能。因此更多的引用透明能夠時(shí)開(kāi)發(fā)者更加高效,并更樂(lè)于提高軟件質(zhì)量。**