JuliaのFunction-like objectについて理解したい

どうにもとっつきにくかったJuliaの"Function-like object"を公式ドキュメントを読んで(たぶん)理解したのでメモ。

以下のコードはすべてJulia v1.4.2で確認しています。

Function-like objectの定義

"Function-like object"は、ざっくり言うと「Function型でなくても関数のように振る舞うオブジェクト」です。

もう少し細かく言えば「Function型,Type型以外でメソッドを関連付けられたオブジェクト」という認識でいい…はずです。

Juliaでは関数は「引数のタプルを返り値にマッピングするオブジェクト」、メソッドは「関数がとるかもしれない動作定義」と定義されています。Juliaでは1つの関数に対し、引数の違いによっていくつものメソッドが関連付けられているのはご存知のことと思います。

今回は公式ドキュメントに載っているサンプルコードを用いて説明していきます。

julia> struct Polynomial{R}
           coeffs::Vector{R}
       end

julia> function (p::Polynomial)(x)
           v = p.coeffs[end]
           for i = (length(p.coeffs)-1):-1:1
               v = v*x + p.coeffs[i]
           end
           return v
       end

julia> (p::Polynomial)() = p(5)

julia> p = Polynomial([1,10,100])
Polynomial{Int64}([1, 10, 100])

julia> p(3)
931

julia> p()
2551


1つずつ説明していきたいと思います。

struct Polynomial{R}
    coeffs::Vector{R}
end

まずComposite type(複合型)を定義します。
コードの気持ちとしては「多項式」という型を作っておいて、その多項式の係数coeffsを持たせておきたいということです。
例えば、Polynomial([1,2,3])なら\(1+2x+3x^2\)を表現したいわけです。

function (p::Polynomial)(x)
    v = p.coeffs[end]
    for i = (length(p.coeffs)-1):-1:1
        v = v*x + p.coeffs[i]
    end
    return v
end

早速関数定義っぽいものが出てきましたが、これがオブジェクトへのメソッドの定義です。function-like objectの要になります。

これによってPolynomialのオブジェクトを関数のように扱うことができるようになります。

普通のFunction型の関数定義と異なる点は

の2点です。特に後者は紛らわしいので注意してください。

このpはメソッドの定義をするfunctionendのスコープでのみ用いられる変数を意味します。この定義内では「p::Polynomialxを使って値を返す処理」を書くことができます。上の例ではp.coeffsを使って多項式の具体的な値を計算する処理を書いています。例えば、Polynomial([1,2,3])(2)であれば9が返ってきます。

(p::Polynomial)() = p(5)

これも1つ上と同じくPolynomialオブジェクトに対するメソッドの定義になりますが、上と違うのは引数xが無いことです。
ここでも左辺のpはこのメソッド定義内でのみ使用される変数で、Polynomialのオブジェクトを意味します。
右辺のp(5)Polynomialに既に関連付けられたメソッド(p::Polynomial)(x)を引数5で呼び出しています。

p = Polynomial([1,10,100])

ここから(ようやく)上で定義したfunction-like objectを具体的に使っていきます。

今までと異なり、この式では変数pPolynomialのオブジェクトを代入しています。例にはありませんが、ここでmethods(p)を呼び出して関連付けられているメソッドを確認してみましょう。

julia> methods(p)
# 2 methods:
[1] (p::Polynomial)() in Main at REPL[3]:1
[2] (p::Polynomial)(x) in Main at REPL[2]:2

上で定義したメソッドがPolynomialオブジェクトに関連付けられているのが確認できました。

p(3)

p()

最後の2つはまとめて説明します。Polynomialオブジェクトが代入されたpを関数のように扱っています。
ここで呼び出されているメソッドは上で確認したとおりです。

以上がfunction-like objectのサンプルコードの説明になります。
個人的にはこのサンプルコードだとメソッドの定義内で使うpと、具体的に代入される変数pが別物なのに同じ文字を使っているので混乱を招きそうな気がしました。
(せめて後者はpolyとかにしてあげればいいのに…)

ちなみに関連付けるメソッド内でそのオブジェクトのフィールドを使わないような場合は

function (::Polynomial)()
    return 0
end

のように定義内で使うpを頭に書かなくても大丈夫です。

Function-like objectかどうかの判定

あるオブジェクトがfunction-like objectかどうかの判定は以下のように関数callable()を定義してあげると便利です。

julia> callable(x) = !(isempty ∘ methods)(x)
callable (generic function with 1 method)

julia> callable(p)  # pは上のサンプルコードのPolynomial
true

julia> struct Foo end

julia> foo = Foo()
Foo()

julia> callable(foo)  # Fooのオブジェクトはfunction-like objectではないのでfalse
false


ただし、上の関数はfunction-like objectだけでなくFunctionやType(コンストラクタもメソッドなので)でもtrueを返します。

julia> a = 100
100

julia> callable(a)  # a::Intはfunction-like objectではない
false

julia> callable(Int)  # Type{Int}はコンストラクタとなるのでtrueとなる
true


ちなみにx isa Base.Callableではfunction-like objectは拾えないので注意です。(Base.CallableUnion{Function, Type}でしかないため)

Function-like objectをいつ使うべきか

基本的にはパラメータを持つ関数を作るときには便利な気がします。
もちろん、ただ関数の値を計算するだけなら

function gaussian(x; mu=0, sigma=1)
    # 適当な処理
end

のように、オプション引数にパラメータを持たせた関数を作ってもいいのですが、関数自体を対象として様々な計算を行う場合(例:関数空間での内積など)を考えるときには関数そのものをcomposite typeとして扱った方が何かと便利です。

私が初めてfunction-like objectの使用例を見たのはDiffEqNoiseProcess.jlOrnstein-Uhlenbeck過程の定義の部分でした。今回のサンプルコードの多項式のようにcomposite typeではパラメータのみを保持させ、実際の確率過程のパス生成はfunction-like objectとして関連付けられたメソッドで処理しています。

完璧に使いこなすにはまだまだハードルの高さを感じてしまいますが、より良いコードのためにも、使えそうなところでは躊躇わずに使えるようになりたいものです。