F#の設計と妥協点

.NET上で実現する関数型パラダイム

書籍『関数型ドメインモデリング』

  • ドメイン駆動設計と F# でソフトウェアの複雑さに立ち向かおう
  • せっかくなので、F#にも興味を持ってもらいたい

Agenda

  • Part 1: 非純粋関数型言語としてのF#
  • Part 2: コンピュテーション式と非同期処理
  • Part 3: 型システムの制約とSRTP

Part 1: 非純粋関数型言語としてのF#

  • F#の歴史的背景と位置づけ
  • F#の基本思想

F#の生みの親

F#とは

  • .NETをターゲットプラットフォームとする関数型プログラミング言語
  • ML(OCaml)に大きな影響を受けている
  • 金融工学、データサイエンス、科学計算、Webアプリケーションなどで活用

F#の歴史

  • 2005年、Microsoftのリサーチプロジェクトとして誕生
  • 2010年、商用プロダクト(Visual Studio)に組み込まれた
    • エンタープライズシステムでの関数型パラダイム導入における現実的選択肢
  • 同じく2010年、OSS化&クロスプラットフォーム化
  • 2014年、F# Software Foundation設立
  • 2016年、.NET自体もOSS化&クロスプラットフォーム化
  • 2020年以降は年1回ペースでメジャーリリース

F#の基本思想

  1. 非純粋関数型であること
    • 関数型プログラミングとして純粋性やイミュータビリティを推奨する一方で、副作用や状態変化を伴う命令型プログラミングの記述も許容する
  2. ホスト環境との相互運用性
    • .NET自体、共通言語基盤の実装という位置づけ
    • F#の設計目標は「双方向の相互運用」
  3. マルチパラダイム言語
    • 関数型プログラミングのパラダイムを核に、オブジェクト指向プログラミングの要素を取り入れる

F#の基本思想 1. 非純粋関数型であること

非純粋関数型

  • デフォルトはイミュータブル
    • 基本的な型の値 (基本型、タプル、レコード、連結リスト)
    • F#で定義したクラスや構造体
    • let による値の束縛
  • mutableキーワードを使用することで、変更可能な値を明示的に定義できる
    • mutable フィールド
    • let mutable による値の束縛
  • 状態を持つ型もある
    • 配列、参照セル
    • .NET 標準のコレクション、その他 .NET の型

非純粋関数型

  • あえて命令型スタイルでループする例
// ミュータブルな値
let mutable count = 0

while count < 5 do
    printfn "現在のカウント: %d" count
    count <- count + 1 // 値を変更

printfn "ループ終了。最終カウント: %d" count
  • whileの中でbreakやcontinueはできない
    • 再帰関数で書く方がよい

副作用が型シグネチャに現れない

  • 副作用(状態変更やI/O処理)を含む処理も だが、型に現れない
  • Haskellの IOST のような型は存在しない
  • F# だけでなく、多くの関数型プログラミング言語も同様

副作用が型シグネチャに現れない

// 状態変更とコンソール出力を含む関数
let mutable globalCounter = 0
let incrementCounter x =
    globalCounter <- globalCounter + 1
    printfn "Call count: %d" globalCounter
    x + globalCounter
// 型: int -> int
// ファイルIOを含む関数
let saveAndAdd filename x y =
    let result = x + y
    File.WriteAllText(filename, string result)
    result
// 型: string -> int -> int -> int

副作用が型シグネチャに現れない

  • あるコードが副作用を持つかどうかを厳密にチェック・強制する道具がない
  • イミュータビリティを強く推奨する道具を使い、コードの純粋な部分をできる限り広げていくことが好まれる
  • 段階的に関数型パラダイムを導入したい開発者にとって、現実的な選択肢のひとつ

※ 言語レベルでなくても、副作用を型で表現して分離することは可能 ⇒ 発展的な話題

F#の基本思想 2. ホスト環境との相互運用性

.NETランタイムは仮想マシン型

  • Java VMと.NETのランタイムは似ているが、違う点もある
  • .NETの特徴
    • 言語中立性を非常に強く意識
      • 共通言語基盤:共通中間言語・共通型システム・共通言語仕様
    • ユーザー定義参照型とユーザー定義値型の両方をサポート
      • (参考:ポインタなどのネイティブ相互運用性をCLIレベルで定義)

ホスト環境との相互運用性

  • F#の設計思想
    • .NET言語やライブラリと「相互に」運用できる最良の関数型言語
    • F#で書かれたコードは、他の.NET言語から自然かつ安全に利用可能
    • マルチパラダイム(後述)
    • 商用開発の現場で他言語と共存できる
    • .NET言語であること ≫ OCaml互換であること

