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

Mon May 4 12:58:01 JST 2015 (modified: Fri Sep 22 23:27:45 JST 2017)
views: 2498, keywords: この記事は最終更新日が6年以上前のものです。

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

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

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

11.1. はじめに

  [1] 。富山の徒花 [2] 、 ブラックエンジェル上田です。冬ですね。 皆様は冬の楽しみと言えば何でしょうか? 私はありません。全くありません。

11.2. 本日やること

 と、あっという間に冒頭の挨拶が終わりましたので、 今月はたっぷりHaskellを書くことにしましょう。 前回からやっている通り、今回もHaskell版のOpen usp Tukubai の開発を続けることとします。

 サンプルコードを含んだブランチは、

にありますので,参考にしながらHaskellを勉強してみてください.

 前回はmapというコマンドを作っており、 標準入出力からの字の出し入れ、 終了ステータスの適切な出力を実装しました。 今回はいよいよmapで行う処理の中身に入っていきます。

 今回実装する機能を見ましょう。 図1のようなデータがあるとします。 区切り文字はすべて半角スペースです。

  • 図1: 入力データ
1
   2
   3
   4
$ cat data
   a あ 1
   b い 2
   b う 3
   

このデータを、図2のように「第1フィールドを縦軸」、 「第2フィールドを横軸」にして表にするのがmapです。

  • 図2: 出力データ
1
   2
   3
   4
   5
   6
   7
   8
   9
$ map num=1 data
   * あ い う
   a 1 0 0
   b 0 2 3
   ###出力を揃えるときにはketaを使う###
   $ map num=1 data | keta
   * あ い う
   a 1 0 0
   b 0 2 3
   

オプションで指定している num=1 は、左から1列目までを縦軸扱いするということです。

11.3. オプションの処理

 んで、最初に実装するのは num=<数字> の数字の部分を読み込む処理です。 ちょっと長いですが、この部分を実装した map.4.hs を全部載っけておきます。

  • 図3: numオプションから数字を読み込む処理を入れたmap.4.hs
 1
    2
    3
    4
    5
    6
    7
    8
    9
   10
   11
   12
   13
   14
   15
   16
   17
   18
   19
   20
   21
   22
   23
   24
   25
   26
   27
   28
   29
   30
   31
   32
   33
   34
   35
   36
   37
   38
   39
   40
   41
   42
import System.Environment
   import Data.ByteString.Lazy.Char8 as BS
   import System.IO
   import System.Exit
   import Text.Read

   showUsage :: IO ()
   showUsage = do System.IO.hPutStr stderr (
    "Usage : map <num=<n>> <file> \\n" ++
    "Thu Oct 23 08:52:44 JST 2014\\n" ++
    "Open usp Tukubai (LINUX+FREEBSD+Mac), Haskell ver.\\n")
    exitWith (ExitFailure 1)

   die str = System.IO.hPutStr stderr (
    "Error[map] : " ++ str ++ "\\n") >> exitWith (ExitFailure 1)

   main :: IO ()
   main = do args <- getArgs
    case args of
    ["-h"] -> showUsage
    ["--help"] -> showUsage
    [num] -> readF "-" >>= main' (getNum num)
    [num,file] -> readF file >>= main' (getNum num)
    _ -> showUsage

   readF :: String -> IO BS.ByteString
   readF "-" = BS.getContents
   readF f = BS.readFile f

   main' :: Either String Int -> BS.ByteString -> IO ()
   main' (Left str) cs = die str
   main' (Right num) cs = print num -- for debug

   getNum :: String -> Either String Int
   getNum ('n':'u':'m':'=':str) = getNum' (readMaybe str)
   getNum _ = Left "no num option"

   getNum' :: Maybe Int -> Either String Int
   getNum' Nothing = Left "invalid number for num option"
   getNum' (Just n)
    | n > 0 = Right n
    | otherwise = Left "invalid number for num option"
   

 ・・・大変です。普通の言語なら関数を 一個作ってちょろっと試せばよいのですが、 Haskellの場合は型と関数の接続先を 考えてからでないと関数を書けないので大変です。 ただ、この方がトップダウンな思考は身につくと思います。

 さて、このコードで重要なのは何と言っても getNum 関数です。この関数は、オプションを読み込んで、 num= の後ろの数字をInt型で返すのが第一の目的です。 ただ、話はそんなに簡単ではありません。 変な数字が入っていたらそれを通知しなければなりません。 このとき、例えば数字でなくて num=aaa とか書いてあったら -1を返すなど、Int型で済ませる方法もありますが、 ここではもう少しこだわって EitherMaybe というものを使っています。

 まず、35〜42行目に出てくる LeftRight から、 Either を理解してみましょう。 図4を見ながら説明すると分かりやすいと思いますが、 Left は引数にとったものを Either a b の左側の a に、 Right は右側の b に包んで返します。

  • 図4: LeftとRightの型
