Macros implementing multitraits and generation of forward methods for type composition



Travis AppVeyor Codecov

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.

What is the problem?

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
struct Student

Where ... represents other abilities we may want to confer to Teachers and Students, 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)

What does this package do?

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
# Declare that bar has the trait T from trait class TC
@hastrait bar TC{T}

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

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

Modular types

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
# Create a method for fT
@forwardtraitmethod fooz(x::::fT) = x.x
# Type that includes fT in fieldfT
struct fbar
@hastrait fbar fTC{fT}

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

Compatibility across modules

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.

Containing multiple types

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

is equivalent to:

struct bar2
@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.

Keyword and optional arguments

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

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}
@hastraits bar{T1,S1} TC{T{T1,S1}}

Note that the T1s and S1s 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.

How does this work?

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)

First Commit


Last Touched

4 months ago


25 commits

Used By: