LoginSignup
158
88

F# 8 のリリースで F# が最強の言語になってしまった件

Last updated at Posted at 2023-11-23

おはようございます.
遅ればせながら,11/14/2023 の .NET 8 のリリースの内容を確認し,コードジェネレータの新しい最適化機構(PGO)のデフォルト有効化や AI/LLM のアプリケーションへの統合,各フレームワークのアップデートや,C# の方ではコレクション記法の統合など様々なトピックがある中で,付随してリリースされた F# 8 についても新機能をチェックしてみました.

ヤバすぎました.

私は涙しました.これまであらゆる F#er が望んでも得られなかったものがそこにはありました.F# という言語がこれまでの不満点を一気に払拭し,至高の領域に到達しようというヤバみを感じるリリースだったので,今回は以下のブログポストに記載されている新機能から個人的に凄いと思ったものを抜粋して解説します.

11/24/2023 一部サンプルコードのミスを修正しました.

F# および F# 8 について

F# をご存じない方向けに説明しておくと,F# は Microsoft (Research) が開発した .NET Framework の関数型言語で,

  • 他の .NET 言語との極めて高い相互運用性
  • マルチパラダイムで OOP と FP をバランスよくサポートする柔軟性の高さ
  • インデント記法やパイプライン演算子などにより他の関数型言語とも一線を画す書きやすさ・読みやすさ
  • コンピューテーション式と呼ばれる多機能かつ扱いやすい言語内 DSL
  • メタプログラミング機構

などの優れた特性によりWeb(フロントエンドバックエンド),モバイルアプリクラウドゲーム開発データサイエンス機械学習などありとあらゆる用途に用いることができる言語です.

一見すると何故全然流行っていないのか分からないほど優秀な F#1 ですが,.NET Framework のアップデートと並行して言語機能のさらなる拡張やパフォーマンス改善を続けており,11/14/2023 に .NET 8 の一部分として F# 8 がリリースされました.

(.NET 8 側の記事から若干ハブられ気味なのは気になるところですが)リリース内容は F#er が絶対に欲しかった機能を詰め込んだ最高の仕上がりとなっています.早速内容を見ていきましょう.

言語仕様

_.Property 構文

ヤバいです.これが欲しくなかった F#er はいないはずです.

どういうことかというと,今まで F# ではクラスやレコードのメンバにアクセスする関数を短く定義する構文が存在しませんでした.

従来の構文(そのまま書いた場合)

type Student =
    { Name: string
      Class: string }

let students =
    [ { Name = "Alice"
        Class = "A" }
      { Name = "Bob"
        Class = "B" }
      { Name = "Carol"
        Class = "A" }
      { Name = "Dave"
        Class = "C" } ]

let classes =
    students
    |> List.groupBy (fun st -> st.Class)
        // ラムダ式を使って Class を取り出す必要があった
    |> List.map (fun (cls, grp) ->
        cls, grp |> List.sortBy (fun st -> st.Name))

このように (fun x -> x.Property) という式を一々書かなければならないのは F# 的には非常なストレスなので,通常このような場合には Student の型にプロパティにアクセスするだけの静的メンバを作成して用います(または同名の Student モジュールを作成してその中に関数を定義する).

従来の構文(多少改善した場合)

type Student =
    { Name: string
      Class: string }

    static member name this = this.Name
    static member class_ this = this.Class

let students =
    [ { Name = "Alice"
        Class = "A" }
      { Name = "Bob"
        Class = "B" }
      { Name = "Carol"
        Class = "A" }
      { Name = "Dave"
        Class = "C" } ]

let classes =
    students
    |> List.groupBy Student.class_
    |> List.map (fun (cls, grp) ->
        cls, grp |> List.sortBy Student.name)

しかし static member foo this = this.Foo というボイラープレートを一々書かなければならないのが気になります.また使用する側も,型推論で Student 型だと分かっているのに毎回 Student.Foo と書かなければならないのも違和感を感じるところです2.Scala のように単に _.Foo と書くことができれば解決するのですが......

F# 8 での構文

type Student =
    { Name: string
      Class: string }

let students =
    [ { Name = "Alice"
        Class = "A" }
      { Name = "Bob"
        Class = "B" }
      { Name = "Carol"
        Class = "A" }
      { Name = "Dave"
        Class = "C" } ]

