Perl使いがHaskellに入門してみて思ったこと

ここ数ヶ月ばかり、ちょこちょことHaskellをいじってみた上での感想。 今までやったこととしては、

以下、思ったこと。

データ型の表現力がスゴい

今のところ一番スゴいと思ったのがこれ。

Haskellの型システムとその表記法は表現力がとんでもなくて、データ構造とアルゴリズムを極限まで簡潔な形式で書き下せるあたりがスゴい。

mapM :: Monad m => (a -> m b) -> [a] -> m [b]

とかこんな感じ。 (->)記法、カリー化、型コンストラクタ、型クラスといった強力な仕組みがこの表記を実現しているのだと思う。

これ、読み解くのが難しいという点ではアレかもしれないけど、設計時の思考のツールとしては役に立つ。 最近ではJavaのデータ構造をHaskellで考えてから書き下したりしている。

複雑なデータ構造を扱うのがツラい

一見すると上と矛盾するようだが、Haskellではある程度複雑なデータ構造を組み立てたり中身にアクセスしたりするのが面倒なように思える。

Haskellには複雑なデータ構造を記述するための「レコード構文」があるが、基本的にこれはデータ構造の内部フィールドにアクセスするための関数を作り出す仕組みである。関数である以上、他の関数と同じような扱いを受けるため、それが制約になる場合がある。

例えば以下のようなシンプルなRecord型を作ったとする。

data Record = Record { id :: Int,
                       name :: String }

getRecord :: IO Record
getRecord = undefined -- 何かしらデータをとってくる

main :: IO ()
main = do
  record <- getRecord
  putStrLn $ "id: " ++ (show $ id record) ++ ", name: " ++ (name record)

上のコードはコンパイルすら通らない。なぜならid関数は既にPreludeで定義されており、多重定義になってしまうからである。これを避けるには、Preludeからidをインポートしないように明示するか、Record型のid関数の名前を変えるか、Main.idとしてパッケージ名を明示して呼び出すようにしないといけない。

Perlでは組み込み関数と同じ名前のメソッドも作ることができる。

package Record;

sub new { bless { map => 0 }, shift }

sub map { shift->{map} }

my $rec = Record->new;
print "map: " . $rec->map . "\n";

mapは組み込み関数であるが、Recordクラスのオブジェクトメソッドとしても普通に定義し、呼び出せる。まあこれは$rec->mapがそもそもメソッド呼び出しのための特別な記法だからではあるが、ここで重要なのは、メソッド呼び出し時に$recオブジェクトのクラスであるRecordパッケージが参照されるということだろう。オブジェクト指向言語ではこのようにオブジェクトから名前空間を手繰ってくれるので、いろいろな名前空間に散らばった関数をシンプルな記法で使いこなすことができる。

Haskellにはこのような、「データと名前空間を紐づける」機構はないのだろうか?それがあればだいぶラクになりそうな気がする。現状は、ある程度のデータ構造を扱うモジュールをいくつかインポートすると、どのシンボルをインポートするか、あるいはしないか、あるいはqualified importするか、よく悩むし、いまだにベストな解にたどり着けたことがない。

ところで、まだ使ったことはないがHaskellにはLensという、データ構造へのアクセサを提供するモジュールがあるらしい。これを使えばだいぶラクになるかもしれない。

なんとなく、コアの言語仕様の機能的な不足をライブラリでカバーするこのやり方はPerlMoose系モジュールのアプローチに似ていると思う。(Moose系モジュールも自分は普段使わないが)

Cabalはそれなりにスゴい

cabalさえあればライブラリ管理、自動テスト、パッケージングなどを一手に引き受けてくれるあたりはやはりスゴいと思った。

Perlの場合、一般的にはこれらのタスクはそれぞれ独立したソフトウェアが担当しているし、(だいぶ収束してきたとはいえ)それぞれ複数の選択肢がある。 このへんは文化の差だろうか。

Haskellソースコードをあらかじめコンパイル、リンクして使うのが一般的なので、どっちかというとJavamavenっぽいツールが求められているのかもしれない。

ただ、cabalは一枚岩すぎて、ちょっとテストを回したい時でも.cabalファイルをガッツリ書かないといけないのがツラい。 まあcabalなしでテストを回すこともできるわけだけど、Perlならprove -lでサクっと回せるのになーと思ってしまう。

.cabalファイルもかなりの堅物で、いろんな情報を全て明示的に書いてあげないと動いてくれない。 まあ勝手に空気読んで予測不能な動作をされるよりかはマシかもしれない。

