Method inheritance the Julian way

Suppose I am trying to describe the behavior of a Pet. I implemented several methods common to all instances of Pet e.g. eat(pet::Pet) and walk(pet::Pet, towards). I also want to define

function greet(pet::Pet, person::Person)
walk(pet,person)
say_hello(pet)
# do some other stuff
end

where say_hello() depends on whether Pet is a Cat or a Dog e.g.

say_hello(::Dog) = bark()
say_hello(::Cat) = meow()

The key idea is that greet performs some action common to all Pet instances but also some action that depends on which subtype of Pet it is. In the MWE above, greet is pretty simple and sequential but in principle it could be more complicated, and say_hello could be called in some loop, or be called multiple times, etc. Moreover, I’d like other people to be able to define other kinds of Pet’s who say_hello() in other ways without my code for greet() needing to be aware of that.

In an OOP language, I would implement this as something like this (pseudocode):

class Pet {
abstract say_hello();
eat() { 
internal_eat(); 
}
walk(towards) { 
internal_walk(); 
}
function greet(towards) {
walk(person)
say_hello(pet)
# do some other stuff
}
}

class Dog extends Pet {
say_hello() {
bark()
}
}

class Cat extends Pet {
say_hello() {
meow()
}
}

where internal_eat(), internal_walk(), bark(), and meow() are “private” methods of their respectiveclasses.

Now, I can’t do this in Julia, so the best alternative I can think of is:

struct Pet{F<:Function}
say_hello::F 
end

eat(pet::Pet) = internal_eat(pet)
walk(pet::Pet, person::Person) = internal_walk(pet, person)
function greet(pet::Pet, person::Person)
walk(pet, person)
pet.say_hello()
end

dog = Pet(bark())
cat = Pet(meow())

But this looks kind of ugly, is hard to read, doesn’t feel very Julian, and just seems like I’m trying to recreate OOP instead of buying into a different/better paradigm.

How do I think about this problem in a more Julian way?

BTW, if the MWE is too contrived, the problem that inspired it is a class of models that are all solved in the same way, but where the specifics of one step of the solution depend on the specifics of the model – not just parameters but equations, too.

julia> abstract type Pet end

julia> function greet(pet::Pet, name)
           print("$(pet.name) $(say_hello(pet))ed $name.")
       end

julia> struct Dog <: Pet
           name::String
       end

julia> say_hello(d::Dog) = "Bark"

julia> greet(Dog("jerry"), "tom")
jerry Barked tom.

julia> struct Cat <: Pet
           name::String
       end

julia> say_hello(d::Cat) = "Meow"
say_hello (generic function with 2 methods)

julia> greet(Cat("mary"), "tom")
mary Meowed tom.

something like this? The big picture is that, if Pet is not enough to determine what happens (in terms of your abstraction model, say_hello), then you shouldn’t be able to have a concrete Pet object, thus it’s an abstract type. Users then just go about implement their own Pet concrete sub types.

9 Likes

Your first code snippet doesn’t do already what you want? (As shown above, there was only a subtyping missing).

You can’t inherit from concrete types. My MWE was too M, I guess, but there is data that I need to store in Pet b/c both, say, eat and greet rely on that data. So I need Pet to be concrete. I guess I can just expect Dog and Cat to have the appropriately named fields containing the needed data, but there’s no good way to document that or enforce it. So that doesn’t sound great.

You may be interested in traits: Holy Traits Pattern (book excerpt)

1 Like

I suppose I can use functions to obtain the needed data in a subtype-agnostic way.

E.g. instead of

struct Pet
name::String
end

struct Dog<:Pet
end

which is illegal in Julia, the interface for Pet can just require that get_name() be implemented for all subtypes, so that any functions with Pet-type argument that need to know the pet’s name just call get_name(pet) and don’t need to know how that name is stored. But if there are a lot of fields like that, I’m asking the developers of Dog and Cat to write a lot of boilerplate code…

What about:

struct Common
   name
end

struct Pet
   common::Common
   sayhello
end

Pet(name::String, sayhello:: String) = Pet(Common(name),sayhello)

name(p::Pet) = p.common.name
sayhello(p::Pet) = p.sayhello
3 Likes

duck typing is common in Class-Based OOP too. In the end of the day, you just can’t enforce much and patterns like setter getter just gets in your way. Inherited thing can have altered behavior (even harder to catch when debug), as long as it’s not “final”.

btw sounds like you could use https://github.com/rafaqz/Mixers.jl

2 Likes

This is the standard way to do it.

1 Like

This sounds like a toy problem (a classic one at that, looking at Pet. Almost sounds like the classic “suppose I have a type Car which has subtypes Tesla, Audi…”), do you have a more concrete example instead of only hypotheticals? Trying to model real life structure with subtyping relations is almost always a bad idea. There have been a lot of threads with a similar theme though.

In any case, julia favors composition over inheritance, combined with a shallow type hierarchy. Defining an interface for Pet that all subtypes would have to adhere to is the way to go - if you find that you need a lot of methods, it’s usually a good idea to think about a refactor away from one big “god” object and towards composition of smaller, more self contained objects.

7 Likes

@Sukera , you’re right. I tried to simplify things but probably simplified it too much.

Here’s the overall challenge. I have a method for solving a class of models. The specific models vary not just in terms of their parameter values but in terms of the list of parameters itself as well as in the equations, dimensions, etc. The method is general enough that it can handle all of these types.

