Alternative to OOP?

I’m curious about what the best practice in big packages are. My favorite part about python is OOP, which I don’t find in Julia. I’ve been working for a while on a project that’s gotten fairly big already, and it being physics oriented I need to keep track of many global parameters (mass, charge, …) and other things (functions such as custom ODE solvers, …). I’ve found a solution I like (though I’m not sure if it’s standard), which is using a dictionary that I pass all the time. I.e. at the beginning of the program I define the dictionary which will slowly change according to what the code should do.

What’s the best practice for this kind of thing? Specially since I like having the code modularized into many small files and dirs.

Do the changes include adding or deleting dictionary keys that methods rely on?

This is surprisingly hard to answer because OOP means different things to different people. While it’s true that Julia doesn’t have OOP by any reasonable definition, the aspects of OOP that proponents miss when coding in Julia differ.

One aspect of Python’s classes is encapsulating a bunch of state in a class, similar to how a dict does. This, you can do in Julia via a (mutable) struct.

Python classes has functions associated with them, as attributes (that is, methods). In Julia, functions are freestanding objects, and the functions have methods associated with them. This is a break from OOP in that a method frobulate operating on a Foo is stored in the global frobulate function object as opposed to inside the Foo type. They are functionally the same thing, unless you want to dynamically assign functions to types, which I would strongly advice against, even in Python.

Another distinction is the single- versus multiple dispatch. Precisely because methods in Python are stored in classes, the method resolution order (MRO) only depends on the first argument.

Another difference is autocompletion of methods, which only really works in Python since methods are associated with each type. In Julia, you can tab-complete all methods of a function, but there is no way to tab-complete “which methods does X have”.

At the end of the day, what Python’s “OOP” means to most people is just the very specific organization of classes and their data attributes and methods. Julia is simply a different language, which works in a different manner. I recommend not trying to emulate OOP in Julia, and instead lean into immutable structs, multiple dispatch and multi-method functions.

If you really want OOP in Julia, there is ObjectOriented.jl. It provides a fairly complete OOP-style layer, so if your goal is to write Julia in a Python-like class-based style, that package may be worth looking at.

However, I would not recommend making traditional OOP the default design style in Julia. The idiomatic Julia style is much closer to procedural-oriented programming (POP): define explicit data structures, then write functions that operate on them. With multiple dispatch, this becomes much more powerful than plain C-style procedural programming, because functions can dispatch on the types of multiple arguments, not just on one “owner object”.

This is also consistent with the broader trend in many modern or scientific programming languages. Fortran has historically been procedural. R is very often used as functions operating on data. Go, Rust, Zig, and many functional languages do not make class-based inheritance the central abstraction. Even when some OOP-like features exist, the mainstream community style is often procedural-first.

Using a dictionary is one option in standard Julia. You can combine this with function barriers to get a good balance between compile time and runtime performance. Functions are first class so you can store them in dictionaries just like variables. The best practice is going to depend on what your code is actually doing. Julia is very flexible in this respect, you can even emulate python style OOP if you really want, but usually that is not the best fit for many problems.

It might be helpful to give us a small example of how you might have used object oriented programming with Python. I find it somewhat surprising that doing object oriented programming in Python is your model here because Python’s concept of OOP is weak compared to some other languages more committed to the paradigm.

The distinct paradigm that Julia uses in place of object oriented programming is called multiple dispatch. In object oriented programming the first argument takes priority over all other arguments. In Python this is typically called “self”. The method being called is heavily influenced by the first argument and no other arguments. In multiple dispatch, the types of all the arguments are considered when selecting the method to call. This does result in a change of syntax because the first argument is no longer special. In OOP, you might write obj.method(arg_A, arg_B). In Julia with multiple dispatch, we would write method(obj, arg_A, arg_B) since there really is not a distinct difference between obj, arg_A, and arg_B.

Let’s say we created an object to contain parameters for a harmonic oscillator in Python:

