Package TypeUtils
provides useful methods to deal with types in
Julia and facilitate coding with numbers whether they have
units or not. The package provides methods to strip units from numbers or numeric types,
convert the numeric type of quantities (not their units), determine appropriate numeric
type to carry computations mixing numbers with different types and/or units. These methods
make it easy to write code that works consistently for numbers with any units (including
none). The intention is that the TypeUtils
package automatically extends its exported
methods when packages such as Unitful
are
loaded.
The method, as
is designed to cast a value to a given type. The name was inspired by
the built-in Zig function
@as
.
A first usage is:
as(T, x)
which yields x
converted to type T
. This behaves like a lazy version of
convert(T,x)::T
doing nothing if x
is already of type T
and performing the
conversion and the type assertion otherwise.
By default, the as
method calls convert
only if needed but also implements a number of
conversions not supported by convert
. The as
method is therefore a bit more versatile
than convert
while relaxing the bother to remember which function or constructor to call
to efficiently perform the intended conversion. For example:
julia> as(Tuple, CartesianIndex(1,2,3)) # yields tuple of indices
(1, 2, 3)
julia> as(CartesianIndex, (1,2,3)) # calls constructor
CartesianIndex(1, 2, 3)
julia> as(Tuple, CartesianIndices(((-2:5), (1:3)))) # yields tuple of index ranges
(-2:5, 1:3)
julia> as(CartesianIndices, ((-2:5), (1:3))) # calls constructor
CartesianIndices((-2:5, 1:3))
julia> as(String, :hello) # converts symbol to string
"hello"
julia> as(Symbol, "hello") # converts string to symbol
:hello
Another usage is:
as(T)
which yields a callable object that converts its argument to type T
. This can be useful
with map
. For instance:
map(as(Int), dims)
to convert dims
to a tuple (or array) of Int
s.
Additional conversions becomes possible if another package such as
TwoDimensonal
is loaded.
unitless(x)
yields x
without its units, if any. x
can be a number or a numeric type.
In the latter case, unitless
behaves like bare_type
described below.
bare_type(x)
yields the bare numeric type of x
(a numeric value or type). If this
method is not extended for a specific type, the fallback implementation yields
typeof(one(x))
. With more than one argument, bare_type(args...)
yields the type
resulting from promoting the bare numeric types of args...
. With no argument,
bare_type()
yields TypeUtils.BareNumber
the union of bare numeric types that may be
returned by this method.
real_type(x)
yields the bare real type of x
(a numeric value or type). If this method
is not extended for a specific type, the fallback implementation yields
typeof(one(real(x))
. With more than one argument, real_type(args...)
yields the type
resulting from promoting the bare real types of args...
. With no argument, real_type()
yields Real
the super-type of types that may be returned by this method.
The only difference between bare_type
and real_type
is how they treat complex numbers.
The former preserves the complex kind of its argument while the latter always returns a
real type. You may assume that real_type(x) = real(bare_type(x))
. Conversely,
convert_bare_type(T,x)
yields a complex result if T
is complex and a real result if
T
is real whatever x
, while convert_real_type(T,x)
yields a complex result if x
is
complex and a real result if x
is real, only the real part of T
matters for
convert_real_type(T,x)
. See examples below.
floating_point_type(args...)
yields a floating-point type appropriate to represent the
bare real type of args...
. With no argument, floating_point_type()
yields
AbstractFloat
the super-type of types that may be returned by this method. You may
consider floating_point_type(args...)
as an equivalent to tofloat(real_type(args...))
.
The floating-point type can be seen as the numerical precision for computations involving
args...
.
convert_bare_type(T,x)
converts the bare numeric type of x
to the bare numeric type of
T
while preserving the units of x
if any. Argument x
may be a number or a numeric
type, while argument T
must be a numeric type. If x
is one of missing
, nothing
,
undef
, or the type of one of these singletons, x
is returned.
convert_real_type(T,x)
converts the bare real type of x
to the bare real type of T
while preserving the units of x
if any. Argument x
may be a number or a numeric type,
while argument T
must be a numeric type. If x
is one of missing
, nothing
, undef
,
or the type of one of these singletons, x
is returned.
convert_floating_point_type(T,x)
converts the bare real type of x
to the suitable
floating-point type for type T
while preserving the units of x
if any. Argument x
may be a number or a numeric type, while argument T
must be a numeric type. If x
is
one of missing
, nothing
, undef
, or the type of one of these singletons, x
is
returned. You may consider convert_floating_point_type(T,x)
as an equivalent to to
convert_real_type(float(real_type(T)),x)
.
The call:
parameterless(T)
yields the type T
without parameter specifications. For example:
julia> parameterless(Vector{Float32})
Array
The TypeUtils
package provides the following types for array shape, size, and axes:
-
ArrayAxis
is an alias to the possible canonical types representing a single array index range, that isAbstractUnitRange{eltype(Dims)}
. -
ArrayAxes{N}
is an alias to the possible canonical types representingN
-dimensional array axes, that isNTuple{N,ArrayAxis}
. For anyN
-dimensional arrayA
,axes(A) <: ArrayAxes{N}
should hold. -
ArrayShape{N}
is an alias to theN
-tuple of array dimensions and/or index ranges to whichas_array_shape
,as_array_size
, oras_array_axes
are applicable (see below).
Note that Dims{N}
is the same as NTuple{N,Int}
in Julia and represents the result of
size(A)
for the N
-dimensional array A
, so eltype(Dims) = Int
.
Given a list inds...
of array dimension lenghts (integers) and/or index ranges
(integer-valued unit ranges), the following methods from TypeUtils
are applicable:
-
as_array_shape(inds...)
yields canonical array axes (ifinds...
contains any index range other thanBase.OneTo
) or canonical array size (otherwise). The former is an instance ofArrayAxes{N}
, the latter is an instance ofDims{N}
. -
as_array_axes(inds...)
yields canonical array axes, that is aN
-tuple of typeArrayAxes{N}
. -
as_array_size(inds...)
yields canonical array size, that is aN
-tuple of typeDims{N}
.
Of course, these methods also accept that their arguments be sopecified by a tuple of
array dimension lenghts and/or index ranges, that is (inds...,)
instead of inds...
in
the above example.
To deal with individual array dimension length and/or index range:
-
as_array_axis
converts its argument to a single array axis of typeArrayAxis
. -
as_array_dim
converts its argument to a single array dimension length of typeeltype(Dims)
, that is anInt
.
The method new_array(T, inds...)
yields a new array with elements of type T
and shape
defined by inds...
. Following the semantic of as_array_shape
, the returned array is an
OffsetArray{T}
if inds...
contains any index range other than Base.OneTo
and an
Array{T}
otherwise. In the former case, an exception is thrown if the package
OffsetArrays
has not been loaded.
The TypeUtils
package provides a few methods to deal with array element types:
-
promote_eltype(args...)
yields the promoted element type of the argumentsargs...
which may be anything implementing theeltype
method. -
convert_eltype(T,A)
yields an object similar toA
except that its elements have typeT
. -
as_eltype(T,A)
yields an array which lazily converts its entries to typeT
. This can be seen as a memory-less version ofconvert_eltype(T,A)
. The methodas_eltype
is similar to the methodof_eltype
provided by theMappedArrays
package.
Methods convert_eltype(T,A)
and as_eltype(T,A)
just return A
itself if its elements
are of type T
.
The call:
g = as_return(T, f)
yields a callable object such that g(args...; kwds...)
lazily converts the value
returned by f(args...; kwds...)
to the type T
. Methods return_type(g)
and
parent(g)
can be used to respectively retrieve the type T
and the original function
f
. A similar kind of object be built with the composition operator:
g = as(T)∘f
The method return_type
may also be used as:
T = return_type(f, argtypes...)
to infer the type T
of the result returned by f
when called with arguments of types
argtypes...
.
It is sometime useful to collect the values stored by a structured object into simple collection of values (a tuple or a vector). The reverse operation is also needed. The call:
vals = destructure(obj)
yields a tuple, vals
, of the values of the structured object obj
and, conversely, the
call:
obj = restructure(T, vals)
builds a structured object of type T
given vals
, a tuple or a vector of its values.
For an immutable concrete object obj
, the following identity holds:
restructure(typeof(obj), destructure(obj)) === obj
It is also possible to destructure an object into a given vector of values:
destructure!(vals, obj)
Optionally, in restructure
and destructure!
methods, keyword offset
may be specified
to not start with the first value in vals
.
Method struct_length
yields the minimal number of values needed to destructure an
object.
The following examples illustrate the result of the methods provided by TypeUtils
, first
with bare numbers and bare numeric types, then with quantities:
julia> using TypeUtils
julia> map(unitless, (2.1, Float64, true, ComplexF32))
(2.1, Float64, true, ComplexF32)
julia> map(bare_type, (1, 3.14f0, true, 1//3, π, 1.0 - 2.0im))
(Int64, Float32, Bool, Rational{Int64}, Irrational{:π}, Complex{Float64})
julia> map(real_type, (1, 3.14f0, true, 1//3, π, 1.0 - 2.0im))
(Int64, Float32, Bool, Rational{Int64}, Irrational{:π}, Float64)
julia> map(x -> convert_bare_type(Float32, x), (2, 1 - 0im, 1//2, Bool, Complex{Float64}))
(2.0f0, 1.0f0, 0.5f0, Float32, Float32)
julia> map(x -> convert_real_type(Float32, x), (2, 1 - 0im, 1//2, Bool, Complex{Float64}))
(2.0f0, 1.0f0 + 0.0f0im, 0.5f0, Float32, ComplexF32)
julia> using Unitful
julia> map(unitless, (u"2.1GHz", typeof(u"2.1GHz")))
(2.1, Float64)
julia> map(bare_type, (u"3.2km/s", u"5GHz", typeof((0+1im)*u"Hz")))
(Float64, Int64, Complex{Int64})
julia> map(real_type, (u"3.2km/s", u"5GHz", typeof((0+1im)*u"Hz")))
(Float64, Int64, Int64)
The following example shows a first attempt to use bare_type
to implement efficient
in-place multiplication of an array (whose element may have units) by a real factor (which
must be unitless in this context):
function scale!(A::AbstractArray, α::Number)
alpha = convert_bare_type(eltype(A), α)
@inbounds @simd for i in eachindex(A)
A[i] *= alpha
end
return A
end
An improvement is to realize that when α
is a real while the entries of A
are
complexes, it is more efficient to multiply the entries of A
by a real-valued multiplier
rather than by a complex one. Implementing this is as simple as replacing
convert_bare_type
by convert_real_type
to only convert the bare real type of the
multiplier while preserving its complex/real kind:
function scale!(A::AbstractArray, α::Number)
alpha = convert_real_type(eltype(A), α)
@inbounds @simd for i in eachindex(A)
A[i] *= alpha
end
return A
end
This latter version consistently and efficiently deals with α
being real while the
entries of A
are reals or complexes, and with α
and the entries of A
being
complexes. If α
is a complex and the entries of A
are reals, the statement A[i] *= alpha
will throw an InexactConversion
if the imaginary part of α
is not zero. This
check is probably optimized out of the loop by Julia but, to handle this with guaranteed
no loss of efficiency, the code can be written as:
function scale!(A::AbstractArray, α::Union{Real,Complex})
alpha = if α isa Complex && bare_type(eltype(A)) isa Real
convert(real_type(eltype(A)), α)
else
convert_real_type(eltype(A), α)
end
@inbounds @simd for i in eachindex(A)
A[i] *= alpha
end
return A
end
The restriction α::Union{Real,Complex}
accounts for the fact that in-place
multiplication imposes a unitless multiplier. Since the test leading to the expression
used for alpha
is based on the types of the arguments, the branch is eliminated at
compile time and the type of alpha
is known by the compiler. The InexactConversion
exception may then only be thrown by the call to convert
in the first branch of the
test.
This seemingly very specific case was in fact the key point to allow for packages such as
LazyAlgebra or
LinearInterpolators to work seamlessly
on arrays whose entries may have units. The TypeUtils
(formerly Unitless
) package was
created to cover this need as transparently as possible.
TypeUtils
can be installed as any other official Julia packages. For example:
using Pkg
Pkg.add("TypeUtils")