Zig Like Interfaces for Julia

I’ve recently come across how Zig handles interfaces. The gist is that Zig doesn’t have syntax sugar for interfaces and instead opts for manually managing the vtable.
I got curious and started playing with the idea, and I think it translates to julia quite well:

# An interface is simply a struct that contains a list of function pointers
writer(x) = error("$(typeof(x)) does not implement the Writer interface.")
struct Writer{T}
    impl::T
    writeAll::Function
    writeSome::Union{Nothing,Function}
    about::Function
end
# The signature of these functions is encoded in these definitions
writeAll(w::Writer, data::String) = w.writeAll(w.impl, data)
writeSome(w::Writer, data::String) = w.writeSome(w.impl, data)
about(w::Writer) = println("\nI'm a writer! of subtype $(typeof(w.impl))\n")

# we can write custom Initializers to include functions always available to an interface:
Writer(x, writeAll, writeSome = nothing) = Writer(x, writeAll, writeSome, about)

# We can define functionality that assumes a method is available
# And this is enforced by the fact that a Writer object must be
# initialized with the needed functions.
complex_functions(x, data) = complex_functions(writer(x), data)
function complex_functions(w::Writer, data)
    about(w)

    # w will always have a writeAll function
    writeAll(w, data)

    # we can also check if this particular implementation has
    # defined the optional writeSome.
    isnothing(w.writeSome) || writeSome(w, data)

    return nothing
end


# Then we can implement this interface on different types
struct File
    fd::Int
end
writeAll(f::File, data) = println("value: ", f.fd, " msg: ", data)
# we could also pass existing functions that act on File, 
# not necessarily the writeAll, as long as it accept the correct inputs
writer(f::File) = Writer(f, writeAll)

# or another implementer:
struct StrangeFile
    fd::Float64
end
writeAll(f::StrangeFile, data) = println("value: ", f.fd, " msg: -VERY STRANGE-", data)
writeSome(f::StrangeFile, data) = println("value: ", f.fd, " msg: -OPTIONAL METHOD-", data)
writer(f::StrangeFile) = Writer(f, writeAll, writeSome)

# or yet another, simpler one
writeAll(i::Int, data) = println("value: ", i, " msg: -I'm an Int-", data)
writer(i::Int) = Writer(i, writeAll)


# And it just works(TM) :D
f = File(1)
sf = StrangeFile(1.0)
f = File(1)
sf = StrangeFile(1.0)

complex_functions(f, "hello interfaces")
# I'm a writer! of subtype File
#
# value: 1 msg: hello interfaces

complex_functions(sf, "hello interfaces")
# I'm a writer! of subtype StrangeFile
#
# value: 1.0 msg: -VERY STRANGE-hello interfaces
# value: 1.0 msg: -OPTIONAL METHOD-hello interfaces

complex_functions(10, "hello interfaces")
# I'm a writer! of subtype Int64
#
# value: 10 msg: -I'm an Int-hello interfaces

complex_functions(1.0, "Not supported")
# ERROR: Float64 does not implement the Writer interface.
# Stacktrace:
# 	...

I don’t know if this is something that is already well known, and if it is I apologise for the noise, but I thought this was an interesting design pattern, reminiscent of holy traits.
You could argue, “congratulation for figuring out polymorphism”, but I think that the valuable part is having to fill all the gaps when instantiating the interface object, which gives you the extra assurance that a method exists when dispatching on it.
Also from my limited testing, ti seems most of the machinery is compiled away.

However, I am not knowledgeable enough about the internals to figure out why this design pattern could be a bad idea, or where it could break, or what are the edgecases. So I aim to leverage Cunningham’s Law with this post and leech some knowledge :slight_smile: .

11 Likes

Interesting approach. How would you constrain call signatures? IIUC, this design would not prevent storing inappropriate functions. Would you combine this with something like FunctionWrappers?

As it is right now yes, you can’t prevent storing functions with the wrong signature.
You’ll most likely encounter a method error once called (defeats the purpose a bit yeah). In the end whatever function you pass in, it gets called with the type signature defined in the interface

writeAll(w::Writer, data::String) = w.writeAll(w.impl::T, data::String)
# that `w.writeAll` is the function passed to the interface

I am not familiar with FunctionWrappers, but having something that would allow for:

