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: 2455, keywords: この記事は最終更新日が7年以上前のものです。
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 型を定義しました。しかし、 本連載では複数の値があるときの処理は割愛したいと思います。 ということで、一応これで完成ということで、 次回からは別のコマンドを扱いたいと思います。