rio ライブラリを試す その1
先日,alt. Prelude
を目指している rio
ライブラリの ver.0.1 がリリースされました. 自作している CLI ツールを試しに rio
で置き換えようかとしてまして,自分の整理のためにまとめてみようと思います.
ただし,rio
作者ではないし,全部をちゃんと追っていないので間違っているかも. 間違っている場合はこのリポジトリに Issue するか Reddit でコメントでもしてください m(_ _ )m
README でひとめぐり
もともとはビルドツール Stack を作成するために考えたデザインパターンをまとめたものっぽい.
rio
ライブラリの README にはライブラリのゴール・目的の他に(彼らにとっての)ベストプラクティスが書いてある(rio
自体がそれに則って作られてる). ここには翻訳というより,README の各項目の要約を記述する.
Goal
rio
ライブラリのゴール(目的)は以下の3つ.
- よく設計された信頼できるライブラリのコレクション
- より優れた Prelude の代替え
- 高品質な Haskell コードを書くためのベストプラクティス
(1)により text
や bytesyring
のような,ほとんど base
のようなパッケージを dependencies
に列挙する必要が無くなる. (3)は大域変数(Reader
モナド)やロガーのような実用モナド回りのベストプラクティスが目玉かな(他にもあるけど).
標準ライブラリ
いくつかのパッケージを「標準」ライブラリとして再エクスポートしている. rio
の依存関係を見る限り,次のパッケージを再エクスポートしているようだ.
- bytestring
- containers
- deepseq
- directory
- exceptions
- filepath
- hashable
- lens(microlens)
- process
- text
- time
- unliftio
- unordered-containers
- vector
もちろん,元のパッケージの全ての関数や型を再エクスポートしているのではなく,取捨選択して再エクスポートしている. また,後述する Prelude
の代わりである RIO
モジュールに含まれるものもあれば,RIO.XXX
として別のモジュールとして提供されているものもある.
Lens
lens
の場合,申し訳程度の関数しか再エクスポートされていない. 基本的に set
・sets
・over
・to
だけだ. 演算子は参照の (.^)
しかない. 今後どうなるか分からないが,現状 (.~)
や (%~)
は無いので替わりに関数を中置演算子にして使うしか無さそうだ.
Prelude
の代替え
Prelude
の代替えとして RIO
モジュールというのがある. README には部分関数や遅延 I/O のような,よく問題になるものを削除していると書いてある. また,Data.Maybe
や Control.Arrow
のような良く使う base
ライブラリのモジュールが再エクスポートされていたり,mapLeft
や whenM
のような良く使いそうなのに base
には無い関数が定義されている. 正直,この辺りがすごい便利.
ベストプラクティス
ココからが長い + 意見の分かれるところ. 結構 TODO
と書いてあるところも多いので彼らの中でもまとまってないのかな??
インポートプラクティス
以下をやってほしいらしい
NoImplicitPrelude
言語拡張をオン- 全てのモジュールに
import RIO
を追加 (すごいめんどい) - 必要に応じて
RIO.XXX
モジュールをqualified
を使ってインポート- 適切な
qualified
の付け方は各モジュールの Haddock の冒頭に書いてある - 例えば
import qualified RIO.ByteString as B
とか - ドキュメントに
qualified
が書いてない場合はqualified
しなくていいのかな??(例えばRIO.Directory
とか)
- 適切な
- 中置演算子は
qualified
しなくていい(他のモジュールと衝突しない限りは)
言語拡張
「言語拡張を使わないプロジェクトなんて,最近じゃほとんどないよね」とか書いてある. 以下の観点を基にデフォルトで利用しても良さそうな言語拡張を選定したそうだ.
- コミュニティで受け入れられている
- コードを壊すようなことが ほとんど ない
- 一般的に 安全だと考えられている
割と断言していないのが面白い(笑) 推奨する言語拡張はこちら
AutoDeriveTypeable
BangPatterns
BinaryLiterals
ConstraintKinds
DataKinds
DefaultSignatures
DeriveDataTypeable
DeriveFoldable
DeriveFunctor
DeriveGeneric
DeriveTraversable
DoAndIfThenElse
EmptyDataDecls
ExistentialQuantification
FlexibleContexts
FlexibleInstances
FunctionalDependencies
GADTs
GeneralizedNewtypeDeriving
InstanceSigs
KindSignatures
LambdaCase
MultiParamTypeClasses
MultiWayIf
NamedFieldPuns
NoImplicitPrelude
OverloadedStrings
PartialTypeSignatures
PatternGuards
PolyKinds
RankNTypes
RecordWildCards
ScopedTypeVariables
StandaloneDeriving
TupleSections
TypeFamilies
TypeSynonymInstances
ViewPatterns
RecordWildCards
と OverloadedStrings
は議論の余地あり的なことが書いてある(詳しくはもとの README を見て).
GHCオプション
以下を使いなさいとのコト.
-Wall
-Wcompat
-Wincomplete-record-updates
-Wincomplete-uni-patterns
-Wredundant-constraints
どういう形で指定してもいいけど,package.yaml
で書くと楽だし,後述するテンプレートにも書く予定だそうだ. あと,プロダクションコードなら -Werror
をオンにして,最後には確認した方が良いとも書いてある(CIとかでチェックしたいよね).
Monads
まってました! モナドです. 実用的なプロジェクトを考えるとき,必要になってくるのが大域変数・ロガー・例外・IO だと思うので,気になるのはこの辺りだよね.
全体としての推奨事項は以下の通り.
IO
を使いたいときはRIO
モナドを使いなさい.RIO
モナドはReaderT env IO
と同じだけど,rio
にはRIO
モナドに対する補助関数が含まれるのでぜひ使って.
RIO
モナドでenv
の参照関数を書くときは,具体的な型に対する参照関数を書かずに,型クラスを用いた多相的な関数を書くべき(詳しくは後述)Has
スタイルの型クラスを使えば lens を利用することが出来るよ
- いくつかの良い
mtl
スタイルの型クラスも利用する必要は出てくるはず- ただし,推奨しているのは
MonadReader
MonadIO
MonadUnliftIO
PrimMonad
MonadReader
+Has
の方がMonadLogger
のように新しい型クラスを作るより優れていると思う- 特に
MonadBase
MonadBaseControl
MonadMask
MonadCatch
は避けるべき
- ただし,推奨しているのは
大域変数
env
回りについて. Has
スタイル(パターン)というのがあって,それ自体はこのページが参考になるのかな? env
から何らかの値を参照する場合には
myFunction :: RIO Config Foo
のような関数は やめて ,次のように書くのを推奨している.
class HasConfig env where
configL :: Lens' env Config -- more on this in a moment
myFunction :: HasConfig env => RIO env Foo
ここで,env
は次のようなレコードを想定しており
data Env = Env { envConfig :: !Config }
こうすることで,env
に複数の大域変数を持たせても,うまく機能させることが出来る. また,次のように Env
と Config
それぞれでインスタンスを定義することで,それぞれで利用できる.
instance HasConfig Config where
= id
configL
instance HasConfig Env where
= lens envConfig (\x y -> x { envConfig = y }) configL
ロガー
ロガーも同様に Has
スタイルを推奨している. env
にログを出力するための関数(LogFunc
型のもの)を持たせる.
data Env = Env { envLogFunc :: !LogFunc, envConfig :: !Config }
これの Has
スタイル型クラスは RIO
モジュールに定義してある.
instance HasLogFunc Env where
= lens envLogFunc (\x y -> x { envLogFunc = y }) logFuncL
LogFunc
型の値を渡すには withLogFunc
関数を用いるようだ(mkLogFunc
関数もあるがアドバンスドと書いてある.).
main :: IO ()
= do
main <- getConfig
envConfig <- logOptionsHandle stdout False
logOpts $ \envLogFunc -> runRIO Env{..} action
withLogFunc logOpts
action :: RIO Env ()
= ... action
例外
例外をどのように表現すべきかは結構議論されており,今のところの基本的アイデアは以下の通り.
lookup
のような単純に失敗する関数(部分関数になり得る?)の場合はMaybe
やEither
を返そう- それらを使いたくない場合(大域脱出とか?)は例外を使って
- 純粋なコード(IOではなく)の場合は
MonadThrow
制約を使う IO
の場合はthorowIO
を介した実行時例外を使う(RIO
モナドも同じ)
- 純粋なコード(IOではなく)の場合は
- 「IOアクションがどのように失敗するかを正確に把握できないことにより,不安と不満を感じるかもしれない.しかし,その痛みを受け入れて共存し内在化して,
tryAny
を用いて移動してください.これは非同期例外に対して支払う代償です.」- ちょっと何言ってるかよくわからない...
- 全てのリソース割り当ては
bracket
やfinally
のような関数で行う
以下のようにして,アプリ専用の例外を定義し使用することが推奨されている.
data AppExceptions
= NetworkChangeError Text
| FilePathError FilePath
| ImpossibleError
deriving (Typeable)
instance Exception AppExceptions
instance Show AppExceptions where
show = \case
NetworkChangeError err -> "network error: " <> (unpack err)
FilePathError fp -> "error accessing filepath at: " <> fp
ImpossibleError -> "this codepath should never have been executed. Please report a bug."
ちなみに,Exception
型クラス(というか SomeException
型)は,いわゆる存在型を利用して具体型(AppExceptions
とか)を隠蔽している.
疑問なところは,投げた例外を catch
してからディスパッチして良いのだろうか? 実行時例外じゃなくて大域脱出をしたいだけなんだけどな. ちょっとイマイチ使い方が分からない.
Strict
特別な理由が無い限り,データフィールド(レコードのフィールド??)は正格にすべき,とのこと. 割と重要そうなのに一文しかない.
プロジェクトテンプレート
そのうち,新しい Stack テンプレートを作るそうだ. もちろん,cabal
ファイルは使わず hpack
を使用する.
安全第一
rio
は安全性を最優先しており,そのため部分関数と遅延 I/O を全力で避けている. もし,遅延 I/O を使いたい場合は conduit
のようなストリーミングライブラリを使いなさい,とのこと.
一般化
Haskell のよくある疑問として,いつ一般化すべきか,というのがある(いつ?). README にはいくつかの簡単なガイドラインが書いてある.
パラメトリック多相の場合,わりと議論の余地が無く,多相的な方が有用. つまり,reverse :: [a] -> [a]
は reverse :: [Int] -> [Int]
より優れている.
型クラスの場合は話が微妙になる. Foldable
や Traversable
のような RIO
で定義済みの型クラスには可能な限り一般化(インスタンスを定義)するのが良いだろう. しかし,本当の疑問は 自身で型クラスを定義すべきかどうか の場合. 原則としては可能な限りそれは避けるべきだ. もし,自身で型クラスを定義している場合は,自分が期待していなかった型がインスタンス化されてもバグが起きないよう に気を付けること,とのこと.
コーディングスタイル
議論中だそうだ.
モジュール階層
RIO.Prelude.XXX
モジュールはドキュメントを Haddock で読みやすくするための階層で,個別にインポートすることを想定しているわけではない. と書いてあるが,結局 ver.0.1 からは Haddock からも消えてしまったので気にする必要はない.
I/O まわり
(この話は README に書いてあるわけではないです)
RIO
モジュールには文字列型(String
)の一般的な putStr
や getLine
のような I/O 関数は無い. 実用コードの場合,これらの関数を直接呼ぶことは稀だろうが,例えば CLI を作ったときに --version
オプションでバージョン情報を出力したい場合などがある.
替わりとして次のような I/O 関数が提供されている.
Strict.ByteString
型のputStr
やgetLine
であればRIO.ByteString
モジュールで再定義されている- ファイルの入出力であれば
RIO
モジュールにStrict.ByteString
版とText
版が提供されている Builder
型の標準出力hPutBuilder
関数ならある
コンソールに対する I/O はどれを使うべきかはまだ議論中みたいだ.
おしまい
何となく置き換えはできたけど,例外や I/O 回りは良く分かってない... また試していこう.