matarillo.com

The best days are ahead of us.

9.8 F# 1.0 -- 機能コアの改善:コンピュテーション式

2021-09-25 00:00:21

F# のコンピュテーション式記法は「async」から始まりましたが、その他の重要な背景として、Haskellのリスト内包構文[Haskell Contributors 2019]、Haskellの「do記法」[Haskell Contributors 2020a]、Haskellの実験的な「アロー」構文[Paterson 2020]がありました。 Haskellではこれらは別々の構文メカニズムです。 その他の背景にはC# のLINQ式[Microsoft 2019]がありますし、多くの定理証明システムが汎用的な表記法の拡張機構を持っていました。

そこには重要な方法論の違いがありました。 従来のHaskellの方法論では、意味的に関心のあるオブジェクト(例えば「モナド」ですが、これは構文上ではなく意味上の話です)を特定し、これらを可能な限り近い形で特徴づけられる意味的な公理を含むことに重点を置いていました。 Haskellでは、これらのオブジェクトの操作に対応する型クラスと、その型クラスのインスタンスに期待される(チェックされない)等式の特性が定義されました。 最後に、任意の型クラスのインスタンスで使用可能な記法(do記法など)が追加されました。 この進め方では、意味解析が優先され、「表現力」は言語の表記法ではなく、研究対象の意味論に焦点が当てられました。

この進め方に従わない重要な例外に、WadlerとPeyton Jonesの論文「包括的な内包」[Peyton Jones and Wadler 2007]がありました。 この論文は「内包に表現力を加える」、つまり、リストのセマンティクスではなく表記法に力を与えるものでした。 そして、表記法自体が真の改善の対象となりうることを私の胸に刻みました。 この論文(および2004年から2010年にかけてMicrosoft ResearchのPeyton Jonesと私が1対1で行った議論)は、F# のコンピュテーション式の開発と、それがカバーすべき表現の範囲に大きな影響を与えました。 記法的な表現力がプログラミング言語設計における重要な議論の対象とみなされるかどうかは重要な問題ですが、F#を生み出した学術的な伝統では、この話題をほとんど敬遠しています。 私自身のプログラマーとしての経験から、記法的な表現力は人間の使いやすさという観点で非常に重要であると感じていました。 特に、他の人間工学的な問題、たとえばコードを「非モナディック」から「モナディック」へ(例えば同期から非同期へ)変換する際の容易さなどと合わせて考えるとなおさらでした。 このようなコード変換のしやすさは、F# の非同期プログラミングの設計理念やトレーニング教材の重要な部分を占めています – 例えば、Luca Bologneseの2008年のF#に関する好評を博したプレゼンテーション[Bolognese 2008]などを参照してください。 移行を容易にするために、F# は通常のコードと、リスト内包とや非同期コードの間の表記上の類似性を強調しています。 一方、C# のLINQ表記、Haskellのリスト内包、Haskellのdo記法は、表記上の違いを強調したデザインになっています。 これらは、それぞれのホスト言語と比較して、明らかに異なる表記形式になるように設計されています。

技術的な観点からは、F# のコンピュテーション式は言語要素の構文的な脱糖として規定されています[Syme 2020]。 この脱糖は、builder { ... } 式全体に対して行われます。 この builder とは、例えば、seqasyncのことで、これらは、後述する特定のメソッドを持つオブジェクトに結び付けられます。 式の本文では、次のようなF# /OCamlの制御構文要素を使用できます。

let! 𝑝𝑎𝑡 = 𝑒1 in 𝑒2            (Bindメソッドが必要)
for 𝑝𝑎𝑡 in 𝑒0 do 𝑒1            (Forメソッドが必要)
while 𝑒1 do 𝑒2                 (Whileメソッドが必要)
𝑒1; 𝑒2                         (Combineメソッドが必要)
try 𝑒1 with exn -> 𝑒2          (TryWithメソッドが必要)
try 𝑒1 finally 𝑒2              (TryFinallyメソッドが必要)
return 𝑒                       (Returnメソッドが必要)
return! 𝑒                      (ReturnFromメソッドが必要)
yield 𝑒                        (Yieldメソッドが必要)
𝑒                              (暗黙のyield, Yieldメソッドが必要)
yield! 𝑒                       (YieldFromメソッドが必要)
let 𝑝𝑎𝑡 = 𝑒1 in 𝑒2             (常に有効)
match 𝑒0 with 𝑝𝑎𝑡1 -> 𝑒1 | ... (常に有効)
if 𝑒1 then 𝑒2 else 𝑒3          (常に有効)
if 𝑒1 then 𝑒2                  (空の分岐にZeroメソッドが必要)

それぞれの構文要素は、対応する指定されたオブジェクトメソッドをビルダーがサポートすることで有効になります(let, match, if ... then ... else は常に有効です)。 特定のビルダーは、これらのメソッドのいずれかをサポートするだけでもいいですし、すべてをサポートすることもできます。 メソッドの名前は重要です。なぜなら、異なるメソッド名はソースの異なる構文に対応するからです。 これらの構成体の完全な脱糖規則は[Syme 2020]にあります。例を挙げると、

let! 𝑝𝑎𝑡 = 𝑒1 in 𝑒2  は builder.Bind(𝑒1, fun 𝑝𝑎𝑡 -> 𝑒2) に脱糖されます
𝑒1; 𝑒2               は builder.Combine(𝑒1, 𝑒2)         に脱糖されます

さらにいくつかの例を次に示します1。 F# のコンピュテーション式の各ビルダーは、通常、適切なメソッドを提供することで、モナディック構文または内包構文のどちらかとして使用することを意図しています。 前者の場合、通常はビルダーに次のメンバーが必要です。

member Bind: M<'T> * ('T -> M<'U>) -> M<'U>
member Return: 'T -> M<'T>