1
   2
   3
   4
Prelude> :t Left
   Left :: a -> Either a b
   Prelude> :t Right
   Right :: b -> Either a b
   

 この包みは、図3の30〜32行目の main' で荷ほどきされています。 main' ではパターンマッチが行われており、 getNumLeft で値を返してきたか Right で返してきたかで挙動を変えています。

 ここまで来て核心を言うと、 Either は、この例のように 異なる型を返したいときに使われます。 main では、 Left で文字列 (エラーメッセージ)が返ってきたら die 関数を呼び、 Right で数字が返ってきたら デバッグ用にその数字を表示しています。 die 関数は、14, 15行目で定義されており、 エラーメッセージを標準エラー出力に出して終了ステータス1 でこのプログラムを終わらせる関数です。 22, 23行目は main'getNum を呼び出すために 前回の map.3.hs から 書き換えられているので、これもチェックしておいてください。

 次に Maybe です。35行目の readMaybe は、これまで何回か使ってきた read 関数の変種です。 read は文字列が数字に変換できないとその場でプログラム 共々自爆するという悪い癖があるのですが、 readMaybe は、次のような型を返すことでそれを回避します。

1
   2
Prelude> :t Text.Read.readMaybe
   Text.Read.readMaybe :: Read a => String -> Maybe a
   

map.4.hs の使い方だと、 a には Int が入ります。つまり readMaybe は、 Maybe Int (Intかもね〜〜〜。そうじゃないかもね〜〜) というふざけた型のものを出力します。

 んで、 Maybe Int の入力をさばいているのが getNum' 関数です。パターンを見ると、 Just nNothing というのがあります。このうち、 Just n が、「数字が読めてその数字はnだ」という意味で、 Nothing が、「読み取りできなかった」という意味です。 これに合わせて、 getNum' はRightかLeftを返しています。 また、nが0以下の場合にもLeftを返すためにガードも使っています。

 実は EitherMaybe の「モナド」です。 「Haskell=モナド」、「モナドを制す者はHaskellを制す」 というような風潮もないことはないのですが、 あまり最初は考えずに使い方から入っていけばよいと思います。 私もよく分かってません。 何をもって分かったというのかも分かりませんので、 とりあえずは使えるようにしましょう [4]

 ところで、 getNum では Left にエラーメッセージ、 Right に数字を指定しています。 英語では「Right=正しい」ですので、 逆にするといけないことになっているようです [5] 。 また、 getNum のパターンマッチが随分無理やりですが、 'n':'u':'m':'=' で文字列の頭が num= のときのパターンを作っています。

  map.4.hs の動作確認の様子を図5に示しておきます。

  • 図5: map.4.hsの動作確認
1
   2
   3
   4
   5
   6
   7
   8
   9
uedambp:COMMANDS.HS ueda$ ghc map.4.hs
   uedambp:COMMANDS.HS ueda$ ./map.4 num=100
   100
   uedambp:COMMANDS.HS ueda$ ./map.4 num=-1
   Error[map] : invalid number for num option
   uedambp:COMMANDS.HS ueda$ ./map.4 num=
   Error[map] : invalid number for num option
   uedambp:COMMANDS.HS ueda$ ./map.4 aaa
   Error[map] : no num option
   

11.4. データを型にはめる

 やっと本題中の本題です。 main' 関数内に標準入力の処理を書いていきましょう。 図6は main' 関数と、その前後に付け足したコード、 そしてヘッダ部分の変更を示します。

  • 図6: map.5.hsの一部
 1
    2
    3
    4
    5
    6
    7
    8
    9
   10
   11
   12
   13
   14
   15
   16
   17
   18
   19
   20
   21
   22
