スコープやオフサイドルール小話

この記事は F# Advent Calendar 2014 の28日目の記事です。 引き続きガス欠気味なので小ネタで。

module Hoge =

  let hoge =
    // lazy もオフサイドルールの対象である
    lazy
      let a = 1
      a + 1

  do
    // スコープが異なるので定義できる
    let hoge = 1

    let mutable x = 0
    
    x <-
      printfn "test"
      1

    // これは無理
    // x <- printfn "test"
    //   1

  // 変数 x は上記 do 束縛のスコープ内のみ有効なので、以下のコードはコンパイルエラー
  // let fuga = x

ところで、 domsdn さん曰く do束縛らしい。

気になったので F# Specification 3.0 を検索したら 8.13.2 にのみ "do bindings" という記述が見つかった。 "let bindings"も同じセクションでしか見つからなかったので、仕様書では bindings という単語をつかわない模様。 モジュール内での do に関しては 10.2.5 で "do statements" と記載されている。

1ファイル内に複数のnamespaceを書く

この記事は F# Advent Calendar 2014 の28日目の記事です。 ガス欠気味なので超小ネタで。

namespace Hoge

type Fuga() = class end

namespace Foo

type Bar() = class end

このコードは1ファイル内でコンパイルできます。 =が必要ないのがミソですね。

入れ子の名前空間にしたい場合は、完全修飾で

namespace Hoge

type Fuga() = class end

namespace Hoge.Foo

type Bar() = class end

と書けばよいです。

F# 製ユニットテスティングフレームワーク Persimmon について #FsAdvent

この記事は F# Advent Calendar 2014 の27日目の記事です。

内容は今年の10月末あたりから作っている Persimmon というフレームワークについてです。

諸注意

本記事に書くことはあくまでpocketberserker個人の見解です。 他の開発者がどう考えているのかは

ソースコードやドキュメントなど

Persimmon

persimmon-projects/Persimmon · GitHub

Getting Started があるので使い方はそっちを参考にしてください。

あと public に書き込める場所がほしかったので Gitter に部屋を作りました。

persimmon-projects/ja - Gitter

柿は何であって何でないのか

F# の、 F# による、 F# のためのテスティングフレームワークです。

コンピュテーション式を前提にしている時点で他の言語で使うことは想定していません。 そして、少なくとも私は極力複数言語をプロダクションコードに採用したくない(メンテナンス面倒)ので、ほぼ F# オンリーでの開発になるでしょう。*1 そういうわけで上記の見解です。

F# でユニットテストというと NUnit (とその Wrapper の FsUnit)が多数派です。 ビルドツールの FAKE にはこのほかに xUnit.NET、 MSpec のヘルパー関数が用意されています。 FsCheck も結構使われていますが、あれは性質が異なるので今回の話では対象外です。

さて、この3つのテスティングフレームワークには共通点があります。 それは C# で実装されている点と、既存テスティングフレームワークの手法を踏襲して実装されている点です。

前者はともかく後者については、

  • 期待値と実行結果の型が異なっても実行時エラーでしか気づけないことがある
  • 属性ベースのテスト識別

という問題があります。 属性に関しては単に面倒くさいのと、渡せる型に制約があって一部F# の型は使えない点ですかね。

特徴と類似品

特徴はドキュメントに書いてあります。 まぁ、それだけでおわらせるのもアレなのでちょっとだけ追加。

副作用

Persimmon では、変数、unit を引数にとる関数、static member なプロパティがテスト実行の対象になります。 変数束縛で済ませるか関数にするかは、そのテストに副作用が含まれるかどうかで判断するのが良いのではと思っています。 unit をみたら副作用があるかもしれないと思え、ということですね。

型安全

NUnit の Assertion は型安全ではなかったりしますが、Persimmon の Assertion はよほど変なことをしない限りは型安全です。

コンピュテーション式

「属性を使わないなら、型で表現すればいいじゃない」

とはいえ、ただ型で表現するだけだと色々と面倒なこともあるので、Persimmon ではコンピュテーション式で記述できるようになっているとか、いないとか。

現時点で、master ブランチに4つのコンピュテーション式が存在します。

  • テストの記述
  • parameterized test の記述
  • 例外発生を期待する
  • Asyncな値を実行し非同期例外を補足する

テスト、アサーションの合成

