10
1

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 5

F# の隠された機能① Dynamic Lookup Operator

Last updated at Posted at 2023-12-05

おはようございます.皆さん,F# 書いてますか?

数多くの優れた特性を持つ F# ですが,個人的に F# の最大の魅力はやはり優れた DSL 構築機能ではないかと思います.コンピューテーション式やユーザー定義演算子といった構文機能やオブジェクト指向デザインの援用により最低限の記述で豊かな表現力を獲得できるのが F# プログラミングの楽しいポイントではないでしょうか.

さて,F# の機能の中には,Microsoft Learn などの標準的なドキュメントにほとんど記載がなく,ライブラリドキュメントや個人ブログを参照して初めて存在を知ることになりがちな,隠された機能ともいうべきものが存在しています.今回はそんな隠れ機能の一つである dynamic lookup operator ? / ?<- について紹介します.適切に使えば F# による DSL 設計を直感的で使いやすいものにすることができる機能です.

参考:
https://weblogs.asp.net/podwysocki/using-and-abusing-the-f-dynamic-lookup-operator

Dynamic Lookup Operator

オブジェクトに対する通常のフィールドアクセスは,当然ですが既に定義されたプロパティのみを受け付けます.

type Foo() =
    member val Foo = 334 with get, set
    member val Bar = 57 with get, set

let foo = Foo()
printfn "%d" foo.Foo
foo.Bar <- 42
printfn "%d" foo.Bar
printfn "%d" foo.Baz // コンパイルエラー :(

それに対し,任意の名前を受け付ける dynamic property の getter, setter として使えるのが dynamic lookup operator です.

let bar = Bar()
foo?Bar <- 42 // Bar に対応するメンバを定義していなくても使える :)
printfn "%d" foo?Bar

Dynamic lookup operator は以下のように ??<- という演算子を静的メンバとして定義することで使えるようになります.

open System.Collections.Generic

type Bar() =
    member val private dict = Dictionary<string, int>()

    static member (?)(this: Bar, key: string) = this.dict.[key]

    static member (?<-)(this: Bar, key: string, value: int) = this.dict.[key] <- value

? のシグネチャは定義上は Bar * string -> int ですが,使用時には通常の二項演算子のように bar ? "Bar" とはせず,直接識別子を書いて bar?Bar とします.

bar?Bar
bar?``Bar Baz`` // 識別子に使えない文字があるときは `` `` で囲む.
bar?("Bar") // 括弧で囲むと文字列を渡すことができる.

static member ではなくモジュール内に let 式で dynamic lookup を定義することも可能です.

open System.Collections.Generic

// 既存の Dictionary 型に dynamic property を実装する.
module DictionaryExt =
    let (?) (dict: Dictionary<string, int>) key = dict.[key]
    let (?<-) (dict: Dictionary<string, int>) key value = dict.[key] <- value

open DictionaryExt

let dict = Dictionary<string, int>()

dict?foo <- 1

dict?foo |> printfn "%d"

こんな使い方もできます.すごい,なんか DSL っぽい.

type Interpolator(input: string) =
    member val Result = input with get, set

    // $place を value に置換する.
    static member (?<-)(this: Interpolator, place: Place, value: string) =
        this.Result <- this.Result.Replace("$" + place, value)

let ipl = Interpolator("Hello, $name!")
ipl?name <- "World"
printfn "%s" ipl.Result // Hello, World!

Indexed property との使い分け

この機能,Microsoft の Learn F# で記載のある箇所を改めて探したのですが,Symbol and operator reference という記事に dynamic lookup operator ? / ?<- の存在が簡単に記述されているのみでした.上で述べたような具体的な実装・使用の手順はなしです.ここで述べられていることには,

They are not generally used in routine F# programming and no implementations of these operator are provided in the F# core library.

