來寫些迴圈吧!?

February 10, 2022

在命令式的語言中,通常會有 forwhile 等迴圈語法,Haskell 沒有這類語法,這不意外,迴圈的本質就是變動的(Mutable),使用迴圈就會有副作用,基本上是為了改變狀態,無論是變數狀態、物件狀態、程式狀態或是真實世界的狀態。

不過在 Haskell,可以使用函式來自訂一些長得像迴圈的東西,在這之前,得先來認識 return 函式的應用。

return 函式

C/C++、Java 這類主流語言的 return,是用來從函式中返回,如果有指定值的話,就是函式的傳回值;不過,Haskell 的 return 是個函式,接受一個值,然後傳回一個 Monad

ghci> :t return
return :: Monad m => a -> m a
ghci>

Monad 是個型態類別,別對 Monad 感到太驚恐,我們一步一步來 …

在〈初探 IO 型態〉看過 IO 型態,要透過 getLine 取得使用者輸入,那個你無法控制狀態的世界,會將值裝入 IO,然後以 IO String 型態從 getLine 傳回,那麼要怎麼將值裝入 IO?它的值建構式沒有導出,不能使用 IO "Text" 這樣的方式,也不能使用模式比對取得,對於 IO,Haskell 是使用 <- 來綁定值。

這段描述跟 return 有什麼關係?IO 型態具有 Monad 的行為,要將值裝入 Monad,可以使用 return,例如:

ghci> let ms = return "Text"
ghci> :t ms
ms :: Monad m => m String
ghci> text <- ms
ghci> text
"Text"
ghci>

return "Text" 傳回 Monad m => m String 型態的值,接著使用 <- 綁定 Monad 的值,就 Monad 來說,return 就好比 <- 的相反。

舉例來說,Maybe 也具有 Monad 的行為,除了使用 Just "Text" 傳回一個 Maybe String 之外,也可以這麼做:

ghci> let maybe = (return "Text") :: Maybe String
ghci> :t maybe
maybe :: Maybe String
ghci> let (Just text) = maybe
ghci> text  
"Text"
ghci> 

Maybe 有導出值建構式,因此可以直接使用模式比對來取得 Maybe String 的值,然而實際上,也可以透過 <-Maybe 裡的值綁定到變數,因為這是 Monad 的特性,之後還會正式介紹,接下來還是先著重在 returnIO,它們有什麼關係呢?

如果要寫個 echo 程式,重複讀取使用者輸入,直到輸入某個特定字串後結束的話,要怎麼寫呢?

echoUntil :: String -> IO ()
echoUntil str = do
    input <- getLine
    if input /= str 
        then do
            putStrLn (">> " ++ input)
            echoUntil str
        else return ()

main = echoUntil "quit"

echoUntil 函式中,如果輸入不為 str,那麼就顯示輸入並繼續遞迴呼叫,如果為 str,那麼就 return (),由於函式型態定義了傳回型態為 IO ()return () 傳回了 IO (),也就是 IO 裝著一個空的 tuple,putStrLn 的傳回型態也是 IO (),這樣型態就一致了。

再來看個 returnIO 的應用,Haskell 的 putStr 可以輸出一個字串而不換行,實際上,它是使用 putChar 實作出來,顧名思義,putChar 就是輸出一個字元,來看看它怎麼實作 putStr

putStr :: String -> IO () 
putStr [] = return () 
putStr (x:xs) = do 
    putChar x 
    putStr xs

來個 while 迴圈

方才的 echoUntil 函式會重複讀取使用者輸入,直到輸入某個特定字串後結束,也許你也會寫個函式重複讀取檔案中每一行,直到讀到某行後結束,或者你還會寫個讀取字元的函式,從網路接受字元,直到某個字元出現後結束 …

只要寫幾個這類函式,就會發現這個結構一直出現:

if something 
    then do
        -- 一些 IO Action
    else return ()

這時就是該將這個結構封裝起來了:

while :: Monad m => Bool -> m () -> m ()
while cond value = do
    if cond then value
            else return ()

