これから何回か(未定、たぶん10回程度)にわたり、Haskellについて語ってみようと思う。一連の記事の題名を「つまずき1の記憶」としてみた。
初回である今回は、つまずきの歴史を振り返ってみて思いついたところを羅列してみる。今思えば恥ずかしく感じるようなことも、あえて書いてみた。つまらぬ勘違いなどで貴重な時間を浪費する人が一人でも減れば幸いである。
Haskellを学ぶ際、恐らく最も重要な事項は「型」ではないかと思う。実際、『実践本』には「「型」はすべての基本である」と書かれているし、『ゾウ本』の第2章の表題は「型を信じろ!」である。型の重要性はいくら強調してもしすぎということは無いと思う。
特に関数の型についてはたくさんコードを書いて慣れないといけないと思う。Haskellでは関数を関数に渡したり合成したりということをごく普通に行うので、関数適用や合成の結果がどんな型になるかで毎回悩むようだとコード書きが辛くなってしまうし、他人のコードも読めないであろう。
自分で思いつくままに関数を組み合わせてみて、適用あるいは合成の型がどんな風になるのかを考えてみると良いかもしれない。正解はGHCiさんにたずねると教えてくれる。やさしいのがすぐに分かるようになってきたら、次はたとえば、map map
や map (+)
のような、実際にはあまり使わないようなものの型を考えてみたりすると思考トレーニングには良いような気がする。
最初の頃は、数値リテラルにちょっと悩まされるかもしれない。GHCiさんに :t 1
として 1
の型をたずねてみると、答えはIntでもIntegerでもなく、FloatでもDoubleでもない。いや、IntでもIntegerでもあり、FloatでもDoubleでもある、と言ったほうが正しいか。このあたりはGHCiで色々おたずねしながら学び慣れていくのが良いと思う。
『実践本』のp.176に、再帰関数を書くことについての危険について触れられており、
とくに構造再帰を直接書くということは、時に必要となるものの、アセンブラを直接書くような低級な行為と認識されます。
と書かれている。とっても身に覚えがある(笑)。これは今なら理解できるが、最初の頃はそんな考えは全くなかった。map
と zip
くらいは深く考えずに使えたが、foldr
あたりになると頭が混乱し始めてきて、コンパイルエラーが2,3度続いたら「無理してfold
を使わんでもいいか…」と諦めて、セッセと再帰関数を書いていた。低級な行為を繰り返していたわけである。今は foldr
などは積極的に使うようにしている。たとえば、リストの要素に何か処理をして畳み込むようなものであれば、fold
系の関数が使えると予想できる。ならば、処理する関数の型を a -> b -> b
で作って foldr
に渡せばいいな、という具合に思考が進むのである。
Haskellのライブラリ群は膨大であるが、興味のありそうなものを時々眺めて見るのは良いことだと思う。たとえばリストを使って何かをしようとしているのならば Data.List を眺めてみるのである。自分で作ろうかと思っていた関数がすでにあるかもしれないし、うまく使えそうな関数があるかもしれない。fold
のような、「お決まりの処理」のような関数はたいていライブラリに用意されているので、そのあたりはライブラリ関数に任せ、自分はそれらに渡す関数に注力したほうが効率が良くなる。
これらの適当な訳語がないかと考えたのだが思いつかなかったので英語表記のまま書くことにする。
例えば、ある数を2乗する関数 sqr
を定義しようとした際、sqr x = x^2
とするのが point-wise、sqr = (^2)
とするのが point-free である。つまり point というのは、関数を適用する個々の値のことであり、point-wise は「値毎に」定義するという意味である3。ボクが知ってる範囲の手続き型言語はみな point-wise で関数を定義していると思う。一方、point-free の方は、関数自体と、関数の合成を使って定義する流儀である。つまり、sqr
に x
を与えると…とするのではなく、直接的に、「sqr
は2乗する関数」と言い切るのである。
どちらの流儀が好ましいか?という議論があるのかどうか知らないが、どちらの流儀にも慣れておくのが一番良いと思う。IFP本の最初の方(p.16)に、どちらを使うかはある程度好みの問題(partly a question of taste)と書かれている。Haskellをいじり始めた頃は手続き型言語の思考法にどっぷりと浸かっていたので point-wise のほうが分かりやすかったし、どうせ好みの問題ならば…と思って point-free を敬遠していたのだが、point-free と関わらずに済ませることは難しい。特に、他人が書いたコードには必ずpoint-freeの部分がある(あるいは大半がそうである)と思ったほうが良い。最初のうちは違和感があるかもしれないが、次第に慣れてきて、やがて違和感も消えるであろう。
なお、point-freeについては『ふつう本』のp.209から1節を割いて詳しく説明されているので、とても参考になると思う。
たとえば、Float値を使って2次元の座標を表す型 Point2D を次のように定義する:
data Point2D = Point2D Float Float
ここで Point2D
が等号の両側に現れているが、左辺のものは型構築子、右辺のものはデータ構築子である。別物なのだが同じ名前なので紛らわしい。別物だから当然、名前を変えても問題無い。慣れるとそうでもないのだが、Haskellを学び始めの頃は混乱した。Point2D
は簡単な例なので混乱するほどではないかもしれないが、型定義が複雑になると混乱しやすい。
ボクの場合、引数を持つ型定義でちょっとハマった。説明のためだけに、Foo
という簡単な型を定義する。
newtype Foo a = Foo (Int -> a)
等号の左のFoo
は型構築子、右のFoo
はデータ構築子、関数定義などのパターンマッチに現れて良いのはデータ構築子なのだが、同じ名前を使ってるので混同してしまって、Foo x
でパターンマッチさせて取り出したx
の型はa
だと思ったのである(正解はもちろん Int -> a
)。いま思えば「つまらない勘違い」に過ぎないのだが、勘違いしている間はそれなりに辛い時間を過ごすハメになった。こんな勘違いは、例えばデータ構築子の名前がF
であれば起きないのだから、最初のうちは型構築子とデータ構築子に同じ名前を使わない方が無難のように思う。また、他人が書いたコードを読む場合、もし頭が混乱している自覚があれば、どちらか一方を書き換えた方が良い。型宣言の所だけを書き換えるとコンパイルエラーとなるので、エラーがなくなるまで新しい名前に書き換えれば勘違いの無いコードとなるはずである。
Point2D
は以下のように定義することもできる:
data Point2D = P2 { getX :: Float, getY :: Float }
データ構築子の名前を変え、2つのFloatの要素に名前をつけてみた。「getX の型は?」と問われて即答できなければ勉強不足。正解は Float
ではない。getX :: Point2D -> Float
、すなわち2次元座標(x,y)からxを取り出す関数である。これは難しいことでもなんでもなくて、分かった「つもり」になって失敗した経験があるので書いてみたまでである。なおついでに書くと、P2
は型名ではなくデータ構築子なので、getX
の型が P2 -> Float
となることもない。
ところで、新しい型を定義する機能に、type
, newtype
, data
の3つがある。type
は単なる名前の付け替えだからすぐに意味は分かったのだが、残りの2つについてはちゃんと分かったという気分になるまでかなり時間がかかった。特に newtype
の意義が良く分からなくて、type
と data
があれば十分なんじゃないかなぁ…と思ってた時期もあった。この3者については『ゾウ本』のp.263「type vs. newtype vs. data」という節に詳しく説明されており、とても分かりやすい。
関数をpoint-freeのスタイルに書き換えたい場合など、$
を .
に置き換えたいことが良く生じる。例えば、以下の関数を考えてみる:
sqrtSumSqr xs = sqrt $ sum $ map (^2) xs
この式をpoint-freeの形にしたい場合、関数の合成 (sqrt . sum . map (^2))
を xs
に適用する形に右辺を変形してから xs
を両辺から取り除けば良い。
sqrtSumSqr = sqrt . sum . map (^2)
こんな感じでpoint-freeにできるのかぁ…と少し分かった気になった頃にハマった。
sqrSum x y = (x + y)^2
という単純な関数である。これをpoint-freeで定義しようと思った。まず書き換え。
sqrSum x y = (^2) $ (+) x y
となったので、両辺から x
, y
を省いて
sqrSum = (^2) . (+)
としたのである。これはコンパイルエラーとなる4。
関数の合成演算子 (.)
と、足し算 (+)
の型は
(.) :: (b -> c) -> (a -> b) -> a -> c
(+) :: Num a => a -> (a -> a) -- 強調のためカッコをつけた
であるから、(+)
の後で (^2)
と合成しようとすると、加算の結果を2乗するのではなく、(+)
に引数をひとつ与えたセクション(= 関数)を2乗しようとしたことになる。2乗できるのは Num
のインスタンスとなっている型のものでなければならないが、a -> a
はNum
型じゃないぞ!とコンパイラが怒ったのである。頭の中で、sqrSum x y
の右辺を((^2) . (+)) x y
と変形していたのがそもそもの間違いであった。
しかし、((^2) . (+ x)) y
と変形するのは正しいので、y
だけ省略して次のように書くのは正しい定義である。
sqrSum x = (^2) . (+ x)
もっとも、元の定義式におけるx
とy
の対称性を壊してまでこう書き換えるメリットがあるかどうかは疑問であるが…。
■■■
思いつくままに失敗したときのことを書き連ねてみた。他にも色々あったように思うが、何か思い出したら書き残しておこうと思う。
次回はメモ化という技法を学んだときのこと書いてみようと思う。Haskellを難しく感じさせる要因のひとつは、やはり関数を値として受け渡しすることにあると思っている。メモ化を実現する過程を通してHaskellの理解を深めて行きたいと思う。