7
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

F#Advent Calendar 2023

Day 4

F# 8 で追加された便利な機能の紹介

Last updated at Posted at 2023-12-04

先月 .NET 8.0 のリリースがあり、F#もバージョン8が正式リリースされました。
F# 8は大規模なアップデートとなっており、様々な機能が追加されました。
その中で私が特に便利そうだと感じた機能をいくつか紹介します。

_.Property 記法

プロパティ取得関数が簡単に書けるようになった!

ある型のプロパティを抽出する関数を書く時に、今までは fun x -> x.Property と書く必要がありましたが、もっと短く _.Property と書けるようになりました。

例えば以下のような人物リストがあるとします。

type Person = { Name: string; Age: int }
let persons = [
  { Name = "Alice"; Age = 15 }
  { Name = "Bob"; Age = 20 }
  { Name = "Carol"; Age = 25 }
]

名前だけを抽出したい場合、従来のF#では以下のように書く必要がありました。

let names = persons |> List.map (fun person -> person.Name)

// 結果
val names: string list = ["Alice"; "Bob"; "Carol"]

F#8からは以下のようにもっと短く書くことができます。

let names2 = persons |> List.map _.Name

// 結果
val names2: string list = ["Alice"; "Bob"; "Carol"]

この書き方が使えるのは map の引数に限定されません。
例えば人物リストの年齢の平均を averageBy 関数を使って求める時にも以下のように書けます。
Ageint 型なのですが int 型のままでは平均を取る操作に対応していないので、関数合成 >> を使って Age の値を float 型に変換しています。

let ageAverage = persons |> List.averageBy (_.Age >> float)

// 結果
val ageAverage: float = 20.0

そもそもラムダ式の引数以外の箇所でもこの書き方を使うことができます。
以下の例では人物の名前を取得する関数を定義しています。
_.Namefun x -> x.Name と同じということを考えれば当然でしょう。

let getName = _.Name

// 結果
val getName: _arg1: Person -> string

filter 系関数でも使いたいけど…

人物リストから成人のみを抽出したい場合、従来のF#では以下のように書きます。

let adultPersons = persons |> List.filter (fun person -> person.Age >= 20)

// 結果
val adultPersons: Person list = [{ Name = "Bob"
                                   Age = 20 }; { Name = "Carol"
                                                 Age = 25 }]

これを _.Property 形式の書き方で書ければ便利なのですが、残念ながら以下のコードはコンパイルエラーとなります。

let adultPersons2 = persons |> List.filter (_.Age >= 20)

// 結果
error FS0001: この式に必要な型は
    'Person -> bool'
ですが、ここでは次の型が指定されています
    'bool'

_.Property はあくまで fun x -> x.Property の書き換えだということを思い出せば上記がエラーになってしまうことも納得できます。

// こんな書き方はできない
let adultPersons2 = persons |> List.filter ((fun x -> x.Age) >= 20)

関数合成を使うことで無理やりこの書き方を使うことができますが、特にコードが短くなっているわけでもないし従来の書き方の方が理解しやすいです。

let adultPersons2 = persons |> List.filter (_.Age >> fun age -> age >= 20)

// 結果
val adultPersons2: Person list = [{ Name = "Bob"
                                    Age = 20 }; { Name = "Carol"
                                                  Age = 25 }]

年齢判定部分をラムダ式の代わりに演算子を関数として使うことで短く書けはするのですが、非常に誤解を生みやすいコードになってしまいます。

let adultPersons3 = persons |> List.filter (_.Age >> (<=) 20)
// "(<=) 20" は "fun x -> 20 <= x" と等しい
// 20歳以上なら >= 20 と書ければ理解しやすいのに (<=) 20 と逆向きに書かないといけないので誤解を生みやすい

// 結果
val adultPersons3: Person list = [{ Name = "Bob"
                                    Age = 20 }; { Name = "Carol"
                                                  Age = 25 }]

以下のような flip 関数を導入することで不等号の向きを直感と一致させることができます。
flip の位置に Age の値が来ていると考えて読めばすっきり読めると思います。
この flip 関数は FSharpPlus ライブラリなどでも実装されています。

