Ambiguity with Vararg outer constructor and inner constructor

I have a working Point type that works smoothly in 2D and 3D with various outer constructors:

using StaticArrays

struct Point{Dim,T}
  coords::SVector{Dim,T}
end

# convenience constructors
Point{Dim,T}(coords::NTuple{Dim,V}) where {Dim,T,V} = Point{Dim,T}(SVector(coords))
Point{Dim,T}(coords::Vararg{V,Dim}) where {Dim,T,V} = Point{Dim,T}(SVector(coords))
Point(coords::NTuple{Dim,T}) where {Dim,T} = Point{Dim,T}(SVector(coords))
Point(coords::Vararg{T,Dim}) where {Dim,T} = Point{Dim,T}(SVector(coords))
Point(coords::AbstractVector{T}) where {T} =
  Point{length(coords),T}(SVector{length(coords)}(coords))

# coordinate type conversions
Base.convert(::Type{Point{Dim,T}}, coords) where {Dim,T} = Point{Dim,T}(coords)
Base.convert(::Type{Point{Dim,T}}, p::Point) where {Dim,T} = Point{Dim,T}(p.coords)
Base.convert(::Type{Point}, coords) = Point{length(coords),eltype(coords)}(coords)

# type aliases for convenience
const Point2  = Point{2,Float64}
const Point3  = Point{3,Float64}
const Point2f = Point{2,Float32}
const Point3f = Point{3,Float32}

I can for example create the following points:

# most commonly used
Point((1,2)) # figures out that (Dim, T) = (2, Int)
Point(1,2)   # the same without the extra parenthesis
Point([1, 2]) # from a vector because most users expect it to work

# forcing different types
Point{2,Float32}((1.0,2.0)) # converts from Float64 to Float32
Point{2,Float32}(1.0, 2.0)   # the same without parenthesis

# with cleaner syntax
Point2f((1.0,2.0)) # same as previous section
Point2f(1.0, 2.0) # same as previous section

# converting from array syntax
Point2f[(1,2), (3,4)] # vector of points with Float32 coordinates

The problem starts when I try to do the same with 1D points, i.e. a single coordinate. The code enters in an infinite loop because the outer Vararg constructor keeps calling itself as opposed to calling the inner constructor with SVector:

Point(1.0) # infinite loop here
Point((1.0,)) # the same infinite loop

I expected a Point{1,Float64} as the result. Is this intended behavior? How can I preserve the functionality above and still handle the 1D case? I appreciate any help, I’ve been struggling with this dispatch rule for a while now, and other people are trying to help too (cc: @FPGro).

1 Like

To clarify: the problem is this line:

For the one dimensional case Point{Dim,T}(::Vararg{V,Dim}) is ambiguous with the (implicit) default constructor, which should approximately be Point{Dim,T}(::Any). As it stands now, the outer constructor appears to be more specific and is recursively called for one dimensional input. My proposed change was Point{Dim,T}(coord::V, coords::Vararg{V}) where {Dim,T,V}, which is now apparently less specific than the default constructor and is now never called for 1D input. Can somebody point me at a reference for how julia determines specificity? It’s a tiny bit confusing in this case.
Anyways, my proposed fix mostly works, but leads to the inconsistency that Point{2,Type}(x, y) works fine (Varargs constructor), but Point{1,Type}(x) now calls the inner constructor in all cases, so it fails for the usual input types like Ints and Floats.

My next idea would be providing an explicit inner constructor with restricted type ::AbstractArray or even ::StaticArray, which should hopefully catch the outputs of all outer constructors but still let the Varargs constructor handle cases like Point{1,Int}(1).

Any thoughts on that?

If everything fails, I can still do a little manual dispatch to the inner constructor with a dummy type, but that would be less elegant.

1 Like

This seems to work:

using StaticArrays

struct Point{Dim,T}
  coords::SVector{Dim,T}
end

# convenience constructors
Point{Dim,T}(coords::NTuple{Dim,V}) where {Dim,T,V} = Point{Dim,T}(SVector(coords))
Point{Dim,T}(coords...) where {Dim,T} = Point{Dim,T}(coords)
Point(coords::NTuple{Dim,T}) where {Dim,T} = Point{Dim,T}(SVector(coords))
Point(coords...) = Point(coords)
Point(coords::AbstractVector{T}) where {T} =
  Point{length(coords),T}(SVector{length(coords)}(coords))

# coordinate type conversions
Base.convert(::Type{Point{Dim,T}}, coords) where {Dim,T} = Point{Dim,T}(coords)
Base.convert(::Type{Point{Dim,T}}, p::Point) where {Dim,T} = Point{Dim,T}(p.coords)
Base.convert(::Type{Point}, coords) = Point{length(coords),eltype(coords)}(coords)

