[ANN] TypeUtils: dealing with types in Julia

New package TypeUtils provides useful methods to deal with types in Julia.

Cast value to type

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 Ints.

Additional conversions becomes possible if another package such as TwoDimensonal is loaded.

Parameter-less type

The call:

parameterless(T)

yields the type T without parameter specifications. For example:

julia> parameterless(Vector{Float32})
Array

Deal with array element types

The TypeUtils package provides a few methods to deal with array element types:

  • promote_eltype(args...) yields the promoted element type of the arguments args... which may be anything implementing the eltype method.

  • convert_eltype(T,A) yields an array with the same entries as A except that their type is T.

  • as_eltype(T,A) yields an array which lazily converts its entries to type T. This can be seen as a memory-less version of convert_eltype(T,A). The method as_eltype is similar to the method of_eltype provided by the MappedArrays package.

Methods convert_eltype(T,A) and as_eltype(T,A) just return A itself if its elements are of type T.

Type of result returned by a function

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....

7 Likes

Seems generally interesting, but not sure what specific usecases many of these functions solve — compared to Base/existing ones.

For example, when would one prefer as instead of convert or constructor?
Why would one use parameterless()?
promote_eltype(args) doesn’t really read simpler than promote_type(map(eltype, args)...), and more people would know what the latter means.

Also, some as conversions are weird:

What’s the motivation to make this different from Tuple(CartesianIndices(...))? This inconsistency can cause definite confusion.

convert_eltype isn’t as generic as map:

julia> convert_eltype(Float64, (1, 2, 3))
ERROR: MethodError: no method matching convert_eltype(::Type{Float64}, ::Tuple{Int64, Int64, Int64})

julia> map(Float64, (1, 2, 3))
(1.0, 2.0, 3.0)

nor as efficient as specialized functions:

julia> convert_eltype(Float64, 1:5)
5-element Vector{Float64}:

julia> float(1:5)
1.0:1.0:5.0
3 Likes

Erm, convert already does this.

julia> @code_typed convert(Int, 2)
CodeInfo(
1 ─     return x
) => Int64

1 Like

The parameterless method is used by the StructuredArrays package here.

The as(T,x) method is an attempt to unify the syntax. Sometime in Julia you’ll have to type convert(T,x), sometime you have to type T(x), and there may be other possibilities. This is the case for the example you provided: convert(Tuple,CartesianIndices(....)) does not work and as(Tuple,CartesianIndices(....)) amounts to calling Tuple(CartesianIndices(....)). Using as(T,x) avoids you to remember how exactly to do the conversion and makes your intent clear when reading the code.

The idea is to update TypeUtils so that a consistent result is returned as expected by the caller of as(T,x) for different types T. This would be type-piracy to extend convert in that way.

Applying convert_eltype to a range has been fixed as of version 0.3.1 by this commit. I have just submitted a new version (0.3.2) to deal with tuples. Thanks for your suggestion.

1 Like

Yes but, as said in the previous reply, convert(T,x) is not always implemented for given T and typeof(x) although it would make sense to have it. as(T,x) fills this gap avoiding type-piracy.

Really?

julia> struct Foo end

julia> convert(Foo, Foo())
Foo()

julia> @which convert(Foo, Foo())
convert(::Type{T}, x::T) where T
     @ Base Base.jl:84

Does it? The results are different, in a weird and potentially confusing way:

julia> as(Tuple, CartesianIndices(((-2:5), (1:3))))
(-2:5, 1:3)

julia> Tuple(CartesianIndices(((-2:5), (1:3))))
(CartesianIndex(-2, 1), CartesianIndex(-1, 1), CartesianIndex(0, 1), CartesianIndex(1, 1), CartesianIndex(2, 1), CartesianIndex(3, 1), CartesianIndex(4, 1), CartesianIndex(5, 1), CartesianIndex(-2, 2), CartesianIndex(-1, 2), CartesianIndex(0, 2), CartesianIndex(1, 2), CartesianIndex(2, 2), CartesianIndex(3, 2), CartesianIndex(4, 2), CartesianIndex(5, 2), CartesianIndex(-2, 3), CartesianIndex(-1, 3), CartesianIndex(0, 3), CartesianIndex(1, 3), CartesianIndex(2, 3), CartesianIndex(3, 3), CartesianIndex(4, 3), CartesianIndex(5, 3))