let classes =
    students
    |> List.groupBy _.Class
    |> List.map (fun (cls, grp) ->
        cls, grp |> List.sortBy _.Name)

できます.F# 8 なら.

私はあまりにもこれが欲しすぎて一時期 F# に _.Property 記法だけを追加した構文拡張を自作しようと計画していたくらいなのですが,普通に F# 8 がすべてを解決してしまいました.ありがとう F#.ありがとうすべての F# コントリビュータ.

もちろん引数付きのメンバにもアクセスできますし,メンバがコレクションなら続けてインデクスアクセスを行うこともできます.ネストしたメンバアクセスも問題なくできます.

foo |> _.ToString()
foo |> _.Students[334]
foo |> _.Bar.Baz.Qux

引数として関数を渡す必要のある場面でなくても,単にパイプラインの途中や最後に気軽にメンバアクセスを挟めるだけでも便利です.

foo
|> proc1
|> _.Bar
|> proc2
|> _.Baz

上の式は従来はこのように書かなければなりませんでした.

(proc2 (proc1 foo).Bar).Baz // パイプラインを諦める
((foo |> proc1).Bar |> proc2).Baz // 無理やりパイプライン
foo
|> proc1
|> (fun x -> x.Bar)
|> proc2
|> (fun x -> x.Baz) // ラムダ式でメンバアクセス.ここまでしたくはないかも......

このような可読性が低く煩わしい括弧のネストや冗長なボイラープレートを書くことなく,パイプラインで線形に処理の流れを記述できるのは非常に快適です.

これまでは型・クラスを定義する際に通常のメンバ foo.Bar() とパイプラインで用いるための静的メンバ Foo.bar foo を両方定義しておいたものです.つまり一つの実装に必ず二つのメンバを定義する必要があったも同然なわけなのですが,_.Property が来ればわざわざもう一つメンバを定義して他方のメンバを呼び出すボイラープレートは必要なくなります.圧倒的感謝.

type Foo =
    // ...
    
    member this.Bar() = // ...

    static member bar this = this.Bar()
    // ↑これがなくても良くなる!

さらに _.Property 構文を let inline 内で使用すると,インタフェースを定義することなく特定のメンバを持つあらゆる型に対してメンバアクセスを行う関数として使えます.マジですか?

let inline getName (x: 'a when 'a: (member Name: string)) = x |> _.Name // Name メンバを持っている型の値ならなんでも受け付ける

F# の経験のない方にはたかが構文糖の一つと思われるかもしれないですが,前述のとおりこの構文の登場によってすべての型のメンバ数を半分にできるという凄まじい効果が期待でき,この一見しょうもない構文糖はその実 F# プログラミングという営みを根底から変革する機能であることは言うまでもありません.すみませんちょっと盛ったかも.

すこし注意点として,Deedle などのライブラリでは通常のメンバは C# 向け(副作用あり)で静的メンバは F# 向け(副作用なし)となるように設計されていたりするので,脳死で _.Property 構文に置き換えずドキュメントをよく読んで使用する必要は(当然ですが)あります.

df |> _.AddCol(col, ser) // df 自体を更新する.
let df' = df |> Frame.addCol col ser // df は変化せず,df を更新した新しい値が作成される.

あと欲を言うなら Scala のように _ + 2 的な演算子のプレースホルダも実装してほしいところではありますが,アンダースコア _ はワイルドカードパターンとして使用されている構文要素であり,後からむやみに構文拡張するのも危ういのかもしれません.実際,以下のような式は _ の意味の曖昧さを生むので警告(FS3570)が出るそうです.

fun _ -> 5 |> _.ToString()

ともかく個人的には _.Property 構文だけで十分満足です.次の個人開発では _.Property を使ってどんな素敵なコードが書けるのだろうと胸を躍らせています.F# 最高,愛してる.

ネストしたレコードのコピー・更新構文

これも待望の機能でしょう.レコード作成の記述量を大幅に削減するものになります.

前提として,F# には「レコードをコピーして値の一部を更新する」という操作を簡単に行う構文が存在します.

let foo =
    { Foo = 334
      Bar = 57 }

let bar =
    { foo with Bar = 42 } // { Foo = foo.Foo; Bar = 42 } と同じ