class HarmonicOscillator:
    def __init__(self, mass: float, k: float, t_span: tuple, dt: float = 0.01):
        self.mass = mass
        self.k = k
        self.t_span = t_span
        self.dt = dt

    def omega(self) -> float:
        return (self.k / self.mass) ** 0.5

    def rhs(self, t: float, y: list) -> list:
        x, v = y
        return [v, -(self.k / self.mass) * x]

    def __repr__(self):
        return f"HarmonicOscillator(m={self.mass}, k={self.k}, ω={self.omega():.3f})"


sys = HarmonicOscillator(mass=1.0, k=4.0, t_span=(0.0, 10.0))
print(sys)                      # HarmonicOscillator(m=1.0, k=4.0, ω=2.000)
print(sys.rhs(0.0, [1.0, 0.0])) # [0.0, -4.0]

In the object oriented sense, the methods belong to the HarmonicOscillator class. They cannot be used outside of the class. This is bit jarring in Python because Python also has first class procedures, functions declared outside of classes.

In multiple dispatch, the methods do not belong to one struct type. They are first class citizens at the same level as the “struct type”, HarmonicOscillator. Declaring a method also creates a corrsponding “function type”.

struct HarmonicOscillator
    mass::Float64
    k::Float64
    t_span::Tuple{Float64,Float64}
    dt::Float64

    HarmonicOscillator(mass, k, t_span; dt=0.01) =
        new(mass, k, t_span, dt)
end

function omega(sys::HarmonicOscillator)
    sqrt(sys.k / sys.mass)
end

function rhs(sys::HarmonicOscillator, t::Float64, y::Vector{Float64})
    x, v = y
    return v, -(sys.k / sys.mass) * x
end

function Base.show(io::IO, sys::HarmonicOscillator)
    print(io, "HarmonicOscillator(m=$(sys.mass), k=$(sys.k), ω=$(round(omega(sys), digits=3)))")
end

sys = HarmonicOscillator(1.0, 4.0, (0.0, 10.0))
println(sys)                        # HarmonicOscillator(m=1.0, k=4.0, ω=2.0)
println(rhs(sys, 0.0, [1.0, 0.0])) # [0.0, -4.0]

Here the only method declared within the struct definition is the constructor. In this case, it does not need to and there some implications when the constructor is declared within the struct definition or outside it.

One distinguishing aspect here is the Base.show function. In the Julia example, it is qualified with the Base module name because we are extending the “function type” show originally declared in the module Base. Base.show does not belong to HarmonicOscillator, it is its own first class type. Base.show is subsequently invoked by println. Compare this to Python _repr_ which is a special thing, hence the underscores. In Julia, we don’t like special things that only core developers can do. In Julia, we give you, the user, the power to have common “verbs”, “function types”, that are shared conceptually between “objects”. In Python, this concept is at best informal in the form of “protocols”, or “interfaces” as they are known, in other languages. That said, these “interfaces” could be more formal in Julia as well, but that discussion is elsewhere.

Rather than using dictionaries, I would declare the parameters of your system in a struct where you might have used a class. If you want them to be mutable like in a dictionary, you can make the struct mutable.

mutable struct HarmonicOscillator
    mass::Float64
    k::Float64
    t_span::Tuple{Float64,Float64}
    dt::Float64

    HarmonicOscillator(mass, k, t_span; dt=0.01) =
        new(mass, k, t_span, dt)
end

# other method definitions as above

sys = HarmonicOscillator(1.0, 4.0, (0.0, 10.0))
println(sys) # HarmonicOscillator(m=1.0, k=4.0, ω=2.0)
sys.mass = 2.0
println(sys) # HarmonicOscillator(m=2.0, k=4.0, ω=1.414)

However, in Julia, we encourage immutability because this makes complex usage such as in concurrency simpler. In Python and other languages immutable data types have only come more recently. See “frozen dataclasses” in Python or “records” in Java. Rather than mutating the system, we encourage you to just create a new system:

