Subscribed unsubscribe Subscribe Subscribe

Re: Haskellの勉強で詰まってる部分

mizchi.hatenablog.com

Haskellを習得する上で難しいポイントだと思います。大きく分けると次の二つにまとめられるのではないかと思います。

  • コードの中で現れる識別子からそれが何なのかを探しづらい
  • Cabalがつらい

それぞれ個人的な見解を書いてみます。

コード中の識別子の探し方

モナドのところの

<$> とか <*> とか、え〜どっちがApplicativeで何がFunctorだっけ、そもそもその定義はなんだったっけ。え〜あ〜〜〜みたいになる。

名前空間

そして名前で役割を推測することが困難な事が多々ある。mapM_ とか、前述した演算子とか。いや mapM_map があって mapM があって、っていう段階があるのは理解しているけど、ソース読んでて突然出現するそれには全く対応できない。

はどちらも識別子から型がわかれば大部分が解決します。ありがたいことに近年はghc-modをベースにしたサポートを各エディタから利用できます。EmacsVimはもちろん、SublimeではSublime HaskellAtomでもide-haskellを使えば、カーソル下の識別子の型を簡単に確認できるようになります。あとはhasktagsなどでタグジャンプできるようにしておくのもよいと思います。

これに加えてHoogleHayoo!演算子の検索も充実しているので役に立ちます。

プロダクションでは 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という人力で安定しているパッケージセットを作るという涙ぐましい努力が行われています。これを利用するのも一つの手でしょう。

debパッケージをデバッグシンボル付きでリビルドする

HaskellFFI呼び出しで返ってくる値がおかしいのでgdbデバッグしたいという時、*-dbgパッケージがあればいいのだけどない場合は自分でパッケージをリビルドする必要がある。

sudo apt-get build-dep foo
mkdir foo
cd foo
DEB_BUILD_OPTIONS="nostrip noopt debug" fakeroot apt-get source -b foo

これでfooディレクトリの下にdebファイルができるのでインストールすればいい。あとはgdbなりcgdbなり適当なフロントエンドを使ってデバッグする。

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が新しくなって、モナド変換子スタックの各層がもつ状態を表すStTStMが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)が出力される。何故か?実装上の都合でwaitasyncの中で起こったStateT上の副作用をすべて親スレッド側で復帰しているからである。つまりmodify (+2)は暗黙のうちに捨てられる。

どうしてこんな残念なことになっているかを理解するには実装を見てみる必要がある。asyncwaitの実装は次の通り。

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に復帰する。もし仮にasyncm (Async (StM m a))でなくm (Async a)を返す、またはwaitrestoreMを使わずにaを取り出すことができれば、子スレッド中のm上の副作用はすべて無視するという戦略がとれるが、これをmについて多相的に実装する方法はないと思う。

Safeモジュール

問題の原点に立ち返ってみると、 並行処理を目的としたライブラリが状態を持つモナド上で無制限に利用可能なのがおかしい。状態を持たないモナド、すなわちStM m a ~ aを満たすモナドだけで利用できるモジュールを作ろうというのがSafeモジュールのアイデアである。

Safeモジュールではasyncwaitの型は次のようになっている。

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を満たさないため、そもそもこのモジュールを利用できないからである。現実のコードでは並行処理が必要かつ共有状態を持つモナド変換子スタックはReaderTMVarなどを持たせているはずなのでこの制約は問題にならないことがほとんどではないかと思う。

Concurrentlyの実装上の問題

当初Safeモジュールを実装したときにConcurrentlyApplicativeインスタンスを書くのは困難に見えた。理由はStM m a ~ aという制約はmが状態を持たないという制約であるだけでなくaStM m aと等しくなるというようにaに対して言及しているために、abに何の制約も持たない(<*>) :: 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 ab

ほかにもマイナーな変更を入れた。

当初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 = ...

Debugging - HaskellWiki