5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Rust開発者がF#に触れてみた

Posted at

はじめに

Rustacean のための F# 入門 という記事を見てF#に興味を持ち、実際に触れてみました。

私はもともとC++からRustに入った人間であり、Haskellの本を1冊読んだほかは、関数型言語の経験はありません。そんな筆者がF#に触れて感じたことを書いてみます。

F#について

Microsoftが開発した、関数型とオブジェクト指向の両方を併せ持つ言語です。C#と同じく.NETの上で動きます。

マイナーながらも言語自体は毎年アップデートされており、新機能の追加も行われています。現在 (2025年3月) の最新バージョンは F# 9 です。

RustとF#はどちらもOCamlという言語の影響を受けており、例えばOption型やmatch式など、RustとF#の間には多くの共通点があります。ですが、F#の方が関数型のとくちょうが強く、より関数型言語らしい書き味になります。

言語バージョン

この記事はF# 9を元に書かれています。

補足: 言語のバージョンについて

最新版は F# 9 ですが、LTSとしての最新版はF# 8です。これは、2年おきにLTS版を出すという .NET の方針によるものです。

バージョンの関係

.NET C# F# Release
.NET 8 (LTS) C# 12 F# 8 2023
.NET 9 (latest) C# 13 F# 9 2024

言語のバージョンとランタイムのバージョンとで混同しそうですが、長期サポート版 (LTS) と標準期間サポート版 (STS) というのは .NET のサポートの方を指します。その上で、 C# や F# の言語バージョンが .NET のバージョンと紐づいているという関係です。

ざっくりした感想

  • 学びやすい

    • 直和型、ResultやOption、match式、式指向の構文など、Rustで知ったものが多くあります。
    • Rustと共通点があるぶん、C#から入るよりも学びやすいかもしれません。
  • 手軽

    • Python のような軽量な構文

      • {} を使わずにインデントでブロックを表現するため、書き方が簡潔です。
    • 型をあまり書かずに済む

      • Rust だと型をしっかり書かせますが、F# だと推論が効き、型を書かなくても良い場面があります。
      • それでいて型の扱いは堅く、IDEやコンパイラによって型レベルの誤りを防ぐことができます。
  • 開発体験の良さ

    • VS Code の Ionide という拡張機能が便利です。関数や変数の型を inlay-hint として表示してくれるので、型は書かなくても画面に表示されます。
    • Fantomas と FSharpLint (フォーマッタとリンタ―) も入れておくと良いです。
    • cargo のように、操作の多くは dotnet コマンドで完結します。dotnet add packageでパッケージを追加したり、 dotnet publishでビルドしたりといった具合です。
    • シングルバイナリを生成することもできます。
  • 関数型言語

    • 自分がこれまで触れてこなかった関数型言語ならではの体験ができました。
    • Haskell を先に知ったことが理解の助けになりました。
      • 特にモナド。F#には高カインド型がなく、言語仕様や標準ライブラリの中に Monad という型は直接的には出てきませんが、コンピュテーション式という構文でモナド的な (monadicな) ものを扱います。そのため、モナドについて事前に知っていると理解しやすいと感じました。
      • Microsoft Learn の中でもモナドという語を使っているので、関数型言語の世界では普通に使う用語なんだろうなと思います。

総じて、F#はけっこう良い言語なのではという気持ちになっています。自分の仕事のメインとして使う可能性はなさそうですが、趣味やちょっとしたCLIツールに使う分には十分に使えそうな感じがしています。

学習リソース

日本語

Microsoft Learn

本家。リファレンス的に参照するならここ。

F# ではじめる関数型プログラミング入門 (上巻)
F# ではじめる関数型プログラミング入門 (中巻)
Midoliy|F#プログラミング

いずれも Midoliy さんという方が書かれているもの。丁寧な解説で助かります。

英語

F# for Fun and Profit

有名な学習リソースのようです。

Grokking Functional Programming

Monad, Applicative, Lens など関数型言語にある概念を F# で解説するシリーズ。F#には関数型プログラミングを支援する FSharpPlus というライブラリがあり、それを使う上でこのシリーズの知識が役立ちます。

作ったもの

私的なものなので公開はしませんが、小さなCLIツールを2つほど作りました。Haskellは本を読んだだけで、実際にゼロからプログラムを作ることはしなかったのですが、F#では少しだけ実用することができました。

書いてみると、少ない記述で簡潔にプログラムを書くことができ、F#の良さを体験できました。百聞は一見に如かずというやつですね。

構文

網羅的にではありませんが、Rustを知っている人向けにF#の構文をかいつまんで紹介します。

