Julia is a nice and promising scientific language for high performance computing, with the central paradigm of multiple dispatch. However, Julia does only partially support object oriented programming (OOP) with dot notation. For example, "objects" can not have their own methods. Unfortunately, this happens to be the style of programming that I prefer.
This StackOverflow question
discusses the matter, however, the fields (if exposed) in the solution get wrapped in
Core.Box, which kind of defeats the purpose of fields, as they can no longer be accessed in the manner that one would expect.
I figured out an undocumented way in which object oriented programming with dot notation, including methods and without boxing, can be implemented and decided to write this post so that the knowledge can be passed on and the relative common question of dot notational OOP in Julia could be answered with more than "No, it does not work". Additionally, I hope that this way of programming would become more widely supported in Julia.
Consider the following class in Python and how it's methods are called:
# Python class ExampleClass: def __init__(self, field_0, field_1): self.field_0 = field_0 self.field_1 = field_1 def method_0(self): return self.field_0 * self.field_1 def method_1(self, n): return (self.field_0 + self.field_1) * n def method_2(self, val_0, val_1): self.field_0 = val_0 self.field_1 = val_1 ex = ExampleClass(10, 11) ex.method_0() ex.method_1(1) ex.method_2(20, 22)
The common way to implemented this in Julia would look like this:
# Julia mutable struct ExampleClass field_0 field_1 end function method_0(example_class) return example_class.field_0 * example_class.field_1 end function method_1(example_class, n) return (example_class.field_0 + example_class.field_1) * n end function method_2(example_class, val_0, val_1) example_class.field_0 = val_0 example_class.field_1 = val_1 end ex = ExampleClass(10, 11) method_0(ex) method_1(ex, 1) method_2(ex, 20, 22)
The key difference between these two examples is that the methods in Python belong to the object and the functions in Julia belong to the global scope. I do not like the concept of global scope and would like to avoid it as much as possible.
By implementing the class in a different way we can replicate the Pythonic dot notation. Consider the following:
# Julia mutable struct ExampleClass field_0 field_1 method_0 method_1 method_2 function ExampleClass(field_0, field_1) this = new() this.field_0 = field_0 this.field_1 = field_1 this.method_0 = function() return this.field_0 * this.field_1 end this.method_1 = function(n) return (this.field_0 + this.field_1) * n end this.method_2 = function(val_0, val_1) this.field_0 = val_0 this.field_1 = val_1 end return this end end ex = ExampleClass(10, 11) ex.method_0() ex.method_1(1) ex.method_2(20, 22)
Now it works like in Python, just as I like it!
What about private variables? I see that there are two ways in which private variables can be implemented. One is to add new fields to the
struct, but prefix them with an underscore like this:
# Julia mutable struct ExampleClass field_0 field_1 method_0 method_1 method_2 _private_var function ExampleClass(field_0, field_1) this = new() this._private_var = 0 ... end end
Unfortunately, the variables are not really private and can still be accessed from the outside. Another way, with truly private variables
is to include them in the constructor like this:
# Julia mutable struct ExampleClass field_0 field_1 method_0 method_1 method_2 function ExampleClass(field_0, field_1) this = new() private_var = 0 ... end end
Now the private variables are really private, however, they can not be accessed with dot notation from
this, which is unfortunate.
I prefer the underscore method.
The revised Julia code relies primarily on three things: closures, variable capturing and anonymous functions.
Unfortunately this way of programming has some caveats, at least when compared to the "idiomatic" Julia way.
- The size of the classes grow, with each method increasing the size by 8 bytes (at least on 64 bit systems) and thus allocation becomes slightly slower.
- Calling the anonymous functions is slightly slower than calling the global functions.
- Capturing variables in Julia has some performance implications. See the Julia docs.
let this = thismay have some performance benefit when applied to the anonymous functions, but I am uncertain of its effects in this case.
- The classes (or structs) must be mutable, since they are modified in the constructor. This can perhaps be circumvented in some fashion.
This section provides a quick performance comparison between the both styles in Julia. Note that the results are highly dependent on the fields and
methods of the classes and as such this test should not be considered to be representative of every situation. This test focuses on allocation
and method call performance. You should benchmark your own code for your self, which probably has other underlying assumptions. The benchmarks are executed
with these two types:
# Julia mutable struct DotNotationType field_0::Int field_1::Int field_2::Int field_3::Int do_work::Function function DotNotationType(field_0::Int, field_1::Int, field_2::Int, field_3::Int) this = new() this.field_0 = field_0 this.field_1 = field_1 this.field_2 = field_3 this.field_2 = field_3 this.do_work = function(n::Int) sum = 0 for i in 1:n sum += this.field_0 * this.field_1 + this.field_2 * this.field_3 ^ n end return sum end return this end end mutable struct GlobalScopeType field_0::Int field_1::Int field_2::Int field_3::Int end function do_work(gst::GlobalScopeType, n::Int) sum = 0 for i in 1:n sum += gst.field_0 * gst.field_1 + gst.field_2 * gst.field_3 ^ n end return sum end
println(DotNotationType(1, 2, 3, 4).field_0) println(DotNotationType(1, 2, 3, 4).method_0) println(sizeof(DotNotationType)) println(sizeof(GlobalScopeType))
1 #61 40 32
The fields of
DotNotationType are not boxed within
Core.Box, as in this question on StackOverflow.
Additionally, the size of
DotNotationType is 8 bytes, or 1.25x, larger than the size of
GlobalScopeType. Note that in "real" OOP languages, methods on a class do not generally make its memory imprint larger. This is due to the fact that the methods are conceptually placed in the global scope at compile time (not accounting for anonymous functions in other languages).
using Pkg Pkg.add("BenchmarkTools") using BenchmarkTools function allocate_DotNotationType() types = DotNotationType for i in 1:100000 push!(types, DotNotationType(i, i * i - i)) end return types end function allocate_GlobalScopeType() types = GlobalScopeType for i in 1:100000 push!(types, GlobalScopeType(i, i * i - i)) end return types end # Assuming execution from a Jupyter Notebook display(@benchmark allocate_DotNotationType()) display(@benchmark allocate_GlobalScopeType())
BenchmarkTools.Trial: memory estimate: 78.89 KiB allocs estimate: 2010 -------------- minimum time: 23.629 μs (0.00% GC) median time: 26.958 μs (0.00% GC) mean time: 34.836 μs (17.34% GC) maximum time: 3.753 ms (96.40% GC) -------------- samples: 10000 evals/sample: 1 BenchmarkTools.Trial: memory estimate: 63.27 KiB allocs estimate: 1010 -------------- minimum time: 17.818 μs (0.00% GC) median time: 20.827 μs (0.00% GC) mean time: 26.720 μs (15.82% GC) maximum time: 3.183 ms (95.79% GC) -------------- samples: 10000 evals/sample: 1
DotNotationType is about 1.29x (comparing medians) slower than allocating
GlobalScopeType. This seems to be directly dependent on the
fact that the size of
DotNotationType is 1.25x larger than the size of
using Pkg Pkg.add("BenchmarkTools") using BenchmarkTools dnts = allocate_DotNotationType() gsts = allocate_GlobalScopeType() function call_DotNotationType(dnts) sum = 0.0 for d in dnts sum += d.do_work(4) end return sum end function call_GlobalScopeType(gsts) sum = 0.0 for g in gsts sum += do_work(g, 4) end return sum end # Assuming execution from a Jupyter Notebook display(call_DotNotationType(dnts)) display(call_GlobalScopeType(gsts)) display(@benchmark call_DotNotationType(dnts)) display(@benchmark call_GlobalScopeType(gsts))
-6.042282548123895e20 -6.042282548123895e20 BenchmarkTools.Trial: memory estimate: 31.25 KiB allocs estimate: 2000 -------------- minimum time: 72.623 μs (0.00% GC) median time: 74.246 μs (0.00% GC) mean time: 79.119 μs (1.95% GC) maximum time: 2.326 ms (96.27% GC) -------------- samples: 10000 evals/sample: 1 BenchmarkTools.Trial: memory estimate: 16 bytes allocs estimate: 1 -------------- minimum time: 24.066 μs (0.00% GC) median time: 31.175 μs (0.00% GC) mean time: 30.374 μs (0.00% GC) maximum time: 377.014 μs (0.00% GC) -------------- samples: 10000 evals/sample: 1
Both methods get the same result, which implies that both methods do the same work. However, calling the method of
DotNotationType is about 2.38x (comparing medians) slower than calling the function of
GlobalScopeType. I am not entirely sure what is causing the slow down, but it may have something to do with the issues mentioned in the Julia docs about the performance of capturing variables or it may have something to do with the optimization of anonymous functions.
- OOP is possible in Julia.
- OOP with dot notation methods is possible in Julia.
- OOP without boxing of fields is possible.
- OOP with dot notation does have caveats and generally worse performance.
- OOP dot notation methods in Julia are implemented in a generally inferior approach than that of most mainstream programming languages. Note that this is due to the fact that the way of programming presented in this post declares the functions as anonymous functions. This is not how the most common "real" OOP languages implement class methods.
Great, we got a working solution for object oriented programming with dot notational methods! The issues with the dot notational way of programming may or may not affect the general performance of your application, but I expect that this is highly domain specific. For example, in the "Method Performance" section I specifically focused on measuring the performance of method calls. Measuring the performance of actual work within a method may behave drastically different. Regardless, this may at least be a good way of porting dot notational code from, for example, Python to Julia and hopefully this will encourage Julia to implement true classes with true dot notation!