當談論引用透明時,我們在說什麼

談論到引用透明(Referential Transparency),我們都會聊函數式編程(FP),會聊Effect和Side Effect,會聊純函數(Pure Function)等,這些概念相互關聯,有時甚至彼此引用定義,能夠真正理解它們的含義非常重要。

使用了引用透明,可以爲我們帶來諸多好處,讓我們的代碼更易於設計,方便測試和重構,讀起來也更容易理解。

用幾個例子來測試是否理解引用透明

判斷一下下面兩個測試是否引用透明?答案在後面。(本文都以Scala進行舉例)

測試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: 引用透明。

測試1是比較簡單常見的例子,也比較容易理解,但是測試2可能就沒那麼容易明白了,如果還沒徹底想清楚,那麼繼續往下看吧!

基本概念

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的定義較長,這裏總結一下,滿足以下兩個條件即爲純函數:

  1. 對所有的輸入,相同的輸入都有相同的輸出;
  2. 該Function沒有Side Effect;

而事實上,這三個概念都是在描述不同Scope的東西,我們同在“函數”這一Scope內認爲三個概念是等同的,即:

  • 純函數
  • 沒有Side Effect的函數
  • 對任何入參表達式都引用透明的函數

這三個概念是等同的。由此可得,理解並能夠正確判斷引用透明非常重要。

用幾個例子來理解引用透明

1. 判斷 method 是否引用透明

def method(): Int = 1

// One
val value = method()
someFunc(value)

// Two
someFunc(method())

透明。這是一個最基本最簡單的例子,還記得上面對引用透明的定義嗎,其中有三個比較重要的概念:

  1. expression:表達式,即這裏的 method()
  2. value: 值,即這裏的 value
  3. 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來判斷就無法滿足條件,因此結論是引用不透明。

回到開頭的例子

根據上面的學習結果來再次分析一下開頭的測試2爲什麼是引用透明的:

測試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 }))

測試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 pure很難,函數時的最終目的是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的原因之一。

以上就是關於引用透明的一些例子和分享,在實際的日常FP開發中,我們經常會面臨類似的問題,這就需要我們除了能夠正確引用第三方的FP庫之外,還能夠寫出更加FP的代碼,因此正確理解和使用透明這一概念非常重要。


文/Thoughtworks 王亞鑫 袁迪
原文鏈接:https://insights.thoughtworks.cn/how-to-referential-transparency/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章