Persimmon のテストやアサーションは値を返します。 そして、 unit を返すことは他のテストやアサーションに影響を与えないことを宣言することになります。 逆に、テストやアサーションの返り値を使って別のテストやアサーションを行うということは、それらが別のテスト、アサーションの通過/違反に依存していることを明示します。

テストを合成できることで

  • 状態を変更するテストと状態を変更しないテストの分離
  • テストの重複除去

を意識できるのではないか、と考えています。 あとはテストを解析することでテストの依存グラフを出力できるなるはずなので、何かしら役にたたないかなと。 テストを合成できるようにして依存グラフ云々みたいな話は過去に id:kyon-mm さんとも話していた気がするけど、参照実装や資料があるのかはちょっとわからないです…。

Monad ではない(はず)

何らかの制約をかければあるいは…ですが、結合則を証明できる気がしない。

AssertJ

最近になって存在を知った AssertJ というライブラリがあります。 Persimmon 実装中に kyon-mm さんに存在を教えてもらったのでこのライブラリの影響を受けた、というわけではないのですが、やりたいことの一部が似ています。

"継続可能なアサーション" は AssertJ では "Soft Assert"と呼ばれているようです。 この名前を踏襲するかは未定ですが、少なくとも新しい概念ではないのは確かなので、そのうちドキュメントに反映したいところです。

AssertJ の SoftAssertion との違いは

  • AssertJは任意、Persimmonは強制
  • AssertJの実装はstateful、Persimmonの実装はstateless
  • AssertJのAssertionは void だが、PersimmonのAssertionは値を返すことができる

SoftAssertionsが stateful なのはSoftAssertionのAPIドキュメントに書いてあります。 また、 assertAll メソッドを呼び出さないとテストに通るだろうこともこのドキュメントに書いてあります。

これはこれでありだと思いますが、合成できるなら合成したほうが楽かもね、とも思います。

NUnit, xUnit.NET, MSTest

主な違いは

  • 型安全性
  • 属性ベースと型ベースの違い
  • 例外によるハンドリングか値を返すか

です。

.NETの枠組みで既存の代替物となることはないでしょう。 テストとプロダクションコードが別言語でも良いと思っている人は移行する可能性はありますが、C# Wrapper を提供しない時点で C#er の方々が使う確率は減ります。 F#書きづらい(or 嫌い)という人もいるでしょうし。 あくまで、F#でのテストの代替物の可能性があるといいね、というレベルです。

モデル

1.0.0 αリリース以前にモデルが一度変更されています。 どれくらいかわったかというと これくらい

実装前の検討段階の資料はプロジェクトのWikiにありますが、最新版は存在しません。 というわけで、最新版の日本語情報を作ってみました。 ある程度固まったら英語に書き直してから日本語に再翻訳して公式ドキュメントに反映したいと思います。

エラーが AssetionResult<'T>ではなくTestResult<'T>にあるのは、例外発生時は多くの場合テストの続行自体不可能である場合が多いためです。

Visual Studio Test Explorer 対応

1.0.0 をリリースしたら作業を再開する予定です。 リフレクション使って実装しないといけないらしいので既にやる気が減衰していますが、まぁないと不便だし一回作れば楽になるのはわかっているので…冬休みの宿題ですね。

問題点

現時点で問題かなと思っていることをさらっとかいてみます。

assertion が貧弱

現状の Assertion は等値比較と述語の真偽比較くらいしか関数がないので貧弱です。 とはいえ、assertHoge 関数を増やしても覚えるのが面倒ですし、言葉つなぎ系は面倒なのであまり実装したくないなという話もあって今の状況なのですが…。

ぱっと思いつく解決策の一つは Power Assert ですが、この issue を見てもらうとわかる通りPower Assert ではない別の Assert を模索しようという方針になっています。

実践的なサンプルが少ない

これはそのうちアプリケーション作ってみないとなー、とかそういう話ですね。 今の FizzBuzz とポーカーの例だけでは情報量が少ない…。

一部既存ライブラリとの相性が悪い

NUnit や xUnit.NET と連携することを想定しているライブラリは、その性質上 例外を用いた Assertion の通知を行うものが多いです。 そしてこの方法は、Persimmon との相性はあまりよくありません。

こればかりはどうしようもないので、「ないなら作るしかねぇ」的な方針にせざるを得ないですね。 ぶっちゃけ FsCheck がそんな感じで相性が良いとは言えないので、 Persimmon 用に作ることを画策しています。

開発体制

