Post

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.

This post is licensed under CC BY 4.0 by the author.