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.