式指向

F#はPythonと同様、ブロックを {} ではなくインデントで表します。また、Rustと同様に式指向の言語であり、ブロックの最後の式が値を返します。

F#
let c =
    let a = 1
    let b = 2
    a + b

Rust と見比べると、けっこう似ていますね。

Rust
let c = {
    let a = 1;
    let b = 2;
    a + b
};

可変性

再代入する変数には mutable が必要です。また、再代入の際は <- を使います。

let mutable a = 1
a <- 2

Rust でも let mut を使いますね。どちらもimmutableなものを基本にしたいという考えがありそうです。

ただし、Rustと違い mutable は破壊的な操作の有無を示しません。例えば可変長配列である ResizeArray (RustでいうVec) に対して、 mutable がなくても要素の追加は可能です。

// let mutable と宣言しなくても変更可
let a = ResizeArray<int>()
a.Add 1
a.Add 2

F#独自の型 (listarray) は不変が基本であり、破壊的な変更はできません。例えば list への要素追加は「要素が追加された新しいリストを返す関数」として提供されており、元のリストを変更しません。ですが、ResizeArray は .NET のクラス (C#におけるList型) であり、このルールからは外れます。

実用上は .NET のライブラリを使う場合も多いので、F#独自の型と.NETの型とで考え方に違いがあるという点は知っておくと良さそうです。

関数

関数は let 関数名 引数1 引数2 ... = のように定義します。

// 短い関数は1行で書いて良い
let add a b = a + b

// 複数行にわたる場合はブロックにする
let addTwo a =
    let b = a + 1
    let c = b + 1
    c

// 引数や戻り値の型を明示する場合
let mult (a: int) (b: int) : int =
    a * b

// 関数の呼び出し
let x = add 1 2

Rust と違い、早期リターンはできません。returnというキーワードはありますが、これはコンピュテーション式という構文で使うもので、他の言語のような「値を返す」という意味では使いません。

Rust
fn abs(a: i32) -> i32 {
    if a < 0 {
        // Rustでは早期リターン可能だが、F#ではできない
        return -a;
    }
    a
}

そのため、match式やif式をうまく使うことがRust以上に求められそうです。

型推論

Rustと同じく、型は前後の文脈から判断されます。引数に型を書かなくて済むなど、Rustよりも推論が強力です。

// foo: float -> string を明示
let foo (x: float) : string = x.ToString()

let bar a =
    let b = foo a
    b

上記のコードでは関数barに型を書いていませんが、引数afoo関数に渡されていることから、aは自動的にfloatと推論されます。また、戻り値も同様に推論されるため、bar関数の入出力はfloat -> stringと推論されます。

エディタの inlay-hint が有効であれば以下のように型が表示されますし、bar関数に誤った型を渡せばエラーになります。bar関数にマウスを当てると float -> string というシグネチャも表示されるので、型を書かなくてもあまり困りません。

inlay.png

Pythonみたいに型を書かなくて済み、それでいて型エラーが生じないというのはなかなか便利かもしれません。

個人的な経験として、引数については型の明示が必要な場面や、型を書いた方がエディタの支援を受けやすい場面がありましたが、戻り値は書かなくて良いことが多いと思いました。

Rustと異なる点として、整数型や少数型のサイズは前後の文脈から推論されません。Rustでは以下のように書いたとき、 a の型は関数の戻り値から決まります。

Rust
fn foo() -> i16 {
    let a = 1;  // 戻り値から i16 と判断
    a
}

F# では let a = 1 と書いた場合、右辺の1はint型 (signed 32bit) のリテラルであるため、文脈に応じてaint16uint16になることはありません。必要な場合はlet a = 1s のように接尾辞を付けるか、let x = int16 1 のように型と同名の関数に渡します。

Option, Result

Rustから入った人であれば、以下のコードは難なく理解できると思います。

let divide a b =
    if b = 0 then None
    else Some(a / b)

let x: int option = divide 2 0

match x with
| Some v -> printfn $"result: {v}"
| None -> printfn "Zero division"

Resultも同様です。識別子は Ok, Error を使います。

let divide a b =
    if b = 0 then Error "Zero division"
    else Ok(a / b)

let x: result<int, string> = divide 2 0

match x with
| Ok v -> printfn $"value: {v}"
| Error e -> printfn $"error: {e}"

match式

Option/Resultの例で書いた通り、使い方はRustのものと大体同じです。 F#だとパターンとして使えるものが多かったり、パターンマッチだけを行う関数をfunction構文という方法で書けるなどの違いがあります。

