Classes

A simple, Julian approach to inheritance of structure and methods


License
MIT

Documentation

Build Status Build status codecov Coverage Status

Classes.jl

A simple, Julian approach to inheritance of structure and methods.

Motivation

Julia is not an object-oriented language in the traditional sense in that there is no inheritance of structure. If multiple types need to share structure, you have several options:

  1. Write out the common fields manually.
  2. Write a macro that emits the common fields. This is better than the manual approach since it creates a single point of modification.
  3. Use composition instead of inheritance: create a new type that holds the common fields and include an instance of this in each of the structs that needs the common fields.
  4. Use an existing package that provides the required features.

All of these have downsides:

  • As suggested above, writing out the duplicate fields manually creates maintenance challenges since you no longer have a single point of modification.
  • Using a macro to emit the common fields solves this problem, but there's still no convient way to identify the relatedness of the structs that contain these common fields.
  • Composition -- the typically recommended julian approach -- generally involves creating functions to delegate from the outer type to the inner type. This can become tedious if you have multiple levels of nesting. Of course you can write forwarding macros to handle this, but this also becomes repetitive.
  • Neither of the packages I reviewed -- OOPMacro.jl and ConcreteAbstractions.jl -- combine the power and simplicity I was after, and neither has been updated in years.

Classes.jl provides two macros, @class and @method that are simple wrappers around existing Julia syntax. Classes.jl exploits the type Julia system to provide inheritance of methods while enabling shared structure without duplicative code.

The @class macro

A "class" is a concrete type with a defined relationship to a hierarchy of automatically generated abstract types. The @class macro saves the field definitions for each class so that subclasses receive all their parent's fields in addition to those defined locally. Inner constructors are passed through unchanged.

Classes.jl constructs a "shadow" abstract type hierarchy to represent the relationships among the defined classes. For each class Foo, the abstract type AbstractFoo is defined, where AbstractFoo is a subtype of the abstract type associated with the superclass of Foo.

Given these two class definitions (note that Class is defined in Classes.jl):

using Classes

@class Foo <: Class begin       # or, equivalently, @class Foo begin ... end
   foo::Int
end

@class mutable Bar <: Foo begin
    bar::Int
end

The following julia code is emitted:

abstract type AbstractFoo <: AbstractClass end

struct Foo{} <: AbstractFoo
    x::Int

    function Foo(x::Int)
        new(x)
    end

    function Foo(self::T, x::Int) where T <: AbstractFoo
        self.x = x
        self
    end
end

abstract type AbstractBar <: AbstractFoo end

mutable struct Bar{} <: AbstractBar
    x::Int
    bar::Int

    function Bar(x::Int, bar::Int)
        new(x, bar)
    end

    function Bar(self::T, x::Int, bar::Int) where T <: AbstractBar
        self.x = x
        self.bar = bar
        self
    end
end

Note that the second emitted constructor is parameterized such that it can be called on the class's subclasses to set fields defined by the class. Of course, this is callable only on a mutable struct.

In addition, introspection functions are emitted that relate these:

Classes.superclass(::Type{Bar}) = Foo

Classes.issubclass(::Type{Bar}, ::Type{Foo}) = true
# And so on, up the type hierarchy

Adding the mutable keyword after @class results in a mutable struct, but this feature is not inherited by subclasses; it must be specified (if desired) for each subclass. Classes.jl offers no special handling of mutability: it is the user's responsibility to ensure that combinations of mutable and immutable classes and related methods make sense.

  • Keyword parameters

Generated accessor functions

The @class macro also generates "getter" and "setter" functions for all locally defined fields in each class. For example, for a class Foo with local field foo::T, two functions are generated by default:

    # "getter" function
    get_foo(obj::AbstractFoo) = obj.foo

    # "setter" function
    set_foo!(obj::AbstractFoo, value::T) = (obj.foo = foo)

Note that these are defined on objects of type AbstractFoo, so they can be used on Foo and any of its subclasses.

See the detailed docs for information on customizing the names of accessor functions.

The @method macro

A "method" is a function whose first argument must be a type defined by @class. The @method macro uses the shadow abstract type hierarchy to redefine the given function so that it applies to the given class as well as its subclasses.

Thus the following @method invocation

@method my_method(obj::Bar, other, stuff) = do_something(obj, other, stuff)

emits essentially the following code:

my_method(obj::AbstractBar, other, stuff) = do_something(obj, other, args)

The only change is that the type of first argument is changed to the abstract supertype associated with the concrete type Bar, allowing subclasses of Bar -- whose abstract supertype would by a subtype of AbstractBar -- to use the method as well. Since the subclass contains a superset of the fields in the superclass, this works out fine.

Subclasses can override a superclass method by redefining the method on the more specific class.

The @class macro emits the following function:

get_foo(obj::AbstractFoo) = obj.foo

Since Bar <: AbstractBar <: AbstractFoo, the method also applies to instances of Bar.

julia> f = Foo(1)
Foo(1)

julia> b = Bar(10, 11)
Bar(10, 11)

julia> get_foo(f)
1

julia> get_foo(b)
10

We can redefine get_foo for class Bar to override its inherited superclass definition:

julia> @method get_foo(obj::Bar) = obj.foo * 2
get_foo (generic function with 2 methods)

julia> get_foo(b)
20

Subclasses of Bar now inherit its definition, rather than the one from Foo, since the prior class is more specialized (further down in the shadow abstract type hierarchy).

julia> @class Baz <: Bar begin
          baz::Int
       end

julia> z = Baz(100, 101, 102)
Baz(100, 101, 102)

julia> dump(z)
Baz
  foo: Int64 100
  bar: Int64 101
  baz: Int64 102
  
julia> get_foo(z)
200