Generally, semantics of convert is pretty well-defined. For now, I don’t really understand the semantics of functions like convert_eltype. It can be actively dangerous to code correctness, allowing to “convert” units in arbitrary ways:

julia> convert_eltype(typeof(1.0u"s"), 1:3)
3-element Vector{Quantity{Float64, 𝐓, Unitful.FreeUnits{(s,), 𝐓, nothing}}}:
 1.0 s
 2.0 s
 3.0 s

This isn’t even consistent with as that correctly refuses to convert:

julia> as(typeof(1.0u"s"), 1)
ERROR: DimensionError: s and 1 are not dimensionally compatible.

Your example is a no-op, so what?

BTW what I meant was: “not always implemented in a useful way”.

It shows that convert(T, ::T) is always implemented.

Sorry, I was mistaken CartesianIndex and CartesianIndices. For the former, as(Tuple,x) is the same as Tuple(x) while for the latter it yields x.indices hence your result. This choice is deliberate and is documented here. At the time I decided this, it seemed to make sense (at least to me) and was useful in a number of situations.

This is normal as convert is called here and the units are not compatible (seconds against unitless).

To add to the inconsistent behavior you pointed:

consider the following (very similar) example:

julia> convert_eltype(typeof(1.0u"s"), collect(1:3))
ERROR: DimensionError: s and 1 are not dimensionally compatible.
...

so I completely agree with you this behavior is inconsistent and can be fixed by having convert_eltype directly call as which is not currently the case (it calls the type constructor).

Thank you for pointing this issue, I will fix it very soon unless you have other objections…

I have investigating a bit. The behiavior comes from the different methods (in base Julia) for map(T,r) when r is a range and depending on whether T is a Real (or an AbstractFloat) or something else like typeof(1.0u"s"). Following your example:

julia> map(Float32, 1:3)
1.0f0:1.0f0:3.0f0

julia> map(typeof(u"1.0s"), 1:3)
3-element Vector{Quantity{Float64, 𝐓, Unitful.FreeUnits{(s,), 𝐓, nothing}}}:
1.0 s
2.0 s
3.0 s

For the moment, I am a bit uncertain about what is the most appropriate behavior and how to implement it. Yet I still agree that the current behavior of convert_eltype is inconsistent and has to be fixed

Commit Fix convert_eltype(T,A) when A is a range · emmt/TypeUtils.jl@6d32d7e · GitHub is an attempt to fix convert_eltype for ranges. As said in the doc., The idea is that a range should yield a range.

  1. This is very related to the constructorof function from the ConstructionBase package.
  2. Relevant section in Julia’s Manual: Design Patterns with Parametric Methods.
  3. IMO, the use of parameterless or similar ideas very likely points to design flaws in a piece of Julia code and possible misunderstandings of how Julia should be used. Although it may sometimes appear convenient. Related discussion, design, issues: Missing functionality: converting coefficients · Issue #449 · JuliaMath/Polynomials.jl · GitHub
1 Like

Than you for pointing this. Package ConstructionBase is very interresting. I agree that parameterless (or similar) is not to be widely used, I only have one example of such a need (and I put the code in TypeUtils to not forget this trick).

The implementation of constructorof is more elegant than that of parameterless:

@generated function constructorof(::Type{T}) where T
    getfield(parentmodule(T), nameof(T))
end
@inline parameterless(::Type{T}) where {T} = getfield(Base.typename(T), :wrapper)

After benchmarking, I found that the two methods are as fast. Can you explain the needs to make constructorof a generated function (I found that it does not make it faster).

Don’t know, perhaps making it a generated function was required for good type inference in earlier Julia versions. In any case, the Polynomials package uses the same approach as in TypeUtils, instead of the ConstructionBase approach:

BTW, there’s this relevant open feature request on the Julia GitHub:

Right, this is the same implementation as in TypeUtils but constructorof in ConstructionBase seems less likely to be broken by low-level changes in Julia. I will change parameterless in TypeUtils accordingly.

While I don’t agree with the idea of as since it functions almost identical to convert and occupies a relatively simple name which can easily cause ambiguity, other methods are very useful to me and I appreciate the work.

You can type using TypeUtils: x, y, z with x, y, and z the methods you want to use.

The rationale for as is precisely to have a short name and, compared to convert, to allow for conversions that are not supported by convert whose main purpose is to convert field values to be stored in mutable structures by the fieldset! method.