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:
- Write out the common fields manually.
- Write a macro that emits the common fields. This is better than the manual approach since it creates a single point of modification.
- 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.
- 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