しかし,この構文はレコードのネストしたフィールドを直接更新することができないという不便さがありました.

従来の構文

let foo =
    { Foo = 334
      Bar =
        { Baz = 334
          Qux =
            { Quux = 334
              Corge = 57 } } }

let bar =
    { foo with
        Bar =
          { foo.Bar with
              Qux =
                { foo.Bar.Qux with
                    Corge = 42 } } }

これを改善し,ネストの深いフィールドを直に更新できるようになったのが今回の新機能になります.

F# 8 での構文

let bar =
    { foo with Bar.Qux.Corge = 42 }

これも非常にありがたいです.従来の記法では利便性や設計上の都合からフィールドをネストさせると構文上フィールドの更新が書きづらくなるというジレンマを抱えることになっていたところ,しっかりと需要に応える形で構文の改善が行われたのは素晴らしいと感じます.

ただし,メンバ名と型名が同じ場合には明示的に型名を指定しないと少しややこしいエラーで困ることになるようなので注意が必要です.

type Class = {
    Grade: int
    Name: string
}
type Student = {
    Name: string
    Class: Class
}

let alice = { Name = "Alice"; Class = { Grade = 6; Name = "A" } }

let alice0 = { alice with Class.Name = "A" } // これはエラー.
// Class.Name と書くと「型 Class のフィールド Name」と解釈されてしまう.
let alice1 = { alice with Student.Class.Name = "A" } // これは OK.
// Student.Class.Name なら「型 Student のフィールド Class のフィールド Name」と解釈される.

コンピューテーション式における while! の追加

コンピューテーション式に,文脈付きのコンディションを受け付ける while! が追加されました.while! についてはこちらの記事にもまとめられています.

F# にはコンピューテーション式と呼ばれる言語内 DSL を定義して用いることのできる仕組みがあります.特に Haskell における do 式のようにモナディックな文脈のついた計算を直感的に記述する用途で広く用いられて,そのための便利な構文が様々用意されています.
たとえば非同期処理を記述するコンピューテーション式 task の中では,Task<T> 型の値を await して T 型の値を取り出す処理を以下のように書けます.

task {
    // ...
    // fooTask: Task<T>
    let! foo = fooTask
    // foo: T
    // ...
}

let!do!return! などの構文により,文脈に包まれた値を純粋な値として取り出して残りの処理を書くことができるため,通常の文脈のない計算と近い感覚で記述することができ,可読性も高くなるという利点があります.

さて,コンピューテーション式の中で while ループを行う場合,条件式の部分が文脈に包まれている状況では非常にややこしい書き方をしなければなりませんでした.

従来の構文

task {
    let! firstCond = getCondition // 非同期処理を await して bool 値を受け取る
    let mutable cond = firstCond // 受け取った bool 値を一度可変引数に代入
    while cond do // これを評価してループに入る
        do! someProc
        let! nextCond = getCondition // 再び非同期処理を await して bool 値を受け取る
        cond <- nextCond // cond に nextCond を代入
    // ...
}

F# 8 では以下のように書けます.

F# 8 での構文

task {
    while! getCondition do
        do! someProc
    // ...
}

素晴らしいですね.あるべき姿になったという感じがします.

while! はビルダーの _.Bind の実装を利用する形になるようで,新しく _.While のようなメソッドをビルダーに手で実装する必要はなくすぐに使用できるようです.

なお,コンピューテーション式にはもともと if!match! のような構文も存在していません3.while ループに限り,上のような煩わしい書き方を避ける目的で導入されたようです.

// if! がなくてもそこまで煩雑にはならない
task {
    let! cond = getCondition
    if cond then
        // ...
}

また,以下のようにそもそも while ループではなく再帰関数を使うことで同様の処理を実現するという手もありますが,コンピューテーション式のビルダーの実装によっては最終的な処理が末尾再帰にならない可能性があるようで,while! の導入はパフォーマンス面での利点もあってのものであったようです.

task {
    let rec loop() = task {
        let! cond = getCondition
        if cond then
            do! someProc
            return! loop()
        return ()
    }
    return! loop()
}

cf. while! の issue

リテラルに関する機能の充実

F# では [<Literal>] アトリビュートを付けることでコンパイル時に計算されリテラルとして扱われる定数を定義することができます.

