Default constructor not generated for struct created by `eval`

I’m trying to write a function to “vectorize” a struct. A plain struct would be defined like:

struct Bus{T}
    vm::T
end

And I want the output to be:

struct BusVec{T}
    vm::Vector{T}
end

A function is implemented below by parsing the defined Bus to get the field names and types. A struct expression will be created and evaled.

The problem is that the newly created struct does not have any constructor.

Copy-and-paste MWE:


"""
This is the MWE of some function to derive a new type from a defined type.
"""
function generate_vector_type(::Type{T}) where T
    struct_name = Symbol(nameof(T), "Vec")
    fields = fieldnames(T)
    
    # Get field information from the original type definition
    type_def = Base.unwrap_unionall(T)
    field_types = type_def.types
    
    # Create vectorized field expressions
    field_exprs = [:($(fields[i])::Vector{$(field_types[i])}) for i in eachindex(fields)]
    
    # Get type parameters, ignore supertype for now
    type_params = if T isa UnionAll
        [T.var.name]
    else
        T.parameters
    end
    
    # Construct the type expression with parameters
    param_expr = if !isempty(type_params)
        Expr(:curly, struct_name, type_params...)
    else
        struct_name
    end

    # Generate struct definition
    struct_expr = Expr(:struct, true,
        param_expr,
        Expr(:block, field_exprs...)
    )
    return struct_expr

end

struct Bus{T}
    vm::T
end

expr = generate_vector_type(Bus)
@show expr

eval(expr)

methods(BusVec)

Ouputs


expr = :(mutable struct BusVec{T}
      vm::Vector{T}
  end)
# 0 methods for type constructor

I then copied expr, renamed the struct, and run it in the same REPL, the default constructor is generated.

mutable struct BusVec2{T}
      vm::Vector{T}
  end

methods(BusVec2)

This gives

# 1 method for type constructor:
BusVec2(vm::Vector{T}) where T in Main at In[24]:2

I’m suspecting rules/scoping issues related to eval but couldn’t figure it out after a day. Any suggestions are appreciated!

3 Likes

dump(expr) exposes this discrepancy in vm::Vector{T} that doesn’t print differently:

        1: Expr
          head: Symbol ::
          args: Array{Any}((2,))
            1: Symbol vm
            2: Expr
              head: Symbol curly
              args: Array{Any}((2,))
                1: Symbol Vector
                2: TypeVar

That 2:TypeVar should be a 2: Symbol T. A quick fix to field_types[i].name makes the expected default method show up, but I wonder if there are nicer methods, even internal ones, for all this.

2 Likes

Thank you very much for your reply!

I never thought about that curly’s args should only be Symbols. TypeVar is a non-functional argument to {}.

What would be a permanet fix? Calling Symbol is not, because there are edge cases like imported types and Union types…

I wonder what gets created out of my wrong expression, and why it didn’t fail loudly…

This is what I’m currently doing:

    field_exprs = [
        begin
            field_type = field_types[i]
            type_expr = if field_type isa Union
                # Handle Union types using Base.uniontypes
                union_types = map(t -> Symbol(split(string(t), ".")[end]), Base.uniontypes(field_type))
                Expr(:curly, :Union, union_types...)
            else
                # Handle simple types, stripping module qualification
                Symbol(split(string(field_type), ".")[end])
            end
            :($(fields[i])::Vector{$type_expr})
        end for i in eachindex(fields)
    ]

This snippet reconstructs the symbols for the type of the fields. It’s handling qualified name by stripping the module name and handling Union types.

The root of the problem is that it’s a bit different from the normal metaprogramming of Expr to Expr. Instead, you’re processing to and taking apart an atypical DataType, and .types contains TypeVars, not Symbols, for type parameters.

julia> Base.unwrap_unionall(Bus).types
svec(T)

julia> x = Base.unwrap_unionall(Bus).types[1]
T

julia> x |> dump
TypeVar
  name: Symbol T
  lb: Union{}
  ub: Any

TypeVars have extra information in the type bounds. You normally never get access to TypeVars, but you have to be extra careful when you do.

The first reason why it doesn’t fail loudly is that Expr trees can contain any objects, not just the Symbols, Exprs, and a few literals parsed from source code. The correct type definition is obviously parseable from source code, so interpolating something else like TypeVar reasonably risks a difference.

The second reason it doesn’t fail loudly is because the bad expression evaluates to the same kind of DataType that unwrap_unionall makes:

julia> :(Vector{$x}) |> eval
Array{T, 1}

julia> :(Vector{$x}) |> eval |> typeof
DataType

Such a DataType is allowed to exist for type processing, and that’s what you annotated the field with.

The third and final reason it doesn’t fail loudly is that Julia doesn’t know to check for such atypical DataTypes. If you didn’t have a type parameter, it does complain for another reason:

julia> :(struct Y y::$x end) |> eval
ERROR: ArgumentError: method definition for Y at REPL[8]:1 has free type variables

but once you have enough type parameters, it just trusts that you didn’t do something unusual.

Although the type definition or a concrete type does not fail, instantiation does because the type’s parameter cannot be associated with the inserted TypeVar:

julia> BusVec{Int} |> dump
BusVec{Int64} <: Any
  vm::Array{T, 1}

julia> BusVec{Int}([1])
ERROR: UndefVarError: `T` not defined in static parameter matching
Suggestion: run Test.detect_unbound_args to detect method arguments that do not fully constrain a type parameter.

Since Base.unwrap_unionall is already an internal function, I think getting the symbol from the internal (assume if not documented or only documented in experimental or developer documentation) .name is just as okay for working with TypeVars. I should clarify my earlier comment that it’s preferable to work with a well-maintained library that exposes public API for similar purposes because someone handles the internals for you and everyone else, and if you must dig into internals it’s better to use ones that seem designed to change less.

1 Like

Thank you very much for your thoughtful response! I now understand the internals of the type system a little better.

I guess an alternative is to implement a macro which does Expr to Expr processing. It will look like

@make_vec struct Bus{T}
    vm::T
end

But in my actual code, the macro was getting to long. I’ll see if I can find an existing package or make one my own.