Having human-readable and parseable display styles

What’s the best way to have both human-readable and parseable display styles for your own type, when there are both multiline and inline versions of the human-readable style?

For example, suppose I have a vector-like type which should display its components in “unit vector notation”, (e.g., 2𝒗_1 + 4𝒗_3) like so:

julia> Foo([2, 0, 4])
3-component Foo{Int64}:
 2v1
 4v3

I want repr to be reflective (parseable), so that:

julia> repr(Foo([0, 10, 20]))
"Foo{Int64}([0, 10, 20])"

As I understand, array elements are displayed with show(::IO, ::MIME"text/plain", …), unless the result contains multiple lines, in which case it falls back to show(::IO, …). So by default, an array of Foos would show elements in the repr-style above.

However, my Foo type also lends itself to human-readable single-line representation, which I’d like to use for displaying in arrays:

julia> [Foo([2, 0, 4]), Foo([50, 60, 0])]
2-element Vector{Foo{Int64}}:
 2v1 + 4v3
 50v1 + 60v2

What’s the preferred way to achieve these three behaviours at once? I.e., human-readable multi-line, human-readable single-line, and parseable (single-line) display styles.

I’ve been able to find a bit of a hack which (seems) to work by using the presence of the IO property :typeinfo to guess that printing is happening inside an array.

Hacky implementation
struct Foo{T}
	comps::Vector{T}
end

function showcomps(io::IO, comps, inline=true, indent=1)
	isfirst = true
	inline && print(io, " "^indent)
	for (i, x) in enumerate(comps)
		iszero(x) && continue
		isfirst || print(io, inline ? " + " : "\n")
		print(io, " "^(indent*!inline), x, "v", i)
		isfirst = false
	end
end

Base.show(io::IO, f::Foo) = print(io, typeof(f), "(", f.comps, ")")
function Base.show(io::IO, ::MIME"text/plain", f::Foo)
	if :typeinfo ∈ keys(io)
		# assume showing as element within array
		showcomps(io, f.comps, true, 0)
	else
		# show in full
		println(io, length(f.comps), "-component ", typeof(f), ":")
		showcomps(io, f.comps, false)
	end
end

But I’m not sure if this is the preferred way. In particular, other array types (like DataFrame) would use the repr version, even when displaying in a human-readable format.

I would appreciate any extra insight into the print/show/display interface!

1 Like

I also have been wondering about this question. Some programs use the :typeinfo io property for a
human-readable show and others use the :limit io property (:limit is true within the REPL)

I don’t have a proper solution to this yet, but in the meantime, I’m using keys(io) as a litmus test as follows:

function Base.show(io::IO, f::Foo)
	if :__PRETTY_TABLES_DATA__ ∈ keys(io) # shown in a pretty table/DataFrame
		# compact/single-line human-readable style
	else
		# parseable style
	end
end
function Base.show(io::IO, ::MIME"text/plain", f::Foo)
	if :typeinfo ∈ keys(io) # shown in an Array
		# compact/single-line human-readable style
	else
		# full/multi-line human-readable style
	end
end

This displays Foos using parseable style when occuring in Tuples or Pairs, etc, (which are themselves parseable), but uses compact human-readable style for Arrays and DataTables (which aren’t parseable when displayed).

However, new human-readable container types would have to be treated on a case-by-case basis…

1 Like

No insights to offer on the show interface, but some thoughts…

  1. The representation 2 v_1 + 4v_3 is not in a typical “unit vector” notation; conventionally, the unit vectors are represented (for \mathbb R^3 space) \hat e_1=(1, 0, 0), \hat e_2 = (0, 1, 0), and \hat e_3 = (0, 0, 1), and then a vector \vec v=(2, 0, 4) might be represented \vec v = 2\hat e_1 + 4\hat e_3. Some people might write the components as \vec v = (v_1, v_2, v_3), so that \vec v = v_1 \hat e_1 + v_2 \hat e_2 + v_3 \hat e_3.

  2. Why not show the zero elements? In the grander scheme, they probably improve legibility.

  3. Can you make human-readable form converge with reflective form? maybe something like this:

julia> Foo([2, 0, 4])
3-element Foo{Int64}:
 2e[1]
 0e[2]
 4e[3]

julia> [Foo([2, 0, 4]), Foo([50, 60, 0])]
2-element Vector{Foo{Int64}}:
 2e[1] + 0e[2] + 4e[3]
 50e[1] + 60e[2] + 0e[3]

julia> [2e[1] + 0e[2] + 4e[3], 50e[1] + 60e[2] + 0e[3]]
2-element Vector{Foo{Int64}}:
 2e[1] + 0e[2] + 4e[3]
 50e[1] + 60e[2] + 0e[3]

This might be achieved through something like this:

Some code
struct Component{i, T<:Real}
    comp::T    
    Component(i::Int) = new{i, Bool}(true)
    Component(i::Int, comp::T) where T = new{i, T}(comp) 