[<Literal>]
let awesomeDLL = "bin/awesome.dll"

// コンパイル時に awesomeDLL が "bin/awesome.dll" に置き換えられる.
// このように外部関数のインポートや型プロバイダなどに
// コンパイル時にファイルパスを渡すといった用途で用いられることが多い.
[<DllImport(awesomeDLL, CallingConvention = CallingConvention.Cdecl)>]
extern void AwesomeGreeting()

F# 8 では,この [<Literal>] アトリビュートについてできることが格段に増えました.

文字列リテラルを文字列フォーマットとして使う

まず,文字列リテラルを printfn などの関数に渡す文字列フォーマットとして使用できるようになりました.

[<Literal>]
let awesomeTuple = "《%s;%f》"
[<Literal>]
let awesomeHeader = "AWESOME"
[<Literal>]
let awesomeFormat = awesomeHeader + awesomeTuple

printfn awesomeTuple "YEAH" 3.34
// 《YEAH;3.34》
printfn awesomeFormat "FOO" 5.7
// AWESOME《FOO;5.7》

これにより文字列フォーマットのパーツ単位での再利用が可能になりました.

数値リテラルの算術演算

これまでの F# では,リテラル文字列同士を結合するくらいの演算をコンパイル時に行うことはできましたが,数値の算術演算をリテラルで記述することができませんでした.

従来の書き方

let [<Literal>] kilo = 1000f
let [<Literal>] mega = 1_000_000f
let [<Literal>] giga = 1_000_000_000f
let [<Literal>] tera = 1_000_000_000_000f

let [<Literal>] gigaByte = 1073741824

let [<Literal>] mask = 0b11111100uy

F# 8 では,数値に対する +, -, *, /, %, &&&, |||, <<<, >>>, ^^^, ~~~, ** や bool 値に対する not, &&, || といった演算をリテラル内に書けるようになりました.

F# 8 での書き方

let [<Literal>] kilo = 1000f
let [<Literal>] mega = kilo * kilo
let [<Literal>] giga = kilo ** 3f
let [<Literal>] tera = mega * mega

let [<Literal>] gigaByte = 1 <<< 30

let [<Literal>] mask = ~~~ 0b11uy

もちろんリテラル定義内の演算はコンパイル時に計算され,実行時の追加のコストは発生しません.

コンパイル時定数計算については C++, D, Rust, Zig などより進んだ機能を持った言語が存在していますが,パラメータ付きの型プロバイダなどコンパイラにリテラルを渡して利用させる場面のある F# でもリテラルの記述性の向上には需要があったはずです.constexpr 相当の高機能なコンパイル時計算の追加には消極的な姿勢であるようですが(cf. https://github.com/fsharp/fslang-suggestions/issues/804 ),今後定数文脈である種の純粋関数の計算がコンパイル時に行えるような機能拡張が実現する可能性もあるかもしれません.

// あったらいいな
let [<Literal>] xs = "334"
let [<Literal>] len = xs.Length

型制約の交叉記法

これも凄いです.複数のインタフェース制約を課した型引数をシンプルに書ける機能です.

F# で引数にインタフェース制約を課したジェネリック関数を定義するには以下のいずれかを使います.

// インタフェース IFooable を実装する型を引数にとる関数 f を定義したい.

// 1. 直接インタフェースを引数型にする.
let f (x: IFooable) = // ...

// 2. 引数の型を型パラメータ 't にし,'t に型制約を課す.
let f (x: 't when 't :> IFooable) = // ...

単一のインタフェース制約なら 1. を使えた方が短くてわかりやすいのですが,複数の制約を課す場合には 2. の方式で書くしかない状況がありました.

従来の書き方

let f (x: 't when 't :> IFooable and 't :> IBarable and 't :> IBazable) = // ...

一々 and 't :> と書かなければならないのがいかにも気持ち悪いですね.

これを以下の記法でシンプルに書くことができるようになりました.

F# 8 での書き方

