ANN: TraitInterfaces.jl - explicit interfaces in Julia

Hi, I’m pleased to share TraitInterfaces.jl. It provides an @interface macro to declare interfaces and an @instance macro to declare implementations of those interfaces. These implementations are identified with Julia values, which we think of as traits. I write a lot more in the README, but I’ll excerpt a small piece here. I’ll start by showing the use of these macros in the simplest of cases and then show what the macros expand to.

struct Sheep 
  naked::Bool 
  name::String 
end

joe = Sheep(true, "Joe")

@interface AnimalInterface′ begin
  Species::TYPE         # 'abstract type'
  @import String::TYPE  # 'concrete type'
  name(s::Species)::String
  noise(s::Species)::String
end
struct SheepImplsAnimalTrait end
trait = SheepImplsAnimalTrait()

@instance AnimalInterface′{Species=Sheep} [model::SheepImplsAnimalTrait] begin 
  name(s::Sheep)::String = s.name
  noise(s::Sheep)::String = s.naked ? "baaaaah?" : "baaaaah!"
end

@test name[trait](joe) == "Joe" # equivalent to name(Trait(trait), joe)
@test noise[trait](joe) == "baaaaah?"
AnimalInterface′.Meta.@wrapper Animal # type which abstracts implementation details
joe_animal = Animal(joe) 

# error because there is no `@instance Animal [model::Int] ...` 
@test_throws MethodError Animal(100) 

@test name(joe_animal) == "Joe" # equivalent to name[joe]()

The @interface command first expands to putting the abstract types and operations into the namespace where @interface is being declared. Then these are imported into a newly created module:

function name end 
function noise end 
function Species end 

module AnimalInterface′
  export name, noise, Species
  import ..Foo: name, noise, Species # if the ambient module is Foo
  module Meta
    struct T end # A special type associated with the interface

    # Copy of the Julia data structure that stores the content of the interface
    const theory = Interface(:AnimalInterface′, Judgment[...])

    macro wrapper(n)
      ... # to be explained below
    end
  end
end

For convenience, we add getindex methods so that we can call my_operation[implementation](args...) to avoid requiring explicit Trait() wrapping. E.g.:

Base.getindex(::typeof(name), m::Any) = (args...; kw...) -> name(Trait(m), args...; kw...)
Base.getindex(::typeof(noise), m::Any) = (args...; kw...) -> noise(Trait(m), args...; kw...)

The @implements code generates the following methods:

function AnimalInterface′.name(m::Trait{<:SheepImplsAnimalTrait}, s::Sheep)::String
    let model = m.value
        s.name # code that was explicitly written by user appears here
    end
end

function AnimalInterface′.noise(m::Trait{<:SheepImplsAnimalTrait}, s::Sheep)::String
    let model = (m).value
      s.naked ? "baaaaah?" : "baaaaah!"
    end
end

It then generates code to check whether the interface has been fully implemented:

if !(hasmethod(AnimalInterface′.noise, Tuple{Trait{SheepImplsAnimalTrait}, Sheep}))
  throw(MissingMethodImplementation(...))
end
# likewise for `noise`

Lastly it stores the information of how this implementation assigned concrete types to the abstract type of the interface:

impl_type(::SheepImplsAnimalTrait, ::typeof(AnimalInterface′.Species)) = Sheep

The @wrapper macro generates the following code:

@struct_hash_equal struct Animal
    val::Any
    types::Dict{Symbol, Type}
    function Animal(x::Any)
        types = try
          Dict(:Species => impl_type(x, AnimalInterface′.Species))
        catch _
          error("Invalid $AnimalInterface′ model: $x")
        end
        new(x, types)
    end
end

Base.get(x::Animal) = x.val
impl_type(x::Animal, o::Symbol) = x.types[o]
AnimalInterface′.noise(x::Animal, args...; kw...) = 
  AnimalInterface′.noise(Trait(x.val), args...; kw...)
AnimalInterface′.name(x::Animal, args...; kw...) = 
  AnimalInterface′.name(Trait(x.val), args...; kw...)

There are lots of more features, such as aliases, default methods, extending interfaces, multiple inheritance (e.g. ThRing combines ThAbelianGroup and ThMonoid), declaring types which depend on types and depend on terms, traits which have nontrivial data (e.g. struct ModuloArith n::Int end, rather than traits being zero-field structs), traits which have type parameters (e.g. implementing an interface with Matrix{T} where T).

I’m not an expert in the other interface / trait libraries in Julia, so it will take some time to draw detailed comparisons. I also noticed just before posting that MultipleInterfaces.jl got announced recently!

5 Likes