Automating a constructor for all subtypes

MWE below. I have an abstract type (AbstractComponent) and when a user creates a subtype of this abstract type I need to convert that to something else entirely (Python). This converted object is actually a Python object but for the purposes of this example I’ve made it a struct. There is a convenience function (makepython) that does this conversion. In other words they are really initializing a Python object, but I want to hide those details so for the external user it looks like normal Julia with minimal boilerplate.

The user defines their subtype (Comp). I can make this work manually by defining the constructor below, so every time Comp is created, it returns the “Python” object Python instead. My question is, is there a way I can automate/simplify that line Comp(x1::T1, ...) for all AbstractComponent types. It’s kind of a pain to remember, and it will always need to exist for every subtype of AbstractComponent. I’m not really familiar with macros in Julia yet, but perhaps it would fit here?

Side question: I left a commented line in there which I think follows what the documentation says an inner parametric constructor should look like. However, it doesn’t work for me and I’m not sure why.

# ---- defined elsewhere ---

abstract type AbstractComponent end

struct Python{T3}

makepython(comp) = Python(comp.x1*comp.x2)

# -----------------------------

struct Comp{T1, T2} <: AbstractComponent
    # Comp{T1, T2}(x1, x2) where {T1, T2} = makepython(new{T1, T2}(x1, x2))

Comp(x1::T1, x2::T2) where {T1, T2} = makepython(Comp{T1, T2}(x1, x2))

s = Comp(2.0, 3.0)

This feels like a bad idea. Specifically, having the constructor of a type T return something which is not of type T, while legal, is pretty unusual and likely to be confusing to users of your code.

My first instinct is that maybe having your users do:

comp = makepython(Comp(2.0, 3.0))

is really not so bad. Sure, it requires typing one extra function, but it’s also completely unsurprising and easy to see what’s going on.

Another option would be to define a function like:

function component(::Type{C}, args..; kw...) where {C <: AbstractComponent}
  makepython(C(args...; kw...))

which your users would call via:

c1 = component(Comp, 2.0, 3.0)
c2 = component(OtherComponentType, "hello", "world")
1 Like

I agree it’s unusual and not a good idea generally. Your first suggestion is what I currently use. The reason I was exploring a constructor alternative it that the Python code is already an existing established code and so all the workflow looks exactly like the Python examples with the exception of adding an additional wrapper function makepython(). The struct part is already different anyway so I thought perhaps I could contain all deviations from the Python to one place where they are already thinking about it. But perhaps it’s not worth it. The second option I hadn’t considered, that might be worth trying.

If you want to get really fancy, you could even do something like:

# If C is an `AbstractComponent`, then wrap it with `makepython`
function maybe_component(::Type{C}, args...; kw...) where {C <: AbstractComponent}
  makepython(C(args...; kw...))

# otherwise don't
function maybe_component(f, args..; kw...) 
  f(args...; kw...)

And then you could write a macro (check out prewalk() or postwalk() from MacroTools.jl) to transform every function call within a block into a call to maybe_component(f, ...). Then you could write:

@components begin
  # this becomes maybe_component(Comp, 1, 2, 3), which in turn 
  # gives makepython(Comp(1, 2, 3))
  x = Comp(1, 2, 3) 

  # This becomes maybe_component(f, x), which in turn becomes
  # f(x), i.e. it doesn't actually change behavior
  y = f(x)

But I’m not sure it’s worth the added complexity and overhead of understanding your code.

Thanks. Yeah, I don’t think it’s worth the complexity either. Mostly I was curious about what’s possible with macros and how complex would it look. This helps.