(これらは F# プログラミングのルーティンにおいて一般的に使われるものではなく,F# のコアライブラリの中ではこれらの演算子の実装は提供されていない)

だそうです.実際,dynamic property の機能が F# においてそれほど一般的な選択肢でないのには理由があります.というのもこの dynamic property,普通のケースでは indexed property で事足りてしまいます

open System.Collections.Generic

type Bar() =
    member val private dict = Dictionary<string, int>()

    member this.Item // インデクスアクセスを提供.
        with get (key: string) = this.dict.[key]
        and set (key: string) (value: int) = this.dict.[key] <- value

let bar = Bar()
bar["Bar"] <- 42
printfn "%d" bar["Bar"]

大抵の状況では indexed property と dynamic property に機能的な差異はなく,むしろ ? を用いたアクセスの方が見慣れない記法で場合によっては可読性を損ねてしまうかもしれません.

bar?Bar // 普通に使うと短くてよさそうだが,
bar["Bar"]

bar?``Bar Baz`` // 識別子に使えない文字があると `` が加わって途端に過剰になる.
bar["Bar Baz"]

let baz = "Baz"

bar?(baz) // 動的な文字列が来る時には括弧が必須な点もミスを起こしやすい.
bar[baz]
bar? baz // これだと bar?("baz") の意味になってしまう.

このようにいたずらに実装しても大きな恩恵は見込めないので,ある程度 DSL 設計の方向性を考えながら適切に取り入れる必要があります.

大雑把にいえば,dynamic lookup operator は「そこに確かにそういう名前のプロパティがあることをプログラマは(静的に)知っているが,コンパイラにそれを知らせるすべがない」という意図をもった構文であると言えるでしょう.そのため,そういった文脈を持たせるべき機能に割り当てるのが効果的です.

使用例1: データのカラム名

一つ目の用法として,CSV などのテーブルデータのカラムを取得するメンバに dynamic lookup を割り当てるというものがあります.

type Csv(source: string) =
    let lines =
        source.Split([| '\n' |]) |> Array.map (fun line -> line.Split([| ',' |]))

    member private __.Header =
        lines[0] |> Seq.indexed |> Seq.map (fun (i, x) -> x, i) |> Map.ofSeq

    member private __.Rows = lines[1..]

    static member (?)(this: Csv, col: string) : string seq =
        let i = this.Header[col]
        this.Rows |> Seq.map (fun r -> r[i])

let source =
    """
Name,Age
Alice,30
Bob,40
Charlie,50
    """
        .Trim()

let csv = Csv(source)

// Name カラムを取得.
csv?Name |> String.concat "; " |> printf "%s" // Alice; Bob; Charlie

この場合はインデクスアクセスと併せて提供し,ユーザーが必要に応じて使い分けるようにするのが良いでしょう.

let columns = ["Name"; "Age"]

for col in columns do
    // 動的に文字列を読ませる場合にはインデクスアクセスの方が意味が明確.
    printfn "%s" csv[col]

この手の実装は FSharp.DataDeedle といったデータ処理ライブラリに広く用いられています.プロパティアクセスの感覚でカラムを読み込めることでこれらのライブラリの使い勝手を大きく支えています.

一方で,F# では型プロバイダを利用することで,リソースをコンパイル時に読み込み静的メンバ付きの型を取得することができます.FSharp.Data も型プロバイダを用いて CSV リソースに型情報込みでアクセスできる仕組みを提供しています.

open FSharp.Data

[<Literal>]
let source = "./sample.csv"

// CsvProvider 型のパラメタとしてリソースファイルのパスを渡す.
// コンパイル時にリソースファイルを読み込み,どんなカラムがあるのか調べてくれる.
type Csv = CsvProvider<source>

let data = Csv.GetSample()

// リソースファイルの静的な解析により,`Name` カラムが存在することはコンパイル時に分かっている.
// 以下はエディタ上でもエラーを出さない.
data.Rows |> Seq.map _.Name |> Seq.iter (printfn "%s")

そのため,読み込むファイルのスキーマが実行前に分かっているのなら型プロバイダを利用した方が安全かつ便利であり,その点 dynamic lookup を用いるのはある意味中途半端な択といえるかもしれません.

とはいえ型プロバイダは柔軟性に欠ける側面もある上に自前で用意するのはそれなりに大変なため,軽量な DSL を提供するために dynamic property を実装しておくのはアリでしょう.

使用例2: 入力を制限したい場合

先述の通り,dynamic property には2つの構文上の問題点があります.

  • 識別子に使えない文字が来たときに読みづらくなる
  • 文字列を動的に受け取る際に括弧が必須になる
bar?``Too long property name``
let dynamicPropertyName = "Bar"
bar?(dynamicPropertyName)

しかしこれは裏を返せば特殊文字や動的な文字列を軽率に利用できないということでもあります.有効な識別子以外の文字列の使用に負荷を設けてミスを減らしたい場合には有効かもしれません.

// やや無理のある例.
// 文字列内の key:value というペアから直接 value を取り出す.
type RawKVGetter(source: string) =
    member val private Source = source with get

    static member (?)(this: KVGetter, key: string) =
        let orElse f i = if i = -1 then f () else i

        let keyIndex =
            this.Source.IndexOf(key + ":") |> orElse (fun () -> failwith "key not found")

        let valueIndex = keyIndex + key.Length + 1

        let valueEndIndex =
            this.Source.IndexOf(",", valueIndex) |> orElse (fun () -> this.Source.Length)

        let valueLength = valueEndIndex - valueIndex

        this.Source.Substring(valueIndex, valueLength)

let source = "alice:apple,bob:banana"

let kv = RawKVGetter(source)

printfn "%s" kv?alice // apple
printfn "%s" kv?``bob:`` // 不正な key を渡す際に面倒が生じることで,ミスを未然に防ぎやすくなる.

とはいえこれについては単に実行時にチェックを挟めばいいだけのことではあります.他のメリット・デメリットを考慮した上でのおまけ程度の効能と考えるのが妥当です.

使用例3: 動的型付け

F# は静的型付け言語ですが,obj 型にアップキャストしてからダウンキャストすることで擬似的な実行時型チェックを利用することができます.

let x = 334 :> obj // obj にアップキャスト.
let y = x :?> string // string にダウンキャスト.ここで実行時エラー.

ここでダウンキャスト :?> は普通なら省略不可能ですが,getter 内部で型パラメタに対するダウンキャストを行うとあたかも暗黙にダウンキャストしたかのように見せることができます.

type Baz(bar: int -> int) =
    member __.DynBar<'T>() : 'T = bar :> obj :?> 'T

let baz = Baz(fun x -> x * 2)

let foo = baz.DynBar()
foo 334 |> printfn "%d" // OK.foo は int -> int に「ダウンキャスト」される.

let foo' = baz.DynBar()
foo' 334.0 |> printfn "%d" // 実行時エラー.foo' は float -> int に「ダウンキャスト」され,型エラーは生じない.

これと dynamic lookup を組み合わせることで,F# で動的なオブジェクトを実現できます.

open System.Collections.Generic

type DynamicObject() =
    member val Dict = Dictionary<string, obj>() // obj 型として保持.

    static member (?)(this: DynamicObject, key: string) : 'TResult = this.Dict.[key] :?> 'TResult // 推論された型に内部でダウンキャストする.

    static member (?<-)(this: DynamicObject, key: string, value: 'TValue) = this.Dict.[key] <- value

let dyn = DynamicObject()
dyn?add <- fun (x, y) -> x + y // メソッドを追加.
dyn?add (1, 2) |> printfn "%d" // 使用.
dyn?add (3, 3, 4) // 型が合わないと実行時にエラーが出る.

そうか,F# は動的型付け言語だったのか!

なお,indexed property で同じことをやろうとしても上手くいきません.Item メンバの getter, setter が型パラメタを持てないからです.

type DynamicObject'() =
    member val Dict = Dictionary<string, obj>()

    member this.Item
        with get (key: string): 'TResult = this.Dict.[key] :?> 'TResult // 'TResult, 'TValue をパラメタにすることはできない.
        and set (key: string) (value: 'TValue) = this.Dict.[key] <- value

let dyn' = DynamicObject'()
dyn'.["add"] <- fun (x, y) -> x + y // ここで 'TValue 型がジェネリクスを失ってしまう.
dyn'.["x"] <- 334 // ここで型エラー.

これを利用して F# で動的型付けを全力活用するライブラリが FSharp.Interop.Dynamic です.

もちろん実用性はあって,たとえば Python のような動的型付け言語のインタプリタを呼び出す場合によりネイティブに書くことができるようになります(以下は FSharp.Interop.Dynamic のサンプルコードより引用).

open Python.Runtime
open FSharp.Interop.Dynamic
open FSharp.Interop.Dynamic.Operators

do
  use __ = Py.GIL()

  let np = Py.Import("numpy")
  np?cos(np?pi ?*? 2)
  |> printfn "%O"

  let sin: obj -> obj = np?sin
  sin 5 |> printfn "%O"

  np?cos 5 ?+? sin 5
  |> printfn "%O"

  let a: obj = np?array([| 1.; 2.; 3. |])
  printfn "%O" a?dtype

  let b: obj = np?array([| 6.; 5.; 4. |], Dyn.namedArg "dtype" np?int32)
  printfn "%O" b?dtype

  a ?*? b
  |> printfn "%O"

最小限の型アサーションで Python のコードをトレースできていることがわかります.

まとめ

今回は F# の地味ながら面白い機能である dynamic lookup operator を紹介しました.使いどころはやや難しいですが,適切に DSL 設計に組み込むことで色々な面白い使い方ができそうな機能です.ぜひ使いこなしてみてください.

10
1
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
10
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?