今のところ3名で開発が行われています。

  • Gab-km さん
    • 原案発案
    • 怒涛のドキュメントレビュー(超重要)
    • 某ライブラリの開発があるため、現状Persimmonのコードを変更することは稀?
  • bleis-tift さん
    • 改良案発案
    • 新モデルの構築者
    • 忙しくて触れないことがあるようだ
  • pocketberserker
    • その他
    • 上記2名が動けない時に動く役

今のところバランスは保てていますが、今後開発速度をあげたいなら人手がほしいところ。

プロジェクト名の由来

かいつまんで書くと

  • 某社内チャットでプロジェクト名を募集
  • 「秋だから柿か栗では?」
  • とりあえず柿と栗の英単語を調べよう
  • Persimmon、よさそう
  • 「秋にちなんだ名前ってことは秋中に動くようにするんだよね?」「えっ?」

なぜ FAKE を使わないのか

趣味の問題です。

おわりに

1月までには TestExplorer 拡張を含めてリリースしたいところ…。

*1:GUIやパフォーマンスを考える場合はその限りではないです

FsCheckのこまごまとしたこと #FsAdvent

全国のF#erの皆様こんにちは、もみあげことなかやんぺんぎんです。

ここからは F# Advent Calendar 2014の延長戦(25日目)です。 英語圏と張り合ってるわけじゃ、ないんだからね!?

今回は今年めでたく正式版がリリースされた FsCheck についてちょっとした変更点などの紹介です。

利用実績

名古屋某社のいくつかのプロジェクトで利用実績があります。 OSS での利用例は…たくさんあるので割愛します。

FsCheck の target .NET Framework verison

FsCheck は Release Note 的には 1.0.0 より(実際は 0.9.3 あたりからだった気がする)、.NET Framework 4.5 以降のみをサポートするようになりました。 これは、 コード内で使われている ExceptionDispatchInfo が 4.5 以降にしかない機能だからです。 まぁ、テストプロジェクトのバージョンが指定されることはほとんどないので、レガシー環境でない限りはそんなに気にならないかと。

string generator の挙動

0.9.3 までは、Default で提供されている string generator は null を入力として含みませんでしたが、 0.9.4 からは null も生成対象になりました。

これはこれで正しいと思う一方で、面倒くさい null を生成しない generator がほしいという意見もちらほら見かけました。

そこで、 1.0.4 からは NonNull 型を導入することで、null を含まない ランダムな値の生成が可能になりました。 たとえば string なら

  check <| fun (s: NonNull<string>) ->
    let s = s.Get // 値の取得
    ...

こんな感じにすればよいです。 いちいち束縛するのが面倒な場合はアクティブパターンでいい感じにするとはかどるかもしれません。

let (|NonNullStr|) (s: NonNull<string>) = s.Get

...

  // s は string
  check <| fun (NonNullStr s) ->
    ...

IRunner と 拡張ライブラリ

他テスティングフレームワークと連携する方法としては、0.9系までは IRunner を実装する一択だったわけですが、 1.0.0 以降は FsCheck.NUnit や FsCheck.Xunit という拡張ライブラリが登場したため、簡単に使いたいならこれらのライブラリを使うほうが楽です。

とはいえ、出力の制御をおこないたい場合などはやっぱりIRunner を実装する必要があるのでお役御免というわけではないです。

FsCheck.NUnit と FsCheck.Xunit

NUnit と Xunit は公式から拡張ライブラリが公開されています。

このライブラリを入れることでどのように記述がかわるのかは…具体例を見てみましょう。

https://github.com/pocketberserker/FsAttoparsec/commit/5b8861f95ebdadf8a57b4c8cb8402788b8be9f59

FsCheckAddin 型の部分が FsCheck.NUnit をインストールした際についてくるコードです*1

上記コードで目立つ変更点としては、 NUnitTestAttribute から FsCheck.NUnitPropertyAttribute に変わっているところでしょうか。 PropertyAttribute を使うことで、テストごとの設定を変更できたり、ラムダ式を使わずにテストできます。 変更できる値は

  • MaxTest
  • MaxFail
  • StartSize
  • EndSize
  • Verbose
  • QuietOnSuccess
  • Arbitrary

です。各設定の意味については公式ドキュメントを読んでください。

FsCheck.Xunit のみの機能として、 ArbitraryAttribute が存在します。 こいつはクラスやモジュールをターゲットに、 Arbitrary を指定できるものです。 NUnit 拡張にはこの機能はないので、いちいちメソッドに Arbitrary を書くか、 Arb.register するしかありません(後者はグローバル汚染しちゃうけど…)。