F#で特徴的なものとしてアクティブパターンというものがあります。これは以下のように、パターンの判定方法をカスタマイズするものです。

let (|Child|Adult|Senior|) age =
    if age < 13 then Child
    elif age < 60 then Adult
    else Senior

let age = 21

let ticketPrice =
    match age with
    | Child -> 1000
    | Adult -> 1500
    | Senior -> 1200

タプル

タプルは * で表します。例えばRustの (i32, String) という型は、F#では int * string と書きます。

これは直積型という言葉の意味を考えると分かるかもしれません。例えば値が Red, Blue, Green のいずれかであるColor型と、Negative, Positive のいずれかをとるSign型がある場合、この2つの型のタプルは 3 * 2 = 6 通りの値を取り得ます。このように、タプルは型の組み合わせであり、取り得る値の数はそれぞれの型の積になると考えると、タプルを * で表現する理由も分かる気がします。

そう思いつつも、慣れのためか、個人的には (i32, String) の方が分かりやすいように思います。

レコード型

Rustでいうstructです。

type Person = { Name: string; Height: float }

フィールドの区切りは、一行で書く場合はセミコロン ; を使いますが、複数行に書く場合は改行で区別されるためセミコロンを使いません。

type Person =
    { Name: string
      Height: float }

レコード型のフィールドはデフォルトではimmutableです。更新する際は、値を変更した後のレコードを新しく作るのが基本です。

フィールドに mutable を付けると可変になり、 person.Name <- "Bob" のように後から変更できるようになりますが、これはあまり必要なさそうに思います。F#ではimmutableを基本にプログラムを書く方が筋が良さそうです。

判別共用体 (直和型)

Rustでいうenumです。Microsoftの説明 にある例をそのまま引用します。

type Shape =
    | Rectangle of width: float * length: float
    | Circle of radius: float
    | Prism of width: float * float * height: float

Rustでいえばこれは以下と同様です。

Rust
enum Shape {
    Rectangle { width: f64, length: f64 },
    Circle { radius: f64 },
    Prism { width: (f64, f64), height: f64 },
}

X ofX の所がRustのenumの列挙子に相当し、match式などで使います。

F#の判別共用体の場合、フィールド名は書いても省略しても良いです。Rustでいえば、名前付きだけどタプル構造体のようにも使えるという感じです。

let rect1 = Rectangle (length = 1.3, width = 10.0)
let rect2 = Rectangle (1.3, 10.0)

フィールド名が要らない場合は、以下のように書けます。

type Shape =
    | Rectangle of float * float
    | Circle of float

これは Rust の以下の書き方と同等です。

Rust
enum Shape {
    Rectangle(f64, f64),
    Circle(f64),
}

New Type

Rust では struct Foo(int) のように、値が1つだけのタプル構造体を使うことで new type を表現していました。 F# で同等のことを行うには、判別共用体を使って type Foo = Foo of int のように書きます。

中身の値を取り出すには以下のようにします。

type Foo = Foo of int

// a: Foo 型
let a = Foo 1

// 中身の取り出し (b が int になる)
let (Foo b) = a

Foo型の引数を受け取る関数を書く際に、以下のように書くと、中身の int の値が x に束縛されます。

let func (Foo x) = ...

ここまではRustと似ているものも多いですが、ここからはRustにはない、関数型言語らしいものが出てきます。

カリー化

F#の関数は自動的にカリー化されます。これは、複数の引数を受け取る関数は、変数を1つ受け取る関数に分解されるということです。

let sub a b = b - a

let subTwo = sub 2
let c = subTwo 10  // 8

subは「引数 ab を受けとり引き算を行う」関数に見えるかもしれません。しかし、subは正確には「引数aを受け取り、『引数bを受け取ってaを引く関数』を返す関数」となります。このように、複数の引数を受け取る関数は「引数を1つ受け取り、『残りの引数を受け取る関数』を返す」形に分解されます。

見慣れないかもしれませんが、これは map, filter のような処理のほか、パイプライン演算子と組み合わせた時に役立ちます。

なお、引数を (a, b) のように書いた場合、これは「タプルを受け取る関数」として扱われます。

// (a, b) のタプルを受け取る関数。
// 引数は1つであり、これ以上分解されない。
let add (a, b) = a + b

// a, b の2つの引数を受け取る関数。
// これはカリー化される。
let add a b = a + b

.NETのライブラリなど、F#の外にある関数は引数がタプルの形で表現されます。例えばC#で以下のようなメソッドを書いた場合、これはF#からは (a: int, b: int) というタプルを1つだけ受けとる関数として扱われます。

