2010年3月9日火曜日

復習 ふつうの Haskell 6.7 - 行番号を付けてファイルの内容を出力


 

「ふつうの Haskell」 6.7 実習: cat -n コマンド (p.149) には、「ファイルの内容に行番号を付けて出力する」方法が以下のように書かれている。

main = do cs <- getContents
          putStr $ numbering cs

numbering :: String -> String
numbering = unlines . map format . zipLineNumber . lines

…

(http://www.loveruby.net/ja/stdhaskell/samples/catn2.hs.html より、または catn2.hs)

4797336021はじめてこのコードを見たとき、どうも自分の中の書き方の感覚とズレていて理解しにくかった。当初、「関数型プログラミングとはこういう風に書くんだ」と自分に言い聞かせ、そのズレについては考えないようにした。しかし、時間が経つにつれその曖昧だったものが少しずつ自覚できるように。復習がてら、なぜ違和感を感じたのか考える。

 

手続的な思考の縛り  - 一気にドカンと考えられない

例えば、Ruby で printf のような 文字列をフォーマット 用のメソッドを使わずに書くなら、

# 数字 n を幅 width で右揃えの文字列にして返す
def numbering(n, width)
  sNum = n.to_s
  return " " * (width - sNum.size) + sNum + " "
end

row = 1
while line=gets
  puts numbering(row, 6) + line
  row += 1
end

全体の処理を見ると while ループでぐるぐると一行ずつ読み込み、出力するための文字列を整え、出力しつつ行番号に対応した変数を更新。

この書き方に対して Haskell の方は、概念的にはドカンと全て一気に読み込み、一気に行番号に対応させ、一気に整形して、一気に文字列に戻している。もちろん、遅延評価により実際の処理は一気にドカンではない。必要なときに必要な分だけ取ってきてくれる。プログラミングをする側にして見れば、個々のループ処理の中で「あれやってこれやって…」と考える必要がない分思考は一段解放されている。しかし、悲しいかなこの自由を享受できるほど自由人ではなく順次な手続きの奴隷となっているため、逆にどう考えればいいのかわかりずらかった。 (+_+) これが違和感の原因の一つ。

 

リストのレベルで考えることに慣れていない

Haskell の方の処理のメインとなる関数に注目すると、

numbering :: String -> String
numbering = unlines . map format . zipLineNumber . lines

おなじみ List operations Functions on strings である lines – unlines の組み合わせ。この関数でサンドイッチしているということは、処理を文字列のリストのレベルで考え進めていくということ。当り前のことだけれど、これが実に考えにくかった。

極端な話、仮に実装したいことを最初からスルスルと書けたとする。はじめに考えることは、

  1. 文字列を読み込んで、
  2. 何かわからないけれど処理をして、
  3. 最終的に文字列を返す。

よって、次のように書ける。

main = getContents >>= putStr. XXXXXX

処理の対象は中身が文字列のファイル。一行ごとに処理をしたいので行のリストに変換して、最終的にまた戻す。

main = getContents >>= putStr. unlines . XXXXXX . lines

問題は XXXXXX の中身。先ほどのように一気にドカンと処理することを意識するなら、lines の結果を受けて行番号と行のリストを対応づける処理をする。

main = getContents >>= putStr. unlines . XXXXXX . zip [1..] . lines

行ごとに処理をするには、map 関数を使えばいいので、

main = getContents >>= putStr. unlines . map XXXXXX . zip [1..] . lines

zip 関数の結果はタプルなので、map 関数の処理の対象をタプルとする。

main = getContents >>= putStr. unlines . map (\(n,l) -> XXXXXX) . zip [1..] . lines

行はそののまま。行番号の方は整形が必要なので、

main = getContents >>= putStr. unlines . map (\(n,l) –> XXXXXX ++ l) . zip [1..] . lines

上記 XXXXXX を format と名前を付けるなら、format 関数の実装は、

format n width = replicate (width - length num) ' ' ++ num ++ " " where num = show n

結局全体では、以下のようになる。

main = getContents >>= putStr. unlines . map (\(n,l) -> format n 6 ++ l) . zip [1..] . lines
format n width = replicate (width - length num) ' ' ++ num ++ " " where num = show n

しかし、これをスラスラと書けることもなかったし、こういう書き方が自分にとってわかりやすいかと問われたら、そうでもないので一旦この実装を忘れることに。

 

心理的にやさしいイメージで考える

自分はコンパクトで技巧的なコードは全く書くことができない。シンプルでわかりやすく書こうと努めるだけで精一杯。 (+_+) シンプルと言ってもコードが短いという意味ではなく、思考にとって負荷をかけないということ。だから、冗長でも自分にとって理解しやすいものならそちらを選ぶ。できることなら同じ答えを出せる計算を書いた場合、不恰好でも理解しやすいコードとコンパクトで綺麗なコードが、実行時に時間と空間の消費量が等価になるようなコンパイラがあるといいのだけれど。。

ところで、Haskell において文字のリストは `文字列’ と別名が付けられている。

type String = [Char]

こういう風に定義されているのは、文字のリストを `文字列’ と考えた方が理解しやすいため。最悪なければないでコンピュータが処理をするのに困ることはない。人が文字のリストを一塊の文字列として認識した方が心理的イメージとして把握しやすいので String が存在する。

では、先ほどの問題に戻り、「内容が文字列のファイル」は、心理的なイメージとしてどのように頭の中に立ち現われているだろうか?少なくとも自分は `文字列のリスト’ と言うよりは、ノート 1 ページのようなものを思い浮かべる。

img03-06-2010[2]

冗長だとしても、ページに対応した型を定義するなら、

data Page = Page [String]

文字列をページに変換する関数を以下のように定義しておく。

toPage :: String -> Page
toPage = Page . lines

 

Page 型を定義するメリット

このようにわざわざ Page 型を定義したのは、Page 型の値に適用する関数において、値をデータコンストクタで分解したときに束縛された変数が何であるかを理解しやすくするため。例えば、Page 型の値に適用する関数 f を想定したとき、

f (Page ls) = …

ls はページにおける行のリストであることを思い出しやすい。また、当然ながら 関数 f は Stirng のリストに適用する関数ではなく、Page 型に適用する関数であることが明確になる。この方が考えるとき脳みその負担が軽くなる気がする。

では、試しにこれを用いて、ここから二つの方向で実装してみることに。

 

ボトムアップに考える

一つ目は、ボトムアップに考えていく方法。最初に具体的にイメージできる小さな処理から作る。思い浮かんだところから実際に動かせるパーツを作り、後でそのパーツをくっつけるという戦略で。

まずは、個別の行の中の数字を右揃えにする処理にから。 「行番号である数字と、表示するときの幅を与えたら、右揃えで文字列にして返す」 関数を padding とするなら、

img03-06-2010[3]

padding n width = let num  = show n       
                      lNum = length num 
                  in replicate (width - lNum) ' ' ++ num

次に一行の出力。

numberingLine width (n, line) = padding n width ++ " " ++ line

先ほど定義した Page 型に対して、行番号をふる関数 numberingPage を定義する。

numberingPage width (Page ls) = unlines $ map (numberingLine width) numbering
    where
      numbering = (zip [1..] ls)

これで main 関数を次のように定義できる。

main = getContents >>= putStr . numberingPage 6 . toPage

全体のコードはこちら

 

トップダウンに考える

次は、トップダウンに考える方法。実際に動く関数の定義は後まわしにして、先におおよそのイメージと型の整合性について考える。

ざっと頭の中に思い浮かんだイメージは下図。内容が文字列であるファイルをページに変換し、それに print 関数を適用して型に応じた内容を出力。

img03-07-2010[1]

つまり、main 関数は、

main = getContents >>= print . toPage

オブジェクト指向的な視点で見るなら、読み込んだデータから Page クラスのオブジェクトを作成し、print メソッドを呼出したと言える。 Page 型を軸にして考えるのでわかりやすい。

次に、print 関数を適用するためには、Page 型を Show クラスのインスタンスにする。

instance Show Page where
    show (Page ls) = unlines $ map (padding 6) (zip [1..] ls)

-- 行 line に幅 width で行番号 n をふる
padding width (n,line) = spaces ++ num ++ "  " ++ line
    where
      spaces = replicate (width - length num) ' '
      num    = show n

全体のコードはこちら

 

トップダウンに考えるその2

上記のコードを書いたものの、どこか引っかかるところがある。 Page 型を Show クラスのインスタンスにしたのだけれど、ここで行番号を振って出力するのは不自然な気がする。 Page 型の出力は内容をそのまま出力し、行番号付きの出力は別の対応する型が出力した方が自然に思える。下図がそのイメージ。

img03-07-2010[2][6]

行番号つきのページに対応した型を以下のように定義。

data PageWithNumber = Pwn { numberWidth :: Int    -- 行番号の幅
                          , ls :: [(Int, String)] -- 行の内容 [(行番号, 内容)]
                          }

文字列 >>> Page >>> PageWithNumber

と変換した後出力する。

numbering 関数を Page 型から PageWithNumber 型へと変換する関数であるとすると、 main 関数は、

main = getContents >>= print . numbering . toPage

numbering 関数は、

numbering           :: Page -> PageWithNumber
numbering (Page ls) = Pwn 6 numbering'
    where
      numbering' = zip [1..] ls

次に PageWithNumber 型を Show クラスのインスタンスにする。詳細は後回しにして、はじめは必要と思われる関数の型のみを定義。

instance Show PageWithNumber where
    show (Pwn width ls) = unlines $ map lineToStr ls
        where
          lineToStr  :: (Int, String) -> String
          numToStr   :: Int -> String

上記 PageWithNumber 型における変数 ls は、型の定義を見れば [(Int, String)] たどすぐに確認できるので  lineToStr 関数に渡す型もすぐわかる。

先に数字を整形する。このとき整形するための幅は、PageWithNumber 型の値を分解した、データコンストラクタによるパターンマッチで束縛した変数を参照している。オブジェクト指向的に見るなら、プライベート変数を参照している感じ。

instance Show PageWithNumber where
    show (Pwn width ls) = unlines $ map lineToStr ls
        where
          lineToStr        :: (Int, String) -> String
          
          numToStr   :: Int -> String
          numToStr n = space ++ num
              where
                space = replicate (width - length num) ' '
                num   = show n

これで行を文字列に変換する関数を定義できる。

instance Show PageWithNumber where
    show (Pwn width ls) = unlines $ map lineToStr ls
        where
          lineToStr        :: (Int, String) -> String
          lineToStr (n, l) = numToStr n ++ " " ++ l           …

全体のコードはこちら

 

printf を使うと…

ちなみに、Ruby の場合、組み込み関数の printf を使うなら、

row = 1
while line=gets
  printf("%6d %s", row, line)
  row += 1
end

Haskell では、Text.Printfprintf を使い

import Text.Printf

main = getContents >>= mapM_ numbering . zip [1..] . lines
numbering (n,l) = printf "%6d %s\n" (n :: Int) l

うーん、しかしこの printf 関数って不思議だなぁ~。(@_@; どうやって実装されてるんだろう。。