Rust で柔軟な do 式とモナドを使うために作った幾つかのもの
QualifiedDo
によるオーバーライド可能な do 式のマクロと、GATを使ってモナド階層を模倣するトリックを考えたよ
TL;DR:
はじめに
新年明けましておめでとうございます。前回の記事は思ったよりも反響を頂きましてちょっとかなりびっくりしています。
この記事で、?
の中途半端な非局所性や、Try
の有無などによる関数の名前の組合せ爆発などを念頭に、認知負荷軽減のためにモナドと do 式を使いたい、という話をしました。
今日はそこの辺りで色々試してみた結果部分的な解が得られそう、という気がしてきたのでその話をします。
部分的、というのは、Foldable
や Traversable
による認知負荷の軽減は今回のスコープから外れるけど、do-式によって色々な計算を局所的に見通しよく書けるという点は上手く行きそうということです。
内容としては、以下の二点です:
-
GATs を使ってモナドを模倣する方法は思ったより上手くいきそうだよ
-
Option
やResult
をモナドのように使えそうだよ
-
-
意味論が差し替え可能な
do
式QualifiedDo
を実現するqualified_do::qdo
マクロを実装したよ:use qualified_do::qdo; use functo_rs::data::*; let is = vec![1, 2, 3]; let js = vec![4, 5, 6]; let ans: Vec<i64> = { let is = is.clone(); let js = js.clone(); qdo! {ZipVec { i <- is; j <- js; let k = 100i64; return i + j + k } }}; assert_eq!(ans, vec![105, 107, 109]); use functo_rs::nonlinear::*; let ans: Vec<i64> = { let is = is.clone(); let js = js.clone(); qdo! {UndetVec { i <- is; j <- js; let k = 100i64; return i + j + k } }}; assert_eq!(ans, vec![105, 106, 107, 106, 107, 108, 107, 108, 109]); use either::*; let ans: Vec<i64> = { let is: Vec<Option<i64>> = vec![Some(1), None, Some(3)]; let js: Vec<Either<i64, i64>> = vec![Left(4), Right(5), Right(6)]; qdo! {Iter { Some(i) <- is.clone(); Right(j) <- js.clone(); guard j % 2 == 0; let k = 100i64; return i + j + k }} .collect() }; assert_eq!(ans, vec![107, 109]);
この記事の元になっているコードは全て以下のレポジトリにあります。まだ色々とドッグフーディングして改良したいと思っているので、リリースは先になるかとは思いますが、よかったら触ってみてください。
QualifiedDo: 意味論を差し替えられる do 構文
Haskell(やそれに影響を受けた言語)の特色として、任意の「副作用」に対して do
-式 というものがあります。
これは、「モナド」やそのサブクラスである「Applicative」であるような任意の制御構造を、あたかも逐次実行+条件分離を伴う普通のプログラムであるかのような形で記述できる、というものです。
Rustでいえば、Option<T>
や Result<T, E>
、 Iterator<Value = E>
、あとは proptest
の BoxedStrategy<T>
なんかも見方によってはこうしたクラスに該当します。
特に、proptest をある程度使ったことがある方向けに言えば、prop_compose
マクロをもっと通常のプログラムに近い形で書けるようにし、他の型にも使えるようにしたものです。
この機能を提供しているのが、上掲のレポジトリの qualified_do
クレートです。
早速具体例を見てみましょう。まずは prop_compose!
相当です。
ここでは単純に prop_map
を二回重ねれば書けるものではありますが、もっと混み入った例も書けそう、という気がすると思います。
次は、Option
と Result
が入り乱れている例を見てみましょう。
ネストされた別々の型に対するqdo!
が問題なく共存できているのがわかると思います。
一見 Some(_)
や Ok(_)
に包まれている値を再度剥しているだけに見えるので、あまり嬉しさがわからないかもしれません。
ですが、よく見ると行番号40では、クロージャの引数である go
の値に対するパターンマッチをしているのがわかると思います。
これによって、ans(Go::Go)
のように Go
を渡した場合は全体として Ok
が返るのに対して、 false
を渡して ans(NoGo)
とすると、パターンマッチが「失敗」するため、まず内側の qdo!{ Optioned {..}}
が全体として None
になり、次いで ok_or()
でエラー化されて外側の qdo!{ Resulted{..}}
にも伝播し、最終的に Err
になるわけです。
これと同じものをプレーンな Rust の ?
で書こうとすると、?
が現在のクロージャの範囲で一つの型に対してしか使えないため、次のようにクロージャを一回つくってすぐ壊すというちょっと持って回った方法が必要になります:
let ans = |go| {
let x = (|| {
let x = Some(1)?;
let y = Some(2)?;
if x + y % 2 == 1 {
if let Go::Go = go {
return Ok(x + y + 100);
}
}
None
})().ok_or("Fail".to_string());
let y = Ok(3)?;
Ok(x + y + 1000);
};
今回は単純な例ですが、例えばパーザや評価器を実装したり、データのバリデーションを行うときなどに、qdo!
を使った方が見通しがよくなると思ってもらえるのではないでしょうか。。
次の例は、qdo
で Iterator
を「制御構造」っぽく書くものです:
Option<i64>
からなる配列 is
と Either<i64, i64>
から成る列 js
が与えられていて、is
のうちの Some
のもの、 js
のうちの Right
に包まれている偶数について、その全ての組合せの和 + 100 からなる配列を最終的に作っています。
expected
の定義が do
を使わない同値な実装ですがやや混み入っていてこれなら for
で書いたほうがいいかなあ……という感じがするのに対して、do
の方は Some
や Right
に対するパターンマッチや、guard
による枝刈りができているのがわかると思います。
さて、上の例では flat_map
を介した総当たりでしたが、今度は zip
で「同じ位置にあるやつ」を独立に取ってきて和を取るような例を見てみましょう。
同じように i <- is; j <- js;
のような形でとってきていますが、こちらでは同じindexのもの同士だけが組み合わされる結果になっているのが expected
と見比べるとわかると思います。
これは、一つ前の例では qdo!{ UndetVec{..} }
としているのに対して、今回の例では qdo!{ ZipVec{..} }
のような形で qdo!
の直後のプレースホルダで意味論を陽に指定しているためです。
さて、このように qdo!
を使うと種々のデータ/制御構造に対して統一的かつ自由に意味論を取り替えてアルゴリズムを記述できそう、と思ってもらえたのではないでしょうか。
これが、正に Haskell の QualifiedDo で実現されていることであり、 qualified_do
クレートが目指しているものです。
単純に do
式を使いたいだけであれば monadic
や do_notation
クレートなどがありますが、前者は各「モナドっぽい」構造ごとに do
-風マクロが定義されており、後者は Lift
トレイトと使いたい型が and_then
メソッドを実装している、という事を要求しており、決め打ちになっています。mdo
クレートはスコープに mzero
や bind
メソッドがあるかどうかで脱糖をしており、この点 qualified_do
の目指す点に近いですが、スコープ依存性があり複数のデータ構造に対して種々の実装を使い分けることはできなさそうです。また、いずれのクレートも最終更新が約5年以上前であるのも不安です。
qualified_do
は、昨日つくったばっかりですし、do
ごとに指定した名前空間の演算子を呼び出す形なので自由に意味論を局所的に取り替えることができるという点がウリです。
また、qualified_do
では Haskell の世界で ApplicativeDo
と呼ばれる特別な脱糖方法をデフォルトでサポートしており、簡単な変数出現チェックを行って、失敗し得るパターンマッチ[1]がなく、個別の束縛が独立して行える場合は and_then
ではなく zip
と map
相当の関数のみを使って脱糖するようになります。
これによって、束縛する際にリソースに .clone()
が不要になったり、and_then
を使えないような構造に対しても do
-式を使うことができるようになるという効果があります。
わかりやすいのは、「総当たり Iter
」と「同位置 zip
の ZipIter
」の例でしょう。
最初の Iter の例を再掲しましょう。
ここでは「Some
かどうか」「Right
かどうか」といった失敗し得るパターンマッチや、guard
条件文の中で qdo
中で束縛されている j
に言及しています。これらにより束縛変数間に依存関係が生じてしまうので、上の qdo-式は and_then
を使って次のように脱糖されます:
Iter::and_then(
is,
move |i| match i {
Some(i) => {
Iter::and_then(
js.clone(),
move |j| match j {
Right(j) => {
Iter::and_then(
Iter::guard(j % 2 == 0),
move |()| {
let k = 100i64;
Iter::pure(i + j + k)
},
)
}
_ => {
Iter::fail("Pattern match failed:\n expected: Right(j)")
}
},
)
}
_ => Iter::fail("Pattern match failed:\n expected: Some(i)"),
},
)
一方で、次の ZipIter の例を考えます:
ここではパターンマッチも使われていませんし、i, j
に言及するような条件文があるわけでもないので、各束縛はすべて独立して行えることがわかります。
qdo
マクロはこれを検出し、次のように脱糖されます:
ZipIter::fmap(
|((i, j), k)| i + j + k,
ZipIter::fmap(
|x| { let y = 100i64; (x, y) },
ZipIter::zip_with(
|i, j| (i, j),
is, js,
),
),
)
zip_with
と fmap
のみで脱糖されていることがわかると思います。ではここで、ZipIter
の方を次のようにパターンマッチを使って書きかえてみるとどうでしょうか?
qdo! {ZipIter {
Some(i) <- is;
j <- js;
let k = 100i64;
return i + j + k
}}
すると、コンパイラは次のようなエラーを出して失敗します:
error[E0599]: no variant or associated item named `and_then` found for enum `ZipIter` in the current scope
--> qualified_do/examples/debug.rs:23:9
|
23 | qdo! {ZipIter {
| _________^
24 | | Some(i) <- is;
25 | | j <- js;
26 | | let k = 100i64;
27 | | return i + j + k
28 | | }}
| |__________^ variant or associated item not found in `ZipIter`
|
= note: this error originates in the macro `qdo` (in Nightly builds, run with -Z macro-backtrace for more info)
error[E0599]: no variant or associated item named `fail` found for enum `ZipIter` in the current scope
--> qualified_do/examples/debug.rs:23:9
|
23 | qdo! {ZipIter {
| _________^
24 | | Some(i) <- is;
25 | | j <- js;
26 | | let k = 100i64;
27 | | return i + j + k
28 | | }}
| |__________^ variant or associated item not found in `ZipIter`
|
= note: this error originates in the macro `qdo` (in Nightly builds, run with -Z macro-backtrace for more info)
これは、ZipIter
が and_then()
と fail()
を実装していないためです。敢えて実装していないわけではなく、実は、「イテレータを zip
する」という形の制御構造にはdo
がちゃんと動作する「and_then()
」を定義できない事が数学的に証明できます[2]。
今回は他の束縛値に依存するような束縛を書いてしまったので ZipIter
の範囲を越えてしまったことがこのエラーによりわかるわけです[3]。and_then
だけを使った脱糖では ZipIter
に対して do
を使うことはできませんが、qualified_do
が ApplicativeDo
の脱糖にも対応しているため、妥当な範囲では ZipIter
に対しても do
式を使って書いてやることができるわけです。
閑話休題: proc macro を書いてみて
Haskell で総称プログラミングで足りない時はしょっちゅう Template Haskell でマクロを書いているのもあり、 syn
クレートを使って構文木を変形していく形で実装していくのは手に馴染みました。
proc macro は第一義的には入力となるソースコード片をトークン列として表現し、それを変換する形で書くのもあり、本来は Rust の構文としては不正な構文も定義できるのが面白いと思いました。Haskell ではこういう時は QuasiQuote を使って独自のパーザを書いてやって拡張することになりますね。
一方で、Template Haskell ではトークン列の変換ではなく、マクロへの引数とスコープに入っている型・変数などのメタデータを元に構文木を合成する形でマクロを記述します。メタデータがあるので、 None <- hoge
みたいな式があった場合、左辺の None
が変数なのか構築子に対するパターンマッチなのか構文的に判断ができますし、渡されたパターンの構築子が newtype のもので従ってマッチが常に成功するかどうか、といった情報も判断できます。また、フレッシュな変数を生成することもでき、衛生的なマクロを書くことができます。
この点、Rust トークン列の変換でしかなく、名前解決もされていないため、必ずしも必要な情報が揃わないのはちょっと不便でした。フレッシュな変数もなんとか名前の規約でカバーするしかなく、この辺りは改善されると嬉しいなあという気持ちになりました。
GAT を使ったモナド階層の定式化
さて、前節では qdo!
で do
-式を色々な型に対して使えるようにしたという話をしました。
基本的には and_then
, fmap
, pure
, zip_with
などが定義されたモジュールないし型を渡してやればよいわけですが、Option
や Result
などについては、Haskell での Functor
や Monad
とほぼ同等の構造が入るので、上の Optioned
や Resulted
などはこうした抽象化を介して do
-式を使えるようにしています。
これは、型制約でどこまでの機能が使えるのかドキュメントでわかりやすかったり、規則を満たしていることを明示したいという動機からです。
具体的には、qualified_do
の脱糖に関連しているものは次になります:
-
Functor
:.map()
関数があるもの -
Apply
:Functor
に加えて.zip_with()
が使えるもの -
Pointed
:Functor
に加えてSome(_)
やOk(_)
のような「値を文脈に入れる」関数Type::pure(x)
[4]が使えるもの -
Applicative
:Apply
かつPointed
[5] -
Monad
:Applicative
に加えて.and_then()
または.flatten()
が使えるもの -
MonadFail
:Monad
に加えて、失敗関数Type::fail(message)
が使えるもの -
Alternative
:Applicative
かつ.or_else()
や.filter()
が使えるもの
なんだか色々ありますが、大事なことはdo
-式はこうした色々な「制御関数」を手続的な構文で繋げて書くための機構であるということです。
do
-式のどの機能までを使えるのか?という観点からは、次のように言い換えることができます:
-
Functor
:qdo!{ Namespace { x <- a; let a = ...; return (f x a) } }
の形のもの。 -
Applicative
:ApplicativeDo
で脱糖できるもの。束縛した値に関する条件分岐を含まないもの。 -
Monad
: 他の束縛値に依存した値を束縛できるもの。 -
Alternative
:guard
条件式が使えるもの。 -
MonadFail
: 更にパターンマッチがバインディング内で使えるもの。
これらの階層を提供しているのが、上述のレポジトリの functo_rs
クレートです:
ここでは、三種類のモナド階層が定義されています。以前の Linear Haskell の記事や前回の記事でも触れましたが、線型型や所有権システムが絡むと、モナドは複数の種類に分裂します。
元ネタは、Linear Haskell の中の人が書いた次の記事です:
実のところ、Haskell に QualifiedDo が導入されたそもそものモチベーションは、こうした「モナドっぽいけど微妙に異なる型クラスたち」に対しても do
式を使えるようにしたい、というものでした。
簡単にまとめれば、具体的には次の三つです:
-
無制限(非線型)モナド階層:全ての制御関数の引数・返値に
Clone
を要求し、取るクロージャも何回でも使われ得る(FnMut
)もの。- Haskellで通常「モナド」と呼ばれているものです。
-
UndetVec
などは無制限モナドにしかならない
-
データモナド階層:渡された値は何回も使われ得、渡されたクロージャを何回も使える(
FnMut
)。- データ Applicative / Alternative まではあるが、データMonadは定義しても使いようがないので存在しない。
-
ZipVec
はデータ Apply、ZipIter はデータ Applicative だが、次の Control にはならない
-
制御モナド階層:データモナドであって、
map
などに渡されたクロージャを高々一回しか使わないもの(FnOnce
を受け取るもの)。- Haskell の場合は「きっかり一回」使うが、Rustの所有権システムは「高々一回」になる。
-
Option
やResult
は Haskell では制御モナドにならないが、Rust では制御モナドになる!
これらが and_then
などの引数として FnMut
vs FnOnce
のどちらを取るのか、各パラメータにどこまで Clone
を要求するかで分裂する訳です。
functo_rs
ではこれらを使い分けるための AsData
や AsControl
、AsNonlinear
ファントム型などを提供しており、たとえば先の Optioned
などは AsControl<OptionFunctor>
の型シノニムになっています。
上でもちらっと書いたように、Rust の所有権システムは高々一回消費すればよいという事を要求しているため、値を失敗時に捨て得る Option
や Result
にも制御モナドの構造を入れることができ、do
-式を自由に使えるようになるのは、Rust の大きなアドバンテージだと思います。
こうした階層の定式化に、functo_rs
では Generic Associated Types (GAT) の機能を使っています。GATをつかってこうしたパラメータ多相型をエミュレートする方法は、たとえば recursion-schemes クレートなどで使われています。
GAT を使ってモナドを模倣する試みとしては、他にも YOSHIMURA Yu さんの記事があります:
Yu さんの記事では完全には上手く行かなかったようですが、今回開発した functo_rs
や上の recursion-schemes
では直接 Option
や Vec
に対する trait を作るのではなく、OptionやVec
に乗り得るMonadの実装に対応するファントム型を用意して、そのGAT Container<T>
として実際のパラメトリックなデータを定義するようにしています。以下がその定義例です:
トレイトを実装している UndetVec
そのものがコンテナの役割をはたしているのではなく、UndetVec::Container<T>
が本来 Functor や Apply などの性質を持たせたいパラメータ多相の型に対応しているのが見てとれるかと思います。
こうすると、直接 and_then
や fmap
を呼ぼうとした場合メソッドチェーンにならないので使いづらくなりますが、 do
式の脱糖へ利用したいだけであれば特段問題にはならないわけです。また、上の Iter
の例で見たように、同一の Vec
に対して、zip
するのか flat_map
するのかという別々の実装を newtype を介さずに分けて扱えるという利点もあります。
前回の記事でも書いたように、また、recursion-schemesで用いられているように、この方法はたとえば再帰的に定義された型に対して Haskell 流の再帰スキームを使って色々な処理を記述するのにも使えるはずです。
まとめと今後の課題
Rust で QualifiedDo を使うためのマクロと、GATを使ってモナド階層を Rust で模倣する方法について紹介しました。
まだちゃんと使い込んでいないので、しばらくドッグフーディングして育てつつ、いい感じになってきたら crates.io にリリースしたいなと思っています。
また、qualified_do では現状 zip_with
しか使っていませんが、場合によっては ap
と join
を使った方が便利な場合もあります(Haskellではそうしている)。脱糖方法をいいかんじに切り替えられるような方法も考えられたらいいなと思っています。とはいえ、素直にやろうと思うと、与えられた関数が存在するかどうかをマクロ展開時に取得できる必要があり、これは Template Haskell ではできても現状の proc macro ではできないので、どうにかして設定項目を増やすことになるのかなと思っています。
また、今回は Monad 階層だけで、Foldable / Traversable については踏み入っていません。GATを使えば定式化自体はできるかと思いますが、traversable
や sequence
などを呼び出すときにメソッドチェーンが使えないので結構不便になってしまうのではないかと思っています。
それよりも Iterator
の構造を所与のものとして fold
や collect
の try_
ならぬ effectful_
版を考えていくのがいいのかなという気がしています。とはいえ、Option<Result<T, E>>
から Result<Option<T>, E>
にしたりとかにはこの方法は使えなさそうなので、幾つか補助的な機構を用意しないといけないのかなと思っています。
ではでは、そんな感じで。今年もよい Rust & Haskell を!
-
newtype
などに対するものは等は失敗し得ないパターンマッチになりますが、マクロ展開の行われるタイミングの都合上判断できません。そこで、こういう場合は~Newtype(x) <- expr
のように、パターンの前に~
を付けてユーザの側で脱糖方法を指定する形になります。失敗し得るパターンに~
を付けた場合は、Rust の網羅性チェッカが脱糖後のコードをエラーにするので、安全性の上では問題ありません。 ↩︎ -
ここでいう「
do
がちゃんと動作するand_then
が存在しない」というのは、厳密には「zip するような Applicative 構造と両立するような Monad 構造が存在しない」という事です。 ↩︎ -
このあたり、proc macro 内の具体的な対応部分をエラー中で報告できるようにしたいんですが、マクロ展開後のエラー報告を弄る方法があるのかよくわかってないです。もうちょっと勉強したいところ。 ↩︎
-
歴史的注意:かつて Haskell では
pure
はMonad
の一部であるreturn
関数でしたが、Applicative
クラスが導入された際にpure
メソッドとして再導入され、現在ではどちらでも同値であるもののpure
を使うのが主流になっています。 ↩︎ -
Haskellの標準ライブラリでの階層では、
Apply
とPointed
は分離されずFunctor
の次にApplicative
があります。とはいえ、有限配列などは たとえばzip
に関してPointed
になり得ない(無限配列が必要になる)などの都合があるため、Apply
やPointed
を提供しているパッケージがあります。今回は、たとえばVec
などを念頭に置いてApply
とPointed
を分離しています。 ↩︎
Discussion