USP Magazine 2015年3月号「Haskell版Open usp Tukubai完成させるぞ企画: Haskellでやってはいかんのか?」

Mon May 4 12:59:49 JST 2015 (modified: Sun Oct 1 10:50:27 JST 2017)
views: 3510, keywords: この記事は最終更新日が9年以上前のものです。

12. Haskell版Open usp Tukubai完成させるぞ企画: Haskellでやってはいかんのか?

産業技術大学院大学・USP研究所・USP友の会 上田隆一

Open usp TukubaiのHaskell版がまるでサグラダ・ファミリア 状態なので、連載しながら開発しようと思い立った上田は、 不幸にも黒塗りの高級車に追突してしまう。後輩をかばい すべての責任を負った上田に対し、車の主、暴力団員谷岡に 言い渡された示談の条件とは...。

12.1. はじめに

 みなさんこんにちは。これ書いているのが12/24深夜です。やさぐれています。 妻がいて、二人子供がいようがなんだろうが、 素直にクリスマスとかほざく気にはなりません。 突き詰めて考えると、非リア充かリア充かというのは 結局脳内麻薬が出ないか出るかだけですよ。境遇関係ありません。 ラリってる連中が面倒臭い。心底面倒臭い。 自分たちで楽しみ見つけやがれ。もっと申し訳なさそうに生きろ。

 みなさんこんにちは。富山の生んだブラックエンジェル (エンジェルなのに浄土真宗)上田です。 え?私なにか言いました?何も言ってませんよ。 さっさと本題行きましょう。本題。

12.2. 今月の作業

 現在、 map というコマンドを作成中です。 前回はリスト1のように、「キー、サブキー、値」 というレコードを持つデータを、キーを縦軸、 サブキーを横軸にした表に変換するコマンドです。

  • リスト1: mapの動作
1
   2
   3
   4
   5
   6
   7
   8
$ cat ~/data
   a あ 1
   b い 2
   b う 3
   $ cat ~/data | map num=1
   * あ い う
   a 1 0 0
   b 0 2 3
   

詳細な仕様は、リポジトリの MANUAL/map.txt 内にあります。

 前回は、リスト2のように、データを受け入れて リストにするところまで作っていました。 リスト2で示した map.5.hs は

にあります。 今回はこれをどんどん再集計に近づけます。

  • リスト2: map.5.hsをコンパイルして実行
1
   2
   3
   4
   5
   6
uedambp:COMMANDS.HS ueda$ cat ~/data
   a あ 1
   b い 2
   b う 3
   uedambp:COMMANDS.HS ueda$ cat ~/data | ./map.5 num=1
   [("a","\\227\\129\\130",["1"]),("b","\\227\\129\\132",["2"]),("b","\\227\\129\\134",["3"])]
   

12.3. 横軸を取り出す

 さて、最初にやるのはサブキーのリストを作るところです。 map が受け付けるデータはソート済みのデータですが、 ソート順が何か特別な順番になっているかもしれませんので、 順番を崩さないようにしないといけません。 縦軸は出てきた順に表示すればよいでしょうが、 横軸についてはちょっと頭を捻らないといけません。

 一つ例を出します。リスト3は、第2列目のサブキーがMon、Tue、Fri と並んでますが、これをその順番で出さなければなりません [1] 。

  • リスト3: サブキーの出力順の例
1
   2
   3
   4
   5
   6
   7
   8
   9
$ cat data2
   2013 Mon 1
   2013 Tue 1
   2014 Mon 2
   2014 Fri 3
   $ cat data2 | map num=1
   * Mon Tue Fri
   2013 1 1 0
   2014 2 0 3
   

サブキーを取り出してソートして重複を取り除けばできるのですが、 ややこしいことに、 map ではデータで出てきた 順番に表示しなければなりません。 ということで、リスト4のように map.6.hs を作りました。 変更したのはヘッダと main' 関数なので、 そこだけ示します。 Line 型については変更していませんが、 重要なので再掲します。

  • リスト4: map.6.hs(map.5.hsからの変更部分のみ)
 1
    2
    3
    4
    5
    6
    7
    8
    9
   10
   11
   12
###ヘッダの変更###
   import qualified Data.ByteString.Lazy.Char8 as BS
   ###Lineの型###
   type Line = (Key,SubKey,Values)
   ###main'の変更###
   main' :: Either String Int -> BS.ByteString -> IO ()
   main' (Left str) cs = die str
   main' (Right num) cs = print h_axis -- for debug
    where d = [ makeLine num ln | ln <- BS.lines cs ]
    h_axis = hAxis $ map ( \\(_,s,_) -> s ) d
    hAxis [] = []
    hAxis (e:es) = e : (hAxis $ filter ( /= e ) es )
   