# type aliases for convenience
const Point2  = Point{2,Float64}
const Point3  = Point{3,Float64}
const Point2f = Point{2,Float32}
const Point3f = Point{3,Float32}
1 Like

Almost, I like reducing the Vararg constructors to calling tuple constructors. However,

julia> Point{1,Int}((1))
ERROR: MethodError: Cannot `convert` an object of type Int64 to an object of type SVector{1, Int64}
Closest candidates are:
  convert(::Type{SVector{N, T}}, ::CartesianIndex{N}) where {N, T} at C:\Users\gross\.julia\packages\StaticArrays\NTbHj\src\SVector.jl:46
  convert(::Type{SA}, ::Tuple) where SA<:StaticArray at C:\Users\gross\.julia\packages\StaticArrays\NTbHj\src\convert.jl:13
  convert(::Type{SA}, ::SA) where SA<:StaticArray at C:\Users\gross\.julia\packages\StaticArrays\NTbHj\src\convert.jl:12
  ...
and the same happens for `Point{1,Int}(1)`, which was causing problems before.
```

Actually, it does work for the one element tuple, just not for the one scalar call:

julia> Point{1,Int}((1,))
Point{1,Int64}([1])

julia> Point{1,Int}(1)
ERROR: MethodError: Cannot `convert` an object of type Int64 to an object of type SArray{Tuple{1},Int64,1,1}

:thinking:

Oh yeah, that , caught me off guard. But it’s just a tiny step from there. I just tried to include my second proposal by changing the definition of point to

struct Point{Dim,T}
  coords::SVector{Dim,T}
  Point{Dim,T}(coords::AbstractVector) where {Dim,T} = new{Dim,T}(coords)
end

and that seems to work now as intended. Thanks.
Any further comments?

Edit: StaticArrays defines a convert from AbstractArray to StaticArray here, so this can be considered safe in the inner constructor.

1 Like

Another recursion I just discovered, those are really funny:

julia> Point{2,Int}((3,3,3))
ERROR: InterruptException:
Stacktrace:
 [1] Point{2, Int64}(coords::Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Tuple{Int64, Int64, Int64}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}})
   @ Main ~\Desktop\meshes.jl:10
 [2] top-level scope
   @ REPL[10]:1

That’s a lot of Tuples. Point{Dim,T}(coords::NTuple{Dim,V}) where {Dim,T,V} does only catch cases where dimensions match of course.
Since StaticArrays contains a guard against that, they seem to have encountered the same problem: I suggest to also introduce this:

Point{Dim,T}(coords::Tuple{Tuple{Tuple{<:Tuple}}}) where {Dim,T} = 
    throw(DimensionMismatch("No precise constructor for Point{$(Dim),$(T)} found. Length of input was $(length(coords[1][1][1]))."))

which was adapted from the StaticArrays version here:

julia> Point{2,Int}((3,3,3))
ERROR: DimensionMismatch("No precise constructor for Point{2,Int64} found. Length of input was 3.")
...

Edit: or better yet

Point{Dim1,T}(coords::NTuple{Dim2}) where {T,Dim1,Dim2} = 
    throw(DimensionMismatch("Can't construct a Point{$(Dim1),$(T)} with an input of length $(Dim2)"))
julia> Point{1,Int}((3,3,3))
ERROR: DimensionMismatch("Can't construct a Point{1,Int64} with an input of length 3")

which is even clearer what it does in the code.

1 Like

Thank you all, I am a bit confused with the snippets of code. Did we arrive at a final solution? What is the modification needed compared to the original code?

Almost, I did just add to the PR. However, the constructors now fail for mixed input types only. This is seriously weird, but I’ll investigate:

julia> Point{2,Float64}(0,0)
Point{2, Float64}([0.0, 0.0])

julia> Point{2,Float64}(0.,0)
ERROR: DimensionMismatch("Can't construct a Point{2,Float64} with an input of length 1")
Stacktrace:
 [1] Point{2, Float64}(coords::Tuple{Tuple{Float64, Int64}})
   @ Main ~\Desktop\meshes.jl:17
 [2] Point{2, Float64}(coords::Tuple{Float64, Int64})
   @ Main ~\Desktop\meshes.jl:10
 [3] top-level scope
   @ REPL[15]:1

julia> Point{2,Float64}((0.,0))
ERROR: DimensionMismatch("Can't construct a Point{2,Float64} with an input of length 1")
Stacktrace: