Methods
As mentioned in the chapter Functions, a function is an object that maps a tuple of arguments to a return value or throws an exception if the corresponding value cannot be returned. Usually, the same conceptual function or operation is implemented completely differently for different types of arguments: adding two integers is significantly different from adding two floating-point numbers, and both of these principles are different from adding an integer with a floating-point number. Despite the differences in implementation, all these operations fall under the general concept of "addition". Accordingly, in Julia, all these behaviors belong to a single object, the +
function.
To simplify the use of different implementations of the same concept, it is not necessary to define all functions at once. They can be set in parts, specifying specific behavior for certain combinations of argument types and their number. The definition of one of the possible options for the function is called the _ method_. So far, only examples of functions defined using a single method applicable to all types of arguments have been given. However, method definition signatures can be annotated to specify the types of arguments in addition to their number. Multiple method definitions can be provided. When a function is applied to a specific tuple of arguments, the most specific method applicable to those arguments is used. Thus, the general behavior of a function is a set of behaviors of various definitions of its methods. When this set is well designed, even if the implementation of the methods may be completely different, the external behavior of the function will look smooth and consistent.
The choice of a method to perform when applying a function is called controlling. In Julia, the dispatching process can choose which of the function methods to call based on the number of arguments provided and the types of all function arguments. This is in contrast to traditional object-oriented languages, where dispatching is performed only based on the first argument, which often has a special argument syntax, and is sometimes implied rather than explicitly written as an argument. [1] Using all the arguments of a function to select the method to be called, not just the first one, is called https://en.wikipedia.org/wiki/Multiple_dispatch [multiple dispatch]. Multiple dispatch is especially useful for mathematical code, where it makes no sense to artificially assume that operations "belong" to one argument more than to any other: does the addition operation in x + y
belong to x
more than `y'? The implementation of a mathematical operator usually depends on the types of all its arguments. However, even beyond mathematical operations, multiple dispatch is becoming a powerful and convenient concept for structuring and organizing programs.
All the examples in this chapter assume that the methods for a function are defined in the same module. To add methods to a function in another module, you must import it using |
Defining methods
So far, in the examples, we have defined only functions using a single method with unlimited argument types. Such functions operate in the same way as in traditional dynamically typed languages. Nevertheless, we almost constantly used multiple dispatching and methods without realizing it: all the standard functions and operators in Julia, such as the aforementioned +
function, have many methods that define their behavior for various possible combinations of types and number of arguments.
When defining a function, you can further restrict the types of parameters to which it applies by using the type statement operator ::
, presented in the section on composite types:
julia> f(x::Float64, y::Float64) = 2x + y
f (generic function with 1 method)
This function definition applies only to calls in which x
and y
are type values. Float64
:
julia> f(2.0, 3.0)
7.0
Her application of it to any other types of arguments will result in MethodError
:
julia> f(2.0, 3)
ERROR: MethodError: no method matching f(::Float64, ::Int64)
The function `f` exists, but no method is defined for this combination of argument types.
Closest candidates are:
f(::Float64, !Matched::Float64)
@ Main none:1
Stacktrace:
[...]
julia> f(Float32(2.0), 3.0)
ERROR: MethodError: no method matching f(::Float32, ::Float64)
The function `f` exists, but no method is defined for this combination of argument types.
Closest candidates are:
f(!Matched::Float64, ::Float64)
@ Main none:1
Stacktrace:
[...]
julia> f(2.0, "3.0")
ERROR: MethodError: no method matching f(::Float64, ::String)
The function `f` exists, but no method is defined for this combination of argument types.
Closest candidates are:
f(::Float64, !Matched::Float64)
@ Main none:1
Stacktrace:
[...]
julia> f("2.0", "3.0")
ERROR: MethodError: no method matching f(::String, ::String)
The function `f` exists, but no method is defined for this combination of argument types.
As you can see, the arguments must be of type Float64
. Other numeric types, such as integers or 32-bit floating-point values, are not automatically converted to 64-bit floating-point, nor are strings parsed as numbers. Since `Float64' is a specific type, and specific types in Julia cannot be subclassed, this definition can only be applied to arguments that are exactly of type `Float64'. However, it is often advisable to write more general methods in which the declared parameter types are abstract.:
julia> f(x::Number, y::Number) = 2x - y
f (generic function with 2 methods)
julia> f(2.0, 3)
1.0
This method definition applies to any pair of arguments that are instances of the type Number
. They don’t necessarily have to have the same type, as long as they are numeric values. The task of processing heterogeneous numeric types is delegated to arithmetic operations in the expression `2x - y'.
To define a function with multiple methods, it is enough to define the function several times with different number and type of arguments. The first time a method is defined, a function object is created for the function, and subsequent method definitions add new methods to the existing function object. When applying the function, the most specific method definition will be executed, corresponding to the number and types of arguments. Thus, taken together, the two method definitions above define the behavior for f
for all pairs of instances of the abstract type Number', but with a different behavior specific to pairs of values. `Float64
. If one of the arguments is 64-bit floating point and the other is not, the f(Float64,Float64)
method cannot be called, and the more general f(Number,Number)
method should be used.:
julia> f(2.0, 3.0)
7.0
julia> f(2, 3.0)
1.0
julia> f(2.0, 3)
1.0
julia> f(2, 3)
1
The definition of '2x + y` is used only in the first case, and the definition of `2x - y' is used in the rest. Automatic conversion or conversion of function arguments is never performed: all conversions in Julia are not something special - they are carried out in a completely explicit way. However, in the chapter Transformation and Promotion shows how the skillful application of sufficiently advanced technology can be indistinguishable from magic. [2]
For non-numeric values, as well as for arguments whose number is less than or more than two, the function f
remains undefined, and its application will still result in MethodError
:
julia> f("foo", 3)
ERROR: MethodError: no method matching f(::String, ::Int64)
The function `f` exists, but no method is defined for this combination of argument types.
Closest candidates are:
f(!Matched::Number, ::Number)
@ Main none:1
f(!Matched::Float64, !Matched::Float64)
@ Main none:1
Stacktrace:
[...]
julia> f()
ERROR: MethodError: no method matching f()
The function `f` exists, but no method is defined for this combination of argument types.
Closest candidates are:
f(!Matched::Float64, !Matched::Float64)
@ Main none:1
f(!Matched::Number, !Matched::Number)
@ Main none:1
Stacktrace:
[...]
To see which methods exist for a function, enter the function object itself in an interactive session.:
julia> f
f (generic function with 2 methods)
It is clear from this output that f
is a function object with two methods. To find out the signatures of these methods, use the function methods
:
julia> methods(f)
# Два метода для универсальной функции f из Main:
[1] f(x::Float64, y::Float64)
@ none:1
[2] f(x::Number, y::Number)
@ none:1
which shows that f
has two methods, one of which takes two arguments of Float64
, and the other - arguments of type `Number'. It also specifies the file and line number where the methods were defined: since these methods were defined in the REPL, the obvious line number is `none:1'.
- If there is no type declaration using `
-
the default parameter type of the method is
Any
, which means that it has no restrictions, since all values in Julia are instances of the abstract typeAny'. Thus, it is possible to define a universal method for `f
as follows:
julia> f(x,y) = println("Whoa there, Nelly.")
f (generic function with 3 methods)
julia> methods(f)
# Three methods for the universal function f from Main:
[1] f(x::Float64, y::Float64)
@ none:1
[2] f(x::Number, y::Number)
@ none:1
[3] f(x, y)
@ none:1
julia> f("foo", 1)
Whoa there, Nelly.
This universal method is less specific than any other possible method definition for a pair of parameter values, so it will only be called for those pairs of arguments to which no other method definition applies.
Note that the type is not specified in the signature of the third method for the arguments x
and y
. This is an abbreviated way of expressing f(x::Any, y::Any)
.
Despite its apparent simplicity, multiple dispatch for value types is perhaps the single most powerful and main feature of the Julia language. There are usually dozens of methods used in basic operations.:
julia> methods(+)
# 180 методов для универсальной функции «+»:
[1] +(x::Bool, z::Complex{Bool}) in Base at complex.jl:227
[2] +(x::Bool, y::Bool) in Base at bool.jl:89
[3] +(x::Bool) in Base at bool.jl:86
[4] +(x::Bool, y::T) where T<:AbstractFloat in Base at bool.jl:96
[5] +(x::Bool, z::Complex) in Base at complex.jl:234
[6] +(a::Float16, b::Float16) in Base at float.jl:373
[7] +(x::Float32, y::Float32) in Base at float.jl:375
[8] +(x::Float64, y::Float64) in Base at float.jl:376
[9] +(z::Complex{Bool}, x::Bool) in Base at complex.jl:228
[10] +(z::Complex{Bool}, x::Real) in Base at complex.jl:242
[11] +(x::Char, y::Integer) in Base at char.jl:40
[12] +(c::BigInt, x::BigFloat) in Base.MPFR at mpfr.jl:307
[13] +(a::BigInt, b::BigInt, c::BigInt, d::BigInt, e::BigInt) in Base.GMP at gmp.jl:392
[14] +(a::BigInt, b::BigInt, c::BigInt, d::BigInt) in Base.GMP at gmp.jl:391
[15] +(a::BigInt, b::BigInt, c::BigInt) in Base.GMP at gmp.jl:390
[16] +(x::BigInt, y::BigInt) in Base.GMP at gmp.jl:361
[17] +(x::BigInt, c::Union{UInt16, UInt32, UInt64, UInt8}) in Base.GMP at gmp.jl:398
...
[180] +(a, b, c, xs...) in Base at operators.jl:424
Thanks to multiple dispatching, along with a flexible parametric type system in Julia, high-level algorithms can be abstractly expressed, separated from implementation details.
Specialization of methods
If multiple methods of the same function are created, this is sometimes called "specialization". In this case, the function is specialized by adding additional methods: each new method is another specialization of the function. As shown above, specializations are returned by the methods
function.
There is another type of specialization that occurs without programmer intervention: The Julia compiler can automatically specialize the method for the argument types used. Such specializations are not included in the list output by the methods
function, so no new methods (Method
) are created. However, they can be viewed using tools such as @code_typed
.
For example, when creating a method
mysum(x::Real, y::Real) = x + y
The mysum' function will receive one new method (possibly the only one) that accepts any pair of the `Real
type as input. But if you then execute
julia> mysum(1, 2)
3
julia> mysum(1.0, 2.0)
3.0
Julia compiles mysum
twice: once for x::Int, y::Int
and once again for x::Float64, y::Float64
. The point of double compilation is to improve performance: the methods called for +
(and used by mysum
) vary depending on the types x
and y
, and by compiling different specializations in Julia, it becomes possible to search for methods in advance. This allows the program to run much faster, since it does not have to search for methods during execution. Automatic specialization in Julia allows you to write universal algorithms, expecting the compiler to generate efficient, specialized code for each necessary case.
In situations where the number of possible specializations may be unlimited, this default specialization mechanism may not be applied in Julia. For more information, see Keep in mind when specialization in Julia is not done automatically.
Ambiguity of methods
A set of function methods can be defined in such a way that there is no unique most specific method applicable to some combinations of arguments.:
julia> g(x::Float64, y) = 2x + y
g (generic function with 1 method)
julia> g(x, y::Float64) = x + 2y
g (generic function with 2 methods)
julia> g(2.0, 3)
7.0
julia> g(2, 3.0)
8.0
julia> g(2.0, 3.0)
ERROR: MethodError: g(::Float64, ::Float64) is ambiguous.
Candidates:
g(x, y::Float64)
@ Main none:1
g(x::Float64, y)
@ Main none:1
Possible fix, define
g(::Float64, ::Float64)
Stacktrace:
[...]
Here, the call to g(2.0, 3.0)
can be handled either by the g(::Float64, ::Any)
method or by the g(::Any,::Float64)
method. The order in which the methods are defined does not matter, and neither is more specific than the other. In such cases, Julia issues MethodError
, rather than choosing a method arbitrarily. To eliminate the ambiguity of the methods, you need to specify the appropriate method in case of an intersection.:
julia> g(x::Float64, y::Float64) = 2x + 2y
g (generic function with 3 methods)
julia> g(2.0, 3)
7.0
julia> g(2, 3.0)
8.0
julia> g(2.0, 3.0)
10.0
It is recommended that you first define an unambiguous method, because otherwise there will be ambiguity, albeit temporarily. It will remain until a more specific method is determined.
In more complex cases, a certain design element is required to resolve the ambiguity of the method. This issue is being considered below.
Parametric methods
Method definitions may additionally have type parameters defining the signature.:
julia> same_type(x::T, y::T) where {T} = true
same_type (generic function with 1 method)
julia> same_type(x,y) = false
same_type (generic function with 2 methods)
The first method is used when both arguments have the same specific type, regardless of which type it is, while the second method acts as a universal one, extending to all other cases. So in general, this defines a Boolean function that checks whether two of its arguments have the same type.:
julia> same_type(1, 2)
true
julia> same_type(1, 2.0)
false
julia> same_type(1.0, 2.0)
true
julia> same_type("foo", 2.0)
false
julia> same_type("foo", "bar")
true
julia> same_type(Int32(1), Int64(2))
false
Such definitions correspond to methods whose type signatures are unionAll
types (see section unionAll types).
This definition of function behavior through dispatching is quite common — idiomatic — even in Julia. Method type parameters can be used not only as argument types: they can be applied wherever the value is in the function signature or function body. Here is an example where the method type parameter T
is used as the type parameter for the parametric type Vector{T}
in the method signature:
julia> function myappend(v::Vector{T}, x::T) where {T}
return [v..., x]
end
myappend (generic function with 1 method)
The type parameter T
in this example ensures that the added element x
is a subtype of the existing element type of the vector v'. The `where
keyword provides a list of these constraints after defining the method signature. It also works for single-line definitions, as shown above, and should appear _ before_ return type declaration, if present, as shown below:
julia> (myappend(v::Vector{T}, x::T)::Vector) where {T} = [v..., x]
myappend (generic function with 1 method)
julia> myappend([1,2,3],4)
4-element Vector{Int64}:
1
2
3
4
julia> myappend([1,2,3],2.5)
ERROR: MethodError: no method matching myappend(::Vector{Int64}, ::Float64)
The function `myappend` exists, but no method is defined for this combination of argument types.
Closest candidates are:
myappend(::Vector{T}, !Matched::T) where T
@ Main none:1
Stacktrace:
[...]
julia> myappend([1.0,2.0,3.0],4.0)
4-element Vector{Float64}:
1.0
2.0
3.0
4.0
julia> myappend([1.0,2.0,3.0],4)
ERROR: MethodError: no method matching myappend(::Vector{Float64}, ::Int64)
The function `myappend` exists, but no method is defined for this combination of argument types.
Closest candidates are:
myappend(::Vector{T}, !Matched::T) where T
@ Main none:1
Stacktrace:
[...]
If the type of the element to be added does not match the type of the vector element to which it is being added, an error occurs. MethodError
. In the following example, the method type parameter T
is used as the return value.:
julia> mytypeof(x::T) where {T} = T
mytypeof (generic function with 1 method)
julia> mytypeof(1)
Int64
julia> mytypeof(1.0)
Float64
Just as you can impose subtype restrictions on type parameters in type declarations (see the section Parametric types), you can also impose restrictions on the parameters of method types.
julia> same_type_numeric(x::T, y::T) where {T<:Number} = true
same_type_numeric (generic function with 1 method)
julia> same_type_numeric(x::Number, y::Number) = false
same_type_numeric (generic function with 2 methods)
julia> same_type_numeric(1, 2)
true
julia> same_type_numeric(1, 2.0)
false
julia> same_type_numeric(1.0, 2.0)
true
julia> same_type_numeric("foo", 2.0)
ERROR: MethodError: no method matching same_type_numeric(::String, ::Float64)
The function `same_type_numeric` exists, but no method is defined for this combination of argument types.
Closest candidates are:
same_type_numeric(!Matched::T, ::T) where T<:Number
@ Main none:1
same_type_numeric(!Matched::Number, ::Number)
@ Main none:1
Stacktrace:
[...]
julia> same_type_numeric("foo", "bar")
ERROR: MethodError: no method matching same_type_numeric(::String, ::String)
The function `same_type_numeric` exists, but no method is defined for this combination of argument types.
julia> same_type_numeric(Int32(1), Int64(2))
false
The same_type_numeric
function works the same way as the same_type
function defined above, but it is defined only for pairs of numbers.
Parametric methods allow the same syntax as the where
expressions used to write types (see the section unionAll types). If there is only one parameter, curly braces (in where {T}
) can be omitted, but for clarity they often remain the preferred option. Several parameters can be separated by commas, for example, where {T, S<:Real}
, or written using a nested where', for example `where S<:Real where T
.
Redefining methods
When redefining a method or adding new methods, it is important to understand that these changes do not take effect immediately. This is a key factor for Julia’s ability to statically output and compile code for fast execution without the usual techniques and costs associated with JIT. Each new method definition will not be visible to the current runtime environment, including tasks and threads (and any previously defined @generated
functions). Let’s start with an example to understand what this means.
julia> function tryeval()
@eval newfun() = 1
newfun()
end
tryeval (generic function with 1 method)
julia> tryeval()
ERROR: MethodError: no method matching newfun()
The applicable method may be too new: running in world age xxxx1, while current world is xxxx2.
Closest candidates are:
newfun() at none:1 (method too new to be called from this world context.)
in tryeval() at none:1
...
julia> newfun()
1
Note that in this example, a new definition has been created for newfun
, but it cannot be called immediately. The new global object is immediately visible to the tryeval
function, so you can write return newfun
(without parentheses). But neither you, nor the calling objects, nor the functions they call can call this new method definition.
However, there is an exception: future calls to newfun
_ from REPL_ work as expected and can both see and invoke the new definition of the newfun
method.
But future calls to tryeval
will continue to see the definition of newfun
as it was _ before the previous call to REPL_, and therefore before this call to tryeval
.
You can try it yourself to see how it works.
The implementation of this behavior is the "world age counter" (hierarchy of method definitions). This monotonously increasing value tracks each method definition operation. The "set of method definitions visible for a given runtime environment" can be described as a single number, or the "age of the world". In addition, it is possible to compare the methods available in the two worlds by simply comparing their sequence number. The example above shows that the "current world" (in which the newfun
method exists) is one more than the task-specific "runtime world", which was fixed when the execution of `tryeval' began.
Sometimes this situation needs to be circumvented (for example, in the case of implementing the REPL described above). Fortunately, there is a simple solution: call the function using Base.invokelatest
:
julia> function tryeval2()
@eval newfun2() = 2
Base.invokelatest(newfun2)
end
tryeval2 (generic function with 1 method)
julia> tryeval2()
2
Finally, let’s look at some more complex examples where this rule begins to apply. Define the function f(x)
, which initially has one method:
julia> f(x) = "original definition"
f (generic function with 1 method)
Run other operations using f(x)
:
julia> g(x) = f(x)
g (generic function with 1 method)
julia> t = @async f(wait()); yield();
Add a number of new methods to f(x)
:
julia> f(x::Int) = "definition for Int"
f (generic function with 2 methods)
julia> f(x::Type{Int}) = "definition for Type{Int}"
f (generic function with 3 methods)
Compare the differences between these results:
julia> f(1)
"definition for Int"
julia> g(1)
"definition for Int"
julia> fetch(schedule(t, 1))
"original definition"
julia> t = @async f(wait()); yield();
julia> fetch(schedule(t, 1))
"definition for Int"
Design patterns with parametric methods
Although complex dispatch logic is not necessary to improve performance or usability, it can sometimes be the best way to express some algorithms. Here are some common design patterns that sometimes appear in this use case of dispatching.
Extracting a type parameter from a supertype
Here is the correct code template for returning the element type T
of any arbitrary subtype of AbstractArray
, which has a well-defined element type.:
abstract type AbstractArray{T, N} end
eltype(::Type{<:AbstractArray{T}}) where {T} = T
using the so-called three-way dispatching. Note that the types are unionAll', for example `+eltype(AbstractArray{T} where T <: Integer)+
, do not match the above method. For such cases, the implementation of eltype' in `Base
adds a backup method to Any
.
One of the common mistakes is trying to get the element type by introspection.:
eltype_wrong(::Type{A}) where {A<:AbstractArray} = A.parameters[1]
However, it is not difficult to simulate cases when it fails.:
struct BitVector <: AbstractArray{Bool, 1}; end
The BitVector
type has been created here, which has no parameters, but the element type is still fully defined, and T
has the value `Bool'.
Another mistake is trying to go up the type hierarchy using supertype
:
eltype_wrong(::Type{AbstractArray{T}}) where {T} = T
eltype_wrong(::Type{AbstractArray{T, N}}) where {T, N} = T
eltype_wrong(::Type{A}) where {A<:AbstractArray} = eltype_wrong(supertype(A))
While this approach works for declared types, it fails for types without supertypes.:
julia> eltype_wrong(Union{AbstractArray{Int}, AbstractArray{Float64}})
ERROR: MethodError: no method matching supertype(::Type{Union{AbstractArray{Float64,N} where N, AbstractArray{Int64,N} where N}})
Closest candidates are:
supertype(::DataType) at operators.jl:43
supertype(::UnionAll) at operators.jl:48
Creating a similar type with a parameter of a different type
When creating a universal code, it is often necessary to build a similar object with some modification of the type structure, which also leads to the need to change the type parameters. For example, you may have an abstract array with an arbitrary type of elements, and you want to write calculations that will be performed in it with a certain type of elements. For each subtype of AbstractArray{T}
it is necessary to implement a method that describes how to calculate this type conversion. There is no general conversion from one subtype to another subtype with a different parameter.
To get the desired result, subtypes of AbstractArray
usually implement two methods: a method for converting an input array into a subtype of a specific abstract type AbstractArray'.{T, N}
and a method for creating a new uninitialized array with a specific element type. Examples of their implementation can be found in Julia Base. The following is a basic example of their use, ensuring that input
and output
have the same type.
input = convert(AbstractArray{Eltype}, input)
output = similar(input, Eltype)
In addition, in cases where the algorithm requires a copy of the input array, use the function convert
will not be enough, because the return value may replace the original input. Combination similar
(to create an output array) and copyto!
(to fill it with input data) is a common way of expressing the requirement for a mutable copy of the input argument.:
copy_with_eltype(input, Eltype) = copyto!(similar(input, Eltype), input)
Iterable dispatching
For dispatching a multi-level parametric argument list, it is often recommended to divide each dispatching level into separate functions. It may seem that this approach is similar to single dispatch, but, as will be seen later, it is still more flexible.
For example, when trying to dispatch an array element type, ambiguous situations often arise. But usually the code first performs dispatching of the container type, and then proceeds to a more specific eltype-based method. In most cases, algorithms easily follow this hierarchical approach, while in other cases, the issue of process rigor must be resolved manually. Such a branching of dispatching can be observed, for example, in the logic of summing two matrices.:
# Сначала диспетчеризация выбирает алгоритм сопоставления для поэлементного суммирования.
+(a::Matrix, b::Matrix) = map(+, a, b)
# Затем диспетчеризация обрабатывает каждый элемент и выбирает подходящий
# общий тип элемента для вычисления.
+(a, b) = +(promote(a, b)...)
# После получения элементов одинакового типа их можно сложить.
# Например, через примитивные операции, доступные в процессоре.
+(a::Float64, b::Float64) = Core.add(a, b)
Feature-based dispatching
A natural extension of the iterable dispatching described above is to add a layer to the method selection that allows you to dispatch sets of types independent of the sets defined by the type hierarchy. Such a set could be created by writing the Union
types of interest, but then this set would not be extensible, since the Union
types cannot be changed after creation. However, such an expandable set can be programmed using a design pattern, often called https://github.com/JuliaLang/julia/issues/2345#issuecomment-54537633 [Holy-trait].
This pattern is implemented by defining a universal function that calculates a different single value (or type) for each set of attributes that the function arguments may belong to. If this feature is clean (without side effects), it does not affect performance compared to regular dispatching.
In the example given in the previous section, the details of the function implementation were omitted. map
and promote
, each of which works according to these criteria. When iterating a matrix, as, for example, in the implementation of map', one of the important issues is the order of passage through the data. When subtypes of `AbstractArray
implement the feature Base.IndexStyle
, other functions such as map
can use this information to select the best algorithm (see section Interface of an abstract array). This means that each subtype does not need to implement a custom version of map
, since universal definitions and feature classes will allow the system to select the fastest version. A simulated implementation of `map' is shown below, illustrating feature-based dispatching.
map(f, a::AbstractArray, b::AbstractArray) = map(Base.IndexStyle(a, b), f, a, b)
# универсальная реализация:
map(::Base.IndexCartesian, f, a::AbstractArray, b::AbstractArray) = ...
# реализация с линейным индексированием (более быстрая)
map(::Base.IndexLinear, f, a::AbstractArray, b::AbstractArray) = ...
This feature-based approach is also present in the mechanism promote
, used by the scalar +
. It uses the function promote_type
, which returns the optimal general type for calculating an operation with two types of operands. This reduces the problem of implementing each function for each pair of possible type arguments to a much smaller problem of implementing the operation of converting each type into a general one and a table of preferred rules for pairwise promotion.
Calculating the output type
After discussing feature-based promotion, let’s move on to the next design pattern: calculating the type of output element for a matrix operation.
To implement primitive operations, such as addition, the function is used promote_type'
to calculate the desired output type. (We’ve seen it work before in the `promote call when calling `+'.)
For more complex functions in matrices, it may be necessary to calculate the expected return type for a more complex sequence of operations. To do this, the following steps are often performed.
-
Writing a small
op
function that expresses a set of operations performed by the algorithm core. -
Calculating the element type
R
of the result matrix aspromote_op(op, argument_types...)
, whereargument_types
calculated from theeltype
applied to each input array. -
Creating an output matrix in the form of
similar(R, dims)
, where `dims' are the desired dimensions of the output array.
In a more specific example, the universal pseudocode of square matrix multiplication may look like this:
function matmul(a::AbstractMatrix, b::AbstractMatrix)
op = (ai, bi) -> ai * bi + ai * bi
## этого недостаточно, поскольку предполагается, что `one(eltype(a))` является конструируемым:
# R = typeof(op(one(eltype(a)), one(eltype(b))))
## это завершается сбоем, поскольку предполагается, что `a[1]` существует и является представителем всех элементов массива
# R = typeof(op(a[1], b[1]))
## это неверно, поскольку предполагается, что `+` вызывает `promote_type`
## но это не является истинным для некоторых типов, таких как Bool:
# R = promote_type(ai, bi)
# это неправильно, поскольку зависимость от возвращаемого значения
# при выводе типов очень хрупкая (а также не оптимизируемая):
# R = Base.return_types(op, (eltype(a), eltype(b)))
## но в конечном итоге это работает:
R = promote_op(op, eltype(a), eltype(b))
## хотя иногда результатом может быть более крупный тип, чем нужно
## всегда будет выдан правильный тип
output = similar(b, R, (size(a, 1), size(b, 2)))
if size(a, 2) > 0
for j in 1:size(b, 2)
for i in 1:size(a, 1)
## здесь не используется `ab = zero(R)`,
## поскольку `R` может иметь значение `Any`, а `zero(Any)` не определен
## также необходимо объявить `ab::R`, чтобы сделать тип `ab` постоянным в цикле,
## поскольку возможно, что typeof(a * b) != typeof(a * b + a * b) == R
ab::R = a[i, 1] * b[1, j]
for k in 2:size(a, 2)
ab += a[i, k] * b[k, j]
end
output[i, j] = ab
end
end
end
return output
end
Separation of conversion logic and core
One way to significantly reduce compilation time and test complexity is to isolate the logic for conversion to the desired type and calculations. This allows the compiler to specialize and embed the conversion logic independently of the rest of the larger core body.
This is a common pattern observed when converting from a large type class to one specific argument type that is actually supported by the algorithm.:
complexfunction(arg::Int) = ...
complexfunction(arg::Any) = complexfunction(convert(Int, arg))
matmul(a::T, b::T) = ...
matmul(a, b) = matmul(promote(a, b)...)
Parametrically limited methods with a variable number of arguments
Function parameters can also be used to limit the number of arguments that can be provided to a function with a variable number of arguments (varargs) (Functions with a variable number of arguments). The notation Vararg' is used to specify such content.{T,N}
. For example:
julia> bar(a,b,x::Vararg{Any,2}) = (a,b,x)
bar (generic function with 1 method)
julia> bar(1,2,3)
ERROR: MethodError: no method matching bar(::Int64, ::Int64, ::Int64)
The function `bar` exists, but no method is defined for this combination of argument types.
Closest candidates are:
bar(::Any, ::Any, ::Any, !Matched::Any)
@ Main none:1
Stacktrace:
[...]
julia> bar(1,2,3,4)
(1, 2, (3, 4))
julia> bar(1,2,3,4,5)
ERROR: MethodError: no method matching bar(::Int64, ::Int64, ::Int64, ::Int64, ::Int64)
The function `bar` exists, but no method is defined for this combination of argument types.
Closest candidates are:
bar(::Any, ::Any, ::Any, ::Any)
@ Main none:1
Stacktrace:
[...]
It is more useful to limit methods with a variable number of arguments to a parameter. For example:
function getindex(A::AbstractArray{T,N}, indices::Vararg{Number,N}) where {T,N}
it will be called only when the number of indexes
corresponds to the dimension of the array.
When it is necessary to limit only the type of arguments provided, Vararg{T}
can also be written as T...
. For example, f(x::Int...) = x
is an abbreviation for f(x::Vararg{Int}) = x
.
A note about optional and named arguments
As briefly mentioned in the chapter Functions, optional arguments are implemented as syntax for multiple method definitions. For example, this definition:
f(a=1,b=2) = a+2b
it is converted into the following three methods:
f(a,b) = a+2b
f(a) = f(a,2)
f() = f(1,2)
This means that calling f()
is equivalent to calling f(1,2)'. In this case, the result will be `5
because f(1,2)
calls the first method f
above. However, this is not always the case. If we define a fourth method, more specialized for integers:
f(a::Int,b::Int) = a-2b
the result of f()`and `f(1,2)`it will be `-3
. In other words, optional arguments are bound to a function, not to any specific method of that function. The method being called depends on the types of optional arguments. If optional arguments are defined according to a global variable, the type of the optional argument may even change during execution.
Named arguments act completely differently from regular positional arguments. In particular, they are not involved in dispatching methods. Methods are dispatched based on positional arguments only, and named arguments are processed after the appropriate method is determined.
Objects like functions
Methods are related to types, so any arbitrary Julia object can be made "callable" by adding methods to its type. (Such "callable" objects are sometimes called "functors".)
For example, you can define a type that stores the coefficients of a polynomial, but acts as a function that calculates this polynomial.:
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)
Note that the function is specified by type, not by name. As with regular functions, there is a short syntax. In the body of the function, p
will refer to the object that was called. The Polynomial
can be used as follows:
julia> p = Polynomial([1,10,100])
Polynomial{Int64}([1, 10, 100])
julia> p(3)
931
julia> p()
2551
This mechanism also determines how type constructors and closures (internal functions that refer to their environment) work in Julia.
Empty universal functions
Sometimes it is useful to introduce a universal function to which methods have not yet been added. This can be used to separate interface definitions from their implementations. In addition, this option is suitable for documentation purposes or to improve the readability of the code. Here, the syntax is an empty function
block without a tuple of arguments.:
function emptyfunc end
Development of methods and elimination of ambiguity
Polymorphism of methods in Julia is one of the most powerful features of this language, however, its use can cause difficulties during development. In particular, in more complex hierarchies of methods, there are often ambiguity.
It was noted above that to eliminate ambiguities such as
f(x, y::Int) = 1
f(x::Int, y) = 2
you can define a method
f(x::Int, y::Int) = 3
Most often, this decision will be the right one, but there are circumstances in which thoughtlessly following this advice can lead to the opposite results. In particular, the more methods a universal function has, the higher the possibility of ambiguity. When the hierarchy of methods becomes more complex than in this simple example, it may be necessary to carefully consider alternative strategies.
Next, specific problems and some alternative solutions will be discussed.
Tuple and NTuple arguments
The arguments Tuple
(and `NTuple') create special problems.
Examples:
f(x::NTuple{N,Int}) where {N} = 1
f(x::NTuple{N,Float64}) where {N} = 2
ambiguous because of the probability that N == 0
: there are no elements defining which option — Int
or `Float64' — should be called. To resolve the ambiguity, you can define a method for an empty tuple.:
f(x::Tuple{}) = 3
Or, for all methods except one, the condition must be met that there is at least one element in the tuple.:
f(x::NTuple{N,Int}) where {N} = 1 # это резервный вариант
f(x::Tuple{Float64, Vararg{Float64}}) = 2 # для этого требуется по крайней мере один тип Float64
Orthogonalization of development
If you feel like dispatching two or more arguments, consider whether a wrapper function can simplify the process. For example, instead of writing multiple variants:
f(x::A, y::A) = ...
f(x::A, y::B) = ...
f(x::B, y::A) = ...
f(x::B, y::B) = ...
you can define
f(x::A, y::A) = ...
f(x, y) = f(g(x), g(y))
where g
converts the argument to type A'. This is a very specific example of a more general principle. https://en.wikipedia.org/wiki/Orthogonality_ (programming)[orthogonal design], in which separate concepts are assigned to separate methods. Here, a backup definition will most likely be required for `g
.
g(x::A) = x
The related strategy uses promote
to bring x
and y
to a common type.:
f(x::T, y::T) where {T} = ...
f(x, y) = f(promote(x, y)...)
One of the risks in this situation is as follows: if there is no suitable promotion method that converts x
and y
into one type, the second method will be infinitely repeated and will lead to stack overflow.
Dispatching one argument at a time
If you need to dispatch multiple arguments and there are many backup methods with too many combinations to determine all available options, consider introducing a "cascade of names" when (for example) you dispatch the first argument and then call the internal method.:
f(x::A, y) = _fA(x, y)
f(x::B, y) = _fB(x, y)
Then the internal methods _fA
and _fB' can perform dispatching of `y
without creating mutual ambiguity about x
.
Keep in mind that this strategy has at least one significant drawback: in many cases, users cannot further customize the behavior of f
by defining further specializations of the exported function f
. Instead, they have to define specializations for the internal methods _fA
and _fB
, which blurs the lines between exported and internal methods.
Abstract containers and element types
Whenever possible, try to avoid defining methods that dispatch specific types of abstract container elements. Examples:
-(A::AbstractArray{T}, b::Date) where {T<:Date}
it causes ambiguity for anyone who defines the method.
-(A::MyArrayType{T}, b::T) where {T}
The best approach is not to define each of these methods, but to use the universal method -(A::AbstractArray, b)
and implement it using universal calls (such as similar
and -
) that correctly handle each container type and element type individually. This is just a more complicated version of the recommendation regarding orthogonalization methods.
When this approach is not possible, it may be worth organizing a discussion with other developers about the resolution of ambiguity. The fact that one method was defined first does not necessarily mean that it cannot be changed or decommissioned. As a last resort, one of the developers can determine a temporary solution.,
-(A::MyArrayType{T}, b::Date) where {T<:Date} = ...
which removes the ambiguity directly.
A complex "cascade" method with default arguments
If you define a "cascading" method that provides default values, be careful when discarding any arguments that match potential default values. For example, suppose you are writing a digital filtering algorithm, and you have a method that processes the boundaries of a signal by applying padding:
function myfilter(A, kernel, ::Replicate)
Apadded = replicate_edges(A, size(kernel))
myfilter(Apadded, kernel) # теперь выполняется «реальное» вычисление
end
This will disrupt the execution of the default filling method.:
myfilter(A, kernel) = myfilter(A, kernel, Replicate()) # реплицирует границу по умолчанию
Together, these two methods create an infinite recursion in which A
is constantly increasing.
The best option would be to define the call hierarchy as follows:
struct NoPad end # указывает, что заполнение не требуется или что оно уже применено
myfilter(A, kernel) = myfilter(A, kernel, Replicate()) # default limit conditions
function myfilter(A, kernel, ::Replicate)
Apadded = replicate_edges(A, size(kernel))
myfilter(Apadded, kernel, NoPad()) # specifies new limit conditions
end
# here are other filling methods
function myfilter(A, kernel, ::NoPad)
# This is the "real" implementation
of the end kernel calculation
'NoPad` is specified in the same argument position as any other type of padding, so the dispatch hierarchy remains well organized with less chance of ambiguity. Moreover, it extends the "public" interface of myfilter
: a user who wants to explicitly control the filling can call the 'NoPad` option directly.
Defining methods in the local area
You can define methods in local area, for example:
julia> function f(x)
g(y::Int) = y + x
g(y) = y - x
g
end
f (generic function with 1 method)
julia> h = f(3);
julia> h(4)
7
julia> h(4.0)
1.0
However, local methods should not be defined conditionally or depending on the order of execution, for example:
function f2(inc)
if inc
g(x) = x + 1
else
g(x) = x - 1
end
end
function f3()
function g end
return g
g() = 0
end
since it will be unclear which function will eventually be defined. In the future, such a definition of local methods may be considered an error.
In such cases, use anonymous functions.:
function f2(inc)
g = if inc
x -> x + 1
else
x -> x - 1
end
end
obj.meth(arg1,arg2)
, the obj object "receives" the method call and is implicitly passed to the method through the this
keyword, rather than as an explicit method argument. When the current object is this
it is the recipient of the method call, and it can be omitted altogether by writing only meth(arg1,arg2)
, where this
is meant as the receiving object.