8行目で print している h_axis がサブキーのリストです。 10行目で作っていますが、結構ややこしい書き方をしていますので、 丁寧に説明していきます。

 まず map に渡している無名関数 \\(_,s,_) -> s ですが、これは Line 型を構成している (Key,SubKey,Values) の組から SubKey だけ出力するという意味になります。 ですので、10行目の hAxis 関数にはサブキーのリスト が渡されます。このリストのキーは重複しているので、 hAxis 関数でそれを除去します。

  hAxis は11、12行目で定義されています。 where の中で定義されており、 main' の中だけで使えます。 この hAxis は、データの重複を除去しているのですが、理解できるでしょうか。 12行目での入力されたリストを先頭の e とそれ以外の es に分け、出力では e を残し、 es から e と同じ要素を 除去したものを再度 hAxis に渡し、その出力を e の後ろに : で連結して出力としています。 結果、10行目の h_axis は、 入力順を崩さずに重複が除去されたリストになります。 動かしたものをリスト5に示しておきます。

  • リスト5: map.6.hsをコンパイルして動作確認
1
   2
$ cat data2 | ./map.6 num=1
   ["Mon","Tue","Fri"]
   

 ところで、 map.6.hs のヘッダには、 map.5.hs にはなかった qualified という文言をつけました。 これは、 Data.ByteString.Lazy.Char8 モジュール の関数には必ず BS. をつけるようにしろという命令です。 前回までのように hiding でやっているときりがないので使いました。

12.4. ヘッダを出力

 次はヘッダを出力しましょう。 キーのフィールド数だけ * を埋めて出力します。 リスト6に map.7.hs を示します。 header という名前の関数を実装して main' で使っています。

  • リスト6: ヘッダを出力するmap.7.hs
1
   2
   3
   4
   5
   6
###main'関数(リスト4の8行目を次のように変更)###
   main' (Right num) cs = header num h_axis
   ###新たにheader関数を実装###
   header :: Int -> [SubKey] -> IO ()
   header num ss = BS.putStrLn $ BS.unwords (keyf ++ ss)
    where keyf = replicate num (BS.pack "*")
   

 6行目の keyf には縦軸のキーフィールド数( num ) だけ * のリストを作っています。 replicate 関数は、第1引数で指定した個数だけ 第2引数の要素を持つリストを出力します。 BS.pack は、String型の文字列を ByteString 型に変換する関数です。 5行目で keyf を横軸のキーにくっつけて BS.unwords で空白を挟んでリストを連結し、出力しています。

  • リスト7: map.7.hsをコンパイルして動作確認
1
   2
   3
   4
$ cat data2 | ./map.7 num=1
   * Mon Tue Fri
   $ cat data | ./map.7 num=1
   * あ い う
   

12.5. ボディーを出力

 いよいよ本体の出力です。 ただ、いきなり出力するのではなく、 キーごとにデータを分けることからやっていきましょう。 map.7.hs からリスト8のように map.8.hs を作りました。

  • リスト8: map.8.hs
1
   2
   3
   4
   5
   6
   7
   8
###main'のheader関数の後ろに追記###
   main' (Right num) cs = header num h_axis >> mapM_ print (splitByKey d)
   ###splitByKey関数を追加###
   splitByKey :: [Line] -> [[Line]]
   splitByKey [] = []
   splitByKey lns@((key,_,_):_) = a : splitByKey b
    where a = takeWhile (\\(k,_,_) -> key == k) lns
    b = dropWhile (\\(k,_,_) -> key == k) lns
   

 先に実行して結果を見ておきましょう。 リスト9のようにキーごとにレコードが まとまります。

  • リスト9: map.8.hsをコンパイルして実行
1
   2
   3
   4
$ cat ./data2 | ./map.8 num=1
   * Mon Tue Fri
   [("2013","Mon",["1"]),("2013","Tue",["1"])]
   [("2014","Mon",["2"]),("2014","Fri",["3"])]
   

 さて、リスト8は短いながらもたくさんのことをやっていますし、 新しいモノも登場しています。 まず、2行目の mapM_ というへんちくりんな名前の関数ですが、 これは print のようにモナドを出す関数をmapするものです。 リスト10のように、 mapM_ print でリストのものを一つずつ出力するという意味になります。

  • リスト10: mapM_ の型と mapM_ print の実行例
