I recently found myself writing julia code while somewhat OOP-brained. Surprisingly, the following design pattern works pretty well, so I want to share it for comments:
# or mutable struct
struct Base{Ext}
baseField::Int
ext::Ext
end
function do_something(base::Base)
base.baseField
end
I can get unextended “Base and nothing else” by e.g. Base(baseField, nothing).
Now suppose I want to subclass my Base, adding new fields, overriding behaviors and using superclass calls. Sure, can do:
const AbstractDerived = Base{<:Extension}
struct Extension{Ext}
newField::Int
ext::Ext
end
function do_something(base::AbstractDerived)
base.ext.newField + @invoke do_something (base::Base)
end
Ah, but now I later want to add a new interface that has a fallback implementation in terms of Base, and a faster specialization for AbstractDerived. Sure, can do:
function somethingExtra(base::Base)
#fall-back code
end
function somethingExtra(base::AbstractDerived)
#...
end
Note that the Extension itself has an ext field. Of course it has: this is needed if I want to subclass the subclass.
This naively looks convoluted / type-unstable, but it is not: The concrete types are all statically known to the compiler.
The memory layout naively looks like a horror show, but it is not: Just make all extensions immutable structs, then they will be inlined into base. This should give almost the exact same memory layout as the same thing in C++. I am saying “almost” because of internal structure padding.
This is not a surprise: This is exactly how you do OOP of data in C. And as we all know, C++ is just overengineered syntactic sugar over good old C.
Making the extensions immutable is no real price to pay: They always live inside some mutable base.
Next, suppose that you want to mix-and-match behaviors, a la java interfaces. For that, we can use contravariant / upper type bounds and unions:
struct MixinCarrier{Mixins} end;
struct Mixin1 end;
struct Mixin2 end;
mutable struct Base{Ext, Mix}
basefield::Int,
ext::Ext
mix::Mix
end
function foo(base::Base{<:Any, MixinCarrier{>:Mixin1}})
#...
end
foo(Base(1, nothing, MixinCarrier{Union{Mixin1}}()))
Note that the mixed in things cannot carry data, just like java interfaces.
I found the direct type constructors in this thing very cumbersome. But if you have a factory function that is supposed to create and do something with one of our objects (which is almost always the case), you can just use keyword arguments
function makeAndUseBase(args; ext=nothing, mixins=MixinCarrier{Union{}}())
Base(arg, ext, mixins)
end
Initialization order needs some time to get used to. It’s less complex than the insane constructor rules for e.g. java; here, you must construct Ext before constructing Base.