2009年2月2日月曜日

Haskell で簡単なテキスト処理 (3) – break 関数を使って再帰処理

Haskell で簡単なテキスト処理 (2) の続き。まいど、習うより慣れろということで。

 

例1.

以下の各行から漢数字と空白を取り除きたい。

一 保健、医療又は福祉の増進を図る活動
二 社会教育の推進を図る活動
三 まちづくりの推進を図る活動
四 学術、文化、芸術又はスポーツの振興を図る活動
五 環境の保全を図る活動
六 災害救援活動
七 地域安全活動

( 特定非営利活動促進法 の「別表」より, NPO の活動を分類したもの )

 

日本語を扱うために

まずは、対象とソースを UTF-8 で保存。

ソースの先頭には、(cf. Haskell で日本語表示)

import qualified System.IO.UTF8 as U

 

>>= (bind) と関数合成

前回と同様、do 式を書くと変数を束縛するだけ面倒なので >>= を用いる。

main = U.getContents >>= U.putStr . unlines . map f . lines

unlines --- lines で挟むのは、おなじみテキストファイルを行ごとに処理するパターン。map を間に入れると、その引数となる関数は行ごとの処理となる。

(>>=) の右側は、関数を取るので関数合成を利用して定義。最初読みにくくてしょうがなかった関数の合成にも徐々に目が慣れてきた。「なぜ関数プログラミングは重要か」に目を通したおかげかも。(cf. Haskell で関数の抽象化)

 

break で文字列の分割

map 関数の引数を f としたのは、いちいち関数名を考えるのが面倒だったので、というか思い浮かばなかったため。^^;

さて、関数 f の定義をどうしよう。単純に全角の空白で文字列を分割すればいいので、この前見た break 関数を使うことに。 break 関数の使い方は、例えば、コンマ区切りの文字列があったら、

*Main> break (== ',') "hoge,piyo,fuga"
("hoge",",piyo,fuga")

というように、述語で指定した基準で分割し、基準となる文字列はタプルの第2要素の先頭に納まる。

090201-001

これを用いて、

f = tail . snd . break (== ' ')

上記の例の場合だと、例えば 1 行目に対しては、

