💡

スタイル別 Haskell コードの読み方

2022/08/01に公開

命令スタイルと関数スタイル

先日、こんな tweet があったので、
https://twitter.com/ringo1625/status/1553002997164437506
こんな変な返信をしました。
https://twitter.com/nobsun/status/1553025014173413377
これは func1 の定義で、func2 を使うはずだったのを展開してしまう(func2 の定義は不要になってしまう)ミスがふくまれています。筆者としては、

module Main where

main :: IO ()
main = putStrLn =<< join (func1 =<< getLine)

func1 :: String -> IO (IO String)
func1 = (func2 <$>) . (<$> getLine) . (++)

func2 :: String -> IO String
func2 = (<$> getLine) . flip (++)

というつもりでした。

それはさておき、読むのが難しい理由は以下のたぶん2つ。

  1. do 構文を使っていない関数スタイルである
    • 命令の順次実行という制御に沿った記述になっていない
  2. ポイントフリースタイルである
    • 関数の定義が、関数適用の有り様を示した記述になっていない

それぞれのスタイルの Haskell のコードを読んでみましょう。

命令スタイルの Haskell コードの読み方

もとのコードは解説の必要のないくらいシンプルなものですが、読んでみましょう。
もとのコードは do 構文を使った命令スタイルで書かれていますので、命令スタイル風に読むことにします。

まず、func2

func2 x2 = do
    e <- getLine            -- 1.
    return (e ++ x2)        -- 2.

func2 は標準入力から読み込んだ文字列を、引数で与えた文字列の前に連結してできた文字列を結果とする手続き。分解すると

  1. 標準入力から1行読む手続き getLine の呼び出し結果をとりだし e に格納する。
  2. e の中身と x2 の中身を連結した文字列を結果とする。

となっています。次に func1

func1 x = do
    d <- getLine            -- 1.
    return (func2 (x ++ d)) -- 2.

func1 は標準入力から読み込んだ文字列に、引数で与えた文字列を連結したものを、func2 に渡して、できた手続きを返す手続き。分解すると

  1. 手続き getLineの呼び出し結果をとりだし d に格納する。
  2. 引数 x の中身と d の中身を連結したものを func2 に渡し、文字列を返す手続きを作成し、その手続きを結果とする。

となります。そして、最後 main

main :: IO ()               
main = do
    a <- getLine            -- 1.
    b <- func1 a            -- 2.
    c <- b                  -- 3.
    putStrLn c              -- 4.
  1. 手続き getLine の呼び出し結果をとりだし a に格納する
  2. func1 a の呼び出し結果(文字列を返す手続き)をとりだし b に格納する
  3. b の中身の手続きの呼び出し結果(文字列)を c に格納する
  4. c の中身を putStrLn に渡して印字する

これらの命令的手続きが、関数的に表現されるとどうなるでしょう。

do 構文を使わない関数スタイルの Haskell コードの読み方

do 構文を使わない関数スタイルのコードは以下のようになります。

まず、func2 は文字列(String)から実行時文字列[1]IO String)への関数です。

