ModularTypes allows creating Julia types by making use of type composition and a multitrait-dispatch system. This package those situations where, unlike in generic programming, an algorithm and its associated data should not be decoupled. A common situation where such deocupling is not adequate is when building models by reusing modules (e.g. in agent based modelling), hence the name of the package.
Type composition may fit naturally the concept of modular building (i.e. the model has a module rather than is a module). Even in examples that are used to introduce inheritance, type composition may be more powerful. For example, rather than saying "a teacher is a person" and a "student is a person" one may say that a person may "have the ability to teach" or "have the ability to learn". In this case, modules would be defined for "Teaching" and "Learning" that provide all the methods and data required to implement such abilities. This avoids having to redesign the type hierarchy whenever a change is introduced in the system (i.e. reuse of data and functionality is horizontal rather than hierarchical).
struct Teacher
teaching::Teaching
...
end
struct Student
learning::Learning
...
end
Where ...
represents other abilities we may want to confer to Teacher
s and
Student
s, such Eating
, Driving
, etc. A hierarchical relationship can still
be emulated by composing types at multiple levels (like a matryoshka doll).
However, with type composition, the person would not really acquire the
ability to teach or learn, which remain attached to the fields to which the Teaching
or Learning modules were attached. That is, instead of teach(t::Teacher, s::Student)
one would have to say teach(t.teaching::Teaching, s.learning::Learning)
. This
code suffers from a semantic displacement as the original intention was to make
the teacher teach, and the fields teaching and learning do not represent an actual
entity. This can get particularly complicated if types are nested at multiple levels.
The solution to this problem is to generate forwarding methods that corrects the
semantic displacement as in:
teach(t::Teacher, s::Student) = teach(t.teaching, s.learning)
ModularTypes provides macros that automatically generate these forwarding methods for any existing Julia type and make them available when included in other types as described in the above. This is achieved by using a multitraits system, similar in nature to the package SimpleTraits.jl.
The main difference with respect to SimpleTraits.jl is that traits are organized into trait classes and that a trait is a property defined when generating the methods (i.e. any existing type can be used as a trait).
A trait class is then use to dispatch the same function for different traits
included in the class. Both traits and trait classes are single parameter but
multiple traits may be used within a function signature. Four colons (::::
)
are used to denote function arguments that are traits or trait classes. This is
inspired by Traitor.jl.
The multitrait system may be used independent of type composition. The trait
dispatch method and the specific trait methods are implement by the macros
@traitdispatch
and @traitmethod
. For example:
# Type to be used for trait dispatching
struct TC end
# Create a dispatch method associated to a trait class
@traitdispatch function foo(x::::TC) end
# Type to be used as trait
struct T end
# Create a method for trait T
@traitmethod foo(x::::T) = x.x
The macro @hastrait
is then used to indicate than a given type implements a
given trait. It is necessary to specify both the trait class and the trait
being implement, as in the following example:
struct bar
x::Int64
end
# Declare that bar has the trait T from trait class TC
@hastrait bar TC{T}
foo(bar(3))
Note that only one trait per trait class should be implemented. Traits can be
added to a type at any moment after its definitions, and trait methods will become
available even if their are created after a trait is assigned to a type. If the
traits to be implemented are known at the time of type definition, the @implements
macro comes in handy:
@implements TC{T} struct baz
y::Int64
end
If you are used to define your types with @with_kw
from the Parameters.jl
package, you can use @implements_kw
, which will automatically call @with_kw
In the case that we want to create forwarding methods to correct the semantic displacement of type composition, the procedure is exactly the same as for multitraits (see previous section) with the differences:
- The
@forwardtraitmethod
macro should be used instead of@traitmethod
- The type must be assigned to a field with the name
field<typename>
where<typename>
is the name of the type.
For example:
# Type to be used for trait dispatching
struct fTC end
# Create a dispatch method associated to a trait class
@traitdispatch function fooz(x::::fTC) end
# Just a regular type, for which a forwarding method will be created
struct fT
x::Int64
end
# Create a method for fT
@forwardtraitmethod fooz(x::::fT) = x.x
# Type that includes fT in fieldfT
struct fbar
fieldfT::fT
end
@hastrait fbar fTC{fT}
fooz(fbar(fT(3)))
Similarly to multitraits, if the traits are known at the moment of type definition,
the keywords @contains
and @contains_kw
may be used. The latter is particularly
handy as type composition can result in complex object construction. For example:
@contains_kw fTC{fT} = fT(1) struct fbarkw
end
fooz(fbarkw())
Note that traits may live in a different module to the module where the forward methods are defined and/or the module where the container types are defined. Normal module prefixing may be used if the symbols are no imported in all the macros described in the above. However, the name of the field to which a type with forwarding methods is assigned should strip out all the module prefixing.
A type may implement multiple traits and contain multiple types. When using @implements
, @contains
or their
kw
equivalents, all the traits should be listed as different arguments of the macro call.
@contains
and its kw
equivalent will insert the instances of the type-traits
in the order in which they are listed after all the fields already existing. Then
it will add the traits to the type in the same order. That is,
@contains TC1{T1} TC{T2} struct bar2
y::Int64
end
is equivalent to:
struct bar2
y::Int64
fieldT1::T1
fieldT2::T2
end
@hastrait bar2 TC1{T1}
@hastrait bar2 TC2{T2}
Only one trait class can dispatch a given method on a given namespace. That is,
@traitdispatch function foo(x::::TC, y) end
@traitdispatch function foo(x::::TC2, y) end
will result in the second definition overwriting the first. However,
@traitmethod function foo(x::::T, y) end
@traitmethod function foo(x::::T2, y) end
will work.
Methods and function signatures used with @traitdispatch
, @traitmethod
and
@forwardtraitmethod
may contain optional and keyword arguments. However, these
arguments cannot be used for trait dispatch. Also, the default values assigned
in the @traitdispatch
method will override any default values assigned in the
@traitmethod
or @forwardtraitmethod
methods.
Parametric types may be used as traits and trait dispatch will correctly propagate the type parameters. When composing a type from parametric types, the name of the field should not take into account the type parameters. But the type parameters still need to be considered in the type definition. That is:
@contains TC{T{T1,S1}} struct bar{T1,S1} end
is equivalent to:
struct bar{T1,S1}
fieldT::T{T1,S1}
end
@hastraits bar{T1,S1} TC{T{T1,S1}}
Note that the T1
s and S1
s must coincide within the type definition and
within the @hastrait
macro. Type parameters may also be used in methods
modified by @traitdispatch
, @traitmethod
or @forwardtraitmethod
and they
will be respected in the generated methods.
This is an implementation of inspired on the packages SimpleTraits.jl and Traitor.jl.
@traitdispatch
will takes an empty function, extract its signature and generates
a method where each argument qualified with ::::
is converted into a type parameter
in the method signature. The body of the generated method is a call to the same
function but with an extra argument for each trait used for dispatch. These
arguments are calls to a constructor with the same name as the trait class and
taking the type parameter as input. The extra argument go first, as otherwise it
would not be possible to use optional and keyword arguments. That is:
@traitdispatch function fun(x::::TC1, y::Int64, z::::TC2) end
will generate
fun(x::traitTC1, y::Int64, z::traitTC2) where {traitTC1, traitTC2} =
fun(TC1(traitTC1), TC2(traitTC2), x, y, z)
@traitmethod
will add new method definition based on its argument. In the signature
of the extra method, each argument qualified with ::::
is left unqualified. In
addition, a value type argument is added for each trait, matching the extra arguments generated by @traitdispatch
. The body of the method remains unchanged. For example:
@traitmethod fun(x::::T1, y::Int64, z::::T2) = x+y+z
will generate 2 methods
fun(x::T1, y::Int64, z::T2) = x+y+z
fun(::Type{T1}, ::Type{T2}, x, y::Int64, z) = x + y + z
The first method allows to bypass trait dispatch when using objects of type T1
and T2
. The second method is the one that will be executed when x
and z
are objects
that have the traits T1
and T2
(i.e. that behave as if they were of type T1
and
T2
as far as fun
is concerned).
@hastrait
will define the constructors required by @traitdispatch
, that
returns the type associated to a trait when taking as argument the
type implementing the trait. That is
@hastrait bar TC{T}
will generate
TC(::Type{bar}) = T
@forwardtraitmethod
will take a method definition and add an extra method.
The signature of the extra method is modified as in @traitmethod
. However,
the body of the method is subtituted by a call to the original method, but
substituying any argument that is qualified by a trait with a reference to
the field of the correct name (i.e. field<trait_name>
). For example:
@forwardtraitmethod fun(x::::T1, y::Int64, z::::T2) = x+y+z
will generate
fun(x::T1, y::Int64, z::T2) = x+y+z
fun(::Type{T1}, ::Type{T2}, x, y::Int64, z) = fun(x.fieldT1, y, z.fieldT2)