(“一", ” 保健、医療又は福祉の増進を図る活動”)

というようになる。先頭要素は必要ないので tail 関数を続けて適用。つまり、やってることは、

ぶった切って (break)、 2 番目とって (snd) 、しっぽとる (tail) 。

ソースを text.hs 、上記のテキストを target.txt として保存し実行。

runghc text.hs < target.txt > result.txt

まとめると、

import qualified System.IO.UTF8 as U
main = U.getContents >>= U.putStrLn . unlines . map f . lines
f = tail . snd . break (== ' ')

こうやって見ると、最初の import を書かなくて済むなら 2 行だけなので結構シンプル。

 

例2.

以下のように、表計算に入力する項目が複数あるとする。

090130-005

 

これをコピーしてテキストエディタにペーストすると、項目がタブ区切りの文字列が得られる。

名前 年齢 性別 身長 体重

上記を以下のように変換したい。

名前:
年齢:
性別:
身長:
体重:

 

再帰的に break

タブ文字で分割して、それぞれの行にコロンを付ければいいので、全体としては次のように想定。

main = U.getContents >>= U.putStr . unlines . map addColon . split

次に、具体的に split 関数と addColon 関数を考える。 addColon の方が単純なので、先に定義。

addColon = (++ ":")

 

split 関数はのイメージは、項目を順次スパッと切っていくというもの。普通なら For ループだけれど、Haskell なので再帰。

090131-006

break 関数を再帰的に適用する方法は、先頭要素をスパッと切ったら、残りも同じようにスパッとやるということ。

090131-009

切った要素は、最後にリストとして返したいので (:) コンスでつなげる。

 

最初、与えられた文字列を break で分割して、その切り取った部分を結果のリストの先頭に置きたいので、

split xs = (fst $ break (== '\t') xs) :

break で分割した後、残りの要素のリストを同様の手順で繰り返し適用したいから、これを再帰的に定義。コードが重複した部分は where 節に括りだす。

split xs = (fst $ split' xs) : (split $ tail $ snd $ split' xs)
    where 
      split' = break (== '\t')

 

さて、これで実行したら結果は、

Prelude.tail: empty list

あら… (@_@;)

空文字に tail 関数を適用するとダメかな?つまり、対象の文字列の最後に split 関数を適用した後のこと。例えば、

*Main> break (== '\t') "hoge"
("hoge","")
*Main> tail ""
"*** Exception: Prelude.tail: empty list

ということは、tail 関数の適用を場合分けし、

split xs = (fst $ split' xs) : (split $ tail' $ snd $ split' xs)
    where 
      split' = break (== '\t')
      tail' [] = []
      tail' xs = tail xs

これで実行… と思ったら固まった。 (@_@;)

あ~、そうか。 split [] となったときに延々と繰り返されてしまうか。

split [] = []
split xs = (fst $ split' xs) : (split $ tail' $ snd $ split' xs)
    where 
      split' = break (== '\t')
      tail' [] = []
      tail' xs = tail xs

 

結局全体では、

import qualified System.IO.UTF8 as U

main = U.getContents >>= U.putStr . unlines . map addColon . split

split [] = []
split xs = (fst $ split' xs) : (split $ tail' $ snd $ split' xs)
    where 
      split' = break (== '\t')
      tail' [] = []
      tail' xs = tail xs
addColon = (++ ":")

しかし、何か不恰好だなぁ… ^^;

 

case 式とデータコンストラクタによるパターンマッチ

何か違う実装方法がないか検索したら、Haskell プログラミング ~ 純粋関数型言語への誘い~ に、

split c cs = case break (==c) cs of
               (w,"") -> [w]
               (w,_:cs') -> w:split c cs'

あ~、case 式使ってリストのデータコンストラクタによるパターンマッチで tail 関数を使わなければよかったのかぁ。 (+_+) (cf. 関数の抽象化リストのデータコンストラクタによるパターンマッチ)

 

一文字ごとに考える

The Julipedia: A split function in Haskell では、break 関数を使わない方法で書かれていた。

split :: String -> Char -> [String]
split [] delim = [""]
split (c:cs) delim
| c == delim = "" : rest
| otherwise = (c : head rest) : tail rest
where
rest = split cs delim

これを見ると、対象の文字列を一文字ごとに分割してリスト処理をしている。パッと見てよくわからなかったので絵を描いて考える。^^;

最初は例のごとく、文字列を先頭とそれ以降の要素に、データコンストラクタによるパターンマッチで分割。そうしたら、残りの要素に再帰的に split 関数を適用。その結果の先頭要素を、最初に分割した先頭の一文字とくっつける。そして、またその結果を残りの要素にもくっつけてリストにする。ただし、split する対象の文字列の先頭が区切り文字なら、それを削除してリストにするということか。なるほど。

090201-002

 

上記記事のコメント欄には、他に unfoldr を使う方法が書かれていた。

import Control.Arrow
split delim = takeWhile (not . null) . unfoldr (Just . (second $ drop 1) . break (==delim))

しかし、second 関数が Arrow クラスのメソッドのようで、Monad すら理解してないのに Arrow と言われても理解不能。。。 (+_+)

 

drop 関数を使って

上記コメントを読んでいたらもう一つ方法が書かれており、そこでは drop 関数が使われていた。えーと、 drop 関数って何だっけ?と思いつつ確認。

Prelude> drop 1 "hoge"
"oge"
Prelude> drop 1 "h"
""
Prelude> drop 1 ""
""

おなじみ take 関数の反対で、第1引数が 1 のとき tail 関数と似た動作をする。しかし、tail と違い、上記のように空文字のときにエラーとならない。ということは tail を使うより drop を使った方がよかったみたい。先ほど定義した関数を少し書き直し、

split _ [] = []
split delim xs = (fst $ split' xs) : (split delim $ drop 1 $ snd $ split' xs)
    where 
      split' = break (== delim)

ちょとすっきりしたかな。

 

Text.Regex の splitRegex を使うなら

splitRegex なんてすっかり忘れていた。 (cf. 文字列の分割と結合)

import qualified System.IO.UTF8 as U
import Data.List
import Text.Regex
main = U.getContents >>= U.putStr . unlines . map (++ ":") . splitRegex (mkRegex "\t")

 

例3.

例2 で作成したものに対して、データが以下のように入力されたとする。

名前:太郎
年齢:20
性別:男
身長:170
体重:65

これよりデータの内容だけ取り出して、タブ区切りの文字列にして表計算に貼り付けたい。

 

import qualified System.IO.UTF8 as U
main = U.getContents >>= U.putStrLn . foldr (++) [] . map f. lines
    where f = (++ "\t") . drop 1 . snd . break (== ':')

 

そういえば、リストを特定の区切り文字で連結する関数 intercalate があった。(cf. 文字列の分割と結合)

main = U.getContents >>= U.putStrLn . intercalate "\t" . map f . lines
    where f = drop 1 . snd . break (== ':')

 

普段から使わないとスッと出てこないなぁ~ (@_@;)