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スタイルの型クラスも利用する必要は出てくるはず- ただし,推奨しているのは
MonadReaderMonadIOMonadUnliftIOPrimMonad MonadReader+Hasの方がMonadLoggerのように新しい型クラスを作るより優れていると思う- 特に
MonadBaseMonadBaseControlMonadMaskMonadCatchは避けるべき
- ただし,推奨しているのは
大域変数
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
configL = id
instance HasConfig Env where
configL = lens envConfig (\x y -> x { envConfig = y })ロガー
ロガーも同様に Has スタイルを推奨している. env にログを出力するための関数(LogFunc 型のもの)を持たせる.
data Env = Env { envLogFunc :: !LogFunc, envConfig :: !Config }これの Has スタイル型クラスは RIO モジュールに定義してある.
instance HasLogFunc Env where
logFuncL = lens envLogFunc (\x y -> x { envLogFunc = y })LogFunc 型の値を渡すには withLogFunc 関数を用いるようだ(mkLogFunc 関数もあるがアドバンスドと書いてある.).
main :: IO ()
main = do
envConfig <- getConfig
logOpts <- logOptionsHandle stdout False
withLogFunc logOpts $ \envLogFunc -> runRIO Env{..} action
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 回りは良く分かってない... また試していこう.