import Data.ByteString.Lazy.Char8 as BS hiding (take,drop,head)
   (中略)

   type Word = BS.ByteString
   type Key = BS.ByteString
   type SubKey = BS.ByteString
   type Values = [Word]
   type Line = (Key,SubKey,Values)
   type Data = [Line]

   main' :: Either String Int -> BS.ByteString -> IO ()
   main' (Left str) cs = die str
   main' (Right num) cs = print d -- for debug
    where d = [ makeLine num ln | ln <- BS.lines cs ]

   makeLine :: Int -> BS.ByteString -> Line
   makeLine num ln = (k,s,v)
    where k = BS.unwords $ take num $ BS.words ln
    s = head $ drop num $ BS.words ln
    v = drop (num + 1) $ BS.words ln

   (略)
   

まず1行目の、 hiding ... の説明から。 これは Data.ByteString.Lazy.Char8 モジュールが take, drop, head という関数を持っており、 これがデフォルトの take, drop, head と名前が衝突してしまうので、 隠しておしまいなさい [7] という意味です。 ちなみに「デフォルト」で定義されている関数というのは、 Prelude というモジュールにあります。 Prelude モジュールは、明示的に書かなくてもインポートされているので、 我々は maphead 等を使えることができるという 仕掛けになっています。

 次、4〜9行目ですが、 ここでは type という呪文(type宣言)を使い、 BS.ByteString 型に Word やら Key やら自分で勝手に別名をつけ、 それらを組み合わせて型を作っています。 このように型を作っていく作業は、 Haskellというかプログラムでは重要な作業です。 例えば8行目では、 Line (一行) が key (キー、縦軸)、 SubKey (サブキー、横軸)、 Values (複数の値)であると定義しています。 プログラムは、この型に合わせて各行を解析していくわけですが、 このように型を先にはっきりさせておくと、 入出力が何であるかを意識してプログラミングすることになります。

  main' 関数は、13, 14行目が修正されています。 13行目にはデバッグ用の print が書いてあります。 14行目は読み込んだデータを行ごとに分解し、 8行目で宣言した Line 型に各行を加工しています。 ここで使っている makeLine は16〜20行目で定義されています。

 話を makeLine 関数に移しましょう。 この関数は、キーのフィールド数と一行を受け取り、 Line 型に行を加工して返します。 k,s,v がそれぞれ縦軸にするキー、 横軸にするキー、値です。 では、 map.5.hs を動かしてみましょう。 図7のようになりました。

  • 図7: map.5.hsの動作確認
1
   2
   3
$ ghc map.5.hs
   $ cat ~/data | ./map.5 num=1
   [("a","\\227\\129\\130",["1"]),("b","\\227\\129\\132",["2"]),("b","\\227\\129\\134",["3"])]
   

11.5. おわりに

 今回はmapコマンドの作成の途中までを行いました。 エラー処理、オプションの処理、 データを型に合わせて加工するコードを扱いました。 エラー処理ではモナドである EitherMaybe が出てきました。 これらが使えると、「返す値の型は一つだけ」 というHaskellの厳しさが緩和されます。 また type で既存の型から型を定義することで、 行をどのように解釈するかを決めました。 というように今回は型絡みの話が多かったのですが、 結局のところ、「型を制すものはHaskellを制す」 ということなんでしょう。よくわからんけど。

 次回は、最低限の map の機能は実装したいなと考えております。 んでは。

11.6. コラム: コマンドとHaskellの関数

 え?また余白ですか。すんません。 では、フリーダムコラムを書かせていただきます。

 シェル芸は関数型というのは、全国1億2千万人のシェル芸人には周知の事実です。 ただ、シェル芸人兼Haskellerという人になると桁が7個くらい落ちるので、 根拠資料を出させていただきます。 コマンドとHaskellの関数の対応表です。表1に示します。

 この表では、ファイルの1行をHaskellのリストの一要素、 ファイルでスペース区切りの2次元テーブルとして作ったデータを Haskellの二次元リスト(リストのリスト)とみなしています。

  • 表1: Haskellの関数とコマンドの対応表

(201502.table.htmlをここに挿入。)

 どうです?一緒でしょ?え?一緒じゃない? 無理やり一緒だと思えば Haskellが書けるようになるかもしれません。

脚注

[1][3] を参照のこと。
[2]咲いても実を結ばずに散る花。転じて、実(じつ)を伴わない物事。 (「デジタル大辞泉」より)
[3][6] を参照のこと。
[4]要はHaskell好きがこういう議論を好むだけの話なので、 使うだけなら巻き込まれない方が利口です。
[5]なんかしゃらくさい。
[6]こんにちわわ
[7]水戸黄門風に読んでください。
ノート   このエントリーをはてなブックマークに追加 
 

やり散らかし一覧

記事いろいろ