2014/12/24 時点での NUnit の注意事項

完全に余談ですが、2014/12/17 に NUnit 2.6.4 がリリースされました。

が、対応する NUnit Test Adapter(Visual Studio拡張)がまだなく、かつ FsCheck.NUnit との相性が悪いのか、VS Test Explorer 上でテストが実行できません。

でも NUnit.Runners 2.6.4 に同梱されている nunit-console なら実行できるだろうと慢心していましたが、こちらはこちらで "nunit-core-interface 2.6.3 をよこせ"と言って落ちる…。

というわけで、いろいろ環境が追いつくまでは大人しく 2.6.3 を使いましょう…。

CSharp Wrapper

あるらしいですが、使ったことがないので使用感がわからず…そのうち試してみたいところです。

MSTest 用の拡張もあるらしいです。

ツールや環境整備

1.0.0 リリースのタイミングで整備されました。 具体的には

  • FAKE を使って自動リリース
  • FSharo.Formatting によるドキュメント作成
  • non Windows 環境での mono でのビルド
  • CIサービス(AppVeyor, Travis)との連携

です。 最近の オープンソース F# プロジェクトはだいたい正式リリースまでの間にこの辺りを用意するみたいですね。*2

その他

README の一説に以下の記述があります。

Since v0.5, scalacheck has influenced FsCheck as well. Scalacheck is itself a port of QuickCheck to Scala.

FsCheck はだいたい QuickCheck と scalacheck の影響を受けている、と。 出現時期がわかる一文ですね。

おわりに

良い property based test ライフを!

*1:本来は別のファイルに入っているのですが、FsAttoparsecでは移動させました

*2:私は FAKE は使わないことにしていますが…

FsAttoparsecについて

この記事は F# Advent Calendar 2014の16日目の記事兼ML Advent Calendar 2014の16日目の記事になります。

今日はF# のネタリストの中から、FsAttoparsecについてです。

attoparsec とは

attoparsec は Haskell のパーサコンビネータライブラリの一つです。 CPSとインライン展開を用いることで高速化を図っていることが特徴です。 あと実装がそこそこ小さいです。

速度を重視する代わりに犠牲となっているものは、エラー出力です。 まぁ、仕方ないですよね。

なお、attoparsec の現在の実装はこの記事 曰く第3世代だそうです。

FsAttoparsec とは

F# 移植版(+ちょっとした機能追加) attoparsec です。 本家とは違い、今のところ高速とは名乗っていません(実際遅い)。

実装理由

F# のパーサコンビネータライブラリ事情 - pocketberserkerの爆走

この記事にも書いたのですが、FParsec は速いけど文字列のみ、ParsecClone はバイナリに主眼をおいているけどシグネチャがよみづらいし実装間違えると例外が投げられるのがつらいという現実がありました。

というわけなので、上記2つ以外にも候補があってもいいよねと思い、ちょうど現実逃避もしたかったし CPS クンカクンカしたかったので実装しました。

特徴や欠点

  • pure F# implementation
  • 小さな実装
  • トークンパーサ
  • パフォーマンスへの影響
  • 開発者が日本人

pure F# implementation

F# オンリーで FParsec に追いつきたいという意地。

小さな実装

全実装行数がFParsecの1ファイル分だったりするという…FParsecが巨大なだけですが。

トークンパーサ

本家 attoparsec には存在しないのですが、FsAttoparsec には Parsec の TokenParser も実装されています。

パフォーマンスへの影響

F# で GHC 拡張の RankNTypes をエミュレーションするにはメソッドベースで CPS にしなければならず、メソッドを実装するためにはクラスベースにしなければなりません。 このためインターフェースを利用せざるを得なくなり、仮想テーブルの影響でパフォーマンスに影響が出るのが一つ目の問題です。

メソッドはビルド環境設定によっては最適化されないので、スタックオーバーフローを起こします それを回避するために trampoline を使っているのですが…当然のごとくパフォーマンスに影響します。

開発者が日本人

母国語で実装についてきけるのは楽なのではないでしょーか!

むしろ英語で訊かれても答えられない可能性…。

利用実績

F#erが多く在籍している名古屋の会社のどこかで使われているらしいです。

サンプル

テストコード内にLTSVパーサの例が、ベンチマークプロジェクト内にJsonパーサの例があります。

あとは Haskell のコードを参考にするとか、ですかね。

実際どのパーサコンビネータライブラリがいいの?