sys = HarmonicOscillator(1.0, 4.0, (0.0, 10.0))
sys2 = HarmonicOscillator(2.0, sys.k, sys.t_span; sys.dt)

There are convenience packages that can make such operations easier.

I would not recommend a dictionary as a clear replacement. The problem with a dictionary is that it does have a clear “schema”. This means methods can add or delete keys at a whim without clearly having to coordinate this with other methods.

One thing I do not like about the above example is that the y argument of the rhs function, which stands for the right hand side of the equation.

\frac{d}{dt}\begin{bmatrix}x \\ v\end{bmatrix} = \underbrace{\begin{bmatrix}v \\ -\frac{k}{m}x\end{bmatrix}}_{\text{rhs}}

Let’s say I would like the position-velocity pair to be more clearly structured so the two parts of are clearly labeled.

@kwdef struct PositionVelocity
    x::Float64 # position
    v::Float64 # velocity
end

function Base.show(io::IO, pv::PositionVelocity)
    print(io, "PositionVelocity(position=$(pv.x), velocity=$(pv.v))")
end

The @kwdef macro allows me declare the constructor via explicit keywords.

julia> PositionVelocity(1.0, 5.0)
PositionVelocity(position=1.0, velocity=5.0)

julia> PositionVelocity(v=5.0, x=2.0)
PositionVelocity(position=2.0, velocity=5.0)

Now I can define a new method for rhs that uses my new type instead of Vector{Float64}:

function rhs(sys::HarmonicOscillator, t::Float64, pv::PositionVelocity)
    return pv.v, -(sys.k / sys.mass) * pv.x
end

In the REPL, we can see that the new method, rhs belongs as much to PositionVelocity as it does to HarmonicOscillator:

julia> sys = HarmonicOscillator(1.0, 4.0, (0.0, 10.0))
HarmonicOscillator(m=1.0, k=4.0, ω=2.0)

julia> pv = PositionVelocity(x=5.0, v=3.0)
PositionVelocity(position=5.0, velocity=3.0)

julia> rhs(sys, 1.0, pv)
(3.0, -20.0)

julia> methods(rhs)
# 2 methods for generic function "rhs" from Main:
 [1] rhs(sys::HarmonicOscillator, t::Float64, pv::PositionVelocity)
     @ REPL[26]:1
 [2] rhs(sys::HarmonicOscillator, t::Float64, y::Vector{Float64})
     @ REPL[5]:1

julia> methods(rhs)[1].sig
Tuple{typeof(rhs), HarmonicOscillator, Float64, PositionVelocity}

julia> methods(rhs)[2].sig
Tuple{typeof(rhs), HarmonicOscillator, Float64, Vector{Float64}}

In summary, for properties of a class in an “object oriented” language, I would add them as fields of a struct in Julia. The lack of OOP in Julia does not mean we do not have “objects”. Rather in multiple dispatch, we treat functions and objects as first class citizens. That is methods do not belong to a single class type. Rather methods are associated with the types of all of their arguments.

Apart from the more convenient auto-complete, my main OOP-issue in julia is the following:

It is very hard to take an existing type MyType, on which a lot of methods are defined, and saying “ok; now I want a AlmostMyType that has the same behavior, except for some few cases that I want to overwrite”.

The reason for this is that people dispatch on the concrete unextendable type MyType. Turning all these methods into ones that dispatch on AbstractMyType instead is a refactoring chore within a project, and a major API break if it crosses projects.

My thoughts how to deal with that are here. That linked thread describes a design-pattern that is not terribly unergonomic, but not super pretty either.

I think that does capture both the julia / multiple-dispatch spirit and the OOP-spirit (it does at least mostly reproduce the exact same data-layout as C++ single inheritance).

You apparently forgot to actually use @kwdef in the struct definition.

Thanks. Fixed.