OOP-brained design patterns

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.

2 Likes

Not a fan of how the types look but there’s not much wiggle-room in a language where concrete types can’t subtype each other. Abstract types are still doing the subtyping of course, and taking away the shortcuts and aliases, we have

  • ::Base{<:Any} methods working on the concrete “parent” Base{Nothing} as well as concrete “child” types like Base{Extension{Nothing}}.
  • ::Base{<:Extension{<:Any}} methods working on the concrete “parent” Base{Extension{Nothing}} as well as concrete “child” types like Base{Extension{Further{Nothing}}}

So the couple issues I see here is that

  1. With a new component struct for each set of additional fields, child types get longer unless we make aliases that look kind of like a shuffled subtyping statement, and those don’t always get printed. I don’t know if this recovers all the features, but abstract type statements with *bona fide` subtyping and nesting parent fields instead would be cleaner and more familiar.
  2. It’s possible to nested repeated fields, causing strange field shadowing (which isn’t good practice anyway). In a dynamically typed language, we’d expect the exact runtime type to pick the field, in this case the most nested one. In a statically typed language, the static type might pick the field, but there might also be a dynamic option. Julia is dynamically typed, but a fallback method would only match its annotation, not the exact runtime type:
julia> const ShadowedBase = Base{<:Base} # simplest subtype with repeated field
ShadowedBase (alias for Base{<:Base})

julia> function do_something(base::ShadowedBase) # overriding method
         base.ext.baseField
       end
do_something (generic function with 2 methods)

julia> do_something(Base(1, Nothing))
1

julia> do_something(Base(1, Base(2, Nothing)))
2

julia> function do_another(base::Base) # only fallback
         base.baseField
       end
do_another (generic function with 1 method)

julia> do_another(Base(1, Nothing))
1

julia> do_another(Base(1, Base(2, Nothing)))
1

To get the expected dynamic behavior, I don’t see any way around getters that effectively flatten field access, and that’s probably better enforced with some sort of macro.

With a new component struct for each set of additional fields, child types get longer unless we make aliases that look kind of like a shuffled subtyping statement…

True! This is verbose, and that is somewhat annoying.

It doesn’t really allow to re-use extensions for multiple Base classes, a la scala mixin trait. But then, the data layout for scala trait data fields is a horror show.

My main use was for very old-style OOP: You traverse a graph, e.g. for parsing a document, and then you call a variety of onFooBarEnter / onFooBarExit functions, SAX style / “event-driven parsing”. So you have

struct Visitor{Ext}
ext::Ext
end

struct Traversal{Ext}
ext::Ext
end

handleFooEvent(::Visitor, arg) = nothing
handleBarEvent(::Visitor, arg) = nothing


function traverse(traversal, visitor, data)
   ...
   handleFooEvent(visitor, something)
   handleBarEvent(visitor, something)
end

In this type of thing, it is very convenient to be able to “subclass” a visitor with all its existing functionality (data, all the event handlers) and just modify some of them, maybe adding more internal data.

Likewise, it is very convenient to be able to add new events without having to touch code for other visitors – just need a fallback implementation.

Especially for testing, it is quite valuable to be able to mix and match behaviors by constructing bespoke objects that combine various behaviors, with a minimum of boilerplate. Also useful for println debugging: Subtype your nice solver with one that adds strategic println and then @invoke the super.