静的型付けによる安全性はいいこと、、なんだと思う

Haskellの強力な型システムによる型安全性はいいことだとは思うけど、実際プログラミングをしていると、「コンパイルを通すためだけに、本質的に等価なデータの型変換をやる作業」が割と発生するように思える。

例えば後述する文字列型間の変換とか、メッセージ文字列を作るためにshowしまくったりとか、MaybeモナドをEitherモナドに変える作業とか、リストの中のモナドをリストの外に出す作業とか。 なんかモナドがネストするとだいたい面倒なような。。

もちろん多くの典型的な処理については既に変換関数があるわけだけど、慣れないうちはそれを調べる作業がなかなかツラい。

あと、「本質的に等価なデータ」と言ったけど、もしかしてHaskell的にはこの型変換の作業こそがコンピューティングの本質なんじゃないかという思いもよぎったりして、哲学的な気分になる。

文字列型がたくさんある

Haskellにはよく使われるものだけでも5種類の文字列型がある。

  • String : [Char]と等価。Charの実体はUTF-32エンコードされた4バイトデータらしい。
  • 正格ByteString : バイトシーケンス。
  • 遅延ByteString : 上の遅延評価版。
  • 正格Text : UTF-16を使った文字列用データ型。
  • 遅延Text : 上の遅延評価版。

参考: http://blog.ezyang.com/2010/08/strings-in-haskell/

慣れないうちはこれらの使い分けと相互変換がもうほんとにツラい。 一応、Data.String.Conversionsという相互変換ライブラリがあるのでこれを使っているはいるが、事あるごとにcs関数による変換が求められる。

ちなみにPerlにはoctet stringとcharacter stringの2種類がある*1。 octet stringは文字としての情報を持たないただのバイトシーケンスで、character stringはUnicode文字列として扱えるデータ型である(たしか内部的にはUTF-8エンコードされていたはず)。

octet stringとcharacter stringを連結したりしようとすると、octet stringは自動的にcharacter stringにアップグレードされるが、運が悪いとこの時に文字化けが発生する。 そこでPerlでは、「プログラム外部との入出力はoctet stringで実施し、境界で変換を行い、内部ではcharacter stringを使う」というのが一般的なベストプラクティスとされている。

と考えるとHaskellではByteStringとTextを使っていればいいのだろうか? しかしHaskellには正格 or 遅延というもう一つの判断基準もあるわけで・・・

遅延評価は魔法ではない

Haskellの強力な遅延評価の仕組みがあれば、プログラマは本質的なロジックさえ記述すればあとは処理系が勝手に最適で必要最低限な手続きに仕立て上げてくれる」と思っていた時期が自分にもありました。

しかし実際はそんなことはなく、Haskellでは様々な関数やデータ構造で正格評価版と遅延評価版の両方が提供されている。正格評価も遅延評価を時と場合によって一長一短があるので、プログラマがそのどちらを使うべきかを判断しないといけない。

しかし、その「遅延評価版」とやらが何の評価をどう遅延させるのか、その結果パフォーマンスやプログラムの動作にどう影響が出るのかといったことが、自分にはまだイマイチよく分からない。もしそのへんに立ち入ったチューニングが必要になったらなかなか大変そうだと思う。

ただし、将来的にコンパイラが魔法と区別できなくなるほど賢くなれば、正格か遅延かをプログラマが判断しなくても済むようになるかもしれない。

型推論は魔法ではない

Haskellの強力な型推論の仕組みがあれば、プログラマは必要最低限の型情報さえ明示していればあとは処理系が勝手に正しい型情報を与えてくれる」と思っていた時期が自分にもありました。

これについては概ねその通りではあるが、それでも何回か「これ型推論できないの?」と思える状況に遭遇した。

例えばregex-系の正規表現ライブラリのマッチング演算子=~は、結果を受けるデータ型によって返す内容を変化させるという多態的な挙動を示す。が、これがアダとなり、受け側のデータ型を明示しないとコンパイルが通らないことがあった。

また、OverloadedStringsオプションを有効にすると文字列リテラルが多態的になるわけだが、これによって型が判別できなくなってコンパイルが通らない、なんてこともあった。

このへんについては自分のコーディングの仕方に問題があるかもしれないが、型推論に失敗したときのエラーメッセージも初心者には分かりづらいと思う。

*1:この呼称は一般的ではないかもしれないが、たしかハッキリしたコンセンサスもなかったと思う