有了這個 while 函式,之前的 echoUtil 函式就可以改寫為:

echoUntil :: String -> IO ()
echoUntil str = do
    input <- getLine
    while (input /= str) $ do
        putStrLn (">> " ++ input)
        echoUntil str

在〈初探 IO 型態〉談過,「可以先將 do 理解為,逐層銜接一組 IOdo 最後呼叫函式的傳回型態,就是 do 的傳回型態」,因為惰性的關係,這個 IO 在真正需要前不會估值,因此,看來就真的像是命令式語言中的 while 迴圈語法。

Haskell 的 Control.Monad 模組中,就有提供 when 函式,可以使用 when 來改寫上頭的程式:

import Control.Monad

echoUntil :: String -> IO ()
echoUntil str = do
    input <- getLine
    when (input /= str) $ do
        putStrLn (">> " ++ input)
        echoUntil str

main = echoUntil "quit"

無窮迴圈

如果需要無窮迴圈,可令 when 第一個引數為 True,例如:

echo :: IO ()
echo = do
    when True $ do
        input <- getLine
        putStrLn (">> " ++ input)
        echo

不過,總是要記得呼叫函式本身,這時有個 forever 函式可以幫忙:

import Control.Monad

echo :: IO ()
echo = do
    forever $ do
        input <- getLine
        putStrLn (">> " ++ input)

處理一組 Monad

如果你有個 list,想要逐一將結果顯示怎麼辦?例如,System.EnvironmentgetArgs,可以取得使用者指定的命令列引數,若想逐一顯示呢?基本上可以如下遞迴處理:

import System.Environment

printAll :: [String] -> IO ()
printAll [] = return ()
printAll (x:xs) = do
    putStrLn x
    printAll xs    
    
main = do
    args <- getArgs
    printAll args

不過來換個角度想想,putStrLn 會傳回 IO (),也就說可以裝載到 list 裡,像是 [putStrLn arg | arg <- args],這麼一來 list 裡就全是 IO (),不過因為惰性的關係,還不會真正執行,必須將這串 IO ()do 串起來:

import System.Environment

sequence' :: Monad m => [m a] -> m ()
sequence' [] = return ()
sequence' (x:xs) = do
    x
    sequence' xs
    
main = do
    args <- getArgs
    sequence' [putStrLn arg | arg <- args]

其實 Haskell 本身就內建了 sequence,它是從 Control.Monad 匯入至 Prelude,這麼寫就可以了:

import System.Environment
    
main = do
    args <- getArgs
    sequence [putStrLn arg | arg <- args]

那個 Comprehension 表示式可以使用 map 取代:

import System.Environment
    
main = do
    args <- getArgs
    sequence $ map putStrLn args

Control.Monad 有個 mapM 做了類似的事情,它匯入 Prelude,這麼寫就可以了:

import System.Environment
    
main = do
    args <- getArgs
    mapM putStrLn args

Control.Monad 有個 forM,它的參數與 mapM 相反,這表示 args 可以放在 forM 後面,這能形成像是命令列語言的 foreach 風格:

import System.Environment
import Control.Monad

main = do
    args <- getArgs
    forM args $ \arg -> do
        putStrLn arg

因此想模仿命令式語言,用 for 來遞增索引,就可以如下:

import System.Environment
import Control.Monad

main = do
    args <- getArgs
    forM [0..length args - 1] $ \i -> do
        putStrLn $ args !! i

重複 n 次動作

如果只是想重複執行 n 次動作,雖然也可以如下:

import System.Environment
import Control.Monad

main = do
    let n = 3 
    forM [0..n - 1] $ \_ -> do
        putStrLn "Hello, World"

不過使用 Control.MonadreplicateM 會更方便:

import System.Environment
import Control.Monad

main = do
    let n = 3 
    replicateM n $ do
        putStrLn "Hello, World"

更多 Control.Monad 模組的函式介紹,可參考〈輸入與輸出〉。

分享到 LinkedIn 分享到 Facebook 分享到 Twitter