Printing of UnionAll types is misleading, as of Julia version 1.7

Printing of UnionAll types used to be honest:

Julia 1.6

julia> struct A{asdf, qwer} end

julia> A{Int}
A{Int64, qwer} where qwer

julia> using StructArrays

julia> struct Foo
           x::Int
       end

julia> StructArray{Foo}
StructArray{Foo, N, C, I} where {N, C<:Union{Tuple, NamedTuple}, I}

But as of Julia version 1.7, printing of UnionAll types is misleading:

Julia 1.7

julia> struct A{asdf, qwer} end

julia> A{Int}
A{Int64}

julia> using StructArrays

julia> struct Foo
           x::Int
       end

julia> StructArray{Foo}
StructArray{Foo}

The printing in 1.7+ could easily lead a programmer to believe that A{Int} and StructArray{Foo} are concrete types. In fact, here’s a Discourse question where the misleading printing might have been a contributing factor to the mistake that was made.

Question

Why was this change made to UnionAll printing?

Footnote

I’m aware that StructArray{Foo} is equivalent to the long form:

julia> StructArray{Foo} == (StructArray{Foo, N, C, I} where {N, C<:Union{Tuple, NamedTuple}, I})
true

However, I think printing StructArray{Foo} instead of the long form just has too much potential for confusion and obfuscation.

8 Likes

Everyone’s always complaining that parametric type printing is too verbose and cryptic. So you make the printing of parametric types less verbose and closer to what a person would write. Then bam, what happens? That’s no good neither.

6 Likes

I know you can’t please everyone, but I would err on the side of clarity rather than brevity. A common mistake that I see from beginners is not realizing that Array{Int} is an abstract type. This new parsimonious printing just exacerbates that problem.

3 Likes

One slightly subtle option would be printing a trailing comma if there are more type parameters. Ie Vector{Int} but Array{Int,}.

13 Likes

The trailing comma idea seems perfect to me. We need a cake-and-eat-it-too solution, where stack traces are still short but we can see type instability easily. Array{Int,} contains just as much information as Array{Int,N} where N with only one character added. We could even have Array{,}, and SomeType{Int,,,,,,} ? Those commas communicate a lot using only one character.

7 Likes

I can see the other point of view, which is this:

Type parameters that are part of the interface for a package should be documented. Type parameters that are not documented are implementation details. Since the number of public type parameters is defined by the documentation, there is no obligation to print trailing type parameters. (Hopefully sane package authors will have all the public type parameters precede the private type parameters.)

For example, it is documented that Array has two type parameters, T and N. So if one has read the documentation, then one would know that Array{T} has one free parameter and is therefore a UnionAll abstract type.

However, without careful documentation, folks that are creating wrapper types might accidentally define a struct field with an abstract type. So, the StructArray docstring would probably need a note like the following:

The type StructArray{T, N} with T and N fixed is an abstract type, because there are additional free type parameters that are considered implementation details.

Printed types are usually runnable code. Array{Int,} is runnable, but multiple trailing commas is not runnable:

julia> struct Foo{A, B, C} end

julia> Foo{Int,}
Foo{Int64}

julia> Foo{Int,,}
ERROR: syntax: unexpected ","
2 Likes
julia> struct Foo{A, B, C} end

julia> Foo{Int, #=...=#}
Foo{Int64}

Is another option. It’s a bit verbose, but at least the verbosity only scales O(1).

3 Likes

That makes sense, but I’m trying to workshop your problem without creating a problem for me, which is usually stack traces that are far too long. The idea is a some middle ground that is “honest” but doesn’t take characters.

@jar1 has a possible solution. I wonder what other middle ground ideas could work and also be runnable types.

Can you give an example? Off the top of my head, I’m not seeing how UnionAll printing affects stack traces. In the couple examples that I’ve tried, what gets printed is the concrete types of objects.

I don’t really know… I haven’t been following this much more than “there seems to be less junk in the REPL these days, thanks whoever did whatever that was”.

But generally printing X} where X is a waste of space when a comma would communicate nearly as much. Basically I just like the idea of a comma as an abbreviation of unspecified type parameters anywhere.

3 Likes

While the , is a middle ground for this, IMHO, I think it will break some Julia concepts. For example, under Noteworthy differences from R, it states:

Julia allows an optional trailing comma when that comma does not change the meaning of code.

