A Julia package providing functionality for annotating arbitrary callables with type signature data.
Might help with bad inference.
Provides somewhat of a restricted way of expressing a type signature for a callable in Julia's type system.
The package exports the following bindings:
-
CallableWithReturnType
-
Throws after calling the underlying callable if the return type does not match.
-
Lacks constructor methods.
-
Not a newly defined type, just a nice interface over functionality provided by
Base
. In particular, for anyReturn::Type
we have:CallableWithReturnType{Return} == ComposedFunction{Base.Fix2{typeof(typeassert), Type{Return}}}
-
-
CallableWithTypeSignature
-
Throws after calling the underlying callable if the return type does not match.
-
Throws before calling the underlying callable if the argument types do not match.
-
Subtypes
CallableWithReturnType
. -
Lacks constructor methods.
-
-
typed_callable
- Use
typed_callable
to constructCallableWithReturnType
orCallableWithTypeSignature
values.
- Use
julia> using EnforcedTypeSignatureCallables
julia> typed_callable(sin, Float32)(0.3f0)
0.29552022f0
julia> typed_callable(sin, Float32)(0.3)
ERROR: TypeError: in typeassert, expected Float32, got a value of type Float64
[...]
julia> typed_callable(hypot, Float64, Tuple{Int, Int})(3, 4)
5.0
julia> typed_callable(hypot, Float64, Tuple{Int, Int})(3, 4.0)
ERROR: TypeError: in typeassert, expected Tuple{Int64, Int64}, got a value of type Tuple{Int64, Float64}
[...]
As discussed in Julia issue
#42372, a type constructor is
not required to return a value of the given type: the return value of a constructor
can technically be of any type! Thus, in the worst case, the compiler is not able
to infer the return type of a constructor like, for example, Int
.
This package presents a workaround (actually merely a nice interface over
functionality already provided with Julia Base
):
julia> using EnforcedTypeSignatureCallables
julia> naive(x) = map(Int, x)
naive (generic function with 1 method)
julia> improved(x) = map(typed_callable(Int, Int), x)
improved (generic function with 1 method)
julia> Base.infer_return_type(naive, Tuple{NTuple{5, Any}}) # pessimistic type inference result
NTuple{5, Any}
julia> Base.infer_return_type(improved, Tuple{NTuple{5, Any}}) # the return type is now known concretely
NTuple{5, Int64}
NB: typed_callable(Int, Int)
actually only consists of types already present in
Base
Julia, so it's just a nicer interface for functionality that already comes
with Julia:
julia> typed_callable(Int, Int)
Base.Fix2{typeof(typeassert), Type{Int64}}(typeassert, Int64) ∘ Int64
The three-argument version of typed_callable
depends on a type defined in this
package, though.
This is what many newcomers to Julia ask for, especially when coming from a statically typed language: being able to express the type signature of a "function" in the type system.
Suppose one is writing a method which takes, among other arguments, a function
(callable object). Further suppose the method needs to be constrained to only apply
when the callable argument has a certain type signature. One way to achieve this is
to restrict the allowed types of the callable argument to chosen subtypes of
CallableWithReturnType
. This includes CallableWithTypeSignature
, so the entire
type signature, except any keyword arguments, may be accounted for.
For example, a CallableWithReturnType{Float32}
is guaranteed to return a
Float32
value, if a value is returned. A
CallableWithTypeSignature{Float32, Tuple{Float32, Float32}}
additionally
guarantees to only accept exactly two Float32
values as positional arguments.
For example, suppose your package has a method that accepts a function from the
user. Further suppose the method code expects the user-provided function to only
ever return Float64
. Instead of sprinkling typeasserts all over your code, it
suffices to call typed_callable
once:
function accepts_a_function_from_the_user(func, other_arguments...)
func = typed_callable(func, Float64)
# any call of `func` is now guaranteed not to return anything other than `Float64`
end
Furthermore, dispatch can also be used to achieve type safety in this regard:
function accepts_a_function_from_the_user_type_safe(func::CallableWithReturnType{Float64}, other_arguments...)
# any call of `func` is guaranteed not to return anything other than `Float64`
end
function accepts_a_function_from_the_user(func, other_arguments...)
func = typed_callable(func, Float64)
accepts_a_function_from_the_user_type_safe(func, other_arguments...)
end
Creating a new local function with a typeassert in the method body would work as intended for both:
-
helping the compiler achieve good inference
-
wrapping a user-provided function into a type-safe wrapper function
However using typed_callable
instead is slightly better:
-
avoiding the creation of a new function is slightly friendlier to the compiler, giving it less work to do
-
using a standardized type may allow the ecosystem to converge on a single type to dispatch on when a callable with a certain type signature is required
- This is more so appropriate as
CallableWithReturnType
is just a type alias for a type already provided byBase
. Thus a package doesn't even need to depend on this package to dispatch onCallableWithReturnType{ReturnType}
.
- This is more so appropriate as