func2 x2 :: String -> IO String
func2 :: String -> IO String
func2 x2 = (++ x2) <$> getLine
--         ~~~~~~~ ~~~ ~~~~~~~
--            3     2     1
  1. 標準入力から読み込む実行時文字列(型 IO String
  2. 関数 String -> StringIO String -> IO String に変換してから、実行時文字列 IO String に適用する拡張適用演算子[2]
  3. x2 を後ろへ連結する関数(型 String -> String

次に func1 は文字列(String)から実行時·実行時文字列(IO (IO String))への関数です。

func1 :: String -> IO (IO String)
func1 x = func2 <$> ((x ++) <$> getLine)
--        ~~~~~ ~~~ ~~~~~~~~~~~~~~~~~~~~
--          3    2          1
  1. x に標準入力から読み込んだ文字列を連結した実行時文字列(IO String
  2. 関数 String -> IO StringIO String -> IO (IO String) に変換してから、実行時文字列 IO String に適用する拡張適用演算子[2:1]
  3. 文字列を func2 で実行文字列へ変換し、それを return で実行時·実行時文字列にする関数

最後に main :: IO () です。

main :: IO ()
main = (putStrLn =<<) =<< (func1 =<< getLine)
--     ~~~~~~~~~~~~~~ ~~~ ~~~~~~~~~~~~~~~~~~~
--            3        2          1
  1. 標準入力から読み込む実行時文字列に func1 を拡張適用して生成した実行時·実行時文字列(IO (IO String)
  2. 関数 IO String -> IO ()IO (IO String) -> IO () に変換してから、実行時·実行時文字列に適用する拡張適用演算子[3]
  3. 実行時文字列(IO String)の内容を標準出力に置くコマンド(実行時ユニット IO ())に変換する関数

do 構文を使っていませんが、それほど読むのに苦労はないように見えますね。もちろん、もともとが単純な逐次構造だからですが、do 構文を使わないからといって、必ずしも読み難くなるわけではないようです。

ポイントフリースタイルの Haskell コードの読み方

関数の定義のスタイルは2つに大別されます。

  1. 関数抽象による定義(関数適用の結果を定義)
    f x = 10 * x + 3
    
    fx に適用したものは、10 * x + 3 である」
    このスタイルをポイントフリースタイルに対して、ポイントワイズスタイルと呼びます。
  2. 関数合成による定義(関数を別の関数を組み合わせとして定義)
    f = (+ 3) . (10 *)
    
    f は、(+ 3) . (10 *) である」

ポイントワイズスタイルの定義は、機械的にポイントフリースタイルに書き直せます。

f x = 式 のようなポイントワイズスタイルの定義において、右辺の式を g $ h $ x という形にできれば、あとは、以下のような書き換えするだけです。

  f x = g $ h $ x
≡ { $ の定義 }
  f x = g (h x)
≡ { . の定義 }
  f x = (g . h) x
≡ { η 変換 }
  f = g . h

ポイントワイズスタイルの定義を読むときは、f = g . hf x = g $ h $ x のように引数を補って、右側から読むと意味を掴みやすいでしょう。

func2 のポイントフリースタイルの定義を読んでみましょう。

func2 :: String -> IO String
func2 = (=<< getLine) . flip (++)

flip は以下のように定義された Prelude 関数です。

flip :: (a -> b -> c) -> (b -> a -> c)
flip f x y = f y x

したがって、引数 x2 を補って以下のように読めます。

  (=<< getLine) $ flip (++) $ x2
= { $ を適用 } 
  (=<< getLine) $ (++ xs)
= { $ を適用 }
  (++ xs) =<< getLine

関数スタイルのところで読んだ形になりましたね。

つぎは、 func1 のポイントフリースタイルの定義を読んでみましょう。

func1 :: String -> IO (IO String)
func1 = (func2 <$>) . (<$> getLine) . (++)

引数 x を補って読みましょう

  (func2 <$>) $ (<$> getLine) $ (++) $ x
= { $ を適用 }
  (func2 <$>) $ (<$> getLine) $ (x ++)
= { $ を適用 }
  (func2 <$>) $ (x ++) <$> getLine
= { $ を適用 }
  func2 <$> ((x ++) <$> getLine)

こちらも関数スタイルのところで読んだ形になりましたね。

最後に main。これは関数ではありませんが、関数スタイルのところと少し違いがありますので、読んでおきましょう。

main :: IO ()
main = putStrLn =<< join (func1 =<< getLine)

ここでは、join を使って、実行時·実行時文字列 IO (IO String) を実行時文字列 IO String に変換しています。join は以下のように定義されています。

join :: Monad m => m (m a) -> m a
join x = id =<< x

これを展開すると以下のように読めます。

  putStrLn =<< join (func1 =<< getLine)
= { join を適用 }
  putStrLn =<< (id =<< (func1 =<< getLine))
= { f =<< (id =<< x) ≡ (f =<<) =<< x }
  (putStrLn =<<) =<< (func1 =<< getLine)

これで、関数スタイルのところで読んだ形になりましたね。

脚注
  1. IO のことを「実行時」ということにします。もちろん、一般的ではなく筆者独自の勝手造語です。 ↩︎

  2. <$> は実際は、多相演算子で、型シグネチャは、(<$>) :: Functor f => (a -> b) -> (f a -> f b) です。「拡張適用演算子」も一般的な用語ではなく筆者の造語(オレオレ用語)です。初出は以下です。 https://github.com/nobsun/hday2019/blob/master/doc/ftype.pdf ↩︎ ↩︎

  3. =<< は実際は、多相演算子で、型シグネチャは、(=<<) :: Monad m => (a -> m b) -> (m a -> m b) です。 ↩︎

Discussion