ObjectOriented.jl cheat sheet
ObjectOriented.jl has provided relatively complete object-oriented programming support for Julia. It supports multiple inheritances, dot-operator access to members, Python-style properties and interface programming.
1. Type definition
Define immutable OO structs:
@oodef struct ImmutableData
x :: Int
y :: Int
function new(x::Int, y::Int)
@mk begin
x = x
y = y
end
end
end
d = ImmutableData(1, 2)
x = d.x
new
is the constructor. Constructs and methods can be overloaded.
A @mk
block creates an instance for the current struct/class. Inside the block, an assignment statement a = b
initializes the field a
with the expression b
; a call statement like BaseType(arg1, arg2)
calls the constructor of the base class/struct BaseType
.
Defining OO classes (mutable structs):
@oodef mutable struct MutableData
x :: Int
y :: Int
function new(x::Int, y::Int)
@mk begin
x = x
y = y
end
end
end
mt = MutableData(1, 2)
mt.x += 1
Default field values
Using this feature, when defining a field for classes/structs, if a default value is provided, then the initialization for this field can be missing in the @mk
block.
function get_default_field2()
println("default field2!")
return 30
end
@oodef struct MyType
field1 :: DataType = MyType
field2 :: Int = get_default_field2()
function new()
return @mk
end
function new(field2::Integer)
return @mk field2 = field2
end
end
julia> MyType()
default field2!
MyType(MyType, 30)
julia> MyType(50)
MyType(MyType, 50)
Some points of the default field values:
- there is no performance overhead in using default field values.
- when a field has been explicitly initialized in the
@mk
block, the expression of the default field value won't be evaluated. - unlike
Base.@kwdef
, default field values cannot reference each other.
2. Inheritance
@oodef mutable struct Animal
name :: String
function new(theName::String)
@mk begin
name = theName
end
end
function move(self, distanceInMeters::Number = 0)
println("$(self.name) moved $(distanceInMeters)")
end
end
@oodef mutable struct Snake <: Animal
function new(theName::String)
@mk begin
Animal(theName) # 初始化基类
end
end
function snake_check(self)
println("Calling a snake specific method!")
end
end
sam = Snake("Sammy the Python")
sam.move()
# Sammy the Python moved 0
sam.snake_check()
# Calling a snake specific method!
CAUTION:
Snake <: Animal # false
Snake("xxx") isa Animal # false
Note that Julia's native type system does not understand the subtyping relationship between two oo classes! See Interface-based polymorphism for more details.
Use the following methods to test inheritance relationship:
issubclass(Snake, Animal) # true
isinstance(Snake("xxx"), Animal) # true
Snake("xxx") isa @like(Animal) # true
4. Python-style properties
@oodef mutable struct Square
side :: Float64
@property(area) do
get = self -> self.side ^ 2
set = (self, value::Number) -> self.side = convert(Float64, sqrt(value))
end
end
square = Square()
square.side = 10
# call getter
square.area # 100.0
# call setter
square.area = 25
square.side # 5.0
5. Interfaces
An interface in ObjectOriented.jl means an OO struct type which satisfies sizeof(interface) == 0
.
Interface constructors are auto-generated, but custom constructors are allowed.
The following HasLength
is an interface.
@oodef struct HasLength
@property(len) do
get # abstract getter property
end
end
@oodef struct Fillable
function fill! end # an empty function means abstract method
# define an abstract property to set all values
@property(allvalue) do
set
end
end
@oodef struct MyVector{T} <: {HasLength, Fillable} # multiple inheritance
xs :: Vector{T}
function new(xs::Vector{T})
@mk begin
xs = xs
end
end
end
check_abstract(MyVector)
# Dict{PropertyName, ObjectOriented.CompileTime.PropertyDefinition} with 3 entries:
# fill! (getter) => PropertyDefinition(:fill!, missing, :((Main).Fillable), MethodKind)
# len (getter) => PropertyDefinition(:len, missing, :((Main).HasLength), GetterPropertyKind)
# allvalue (setter) => PropertyDefinition(:allvalue, missing, :((Main).Fillable), SetterPropertyKind)
check_abstract(MyVector)
is not empty. This means MyVector
is abstract (more accurately, shall not be instantiated). Otherwise, implementing len
, fill!
和allvalue
is required.
@oodef struct MyVector{T} <: {HasLength, Fillable} # multiple inheritance
xs :: Vector{T}
function new(xs::Vector{T})
@mk begin
xs = xs
end
end
# add the following definitions to
# implement `HasLength` and `Fillable`
@property(len) do
get = self -> length(self.xs)
end
@property(allvalue) do
set = (self, value::T) -> fill!(self.xs, value)
end
function fill!(self, v::T)
self.allvalue = v
end
end
vec = MyVector([1, 2, 3])
vec.allvalue = 4
vec
# MyVector{Int64}([4, 4, 4], HasLength(), Fillable())
vec.len
# 3
vec.fill!(10)
vec
# MyVector{Int64}([10, 10, 10], HasLength(), Fillable())
In addition, the most important reason for interfaces is the interface-based polymorphism. See Interface-based polymorphism.
6. Multiple inheritance
MRO (Method resolution order) is using Python's C3 algorithm, so the behaviour is mostly identical to Python. The major difference is that the order of inheriting mixin classes is less strict.
@oodef struct A
function calla(self) "A" end
function call(self) "A" end
end
@oodef struct B <: A
function callb(self) "B" end
function call(self) "B" end
end
@oodef mutable struct C <: A
function callc(self) "C" end
function call(self) "C" end
end
@oodef struct D <: {A, C, B}
function new()
@mk begin
A() # can omit. A is interface.
B() # can omit. B is interface.
C() # cannot omit. C is class (mutable struct).
# you can also write them in one line:
# A(), B(), C()
end
end
end
d = D()
d.calla() # A
d.callb() # B
d.callc() # C
d.call() # C
[x[1] for x in ootype_mro(typeof(d))]
# 4-element Vector{DataType}:
# D
# C
# B
# A
7. Interface-based polymorphism
The following example shows an inproper use of the base class (A
):
@oodef struct A end
@oodef struct B <: A end
myapi(x :: A) = println("do something!")
myapi(A())
# do something!
myapi(B())
# ERROR: MethodError: no method matching myapi(::B)
Remember that Julia's type system does not understand the subtyping relationship between two OO classes!
If you expect myapi
to accept A
or A
's subtypes, you should do this:
myapi(x :: @like(A)) = println("do something!")
myapi(B())
# do something!
myapi([])
# ERROR: MethodError: no method matching myapi(::Vector{Any})
8. A machine learning example
In the following code, we implement a machine learning model trained using least squares and make it support the ScikitLearn interface (ScikitLearnBase.jl) in Julia. With the following code, users can call this model as if it were a normal ScikitLearn.jl model, and can use this model in the MLJ machine learning framework, regardless of whether the model is implemented by object-oriented features or multiple dispatch.
using ObjectOriented
@oodef struct AbstractMLModel{X, Y}
function fit! end
function predict end
end
using LsqFit
@oodef mutable struct LsqModel{M<:Function} <: AbstractMLModel{Vector{Float64},Vector{Float64}}
model :: M # a function to represent the model's formula
param :: Vector{Float64}
function new(m::M, init_param::Vector{Float64})
@mk begin
model = m
param = init_param
end
end
function fit!(self, X::Vector{Float64}, y::Vector{Float64})
fit = curve_fit(self.model, X, y, self.param)
self.param = fit.param
self
end
function predict(self, x::Float64)
self.predict([x])
end
function predict(self, X::Vector{Float64})
return self.model(X, self.param)
end
end
# the example comes from https://github.com/JuliaNLSolvers/LsqFit.jl
@. model(x, p) = p[1] * exp(-x * p[2])
clf = LsqModel(model, [0.5, 0.5])
ptrue = [1.0, 2.0]
xdata = collect(range(0, stop = 10, length = 20));
ydata = collect(model(xdata, ptrue) + 0.01 * randn(length(xdata)));
clf.fit!(xdata, ydata) # train
clf.predict(xdata) # predict
clf.param # inspect model parameters
# ScikitLearnBase provides us two interface functions 'fit!' and 'predict'.
# Now, we connect the ObjectOriented interface with Julia's idiomatic interface
# via '@like(...)'.
using ScikitLearnBase
ScikitLearnBase.is_classifier(::@like(AbstractMLModel)) = true
ScikitLearnBase.fit!(clf::@like(AbstractMLModel{X, Y}), x::X, y::Y) where {X, Y} = clf.fit!(x, y)
ScikitLearnBase.predict(clf::@like(AbstractMLModel{X}), x::X) where X = clf.predict(x)
ScikitLearnBase.fit!(clf, xdata, ydata)
ScikitLearnBase.predict(clf, xdata)
9. Performance issues
Code generated by ObjectOriented.jl does not introduce any overhead, but recursions of dot operations (Base.getproperty(...)
) do have some issues concerning type inference (e.g., this example). Although in most cases, the code produced by ObjectOriented.jl is very efficient, the return type might suddenly becomes Any
or some Union
type.
This might cause performance issues, but only in enumerable cases that have been well understood:
- Using Python-style properties
- Visiting another member in methods, the member will recursively perform dot operations (
Base.getproperty
).
The solution is easy: use @typed_access
to wrap a block of code which might suffer from above issues.
@typed_access my_instance.method()
@typed_access my_instance.property
CAUTION: please make sure that the type of the above my_instance
is inferred when using @typed_access
. Using @typed_acccess
in dynamic code will damage your performance.