let inline flip f x y = f y x
let adultPersons4 = persons |> List.filter (_.Age >> flip (>=) 20)

// 結果
val adultPersons4: Person list = [{ Name = "Bob"
                                    Age = 20 }; { Name = "Carol"
                                                  Age = 25 }]

ネストしたレコードのフィールドの変更

F#は「レコード」を使って値オブジェクトを簡単に定義することができます。
上記の Person 型もレコードでした。

このレコードのフィールドを別のレコードにしたいケースは非常によく出てきます。
例えば上記 Person 型を少し変更して Name フィールドを別のレコードにしてみます。

type PersonName = { FirstName: string; FamilyName: string }
type Person = { Name: PersonName; Age: int }

let person = { Name = { FirstName = "John"; FamilyName = "Doe" }; Age = 42 }

// 結果
val person: Person = { Name = { FirstName = "John"
                                FamilyName = "Doe" }
                       Age = 42 }

この人物が歳を取ったので年齢を変更したデータが必要になりました。
F#は不変性を重視するので、普通にレコードを定義するとすべてのフィールドが変更不可能となります。
この Person 型も既に作成されたインスタンスのフィールド値を変更することはできません。
その代わり、一部のフィールドの値を変更した新しいレコードのインスタンスを簡単に作成する機能があります。

// Age の値だけ変更してその他の値は person のまま維持した新しいインスタンスを作成
let person2 = { person with Age = 43 }

// 結果
val person2: Person = { Name = { FirstName = "John"
                                 FamilyName = "Doe" }
                        Age = 43 }

上記の例ではレコードがネストしていない Age フィールドを書き換えていたのでシンプルに書けましたが、レコードがネストしている場合、例えば Name フィールドの FamilyName だけ変更したい場合は従来だと面倒な書き方をする必要がありました。

// Name.FamilyName だけ変更したいのに Name 全体を変更するような書き方が必要だった
let person3 = { person with Name = { person.Name with FamilyName = "Hoge" } }

// 結果
val person3: Person = { Name = { FirstName = "John"
                                 FamilyName = "Hoge" }
                        Age = 42 }

F#8からはネストしたレコードについてもシンプルな書き方ができます。
非常に便利ですね!

let person4 = { person with Name.FamilyName = "Hoge" }

// 結果
val person4: Person = { Name = { FirstName = "John"
                                 FamilyName = "Hoge" }
                        Age = 42 }

フィールド名と型名が同じ場合は注意

F#ではフィールド名と型名に同じ名前を使うことができるため、よく同じ名前が使われます。
上記例の PersonName 型の名前を Name 型に変更してもコンパイルは問題なく通ります。

type Name = { FirstName: string; FamilyName: string }
type Person = { Name: Name; Age: int }

let person5 = { Name = { FirstName = "John"; FamilyName = "Doe" }; Age = 42 }

// 結果
val person5: Person = { Name = { FirstName = "John"
                                 FamilyName = "Doe" }
                        Age = 42 }

フィールド名と型名が同じ場合にネストしたレコードのフィールドを変更したい場合、今まで紹介した書き方だとエラーとなってしまいます。

let person6 = { person5 with Name.FamilyName = "Hoge" }

// 結果
error FS0001: この式に必要な型は
    'Person'
ですが、ここでは次の型が指定されています
    'Name'

実はこのレコード変更記法では変更したいフィールド名の前にレコードの型名を修飾することができます。
この書き方をすればエラーを回避できます。

let person7 = { person5 with Person.Name.FamilyName = "Hoge" }

// 結果
val person7: Person = { Name = { FirstName = "John"
                                 FamilyName = "Hoge" }
                        Age = 42 }

他にも便利な機能が追加されています!

今回は紹介しなかった新機能の中にも便利なものがたくさんあります。
個人的には末尾再帰関数かどうかをチェックしてくれる [<TailCall>] 属性が追加されたのも嬉しいのですが、筆者の環境(VSCode)ではなぜか警告を出してくれなかったので今回は取り上げませんでした。
他の新機能についても今後紹介したいと考えています。

これを機にF#を書いてみたいって人が増えることを願っています!

7
0
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
7
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?