【Haskell】リストをある幅で分割する、そのためにどれほどの夜を重ねただろう

2023-06-04

例えば2次元配列を扱う際に計算中は1次元に展開して持っておいて、出力時に戻そうとしたときに、リストを固定の長さで分割して二重(2次元)リストにして返す、ということがしたかった。

実装手順

ライブラリ関数でできあいのものがあるかわからなかったので、自作する。

自分で再帰する

まずはナイーブに、自分で再帰させて実装する:

splitByWidth1 :: Int -> [a] -> [[a]]
splitByWidth1 w ss = let (sh, st) = splitAt w ss
in sh: if null st then [] else splitByWidth1 w st

splitAtで分割し、残りがあれば再帰させてリストを作成する。

テスト:

main :: IO ()
main = do
let ss = splitByWidth1 7 ['a'..'z']
print ss -- ["abcdefg","hijklmn","opqrstu","vwxyz"]

unfoldrを使う

自分で再帰するのは明らかに面倒なので、unfoldrを使ってみる。 unfoldrMaybeを返す関数を渡して、Nothingが返るまでのJustの値をリストに展開してくれる:

import Data.List (unfoldr)

splitByWidth2 w = unfoldr (\ss -> if null ss then Nothing else Just (splitAt w ss))
  • Justにはその段の値と、続けるための情報というペアを返す
    • splitAt の結果がうまくそのまま使える形になっていた

if-elseをやめる

自分でif判定するのはまどろっこしい。 Bool値が真のときにはJust aを、偽のときにはNothingを返すwhenMaybeというユーティリティ関数を作って:

import Data.Bool (bool)

whenMaybe :: Bool -> a -> Maybe a
-- whenMaybe True a = Just a
-- whenMaybe False _ = Nothing
whenMaybe = bool (const Nothing) Just

Justの場合は中身に関数を適用する、という具合にしたい。 <$> でまさにそういうことができる:

splitByWidth3 w = unfoldr (\ss -> splitAt w <$> whenMaybe (not $ null ss) ss)
  • 動作の説明:
    • ssnot $ null なら Just ss となり、関数も適用されて Just (splitAt w ss) となる
    • ssnot $ null じゃなければ、Nothingunfoldr終了となる
  • <$>fmapの中置記法シノニム」とのことで、型シグネチャは (<$>) :: Functor f => (a -> b) -> f a -> f b
  • fmapFunctor 型クラスのメソッドで、Functorのインスタンスに関数を適用させられる
    • FunctorであるMaybeは、Justの中身に関数(splitAt w)が適用される(Nothingはそのまま)

引数を2回適用してるのを回避したい

ラムダ式の引数 ss を2ヶ所(not $ nullwhenMaybe)に渡す必要があるのをなんかうまいことやりたい。 not . nullfwhenMaybeの引数の順序を入れ替えたflip whenMaybegとすると、g x (f x)という形になっている。 こういう時は、<*> を使って(g <*> f) xと書けるらしい:

splitByWidth4 w = unfoldr (\ss -> splitAt w <$> (flip whenMaybe <*> not . null) ss)

関数合成する

ラムダ式の引数が最後に渡すだけになったので、関数合成してポイントフリースタイルにしてやる:

splitByWidth5 w = unfoldr $ (splitAt w <$>) . (flip whenMaybe <*> not . null)

flipをなくす

VSCodeで修正のサジェストされて、flip <*>=<< に置き換える:

splitByWidth6 w = unfoldr $ (splitAt w <$>) . (whenMaybe =<< not . null)
  • (=<<) :: Monad m => (a -> m b) -> m a -> m b
  • バインド >>= の左右逆とのこと

ひとまず完。

わかりやすい? わかりにくい?

最後の splitByWidth6 を見るとパッと見、簡潔ですっきりしてるので、わかりやすい気もする。 しかし <$> などがどうやって動くのかを知っている必要がある。 するとファンクターがどうとかアプリカティブがどうとかいう話を掘っていく必要が出てくる。

splitByWidth6 を見て動作を理解できるか? わかってる人が見たら簡潔で理解しやすいのかもしれないが、自分には無理だね…。

プログラミングするのにどれだけのことが一般的に理解できるものとしていいものなんだろうか? 自分からすると圏論はわからんしHaskellでどう活きてるのかも理解してない。 そこが普通理解できるものとして要求されるとなると厳しい。 ただベタに書くんだったら別にHaskellを使う必要もなくなってしまう。

圏論を理解したいと思って本を読んだりするんだけど、ムズいスね。

リンク

動画

後日談:もっと簡単な方法