How does Julia invoke constructors with overlapping constraints

Hello, I am playing with constructors, but seems, that Julia allows to have constructor with overlapping constrains N.

using StaticArrays: SizedVector

mutable struct Context{TYPE_VAR_STACK <: Union{UInt8, UInt16}, N <:Union{UInt8, UInt16}}
    stack::SizedVector
    index::N 

    # Constructor constraint for N <:UInt8
    function Context(stack_type::Type{T}, stack_length::N, def_stack_val=eltype(stack_type)(0),) where {T <: Union{UInt8, UInt16}, N <: Union{UInt8}}
        
        println("ctor UInt8")
        # StaticArrays pkg doesn't allow to have its Size in different types than Int
        size = Int(stack_length)
        new{T, N}(SizedVector{size}(fill(def_stack_val, size)), size)
        
    end

    # More general constructor for N <:Union{UInt8 and UInt16}
    function Context(stack_type::Type{T}, stack_length::N, def_stack_val=eltype(stack_type)(0),) where {T <: Union{UInt8, UInt16}, N <: Union{UInt8, UInt16}}
        # StaticArrays pkg doesn't allow to have its Size in different types than Int
        size = Int(stack_length)
        new{T, N}(SizedVector{size}(fill(def_stack_val, size)), size)
        
    end


end