Using , for Foo{Int64,} and then saying somewhere in the doc : “this means there are still other types present in the DataType”, I think it is breaking (in concepts) since users who won’t read that part of the docs will be open to surprises and then wonder and ask themselves what is actually happening:

julia> Foo{Int64,} == Foo{Int64}
true

As you pointed out @CameronBieganek

Printed types are usually runnable code.

So it would make sense to just leave it as Foo{Int64}. However this isn’t a runnable code:

julia> Vector
Vector (alias for Array{T, 1} where T)

julia> Vector (alias for Array{T, 1} where T)
ERROR: syntax: space before "(" not allowed in "Vector (" at REPL[6]:1

So my suggestion is this: print it as Foo{Int64} but then provide (Foo{Int64, ....}). Using an example, it would be printed this way:

julia> struct Foo{A, B, C, D} end

julia> Foo{Int64}
Foo{Int64} (Foo{A, B, C, D})

julia> Foo{Int64, Int64}
Foo{Int64, Int64} (Foo{A, B, C, D})

julia> Foo{Int64, Int64, Int64}
Foo{Int64, Int64, Int64} (Foo{A, B, C, D})

julia> Foo{Int64, Int64, Int64, Int64}
Foo{Int64, Int64, Int64, Int64}

The (Foo{A, B, C, D}) part will be printed in dull colour just as (alias for Array{T, 1} where T) is; once it’s that way, then it doesn’t have to be a runnable code. This would let ANYONE in the world easily see what number of types are actually need to make it a concrete type.

An alternative would be to print it out this way:

julia> struct Foo{A, B, C, D} end

julia> Foo{Int64}
Foo{Int64} (Foo{..., B, C, D})

julia> Foo{Int64, Int64}
Foo{Int64, Int64} (Foo{..., C, D})

julia> Foo{Int64, Int64, Int64}
Foo{Int64, Int64, Int64} (Foo{..., D})

julia> Foo{Int64, Int64, Int64, Int64}
Foo{Int64, Int64, Int64, Int64}

And users can easily see the number of types that has been called and the number left. I think the first is good though. But anyways, I don’t think the , is great. If for anything, it won’t solve this issue in an elegant way and it would raise more confusions.

1 Like

As far as I can tell, printing of UnionAll types doesn’t come into play in stacktraces, it only comes into play when you are inspecting a type in the REPL. And generally if you are inspecting a type in the REPL, you want as much information about the type as you can get. Thus, it seems to me that the old printing behavior is still the best.

1 Like

Type signatures are printed in stack traces.

This is my best attempt to get UnionAll printing to show up in a stacktrace on Julia 1.6:

julia> struct Foo{A, B} end

julia> struct Bar end

julia> asdf(::Type{Bar}) = 1
asdf (generic function with 1 method)

julia> qwer(x) = asdf(x)
qwer (generic function with 1 method)

julia> qwer(Foo{Int})
ERROR: MethodError: no method matching asdf(::Type{Foo{Int64, B} where B})
Closest candidates are:
  asdf(::Type{Bar}) at REPL[3]:1
Stacktrace:
 [1] qwer(x::Type)
   @ Main ./REPL[4]:1
 [2] top-level scope
   @ REPL[5]:1

This is an uncommon scenario where you pass a UnionAll type (not an instance) as an argument to a function. But even in this example the UnionAll printing only shows up in the MethodError, not in the stacktrace. For other scenarios, you see UnionAll printing in the Closest candidates are: section, but not in the stacktrace.

This doesn’t print a UnionAll in the stacktrace either:

julia> foo(x) = 1 + x
foo (generic function with 1 method)

julia> foo(Foo{Int})
ERROR: MethodError: no method matching +(::Int64, ::Type{Foo{Int64, B} where B})
Closest candidates are:
  +(::Any, ::Any, ::Any, ::Any...) at operators.jl:560
  +(::T, ::T) where T<:Union{Int128, Int16, Int32, Int64, Int8, UInt128, UInt16, UInt32, UInt64, UInt8} at int.jl:87
  +(::Union{Int16, Int32, Int64, Int8}, ::BigInt) at gmp.jl:534
  ...
Stacktrace:
 [1] foo(x::Type)
   @ Main ./REPL[6]:1
 [2] top-level scope
   @ REPL[7]:1

Here’s another thread where the new printing of UnionAll types is a hindrance to understanding:

It’s a little disappointing that I had to go back to an old version of Julia in order to explain a concept.

3 Likes