Engee documentation

Features in Julia

This chapter describes how functions, method definitions, and method tables work.

Method tables

Any function in Julia is universal. A universal function is essentially a separate function, which, however, consists of many definitions or methods. The methods of the universal function are stored in the method table. Method tables (type MethodTable') are associated with the names `TypeName'. `TypeName describes a family of parameterized types. For example, Complex{Float32} and Complex{Float64} have a common name object of type `Complex'.

In Julia, all objects can potentially be called, because each object has a type, which, in turn, is associated with TypeName.

Function calls

When calling f(x, y), the following actions are performed: first, the corresponding method table is accessed in the form of typeof(f).name.mt `. Then a tuple of argument types is formed.: `Tuple{typeof(f), typeof(x), typeof(y)}. Note that the first element is the type of the function itself. The reason is that the type may have parameters, which may require dispatching. The tuple type is searched in the method table.

The dispatching process is performed by the jl_apply_generic function, which takes two arguments: a pointer to an array of values f, x and y and the number of values (in this case 3).

The system has two types of APIs for working with functions and argument lists: some of them accept functions and arguments separately, while others accept a single argument structure. When using the API of the first type, the part with information about the arguments does not contain information about the function, since it is passed separately. When using the second type of API, the function is the first element of the argument structure.

For example, the following function takes only a pointer to args to make a call, so the first element of the args array will be the function being called.

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

This entry point with the same purpose accepts the function separately, so the args array does not contain the function.

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

Adding methods

In the dispatching process described above, all that is needed to add a new method is (1) the tuple type and (2) the code for the method body. This operation is implemented by the jl_method_def function. To extract the corresponding method table from the type of the first argument, jl_method_table_for is called. This is a much more complex process than the corresponding procedure during dispatch, since the type of the tuple of arguments can be abstract. For example, the following definition:

(::Union{Foo{Int},Foo{Int8}})(x) = 0

it will work because all possible methods corresponding to it will be included in the same method table.

Creating universal functions

Since any object can be called, nothing special is required to create universal functions. Therefore, 'jl_new_generic_function` simply creates a single subtype of Function (zero size) and returns its instance. A function can have a mnemonic display name that is used in debugging data and in outputting information about objects. For example, the name for Base.sin is sin. By convention, the name of the type being created matches the name of the function, preceded by the character #. Therefore, typeof(sin) returns Base.#sin.

Short circuits

A closure is simply a callable object whose field names correspond to the captured variables. For example, the following code:

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

it drops to about the following:

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

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

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

Constructors

Calling a constructor is just a type call. The method table for Type contains all the constructor definitions. All subtypes of Type (Type, unionAll, Union, and DataType) currently share a common method table by special agreement.

Built-in functions

The following built-in functions are defined in the Core module.

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

All these are single objects, the types of which are subtypes of the type Builtin, which, in turn, is a subtype of `Function'. Their purpose is to provide entry points during execution that comply with the jlcall call convention.

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

The method tables of the built-in functions are empty. Instead, such functions have a single universal entry in the method cache (Tuple{Vararg{Any}}), whose fptr jlcall pointer points to the corresponding function. It is a kind of crutch, which, however, works quite well.

Named arguments

Named arguments work by adding methods to the kwcall function. This function usually acts as a "named argument sorter" and then calls the internal body of the function (defined anonymously). Each definition in the kwsorter function contains the same arguments as some definition in the regular method table, but the NamedTuple argument is added at the beginning, which contains the names and values of the passed named arguments. The task of the kwsorter function is to place named arguments in the appropriate positions according to the names, as well as to calculate and substitute all necessary default value expressions. The result is a regular list of positional arguments, which is passed to another function generated by the compiler.

The easiest way to understand this process is to look at how the definition of a method with named arguments is lowered. For the following code:

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

In fact, definitions of _three methods are created. The first one is a function that accepts all arguments (including named ones) both are positional and include the method body code. Her name is generated automatically.:

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

The second method is the standard definition of the original circle function for the case when named arguments are not passed.:

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

In this case, dispatching is simply performed to the first method with the transmission of default values. The 'pairs` function is applied to a named tuple with the rest of the arguments for key-value iteration. Note that if the method does not accept the other named arguments, this argument is omitted.

Finally, the definition of kwsorter is created:

function (::Core.kwftype(typeof(circle)))(kws, circle, center, radius)
    if haskey(kws, :color)
        color = kws.color
    else
        color = black
    end
    # и т. д.

    # Остальные именованные аргументы помещаются в `options`
    options = structdiff(kws, NamedTuple{(:color, :fill)})

    # Если метод не принимает остальные именованные аргументы, происходит ошибка
    # при непустом `options`

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

The Core' function.kwftype(t) creates the 't.name.mt.kwsorter` field (if it doesn’t exist yet) and returns the function type.

This approach has one feature: if named arguments are not used at the place of the call, no special procedure is required; everything works as if they did not exist in the language at all. However, if named arguments are used at the call location, the function being called is dispatched to the kwsorter sorter. For example, the challenge:

circle((0, 0), 1.0, color = red; other...)

it goes down to the following:

kwcall(merge((color = red,), other), circle, (0, 0), 1.0)

'kwcall' (also in Core) stands for the signature and dispatching of kwcall. The operation of unpacking named arguments (written as other...) calls the merge function of the named tuple. This function further decompresses each element other, and it is assumed that each of them contains two values (the symbol and the actual value). Naturally, if all the decompressed arguments are named tuples, a more efficient implementation is available. Note that the original circle function is passed on to handle closures.

Creating a new type for each function can have serious consequences in terms of compiler resource consumption, combined with Julia’s "default specialization for all arguments" approach. Indeed, the initial implementation of this approach suffered from disadvantages such as a much longer build and testing time, increased memory consumption, and almost twice the size of the system image compared to the base one. With a primitive implementation, the problem is aggravated so much that the system becomes almost impossible to use. To make this approach practical, a number of significant optimizations were required.

The first problem is the excessive specialization of functions for different values of arguments of a functional type. Many functions simply pass the argument somewhere else, for example to another function or to a storage location. Such functions do not need to be specialized for each passed closure. Fortunately, it is easy to recognize such a case: it is enough to check whether the function calls one of its arguments (that is, whether the argument occurs anywhere in the initial position). Higher-order functions that require high performance, such as map, necessarily call the argument function and therefore specialize as needed. This optimization is implemented by pre-fixing the called arguments during the analyze-variables pass. When cache_method detects an argument in the type hierarchy of Function that is passed to a slot declared as Any or Function, the behavior will be the same as with the @nospecialize annotation. In practice, such a heuristic mechanism proves to be extremely effective.

The next problem is related to the structure of the hash tables in the method cache. Empirical research shows that the vast majority of dynamic dispatch calls use one or two arguments. Moreover, most of these cases can be resolved based only on the first argument. (Let’s get off topic a bit: adherents of single dispatch won’t be surprised at all. However, it actually follows from the above that multiple dispatching is easy to optimize in practice and therefore it should be used rather than making a choice in favor of single dispatching.) Thus, the method cache uses the type of the first argument as the primary key. However, note that for a function call, this corresponds to the second element of the tuple type (the first element is the type of the function itself). As a rule, the types are almost always the same in the initial position. Most functions are single types without parameters. However, in the case of constructors, the situation is different: one method table contains constructors for each type. Therefore, the Type method table is specialized so that the first element of the tuple type is used instead of the second.

The interface part generates type declarations for all closures. Initially, this was implemented by creating regular type declarations. However, as a result, too many constructors were created, each of which was extremely primitive (all arguments were simply passed to new). Since the methods are only partially ordered, adding them has an O(n2) algorithmic complexity and, in addition, requires extra resources to store them. Optimization was achieved by creating struct_type expressions directly (bypassing the creation of default constructors) and directly using new to create closure instances. Maybe not the most elegant solution, but something had to be done.

The next problem was the macro @test, which created a closure without arguments for each test case. In fact, there is no need for this, since each test case is simply executed once on the spot. Therefore, the macro @test was deployed in a try-catch block that captures the test result (true, false, or exception) and calls the test suite handler.