Julia Functions

この文書では、関数、メソッド定義、およびメソッドテーブルの動作について説明します。

Method Tables

すべての関数はJuliaにおいて汎用関数です。汎用関数は概念的には単一の関数ですが、多くの定義、またはメソッドで構成されています。汎用関数のメソッドはメソッドテーブルに格納されています。1つのグローバルメソッドテーブル(型 MethodTable)があり、名前は Core.GlobalMethods です。メソッドに対するデフォルトの操作(呼び出しなど)は、そのテーブルを使用します。

Function calls

与えられた呼び出し f(x, y) に対して、次のステップが実行されます。まず、タプル型が形成されます。Tuple{typeof(f), typeof(x), typeof(y)}。関数自体の型が最初の要素であることに注意してください。これは、関数自体が他の引数と対称的にメソッドルックアップに参加するためです。このタプル型はグローバルメソッドテーブルで検索されます。しかし、システムはその後結果をキャッシュできるため、類似のルックアップに対してはこれらのステップを後でスキップできます。

このディスパッチプロセスは jl_apply_generic によって実行され、2つの引数を取ります:値 fx、および y の配列へのポインタと、値の数(この場合は3)です。

システム全体には、関数と引数リストを処理する2種類のAPIがあります。1つは関数と引数を別々に受け取るAPI、もう1つは単一の引数構造体を受け取るAPIです。最初の種類のAPIでは、「引数」部分には関数に関する情報は含まれていません。なぜなら、それは別々に渡されるからです。2番目の種類のAPIでは、関数が引数構造体の最初の要素です。

例えば、次の呼び出しを実行する関数は、args ポインタのみを受け入れます。したがって、args 配列の最初の要素は呼び出す関数になります:

jl_value_t *jl_apply(jl_value_t **args, uint32_t nargs)

このエントリーポイントは同じ機能のために関数を別々に受け入れるため、args 配列には関数が含まれていません:

jl_value_t *jl_call(jl_function_t *f, jl_value_t **args, int32_t nargs);

Adding methods

上記のディスパッチプロセスに基づいて、新しいメソッドを追加するために必要なのは、(1) タプル型と (2) メソッドの本体のコードだけです。jl_method_def はこの操作を実装しています。

Creating generic functions

すべてのオブジェクトは呼び出し可能であるため、汎用関数を作成するために特別なことは必要ありません。したがって、jl_new_generic_functionは単に新しいシングルトン(サイズ0)のFunctionのサブタイプを作成し、そのインスタンスを返します。関数には、デバッグ情報やオブジェクトを印刷する際に使用されるニーモニックの「表示名」があります。たとえば、Base.sinの名前はsinです。慣例として、作成されたの名前は関数名と同じで、#が前に付けられます。したがって、typeof(sin)Base.#sinです。

Closures

クロージャは、キャプチャされた変数に対応するフィールド名を持つ呼び出し可能なオブジェクトです。例えば、次のコード:

function adder(x)
    return y->x+y
end

は(おおよそ)に下げられます:

struct ##1{T}
    x::T
end