ここで、とある方の意見を見てみましょう。

これは、文字列解析に関して言えば正しいと思います。 利用実績も多いでしょうし、速度も申し分ないので。

他のデータ構造に関してはどうですかね… ParsecClone も FsAttoparsec も一長一短あるので何とも言えません。

参考資料など

attoparsec の知識がそれなりにそのまま使えます。

このあたりは参考になるかもしれません。

移植にどのくらいかかるのか

FsAttoparsecの移植はコードリーディングからバグ修正まで含めて2週間くらいかかりました。

知っておくべき知識は(速度を求めないなら)

  • CPS (call/cc や shift/reset はわからなくてもよい)
  • Monoid
  • rankNtypes
  • 入力データ構造の各種関数
  • ちょっとしたHaskellコードリーディング力

と、多くもなく難しいわけでもないので、わりとどうにかなるのではないでしょうか。 速度を求めなければ。

OCaml は確かモジュールを使えば rankNtype はなんとかできたはずですし、わりと問題なく実装できそうな気がしますね。

まとめ

attoparsec の別言語への移植は CPS と パーサコンビネータ実装の練習にちょうど良いと思うのでした。

"コンピュテーションビルダーに機能を後付けする"の補足?

アドベントカレンダーとその補足記事で色々と賑わっていて素晴らしいですね。 と、前置きはここまでにしておいて。

コンピュテーション式の変形後を覗き見るを改良する - ぐるぐる~

さて、この記事に下記の記述があります。

さて、コンピュテーションビルダーに対する機能の追加ですが、方法としては以下のものがあるでしょう。

  1. コンピュテーションビルダーの書き換え
  2. 既存のコンピュテーションビルダーを継承して機能を追加
  3. 型拡張として機能を追加
  4. 拡張メソッドとして機能を追加

ここでは4つ紹介されていますが、実はもう一つ方法があります。

コンピュテーションビルダーをラップしたコンピュテーションビルダーを作る

asyncquery といった builder-expr はビルダーオブジェクトが変数束縛されているだけの、ごく普通の存在です。*1 なので、ビルダーの持つメソッドを呼び出すことが可能です。

これを利用してビルダーをラップすることで、後付っぽいことを行います。

試しに、 SimpleBuilder をラップしてみましょう。

// 既存の SimpleBuilder
type SimpleBuilder () =
  member __.Return(x) = x
  member __.Bind(x, f) = f x

let simple = SimpleBuilder ()

module Print =

  type SimpleBuilder () =
    member this.Return(x) =
      (sprintf "$b0.Return(x)", simple.Return(x))
    member this.Bind(x, f) =
      let trace, res = simple.Bind(x, f)
      sprintf "$b0.Bind(%A, fun x -> %s)" x trace, res
    member this.Delay(f) = f
    member this.Run(f) =
      let trace,res = f ()
      printf "let $b0: SimpleBuilder = simple\n%s" trace
      res

  let simple = SimpleBuilder()

// モジュールをオープンすればラッパーのほうに切り替わる
open Print

let res = simple {
  let! x = 10
  return x
}

出力は以下の通りです。

let $b0: SimpleBuilder = simple
$b0.Bind(10, fun x -> $b0.Return(x))

使いどころ

使い道は現時点で3つくらい考えられます。

作ろうと思ったメソッドが既に存在する

同じシグネチャを持つメソッドを型拡張や拡張メソッドで後付することはできません。 この場合は、ソースコードを入手できない場合はラップする以外の選択肢がありません。

式木は重過ぎるけど、介入はしたい

コンピュテーション式にデバッグロガーを仕込みたい場合などの、各メソッド適用後の値に介入したい場合に使います。

適用前に介入したい場合は Source という手もありますが、あれは特定の構文にしか現れないので、汎用性に乏しいです。 とはいえ、ラップするより型拡張のほうが楽なのは確かです。

ユーザに既存ビルダーの一部機能を使わせたくない

「このビルダー、この実装以外はいいのに!」という状況や、Source メソッドに介入されてうまくコンパイルできない場合などに使えます。

おすすめ利用順序

機能の後付方法として、個人的なおすすめは

  1. 型拡張
  2. ラップ
  3. コンピュテーションビルダーの書き換え

にの順になります。

型拡張はやっぱり楽なのと、モジュールを分けておけば既存のものを壊さずに済むので、可能であればこれで済ませたいです。

