在Haskell中定義Y組合子
這篇文章的創作動機是:看到了這個問題:Y Combinator in Haskell. 我之前遇到這個問題,沒有思考過解決辦法。而這個問題給出很多好的解決方法,於是對於其中具有代表性的方法進行了解讀。
本文分爲四個部分:給出預備知識;定義問題;介紹解法;進行拓展。所有的lambda表達式都是用Haskell語法書寫的。有些地方爲了方便,表達式的書寫風格不太好,但是保證讀者可以看懂。
閱讀本文之前推薦先閱讀推導Y組合子。
文章目錄
預備知識
omega組合子
-
定義:
w = \f -> f f
-
性質:
w w = w w
不動點
- 定義:如果
f g = g
,則稱g
是f
的不動點。這裏的f
是一個函數,而g
可以是一個函數,也可以是一個值。 - 不動點組合子:Fixed-point combinator(不動點組合子)是具有不動點性質的函數:
forall f, fix f = f (fix f)
。
Y組合子
-
定義:
Y = \f -> (\x -> f (x x)) (\x -> f (x x))
;這個定義是Haskell Curry給出的,所以在下文中稱爲Y組合子的curry定義。 -
性質:
Y = \f -> w (f . w)
- 不動點性質:
forall f, f (Y f) = Y f
。
-
Y組合子和不動點組合子
fix
的關係:Y組合子的curry定義是一個純lambda calculus的函數,而且這個函數滿足不動點性質。因此,Y組合子的curry定義(這個純lambda calculus的函數)是不動點組合子fix
的一種非遞歸的實現方案。
利用Y組合子定義遞歸函數
對於一般的一階遞歸函數,即定義中只因引用自身導致遞歸的函數,它們具有形式f = ...f...
。在這種情況下,可以藉助Y組合子在untyped lambda calculus下給出一個純lambda的定義:
-
遞歸函數
f = ...f...
-
提出非遞歸函數:
f' = \f -> ...f...
,滿足f' f = f
-
藉助Y組合子,定義遞歸函數:
f = Y f'
比如階乘函數fac
:
cond True x y = x
cond False x y = y
w = \f -> f f
y = \f -> w (f . w)
fac' = \f -> \x -> cond (x==0) 1 (x * (f (x-1)))
fac = y fac'
問題
我們試圖把上一節階乘函數的定義翻譯成Haskell代碼,但是發現Y組合子在Haskell中無法直接定義。
問題描述
手動確定每個函數的類型之後,上一節階乘函數的定義變成了這樣的Haskell代碼:
cond :: Bool -> a -> a -> a
cond True x y = x
cond False x y = y
y :: (a -> a) -> a
y = \f -> (\x -> f (x x)) (\x -> f (x x))
fac' :: (Num a, Eq a) => (a -> a) -> a -> a
fac' = \f -> \x -> cond (x == 0) 1 (x * f (x - 1))
fac :: (Num a, Eq a) => a -> a
fac = y fac'
然而上面的這段代碼會報錯:Occurs check: cannot construct the infinite type
。這是因爲Haskell不支持遞歸類型,所以Y組合子(函數y
)在Haskell裏面無法定義。
omega組合子和Y組合子的類型的遞歸性
omega組合子和Y組合子(包括上一篇文章提到的滿足h h = fac
的h
函數)不能在Haskell中直接定義,是因爲他們具有遞歸類型。
對於omega組合子,記w w
的類型爲a
,w
的類型爲b
,那麼在w w
中第二個w
的類型爲b
,所以第一個w
的類型爲b -> a
,那麼w
的類型b
滿足b = b -> a
,這顯然是一個遞歸的類型。
對於Y組合子,根據性質Y f = f (Y f)
,知道等式兩邊的類型都是a
,f
的類型是a -> a
,所以Y組合子的類型是(a -> a) -> a
。但是Y組合子內部實現中帶有遞歸,比如在Y組合子的curry定義中,Y = \f -> (\x -> f (x x)) (\x -> f (x x))
,x的類型是遞歸的,導致這個式子不能在Haskell中通過類型檢查,所以上文的代碼會報錯。
解決方法
本節基於Y Combinator in Haskell的回答,給出了一些解決方案:
- 容忍遞歸類型
- 類型的遞歸 -> 組合子的遞歸定義
- 類型的遞歸 -> 構造支持描述這種遞歸的類型
後面的兩種方法是我們講述的重點:這兩種方法本質都是把兩種組合子類型上的遞歸性轉化爲其他方面的遞歸性,而這種其他方面的遞歸型可以用Haskell允許的語法表示。
容忍遞歸類型
import Unsafe.Coerce
y :: (a -> a) -> a
y = \f -> (\x -> f (unsafeCoerce x x)) (\x -> f (unsafeCoerce x x))
簡單但有效的辦法。由於理論性並不很強,在此就不贅述了。
構造遞歸的組合子:不動點組合子
通過遞歸地定義不動點組合子fix
,來完成遞歸函數的定義。
考慮到我們定義Y組合子,只是爲了用Y組合子的不動點性質,不妨直接定義滿足不動點性質的函數fix
:
fix :: (a -> a) -> a
fix f = f (fix f)
fac :: (Num a, Eq a) => a -> a
fac = fix fac'
這種解法的好處在於,沒有思考成本,直接使用不動點性質的原始公式(f (Y f) = Y f
),就定義出來了能用的東西。但因爲原始公式中自身蘊含的遞歸性,這裏的fix
不是用純的lambda expression定義的,而是用pattern match遞歸定義的。
(在這種定義方案下,階乘函數fac
的正確性證明留給讀者。)
構造遞歸類型:Mu
這種解法通過定義一個遞歸的類型Mu
,使omega組合子和Y組合子能夠在Haskell裏有一個類型,來完成遞歸函數的定義。如下:
newtype Mu a = Mu (Mu a -> a)
y f = (\h -> h $ Mu h) (\x -> f . (\(Mu g) -> g) x $ x)
這段代碼雖然一看就知道是從y = \f -> w (f . w)
過來的,但是可讀性實在是太差了。這促使我(花了億點點時間)把這段代碼化簡成了這樣:
-- Mu :: (Mu a -> a) -> Mu a
newtype Mu a = Mu (Mu a -> a)
w :: (Mu a -> a) -> a
w h = h (Mu h)
y :: (a -> a) -> a
y f = w (\(Mu x) -> f (w x))
-- y f = f . y f
把omega從y拎出來,把所有函數的類型寫明,極大提升了可讀性。
證明這段代碼有效:證明y f = f . y f
y f
= w (\(Mu x) -> f (w x)) -- apply y
= (\(Mu x) -> f (w x)) (Mu (\(Mu x) -> f (w x))) -- apply w
= f (w (\(Mu x) -> f (w x))) -- apply (\(Mu x) -> f (w x))
= f (y f) -- y f = w (\(Mu x) -> f (w x))
= f . y f
(在這種定義方案下,階乘函數的正確性證明留給讀者。)
思考
讀者此時一定非常好奇:Mu
是怎麼想出來的?與其像w
和Y
的引入一樣,再次通過一個實際問題引入Mu
,我們此時從一個更宏觀的視角看這個問題。
無論是使用omega組合子,Y組合子和Mu類型,都是對遞歸的模式進行了總結和抽象,並形成高階的函數/模式進行表示。這一類函數被統一在recursion-schemes
這個包裏,Haskell包的作者是Edward Kmett。GoogleEd Kmett recursion-scheme
可以獲得更多相關資料,包括Awesome Recursion Schemes ,recursion-schemes這些。
參考
The implementation of functional programming languages