Haskell/Arrow 教程
箭頭為用基本函子類結構化計算提供了另一種方法。本章提供了關於它們的動手教程,而下一章,理解箭頭,用概念概述作為補充。我們建議您從教程開始,以便您可以體驗使用箭頭的程式設計感覺。當然,如果您喜歡以更慢的速度進行,您可以在教程和“理解箭頭”的第一部分之間來回切換。務必在 GHC(i) 上遵循教程的每一步。
在本教程中,我將建立自己的箭頭,展示如何使用箭頭 proc 語法,並展示 ArrowChoice 如何工作。最終我們將得到一個簡單的猜詞遊戲。
首先,我們給出語言 pragma(省略)以在編譯器中啟用箭頭 do 語法。然後是一些匯入
module Main where
import Control.Arrow
import Control.Monad
import qualified Control.Category as Cat
import Data.List
import Data.Maybe
import System.Random
任何 Haskell 函式都可以作為箭頭,因為函式型別建構函式 (->) 有一個 Arrow 例項。在本教程中,我將構建一個比這更有意思的箭頭,它能夠維護狀態(普通的 Haskell 函式箭頭無法做到)。箭頭可以產生各種效果,包括 I/O,但我們只會探討一些簡單的例子。
我們將把我們的新箭頭稱為 Circuit,以暗示我們可以將箭頭視覺化為電路。[1]
作為箭頭的普通 Haskell 函式具有 a -> b 型別。我們的 Circuit 箭頭有兩個顯著特徵:首先,我們將它包裝在一個 newtype 宣告中,以清晰地定義一個 Arrow 例項。其次,為了使電路能夠維護其內部狀態,我們的箭頭返回一個替換它自身的箭頭,以及正常的 b 輸出值。
newtype Circuit a b = Circuit { unCircuit :: a -> (Circuit a b, b) }
為了使它成為一個箭頭,我們需要使其成為 Category 和 Arrow 的例項。在這些定義中,我們始終將每個 Circuit 替換為它返回的新版本。
instance Cat.Category Circuit where
id = Circuit $ \a -> (Cat.id, a)
(.) = dot
where
(Circuit cir2) `dot` (Circuit cir1) = Circuit $ \a ->
let (cir1', b) = cir1 a
(cir2', c) = cir2 b
in (cir2' `dot` cir1', c)
Cat.id 函式用自身的一個副本替換自身,而不維護任何狀態。(.) 函式的目的是將兩個箭頭從右到左連線起來。(>>>) 和 (<<<) 基於 (.)。它需要用執行引數 Circuits 返回的兩個替換的點運算替換自身。
instance Arrow Circuit where
arr f = Circuit $ \a -> (arr f, f a)
first (Circuit cir) = Circuit $ \(b, d) ->
let (cir', c) = cir b
in (first cir', (c, d))
arr 將普通 Haskell 函式提升為箭頭。與 id 一樣,它給出的替換隻是自身,因為普通的 Haskell 函式無法維護狀態。
現在我們需要一個函式來執行電路
runCircuit :: Circuit a b -> [a] -> [b]
runCircuit _ [] = []
runCircuit cir (x:xs) =
let (cir',x') = unCircuit cir x
in x' : runCircuit cir' xs
對於像我一樣的 mapAccumL 愛好者,這也可以寫成
runCircuit :: Circuit a b -> [a] -> [b]
runCircuit cir inputs =
snd $ mapAccumL (\cir x -> unCircuit cir x) cir inputs
或者,在 eta-reduction 後,簡單地寫成
runCircuit :: Circuit a b -> [a] -> [b]
runCircuit cir = snd . mapAccumL unCircuit cir
讓我們定義一個通用的累加器作為我們之後工作的基礎。accum' 是 accum 的一個不那麼通用的版本。
-- | Accumulator that outputs a value determined by the supplied function.
accum :: acc -> (a -> acc -> (b, acc)) -> Circuit a b
accum acc f = Circuit $ \input ->
let (output, acc') = input `f` acc
in (accum acc' f, output)
-- | Accumulator that outputs the accumulator value.
accum' :: b -> (a -> b -> b) -> Circuit a b
accum' acc f = accum acc (\a b -> let b' = a `f` b in (b', b'))
這裡是一個有用的具體累加器,它保持所有作為輸入傳遞給它的數字的執行總和。
total :: Num a => Circuit a a
total = accum' 0 (+)
我們可以像這樣執行這個電路
*Main> runCircuit total [1,0,1,0,0,2]
[1,1,2,2,2,4]
*Main>
這裡有一個統計平均值函式
mean1 :: Fractional a => Circuit a a
mean1 = (total &&& (const 1 ^>> total)) >>> arr (uncurry (/))
它維護兩個累加器單元,一個用於總和,一個用於元素數量。它使用“扇出”運算子 &&& 拆分輸入,在第二個流的輸入之前,它丟棄輸入值並用 1 替換它。
const 1 ^>> total 是 arr (const 1) >>> total 的簡寫。第一個流是輸入的總和。第二個流是每個輸入的 1 之和(即輸入數量的計數)。然後,它使用 (/) 運算符合並這兩個流。
這裡有同一個函式,但使用箭頭 proc 語法編寫
mean2 :: Fractional a => Circuit a a
mean2 = proc value -> do
t <- total -< value
n <- total -< 1
returnA -< t / n
proc 語法描述了箭頭之間的相同關係,但以完全不同的方式。您不是顯式描述佈線,而是使用變數繫結和純 Haskell 表示式將箭頭粘合在一起,編譯器為您完成所有 arr, (>>>), (&&&) 工作。箭頭 proc 語法也包含一個純 'let' 語句,與單子 do 語句完全一樣。
proc 是引入箭頭符號的關鍵字,它將箭頭輸入繫結到一個模式(本例中為 value)。do 塊中的箭頭語句採用以下形式之一
變數繫結模式 <- 箭頭 -> 給出箭頭輸入的純表示式箭頭 -> 給出箭頭輸入的純表示式
與單子一樣,do 關鍵字僅用於使用 <- 將多個行與變數繫結模式組合在一起。與單子一樣,最後一行不允許包含變數繫結模式,最後一行輸出的值是箭頭的輸出值。returnA 就像 'total' 一樣是一個箭頭(實際上,returnA 只是恆等箭頭,定義為 arr id)。
同樣與單子一樣,除了最後一行以外的其他行可能沒有變數繫結,並且你只能得到效果,丟棄返回值。在 Circuit 中,這樣做永遠沒有意義(因為沒有狀態可以逃逸,除了透過返回值),但在許多箭頭中會有意義。
如你所見,對於此示例,proc 符號使程式碼更易讀。讓我們試一試
*Main> runCircuit mean1 [0,10,7,8]
[0.0,5.0,5.666666666666667,6.25]
*Main> runCircuit mean2 [0,10,7,8]
[0.0,5.0,5.666666666666667,6.25]
*Main>
Hangman:選擇一個詞
[edit | edit source]現在,對於我們的 Hangman 遊戲,讓我們從字典中選擇一個詞
generator :: Random a => (a, a) -> StdGen -> Circuit () a
generator range rng = accum rng $ \() rng -> randomR range rng
dictionary = ["dog", "cat", "bird"]
pickWord :: StdGen -> Circuit () String
pickWord rng = proc () -> do
idx <- generator (0, length dictionary-1) rng -< ()
returnA -< dictionary !! idx
使用 generator,我們使用累加器功能來儲存我們的隨機數生成器。pickWord 沒有引入任何新內容,只是生成器箭頭是由一個接受引數的 Haskell 函式構建的。以下是輸出
*Main> rng <- getStdGen
*Main> runCircuit (pickWord rng) [(), (), ()]
["dog","bird","dog"]
*Main>
我們將在稍後使用這些小箭頭。第一個在第一次返回 True,之後永遠返回 False
oneShot :: Circuit () Bool
oneShot = accum True $ \_ acc -> (acc, False)
*Main> runCircuit oneShot [(), (), (), (), ()]
[True,False,False,False,False]
第二個儲存一個值並返回它,當它得到一個新值時
delayedEcho :: a -> Circuit a a
delayedEcho acc = accum acc (\a b -> (b,a))
可以縮短為
delayedEcho :: a -> Circuit a a
delayedEcho acc = accum acc (flip (,))
*Main> runCircuit (delayedEcho False) [True, False, False, False, True]
[False,True,False,False,False]
遊戲的主要箭頭將重複執行,我們希望只在第一次迭代時選擇單詞,並在遊戲剩餘時間內記住它。我們不希望在後續迴圈中僅僅掩蓋它的輸出,而是希望實際執行 pickWord 僅一次(因為在實際實現中它可能非常慢)。然而,就目前而言,Circuit 中的資料流必須經過所有元件箭頭的路徑。為了允許資料流經過一條路徑而不經過另一條路徑,我們需要使我們的箭頭成為 ArrowChoice 的例項。以下是最小定義
instance ArrowChoice Circuit where
left orig@(Circuit cir) = Circuit $ \ebd -> case ebd of
Left b -> let (cir', c) = cir b
in (left cir', Left c)
Right d -> (left orig, Right d)
getWord :: StdGen -> Circuit () String
getWord rng = proc () -> do
-- If this is the first game loop, run pickWord. mPicked becomes Just <word>.
-- On subsequent loops, mPicked is Nothing.
firstTime <- oneShot -< ()
mPicked <- if firstTime
then do
picked <- pickWord rng -< ()
returnA -< Just picked
else returnA -< Nothing
-- An accumulator that retains the last 'Just' value.
mWord <- accum' Nothing mplus -< mPicked
returnA -< fromJust mWord
因為 ArrowChoice 已定義,編譯器現在允許我們在 <- 後面放置一個 if,從而選擇要執行的箭頭(要麼執行 pickWord,要麼跳過它)。請注意,這不是普通的 Haskell if:編譯器使用 ArrowChoice 實現它。編譯器在這裡也以相同的方式實現 case。
重要的是要理解,所有區域性名稱繫結,包括 proc 引數,在 <- 和 -> 之間都不在範圍內,除了在 if 或 case 的條件中。例如,以下操作是非法的
{-
proc rng -> do
idx <- generator (0, length dictionary-1) rng -< () -- ILLEGAL
returnA -< dictionary !! idx
-}
要執行的箭頭,這裡為 generator (0, length dictionary -1) rng,在 'proc' 語句外部存在的範圍內進行評估。rng 在此範圍內不存在。如果你仔細想想,這很有道理,因為箭頭只在開頭(在 proc 外部)構建。如果它是在每次執行箭頭時構建的,那麼它如何保持其狀態呢?
讓我們試一試 getWord
*Main> rng <- getStdGen
*Main> runCircuit (getWord rng) [(), (), (), (), (), ()]
["dog","dog","dog","dog","dog","dog"]
*Main>
Hangman:主程式
[edit | edit source]現在,以下是遊戲
attempts :: Int
attempts = 5
livesLeft :: Int -> String
livesLeft hung = "Lives: ["
++ replicate (attempts - hung) '#'
++ replicate hung ' '
++ "]"
hangman :: StdGen -> Circuit String (Bool, [String])
hangman rng = proc userInput -> do
word <- getWord rng -< ()
let letter = listToMaybe userInput
guessed <- updateGuess -< (word, letter)
hung <- updateHung -< (word, letter)
end <- delayedEcho True -< not (word == guessed || hung >= attempts)
let result = if word == guessed
then [guessed, "You won!"]
else if hung >= attempts
then [guessed, livesLeft hung, "You died!"]
else [guessed, livesLeft hung]
returnA -< (end, result)
where
updateGuess :: Circuit (String, Maybe Char) String
updateGuess = accum' (repeat '_') $ \(word, letter) guess ->
case letter of
Just l -> map (\(w, g) -> if w == l then w else g) (zip word guess)
Nothing -> take (length word) guess
updateHung :: Circuit (String, Maybe Char) Int
updateHung = proc (word, letter) -> do
total -< case letter of
Just l -> if l `elem` word then 0 else 1
Nothing -> 0
main :: IO ()
main = do
rng <- getStdGen
interact $ unlines -- Concatenate lines out output
. ("Welcome to Arrow Hangman":) -- Prepend a greeting to the output
. concat . map snd . takeWhile fst -- Take the [String]s as long as the first element of the tuples is True
. runCircuit (hangman rng) -- Process the input lazily
. ("":) -- Act as if the user pressed ENTER once at the start
. lines -- Split input into lines
這裡是一個示例會話。為了獲得最佳效果,請編譯遊戲並從終端而不是從 GHCi 中執行它
Welcome to Arrow Hangman ___ Lives: [#####] a ___ Lives: [#### ] g __g Lives: [#### ] d d_g Lives: [#### ] o dog You won!
高階內容
[edit | edit source]在本節中,我將完成對箭頭符號的介紹。
將箭頭命令與函式組合
[edit | edit source]我們這樣實現 mean2
mean2 :: Fractional a => Circuit a a
mean2 = proc value -> do
t <- total -< value
n <- total -< 1
returnA -< t / n
GHC 定義了一個香蕉括號語法,用於將箭頭語句與對箭頭進行操作的函式組合在一起。(在 Ross Paterson 的論文 [2] 中使用了 form 關鍵字,但 GHC 代替採用了香蕉括號。)儘管沒有真正的原因,但我們可以這樣編寫 mean
mean3 :: Fractional a => Circuit a a
mean3 = proc value -> do
(t, n) <- (| (&&&) (total -< value) (total -< 1) |)
returnA -< t / n
(| ... |) 內的第一個專案是一個函式,它以任意數量的箭頭作為輸入,並返回一個箭頭。這裡不能使用中綴符號。它後面跟著引數,引數的形式為 proc 語句。這些語句如果需要,可以包含 do 和使用 <- 的繫結。每個引數都會被轉換為一個箭頭,並作為引數傳遞給函式 (&&&)。
你可能會問,這樣做有什麼意義?我們可以很方便地組合箭頭,而無需 proc 符號。好吧,重點是你可以在語句中使用區域性變數繫結。
香蕉括號實際上不是必需的。編譯器足夠智慧,可以假設當你這樣編寫時這就是你的意思(注意,這裡允許使用中綴符號)
mean4 :: Fractional a => Circuit a a
mean4 = proc value -> do
(t, n) <- (total -< value) &&& (total -< 1)
returnA -< t / n
那麼我們為什麼要使用香蕉括號呢?對於更簡單的語法存在歧義的情況。原因是 proc 命令的箭頭部分不是普通的 Haskell 表示式。請記住,對於在 proc 語句中指定的箭頭,以下內容是正確的
- 區域性變數繫結僅允許在
->後面的輸入表示式中,以及在if和case條件中。箭頭本身在proc外部存在的範圍內進行解釋。 if和case語句不是普通的 Haskell。它們使用ArrowChoice實現。- 用於組合箭頭的函式也不是普通的 Haskell。它們是香蕉括號語法的簡寫。
遞迴繫結
[edit | edit source]冒著用完 mean 示例的風險,這裡還有另一種使用遞迴繫結來實現它的方法。為了使它能夠正常工作,我們需要一個將輸入延遲一步的箭頭
delay :: a -> Circuit a a
delay last = Circuit $ \this -> (delay this, last)
以下是 delay 的作用
*Main> runCircuit (delay 0) [5,6,7]
[0,5,6]
*Main>
以下是我們遞迴版本的 mean
mean5 :: Fractional a => Circuit a a
mean5 = proc value -> do
rec
(lastTot, lastN) <- delay (0,0) -< (tot, n)
let (tot, n) = (lastTot + value, lastN + 1)
let mean = tot / n
returnA -< mean
rec 塊類似於 do' 塊,只是
- 最後一行可以(通常是)一個變數繫結。它是否是
let繫結還是使用<-的do-塊繫結並不重要。 rec塊沒有返回值。var <- rec ...是非法的,rec不允許是do塊中的最後一個元素。- 預期變數的使用會形成一個迴圈(否則
rec就沒有意義)。
rec 的機制由 ArrowLoop 類的 loop 函式處理,我們為 Circuit 定義如下
instance ArrowLoop Circuit where
loop (Circuit cir) = Circuit $ \b ->
let (cir', (c,d)) = cir (b,d)
in (loop cir', c)
在幕後,它的工作原理是這樣的
- 在
rec中定義的任何在rec中被向前引用的變數都會透過傳遞給loop的第二個元組元素來迴圈。實際上,變數繫結及其對它們的引用可以按任何順序排列(但箭頭語句的順序在效果方面很重要)。 - 在
rec中定義的任何在rec外部引用的變數都會在loop的第一個元組元素中返回。
重要的是要理解,loop(因此 rec)只是繫結變數。它不會保留值並在下次呼叫時傳遞回來 - delay 負責這部分。由變數引用形成的迴圈必須由某種延遲箭頭或惰性求值打破,否則程式碼將陷入無限迴圈,就像你在普通的 Haskell 中編寫 let a = a+1 一樣。
ArrowApply
[edit | edit source]如前所述,箭頭語句的箭頭部分(在 -> 之前)不能包含任何在 'proc' 內繫結的變數。有一個替代運算子 -<<,它消除了此限制。它要求箭頭實現 ArrowApply 型別類。
註釋
- ↑ 這種將箭頭視為電路的解釋是鬆散地基於 Yampa 函式式反應式程式設計庫的。
- ↑ Ross Paterson 的論文,它指定了箭頭
proc符號