ホスト環境との相互運用性

  • 共通中間言語上の表現の安定性、バイナリー互換性
    • 基本型、関数、タプル、レコード、共用体、モジュールなど
  • ジェネリクスを含む型抽象のレベルも同一
  • F#の型は.NETの標準的なインターフェースを実装
  • 名前変換の最小化

F#の基本思想 3. マルチパラダイム言語

マルチパラダイム言語

  • 関数型プログラミングのパラダイムを核に、オブジェクト指向プログラミングの要素を取り入れる
  • クラスやインターフェース以外も、C#やVB.NETで書けることは「ほとんど」F#でも書ける
  • "The Early History of F#" の中で Don Symeは、F#の設計判断を4種類に分類している

参考:OOPに対する4種類の設計判断(F#の妥協点)

1. 取り入れたい機能
2. 必要なら仕方ない機能
  • ドット記法 ( x.Length )
  • 型に基づく名前解決
  • インスタンスメンバー、静的メンバー
  • プライマリーコンストラクター
  • インデクサー記法 ( arr[x] )
  • 名前付き引数、オプション引数
  • インターフェース型とその実装
  • ミュータブルなデータ
  • 型に対する演算子の定義
  • 自動プロパティ
  • IDisposableIEnumerable の実装
  • 型拡張
  • 構造体、列挙体、イベント、デリゲート
  • キャスト

参考:OOPに対する4種類の設計判断(F#の妥協点)

  1. できれば避けたい機能
  • 多段の型階層
  • 実装の継承
  • null と デフォルト値
  1. 意図的にサポートしない機能:
  • protected メンバー
  • カリー化されたメソッド
  • 自己型(self types)
  • ワイルドカード型
  • アスペクト指向プログラミング

F#における型チェック(F#の妥協点)

関数型プログラミングの範囲では、Hindley-Milner型推論が効く

// a' list -> (a' * a') option
let firstTwo list = 
    match list with
    | x :: y :: _ -> Some (x, y)
    | _           -> None
// 'a * 'b -> 'b * 'a
let swap (x, y) = (y, x)

オブジェクト指向の範囲では、型に基づく名前解決になる

// コンパイルエラー: レシーバーの型が不明
let getLength x = x.Length
let lambda = fun x -> x.Length
// 型注釈をつければOK
let getLength (x: string) = x.Length
let lambda = fun (x: string) -> x.Length
// コンパイルエラー: 引数の型が不明
let parse x = System.Int32.Parse(x)

パイプライン演算子 (|>) の活用

ラムダ式の引数に型注釈が不要になる(こともある)

["apple"; "banana"; "cherry"]
|> List.filter (fun s -> s.Contains("a"))
|> List.map _.ToCharArray() // 省略記法
|> List.concat
someStringList
|> List.distinctBy (fun s -> s.)  // 文字列のメンバーが補完される

Part 2: コンピュテーション式と非同期

モナド注意報

  • すみません、なるべく短くすませます

モナド

  • 関数型プログラミングの頻出パターン
  • 計算にともなって引き継がれる、値以外の情報や振る舞い ⇒ 文脈
  • 文脈依存の計算を純粋な関数型プログラミングの枠組みで扱いたい
  • コンテナーとなるデータ型とそれに関連付けられた関数とのセットで、「前の計算の結果によって次の計算のふるまいを変える」ことを表現できる
  • 命令型的な「実行順序」を純粋関数の合成で作れる

モナドは頻出パターンなんだけど……

  • 「関連付けられた関数」 が目立つ(かも)
  • コードのネストがどんどん深くなる(かも)
// 説明のための 誇張しすぎたコード
const result =
  bind(ofMaybe("123"), str1 =>
    bind(safeParseInt(str1), num1 =>
      bind(safeDivide(num1, 100), num2 =>
        safeToString(num2)
      )
    )
  )
return result

専用の構文があると便利

  • ボイラープレートを減らす
  • 見た目を平坦にして、コードを縦に整列できる
  • 値のバインドを明確にできる

いくつかの関数型言語における、モナド用の構文

  • Haskelldo記法
do
  x <- m1
  y <- m2 x
  return f x y
  • Scalafor
for {
  x <- m1
  y <- m2 x
} yield (f x y)
  • F# ⇒ コンピュテーション式
builder {
  let! x = m1
  let! y = m2 x
  return (f x y)
}

モナド構文の裏にある仕組み

  • Haskellは 型クラス ⇒後述
    • あるデータ型がモナドの一種だと明示的に宣言することでdo記法が使える
  • Scalaは 構造的
    • あるデータ型が特定のシグネチャのメソッドを提供していればfor式が使える
  • F#は ビルダー が仲介
    • どのビルダーを使うかはコードで明示的に指定する
    • 指定したビルダーが、あるデータ型を対象にした構文変換規則を提供していればコンピュテーション式が使える

F#のコンピュテーション式の ちょっと変わった特徴

  • モナド(let! - returnまたは リスト内包(for - yield)の構文変換が基本
    • Scalaの場合、どちらも for 式で扱う
  • ビルダーの実装次第で、 さらに多様な構文変換をサポートする
    • 遅延計算, ジェネレーター, while, 例外ハンドラ, コードクォート, カスタム操作
    • 基礎となる考え方は「継続渡しスタイル(CPS)」
  • 複雑だが、DSLとして柔軟な仕組み

非同期処理のためのコンピュテーション式

let getWebContentSafelyAsync (uri: Uri) : Task<string> =
    task {
        use client = new HttpClient()
        try
            let! content = client.GetStringAsync(uri)
            return content
        with
        | :? HttpRequestException as ex ->
            return sprintf "HTTPリクエストエラー: %s" ex.Message
        | ex ->
            return sprintf "予期せぬエラー: %s" ex.Message
    }

どうしてこうなった?

  • F#の設計思想
    • 「記法は人間にとっての使いやすさの観点から非常に重要」
    • 「通常のコード、リスト内包表記、非同期コード間の記法の類似性を重視」
  • プログラマーが、命令的なコーディングから関数型の(モナドと関数合成の)コーディングへ移行するときのハードルを下げようとしている
    • 統一的で読みやすい構文の中にシームレスに統合できる

どうしてビルダー?

  • 文脈の意味が明示される
  • 名前を自由にできる
  • データ型に対する構文変換を、明示的に「後付け」できる
    • 型クラスを持たない言語だから
    • 型拡張の位置づけが低い言語だから

Part 3: 型システムの制約とSRTP

型クラスとは

  • 複数の型に共通する性質に名前を付けて、型を分類するもの
  • Haskellではどういうところで使われているの?
    • Num ... 数として扱える(数値演算できる)データ型のグループ
    • Monad a ... モナドとして扱える(do記法で使える)コンテナー的データ型のグループ
  • 型に応じて、関連づいた関数の実装が選択されるという多相性(ジェネリック性)

型クラスの特徴

  • 型より一段上の概念なので、型同士の関係(サブタイピング)ではない
  • 高度な型システムが必要
    • 特に、モナドを表現するには高カインド型 (HKT) と呼ばれるしくみが必要とされる

ちなみにScalaは……

  • 型クラス相当の仕組みがある
    • たとえばジェネリックな数値演算なら、Numberトレイト、および、関連づいた関数群を自動で渡す仕組み (implicit/given)
  • モナドも表現可能だが、標準では、モナドトレイトなどは用意されていない

そしてF#は……

  • 型クラスがない
    • 要望はあるが、Don Symeが否定的(後述)
  • .NETの型システム もっと言えば .NETのジェネリクス が制約となっている面もある
  • F#の設計思想ふたたび:.NET言語やライブラリと「相互に」運用する
    • .NET言語は一般的に、バイナリーレベルで相互運用できてほしい
    • Don Syme: 「型システムは.NETの進化に合わせる」

.NETのジェネリクスの制約

  • バイナリー(共通中間言語)とランタイムのレベルでジェネリクスをサポートする
    • 型パラメーターは、実行時に実際の型に置き換えられる
    • 型パラメーター制約は、サブタイプ関係をベースにしている
    • パラメーター型のインスタンスメンバーしか呼び出せなかった(※以前は)
    • 高カインド型(HKT)など高度な型はサポートしていない
  • 型クラスのサポートのために.NET自体に手を入れるとしても、後方互換性を保ちたい

F#が選択した方法

「静的に解決される型パラメータ(SRTP)」

  • インライン化とともに使える、型パラメーター制約が柔軟になったようなもの
  • 型パラメーター制約が構造的
    • パラメーター型が特定のシグネチャのメンバーや演算子を提供していることを制約にできる
    • サブタイプ関係は不要
  • 利用側のコンパイル時に、型パラメーターが実際の型に置き換えられる
  • 利用側のコンパイル時に、オーバーロードが解決される
  • コンパイル後のバイナリーは相互運用可能

静的に解決される型パラメータ(SRTP)

let inline double<'a when 'a:(member Double: unit -> 'a)> (x: 'a) =
    x.Double()
  • 型パラメーター 'a をとる インライン関数 double の型は 'a -> 'a
  • インスタンスメソッド Double() を持っている型しか'a に渡せない

SRTPでジェネリックな算術演算

module Number =
  let inline add x y = x + y
  // ^a -> ^b -> 'c when (^a or ^b) : (static member (+) : ^a * ^b -> 'c)

module MyList =
  let inline sum xs = List.fold (+) LanguagePrimitives.GenericZero<_> xs;;
  // ^a list -> ^b
  //   when (^b or ^a) : (static member (+) : ^b * ^a -> ^b)
  //   and ^b: (static member Zero: ^b)

参考:他の.NET言語

  • C#でも型クラスの要望はあるが、こちらも慎重
  • ジェネリックな算術演算は、.NET 7からサポートされるようになった
  • interface の仕様を変更して実現
  • INumber<TSelf> が「抽象静的メソッド」をもつ
  • たとえば intINumber<int> を実装する

F#は.NET 7を待つわけにいかなかったし、オブジェクト指向の機構は「サポート可能」でも「必須」にはしない

参考:さらにSRTPを駆使すると……

module Monad =
  type CMonad() =
    static member inline bind(f: ^a -> ^b list, m: ^a list) = List.collect f m
    static member inline bind(f: ^a -> ^b option, m: ^a option) = Option.bind f m

    // CMonadかカスタム型自身にbind静的メソッドがあれば、それを呼び出す
    static member inline bind_resolve< ^m, ^a, ^b, ^c
      when ^m :> CMonad and (^m or ^c): (static member bind: (^a -> ^b) * ^c -> ^b)>
        (f: ^a -> ^b, m: ^c) =
          ((^m or ^c): (static member bind: (^a -> ^b) * ^c -> ^b) (f, m))

  let inline bind< ^a, ^b, ^c
    when (CMonad or ^c): (static member bind: (^a -> ^b) * ^c -> ^b)>
      (f: ^a -> ^b) (m: ^c) : ^b =
        CMonad.bind_resolve<CMonad, _, _, _> (f, m)

let m1 = Some 1 |> Monad.bind (fun x -> Some (string x))

参考:さらにSRTPを駆使すると……

FSharpPlus:高度抽象の実験場

SRTPの欠点(F#の妥協点)

  • 定義側の記述が面倒で読みにくく、型推論はほぼ効かない
  • コンパイル時のコード生成という意味で、マクロやテンプレートに似ている
    • 理解・デバッグが困難
    • IDEの支援も限られている
    • バイナリーサイズ増大
  • F#コンパイラーだけがサポートする機能
    • インライン化する前の関数は、.NET言語との相互運用性がない
    • 無理やり呼ぼうとすると NotSupportedException

F# の文化とSRTP

  • 第一のユースケースは、ジェネリックな算術演算
  • 型クラスのような使い方も可能だが、必須にしない
  • 高度な型抽象を使う代わりに、具体的な型に応じたモジュールを書く
    • たとえば、 Task<Result<'a, 'b>> 用の便利モジュール

補足:型クラスに対するDon Symeの立場

https://github.com/fsharp/fslang-suggestions/issues/243#issuecomment-916079347

.NETやJavascriptとの相互運用という文脈において……型クラスの有用性はおおむね過大評価されており、多くのマイナス面を抱えています……

  • 型レベルプログラミングと型レベル抽象の要求は高度化しがち
  • 高度な型レベルプログラミングはコンパイルパフォーマンスに深刻な悪影響を及ぼす
  • さらに、コンパイル時のデバッグが必要になる
  • 型分類の「正しさ」に議論が陥りがち
  • 型分類を「調整」することでバイナリ非互換を招きがち
  • 高度な抽象化を理解しないとプログラミングできなくなりがち

セッションまとめ:F#の設計と妥協点

  • 関数型でありながら、純粋性を強制はしない
    • 命令型やOOPとの共存
  • 使いやすい構文を提供
    • 非同期処理も、通常のF#コードの中で違和感が少ない
  • 現実との接点や開発体験を強く意識
    • .NET言語としての相互運用性
    • 高度な型抽象は抑制して使いすぎないようにしつつ、現実的なコードを志向

title slide