1
   2
   3
   4
   5
   6
   7
   8
   9
Prelude> :t mapM_
   mapM_ :: Monad m => (a -> m b) -> [a] -> m ()
   Prelude> :t mapM_ print
   mapM_ print :: Show a => [a] -> IO ()
   Prelude> mapM_ print ["This","is","a","pen"]
   "This"
   "is"
   "a"
   "pen"
   

 さて次に、リスト8の splitByKey 関数の説明を。 これは [List] 型のデータ、 つまり各レコードのリストを入力されると、 キーが同じデータごとにぶった切られて リストのリストを出します。 6行目の a がキーが同じレコードの塊で、 b が残りのレコードです。 b は再度 splitByKey にぶち込まれてぶった切られます。

 まず目新しいのは6行目の引数にある @ だと思います。これは「アズパターン(as-pattern)」 というもので、要は lns@((key,_,_):_) と左辺にあれば、右辺では引数を lns として使っても ((key,s,v):_) として使ってもいいよということになります。 lns は7, 8行目の takeWhile, dropWhile の第二引数として、 key は7, 8行目の無名関数内で使用されています。 lns と key のどっちか一方しか使えないと、 コーディングが少々面倒くさくなるのですが、 実際どう面倒臭くなるかはご自身でお確かめください。 takeWhile, dropWhile は以前も出てきました。 第一引数の式を第二引数のリストの先頭の要素から適用し、 True を返してくる限り取得した/捨てたリストを返す関数です。

 さて、仕上げにかかりましょう。 リスト11に完成した map.hs の一部を示します。 map.8.hs からの変更部分だけ示しています。 2行目の print 関数が body h_axis に置き換わり、 body 関数、 body' 関数が新たに実装されています。

  • リスト11: 完成した map.hs
 1
    2
    3
    4
    5
    6
    7
    8
    9
   10
   11
   12
###main'関数を変更(printをbody h_axisに置き換え)###
   main' (Right num) cs = header num h_axis >> mapM_ (body h_axis) (splitByKey d)
   ###body、body'関数###
   body :: [SubKey] -> [Line] -> IO ()
   body ss lns@((k,_,_):_) = BS.putStrLn $ BS.unwords (k:(body' ss lns))

   body' :: [SubKey] -> [Line] -> [Word]
   body' [] _ = []
   body' subs [] = replicate (length subs) (BS.pack "0")
   body' (sub:subs) alns@((_,s,(v:_)):lns)
    | sub == s = v : body' subs lns
    | otherwise = BS.pack "0" : body' subs alns
   

  body 関数のやっていることは、 出力の各行の先頭にキーをつけることだけです。 キー以降のデータは body' で作っています。 body' がかなりややこしくなっていますが、 サブキーに対応する数字を一つずつリスト化しているだけです。 まず8行目が、サブキーがなくなって body' の再帰を終える処理、 9行目が、サブキーは残っているけど Line 型のレコードがなくなったときの処理で、 ゼロを残ったサブキーの個数分だけリストにして返しています。 11行目は引数の先頭のサブキーと先頭のレコードのサブキーが一致したときに、 レコードの値をリストに追加する処理、 12行目はサブキーが一致しないときにゼロをリストに追加する処理です。 とにかくサブキー1個につき1個データをリストに加えていっているというのを 確かめながら読んでいくと、なんとか理解できるかと思います。

あと、 Line 型 (Key,SubKey,Values) 型では Values のところが一つの値でなくリストになっており、これもややこしい原因になっています。 実は map コマンドはキー、サブキーに対して値を複数持たせることが できるのでこのように List 型を定義しました。しかし、 本連載では複数の値があるときの処理は割愛したいと思います。 ということで、一応これで完成ということで、 次回からは別のコマンドを扱いたいと思います。

12.6. おわりに

 今回は愚痴から始まり、 map (の最低限の機能)を 作るところまでを扱いました。 次回は上記のように別のコマンドに話題が移ります。 さてどのコマンドをやりましょうか・・・。 とりあえずもう眠いので寝てから考えることにします [2] 。

脚注

[1]実はPython版のmapはMon、Fri、Tueの順番で出力してしまいます。 ちょっと手がまわらないのでどなたか助けて・・・。
[2]今回、脚注が少ないのはきっとクリスマスのスカラー電磁波の仕業です。
ノート   このエントリーをはてなブックマークに追加 
 

やり散らかし一覧

記事いろいろ