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: 2207, keywords: この記事は最終更新日が6年以上前のものです。

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行目の無名関数内で使用されています。 lnskey のどっちか一方しか使えないと、 コーディングが少々面倒くさくなるのですが、 実際どう面倒臭くなるかはご自身でお確かめください。 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]今回、脚注が少ないのはきっとクリスマスのスカラー電磁波の仕業です。
ノート   このエントリーをはてなブックマークに追加 
 

やり散らかし一覧

記事いろいろ