(_::##1)(y) = _.x + y

function adder(x)
    return ##1(x)
end

Constructors

コンストラクタ呼び出しは、Type{T}で定義されたメソッドへの型への呼び出しに過ぎません。

Builtins

Coreモジュールで定義されている「ビルトイン」関数は次のとおりです:

<: === _abstracttype _apply_iterate _call_in_world_total _compute_sparams
_defaultctors _equiv_typedef _expr _primitivetype _setsuper! _structtype
_svec_len _svec_ref _typebody! _typevar applicable apply_type compilerbarrier
current_scope donotdelete fieldtype finalizer get_binding_type getfield getglobal
ifelse invoke invoke_in_world invokelatest isa isdefined isdefinedglobal
memorynew memoryref_isassigned memoryrefget memoryrefmodify! memoryrefnew
memoryrefoffset memoryrefreplace! memoryrefset! memoryrefsetonce! memoryrefswap!
modifyfield! modifyglobal! nfields replacefield! replaceglobal! setfield!
setfieldonce! setglobal! setglobalonce! sizeof svec swapfield! swapglobal! throw
throw_methoderror tuple typeassert typeof

これらは主にシングルトンオブジェクトであり、すべての型は Builtin のサブタイプであり、BuiltinFunction のサブタイプです。これらの目的は、「jlcall」呼び出し規約を使用するランタイムのエントリポイントを公開することです:

jl_value_t *(jl_value_t*, jl_value_t**, uint32_t)

Keyword arguments

キーワード引数は、kwcall関数にメソッドを追加することによって機能します。この関数は通常、「キーワード引数ソーター」または「キーワードソーター」であり、その後、関数の内部本体(匿名で定義された)を呼び出します。kwsorter関数内のすべての定義は、通常のメソッドテーブルのいくつかの定義と同じ引数を持っていますが、渡されたキーワード引数の名前と値を提供する単一のNamedTuple引数が前に追加されています。kwsorterの仕事は、名前に基づいてキーワード引数をその標準的な位置に移動させ、必要なデフォルト値の式を評価して置き換えることです。その結果は、通常の位置引数リストとなり、さらに別のコンパイラ生成関数に渡されます。

プロセスを理解する最も簡単な方法は、キーワード引数メソッド定義がどのように低下されるかを見ることです。コード:

function circle(center, radius; color = black, fill::Bool = true, options...)
    # draw
end

実際には 3つ のメソッド定義が生成されます。最初は、すべての引数(キーワード引数を含む)を位置引数として受け取り、メソッド本体のコードを含む関数です。自動生成された名前を持っています:

function #circle#1(color, fill::Bool, options, circle, center, radius)
    # draw
end

2番目の方法は、キーワード引数が渡されない場合を処理する元の circle 関数の通常の定義です。

function circle(center, radius)
    #circle#1(black, true, pairs(NamedTuple()), circle, center, radius)
end

これは単に最初のメソッドにディスパッチし、デフォルト値を渡します。pairsは、キーワード引数の名前付きタプルに適用され、キーと値のペアの反復を提供します。メソッドが残りのキーワード引数を受け入れない場合、この引数は存在しないことに注意してください。

最後に、kwsorterの定義があります:

function (::Core.kwcall)(kws, circle, center, radius)
    if haskey(kws, :color)
        color = kws.color
    else
        color = black
    end
    # etc.

    # put remaining kwargs in `options`
    options = structdiff(kws, NamedTuple{(:color, :fill)})

    # if the method doesn't accept rest keywords, throw an error
    # unless `options` is empty

    #circle#1(color, fill, pairs(options), circle, center, radius)
end

Compiler efficiency issues

関数ごとに新しい型を生成することは、Juliaの「デフォルトで全引数に特化する」という設計と組み合わせると、コンパイラのリソース使用に潜在的に深刻な影響を与える可能性があります。実際、この設計の初期実装は、ビルドとテストの時間が大幅に長くなり、メモリ使用量が増加し、システムイメージがベースラインのほぼ2倍になるという問題に直面しました。ナイーブな実装では、問題が悪化し、システムがほぼ使用不可能になるほどです。この設計を実用的にするためには、いくつかの重要な最適化が必要でした。

最初の問題は、関数値引数の異なる値に対する関数の過度な特殊化です。多くの関数は、単に引数を他の場所、例えば別の関数やストレージ位置に「パススルー」します。このような関数は、渡される可能性のあるすべてのクロージャに対して特殊化する必要はありません。幸いなことに、このケースは、関数がその引数のいずれかを呼び出すかどうかを考慮することで簡単に区別できます(つまり、引数が「ヘッドポジション」に現れる場合)。mapのようなパフォーマンスクリティカルな高階関数は、確かにその引数関数を呼び出し、したがって期待通りに特殊化されます。この最適化は、フロントエンドのanalyze-variablesパス中に呼び出される引数を記録することによって実装されています。cache_methodAnyまたはFunctionとして宣言されたスロットに渡されるFunction型階層の引数を見たとき、それは@nospecializeアノテーションが適用されたかのように振る舞います。このヒューリスティックは、実際には非常に効果的であるようです。

次の問題は、メソッドテーブルの構造に関するものです。実証研究によると、動的にディスパッチされる呼び出しの大多数は1つまたは2つの引数を含んでいます。さらに、これらのケースの多くは最初の引数のみを考慮することで解決できます。(余談:シングルディスパッチの支持者はこれに全く驚かないでしょう。しかし、この議論は「マルチディスパッチは実際に最適化が容易である」という意味であり、したがって私たちはそれを使用すべきであり、シングルディスパッチを使用すべきだということではありません!)。したがって、メソッドテーブルとキャッシュは、効率的な最近傍検索を可能にするために、左から右への意思決定ツリーに基づいて構造を分割します。

フロントエンドはすべてのクロージャの型宣言を生成します。最初は、通常の型宣言を生成することで実装されていました。しかし、これにより非常に多くのコンストラクタが生成され、すべてがトリビアル(すべての引数を new に渡すだけ)でした。メソッドは部分的に順序付けられているため、これらすべてのメソッドを挿入するのは O(n²) であり、保持するにはあまりにも多すぎます。これを最適化するために、struct_type 表現を直接生成し(デフォルトのコンストラクタ生成をバイパス)、new を直接使用してクロージャインスタンスを作成するようにしました。決して美しいものではありませんが、やるべきことをやるのです。

次の問題は、各テストケースのために0引数のクロージャを生成する@testマクロでした。これは実際には必要ありません。なぜなら、各テストケースは単にその場で一度実行されるからです。したがって、@testは、テスト結果(真、偽、または例外が発生した)を記録し、それに対してテストスイートハンドラーを呼び出すtry-catchブロックに展開されるように修正されました。