その次はラップです。 多少面倒くさいとはいえ、自由度に機能を追加できます。

ビルダー自体を書き換えるのは、破壊的変更を生み出しかねないので慎重になるべきだと考えています。

継承はあまりお勧めしません。 コンピュテーションビルダーのメソッドがオーバーライドできる可能性は低いからです。

まとめ

たとえ標準の async だろうと、介入方法はいくらでもあるよというのが言いたかっただけです(ただしドキュメントの仕様から外れるリスクもあります)。

*1:seq だけは例外で、あれはシグネチャを見るとわかるとおり関数です

Async小話(末尾再帰関数)

過去にTomas Petricekさんの非同期に関する記事を翻訳しました。

Async in C# and F#: Asynchronous gotchas in C# (Japanese translation)

この記事の最後には以下の記述が存在します。

また、F# の async も問題を持っています(最も一般的な落とし穴は、末尾再帰関数はリークを避けるために do! の代わりに return! を使うべきだということです)。

まずは試してみよう

簡単なサンプルコードで試してみましょう。

open System

let rec loop x = async {
  do!
    if x < 1 then async { return () }
    else loop x
  }

loop Int32.MaxValue |> Async.Start

このコードはおそらく OutOfMemoryException で落ちます。

open System

let rec loop x = async {
  return!
    if x < 1 then async { return () }
    else loop x
  }

loop Int32.MaxValue |> Async.Start

こちらはメモリ溢れすくこともなく動作し続けると思います。 fsi で実行している場合は fsi.exe の CPU 使用率を見ればなんとなく雰囲気はつかめるかと。

資料を調べる

残念ながら、The final version of the F# 3.0 language specification(pdf) にはそれらしい記述は見当たりません。

しかし、 The F# Asynchronous Programming Model(pdf) の3ページに以下の記述があります。

aexpr :=
  | do! expr                         execute async
  | let! pat = expr in aexpr         execute & bind async
  | let pat = expr in aexpr          execute & bind expression
  | return! expr                     tailcall to async
  | return expr                      return result of async expression
  | aexpr; aexpr                     sequential composition
  | if expr then aexpr else aexpr    conditional on expression
  | match expr with pat -> aexpr     match expression
  | while expr do aexpr              asynchronous loop on synchronous guard
  | for pat in expr do aexpr         asynchronous loop on synchronous list
  | use val = expr in aexpr          execute & bind & dispose expression
  | use! val = expr in aexpr         execute & bind & dispose async
  | try aexpr with pat -> aexpr      asynchronous exception handling
  | try aexpr finally expr           asynchronous compensation
  | expr                             execute expression for side effects

return! の説明に末尾呼び出しする説明が書かれています。

つまりどういうこと?

ここからは推測になります。

今回関係ありそうなコンピュテーション式の変換規則は以下の通りです(詳細は Specification を読んでください)。

T(return! e, V, C, q) = C(b.ReturnFrom(src(e)))
T(let! p = e in ce, V, C, q) = T(ce, V + var(p), λv.C(b.Bind(src(e),fun p -> v), q)
T(do! e;, V, C, q) = T(let! () = src(e) in b.Return(), V, C, q)

直和記号はプラスで代用しています。

do! 系統は let! の規則に変換され、let! では Bind メソッドを呼び出す形になっています。 おそらくこのあたりがスタック溢れにつながっているのではないかと。

つまり、これは別に Async だけでなく他のコンピュテーション式にも同じことが言えるのでは…?

他のコンピュテーション式でも発生するかどうか

というわけで、いつもあなたの隣に這いよる Option でも試してみましょう。

type OptionBuilder() =
  member __.Bind(x, f) = Option.bind f x
  member __.Return(x) = Some x
  member __.ReturnFrom(x) =x

let option = OptionBuilder()

let rec doLoopOption x = option {
  do!
    if x < 1 then option { return () }
    else doLoopOption (x - 1)
}

let rec returnLoopOption x = option {
  return!
    if x < 1 then option { return () }
    else returnLoopOption (x - 1)
}

結果は Async と同様の状況になりました。

まとめ?

ビルダーの実装をかなり工夫しないと(しても?)メモリを食いつくす問題は回避できない可能性があるので、末尾再帰関数でコンピュテーション式を使う場合はおとなしく return! を使いましょう。

余談

空の Async を作りたい場合ってどうしていますか?(あまりないかもしれないですが)

私は

let zero: Async<unit> = async.Zero()
let returnA x = async { return x }

を用意したりします。