Bindメソッドがあるということは、構文にlet!が許されているということなので、このコンピュテーション式ではモナドのバインディングにlet!を使います。 Haskellに慣れている人にとっては、これは「モナド」インスタンスのための構文を提供していると考えるのが自然でしょう。 Haskellの用語がどのようにマッピングされているかを次に示します。

  • モナドの >>= (bind) メソッドは let! 構文に対応していて、 Bind メソッドにマップされます。
  • モナドの return メソッドは return x 構文に対応していて、 Return メソッドにマップされます。

これによって、非同期やその他のモナディックなコードは前節に示した通りに脱糖されます。 構文を豊かにする追加要素もあります。たとえば、try/with, try/finally, while, for などの構文を使うことも可能になります。

F# のコンピュテーション式は、内包構文を使えるようにする目的もあります。その場合は、演算の最小のシグネチャーは典型的にはこのような形になります。

member For: M<'T> * ('T -> M<'U>) -> M<'U>
member Combine: M<'T> * M<'T> -> M<'T>
member Yield: 'T -> M<'T>
member Zero: M<'T>

「内包」の名を保証するために最低限必要なのは、ForYieldです。 これらには For メソッドが含まれていることに注意してください。これは for が構文の中で許可されていることを意味しています。 このコンピュテーション式はバインディングに let!使いません 。 計算構造はモナド(let!)ではなく、内包(for)として扱われます。 これにより、F#では、リストを反復して結果を生成する処理(命令的)と、リストを反復して結果を結合するリスト内包(関数的)との間で、表記上の類似性を実現しています。 例を次に示します。

Haskellに慣れている人にとっては、これはMonadPlus[Haskell Contributors 2020b]インスタンスに対応する記法を提供していると考えるのが自然です。 Haskellの用語は次のように対応しています。

  • MonadPlusの >>= (bind) は for 構文に対応し、For メソッドに脱糖されます。
  • MonadPlusの mplus は、シーケンシャルな合成である 𝑒1; 𝑒2 記法(もしくはセミコロン抜きで並べられたコード行)に対応し、 Combine メソッドに脱糖されます。
  • MonadPlusの returnyield 構文に対応し、 Yield メソッドに脱糖されます。
  • MonadPlusの mzero は暗黙的です。すなわち、if/then の空の else 分岐が Zero メソッドに脱糖されます。

例えば、"seq“コンピュテーション式のメカニズムを使った、(オンデマンドの)シーケンス内包を考えてみましょう。

seq {
    let data = [ 1 .. 5 ]
    yield "zero"
    for a in data do
        match a with
        | 2 -> yield a.ToString()
        | 3 -> yield "hello"; yield "world"
        | _ -> ()
}

記法的には次の命令型のコードと一致することに注目してください。

let data = [ 1 .. 5 ]
printfn "zero"
for a in data do
    match a with
    | 2 -> printfn "%d" a
    | 3 -> printfn "hello"; printfn "world"
    | _ -> ()

命令型の副作用から関数型のデータ生成に変更するには、seq { ... }を追加し、I/O操作のprintfnyieldに置き換えただけです(yieldは暗黙的でも構いません)。 データを生成するコードは以下のように脱糖されます。

let data = [ 1 .. 5 ]
seq.Combine(
    seq.Yield "zero",
    seq.For(data, (fun a ->
        match a with
        | 3 ->
            seq.Combine(
                seq.Yield "hello",
                seq.Yield "world")
        | 2 ->
            seq.Yield (a.ToString())
        | _ ->
            seq.Zero ())))

これは、結果として次のシーケンスに評価されます。

[ "zero"; "2"; "hello"; "world" ] // 訳注:原文の評価結果を修正しています。

F# コンパイラーでは、これをステートマシンに落とし込むための最適化フェーズが適用されます。 2つ目の例は、Haskellのリスト内包に相当するF# コードです。

[ N | x <- L; y <- M ]

に相当するのは次のコードです。

seq { for x in L do
          for y in M do
              yield N }

この yield を暗黙的にすることもできて、その場合は次のようになります。

seq { for x in L do for y in M do N }

どちらの場合も、次のコードに脱糖されます。

seq.For(L, (fun x -> seq.For(M, (fun y -> seq.Yield(N)))))

F# では1つのコンピュテーション式として書くことができるけれども、Haskellの内包やdo式として書くとネスティングが必要になることがよくあります。 例えば、次のようなF# のコンピュテーション式を考えてみましょう。

seq { 3
      for x in xs do (x+1)
      4
      for x in xs do (x+2); 5 }

Haskellではこのようになるでしょう。

[ 3 ] ++ [ x+1 | x <- xs ] ++ [ 4 ] ++ [ y | x <- xs, y <- [ x+2, 5 ] ]

複数回ネストした記法のインスタンスが必要になっています。

F#のコンピュテーション式は他の方法で構成することも可能で、実際にWebプログラミングのDSLや非同期シーケンスなどで行われています。 コンピュテーション式を利用できる様々な制御構造の属は、最終的にPetricekとSymeによって特徴付けられ[Petricek and Syme 2014]、2010年にはJoinadsと呼ばれるパターンマッチングを実験的にモナディックに一般化したもの開発されました[Petricek and Syme 2011]。 アプリカティブの記法拡張[McBride and Paterson 2008]は、2020年にF#に追加される予定です。 F#へのコンピュテーション式の導入は、言語の表記的な表現力を大きく向上させ、その仕組みは現在のF#で広く使われています。


インデックスへ戻る


  1. ここでは簡単にするために、コンピュテーション式のある側面を省略しています。つまり、正格評価のセマンティクスに従うことを保証するための遅延計算の挿入(オプション)です。これらは[Syme 2020]で扱われています。 ↩︎