Manual
This package provides primitives for dispatching on whether certain methods are implemented or not.
For installation or usage, open julia repl and run
using IsDef
which gives you access to following functions
isdef(f, arg1, arg2)::Bool
/isdef(f, Arg1Type, Arg2Type)::Bool
checks whether a function is defined for the given types.If at least one of the arguments is not a type, all arguments are automatically converted to types for you.
Out(f, arg1, arg2)::ReturnType
/Out(f, Arg1Type, Arg2Type)::ReturnType
returns the returntype of the given functioncall.Note, that
Out
may return an abstract type that is wider than necessary, like e.g.Any
. If a functioncall is not defined, or predictably throws an error,IsDef.NotApplicable
is returned.Out
is internally used byisdef
.
Internally of Out(f, Arg1Type, Arg2Type)
a one-argument-version of Out
is used which expects a single Tuple type, specifying the entire call signature. This is the heart of the IsDef
package. For the example it would be
Out(Tuple{typeof(f), Arg1Type, Arg2Type})
If you want to specify inference of your method (output of Out
), or whether it is defined (output of isdef
), you need to overload this very one-argument method of Out
. For the example it could be
Out(::Type{<:Tuple{typeof(f), Arg1Type, Arg2Type, Vararg}}) = ReturnType
Enjoy maintainable type inference.
Example application: Use isdef
for Traits
Traits are a common mean to get back something like multiple inheritance to Julia.
Let's say you want to dispatch on anything which provides a certain interface, which in julia simply means that a set of respective functions are defined for the type.
struct MyType end
interface_func1(::MyType) = :hello
interface_func2(::MyType) = "world"
Then the standard Trait pattern (sometimes called Holy Traits) goes like follows:
abstract type AbstractMyTrait end
struct SupportsMyTrait <: AbstractMyTrait end
traits_func(a::A) where A = traits_func(AbstractMyTrait(A), a)
traits_func(::SupportsMyTrait, a) = println(interface_func1(a), interface_func2(a))
This defines a generic function which dispatches on our given trait type. Yet the trait needs to be associated with our interface function. This is usually done by mere convention, and we just link the trait-type with our custom type, assuming all functions are readily defined
AbstractMyTrait(::Type{MyType}) = SupportsMyTrait() # use instance
Now our traits function works
traits_func(MyType())
# helloworld
No boilerplate with isdef
The standard Traits implementation creates a lot of boilerplate. Do you really need to define these extra abstract trait types, trait structs and connection between your custom type and the trait type?
You already defined that MyType
implements my_func
. We can reuse that information with isdef
. This is how to rewrite the above traits_func
(we use Val
to dispatch on Bool
values):
using IsDef
supports_interface(a) = isdef(interface_func1, a) && isdef(interface_func2, a)
traits_func_new(a) = traits_func_new(Val{supports_interface(a)}(), a)
traits_func_new(::Val{true}, a) = println(interface_func1(a), interface_func2(a))
Which indeed works without defining any extra traits type:
traits_func_new(MyType())
# helloworld
The nested dispatch syntax, as well as the use of Val
can be simplified further by using a dedicated Trait systems like WhereTraits.jl.
using WhereTraits
@traits function traits_func_new2(a) where {supports_interface(a)}
println(interface_func1(a), interface_func2(a))
end
traits_func_new2(MyType())
# helloworld
or without the supports_interface
helper:
using WhereTraits
@traits function traits_func_new3(a) where {
isdef(interface_func1, a),
isdef(interface_func2, a),
}
println(interface_func1(a), interface_func2(a))
end
traits_func_new3(MyType())
# helloworld
Note that isdef
does not rely on convention, but checks whether the interface functions are defined or not.
If isdef
does return false
despite it should be true, the fallback type inference was not good enough and you need to overload Out
for either your interface functions or functions used within these. ⚠️ Never overload isdef
, instead always overload Out(::Type{<:Tuple{...}})
⚠️.
Why not using Julia's builtin type-inference directly?
Julia already has a type inference method, namely Base.promote_op
which internally uses Core.Compiler.return_type
. So why do we need another type inference layer like IsDef
?
The problem: Instability
The issue with Core.Compiler.return_type
is that we cannot rely on its output. Sometimes just changing the order of Julia code will change the type inference. Think of it as an implementation detail of Julia itself.
Here the official warning in the Base.promote_op
docs:
Due to its fragility, use of promoteop should be avoided. It is preferable to base the container eltype on the type of the actual elements. Only in the absence of any elements (for an empty result container), it may be unavoidable to call promoteop.
And even using Base.promote_op
for empty containers is not a good idea, use Any
instead, or Union{}
. Relying on an indeterministic implementation detail which may silently change its behaviour on every minor julia version is just bad practice.
Where IsDef's stability comes from
That said, Julia's type-inference is very useful in that it is the best approximation we can get without any extra knowledge (remember, the worst type inference approximation is always Any
). Hence it would be great to somehow use it in a safe manner.
IsDef
uses Julia's default type inference as a fallback, and adds three safety aspects:
IsDef
uses automatice deterministic code generation where possible in order to entirely circumvent Julia's internal type inferenceIsDef
makes the type inference overloadable, so that if some fallback type inference changes between Julia versions, you can add a custom inference rule yourself to fix it.- ValTypes define a clean interface to work with bits values as part of type inference.
Automatic Code Generation
In case no custom inference rule could be found for Out
, some type-inference code will be generated for you automatically.
This means that you only need to define custom inference rules in a very few situations. Here the most typical cases for when automatic code generation won't be enough:
- the default inference is too wide (you see this if
Out
returns some abstract type likeNumber
, but actually you know that the inference could a concreter type likeInteger
orInt64
) - the default inference is too inefficient (this is quite hard to track down, but may be seen by profiling your code).
- you get a warning which explicitly recommends to define a custom inference rule. ⚠️ Note that
isdef
will not print warnings, you need to useOut
to see warnings ⚠️.
If the automatic code generation fails, Out
falls back to IsDef.Core_return_type
, which wraps Core.Compiler.return_type
. In most cases this leads good inference results, but sometimes infers types inaccurately, in which case IsDef.Core_return_type
prints the above mentioned warning and returns a special return type IsDef.UnsureWhetherApplicable
instead. ⚠️ While this is easily inspectible when using Out
directly, please note that isdef
follows a conservative semantics and hence will return false
in such cases ⚠️.
isdef(f, ...)
isdef
checks whether a given function is defined for subsequent argument-types. Note that in some cases isdef
returns false
despite the method being properly implemented for the given types (see the section Automatic Code Generation for details).
julia> isdef(+, Int, Int)
true
julia> isdef(-, Float32)
true
julia> isdef(-, String)
false
julia> isdef(-, AbstractString)
false
julia> isdef(-, Any)
false
Out(f, ...)
Out
follows the same syntax as isdef
however instead of returning a Bool, it returns the actual inferred returntype.
<!– the output of Type aliases like Vector changed between julia versions, hence we don't use jldoctest –>
julia> Out(Base.map, typeof(string), Vector{Int})
Vector{String} (alias for Array{String, 1})
If the function is not defined, it returns a special exported type NotApplicable
. This ensures that Out
can safely be used for dispatch.
julia> Out(-, AbstractString)
NotApplicable
You can also do higher-order inference, e.g. when working with Base.map
. Usually you would need a concrete function for its first argument, like
<!– the output of Type aliases like Vector changed between julia versions, hence we don't use jldoctest –>
julia> Out(Base.map, typeof(isodd), Vector{Int})
Vector{Bool} (alias for Array{Bool, 1})
Thanks to the package FunctionWrappers
you can define Function types also directly, without having a concrete function:
<!– the output of Type aliases like Vector changed between julia versions, hence we don't use jldoctest –>
julia> import FunctionWrappers: FunctionWrapper
julia> Out(Base.map, FunctionWrapper{Bool, Tuple{Any}}, Vector{Int})
Vector{Bool} (alias for Array{Bool, 1})
The special type Any
will usually result in a NotApplicable
.
julia> Out(-, Any)
NotApplicable
What if I only have a function-type? Use apply
.
IsDef
also exports a little helper function apply
which you can use to infer with function-types instead of function-instances.
julia> isdef(apply, typeof(sin), Int)
true
julia> Out(apply, typeof(sin), Int)
Float64
ValTypes
Julia's type inference does take into account types as well as some values. For instance, if you use a boolean variable somewhere which is known to be true
, and have an if-else using it, julia may well be able to statically optimize your code accordingly.
In order to simplify maintainable type inference which takes into account such value-level type inference, IsDef
exports a special Type ValType
with separate constructor ValTypeof
. (Helper functions along this type can be found in the exported submodule ValTypes
).
julia> ValTypeof(true)
ValType{Bool, true}
isdef
, Out
(and also IsDef.Core_return_type
) all work on these ValTypes as well and may return ValTypes.
julia> isdef(-, ValTypeof(3))
true
julia> Out(!, ValTypeof(true))
ValType{Bool, false}
julia> IsDef.Core_return_type(Tuple{typeof(&), ValTypeof(true), ValTypeof(false)}) # wraps Core.Compiler.return_type
ValType{Bool, false}
julia> Out(Tuple{typeof(|), ValTypeof(true), ValTypeof(false)})
ValType{Bool, true}
This way you can dispatch on concrete values when specifying custom inference rules.
If you want to dispatch on standard Int
or Symbol
or another bits-type, you can still do so: Just combine the plain type with its ValType analog in a Union type like Union{Int, ValType{Int}}
.
julia> ValTypeof(1) <: Union{Int, ValType{Int}}
true
julia> ValTypeof(:two) <: Union{Int, ValType{Int}}
false
julia> ValTypeof(1) <: (Union{T, ValType{T}} where T <: Union{Symbol, Int})
true
julia> ValTypeof(:two) <: (Union{T, ValType{T}} where T <: Union{Symbol, Int})
true
Limitations: Currently, Julia's typeinference does not work well if the value is not known at compile time. Practically, this means you should use ValTypeof
only for constants or for values which you extracted from another ValType
. (See this discourse thread for more details.)