yieldとreturnの話
DESCRIPTION
クラウド温泉4.0@小樽 - The Return of F#の発表資料です。 F#のコンピュテーション式のyieldとreturnがどうあるべきかを説明しています。TRANSCRIPT
yieldと returnの話
bleis-tift
July 27, 2014
自己紹介
id:bleis-tift / @bleis
なごやではたらくゆるふわ F#er
静的型付きの関数型言語が好き
話すこと
第一部:コンピュテーション式
第二部:yieldと returnの違い ~考察編~
第三部:yieldと returnの違い ~実装編~
第四部:あるべき論とまとめ
コンピュテーション式を使う側の話ではなく、作る側の話をします。
第一部:コンピュテーション式
コンピュテーション式とは
通常のF#の文法を拡張し、カスタマイズポイントを提供した式
F#の文法に似た文法に、独自の処理を差し込める
.F#の文法..
.
let someFunc a b =
let x = f a
let y = g b
x + y
.コンピュテーション式..
.
let someFunc a b = builder {
let! x = f a
let! y = g b
return x + y
}
コンピュテーション式でできることの例
optionに対するmatchのネストを取り除く
状態変数の受け渡しを隠す
非同期処理における関数のネストを取り除く
などなどただし今回はここら辺はすっ飛ばすので注意
実現方法
F#では、コンピュテーション式は単なる式変形の規則群によって実現
特定の interfaceを実装するなどの必要がない式を変形した結果がコンパイル可能であるかどうかが重要
.
.コンピュテーション式の文法
変換規則通常の文法
では、変換規則を見ていきましょう!
表記
ゴシック体 F#コード。例えば、fun x -> x
明朝体 F#コードの中で別の何かに置き換わる部分。例えば、cexpr
イタリック F#コードに関わらない部分(変換に関わる部分)。例えば、T (e, C)
変換規則(最外部)
最初はコンピュテーション式の一番外側の部分です。.
. builder-expr { cexpr }
これは、次のように変換されます。.
. let b = builder-expr in {| cexpr |}
bはフレッシュな変数です。
builder-expr
単なる式(別名「ビルダー」)
ビルダー自体は 1つのコンピュテーション式につき 1回しか評価されないビルダーの型に変換で呼び出されるメソッドを定義する
メソッドはすべてインスタンスメソッド
{| ... |}
括弧の中に含まれる式をコア言語に変換する
{| cexpr |}であれば、cexprを変換する
どう変換するかは、以降の変換規則参照
cexpr
変換対象となる一番外側のコンピュテーション式
これ以外のコンピュテーション式は、ceと表現
cexprは必要があればDelay変換、Quote変換、Run変換が行われる
変換規則の表現
変換規則は、T 表記によって記述する.T 表記..
.T (e, C)
e:変換されるコンピュテーション式
C:変換済みのコンテキスト情報
eにマッチする変換規則を探し、変換していく
{| cexpr |}のT表記による表現(T表現)
.{| cexpr |}の T 表現..
. {| cexpr |} ≡ T (cexpr, λv.v)
λv.vの部分は、無名関数ドット以前が引数ドット以降が本体
最終的に、vに変換された式がやってくるので、それをそのまま返す
関数適用は実行時ではなく、コンパイル時に行われる
returnの変換規則
.returnの変換規則..
. T (return e, C) = C(b.Return(e))
cexprが return 42だった場合の変換は、.returnの変換例..
.
T (return 42, λv.v)
−→(λv.v)(b.Return(42))
−→b.Return(42)
できた!
letの変換規則
.変換規則..
.
T (return e, C) = C(b.Return(e))
T (let p = e in ce, C) = T (ce, λv.C(let p = e in v))
.letの変換例..
.
T (let x = 42 in return x, λv1.v1)
−→T (return x, λv2.(λv1.v1)(let x = 42 in v2))
−→(λv2.(λv1.v1)(let x = 42 in v2))(b.Return(x))
−→(λv1.v1)(let x = 42 in b.Return(x))
−→let x = 42 in b.Return(x)
ifの変換規則.変換規則..
.
{| cexpr |} ≡ T (cexpr, λv.v)
T (return e, C) = C(b.Return(e))
T (if e then ce1 else ce2, C) = C(if e then {| ce1 |} else {| ce2 |})T (if e then ce, C) = C(if e then {| ce |} else b.Zero())
.ifの変換例..
.
T (if c then return 42, λv1.v1)
−→(λv1.v1)(if c then {| return 42 |} else b.Zero())
−→(λv1.v1)(if c then T (return 42, λv2.v2) else b.Zero())
−→(λv1.v1)(if c then (λv2.v2)(b.Return(42)) else b.Zero())
−→(λv1.v1)(if c then b.Return(42) else b.Zero())
−→if c then b.Return(42) else b.Zero()
ce1; ce2の変換規則
.変換規則..
.
{| cexpr |} ≡ T (cexpr, λv.v)
T (return e, C) = C(b.Return(e))
T (ce1; ce2, C) = C(b.Combine({| ce1 |},b.Delay(fun () -> {| ce2 |})))
.ce1; ce2の変換例..
.
T (return 10; return 20, λv1.v1)
−→(λv1.v1)(b.Combine({| return 10 |},b.Delay(fun () -> {| return 20 |})))−→(λv1.v1)
(b.Combine(T (return 10, λv2.v2),b.Delay(fun () -> T (return 20, λv3.v3))))
−→(λv1.v1)
(b.Combine((λv2.v2)(b.Return(10)),b.Delay(fun () -> (λv3.v3)(b.Return(20)))))
−→(λv1.v1)(b.Combine(b.Return(10),b.Delay(fun () -> b.Return(20))))
−→b.Combine(b.Return(10),b.Delay(fun () -> b.Return(20)))
whileの変換規則.変換規則..
.
{| cexpr |} ≡ T (cexpr, λv.v)
T (return e, C) = C(b.Return(e))
T (if e then ce, C) = C(if e then {| ce |} else b.Zero())
T (ce1; ce2, C) = C(b.Combine({| ce1 |},b.Delay(fun () -> {| ce2 |})))T (while e do ce, C) = T (ce, λv.C(b.While(fun () -> e,b.Delay(fun () -> v))))
.whileの変換例..
.
T (while f() do if g() then return 42 done; return 0, λv1.v1)
−→(λv1.v1)(b.Combine({| while f() do if g() then return 42 |},b.Delay(fun () -> {| return 0 |})))−→(λv1.v1)(b.Combine(
T (if g() then return 42, λv2.b.While(fun () -> f(),b.Delay(fun () -> v2)))
,b.Delay(fun () -> b.Return(0))))
−→(λv1.v1)(b.Combine(
(λv2.b.While(fun () -> f(),b.Delay(fun () -> v2)))(if g() then b.Return(42) else b.Zero())
,b.Delay(fun () -> b.Return(0))))
−→(λv1.v1)(b.Combine(
b.While(fun () -> f(),b.Delay(fun () -> if g() then b.Return(42) else b.Zero()))
,b.Delay(fun () -> b.Return(0))))
−→b.Combine(b.While(fun () -> f(),b.Delay(fun () -> if g() then b.Return(42) else b.Zero()))
,b.Delay(fun () -> b.Return(0)))
F#のコンピュテーション式の特徴
F#のコンピュテーション式は、
Haskellの do式
Scalaの for式
C#のクエリ式
などと同じような仕組み違うのは、コア言語と同等以上の表現力を持ち得る点(ループ構文や例外処理なども使える)
→ F#のコンピュテーション式は表現力が豊富!
第二部:yieldと returnの違い~考察編~
yieldと returnの変換規則
.変換規則..
.
T (yield e, C) = C(b.Yield(e))
T (return e, C) = C(b.Return(e))
メソッドが違うだけ・・・今回の主題:
なぜコード上での意味が同じ変換規則を持つものがあるのか?
yieldと returnを使い分ける?
yieldっぽいものには yieldを使い、returnっぽいものには returnを使う・・・?
コレクションっぽいものには yieldを使い、そうでないっぽいものには returnを使う・・・?
っぽいって何!曖昧な判断基準は避けたい
yieldと returnの違いを考える
辞書を引いてみる
yield 生み出す。produce/provide
return 戻す。give back
returnはその後の処理を実行しないようにすべきモナドの return?知りませんなぁ
yieldと returnの違い
.yield..
.
list {
yield 1
printfn "done"
}
.return..
.
list {
return 1
printfn "done"
}
"done"が出力されるべきかどうか
C#ではどうか?
returnIE<T>
yield returnyield break
クエリ式select
目指すのは、IE<T>の yield returnと yield breakのようなもの
seq式
returnに非対応
C#での yield breakに相当する操作が困難
よし、seqをコンピュテーション式で再実装してみよう!
第三部:yieldと returnの違い~実装編~
実装する上での問題点
yieldも returnも、変換規則が同じ・・・
実装案1
returnが処理を打ち切る、という点に注目してみる
returnしたらそのコンピュテーション式を抜け、値を返す必要がある
Returnメソッドで返したい値を含む例外を投げ、Runで捕捉すればいい!
例外による実装
.ビルダー..
.
type ReturnExn<'T>(xs: 'T seq) =
inherit System.Exception()
member this.Value = xs
type SeqBuilder<'T>() =
member this.Yield(x: 'T) = Seq.singleton x
member this.Return(x: 'T) =
raise (ReturnExn(Seq.singleton x))
member this.Combine(xs: 'T seq, cont: unit -> 'T seq) =
Seq.append xs (cont ())
member this.Delay(f: unit -> 'T seq) = f
member this.Run(f: unit -> 'T seq) =
try f () with
| :? ReturnExn<'T> as e -> e.Value
let seq2<'T> = SeqBuilder<'T>() // 型関数
例外による実装
.使用例..
.
> seq2 { yield 1; yield 2 };;
val it : seq<int> = seq [1; 2]
> seq2 { return 1; return 2 };;
val it : seq<int> = seq [1]
おぉ!
例外による実装
Scalaの一部の returnや、breakでも例外を使っている
分かりやすいように見える
だがしかし!
例外による実装の問題点
.ダメな例..
.
> seq2 { yield 1; return 2; return 3 };;
val it : seq<int> = seq [2]
yieldがC#の yield return、returnがC#の yield breakだとすると.C#でやる..
.
IEnumerable<int> F() {
yield return 1;
yield break 2;
yield break 3; }
これは、1と 2を含むシーケンスを返す
改良版
.Combineでも ReturnExnを捕捉..
.
type SeqBuilder<'T>() =
member this.Yield(x: 'T) = Seq.singleton x
member this.Return(x: 'T) =
raise (ReturnExn(Seq.singleton x))
member this.Combine(xs: 'T seq, cont: unit -> 'T seq) =
try
Seq.append xs (cont ())
with
| :? ReturnExn<'T> as e ->
raise (ReturnExn(Seq.append xs e.Value))
member this.Delay(f: unit -> 'T seq) = f
member this.Run(f: unit -> 'T seq) =
try f () with
| :? ReturnExn<'T> as e -> e.Value
let seq2<'T> = SeqBuilder<'T>()
例外による実装
try-withを提供する場合は、ReturnExnを捕捉して reraiseする必要がある
結局そんなに分かりやすい実装にはならない
例外をフロー制御に使うことに対する抵抗感
やりたいことは実現できた
実装案2
その後の処理を続ける/続けないが判定できればいい
「その後の処理」を関数として受け取るメソッドでその関数を呼び出すか判定を入れる
状態変数による実装
.ビルダー..
.
type SeqBuilder() =
let mutable isExit = false
member this.Yield(x) = Seq.singleton x
member this.Return(x) =
isExit <- true
Seq.singleton x
member this.Combine(xs, cont) =
if isExit then xs else Seq.append xs (cont ())
member this.Delay(f) = f
member this.Run(f) =
let res = f ()
isExit <- false
res
let seq2 = SeqBuilder()
状態変数による実装
.使用例..
.
> seq2 { yield 1; yield 2 };;
val it : seq<int> = seq [1; 2]
> seq2 { return 1; return 2 };;
val it : seq<int> = seq [1]
> seq2 { yield 1; return 2; return 3 };;
val it : seq<int> = seq [1; 2]
おぉ!
状態変数による実装
単純
分かりやすいように見える
だがしかし!
状態変数による実装の問題点
ビルダーが状態を持っている
マルチスレッド等で同時に同じビルダーのインスタンス(seq2)を使うと・・・
.
.
Thread A
seq2 {yield 1
; // Combineyield 2 // oops!
} // Run
val it : seq<int> = seq [1]
seq2.isExit
false
true
false
Thread B
seq2 {return 10
} // Run
改良版
.ビルダー..
.
type SeqBuilder() =
(* 省略 *)
let seq2 () = SeqBuilder()
.使用例..
.
> seq2 () { yield 1; yield 2 };;val it : seq<int> = seq [1; 2]> seq2 () { return 1; return 2 };;val it : seq<int> = seq [1]> seq2 () { yield 1; return 2; return 3 };;val it : seq<int> = seq [1; 2]
状態変数による実装
ビルダーのインスタンスを毎回作る
ユーザがインスタンスを共有することは禁止できない
毎回関数呼び出しするのは面倒
実用には耐えない・・・
実装案3
状態変数による実装は、ビルダーのインスタンスに保持しているのが問題引数で持ちまわせばいいじゃない!
内部で状態を引数で引き回し、RunではがすCombineで状態が Breakだったら後続処理を実行しない
状態引数による実装
.ビルダー..
.
type FlowControl = Break | Continue
type SeqBuilder() =
member this.Yield(x) = Seq.singleton x, Continue
member this.Return(x) = Seq.singleton x, Break
member this.Combine((xs, st), cont) =
match st with
| Break -> xs, Break
| Continue ->
let ys, st = cont ()
Seq.append xs ys, st
member this.Delay(f) = f
member this.Run(f) = f () |> fst
let seq2 = SeqBuilder()
状態引数による実装
.使用例..
.
> seq2 { yield 1; yield 2 };;
val it : seq<int> = seq [1; 2]
> seq2 { return 1; return 2 };;
val it : seq<int> = seq [1]
> seq2 { yield 1; return 2; return 3 };;
val it : seq<int> = seq [1; 2]
おぉ!
状態引数による実装
yieldと returnの対称性が明確になった
通常の実装よりもかなり複雑
いいのでは?
yieldと returnの実装比較
.例外による実装..
.
member this.Yield(x: 'T) = Seq.singleton x
member this.Return(x: 'T) =raise (ReturnExn(Seq.singleton x))
.状態変数による実装..
.
member this.Yield(x) = Seq.singleton xmember this.Return(x) =isExit <- trueSeq.singleton x
.状態引数による実装..
.member this.Yield(x) = Seq.singleton x, Continuemember this.Return(x) = Seq.singleton x, Break
実装案4
例外による実装では、処理を打ち切るために例外を使ったこれは、継続を捨てることと同義
yieldの場合は継続を呼び出すreturnの場合は継続を捨てる
継続渡しによる実装.ビルダー..
.
type SeqBuilder() =
member this.Yield(x) = fun k -> k (Seq.singleton x)
member this.Return(x) = fun _ -> Seq.singleton x
member this.Combine(f, cont) =
fun k -> f (fun xs -> cont () k |> Seq.append xs)
member this.Delay(f) = f
member this.Run(f) = f () id
let seq2 = SeqBuilder()
.使用例..
.
> seq2 { yield 1; yield 2 };;
val it : seq<int> = seq [1; 2]
> seq2 { return 1; return 2 };;
val it : seq<int> = seq [1]
> seq2 { yield 1; return 2; return 3 };;
val it : seq<int> = seq [1; 2]
継続渡しによる実装
yieldと returnの対称性が明確コードは短いが、複雑(Bindを実装してない詐欺)
これは状態引数版もだけど・・・
実装の速度比較
各実装で 10万回 yieldしてみた
ビルダー 時間
returnしない実装 20.5ms例外による実装 20.5ms状態変数による実装 20.7ms状態引数による実装 21.2ms継続による実装 22.6msseq式 1.18ms
実装法による差は小さいが、そもそも独自のビルダーは遅い
第四部:あるべき論とまとめ
ここまで
コンピュテーション式は表現力が豊富
yieldと returnは変換規則は同じだが、意味は違う
標準の seq式は returnに対応していない→再実装シーケンス用の yieldと returnに別の意味を持たせる複数の実装
例外による実装状態変数による実装(実用には耐えない)状態引数による実装継続による実装
各ライブラリでの実装状況
seq/list/optionのいずれかに対するコンピュテーション式が returnをどう扱うか対象ライブラリ
FSharpxExtCoreFSharpPlusBasis.Core
2014年 7月 21日時点
各ライブラリでの実装状況.検証用コード例..
.
let xs = [30; 10; 15; 21; -1; 50]
builder {
let i = ref 0
while !i < xs.Length do
if xs.[!i] = -1 then
return false
incr i
return true
}
コンパイルできるか
false的なものが返るか
各ライブラリでの実装状況
.検証用コード例展開..
.
let b = builder
b.Run(
b.Delay(fun () ->
let i = ref 0
b.Combine(
b.While(
(fun () -> !i < xs.Length),
b.Delay(fun () ->
b.Combine(
(if xs.[!i] = -1 then b.Return(false)
else b.Zero()),
b.Delay(fun () -> incr i; b.Zero())))),
b.Delay(fun () -> b.Return(true)))))
FSharpx
コンパイルできない
FSharpx
Combineの型が駄目.FSharpxの Combineのシグネチャ...'a option * ('a -> 'b option) -> 'b option
.エラー箇所の展開..
.
// 'a option * ('a -> 'b option) -> 'b option
b.Combine(
// bool option
(if xs.[!i] = -1 then b.Return(false) else b.Zero()),
// unit -> 'a option
b.Delay(fun () -> incr i; b.Zero()))
.正しいシグネチャ...'a option * (unit -> 'a option) -> 'a option
ExtCore
コンパイルできない
ExtCore
Zeroの実装が駄目.ExtCoreの Zeroの実装..
.member inline __.Zero () : unit option =
Some () // TODO : Should this be None?
コメント・・・
FSharpPlus
コンパイルできない
Whileをそもそも提供していない
それはそれでアリな選択肢。潔い
Basis.Core
コンパイルできた
falseっぽいものが返った
各ライブラリでの実装状況
比較になりませんでした!
yieldと returnの違い再考
コンピュテーション式を正しく実装できているライブラリの少なさ
yield/return以前の問題
本当に意味上の違いを与えるべき?F#のコンピュテーション式の表現力を活かすなら与えるべきBindと Returnくらいしか提供しないなら不要(FSharpPlusはこちら)
コンピュテーション式再考
YieldやReturnが継続を受け取ればよかった?効率を考えると、コンパイル時に解決したほうがいい
現状のままでも実現可能この柔軟性を活用しない手はないのでは?
方針の提案
ライブラリの性質によって、何を実装するかを分けて考える
モナド/モナドプラス程度の提供でとどめる場合
より汎用的な計算も行えるようにする場合
モナド以外にコンピュテーション式を使う場合
モナド/モナドプラス提供程度の場合
モナド提供程度Bind/Returnは必須(定義より)ReturnFromもあると便利場合によっては、Runを別で提供
モナドに包まれた値を取り出すコンピュテーション式
モナドプラス提供程度モナド用のメソッドに加え、Zeroと CombineZeroはmzero、Combineはmplusに対応Combineの変換規則により、Delayも必要
member this.Delay(f) = f ()
より汎用的なライブラリの場合
機能別にモジュールを分けるBind/Return程度を提供するビルダー用モジュールCombineも使える高級なビルダー用モジュール
Combineはmplusに対応するものではなく、継続を受け取る版を採用する
必然的に、Delay/Runの実装も必要member this.Delay(f) = f
member this.Run(f) = f ()
Zeroもあった方がいいelseなしの ifが使えるようになる
モナド以外の場合
できるだけやめておいた方が・・・
Combineを提供する場合、yieldと returnを実装し分ける
カスタムオペレータを使うことも考慮に入れる
今後の課題
FSharpx/ExtCoreにバグ報告
機能別にモジュールを分けたコンピュテーション式ライブラリの作成
実装したビルダー(特に状態引数による実装と、継続による実装)の意味的な正しさの検証
提案した方針の啓蒙
おわり