To keep things simple, let’s suppose that the solution to a model is a vector x. Given a vector of parameters p, each model is a system of equations f(g(x,a),h(p,b)) = 0, where the system f, solution x, and parameters p are specific to each model, but where functions g and h and additional parameters a and b are common to all models of this class.

I wrote something like this:

struct Params
p1::Float64
end

struct GParams
a1::Float64
end

struct HParams
b1::Float64
end

struct Model
p::Params
a::GParams
b::HParams
end

# Abstracting away from how these functions are actually implemented
f(y,z)
g(x,a::GParams)
h(p,b::HParams)
initial_guess(p::Params)
update(x,err,p::Params)

function solve_model(mdl::Model, tol)
z = h(mdl.p, mdl.b)
x = initial_guess(p)
while true
  err = f(g(x,mdl.a), z)
  if maximum(abs.(err))<tol
    break
  else
    x = update(x,err,p)
  end
end
end

(Yes, I realize this looks like a generic nonlinear solver, and yes, I’m aware of NLsolve. The actual method is more complicated.)

What I would like to do is to break out Gparams, HParams, g, h, update, solve_model into their own module with an additional type e.g. ModelClass, on which solve_model will dispatch. Then the user can define their own SpecificModel{P<:AbstractParams} with its own SpecificParams<:AbstractParams and their own f and initial_guess.

But solve_model then needs access to the info in a and b and it needs to be able to call initial_guess and f that the user defined.

Here is an example of a package that does something similar. But there the specific model (there are a few in examples) is entirely contained in one function (analogous to my f), so you can just pass the function to its equivalent of solve_model. In my example, there is one more (initial_guess) and in my actual application several more, so I can’t quite figure out how to adapt that design pattern to my case.

BTW, that package uses some strange syntax that I couldn’t find documented anywhere:

function (mdl::Model)(x)
return "$(mdl.onlyfield) $x"
end

which I think can be called by a = Model(1); a(3) which returns 1 3. Can I read more about this somewhere?

this is simply saying mdl::Model is callable, and you can call it.

julia> struct A
           x
       end

julia> a = A(2)
A(2)

julia> a(3)
ERROR: MethodError: objects of type A are not callable
Stacktrace:
 [1] top-level scope
   @ REPL[3]:1

julia> (obj::A)(y) = obj.x + y

julia> a(3)
5

This pattern is used a lot in Flux.jl, where you can construct layers by passing shapes and activation function and the calling the layer will evaluate it.

2 Likes

The syntax you’re referring to is simply described in the Julia documentation. See: Methods · The Julia Language

3 Likes

Mixers.jl was my first julia package, feeling similar frustration to @elenev. But I literally never use it any more, it leads to bad designs. Use composition as @lmiq suggested in his example.

4 Likes

Thanks, all. BTW, I realize the topic has been discussed often but I was missing the right keywords to search for. Now that I know what to look for, these old threads were very relevant and interesting.

Workaround for traditional inheritance features in object-oriented languages - General Usage - JuliaLang

The solutions are the same – either stick to OOP way of thinking about inheritance and use macros to work around Julia’s “limitations”, or adopt the composition approach suggested by @lmiq and @Sukera with the Common struct (perhaps renamed PetState or PetData) containing all the info that functions dispatching on abstract Pet need to be aware of.

There is still the slightly annoying book-keeping issue of accessing fields through nested getproperty() calls e.g.

struct Dog <: Pet
data::PetData
end

d = Dog(PetData("Fido"))
d.name # Error
d.data.name # returns "Fido"

But looks like Lazy.@forward can help save time implementing a bunch of pass-through methods like get_name(pet::Pet) = pet.data.name. Can probably even overload getproperty() but that could come with some performance issues.

3 Likes

You don’t need that forward macro though, if your API for PetData is fixed. Since you presumably want to define and document the API in code anyway, why not define methods like these:

"""
    name(p::Pet)

Returns the name of the pet. The default implementation expects `p` to have a field `data`. 
"""
function name(p::Pet) 
   name(p.data)
end

where Pet is an abstract type.
By virtue of dispatch and method specialization, if there’s no more specific method for a p of e.g. type Dog <: Pet, this will justWork™️ for subtypes of Pet. If people want to implement a more specific name method for their subtype, they’re free to do so and dispatch will select the correct one based on the type of p.

The forward macro is really only useful for wrapper types, where you want to encapsulate some other concrete type and you can’t change the fact that you’re wrapping a concrete type, where you also want to behave like that wrapped type. As I understand it, your usecase here doesn’t necessarily need a wrapped concrete type that you want to behave as, because you can seperate the data hierarchy (which is no hierarchy at all) from the type hierarchy by using an abstract super type.

4 Likes

Can you recall a 'pattern" of those bad designs? (e.g. is it related to the use of helper macros in general, or comes from mixing in multiple fields for a single supertype instead of the “Common” stuct)

Mostly that you dont have the shared fields as an object you can dispatch on or use on its own, or swap out for a variant. That nearly always turns out to be useful.

Macros are also confusing and opaque to newcomers.

2 Likes

we’re still trying to solve https://github.com/tamasgal/UnROOT.jl/blob/master/src/bootstrap.jl with Mixers.jl haha, maybe oneday