class Adder
{
    static int Add(int a, int b)
    {
        return a + b;
    }
}

パイプライン |>

これは関数型言語というよりも、F#ならではのものです。変数を関数に渡す際、 x |> Func のように書くことができます。

let addOne a = a + 1

// 以下は同じ意味
let b = addOne 5
let b = 5 |> addOne

これは先ほど書いたカリー化とも関連します。変数を2つ受け取るadd関数に変数を1つだけ渡したとき、それは「残りの変数を受け取りて足し算をする関数」となります。例えば add 2 は「渡された値に2を足す関数」となります。それらをパイプでつなげていくことで、以下のような書き方ができます。

let add a b = a + b
let mult a b = a * b

let a = 1 |> add 2 |> mult 3 |> add 4

これは慣れると書き心地が良いです。他の言語だと

let temp1 = add(1, 2);
let temp2 = mult(temp, 3);
let a = add(temp, 4);

のように途中の変数を書くか、

let a = add(mult(add(1, 2), 3), 4);

のように書いていきます。後者は中間の変数を書かずに済みますが、適用する関数が多いと括弧が多くて読みづらくなります。パイプラインだと、関数の順次適用を簡潔に書くことができます。

関数合成 >>

f >> g のように書くことで、「関数fを適用し、その結果を関数gに適用する」関数を作ることができます。

let add a b = a + b
let mult a b = a * b

let add2ThenMult3 = add 2 >> mult 3

let a = add2ThenMult3 1 // 9

このように処理結果を繋げたり、関数を合成したりするのは、関数型言語ならではという感じがしました。

コンピュテーション式

Haskell の do 記法にあたるものです。モナド的なものに使うことが多そうですが、DSLのような他の使い方もできるようです。

これは簡単な説明は難しいのですが、例えば async なものを通常の値として取り出したり、 option/result から有効値を取り出したりといったことができます。Rust でいう ? 演算子や await のようなことができ、それを更に汎化したような仕組みです。

コンピュテーション式は、扱う対象ごとにそれぞれ異なるものを用意します。例えば非同期的な処理を扱う場合はasyncというコンピュテーション式を使います。

// 少し待ってから引数と同じ値を返す関数
let fetchValue x =
    async {
        do! Async.Sleep 100
        return x
    }

let a: Async<int> = async {
    let! b = fetchValue 1
    let! c = fetchValue 2
    return b + c
}

asyncの文脈では、let! は Rust の await と同じように、Async<T> から T を取り出します。また、 return は型をその文脈 (ここでは Async) で包むものです。

もう一つ、option型の例を示します。

let a = option {
    let! b = Some 2
    let! c = None
    return b + c
}

後述しますが、optionというコンピュテーション式は標準ライブラリにはありません。ここでは、自作または外部ライブラリを使うなどしてoptionというコンピュテーション式があるものと仮定してください。

これはRustの?に似ていますね。最後の return は結果の b + c を Some で包んで返す役割を持ちます。これはコンピュテーション式が option 型の文脈で使われているためです。

Rustの?演算子と違い、コンピュテーション式は関数全体でなくブロックに使うことができます。Rustの?は (unstable機能を使わない限り) ブロックに対しては使えないので、以下のようにクロージャーを使うなどの方法で対応することがあるかと思います。

Rust
fn func() {
    let a = || -> Option<i32> {
        let b = try_get(1)?;
        let c = try_get(2)?;
        Some(b + c)
    }();
}

F#のコンピュテーション式はこのような制約がないため、より柔軟な使い方ができます。

気になったところ

ここまで駆け足でF#の構文を書きました。ここで、ちょっとイケてないなと思ったことを書いてみます。

ジェネリクスの記法

ジェネリクスはRustと同様、 option<int> のように <> で書きます。 が、 型が一つの時に限り、 int option のように包む側の型を後置することができます。

公式のスタイルガイドに従えば、 list, seq, option など F# 固有の型については後置スタイル (int option, int list) を使い、それ以外については前置スタイル (ResizeArray<int>) を使うとのことです。また、型が複数ある場合は後置スタイルを使えないため、resultは result<int, string> のように書きます。

この辺りは統一感がない感じがするのですが、どうなんでしょう?

コンピュテーション式

標準では async, task, query, seq の4つのコンピュテーション式が提供されています。なぜか optionresult を扱うコンピュテーション式は提供されていません。Rustから来ると、エラーハンドリングに?演算子を気軽に使っているので、同等のものが標準で用意されていないことが少し不思議に思います。

