跳轉到內容

Haskell/Arrow 教程

來自華夏公益教科書,開放世界開放書籍

箭頭為用基本函子類結構化計算提供了另一種方法。本章提供了關於它們的動手教程,而下一章,理解箭頭,用概念概述作為補充。我們建議您從教程開始,以便您可以體驗使用箭頭的程式設計感覺。當然,如果您喜歡以更慢的速度進行,您可以在教程和“理解箭頭”的第一部分之間來回切換。務必在 GHC(i) 上遵循教程的每一步。

Stephen 的 Arrow 教程

[編輯 | 編輯原始碼]

在本教程中,我將建立自己的箭頭,展示如何使用箭頭 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]

Circuit 的型別定義

[編輯 | 編輯原始碼]

作為箭頭的普通 Haskell 函式具有 a -> b 型別。我們的 Circuit 箭頭有兩個顯著特徵:首先,我們將它包裝在一個 newtype 宣告中,以清晰地定義一個 Arrow 例項。其次,為了使電路能夠維護其內部狀態,我們的箭頭返回一個替換它自身的箭頭,以及正常的 b 輸出值。

newtype Circuit a b = Circuit { unCircuit :: a -> (Circuit a b, b) }

為了使它成為一個箭頭,我們需要使其成為 CategoryArrow 的例項。在這些定義中,我們始終將每個 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

Circuit 原語

[編輯 | 編輯原始碼]

讓我們定義一個通用的累加器作為我們之後工作的基礎。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>

Arrow proc 語法

[編輯 | 編輯原始碼]

這裡有一個統計平均值函式

mean1 :: Fractional a => Circuit a a
mean1 = (total &&& (const 1 ^>> total)) >>> arr (uncurry (/))

它維護兩個累加器單元,一個用於總和,一個用於元素數量。它使用“扇出”運算子 &&& 拆分輸入,在第二個流的輸入之前,它丟棄輸入值並用 1 替換它。

const 1 ^>> totalarr (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 引數,在 <--> 之間都不在範圍內,除了在 ifcase 的條件中。例如,以下操作是非法的

{-
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 語句中指定的箭頭,以下內容是正確的

  • 區域性變數繫結僅允許在 -> 後面的輸入表示式中,以及在 ifcase 條件中。箭頭本身在 proc 外部存在的範圍內進行解釋。
  • ifcase 語句不是普通的 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 型別類。

註釋

  1. 這種將箭頭視為電路的解釋是鬆散地基於 Yampa 函式式反應式程式設計庫的。
  2. Ross Paterson 的論文,它指定了箭頭 proc 符號
華夏公益教科書