end
Base.show(io::IO, c::Component{i}) where i = print(io, "$(c.comp)e[$i]")
Base.show(io::IO, c::Component{i,Bool}) where i = c.comp ? print(io, "e[$i]") : print(io, "Component($i, false)")
direction(c::Component{i}) where i = i::Int
abs(c::Component) = c.comp
function e end
Base.getindex(::typeof(e), i::Int) = Component(i)
Base.:*(x::Number, c::Component{i}) where i = Component(i, x*c.comp)
struct Foo{T} comps::Vector{T} end
Foo(cs::Component...) = begin
    all(x ∈ map(direction, cs) for x=1:length(cs)) || error("Foo not fully specified")
    v = Vector{mapreduce(typeof ∘ abs, promote_type, cs)}(undef, length(cs))
    for c ∈ cs;  v[direction(c)] = abs(c)  end
    Foo(v)
end
Base.show(io::IO, ::MIME"text/plain", f::Foo{T}) where T = 
    print(io, "$(length(f.comps))-element Foo{$T}:\n"*join(map(((i,v),)->" $(v)e[$i]", enumerate(f.comps)), "\n"))
Base.show(io::IO, f::Foo) = print(io, join(map(((i,v),)->"$(v)e[$i]", enumerate(f.comps)), " + "))
Base.promote_rule(::Type{Foo{T1}}, ::Type{Foo{T2}}) where {T1,T2} = Foo{promote_type(T1, T2)}
Base.convert(::Type{Foo{T}}, f::Foo) where T = Foo(T[f.comps...])
Base.typejoin(::Type{Foo{T1}}, ::Type{Foo{T2}}) where {T1,T2} = Foo{var"#T"} where {var"#T"<:typejoin(T1, T2)}
Base.:+(cs::Component...) = Foo(cs...)

It’s sort of a yak-shaving contest, but it was fun to write. Also possible to programmatically generate and export constants ê₁, ê₂, ê₃, …, ê₁₀₀₀₀. as stand-ins for e[1], e[2], e[3], …, e[10_000]—that would make things more readable.

All that said, I’m kinda attracted to the simple approach:

struct Foo{T} comps::Vector{T} end
Foo(vals::Number...) = Foo([vals...])
Foo{T}(vals::Number...) where T = Foo{T}([vals...])
Base.show(io::IO, f::Foo{T}) where T =
    print(io, "Foo"*(isconcretetype(T) ? "" : "{$T}")*"""($(join(f.comps, ", ")))""")
Base.promote_rule(::Type{Foo{T1}}, ::Type{Foo{T2}}) where {T1,T2} = Foo{promote_type(T1, T2)}
Base.convert(::Type{Foo{T}}, f::Foo) where T = Foo(T[f.comps...])
Base.typejoin(::Type{Foo{T1}}, ::Type{Foo{T2}}) where {T1,T2} = Foo{var"#T"} where {var"#T"<:typejoin(T1, T2)}

resulting in:

julia> Foo(2, 0, 4)
Foo(2, 0, 4)

julia> [Foo(2, 0, 4), Foo(50, 60, 0)]
2-element Vector{Foo{Int64}}:
 Foo(2, 0, 4)
 Foo(50, 60, 0)
1 Like

Haha, these are certainly lots of interesting thoughts!

For context, this is for a multivector type in the (unregistered) package Jollywatt/GeometricAlgebra.jl.

  1. The reason for diverging from (x, y, z)-style notation is because bivectors and general multivectors don’t translate well. E.g., how could you write 𝒆_1𝒆_2 + 42?

  2. General multivectors can have up to 2^n components in n dimensions, so omitting zeros is handy. By default, I omit zeros for inhomogeneous multivectors only:

    julia> A = Multivector{4,0:4}(rand(0:2, 16))
    16-component Multivector{4, 0:4, Vector{Int64}}:
     2
     2 v2 + 1 v4
     1 v12 + 2 v13 + 2 v23 + 1 v14
     2 v1234
    
    julia> grade(A, 1)
    4-component Multivector{4, 1, SubArray{Int64, 1, Vector{Int64}, Tuple{UnitRange{Int64}}, true}}:
     0 v1
     2 v2
     0 v3
     1 v4
    
  3. This is a good idea. Having the human-readable version be parseable, so that 𝒆_1𝒆_2 + 42 is shown as v[1]v[2] + 42. However, to achieve true reflectivity, I need the missing type information (the algebra/dimension, the underlying array type, etc). It’s also dodgy assuming v is always in scope, especially because the basis blade style is customisable — e.g., it could be printed as 𝐯₁∧𝐯₂ + 42. So I think it best to have

    julia> print(repr(v12 + 7))
    Multivector{2, (0, 2), MVector{2, Int64}}([7, 1])
    

    which actually does satisfy it == eval(Meta.parse(repr(it))). It’s ugly, but better than being frustrating for new users who want to just see the kind of Julia struct they’re dealing with

1 Like

Just a remark. You don’t have to print(repr(x)), just print(x) does that.

1 Like