Re: Haskellの勉強で詰まってる部分
Haskellを習得する上で難しいポイントだと思います。大きく分けると次の二つにまとめられるのではないかと思います。
- コードの中で現れる識別子からそれが何なのかを探しづらい
- Cabalがつらい
それぞれ個人的な見解を書いてみます。
コード中の識別子の探し方
モナドのところの
<$>
とか<*>
とか、え〜どっちがApplicativeで何がFunctorだっけ、そもそもその定義はなんだったっけ。え〜あ〜〜〜みたいになる。
と名前空間の
そして名前で役割を推測することが困難な事が多々ある。
mapM_
とか、前述した演算子とか。いやmapM_
はmap
があってmapM
があって、っていう段階があるのは理解しているけど、ソース読んでて突然出現するそれには全く対応できない。
はどちらも識別子から型がわかれば大部分が解決します。ありがたいことに近年はghc-modをベースにしたサポートを各エディタから利用できます。EmacsやVimはもちろん、SublimeではSublime Haskell、Atomでもide-haskellを使えば、カーソル下の識別子の型を簡単に確認できるようになります。あとはhasktagsなどでタグジャンプできるようにしておくのもよいと思います。
これに加えてHoogleやHayoo!は演算子の検索も充実しているので役に立ちます。
プロダクションでは qualified as するのが当たり前なのか徹底的に import Foo (bar)みたいに絞るのか、それすらわからない(後者のような気はしているが)。
これに関しては結構意見が分かれるかもしれません。すべてqualified importすべしという過激意見もありますが、タイプ量がだいぶ増える上にqualifyされた演算子はお世辞にも読みやすいとはいえません。
またすべての識別子を明示してimportするのもimportリストの管理が煩雑になりますし、80桁に押さえようとするとimportリストが複数行にまたがるようになって、ただでさえ長くなりがちなリストがどんどん長くなります。
個人的には
- いつもimportする定番のモジュールは何も明示せずqualifyもせずにimport
- 巨大なモジュールの一部のみ利用する場合は明示してimport
- qualified importを前手に作られた、ほかのモジュールと衝突するようなモジュールはqualified import
Data.Text
Data.ByteString
etc...
みたいな感じで運用しています。ちなみに、少し話題がずれますがコードベースが巨大になってくるとimportリストよりexportリストをちゃんと管理することが重要で、モジュール内の何を外に見せて何を隠すかをよく考えることが肝心です。
Cabalつらい問題
sandboxが導入されてからはある程度過去の問題になりつつあると思います。cabalにはsandboxを強制onにするオプションがあるので必要なら使うとよいと思います。~/.cabal/configに
require-sandbox: True
と書くことで有効にできます。
あと、cabalファイルの書式はtoml形式ではないのでtomlのことは忘れましょう。独自形式です。
sandboxを利用しているのであれば、--force-reinstalls
しない限りいわゆるcabal hellが起こることほとんどないと思います。ただしHaskell Platformを入れていてHPで提供されているパッケージの新しい版をインストールしたり、GHCのブートパッケージのバージョンを変えようとするとつらいことはあるかもしれません。
Cabalのつらさは皆痛いほど経験してきているので、Stackageという人力で安定しているパッケージセットを作るという涙ぐましい努力が行われています。これを利用するのも一つの手でしょう。
GHCのヒーププロファイルの結果を適当に間引く
GHCのヒーププロファイラが出力する.hpファイルは、計測対象のプログラムが動いている間ずっと追記されるので、長時間動かしているとファイルがとても大きくなって、hp2any-managerなどで開けなくなってしまう。
そういう場合は.hpファイルの中身を適当に間引くといいそうなので、ghpというスクリプトを書いた。
% ghp 10 big.hp
とすると10サンプル中から1サンプルに間引いて標準出力に出力する。
Mac OS XにThreadScopeをインストールする(2015年1月版)
HomebrewのGTK+がQuartzバックエンドに対応したので、XのいらないThreadScopeが使えるようになった。手元の * GHC 7.8.4 * OS X Yosemite な環境では次のコマンドでインストールできた。
brew install gtk+ --without-x11 brew install gcc-4.9 cabal update cabal get threadscope cd threadscope-<version> cabal sandbox init cabal install gtk2hs-buildtools cabal install --bindir=~/.cabal/bin --datadir=~/.cabal/share --with-gcc=gcc-4.9 -fhave-quartz-gtk
新しいlifted-asyncの使い方
monad-controlが新しくなって、モナド変換子スタックの各層がもつ状態を表すStT
やStM
がassociated data typesからassociated type synonymsに変わった。この変更でmonad-controlに依存するたくさんのパッケージが壊れ、拙作lifted-asyncも漏れなく壊れた。修正がてらいろいろ改善を施したのでその話をしようと思う。
Control.Concurrent.Async.Lifted.Safeモジュール
v0.3.0から新たにControl.Concurrent.Async.Lifted.Safeモジュールが追加された。lifted-asyncをより安全に利用するため、既存・新規のコードにかかわらず使えるところではこちらのSafeモジュールを使い、適用できないところでのみ従来のControl.Concurrent.Async.Liftedを使うべきである。
lifted-asyncとモナドスタックのもつ副作用
そもそも以前のlifted-asyncには重大なデザイン上の欠陥があった。たとえば次のコード片は何を出力すべきだろうか。
import Control.Monad.State import Control.Concurrent.Async.Lifted main :: IO () main = do (r, s) <- flip runStateT 42 $ do a <- async $ modify (+1) >> get modify (+2) wait a print (r, s)
async
中のmodify (+1)
で状態が43になりget
で返ってくる値は43、その後さらにmodify (+2)
で状態は45になり(43, 45)
か?スレッド起動より先にmodify (+2)
が実行され状態が44に、その後modify (+1)
が実行され(45, 45)
か?いやそもそも同期なしに共有状態を書き換えるのはおかしい。ではどうするべきだったのか?
現在のlifted-asyncの実装では常に(43, 43)
が出力される。何故か?実装上の都合でwait
がasync
の中で起こったStateT
上の副作用をすべて親スレッド側で復帰しているからである。つまりmodify (+2)
は暗黙のうちに捨てられる。
どうしてこんな残念なことになっているかを理解するには実装を見てみる必要がある。async
とwait
の実装は次の通り。
async :: MonadBaseControl IO m => m a -> m (Async (StM m a)) async m = liftBaseWith $ \runInIO -> A.async (runInIO m) wait :: MonadBaseControl IO m => Async (StM m a) -> m a wait = liftBase . A.wait >=> restoreM
ここでA.
がついているものはasyncパッケージの関数を使っている。async
はモナドm
上の状態をStM m a
という型でAsyncの中に埋め込む。wait
は結果を取り出す過程でこの状態を現在のm
に復帰する。もし仮にasync
でm (Async (StM m a))
でなくm (Async a)
を返す、またはwait
でrestoreM
を使わずにa
を取り出すことができれば、子スレッド中のm
上の副作用はすべて無視するという戦略がとれるが、これをm
について多相的に実装する方法はないと思う。
Safeモジュール
問題の原点に立ち返ってみると、 並行処理を目的としたライブラリが状態を持つモナド上で無制限に利用可能なのがおかしい。状態を持たないモナド、すなわちStM m a ~ a
を満たすモナドだけで利用できるモジュールを作ろうというのがSafeモジュールのアイデアである。
Safeモジュールではasync
とwait
の型は次のようになっている。
async :: (MonadBaseControl IO m, StM m a ~ a) => m a -> m (Async a) wait :: (MonadBaseControl IO m, StM m a ~ a) => Async a -> m a
制約部にStM m a ~ a
が追加され、この制約からAsync (StM m a) ~ Async a
がいえるため、型シグネチャがasyncパッケージに近い直感的でわかりやすいものになった。実装は一部を除いてControl.Concurrent.Async.Liftedの関数をそのまま使っていて、単に型シグネチャだけ書き直している。
Safeモジュールでは先に示した副作用の問題は起こらない。なぜなら副作用が問題になるStateT
などはStM m a ~ a
を満たさないため、そもそもこのモジュールを利用できないからである。現実のコードでは並行処理が必要かつ共有状態を持つモナド変換子スタックはReaderT
にMVar
などを持たせているはずなのでこの制約は問題にならないことがほとんどではないかと思う。
Concurrently
の実装上の問題
当初Safeモジュールを実装したときにConcurrently
のApplicative
インスタンスを書くのは困難に見えた。理由はStM m a ~ a
という制約はm
が状態を持たないという制約であるだけでなくa
がStM m a
と等しくなるというようにa
に対して言及しているために、a
やb
に何の制約も持たない(<*>) :: Applicative f => f (a -> b) -> f a -> f b
と型が合わないように見えたためである。
この問題は@feuerbachさんがconstraintsパッケージを用いたすばらしい解決策を示してくれた。Concurrentlyの実装にはForall (Pure m)
という制約が出てくる。これは次の擬似コード相当のことをしていると考えられる。
data Concurrently m a where Concurrently :: (forall a. (StM m a ~ a)) => { runConcurrently :: m a } -> Concurrently m a instance (MonadBaseControl IO m, forall a. (StM m a ~ a)) => Applicative (Concurrently m) where ...
つまりここで必要なStM m a ~ a
という制約はm
に対するもので、a
はなんであってもよいはずである。しかし現在のGHCはuniversally quantifiedな制約はサポートしていないのでconstraints
パッケージを使っている。
universally quantified constraintsの使い道やconstraintsパッケージの実装の話はSafe, polykinded universally quantified constraintsというポストが詳しい。
Concurrently b m a
のb
ほかにもマイナーな変更を入れた。
当初lifted-asyncにConcurrenly
を移植した際にUndecidableInstancesを嫌ってb
という何もしない型変数を導入したが、これがConcurrently
の見た目を怖くしていたようで、どうやって使うのかと質問している例をいくつか見たことがあった。
改めて見直してみるとMonadBaseControl
のインスタンスで型検査をループさせることは難しそうであること、仮にそのようなインスタンスを書いてしまっても問題箇所の発見は難しくなさそうであることから、UndecidableInstancesを有効にして型変数b
をなくした。先のconstraintsパッケージの影響でまた別の意味で怖い型になってしまったが、型の読みやすさという点である一定の効果はあったのではないかと思う。
今後
今のところlifted-asyncに大きな変更を加える予定はなく、地道にGHCや依存パッケージの更新に合わせてマイナーアップデートをしていくことになると思う。Haddockコメントを改善したいとは考えているけど、あまりやる気が出ないのでPR歓迎。ともあれStackageにも入っているし少しずつ利用者も出てきたようなので、モナド変換子スタックでasyncを使いたい人は是非使ってみてほしい。
Cabal sandboxで実行ファイルをインストールする
c2hsとかthreadscopeなど、ライブラリではなく実行ファイル + αを含むパッケージをcabal hellに陥ることなくホームディレクトリの.cabalにインストールするには--bindirと--datadirを指定してsandboxにインストールすればよい。
cabal get threadscope cd threadscope-0.2.6 cabal sandbox init cabal install --bindir ~/.cabal/bin --datadir ~/.cabal/share
最後のコマンドは呪文みたいなものなのでシェルの履歴に入れておこう。
こうすればsandboxにインストールしているのでユーザのパッケージDBを壊すこともないし、実行ファイルとそれに必要なデータファイルは~/.cabalにインストールされるので、ソースディレクトリを削除しても問題無い。
Debug.Traceで関数の引数を表示するイディオム
Haskellでよくある複数行の関数定義だったり、ガードを使っている場合、全ての場合について引数をtraceするコードが書きにくいことがある。例えば、
data A = A1 | ... data B = B1 | ... data C = C1 | ... f :: A -> B -> C -> ... f A1 b c = ... f a B1 c = ... f a b C1 = ...
こんな定義でaとbとcをどの場合についてもtraceしたい場合、各行にそれぞれtraceを書く必要がありそうに見える。
f :: A -> B -> C -> ... f A1 b c = traceShow (A1, b, c) $ ... f a B1 c = traceShow (a, B1, c) $ ... f a b C1 = traceShow (a, b, C1) $ ...
そういう場合はこうすると良いらしい。
f :: A -> B -> C -> ... f a b c | traceShow (a, b, c) False = undefined f A1 b c = ... f a B1 c = ... f a b C1 = ...