let f (x: 't & #IFooable & #IBarable & #IBazable) = // ...

これは上の when ... and ... の構文糖であって,実際に交叉型 T & U というものが型システムに追加されたわけではないようなのですが,あたかも交叉型であるかのように型の中で使用することが出来ます.

type IQuxable =
    abstract qux: #IFooable & #IBarable & #IBazable -> unit

見た目のインパクトは大きいですが,実際には従来のスタイルを自然に短縮して使用感を向上させる構文であることがわかります.いいですね.

"一貫性"(uniformity)に関する変更

特に型定義において,一部の場面でしか使えなかったステートメントがより広い範囲で使用できるようになりました.

インタフェース内の static member

インタフェースの中に static member が書けるようになりました.

従来の書き方

[<Interface>]
type IFooable =
    abstract member Foo: string -> unit

module IFooable =
    let defaultFoo(a) = a.Foo "foo"

F# 8 での書き方

[<Interface>]
type IFooable =
    abstract member Foo: string -> unit
    static member defaultFoo(a) = a.Foo "foo"

従来は同名の module を用意してそこに定義する必要のあった静的メンバがインタフェース内に直に書けるようになっています.今回のリリースはこのような気になるボイラープレートの削減が目立ちますね.

なお,static abstract とは別物です.

type IFoobarable =
    abstract member Foo: string -> unit
    static abstract bar: string

通常のクラス以外の型での static binding の使用

Static binding というのは static letstatic member val のような,型の最初のコンストラクト時に評価されその後共通して使用される値を定義するステートメントのことです.これまでは通常のクラス定義 type Foo() = の中でしか許容されていませんでしたが,このたび非交和型やレコード,構造体,プライマリコンストラクタを持たない型でも使用できるようになりました.

type Foobar =
    | Foo of index: int
    | Bar of index: int

    static let mutable fooCount = 0
    static let mutable barCount = 0

    static do printfn "Initial construct has been done :)"

    static member tryParse s =
        match s with
        | "foo" ->
            Interlocked.Increment(&fooCount)
            Some(Foo fooCount)
        | "bar" ->
            Interlocked.Increment(&barCount)
            Some(Bar barCount)
        | _ -> None        

コンパイラ解析

コンパイラの新しい警告や,プログラマが付与できる新しいアトリビュートに関する変更です.

  • 末尾呼び出しであることをチェックする [<TailCall>] アトリビュートの追加
  • 静的クラス = [<Sealed>] かつ [<AbstractClass>] な型に関する新しい警告
  • 型やメンバが廃止されたことをマークする [<Obsolete>] アトリビュートの追加
  • 型アノテーションなく値が obj に型推論された場合に警告が出るようになった
  • コピー・更新構文で全てのフィールドを変更しようとした場合に警告が出るようになった

特に [<Obsolete>] は逆にいままでなかったんだという感じもしますが,これにより非推奨 API の使用を確実に検知することができ,ライブラリのアップデートをより安全に行えるようになりました.[<TailCall>] も,絶対に末尾呼び出しでないとまずい再帰のパフォーマンスを手軽に保証できるようになり良さげです.

"QoL の向上"

F# プログラミングを快適にするための,IDE,Language Server 向けの様々な改善がありました.中でも parser recovery と呼ばれる機能に大きな強化があったようです.

Parser Recovery

コードのある部分に不正な部分があって構文解析が失敗した場合,その場でパーサがアボートしてしまうと IDE においてそれ以降の解析情報がまったく得られないという事態になってしまいます.

let x = // 書き途中の式.
// 上の行が完結していないため,次の行は構文エラーになる.
let y = 334
// ここでパーサが止まってしまうと,これ以降のステートメントが全く解析されない.
// でも最初の行を完成させるために以降の式の型情報などを確認したいこともある.
let z = 57

それに対し,ある部分が失敗しても残りの部分をできるだけ解析しようという仕組みが parser recovery (パーサの復帰)です.

F# 8 では,この parser recovery が大幅に改善され,上のような完結していない宣言や不正なインデント,= の欠落といったありがちなミスからより高い精度で復帰できるようになったようです.

Parser recovery の改善は今回のリリースで最も多くプルリクされた項目であるようで,体験の向上のため多くの努力が注がれたことがうかがえます.大感謝.

Strict indentation rules

上記の parser recovery の改善の一環として,これまで警告しか出なかったインデントルールをコンパイルエラーにするモードがデフォルトで有効化されました.

自動補完の改善

各種エディタでより多くの構文が自動補完されるようになりました.

  • パターンにおけるレコードの補完
  • パターンにおけるユニオンフィールドの補完
  • 戻り値の型のアノテーション
  • オーバーライド時のメソッドの補完
  • パターンマッチにおける定数値の補完(System.Double 型など)
  • 列挙型の値の式
  • ラベルに基づいたユニオンケースのフィールド名の提案
  • 匿名レコードのコレクションの補完
  • アトリビュートにおける指定可能なプロパティの補完

エディタにおける F# コードの補完精度は Rust などと比べると不便に感じることも多かったのでこの改善は個人的にかなりおいしいです.

[<Struct>] 付きのユニオンが 49 個を超えるケースを持てるようになった

従来の F# では [<Struct>] アトリビュート付きのユニオンが 50 以上のケースを持っていると実行時エラーが出るバグが存在していたようで,これが修正されたみたいです.

そのほかにもさまざまな改善が行われたようです,このあたりの細やかな便利さは本格的に使い倒していくなかで徐々に実感できるのでしょう.楽しみです.

コンパイラパフォーマンス

F# のコンパイラは速度面が大きな課題と見なされており,今回のリリースでも無数の改善が行われました.

特に F# のコンパイラは歴史的にシングルスレッドで実装されており,ここをいかに並列化し改善していくかというのが大きなテーマであったようです.3つの実験的なコンパイルオプションが新たに追加され,テスト環境では大幅なパフォーマンスの改善が見られています(cf. https://github.com/dotnet/fsharp/pull/14390 ).

また,開発段階でのコンパイルにおいて実装詳細を throw null に置き換えてコンパイル情報の再利用性を高める reference assembly と呼ばれる機能について,これまで活用できていなかったところを改善したようです.特にプロジェクト同士が接続しあう大規模なソリューションで大きな効果が見込めるとのことです.

まとめ

冒頭に挙げたブログポストの内容からさらに(個人的な興味を軸に)絞って紹介しました.ここに挙げていないが強力なアップデートや,ブログのほうにも記載されていない細かい改善はまだまだたくさんありますので,気になる方は元記事を読む,または F# の提案リポジトリ にて詳細をチェックしてみてください.

F# は相互運用性後方互換性を非常に重要視する言語で,なおかつユーザが演算子や DSL などを自ら定義できることもあり,言語レベルで改善するべき課題というのは意外と少ないように思います.そのなかでも使っていく中で不便だと感じるポイントは確実にあり,今回のリリースではそのような不満点がしっかりと解消され,F# 本来のスマートかつ信頼感のある書き味が改めて強調されたように感じます.

ここ最近は F# から心が離れかけていましたが,F# 8 のリリースで改めて胸を張って F# は凄い言語なんだと人々に喧伝する用意が整ったように感じています4あとはもっと普及すれば完璧ですね.みんな,F# を書こう.

11/25/2023 追記

F# が流行っていない理由について思った以上にコメントを頂いて若干困惑しています.個人的にはそもそも F# という選択肢が十分に認識されていない現状をどうにかしないことには利点・欠点が正当に評価されたうえで流行っていないのだとは言えないと思うので,その辺は自分もこういった記事や個人開発を通じて微力ながら貢献していきたいところですね.

モダンなフレームワークも(知られていないだけで)強力なものが揃っていますし,少なくとも趣味開発用途では F# はかなり優れた言語であると思っているのですが,どうにも趣味開発言語としてのシェアも TypeScript あたりに食い尽くされてる感じがありますね.ユーザー層的には Haskell にすら負けている気がします.やはりそもそも全然認知度がないのを何とかするべきなのでは.

あと F# をマイナー言語と呼ぶのはやめてください.F* が泣いてしまいます.

  1. Java に対する Scala/Kotlin の構図と異なり,C# が普通に良い言語なので移行が進まないというのはあるようです.

  2. そんなこと言ったら List.groupBy やら List.map もそうだと言われそうなのですが,実は F# のインライン関数という仕組みを使うと単に mapgroupBy と書くだけでコレクションの型に応じて適切な関数を(静的に)割り当てる多相版のコレクション操作を実装することができ,FSharpPlus というライブラリにそのような実装がまとめられています.

  3. Issue はありました.

  4. F# オタクを自称しつつもリポジトリの最新状況を全然チェックしていなかったことについて反省したので,これからはどんどん追っていこうと思います.

158
88
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
158
88