# More specific
julia> Context(UInt16, UInt8(100))
ctor UInt8
Context{UInt16, UInt8}(UInt16[0, 0, 0, 0, 0, 0, 0, 0, 0, 0  …  0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 100)
# More specific
julia> Context(UInt8, UInt8(100))
ctor UInt8
Context{UInt8, UInt8}(UInt8[0, 0, 0, 0, 0, 0, 0, 0, 0, 0  …  0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 100)

# More generic
julia> Context(UInt8, UInt16(100))
Context{UInt8, UInt16}(UInt8[0, 0, 0, 0, 0, 0, 0, 0, 0, 0  …  0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 100)

and methods

julia> methods(Context)
# 4 methods for type constructor:
 [1] Context(stack_type::Type{T}, stack_length::N) where {T<:Union{UInt16, UInt8}, N<:UInt8}
     @ ~/devel/julia/bf/bf.jl:8
 [2] Context(stack_type::Type{T}, stack_length::N, def_stack_val) where {T<:Union{UInt16, UInt8}, N<:UInt8}
     @ ~/devel/julia/bf/bf.jl:8
 [3] Context(stack_type::Type{T}, stack_length::N, def_stack_val) where {T<:Union{UInt16, UInt8}, N<:Union{UInt16, UInt8}}
     @ ~/devel/julia/bf/bf.jl:18
 [4] Context(stack_type::Type{T}, stack_length::N) where {T<:Union{UInt16, UInt8}, N<:Union{UInt16, UInt8}}
     @ ~/devel/julia/bf/bf.jl:18

I have several question:

  1. Is there some kind of internal mechanism, that uses more specific constructor?
  2. Is there possibility to call more general constructor?
  1. The same way as for any other function multiple dispatch/method specificity Methods · The Julia Language
  2. invoke
3 Likes

Just a slightly off-topic comment. Your struct has an abstract field stack::SizedVector, i.e. a SizedVector without a size and type. This is a performance-killer, since the compiler must generate code for dynamic dispatch when you access the field stack in a Context-object. It’s perfectly ok, but the struct shouldn’t be used in performance critical loops.

1 Like

Thank you!

How does the StaticArrays/SizedVector works?

  1. approach:
#include <stdio.h>
#include <stdlib.h>
int main(void) {
   
  some_type *SizedVector = malloc(sizeof(some_type) * N) 
    
  return EXIT_SUCCESS;
}
  1. approach is basically pointer to some heap memory, and keep reference.

  2. approach

#include <stdio.h>
#include <stdlib.h>
int main(void) {

  some_type Sized_Vector[N];
    
  return EXIT_SUCCESS;
}
  1. Approach, using some macros magic I created sized SizedVector on stack.

I believe, that 2. approach is correct, but I am not sure, I read the code but it slightly cryptic for me (I am just learning Julia for 4th day).

Additional question:
How can I solve that I don’t know the type (T) and size (N) for SizedVector?
Shall I try to use upper boundaries e.g. typemax(UInt16)?

Edit:
Added changed code, but I was out of luck to figure out how does to say SizedVector in the struct correct type.
I tried this, but seems that without luck:

using StaticArrays: SizedVector

mutable struct Context{TYPE_VAR_STACK <: Union{UInt8, UInt16}, N <:Union{UInt8, UInt16}, SIZE <:Val{Int64}}
    stack::SizedVector{SIZE, TYPE_VAR_STACK}
    index::N 

    function Context(stack_type::Type{T}, stack_length::N, def_stack_val::T=eltype(stack_type)(0)) where {T <: Union{UInt8, UInt16}, N <: Union{UInt8, UInt16}}
        # StaticArrays pkg doesn't allow to have its Size in different types than Int
        size = Int64(stack_length)
        stack = SizedVector{size, stack_type}(fill(def_stack_val, size))
        new{T, N, size}(stack, firstindex(stack))
    end
end

Seems, that I was able to do it, but I don’t know if this approach is correct, not sure how many types will be crated based on SIZE.

mutable struct Context{TYPE_VAR_STACK <: Union{UInt8, UInt16}, N <:Union{UInt8, UInt16}, SIZE}
    stack::SizedVector{SIZE, TYPE_VAR_STACK}
    index::N 

    function Context(stack_type::Type{T}, stack_length::N, def_stack_val::T=eltype(stack_type)(0)) where {T <: Union{UInt8, UInt16}, N <: Union{UInt8, UInt16}}
        # StaticArrays pkg doesn't allow to have its Size in different types than Int
        size = Int64(stack_length)
        stack = SizedVector{size, stack_type}(fill(def_stack_val, size))
        return new{T, N, size}(stack, firstindex(stack))
    end
end


julia> sdd = Context(UInt16, UInt16(1200), UInt16(33))
Context{UInt16, UInt16, 1200}(UInt16[0x0021, 0x0021, 0x0021, 0x0021, 0x0021, 0x0021, 0x0021, 0x0021, 0x0021, 0x0021  …  0x0021, 0x0021, 0x0021, 0x0021, 0x0021, 0x0021, 0x0021, 0x0021, 0x0021, 0x0021], 0x0001)

The SizedVector actual has three parameters.

One way to handle this transparently is to parametrize the stack type:

mutable struct Context{TYPE_VAR_STACK <: Union{UInt8, UInt16}, N <:Union{UInt8, UInt16}, VTYPE}
    stack::VTYPE
    index::N 

    function Context(stack_type::Type{T}, stack_length::N, def_stack_val::T=eltype(stack_type)(0)) where {T <: Union{UInt8, UInt16}, N <: Union{UInt8, UInt16}}
        # StaticArrays pkg doesn't allow to have its Size in different types than Int
        size = Int64(stack_length)
        stack = SizedVector{size, stack_type}(fill(def_stack_val, size))
        new{T, N, typeof(stack)}(stack, firstindex(stack))
    end
end

However, there’s a subtle problem here. In the constructor, you create a type from a value,
i.e. the value of size, i.e. of the argument stack_length is put into the type domain when you use it as a type parameter. If you do something like

function f(N, ...)
    ctx = Context(UInt8, N)
    while ...
        <use ctx>
    end
end

The compiler does not know what type ctx is when it compiles the while loop, because the compiler does not know the value of N, only its type (presumably an integer of some type). So it can’t infer the full type of ctx, and may resort to dynamic dispatch.

Unless it’s important to have the size of the stack in Context in the type domain, I would suggest to use an ordinary Vector instead. The SizedVector is anyway just a thin wrapper around the Vector you create with fill:

julia> ctx = Context(UInt8, UInt16(1024));
julia> dump(ctx)
Context{UInt8, UInt16, SizedVector{1024, UInt8, Vector{UInt8}}}
  stack: SizedVector{1024, UInt8, Vector{UInt8}}
    data: Array{UInt8}((1024,)) UInt8[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00  …  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
  index: UInt16 0x0001
1 Like

Thank you!

One more question:
Here I used SIZE and passed integer, I feel that it is wrong:

It is wrong because for each of the size, the compiler creates its own type somewhere?
e.g

julia> dump(Context(UInt16, UInt16(1200), UInt16(33)))
Context{UInt16, UInt16, 1200}
  stack: SizedVector{1200, UInt16, Vector{UInt16}}
    data: Array{UInt16}((1200,)) UInt16[0x0021, 0x0021, 0x0021, 0x0021, 0x0021, 0x0021, 0x0021, 0x0021, 0x0021, 0x0021  …  0x0021, 0x0021, 0x0021, 0x0021, 0x0021, 0x0021, 0x0021, 0x0021, 0x0021, 0x0021]
  index: UInt16 0x0001

julia> dump(Context(UInt16, UInt16(1), UInt16(33)))
Context{UInt16, UInt16, 1}
  stack: SizedVector{1, UInt16, Vector{UInt16}}
    data: Array{UInt16}((1,)) UInt16[0x0021]
  index: UInt16 0x0001

For Size=1200 there will be some type
For Size=1 there is another
?

Yes, but it’s not wrong, it works. It depends on what you’re using this for. When you call a function, it is compiled for the exact type of the actual arguments. So with many different values of Size you will compile a lot. If, in addition, the size isn’t known at compile time, the compiler may not even know which particular method to call, so even a simple access like ctx.stack[14] may end up with a dynamic dispatch (a runtime method lookup), both for getproperty (the .stack part), and getindex (the [14] indexing part).

In some cases, in particular with small StaticVectors with compile time sizes (e.g. 3d vectors and matrices, or other small (say < 100) fixed sizes), the compiler gets many opportunities to optimize, using the stack instead of the heap, unroll loops etc. So then it can very beneficial.

Since SizedVector really is just a wrapper around a Vector, it’s mostly useful for static dispatch on the vector length, I don’t think there are any big optimization opportunities beyond that. I.e. you may write f(x::SizedVector{1000)) = ..., and another method for f(x::SizedVector{2000}) = ....

Btw, there is also a low-level fixed size Memory struct, without the length in the type domain (so no dispatch on size), which works much like a fixed size Vector.

julia> v = Memory{UInt8}(undef, 100)
100-element Memory{UInt8}:
...

Indeed, Vector is a wrapper around a Memory struct. In practice, a Vector works just as well and has more functionality, the wrapper is usually optimized away by the compiler, so there’s usually little to gain by using Memory.

1 Like

Thank you!