再帰関数のスタックオーバーフローを倒す話 その2
連載目次
- 再帰関数のスタックオーバーフローを倒す話 その1
- 再帰関数のスタックオーバーフローを倒す話 その1.5
- F#での「末尾」についての話
- 再帰関数のスタックオーバーフローを倒す話 その2 ← 今回
- .NETにおける末尾最適化の方法についての話
- 再帰関数のスタックオーバーフローを倒す話 その2.5
- 継続モナドと、F#の残念さの話
- 再帰関数のスタックオーバーフローを倒す話 その3
- すべてをあきらめて再帰をwhileに書き直す方法の話
はじめに
再帰関数のスタックオーバーフローを倒す話 その1の続きです。 前回はCPS変換することでスタックオーバーフローが回避できるよやったー!という話でした。 今回は、CPS変換じゃスタックオーバーフロー回避できない場合もあるよ、という話をします*1。 前提知識は、その1の記事を理解していることと、ILがなんとなくわかることです。 その1.5に対する深い理解は不要ですが、 読んでいるに越したことはありません。
CPS変換のおさらい
非末尾再帰の関数を末尾再帰化できる、というのがCPS変換でした。 そして、末尾呼び出しの最適化によってスタックが食いつぶされなくなるので、 スタックオーバーフローが起きなくなるようにできる、というものでした。
2種類の末尾呼び出しの最適化
さて、実はF#では末尾呼び出しの最適化は2つのパターンがあります。
一つ目はジャンプ命令への書き換えで、もう一つはtail.
プレフィックスの付与です。
ジャンプ命令への書き換え
ジャンプ命令への書き換えは、呼び出しをbr
系のIL命令に書き換えてしまうものです。
例えば、
let rec fact acc n = if n = 0 then acc else fact (acc * n) (n - 1)
このようなアキュムレータ変数を使った末尾再帰関数をコンパイルすると、以下のようなILが吐かれます。
IL_0000: nop IL_0001: ldarg.1 // 1番目の引数(n)のload IL_0002: brtrue.s IL_0006 // loadした値が0以外(0: false, 0以外: true)ならIL_0006にジャンプ IL_0004: ldarg.0 // 0番目の引数(acc)のload IL_0005: ret // loadした値を返して関数終了 IL_0006: ldarg.0 // 0番目の引数(acc)のload IL_0007: ldarg.1 // 1番目の引数(n)のload IL_0008: mul // loadした2つの値を乗算(acc * n)・・・(1) IL_0009: ldarg.1 // 1番目の引数(n)のload IL_000a: ldc.i4.1 // 定数1をload IL_000b: sub // loadした2つの値を減算(n - 1)・・・(2) IL_000c: starg.s n // (2)の結果を引数nにstore IL_000e: starg.s acc // (1)の結果を引数accにstore IL_0010: br.s IL_0000 // 無条件でIL_0000にジャンプ
このコードには、call
系の命令がないことが分かります。
その代わりに、最後に無条件で先頭にジャンプしており(br.s IL_0000
)、これが元のコードだと再帰呼び出し部分に相当します。
ILでは分かりにくい、という人向けに、上記ILをF#風言語で表現してみました。
let fact acc n = while n <> 0 do let tmp = acc * n n <- n - 1 acc <- tmp acc
このように、末尾再帰呼び出しをジャンプ命令に置き換えることで、ループと等価になりました*2。
自分自身を末尾で呼び出す再帰関数*3の場合、関数から戻ってきた後にすることがないということは、(引数の状態を更新してから)関数の先頭にジャンプしてもいいと言うことです。
tail.プレフィックスの付与
tail.
プレフィックスの付与は、末尾呼び出しされているcall
系の呼び出し命令にtail.
というプレフィックスを付けることで、
JITコンパイラの最適化によってスタックフレームを消費しないようにしてもらうものです。
例えば、
let rec fact n cont = if n = 0 then 1 |> cont else fact (n - 1) (fun pre -> pre * n |> cont)
このような継続渡しスタイルの関数を「末尾呼び出しの生成」オプションをオンにしてコンパイルすると、以下のようなILが吐かれます。
IL_0000: nop IL_0001: ldarg.0 // 0番目の引数(n)をload IL_0002: brtrue.s IL_000e // loadした値が0以外(0: false, 0以外: true)ならIL_000eにジャンプ IL_0004: ldarg.1 // 1番目の引数(cont)をload・・・(1) IL_0005: ldc.i4.1 // 定数1をload・・・(2) IL_0006: tail. // (1)でloadした関数値に(2)でloadした値を渡して実行 IL_0008: callvirt instance !1 class [FSharp.Core]Microsoft.FSharp.Core.FSharpFunc`2<int32, !!a>::Invoke(!0) IL_000d: ret // 実行した結果を返して関数終了 IL_000e: ldarg.0 // 0番目の引数(n)をload IL_000f: ldc.i4.1 // 定数1をload IL_0010: sub // loadした2つの値を減算(n - 1)・・・(3) IL_0011: ldarg.0 // 0番目の引数(n)をload・・・(4) IL_0012: ldarg.1 // 1番目の引数(cont)をload・・・(5) // (4)でloadした値と(5)でloadした値を渡してfact@22オブジェクトを生成 IL_0013: newobj instance void class Sample/fact@22<!!a>::.ctor(int32, class [FSharp.Core]Microsoft.FSharp.Core.FSharpFunc`2<int32, !0>) IL_0018: starg.s cont // 生成したオブジェクトを引数contにstore IL_001a: starg.s n // (3)の結果を引数nにstore IL_001c: br.s IL_0000 // 無条件でIL_0000にジャンプ
基本的な構造は同じですが、少し複雑になっています。 アキュムレータ変数による末尾再帰関数の例と同様にF#風の言語で表現してみるとこうなります。
let fact n cont = while <> 0 do let tmp = n - 1 cont <- fact@22(n, cont) n <- tmp cont 1
ループの中でcont
をネストさせていき、関数の最後でcont
に1
を渡していることが分かります。
再帰部分の処理は、fact@22
というクラス*4に行ってしまい、
このコードだけでは読み取れなくなりました。
fact@22
というクラスをまずはF#風の言語で表現してみます。
type fact@22<'a> (n: int, cont: int -> 'a) = inherit FSharpFunc<int, 'a>() override __.Invoke(pre) = cont (pre * n)
このように、本体の処理はInvoke
メソッド内に移動しています。
Invoke
メソッドのILはこのようになっています。
IL_0000: nop IL_0001: ldarg.0 // 0番目の引数(this)をload // loadしたオブジェクト(this)のフィールドcontをload・・・(1) IL_0002: ldfld class [FSharp.Core]Microsoft.FSharp.Core.FSharpFunc`2<int32, !0> class Sample/fact@22<!a>::cont IL_0007: ldarg.1 // 1番目の引数(pre)をload IL_0008: ldarg.0 // 0番目の引数(this)をload // loadしたオブジェクト(this)のフィールドnをload IL_0009: ldfld int32 class Sample/fact@22<!a>::n IL_000e: mul // loadした2つの値を乗算(pre * this.n) IL_000f: tail. // (1)でloadした関数値(this.cont)に乗算結果を渡して実行 IL_0011: callvirt instance !1 class [FSharp.Core]Microsoft.FSharp.Core.FSharpFunc`2<int32, !a>::Invoke(!0) IL_0016: ret // 実行した結果を返して関数終了
さて、ここでfact
本体のIL_0006
とInvoke
のIL_000f
を見てみましょう。
tail.
というILが発行されていることが分かります。
このILが発行された後に続くcall
系の呼び出しは、
JITコンパイラによって現在のスタックフレームを再利用*5するようにして実行されるようになります。
2つの最適化の違い
なぜ末尾呼び出しの最適化に2種類の方法があるのでしょうか? それは、両者の性質が大きく異なるためです。
まず、前者のジャンプ命令に変換する方法ですが、
br
系の命令はメソッド内の移動にしか使えません。
そのため、メソッドをまたぐようなジャンプはできず、CPSで現れるようなラムダ式を表すクラスのメソッドへのジャンプはできません。
それに対して、tail.
プレフィックスを付与する方法は、
call
系のメソッドが末尾で呼び出されていれば別のクラスのメソッド呼び出しであろうが有効です。
しかし、tail.
プレフィックスはJITコンパイラ任せなため、
本当に呼び出しが最適化されるかどうかの保証をF#の処理系レベルで担保することができません*6。
また、その1.5でみましたが、コンストラクタ呼び出し(newobj
)は末尾呼び出しの最適化対象ではありません*7。
そのため、ループごとにコンストラクタ呼び出しが走ることになります。
これは、F#ではtail.
プレフィックスを用いた最適化よりもジャンプ命令に変換する最適化の方が効率が良いことを意味します。
まとめると、
最適化方法 | 適用可能場所 | 処理系での保証 |
---|---|---|
ジャンプ命令での置き換え | 自己末尾再帰 | できる |
tail. プレフィックスの付与 |
末尾再帰一般 | できない |
となります。
tail.プレフィックスでスタックオーバーフローになる場合
tail.
プレフィックスが付いているコードであっても、スタックオーバーフローを起こす場合があります。
まだ原因はわかっていないのですが、
というような状況で、リビルド後の初回のアタッチでスタックオーバーフローが起きました。 2回目以降のアタッチではスタックオーバーフローが起きませんし、 小さくて単純な再帰関数をCPS変換してもオーバーフローは起きませんでした。
また、32bitのIISでは試していませんが、 一般的に32bit環境ではスタックフレームのサイズが64bit環境よりも小さくなるため、 スタックオーバーフローを起こしにくくなる可能性はあります。
ということで、特定の条件下ではCPS変換によってスタックオーバーフローを防ぐことができない場合もあるため、 F#においてはCPS変換したからと言って完全に安全とは言い切れない、という話でした。 「特定の条件」がまだ定かではないので、調査は継続しますが、今のところIISでホストしない場合には再現しませんので、非Webアプリであれば問題ないかもしれません。 次回の「その3」がおそらく最後で、ではどうやってスタックオーバーフローを倒せばいいか、の予定です。
再帰関数のスタックオーバーフローを倒す話 その1.5
連載目次
- 再帰関数のスタックオーバーフローを倒す話 その1
- 再帰関数のスタックオーバーフローを倒す話 その1.5 ← 今回
- F#での「末尾」についての話
- 再帰関数のスタックオーバーフローを倒す話 その2
- .NETにおける末尾最適化の方法についての話
- 再帰関数のスタックオーバーフローを倒す話 その2.5
- 継続モナドと、F#の残念さの話
- 再帰関数のスタックオーバーフローを倒す話 その3
- すべてをあきらめて再帰をwhileに書き直す方法の話
はじめに
前回は、CPS変換によって再帰関数のスタックオーバーフローを回避する方法を紹介しました。 その中で、「末尾呼び出し」という言葉が出てきましたが、詳細については触れませんでした。 この記事では、本題からはちょっと脇道に逸れて、F#においては何が「末尾」で何が最適化の対象となる「呼び出し」なのかを説明します。
前提知識として、上記記事を理解していることと、一部についてはILがなんとなくわかることです。 大部分はILについて知らなくても読めるはずです。
末尾
再帰関数のスタックオーバーフローを倒す話 その1 では、末尾の説明でこのようなプログラムを扱い、
let example x = if f1 x then f2 x else 10 + f3 x
f1
, f2
, f3
のうち、f2
のみが末尾呼び出しされていると説明しました。
これは間違ってはいませんが、 F#で「末尾」とみなされる位置、みなされない位置というのは自明ではありません。 そこで、F#で「末尾」とみなされる位置、みなされない位置について掘り下げてみていきます。
ちなみに、ここで調べた結果はあくまで現在の実装について述べたものであり、 仕様として末尾が明示されているわけではありませんので、将来変更される可能性はあります。
調べ方
F#の仕様書の
Expressionsの章に出てくる式について、関数を呼び出した際にtail.
ILプレフィックスが付くかどうかを調べます。
ちなみにこれは、VS2013 / F#3.1.2で調査した結果であり、過去バージョンや将来のバージョンでは結果が変わる可能性がある点に注意してください。
(* これらの関数が定義されている前提 *) let f x y = x + y let g x = f x 1 let h = g
F#での末尾一覧
blockで囲まれた式の末尾
let ``block`` () = (h 2)
blockは、(
と)
で囲まれた式で、囲まれた中の式の末尾は末尾です。
begin-endで囲まれた式の末尾
let ``begin-end`` () = begin h 2 end
begin-endは、begin
とend
で囲まれた式で、囲まれた中の式の末尾は末尾です。
delayedのlazy
に続く式の末尾
let ``delayed`` () = lazy (h 2)
delayedはlazy
キーワードで始まる式で、lazy
の後ろの式の末尾は末尾です。
functionの本体部分の式の末尾
let ``function`` () = List.map (fun x -> h x)
functionはfun
キーワードで始まる式で、本体部分の式の末尾は末尾です。
matching functionの各本体部分の式の末尾
let ``matching function`` () = List.map (function 1 -> h 0 | x -> h x)
matching functionはfunction
キーワードで始まる式で、各本体部分の式の末尾は末尾です。
sequential executionの後ろ側の式の末尾
let ``sequential execution`` () = ignore (h 1); h2
sequential executionは;
もしくは改行で区切られた式の並びで、後ろ側の式の末尾は末尾です。
matchの各分岐の式の末尾
let ``match`` x = match x with 1 -> h 0 | x -> h x
matchはmatch
キーワードで始まる式で、各分岐の式の末尾は末尾です。
conditionalのthen
の式の末尾とelse
の式の末尾
let ``conditional`` cond = if cond then h 1 else h 2
conditionalはif
キーワードで始まる式で、then
の式の末尾とelse
の式の末尾は末尾です。
ただし、else
のないconditionalの場合は、then
の式の末尾だとしても末尾にはならないようです。
[<MethodImpl(MethodImplOptions.NoInlining)>] let uf x = h x |> ignore let ``conditional 2`` cond = if cond then uf 1
この関数を最適化あり、末尾呼び出しの生成ありでビルドすると、以下のようなILが吐かれます。
IL_0000: nop IL_0001: ldarg.0 IL_0002: brfalse.s IL_000c IL_0004: ldc.i4.1 IL_0005: call void Sample::uf(int32) IL_000a: nop IL_000b: ret IL_000c: ret
tail.
が付いていません。
末尾っぽいが末尾ではないもの
assignment
let ``assignment`` () = let mutable x = 1 x <- h 2
当然と言えば当然ですが、h 2
の結果をx
に代入する必要があるため、末尾ではありません。
tuple
let ``tuple`` () = (h 1, h 2)
System.Tuple
のコンストラクタ呼び出しが隠れているため、末尾ではありません。
このように書き換えるとわかりやすいでしょう。
let ``tuple`` () = System.Tuple.Create(h 1, h 2)
try/with
let ``try/with`` () = try h 1 with _ -> h 2
例外処理ブロックを抜けるためにはILレベルではleave
が必要なため、
try
やwith
の中の式は末尾ではありません。
try/finally
[<MethodImpl(MethodImplOptions.NoInlining)>] let uf x = h x |> ignore let ``try/finally`` () = try h 1 finally uf 2
finally
ブロックを抜けるためにはILレベルではendfinally
が必要なため、
finally
の中の式は末尾ではありません。
determinstic disposal
let ``determinstic disposal`` () = use x = { new IDisposable with member __.Dispose() = () } h 2
use
はtry/finaly
が隠れていますので、これも末尾ではありません。
例えば上のコードは、以下のようなコードに書き換え可能です。
let ``determinstic disposal`` () = let x = { new IDisposable with member __.Dispose() = () } let mutable result = 0 try result <- h 2 finally if x != null then x.Dispose() result
最適化される呼び出しとされない呼び出し
ここまでで「末尾」は分かりました。 では、F#において、末尾呼び出し最適化の対象となる「呼び出し」をきちんと理解していると言えるでしょうか?
ここでは、F#における「末尾呼び出し最適化によって最適化される呼び出し」について掘り下げてみていきます。
ここで調べた結果もあくまで現在の実装について述べたものであり、 仕様として最適化される呼び出しが明示されているわけではありませんので、将来変更される可能性はあります。
最適化される呼び出し
関数呼び出し
f x
関数呼び出しが末尾の位置にある場合、その呼び出しは最適化されます。 これが最適化されなかったら困りますよね。
メソッド呼び出し
x.M(y)
メソッド呼び出しが末尾の位置にある場合、その呼び出しは最適化されます。 F#での関数はメソッドとして実装されているので、関数呼び出しが最適化されるのであればこちらが最適化されない理由はないでしょう。
関数値の起動
let g = f g x
関数を直接呼び出さず、(部分適用などを行って)値として扱う場合、
内部ではFSharpFunc<'T, 'U>
型として表現されます*1。
このような場合でも、末尾の位置で関数値を起動している場合、その呼び出しは最適化されます。 安心して部分適用できますね。
演算子
x + y
演算子をユーザ定義している場合、それは関数やメソッドで実装することになるため、 末尾の位置で演算子を適用している場合はその呼び出しは最適化されます。
最適化されない呼び出し
コンストラクタ呼び出し
C(x)
コンストラクタの呼び出しが末尾の位置にあっても、その呼び出しは最適化されません。
コンストラクタの呼び出しはnewobj
というILが発行されるのですが、このILに対してtail.
プレフィックスは付けれないのです。
そのため、コンストラクタの呼び出しが絡むと必ずスタックが消費されることになります。
F#ではコンストラクタの呼び出しがされることが分かりにくい部分が幾つかあるので、これは罠になる場合があります。
コンストラクタ内でのコンストラクタ呼び出し
type C (res: int) = new (acc: int, n: int) = if n = 0 then C(acc) else C(acc * n, n - 1) member __.Result = res
コンストラクタ内で他の(自分もしくは親の)コンストラクタを呼び出す場合、newobj
ではなくcallvirt
などのcall系のILが発行されます。
しかし、これもtail.
プレフィックスは付かないようです。
IL的にはここにtail.
プレフィックスを付けても問題ないようなので、もしかすると将来この挙動は変わるかもしれません(し、変わらないかもしれません)。
Scalaのnull/Nothing/Nil/Noneはやりすぎなのか?
Twitterしてたら目に入ったので軽く。
この後のスライドで、
Scalaにおける「何もないもの」の分類はやり過ぎ感はある
と言われているんですが、ある程度は誤解に基づく意見だよなぁこれは、ということを言っておこうかなと。
Scalaについて
日本では説明が不要なくらいScalaって有名になってると思うんですが一応。 ScalaはJVMの上で動作する、(クラス指向の)オブジェクト指向プログラミングと関数型プログラミングを融合させた言語です。 そして、Scalaのコア機能はどちらかというとオブジェクト指向プログラミング寄りです。 オブジェクト指向プログラミングをベースに、関数型の色々なものを実現している感じです*1。
オブジェクト指向プログラミング的な機能として真っ先に思いつくのは何でしょうか? 割と上位の方に、「継承」とか「型階層」とか来るんじゃないでしょうか? Scalaは、継承とか型階層といったものと、関数型的なものを良い感じに融合させています。
そして、ScalaはJavaとの親和性をそれなりに考えて作られています。 Scalaの機能が豊富なので、どうしても親和性を犠牲にしなければならないけなかった部分もありますが、 ある程度はJavaの諸々に融和させることに成功しているように思います。
Scalaにおける「何もない」を表すものたち?
Scalaで汎用的に「何もない」を表すために使えるものはいくつかあります。 これが混乱の元になっている例をいくつか見たことがありますが、 その多くはScalaの「何もない」を表すものを本来よりも多く考えてしまうことが原因になっているように思えます。
上の資料もその一つで、以下のものを「何もない」を表すものとして挙げています。
null
Nothing
Nil
None
これらを「何もない」を表すものとして一緒くたにするのは間違っていると言い切ることはできませんが、 やめた方がいいでしょう。
Scalaにおける「何もない」を表すものたち
null
とNone
「何もない」を表すものとして考えるべきは、通常この2つだけです。
null
None
null
は「Javaとの親和性」の要求から来ており、None
は関数型から来ています。
使い分けの指針は簡単、「基本的にはNone
(というかOption
)を使い、Javaとの境界ではnull
も考慮する」です。
ScalaではInt
などの数値もオブジェクトとして扱えますが、
これらはAnyVal
という型を継承しています。
そして、JavaでのObject
に相当する型として、AnyRef
があり、
このAnyVal
とAnyRef
の共通の親としてAny
型があります。
null
はAnyRef
という型を継承している型の変数には代入できますが、AnyVal
を継承したInt
型の変数には代入できません。
しかし、Option
型はAnyVal
を継承していようがAnyRef
を継承していようが使えるため、
無理をしてnull
を使う理由はScalaではありません*2。
その他勘違いされやすいけど違うものたち
()
「何もない」ではなく「1つしかない」を表すUnit
型というのもあります。
Boolean
でさえ2つあるのに、1つしかなくていったい何に使うんだ・・・と思いますか?
であれば、null
だって実は1つしかないですよね。
null
も()
も1つか値がありませんが、null
がAnyRef
を継承したどんな型の値としても使えるのに対して、
()
はUnit
型の値としてしか使えません。
・・・ますます何のためにあるのか分からなくなるかもしれませんが、簡単です。
これは、JavaやC++やC#で言うところのvoid
と同じような使い方をします。
JavaやC++やC#のvoid
型は値を持っていませんが、なぜ持っていないのでしょうか?
たぶん歴史的な経緯があるんでしょうけど、多相性*3を入れるなら、
void
も他の型と同じように使えた方が嬉しいのです。
にもかかわらず、これらの言語はvoid
を特別扱いしています。
void
が他の型と同じように値を持ち、return
で返すものがあったなら共通化できたにも関わらず、
そうなっていないため残念なことになっている例はたくさんあります*4。
Scalaではそうなっておらず、「値に意味がないこと」をUnit
型の()
という唯一の値で表すことで、
特別扱いを不要にしているのです。
0bitの情報と考えていいでしょう。情報量ゼロ。
Nothing
さて次はNothing
です。
これは id:kmizu さんのブログに詳しいです。
簡単にまとめると、
- 例外を投げる式の型
- 空のコレクションの要素型
として使います。 最初のうちはそうそう明示することがない型ですし、 一般的なコーディングでの「何もない」を表す型ではありません。
「何もない」をコード上で表すためにnull
やNone
と言った値を使ったのとは違い、Nothing
は値すらないのです。
これをnull
やNone
とひとくくりに表すのは混乱の元となるだけですから、やめましょう。
Nothing
は型を合わせるためだけに使う、と思っておけばいいはずです。
Nil
最後にNil
です。
が、他のものがある程度汎用的で広い範囲を相手にしていたのに対して、これはとても狭い範囲のものを相手にします。
これは単に「空のリスト」を表すオブジェクトです。
歴史的経緯によって(主にRubyなどを知っている層からすると)紛らわしい名前になっていますが、
そこは間違えて使ってもScalaは静的型付き言語なので、コンパイラさんが教えてくれます。
空のリストを「値がないこと一般」を表すように使うこともできますが、 そんな設計はScalaをはじめ、多くの言語ではしないでしょう。 わざわざひとまとめにする必要はありません。
余談:object
あと、スライドではNil
型、None
型としていましたが、
Scala言語の文脈ではこれらは型ではなく(シングルトン)オブジェクトです。
「型」とは言わないほうがいい気がします。
追記:
@bleis val a: None.type = None とかできる(Nilも同様)ので、Noneが型であることは一応間違いではないですね。
確かに一般的に"None型"って言い方はあまりしないし、あのスライドの作者がそのあたり変に勘違いしてそうなのは同意ですが
— Kenji Yoshida (@xuwei_k) 2015, 4月 14
すっかり忘れていましたが、型を取ることはできますね。
まとめ
Scalaでコード上で「何もないもの」を表したい場合は基本的にはNone
を使う。
他の言語を知っていると紛らわしく思える名前が出てきても、それらは別物なので気にしない。
コンパイラを頼れば問題ない。
こんなところで。
再帰関数のスタックオーバーフローを倒す話 その1
再帰関数のスタックオーバーフローを倒す話を何回かに分けてします。
連載目次
- 再帰関数のスタックオーバーフローを倒す話 その1 ← 今回
- 再帰関数のスタックオーバーフローを倒す話 その1.5
- F#での「末尾」についての話
- 再帰関数のスタックオーバーフローを倒す話 その2
- .NETにおける末尾最適化の方法についての話
- 再帰関数のスタックオーバーフローを倒す話 その2.5
- 継続モナドと、F#の残念さの話
- 再帰関数のスタックオーバーフローを倒す話 その3
- すべてをあきらめて再帰をwhileに書き直す方法の話
はじめに
継続渡しスタイルもしくは継続渡し形式(Continuation Passing Style、以降CPS)という言葉を聞いたことがあるでしょうか。 今日はCPSの話をします。 前提知識は、F#のみです。
継続とは
CPSの前に、まずは継続の話です。 継続と言っても、継続的インテグレーションとか継続的デリバリとはまったく、これっぽっちも関係ありませんのでそういう話題を期待した人は回れ右。 これらの文脈では継続は「繰り返し」とかそんな風な意味を含んでいますが、今回扱う継続は「続き」とかそんな意味ととらえてください。
続きったって何の続きだ、となるわけですが、ざっくり説明すると、
「プログラムのある瞬間を考えたときに、その瞬間より後に実行される処理」
が継続です。
プログラムのデバッグでブレークポイントを貼って処理をブレークしたときに、「そのあとに実行される処理」ってあるじゃないですか。 あれをプログラミングの対象にしてしまおう、というような話だと思ってください。
let x = f 42 (* ここでブレークして、fから戻ってきた状態(fは実行済み) *) printfn "%d" x
コメントに書いたような状態だと思ってください。 このときに、継続は
+---------+ | let x = | f 42 | +------+ | printfn "%d" x | +----------------+
この枠で囲われた部分です。
=
の左側が計算されてからその結果が右側の変数x
に設定されるので、let x =
の部分も継続に含めています。
さて、これをプログラミングの対象にするにはどうすればいいでしょう?
継続を無名関数で表す
一つの方法として、プログラムを変形して継続を無名関数で表す、というのがあります。 やってみましょう。
f 42 |> (fun x -> printfn "%d" x)
上のコードとこのコードが同じ動作をすることは分かるでしょうか。
先ほどはlet
の一部分が継続に含まれていたので、プログラミングの対象に出来なさそうでした。
それに対して、このコードでは先ほどの例と同じ継続は
+-----------+ f 42 |> | (fun x -> | +---------+ | | printfn "%d" x) | +---------------------+
この枠で囲われた部分です(|>
演算子は使わないこともできるので含めていません)。
これなら、この関数自体をプログラミングの対象に出来ますね!
(* もはや値としての関数でしかない *) let cont = (fun x -> printfn "%d" x)
これだけだとありがたみがさっぱりですが、継続は関数として表せる、ということがわかりました。
無名関数でletを代用する
先ほどの変形によって、let
が消えたのに気づいたでしょうか?
let
によって導入していた変数は、継続を表す無名関数の引数に変わりました。
let
を無名関数で表すことは後で重要になってくるので、もう少し詳しく見てみましょう。
このようなプログラムがあったとします。
let x = f 42 let y = g x let z = h y printfn "%A" (x, y, z)
これをlet
を使わずに無名関数だけで書いてみます。
f 42 |> (fun x -> g x |> (fun y -> h y |> (fun z -> printfn "%A" (x, y, z) )))
f 42
より後ろを表す継続はf 42
の戻り値をx
として引数で受け取ります。
そして、g x
より後ろを表す継続はg x
の戻り値をy
として引数で受け取ります。
h y
より以下略。
このように、継続を起動した(継続を表す関数を呼び出した)側の結果は、引数として受け取り、その継続の中で使えます。
継続渡しスタイル(CPS)
fとgとhがそれぞれこのような関数だったとしましょう。
let f x = x / 2 // int -> int (intを受け取ってintを返す関数) let g x = x + 10 // 同上 let h x = string x // int -> string (intを受け取ってstringを返す関数)
これを元にして、各関数が「自身の処理をした後の継続cont
*1」を受け取れるようにしてみます。
let fCont x cont = x / 2 |> cont // int -> (int -> 'a) -> 'a (intと「intを受け取って何か('a)を返す関数」を受け取って何か('a)を返す関数) // 元の関数での戻り値は、第二引数で渡される関数の引数になっている let gCont x cont = x + 10 |> cont // 同上 let hCont x cont = string x |> cont // int -> (string -> 'a) -> 'a (intと「stringを受け取って何か('a)を返す関数」を受け取って何か('a)を返す関数) // 元の関数での戻り値は、第二引数で渡される関数の引数になっている
このように、「自身の処理をした後の継続」を受け取る関数のことを、「継続渡しスタイルの関数」と言います。
元の関数では結果はそのまま呼び出し元に返していましたが、このバージョンではcont
に結果を渡しています。
cont
は「自身を処理した後の継続」ですから、それに結果を渡すことによって、cont
の中で結果が使えるようにするためです。
fCont
はどうやって使えばいいでしょうか?
f
であれば、例えばこのように使っていました。
let res = f 10 ...
fCont
はこのように使います。
fCont 10 (fun res -> ...)
「無名関数でlet
を代用する」で見たような書き方になっていますね。
「無名関数でlet
を代用する」では、|>
演算子を使って順番を入れ替えていましたが、継続渡しスタイルの関数を使う場合は不要です。
このように、継続渡しスタイルの関数を使って継続を渡すプログラミングスタイルが、「継続渡しスタイル(CPS)」です。
fCont 42 (fun x -> gCont x (fun y -> hCont y (fun z -> printfn "%A" (x, y, z) )))
ここからは継続が関数として表せると何が嬉しいかを説明するための準備となることを説明します。
末尾呼び出し
末尾呼び出しというのは、関数を呼び出した後に結果を戻す以外にすることがないような関数呼び出しのことを言います*2。 さて、ではf1, f2, f3の中で末尾呼び出しされている関数はどれでしょうか?
let example x = if f1 x then f2 x else 10 + f3 x
答えは、f2だけです。
f1が末尾呼び出しじゃないというのは、f1を呼び出した後にthen
節かelse
節を実行する必要があることから分かります。
f2の後にelse
があるように思えるかもしれませんが、then
節とelse
節は二者択一であり、then
節が選ばれたときにはelse
節は実行されません。
then
節ではf2を呼び出した後は何もすることなくその結果を戻すだけなので、f2は末尾呼び出しです。
f3の呼び出しは、その結果を使って10と加算するという処理がf3から戻ってきたときに必要です。 そのため、f3は末尾呼び出しではありません。
何が「末尾」になるのかは今回は横道なので深入りはしませんが、別の機会に(F#については)まとめようと思います。
末尾呼び出しの最適化
末尾呼び出しは「関数から戻ってきた後に結果を戻す以外にすることがないような関数呼び出し」でした。 何もすることがないのなら、関数呼び出しじゃなくて、単なるジャンプ命令に置き換えてしまえばスタックを消費しなくなっていいよね! というのが末尾呼び出しの最適化です*3。
これが嬉しいのは、例えば再帰関数が末尾呼び出しになっている場合です*4。 このような再帰を末尾再帰と言ったりします。 末尾呼び出しが最適化されないと、再帰の回数が積み重なるとスタックオーバーフローを起こしてしまいます。 末尾呼び出しが最適化されることで、再帰の回数が積み重なってもスタックオーバーフローが起こらなくなるため、再帰の回数が多くなり得る関数は末尾呼び出しの最適化がかかるように末尾再帰の形に変形することがあります。 式木の変形など、単純に書くと末尾再帰にならない再帰は山のようにあるので、末尾再帰の形に変形する方法は重要です。
あ、一応言っておくと、末尾呼び出しの最適化がかかるかどうかは言語や処理系によって違いますので、 末尾再帰に変形したからと言ってスタックオーバーフローが起きなくなることが保証されるわけではありません。 自分の好きなあの言語、あの処理系、末尾呼び出しの最適化がかかるかどうか調べておくといいでしょう。
CPS変換による末尾再帰関数への変換
さて、話を継続に戻します。 CPSに変形(CPS変換)することで、自動的に末尾再帰の関数が手に入るのです! なぜそうなるのかを見てみましょう。
継続渡しスタイルの関数と、それを使うプログラムです。
let fCont x cont = x / 2 |> cont let gCont x cont = x + 10 |> cont let hCont x cont = string x |> cont let program () = fCont 42 (fun x -> gCont x (fun y -> hCont y (fun z -> printfn "%A" (x, y, z) )))
継続渡しスタイルの関数は、継続を末尾呼び出ししているのが一目で分かります。 では、継続渡しスタイルの関数を使っている側はどうでしょうか。 こちらも、それぞれの関数は末尾呼び出しになっています。 インデントを追加するとわかりやすいでしょう。
let program () = // fContの呼び出しは、program関数の末尾で行われている // gContなどの呼び出しは、関数でくるまれた中にいるその場では呼び出されない fCont 42 (fun x -> // gContの呼び出しは、fContの継続の末尾で行われている // hContなどの呼び出しは、関数でくるまれた中にいるのでその場では呼び出されない gCont x (fun y -> // hContの呼び出しは、gContの継続の末尾で行われている hCont y (fun z -> printfn "%A" (x, y, z) ) ) )
継続渡しスタイルの関数では、関数の最後は「継続を表す関数に結果を渡す」ことになりますし*5、 継続渡しスタイルの関数を呼び出す場合もやはり末尾呼び出しになります。 そのため、再帰部分を継続渡しスタイルで書けば自動的に末尾呼び出しになるのです。
つまり、末尾再帰ではない再帰関数をCPS変換したら末尾再帰関数になり、末尾呼び出しの最適化がかかります。 ようやく、CPS変換のうれしさが分かるところまで来ました。 では、末尾呼び出しになっていない再帰関数をCPS変換してみましょう。
階乗をCPS変換
簡単な例として、階乗からやってみます。 まずは、末尾再帰ではないfactの定義です。
let rec fact = function | n when n = 0I -> 1I | n -> n * (fact (n - 1I))
bigint
が定数パターンとして使えないのでwhen
を使っているのがちょっと残念ですが、それ以外は普通のコードです。
この関数は、再帰呼び出しをした後にその結果とn
の値を掛けているため、末尾再帰になっていません。
そのため、この関数に50000I
を渡すとスタックオーバーフローが起きました。
これを、まずは再帰呼び出し部分をlet
を使った形に書き換えます。
let
を使った形にするとCPS変換しやすくなるので、慣れないうちはまずはlet
を使った形に変形するところから始めるといいでしょう。
let rec fact n = if n = 0I then 1I else let pre = fact (n - 1I) n * pre
次に、これをCPSに書き換えます。
まずは、継続を引数cont
として受け取るようにします。
(* 変換途中 *) let rec fact' n cont = if n = 0I then 1I else let pre = fact' (n - 1I) n * pre
cont
は継続なので、fact'
の処理の結果を渡してあげることでfact'
の後ろの処理を実行します。
こうでしょうか?
(* 変換途中: elseがおかしい *) let rec fact' n cont = if n = 0I then 1I |> cont else let pre = fact' (n - 1I) n * pre |> cont
これはコンパイルが通りません。
fact'
は第二引数として継続を受け取るため、pre
はfact'
の結果ではなく関数になってしまっています。
そこで、fact'
を呼び出した後の処理(n * pre |> cont
)をfact'
に渡す無名関数の中に入れてしまいます。
(* 変換完了! *) let rec fact' n cont = if n = 0I then 1I |> cont else fact' (n - 1I) (fun pre -> n * pre |> cont)
let
で導入される変数を無名関数の引数として導入する形にするのは、今まで何回か見てきているので大丈夫でしょう。
これで無事、CPS変換できました!
しかしこのままでは元の関数と同じ使い方ができません。
「スタックオーバーフローしなくなりましたが、代償として継続を渡す必要ができました!」では駄目でしょう。
そこで、CPSな関数をラップする関数を用意します。
CPS版のfact'をラップする
さて、fact'
を外から呼び出す場合、cont
には何を渡せばいいでしょうか?
それを考える前に、fact'
のシグネチャを確認してみましょう。
val fact' : n:System.Numerics.BigInteger -> cont:(System.Numerics.BigInteger -> 'a) -> 'a
System.Numerics.BigIntger
の別名としてbigint
があるので、これを使って書き直すと、
val fact' : n:bigint -> cont:(bigint -> 'a) -> 'a
こうです。 ここから分かるのは、
- 継続を表す関数
cont
には、fact'
が計算した結果が渡される - 継続を表す関数
cont
は、任意の結果型を返せる - 継続を表す関数
cont
が返した型が、fact'
全体の結果型になる
です。
1つ目は、今まで見てきた通りのことです。継続には結果が渡されます。
2つ目と3つ目に注目してください。
今まで、一番外側(一番深い部分)の継続では、printfn
による出力を行っていました。
fact' 5 (fun res -> printfn "%d" res)
今まで通りならこんな感じです。 これを上の3つに当てはめてみると、
res
にはfact'
が計算した結果が入っているprintfn "%d" res
はfact'
が計算した結果を出力して、unit
を返すfact'
に渡した継続がunit
を返すので、fact'
の呼び出し全体としてもunit
を返す
となります。
ということは、CPS変換された関数から値を取り出すには、継続に渡された結果をそのまま返せばいいということになります。
これは、継続としてid
関数を渡せばいい、ということですね。
let res = fact' 5 id printfn "%d" res
つまりこれを関数化すれば、fact
のユーザは中でCPS変換された関数に実装が変わってもなにも気にしなくていいわけです。
let fact n = fact' n id
fact'
を外から使わせないようにするために、関数内関数にしてもいいでしょう。
let fact n = let rec fact' n cont = if n = 0I then 1I |> cont else fact' (n - 1I) (fun pre -> n * pre |> cont) fact' n id
これで変換完了です。
実際にこれを試したい人は、プロジェクトのプロパティから「末尾呼び出しの生成」をオンにしてください(Releaseモードであればデフォルトでオンのはずです)。
また、fsi
であれば設定不要で試せます。
この関数には、50000I
を渡してもスタックオーバーフローは起こしません。
CPS変換をしたことによって、末尾再帰になり、末尾呼び出しの最適化がかかったようです。
スタックオーバーフローするような再帰を書いてしまったときに、CPS変換を行えばスタックオーバーフローを回避できるようになります。 他にも回避する方法はあります*6が、 CPS変換は慣れてしまえばほとんど機械的に行えるので、自分の道具箱に入れておいてもいいでしょう。
その2はコンピュテーション式の話になる予定です。
おまけ
ここからはおまけです。もしくはボーナスステージ。 色々な関数をCPS変換してみましょう。
sum関数
オリジナル
let rec sum = function | [] -> 0 | x::xs -> x + (sum xs)
letで書き換え
let rec sum xs = match xs with | [] -> 0 | x::xs -> let pre = sum xs x + pre
CPS!
let rec sum xs cont = match xs with | [] -> 0 |> cont | x::xs -> sum xs (fun pre -> x + pre |> cont)
あ、id
渡すラッパー関数は自明なので書きません。
max関数をCPS変換
オリジナル
let rec max = function | [x] -> x | x::xs -> let pre = max xs if pre < x then x else pre
letで書き換え
let
で書き換え自体は不要だけど、function
をmatch
にしておく。
let rec max xs = match xs with | [x] -> x | x::xs -> let pre = max xs if pre < x then x else pre
CPS!
let rec max xs cont = match xs with | [x] -> x |> cont | x::xs -> max xs (fun pre -> if pre < x then x |> cont else pre |> cont)
find関数をCPS変換
オリジナル
let rec find pred = function | [] -> failwith "not found." | x::xs -> if pred x then x else find pred xs
letで書き換え
let rec find pred xs = match xs with | [] -> failwith "not found." | x::xs -> if pred x then x else let res = find pred xs res
CPS!
let rec find pred xs cont = match xs with | [] -> failwith "not found." | x::xs -> if pred x then x |> cont else find pred xs cont (* (fun res -> res |> cont)なので、単にcontを渡せばいい *)
map関数をCPS変換
オリジナル
let rec map f = function | [] -> [] | x::xs -> (f x) :: (map f xs)
letで書き換え
let rec map f xs = match xs with | [] -> [] | x::xs -> let y = f x let ys = map f xs y::ys
CPS!
let rec map f xs cont = match xs with | [] -> [] |> cont | x::xs -> let y = f x map f xs (fun ys -> y::ys |> cont)
これは、map
自体のCPS変換です。
f
がCPS変換された関数の場合は、
let rec map f xs cont = match xs with | [] -> [] |> cont | x::xs -> f x (fun y -> map f xs (fun ys -> y::ys |> cont))
こうですね。
フィボナッチ関数をCPS変換
オリジナル
let rec fib = function | 0 | 1 -> 1 | n -> fib (n - 1) + fib (n - 2)
letで書き換え
let rec fib n = match n with | 0 | 1 -> 1 | n -> let pre1 = fib (n - 1) let pre2 = fib (n - 2) pre1 + pre2
CPS!
let rec fib n cont = match n with | 0 | 1 -> 1 |> cont | n -> fib (n - 1) (fun pre1 -> fib (n - 2) (fun pre2 -> pre1 + pre2 |> cont))
*1:contはcontinuationの略です。継続を表す変数名には他にもkなどが使われたりします。
*2:再帰関数のことを扱う場合が多いですが、再帰関数でなくとも末尾呼び出しと言えます。
*3:自分自身のスタックを再利用したり、ループに変形したりというやり方もありますが、どの方法でもスタックを消費しないという効果は同じです。
*4:他にも、Chain of Responsibilityパターンを適用した際に大量のオブジェクトがchainを構成する場合など、再帰しない場合でもうれしい場面はあります。
*5:例外を投げるとか、継続を捨てるとかは無視します。
*6:アキュムレータ変数を使う方法や、ループに書き換える方法などが使えます。
なごやかJava ゆるふわテストツール編で発表してきた
発表してきました。
テストツール編なのに、Javaにもテストツールにも関係のない、テスト自体の話です。 それなりに反応は良かったかな?
資料作ってる時に、盛り込み過ぎだったので資料から抜いたものを独立して別の資料にしたので、時間があったらそっちも発表しようかなー、と思っていたんですが、なかったので公開だけしておきます。
異論は認める。
2つのOptionalで分岐する
僕のOptional<>の使い方がカッコワルイ。 - 谷本 心 in せろ部屋にある最後の例ですが、
2つのOptional
の組み合わせによって処理を分岐させるコードが幾つか載っています。
if
で書いた例を見てみましょう。
public String hello(Optional<String> name1, Optional<String> name2) { if (name1.isPresent()) { if (name2.isPresent()) { return call(name1.get(), name2.get()); } else { return call1(name1.get()); } } else { if (name2.isPresent()) { return call2(name2.get()); } else { return call(); } } }
まとめると、こんな感じでしょうか。
name1 | name2 | 呼び出すメソッド |
---|---|---|
値有り | 値有り | 2引数call |
値有り | Empty | call1 |
Empty | 値有り | call2 |
Empty | Empty | 0引数call |
これ、F#だったらmatch
式を使えば分かりやすく書けます。
let hello name1 name2 = match name1, name2 with | Some name1, Some name2 -> call(name1, name2) | Some name1, None -> call1(name1) | None, Some name2 -> call2(name2) | None, None -> call()
JavaのOptional
にもしmatch
メソッドが実装されていれば、こんな感じに書けるんですけどね。
public String hello(Optional<String> name1, Optional<String> name2) { return name1.match( n1 -> name2.match(n2 -> call(n1, n2), () -> call1(n1)), () -> name2.match(n2 -> call2(n2), () -> call()) ); }
Optional
のような型を導入するのであれば、やはりmatch
式のようなもの*1が欲しくなりますね、という話でした。
Effective Visual F# Power Tools
さてさて、またもや他人のAdvent Calendarに乗っかったネタです。 今日は、Visual F# Power Tools の紹介というF# Advent Calendar 2014の11日目の記事です。
VFPTの基本的な便利な機能は元記事を参照いただくとして、このエントリでは更に進んだ使い方等を紹介します。
シンタックスのカラーリングのカスタマイズ
シンタックスのカラーリングはカスタマイズできます。 カスタマイズは、VS自体のオプションの左のペインから環境、フォントおよび色と選択し、 表示項目のF#から始まる項目です。
カスタマイズできる項目は以下の通りです。
- F# Escaped Characters
- F# Functions / Methods
- F# Modules
- F# Mutable Variables / Reference Cells
- F# Patterns
- F# Printf Format
- F# Quotations
- F# Types
- F# Value Types
この中でおすすめなのが、「F# Mutable Variables / Reference Cells」です。
これを設定することで、mutable
な変数やref
型の変数に色が付き、どこで再代入を使っているかが一目瞭然になります。
スコープもちゃんと認識していますね。 ちなみに、setterがあるプロパティなんかも、Mutable Variablesとみなされて色が付きます。
他には、「F# Escaped Characters」と「F# Printf Format」も便利です。
上のコードでも、printf
系の関数のフォーマットに色が付いているのが分かります。
「F# Quotations」も人によっては便利でしょう。
「F# Patterns」は、便利かどうかは微妙ですが、設定してあります。
Some
とNone
に色がついているのや、自動実装プロパティのFailed
に色がついているのが分かります。
自動実装とタスクリストコメント
元記事ではインターフェイスの自動実装と判別共用体ケースの自動実装に触れられていますが、もう一つ、レコードのスタブ生成機能もあります。 これら自動実装系の機能と、タスクリストコメント*1を合わせる技をadacolaさんが呟いています。
Visual F# Power ToolsのCode GenerationをUncompilable Codeにして内容を(* TODO 自動生成のまま未実装 *)みたいにしておくと、タスク一覧で自動生成したままの箇所が一覧できてよさげ
— adaco (@adacola) November 8, 2014
これは是非設定しておくといいでしょう。
failwith "oops!" (* TODO : Not implemented yet *)
このように設定しておけば、(Uncompilable Codeと言いつつ)コンパイル可能で、かつタスク一覧に「TODO : Not implemented yet」と表示されるようになります。
便利!
その他の機能
元記事では触れられていない機能をざっくり紹介します。
ドキュメンテーションコメントの自動生成
///<
までタイプするとドキュメンテーションコメントのひな形を生成してくれます。
地味ですが、ドキュメンテーションコメントを書くときに便利です。
VFPTとは関係ありませんが、F#ではドキュメンテーションコメントの中身がsummaryだけであれば、summaryを省略できるという便利機能があります。
/// とても素晴らしい変数です。 let x = 42
これも知っていると便利なので使っていきましょう。
ソースコードの整形
整形してくれます。あんまり使ったことはありません。
もちろん、VSの整形のショートカットで呼び出せます。 使う人は使う機能かも?
ナビゲーションバー
便利機能です。この機能を有効にするには、VSを管理者権限で立ち上げてから「Navigation bar」のチェックボックスをチェックし、VSを再起動する必要がありますので注意してください*2。
この機能を有効にすると、エディタの上にナビゲーションバーが表れて、選択した型やメンバに簡単にジャンプできるようになります。 上の画像で、「Program」とか「g」とか表示されているのが分かります。
参照箇所のハイライト
便利機能です。カーソルを置いている変数や関数と同じものを参照している箇所をハイライトしてくれます。
インデントガイド/Depth colorizer
便利機能です。F#はインデントに意味のある言語なので、インデントガイドを出してくれると色々と捗ります。
上の画像でも、インデントガイドが確認できます。
NavigateTo
便利機能です。Ctrlキーを押しながらカンマキーを押すと起動できます。 起動したら、型名とか関数名とか適当にタイプしてみてください。そういうことです。
フォルダ機能
最近使ってません。 プロジェクトが巨大になればお世話になることでしょう。
openしていない名前空間の解決
便利機能です。Hoge
を含むモジュールがあった時に、Hoge.piyo
とかやってHoge
までカーソルを持っていくと、スマートタグが表れるのでCtrl+.
で起動してEnter
です。
未使用宣言のグレーアウト
使っていない変数や関数などをグレーアウトしてくれます。 不安定なので現状ではOffにしておくといいでしょう。 次のバージョンではもうちょっと改善される予定です。
未使用openのグレーアウト
使っていないopen
をグレーアウトしてくれます。
不安定なので現状ではOffにしておくといいでしょう。
次のバージョンではもうちょっと改善される予定です。
メタデータに移動
便利機能です。ソースを持っていないコードのシグネチャ等を確認する際、この機能がない時代はオブジェクトブラウザを使ったりしていましたが、F12で確認できるようになりました。
タスクリストコメント
便利機能です。この機能は実装にも関わったので、思い入れもある機能です。 タスク一覧ウィンドウを開いて、(ユーザー タスクではなく)「コメント」を選ぶと、TODOコメントなどが一覧できます。
オプションからカスタマイズ可能で、「環境」、「タスク一覧」と辿ると設定画面になります。 「名前」の欄に「XXX」とか入れて、追加ボタンを押すとトークンリストに追加されます。 このUIは非常にわかりにくいと思うんですが、まぁVS標準の機能だし仕方ない。
VFPTの今後
次のバージョン
次のバージョンでは、細かい点が修正され、目立った新機能は追加されないようです。
- New folderダイアログがリストアされない問題を修正
- 大きなソリューションでの「全ての参照の検索」機能のパフォーマンスを改善
- Goto Metadataで表示されるコードにエラーが表示される問題のいくつかを修正
- これ以上自動生成するものがないスマートタグを表示しないように変更
printf
系関数のフォーマットの色付けをユーザ定義のものまで拡張- ソースが存在する構造体への移動がメタデータへの移動になる問題を修正
今後載るかもしれない機能
コメントの折り畳み機能がもしかしたら載るかもしれないです。
機能追加の要望方法
GithubのIssuesではなく、VFPTのUser Voiceに投稿してください。 Voteもできるので、気になった機能があればVoteするといいでしょう。 幾つかピックアップして紹介します。
クラスビューでのコードナビゲーション
Feaure Request: Code Navigation Using Class View で提案されています。
ナビゲーションバーは一つのファイルであれば便利だけど、クラスビューであればプロジェクト全体をナビゲートできて便利だよね、という提案です。
ローカルで閉じたIDisposableでletを使っていた場合にuseを使うよう提案
Suggest to use use
instead of let
for local IDisposable valuesで提案されています。
IDisposable
を実装した型にlet
を使っていた場合、それをuse
を使うように提案してくれるような機能の提案です。
実装されれば有用そうですが、ローカルで閉じているかどうかのチェックって簡単にできるんでしょうか・・・
式の型の表示
Show type annotation on expressionで提案されています。
Scala-plugin for Intellij ideaで実装されている機能のようですね。 あると便利なのは間違いないでしょう。
推論された型の表示
Display inferred types above declarationで提案されています。
コンパイラによって推論された型を、定義の上に(CodeLensのように)表示する機能の提案です。 コメントでは結構散々な言われようですが、これ、実装されればめちゃくちゃ便利だと思うんですよ。
CodeLens APIが公開されていないようなので、CodeLensっぽくできるかどうかわからないかつ、公開されたとしても関数内関数は結局自作になりそうなので、全部自作してしまえばいいじゃん、と思うんですが果たして。 エディタ上にCodeLensのように行じゃない行(?)を差し込めるのかどうかが問題だと思うんですが、教えてVS拡張に詳しい人!
まとめ
VFPTを使いこなして楽しくF#でプログラミングしましょう!