Object System in Julia
JOS (Julia Object System)
This post describes how I have implemented with my group of 3 people an Object System in Julia. The source code of the project can be seen here.
Classes
Our Class implementation is dictionary-based and is built atop of the following Julia mutable struct: A class can be defined via the @defclass macro as follows:
1
2
3
4
5
6
mutable struct Struct
class::Any
fields::Dict{Symbol,Any}
end
@defclass(ComplexNumber, [], [real, imag])
This class is used throughout the project as the main metaclass.
Instances
1
2
3
4
5
new(class; initargs...) =
let instance = allocate_instance(class)
initialize(instance, initargs)
instance
end
The new function uses two generic functions allocate_instance and initialize that are can be specified using methods. In our case, we have 1 allocate_instance method and 4 initialize methods.
This function takes as an argument the class to instantiate and all the initargs that we may need to pass to it.
The allocate_instance and initialize functions are generic functions so we can create methods for specific types.
Instances (allocate_instance)
This function instantiates an object of the class specified in the arguments. All classes are composed by a Symbol which identifies the class by name and a Dict that will be used to save the fields of the class ( slots, cpl, getters, setters, etc. )
1
2
3
4
5
6
7
allocate_instance_object(class) =
let instance = Struct(class, Dict{Symbol, Any}())
for (name, value) in class.initforms
instance.fields[name] = value
end
instance
end
Since our classes inherit from Object, this method will be used for all allocations.
This function also ensures the initialization of all the class’s fields with initforms, which are the default values for each field.
Object
Instances (initialize)
After the classes initforms have been correctly initialized, we can now continue with initializing the initial list of arguments.
1
2
3
4
5
initialize_object(instance, initargs) =
for (name, value) in initargs
instance.fields[name] = value
end
end
This function initializes all the fields of the class and is used for any type that does not find a more specific method.
Instances (initialize) Class
In this function we initialize the class, of which is later going to be used to create Objects of itself.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
initialize_class(instance, initargs) = begin
direct_initforms = nothing
for (name, value) in initargs
if name == :direct_initforms
direct_initforms = value # only have the direct we have to compute all
else
instance.fields[name] = value
end
end
instance.fields[:cpl] = compute_cpl(instance)
instance.fields[:slots] = compute_slots(instance)
instance.fields[:initforms] = compute_initforms(instance, direct_initforms)
for slot in instance.fields[:slots]
(getter, setter) = compute_getter_and_setter(instance, slot, - 1 )
instance.fields[:getters][slot] = getter
instance.fields[:setters][slot] = setter
end
end
In this function we initialize the class, of which is later going to be used to create Objects of itself. This part of the function instantiates the cpl, slots, initforms, getters, and setters so that they can be accessed on object creation
We provide a method for the compute_slots function where it computes the class_direct_slots of the current class for all classes in the current class CPL.
1
(call_next_method, class) -> vcat(map(class_direct_slots, class_cpl(class))...)
We utilize the vcat function to concatenate the slot arrays that were mapped by class_direct_slots
We provide a method for the compute_initforms function where it computes the initforms of the current class based on all the classes in the its direct_superclasses
1
2
3
4
5
6
7
8
function compute_initforms(instance, initforms)
for class in instance.fields[:direct_superclasses]
for (name, value) in class.initforms
initforms[name] = value
end
end
initforms
end
Instances (initialize) Generic Functions
To initialize a generic function, we need to add to the initargs, the methods list where the defined methods are going to be stored.
1
2
3
4
5
6
initialize_generic(instance, initargs) = begin
for (name, value) in initargs
instance.fields[name] = value
end
instance.fields[:methods] = []
end
This function initializes the method and inserts it in the Generic Function method list.
Instances (initialize) Methods
1
2
3
4
5
6
7
8
9
10
initialize_method(instance, initargs) = begin
for (name, value) in initargs
instance.fields[name] = value
end
let generic_function = instance.fields[:generic_function]
filter!(m-> m.specializers != instance.specializers,
generic_function.methods)
push!(generic_function.methods, instance)
end
end
We ensure that the method isn’t repeated in the list so we don’t run into future problems with function composition (call_next_method)
Slot Access
To access the slots, we decide to extend the Julia Base libraries, getproperty and setproperty! Since they are built-in functions , by accessing its slots in the fields dictionary.
Whenever we access a slot with ‘foo.x’, for example, the program will automatically call getproperty for accessing it and setproperty! for mutations.
1
2
3
4
Base.getproperty(s::Struct, sym::Symbol) = hasproperty(s, sym)? getfield(s, sym) :
s.class.fields[:getters][sym](s)
Base.setproperty!(s::Struct, sym::Symbol, value) = hasproperty(s, sym)? setfield!(s, sym, value) :
s.class.fields[:setters][sym](s, value)
Generic Functions and Methods
Both generic functions and methods are considered as classes, and when we create an instance with @defgeneric or @defmethod, we are essentially creating an object of that class.
1
2
3
@defgeneric add(a, b)
@defmethod add(a::ComplexNumber, b::ComplexNumber) =
new(ComplexNumber, real=(a.real + b.real), imag=(a.imag + b.imag))
Generic functions are responsible for maintaining a list of methods, which they use to determine the appropriate method to be invoked based on the arguments passed to them.
Pre-defined Generic Functions and Methods
In addition to the print_object generic function specified in the project description, we have also added a couple of additional generic functions, namely:
- compute_slots
- allocate_instance
- initialize
- compute_getter_and_setter
- compute_cpl
As all these are generic functions, we have the ability to create methods for specific types besides the ones provided by the JOS.
We provide a method for the compute_slots function where it computes the class_direct_slots of all classes in the current class CPL.
compute_slots
1
(call_next_method, class) -> vcat(map(class_direct_slots, class_cpl(class))...)
We utilize the vcat function to concatenate the slot arrays that were mapped by class_direct_slots
Readers and Writers
Whenever we define a class, we also define generic functions and methods for the classes functions of which we call getters and setters. Since we have already defined a way to access the Object’s slots, we can do this with:
1
2
3
4
compute_getter(slot) =
(o) -> o.fields[slot]
compute_setter(slot) =
(o, v) -> o.fields[slot] = v
We utilize them inside the initialize_class function in which we go over each Class slot and create a getter and setter function for each: (compute getter and setter returns the functions above in a tuple)
1
2
3
4
5
for slot in instance.fields[:slots]
(getter, setter) = compute_getter_and_setter(instance, slot, - 1 )
instance.fields[:getters][slot] = getter
instance.fields[:setters][slot] = setter
end
Generic Functions Calls
To call a method, we first select the set of applicable methods using the get_applicable_methods function. We then sort these methods according to their specificity using the bubble sort algorithm implemented in the order_methods function. Finally, we apply the most specific method.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
i = 1
methods = get_applicable_methods(f, x...)
if length(methods) >= 1
methods = order_methods(methods, x...)
function call_next_method()
function get_applicable_methods(generic, x...)
methods = []
for m in generic.methods
is = true
for i in 1 :length(m.specializers)
if typeof(x[i]) == Struct
if !(m.specializers[i] in class_cpl(class_of(x[i])))
is = false
break
end
else
if m.specializers[i] != Top
if !(m.specializers[i] in class_cpl(class_of(x[i])))
is = false
break
end
end
end
end
if is
push!(methods, m)
end
end
return methods
end
call_next_method() #this call the most specific method
else
error("ERROR: No applicable method for function $(f.name) with arguments $(x)")
end
For each method of the generic function, we check if all specializers match all arguments. If so, we add it to the methods array, which will be returned with all applicable methods.
In case the argument is an object in our object system, we check if the specializer type is present in the CPL of the argument’s class. If the specializer is not found, we skip to the next one, as this is not applicable.
For arguments that are Julia types, if the specializer is Top, it is considered applicable since all types inherit from Top. Otherwise, we treat the specializer as described above, this is for the case of built-in types.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function order_methods(methods, x...)
for i in 1 :length(methods)- 1
swapped = false
for j in 1 :length(methods)-i
if is_less_specific(methods[j], methods[j+ 1 ], x...)
temp = methods[j]
methods[j] = methods[j+ 1 ]
methods[j+ 1 ] = temp
swapped = true
end
end
if !swapped
break
end
end
return methods
end
The previously mentioned order_methods function uses BubbleSort in order to sort the methods for a certain Class
Ordering methods
In BubbleSort we use the following is_less_specific non-generic function as comparison function, compares for each method call argument compares the indexes of 2 method specifiers in the class precedence list of the method call argument
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function is_less_specific(m1, m2, x...)
for i in eachindex(x)
index1 = index_of(m1.specializers[i], x[i])
index2 = index_of(m2.specializers[i], x[i])
if index1 < index2
return false
elseif index1 > index2
return true
end
end
error("error")
return true
end
function index_of(specializer, o)
cpl = class_cpl(class_of(o))
for i in eachindex(cpl)
if cpl[i] == specializer
return i
end
end
error("error")
return - 1
end
In order to enable the calling of the next most specific method, we pass function, named call_next_method. In this function, we call the next most specific method and invoking the function with the next index and the same arguments.
1
2
3
4
5
6
7
8
9
10
11
function call_next_method()
if length(methods) < i
error("ERROR: No more applicable method for function $(f.name) with arguments $(x)")
return
end
method = methods[i]
i += 1
res = method(call_next_method, x...)
i -= 1
return res
end
Multiple Inheritance
To implement the option of having multiple inheritance, classes have a direct_superclasses field that holds the classes that are directly superclasses.
To obtain all the superclasses and their order of precedence, we use the generic function compute_cpl , which is invoked at the time of class initialization and stored in a field called cpl.
Class Precedence List
In our approach to the CPL, we have stored it as a list of class metaobjects in order of decreasing specificity.
The algorithm used to compute the CPL involves a Breadth First Search through the graph of classes, inserting them in the list.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(call_next_method, class) -> begin
all_classes = [class]
stack = copy(class.direct_superclasses) # we copy to not
interfere with other computations
# Breadth First Search
while length(stack) != 0
c = popfirst!(stack)
push!(all_classes, c)
if c != Object
stack = [stack; c.direct_superclasses]
end
end
push!(all_classes, Top) # saving some iterations with this
since we all inherit from Top
# This removes all the repeated Classes in order
unique!(all_classes)
end
Built-In Classes
1
2
3
4
5
6
7
8
9
10
11
function primitive(value)
if typeof(value) <: Struct
return value
end
for c in allBuiltInClasses
if Symbol("_" * string(typeof(value))) == class_name(c)
return Struct(c, Dict(:value => value))
end
end
return nothing
end
In the primitive function, instead of iterating every primitive class, we could use the eval function with the computed symbol. However, we avoided that implementation due to the professor’s request since it is considered unsafe, and it could be harder to fix any posterior bugs later.
To permit the specialization of the primitive types on generic methods, we thought the idealist way was to take advantage of class_of, so after it, we store the builtIn Class as the most specific.