これらは有名どころのライブラリで提供されているので、実用上はおそらく困りません。例えば以下のものを使うと良さそうです。

  • FSharpPlus

    • これは F# での関数型プログラミングを支援するライブラリで、高度な抽象化やユーティリティなどを提供しています。
    • この中に monad というコンピュテーション式があります。これは monadic な型全般に使える汎用的なものです。 result, option, list などの型をどれも同じ monad コンピュテーション式で扱うことができます。
  • FsToolkit.ErrorHandling

    • 名前通りのライブラリです。エラー処理に便利なものを提供しています。
    • Async<Result<T, E>> のように「非同期かつ失敗し得る操作」などのバリエーションも用意されています。FSharpPlusが抽象的な概念を提供するものだとしたら、こちらは実用的なものを提供するライブラリといえそうです。

FSharpPlusで Async<Result<T, E>> のような複合的なモナドを扱う場合は、モナドトランスフォーマーというものを使います。モナドトランスフォーマーについては、学習リソースとして紹介した Grokking Functional Programming シリーズの Grokking Monad Transformers という記事で触れられています。但し、記事にもあるように、これを使うとコンパイル時間が長くなるという課題があります。エラー処理が目的であれば、素直に FsToolkit.ErrorHandling を使うのが良さそうです。

2つの非同期型

F#にはAsyncTaskの二種類の非同期型があります。歴史的にはF#のAsyncが先にあり、それを元に .NET および C# で Task 型が作られ、それがF#に逆輸入されたという経緯のようです。C# で async Task<int> のようなメソッドを書いた経験があれば、それに対応するものはF#ではAsyncではなくTaskの方になります。

このように、目的の似たものが2つあるのは混乱を招く気がします。

詳しいところは分かりませんが、 .NETとの相性やパフォーマンスなどを考えると Task を使う方が良さそうです。Rust でいう tokio のランタイムに相当するものが .NET にはあり、Taskはそれをベースにしています。例えばHTTPリクエストなど、.NETのライブラリを呼ぶ場合はTaskを使うことになるので、実用的にもこちらを使うことが多いと思います。

F# 6以降はtaskコンピュテーション式が標準で用意されているので、F#からの使い勝手もAsyncと変わらなさそうです。

nullと例外

Rust だとエラーや値無しを Option/Result で表現し、標準ライブラリも含め言語全体としてそれが徹底されています。

F# も option/result を使いますが、 .NET のクラスを呼び出す場合はnullや例外が起こり得ます。.NET の主役は言うまでもなくC#であり、F#の都合に合わせてはいないという感じです。

とはいえ、.NETのライブラリを使えることがF#の利点でもあるため、無視はできません。.NETとの接続部分で option/result に変換するのが良さそうです。