struct Writer{T}
    impl::T
    writeAll::Function{T, String} #signature writeAll(::T, ::String)
    writeSome::Union{Nothing,Function{T, String}
    about::Function{Writer}
end

Would be awesome, yes. That about::Function{Writer} contains a self reference, which might be problematic. But then again, that is the signature only for the functions that are common to all implementations of the interface, so the caller doen’t really touch that. So it can just be about::Function i guess.

Thanks for pointing FunctionWrappers.jl out, i’ll definitely give it a go!

I had a spare evening and I played a bit with the FunctionWrappers.jl and had interesting results
Interface Definition:

using FunctionWrappers

writer(x) = error("$(typeof(x)) does not implement the Writer interface.")
struct Writer{T}
    impl::T

    writeAll::FunctionWrappers.FunctionWrapper{Nothing,Tuple{T,String}}

    writeSome::Union{Nothing,FunctionWrappers.FunctionWrapper{Nothing,Tuple{T,String}}}

    about::FunctionWrappers.FunctionWrapper{Nothing, Tuple{}}

    function Writer(x;
        writeAll,
        writeSome = nothing
    )
        T = typeof(x)

        # short form
        writeAll_wrapper = fieldtype(Writer{T}, :writeAll)(writeAll)

        # generic form, should work for all fields.
        writeSome_t = fieldtype(Writer{T}, :writeSome)
        if isnothing(writeSome)
            writeSome_wrapper = writeSome_t <: Union{Nothing, V} where {V} ?
                writeSome_wrapper = nothing : throw(ArgumentError("writeSome is not Optional."))
        else
            writeSome_wrapper = Base.nonnothingtype(writeSome_t)(writeSome)
        end

        about() = println("\nI'm a writer! of subtype $T\n")
        about_wrapper = fieldtype(Writer{T}, :about)(about)

        return new{T}(x, writeAll_wrapper, writeSome_wrapper, about_wrapper)
    end
end
writeAll(w::Writer, data::String) = w.writeAll(w.impl, data)
writeSome(w::Writer, data::String) = w.writeSome(w.impl, data)

Now This is all a bit of a handfull to write, but I wonder how much of it can be automated inside a macro like or something along these lines.

@interface Writer{T} begin
    writeAll{T, String}::Nothing

    @optional writeSome{T, String}::Nothing

    @shared about{}::Nothing = begin
        println("\nI'm a writer! of subtype $T\n")
    end
end

Then, some usage based on the interface can be defined:

function_using_writer(x, data) = function_using_writer(writer(x), data)

function function_using_writer(w::Writer, data)
    w.about() # shared interface function are accssesed as properties.
    writeAll(w, data)
    isnothing(w.writeSome) || writeSome(w, data)

    nothing
end

And an interface implementer (implementing is just a matter of being able to construct the Writer struct)

struct File
    fd::Int
end
file_speficif_write(f::File, data::String) = println("value: ", f.fd, " msg: ", data)
writer(f::File) = Writer(f; writeAll=file_specific_write)

and it just works ™

julia> f = File(1)
File(1)

julia> function_using_writer(f, "hello interfaces")

I'm a writer! of subtype File

value: 1 msg: hello interfaces

And the type signature is enforced (at runtime though)
EDIT: Is there a way to make a function wrapper throw with the wrong signature upon construction?

struct BadFile
    fd::Int
end
bad_writeAll(f::BadFile, data::Vector{String}) = println("value: ", f.fd, " msg: ", data[1])
writer(f::BadFile) = Writer(f; writeAll=bad_writeAll)

julia> bf = BadFile(1)
BadFile(1)

julia> complex_functions(bf, "hello interfaces")

I'm a writer! of subtype BadFile

ERROR: MethodError: no method matching bad_writeAll(::BadFile, ::String)
The function `bad_writeAll` exists, but no method is defined for this combination of argument types.

Closest candidates are:
  bad_writeAll(::BadFile, ::Vector{String})
   @ Main REPL[24]:1

Stacktrace:

Another great extra is that the interface is self documenting through normal LSP functions:

  • Writer(... completion for the name of the methods. and which are optional
  • Writer(x; writeAll(... you can add a ( instead of a = to peek at the generic signature of the writeAll method. In my editor it gives me writeAll(w::Writer, data::String) and this generic method is part of the interface.

Performance wise, probably bad, it’s dynamic dispatch galore.

5 Likes