nullや例外を扱う際は以下のようなものが役立ちます。

  • Option.ofObj

    • nullの可能性のある参照型 を option 型に変換します (例. string -> string option)
  • Option.ofNullable

    • null許容値型 (C#でいう int? のような型) を option 型に変換します。
    • null許容値型は F# の世界からでも Nullable<int> のように見えるので、nullの可能性を見逃すことはあまりないと思います。
  • Result.protect (FSharpPlus)

    • 関数全体を try-catch で囲み、result<T, exn> を返す関数に変換します。
    • exn は .NET の例外を示す型です。
    • 例えば int -> float というシグネチャをもつ、例外を発生させる可能性のある関数 f について、 1 |> Result.Protect f のように書くと、戻り値は result<float, exn> になります。
  • Task.catch (FsToolkit.ErrorHandling.TaskResult)

    • 例外を発生し得るタスク (Task<T>) を Choice<T, exn> 型に変換します。
    • Choice 型は Result.ofChoiceresult に変換できます。
      • 直接 result を返してくれた方が嬉しい気がします。

例外については try...with式 (いわゆるtry-catch) を使うことができます。Result.protectなどを使う方が関数型らしい感じがしますが、素直に try-catch を書いても良いと思います。

使ったライブラリ

自分が使ったライブラリの中で汎用性が高いと思うものを紹介します。

FShapPlus

FShapPlus

先ほどから何度か名前を出しているライブラリです。F#での関数型プログラミングを支援するライブラリで、monad コンピュテーション式のほか、Lensという機能や、 >>=, >=> などの演算子が提供されています。

演算子は使うと記述がより簡素になりますが、どこまで使って良いのかは微妙なところです。個人の趣味なら好きに使って良いと思いますが、複数人での開発なら、チームみんなが理解できるものだけを使う (もしくは使わない) 等のルールが必要そうに思います。

// int option を返す関数 (失敗する可能性あり)
let divideBy x y =
    if x = 0 then None
    else Some(y / x)

// int を返す関数 (失敗しない)
let sub x y = y - x

// x: Some(6)
let x: int option =
    80
    |> (divideBy 2 |>>> sub 1)
    |>> sub 3
    >>= (divideBy 3 >=> divideBy 2)

int option を返す関数と int を返す関数を織り交ぜて、関数を順次適用したり合成したりしています。 mapflatMap (and_then) のような関数すら登場せず、繋ぎの部分が全て記号になっているので、適用する処理だけが字面として表れておりとても簡潔に見えます ... が、これはちょっとやりすぎかもしれません。

エラーを返す処理を繋げていく場合など、便利な場面はあるのですが、使いすぎには注意が必要そうです。

言語に標準で用意されている |>>> については、使って問題ないと思います。

少しだけ補足すると、>>= はbindと呼ばれるもので、Haskellなど他の関数型言語を知っている人にもおなじみのものだと思います。 >=> (Kleisli Arrow) は私は初めて知りましたが、これも関数型言語に由来するようです。 |>>, |>>> はF#のパイプライン記法に寄せたもので、FShapPlus独自のものだと思います。

FSharpPlusを使う際は Grokking Functional Programming も読んでおくのがおすすめです。モナド以外にも、TraversableやLensなど関数型言語の概念について解説されています。

FsToolkit.ErrorHandling

FsToolkit.ErrorHandling

エラーハンドリングのためのライブラリです。OptionやResultを扱う際に役立つものがあります。

Task型を扱う場合は FsToolkit.ErrorHandling.TaskResult というパッケージを追加で入れます。

FSharpx.Extras

FSharpx.Extras

ユーティリティ的なライブラリです。私はこの中の FSharpx.Collections というモジュールだけ使いましたが、他にも色々入っているようです。

FSharpx.Collections を使うと、 ResizeArray, Dictionary など C#の (.NETの) データ型をF#から扱いやすくなります。

Argu

Argu

CLIを作るためのライブラリです。Rustのclapに相当します。

使い方はチュートリアルを見れば分かるので、説明は省きます。

Tomlyn

Tomlyn

TOMLを扱うライブラリです。C#向けに作られたものですが、F#からも使えます。

私はCLIツールを書く時、設定ファイルとしてTOMLをよく使います。TOMLは人が読み書きしやすく、個人的に好みです。RustだとSerdeとも相性がよく、簡単に扱うことができます。

Tomlyn も Serde のように、型の定義を元にシリアライズ・デシリアライズを行います。Tomlyn はリフレクションを使うのですが、F#のimmutableな型とは相性が悪いので、注意が必要です。これはおそらく、TOMLをユーザーの定義型にデシリアライズする際、内部的には引数なしのコンストラクタでインスタンスを作り、その後に各フィールドに値を set するような仕組みになっているためと思われます。

使う際は、デシリアライズ用に mutable なクラスを作り、それをF#のレコード型に詰め替えるのが良さそうです。

例えば以下のようなTOMLを考えます。

TOML
title = "Example"

# 仕様:
# user.age と user.message は optional なフィールド
[[users]]
id = 1
name = "Alice"
age = 20

[[users]]
id = 2
name = "Bob"
message = "Hello"

これを Tomlyn で扱うには以下のようにします。コード中の monad コンピュテーション式については、 let! が Rust の ? に相当する動作になるものと思ってください。

open System
open FSharpPlus

// Tomlyn から扱うための mutable な型
// これはレコード型ではなくクラスを使います。
type UserDeserialize() =
    member val Id: Nullable<int> = Nullable() with get, set
    member val Name: string | null = null with get, set
    member val Age: Nullable<int> = Nullable() with get, set
    member val Message: string | null = null with get, set

type ConfigDeserialize() =
    member val Title: string | null = null with get, set
    member val Users: ResizeArray<UserDeserialize> = ResizeArray() with get, set

// F# friendly なレコード型
type User =
    { Id: int
      Name: string
      Age: int option
      Message: string option }

type Config = { Title: string; Users: User list }

// F# friendly な型への変換。
// 変換と同時に必須フィールドの値の有無もチェックする。
let toFsUser (user: UserDeserialize) : Result<User, string> =
    monad {
        let! id = user.Id |> Option.ofNullable |> Option.toResultWith "idは必須です"
        let! name = user.Name |> Option.ofObj |> Option.toResultWith "nameは必須です"
        let age = user.Age |> Option.ofNullable
        let message = user.Message |> Option.ofObj

        return
            { Id = id
              Name = name
              Age = age
              Message = message }
    }

let toFsConfig (config: ConfigDeserialize) : Result<Config, string> =
    monad {
        let! title =
            config.Title
            |> Option.ofObj
            |> Option.toResultWith "titleは必須です"

        // NOTE:
        // sequence は List<Result<_>> を Result<List<_>> にするもの。
        // 変換に失敗した User が1つ以上あればリスト全体を失敗にする。
        let! users =
            config.Users
            |> Seq.map toFsUser
            |> List.ofSeq
            |> sequence

        return { Title = title; Users = users }
    }

読み出す際は以下のようにします。

open Tomlyn

let tomlText =
    """
title = "Example"

[[users]]
id = 1
name = "Alice"
age = 20

[[users]]
id = 2
name = "Bob"
message = "Hello"
"""

let config: Result<Config, string> =
    tomlText
    |> Result.protect Toml.ToModel<ConfigDeserialize>  // Result<ConfigDeserialize, exn>
    |> Result.mapError _.Message                       // Result<ConfigDeserialize, string> 
    >>= toFsConfig                                     // Result<Config, string>

TomlynはC#のライブラリであり、失敗をresult型ではなく例外で伝達します。それをresultにするためにFSharpPlusのResult.pretectで包み、その後にエラー型をmapErrorで文字列に変換します。_.Messageexn型が持つMessageプロパティにアクセスするための記法です。

デシリアライズ用のクラスのフィールドを nullable にしているのは、必須フィールドの値の有無を判定できるようにするためです。例えば

type UserDeserialize() =
    member val Id: int = 0 with get, set

のようにすると、TOMLファイルに user.id の値が無い場合でも Id = 0 が入ってしまいます。これは筋が良くないため、デシリアライズには nullable な型を使い、値の有無をバリデートできるようにしています。

Serdeに比べると不便ですが、.NETの世界とやりとりする際の注意点を知れたとも思っています。

参考として、RustのSerdeの例を記します。こちらは以下のように定義するだけで、必須フィールドのチェックも含めて適切にデシリアライズすることができます。

Rust
use serde::Deserialize;

#[derive(Deserialize)]
struct User {
    id: u32,
    name: String,
    age: Option<u32>,
    message: Option<String>,
}

#[derive(Deserialize)]
struct Config {
    title: String,
    users: Vec<User>,
}

TIPS

細々としたものを書きます。

シングルバイナリの生成

ビルドの際に以下のようにします。

dotnet publish -c release -r win-x64 --self-contained false -p:PublishSingleFile=true

PublishSingleFile を有効にすると、外部ライブラリなどの dllを含んだ単一の実行ファイルが生成されます。この項目はプロジェクトの設定 (RustでいうCargo.toml) で指定することもできます。

--self-contained は実行ファイルに .NET のランタイムを含めるかどうかを示します。有効の場合、実行環境に .NET のランタイムがなくても動くプログラムが生成されますが、その分サイズが大きくなります。

F#スクリプト

F#はスクリプト言語としても使うことができます。実行時にコンパイルするため、Python等に比べると少し待たされますが、プロジェクトを作ることなく手軽にコードを動かすことができます。

スクリプトとして使う際は、ファイル拡張子を .fs ではなく .fsx としたうえで、 dotnet fsi コマンドに渡します。

スクリプトの先頭に以下のように書くことで、外部パッケージを利用することもできます。

#r "nuget: FSharpPlus"

// パッケージを利用
open FSharpPlus

ラムダ式

ラムダ式 (無名関数) は fun x -> x + 1 のように書きます。

F#だと関数を変数とほぼ同じように書けるため、ラムダ式は無名関数が欲しい場合にのみ使い、変数へ束縛する場合は通常通り let を使うのが良さそうです。

let rate = 5

// こう書くよりも
let f = fun x -> x * rate

// こう書いた方が良さそう
let f x = x * rate

// map, filter に渡す場合など、
// 無名の関数を使いたい場合に fun を使う
let values =
    [1; 2; 3] |> List.map (fun x -> x * rate)

引数の順序

カリー化やパイプラインとの相性を考えると、操作の主目的になる変数を最後にするのが良さそうです。例えば Person という型を受け取り、引数で指定した値以上のBMI値かどうかを判断する関数を作るとします。

自分が他の言語でこれを書くとしたら、おそらく

fn is_metabolic(person: &Person, threshold: f32);

のようなシグネチャにすると思います。ですが、F#では

let isMetabolic threshold person =
    ...

のように、personを最後に受け取る形にした方が良いです。なぜなら、カリー化されることを考えた際に、 threshold だけを先に渡すことができるからです。例えば以下のように、 map関数に渡す場合を考えます。

let isMetabolic threshold person =
    let bmi = person.Weight / (person.Height ** 2)
    threshold <= bmi

let persons =
    [ { Name = "Tarou"
        Height = 1.80
        Weight = 90.0 }
      { Name = "Jirou"
        Height = 1.60
        Weight = 55.0 }
      { Name = "Saburou"
        Height = 1.70
        Weight = 80.0 } ]

let metaboPersons =
    persons
    |> List.filter (isMetabolic 25.0)

person以外の行数を先に受け取ることで、(isMetabolic 25.0) は「personを受け取り、BMIが 25.0 以上かを判断する関数」になります。これを List.filter に渡すことで、パイプラインで素直に渡すことができます。

変数が3つ以上ある場合でも同様に、主となる対象を最後にします。そうすると persons |> List.map (func arg1 arg2) とした場合に、 func の最後の引数としてリスト内の要素が渡されます。

型と同名のモジュール

F#では、ある型に対する操作を提供する際に、同名のモジュールを作る慣習があるようです。例えば list を扱う関数は List というモジュールにあり、 List.map, List.filter のような関数が提供されます。

let a =
    [1; 2; 3]
    |> List.map (add 1)
    |> List.filter isEven

前述の isMetabolic 関数も、それが Person型 のための関数という意味合いが強いと判断できるようなら、 Person という module を作ってその中に置くのがF#流のようです。

関数とメソッド

F#にもメソッドの仕組みがあり、 x.メソッド() の形で呼ぶことができます。どちらを使うか迷いそうなところですが、関数の方がF#らしいコードになる感じがします。

関数とメソッドの違いは、F#の世界と.NETの世界の違いでもあるように思います。例えばF#の関数はクラスに属さないほか、オーバーロードできない、型推論が効くなどの特徴があります。対してクラスのメソッドはオーバーロードできる、型推論が効きづらいといった特徴があります。

演算子の自作

F# では演算子を自作することができます。例えば a .+. b で「aを10倍してbを足す」というものを作るには、以下のようにします。

let (+.+) x y = x * 10 + y

let a = 1 +.+ 2  // 12

FSharpPlus の演算子もこのような方法で作られています。

拡張子を作れるのは柔軟性をもたらしますが、言語自体の拡張しづらさに繋がるかもしれません。例えば言語仕様として新しい演算子を追加しようとした際に、「その演算子を既に使っているユーザーがいるかもしれない」といった考慮が必要になりそうです。

演算子を関数に

演算子に () を付けることで関数にできます。例えば a + b(+) a b と等価です。これを使うと、以下のような書き方ができます。

let x = [1; 2; 3] |> List.map ((+) 1)

シーケンス

Iterableなものを扱うにはseq型を使います。

let x =
    [1; 2; 3]
    |> Seq.map ((+) 1)
    |> Seq.filter isEven
    |> Seq.map ((+) 2)

これは C# でいう IEnumerable に相当します。評価は遅延されるなので、処理の中間で大きなメモリを確保せずに済みます。

to, of

Rust では型の変換に使うメソッドに .into().to(), .from() のような名前を付ける慣習があります。F#では同様のものに toXXX, ofXXX という名前を使います。例えばシーケンスをリストにする場合は Seq.toList を、シーケンスからリストを作る場合は List.ofSeq を使います (効果はどちらも一緒)。

ket-value のペアのシーケンスから map を作れる点なども Rust と似ています。

F#
let dict: Map<int, string> =
    [(1, "Apple"); (2, "Banana"); (3, "Orange")]
    |> Map.ofList   // もしくは Map.ofSeq
Rust
let dict: HashMap<i32, &str> =
        [(1, "Apple"), (2, "Banana"), (3, "Orange")]
        .into_iter()
        .collect();

おわりに

関数型言語というと敷居が高いと感じていましたが、F#はRustに似ているところも多く、案外何とかなるものだと思いました。個人的には Haskell よりもとっつきやすかったです。パイプラインや関数の合成を使うことでコードがより簡潔になっていくなど、関数型言語ならではの体験もできました。

マイナーな言語だとライブラリが少なそうに思うかもしれませんが、 F#は .NET が使えるので、その心配も無さそうです。F# はマイナーな言語ですが、C#はとてもメジャーな言語であり、多くのライブラリがあります。F#から使う際に多少の不便はあっても、.NETを使えるのはF#の利点だと思います。

F#は静的型付けの割に構文が簡潔で、手軽に感じました。実際に触ってみると、なかなか良い言語だと思います。この記事をきっかけにF#に興味を持つ人がいれば幸いです。

5
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?