Can we have compile-time, zero cost interfaces?

Interfaces have been requested before many times, but for some reason the idea has been rejected?

My proposal doesn’t involve any new type of polymorphism to be added to the language, it doesn’t make it more complex, it would actually make it simpler. It would simply provide better type-safety (and I believe it can be had at zero cost).

The way I’ve seen interfaces (sort of) implemented is with tagged unions (in ziglings there is an example of that), but imho it’s an ugly and hackish workaround which also has a runtime cost, since unions are as big as their biggest member, and it requires some (maybe comptime) form of introspection to have some type safety. It also clashes with one of zig’s tenets (“focus on debugging your application rather than debugging your programming language knowledge”). Interfaces instead are a simple and well known concept and I think they can be had at zero runtime cost.

Example (working in 0.14.1):

const print = @import("std").debug.print;

const a = struct {
    a: u8 = 9,
    b: u8 = 4,
    c: u8 = 2,

    pub fn printSelf(self: @This()) void {
        print("{s}: a = {}, b = {}, c = {}\n", .{@typeName(@TypeOf(self)), self.a, self.b, self.c});
    }
} {};

const b = struct {
    b: u8 = 9,
    a: u8 = 4,

    pub fn printSelf(self: @This()) void {
        print("{s}: a = {}, b = {}\n", .{@typeName(@TypeOf(self)), self.a, self.b});
    }
} {};

pub fn main() !void {
    p(a);
    p(b);
}

// no type-safety at all, but it works
fn p(s: anytype) void {
    s.printSelf();
}

With interfaces, it would be:

const print = @import("std").debug.print;

// no data should be contained in interfaces, only field declarations with
// their type, otherwise it can't be zero-cost
// function prototypes don't need the parameter name, only the type
const ifc = interface {
    a: u8,
    b: u8,
    pub fn printSelf(type) void,
}

// note the symmetry with the tagged union syntax
// the compiler will make sure that all fields declared in the interfaces will
// also be declared here, with the same type
const a = struct(ifc) {
    a: u8 = 9,
    b: u8 = 4,
    c: u8 = 2,

    pub fn printSelf(self: @This()) void {
        print("{s}: a = {}, b = {}, c = {}\n", .{@typeName(@TypeOf(self)), self.a, self.b, self.c});
    }
} {};

const b = struct(ifc) {
    b: u8 = 9,
    a: u8 = 4,

    pub fn printSelf(self: @This()) void {
        print("{s}: a = {}, b = {}\n", .{@typeName(@TypeOf(self)), self.a, self.b});
    }
} {};

pub fn main() !void {
    p(a);
    p(b);
}

// the compiler here will check that only fields declared in the interface are
// used
fn p(s: ifc) void {
    s.printSelf();
}

So interfaces will be used by the compiler, that must:

  1. validate structs types that use the interface, making sure that they implement all fields declared in it, with the exactly the same types (signatures for functions)
  2. validate all functions that take an interface as parameter, making sure that only fields declared in the interface are used in it

Why zero-cost? Because after the compiler has done the above, information about interfaces doesn’t need to end up in the binary or affect runtime in any way. After removing the interface stuff, the above script becomes the same as the first one (where interfaces weren’t used), but you gain type safety in the process:

const print = @import("std").debug.print;

// interface is gone

// again a normal struct
const a = struct {
    a: u8 = 9,
    b: u8 = 4,
    c: u8 = 2,

    pub fn printSelf(self: @This()) void {
        print("{s}: a = {}, b = {}, c = {}\n", .{@typeName(@TypeOf(self)), self.a, self.b, self.c});
    }
} {};

const b = struct {
    b: u8 = 9,
    a: u8 = 4,

    pub fn printSelf(self: @This()) void {
        print("{s}: a = {}, b = {}\n", .{@typeName(@TypeOf(self)), self.a, self.b});
    }
} {};

pub fn main() !void {
    p(a);
    p(b);
}

// again anytype, but the work has been done already, this function is much
// safer than it was
fn p(s: anytype) void {
    s.printSelf();
}

I’m probably oversimplifying, but you surely got the idea? Interfaces would mean:

  • better type safety, much less need for anytype
  • much less need for introspection, and relative boilerplate code
  • overall simplification of the code, using a well-known pattern
  • potentially reduce the need for generics?

Maybe there could be even be runtime benefits. Honestly I don’t see what keeps zig from having interfaces.

10 Likes

You could even forego worrying about fields, simply enforce public function declarations, something akin to Go, C#, etc.

Let it basically just be an anytype, but with stronger typing in that it enforces the type will have a function(s) defined. This would make anytype not so ambiguous, and the current “interface” idioms we use much less of a drudgery to implement. This information is already known to the compiler, it would just make it obvious at the call-site what is expected to be passed in as argument, and more ergonomic/flexible to use.

2 Likes

Yes generally I prefer interfaces to be ‘pure’ (only function prototypes, no fields). But zig has no classes or other abstract types, so having interfaces also accept field declarations wouldn’t harm here, I think (it could limit their usage otherwise). As long as there is no data attached, and types are strictly checked, I wouldn’t mind having field support. Sure, you can live with only functions (and let the field be accessed by these function calls).

The primary reason is that there is a technical reason for it, and it is the same reason you can’t use anytype for the type of a field.

pub const Foo = interface{
    a: usize,
    b: usize,
};

This means that whatever implements Foo will have two fields called a and b that are of type usize. It may have more, but we are at least making this guarantee. No problem.

pub const Bar = struct {
    foo: Foo,
};

What is the @sizeOf(Bar)?
It is impossible to determine. We can say that is at least the size of two usize, but we need concrete values. Go gets around this by making that all interfaces are also reference types, and we could here too, but starts adding extra complexity that isn’t required. Just stick with function declarations, and if you need the value of a, then add getA() to the interface.

pub const Bar = struct {
    foo: Foo,
};

But you couldn’t do this. Interfaces wouldn’t be a type, they couldn’t be used as a type. To be zero-cost, interfaces must be invisible at runtime, they should only be used by the compiler to validate structs and functions using them. The only exception would be allowing functions to accept interfaces as parameters, but only because in reality they would be treated as an anytype, they wouldn’t be a real type. The interface as parameter would only be an indication for the compiler to check that the functions called inside the function are declared in the interface, then it is an anytype really.

Anyway I would be totally fine in having function-only interfaces, it would avoid the problem that a struct could declare a field without assigning a default value, then the uninitialized field could be used by a function accepting the interface as a parameter.

1 Like

I agree, I just think interfaces defining fields seems strange. Fields seem to be indicative of a concrete type with data, while functions indicate a behavior, which is more inline with what I expect of an interface.
This is how it works in my brain at least, reasonable minds could disagree.

Yes the common definition of interface doesn’t include fields, only function prototypes. I’d be fine with that. It would imply some additional indirection (need to call methods to modify struct states) but it would be probably safer that way.

Just a note, anytype is perfectly type-safe. It just doesn’t convey intent effectively.

5 Likes

Thanks I didn’t know that. That changes things a bit.

Well described I think. anytype is a zero-cost type-safe interface as far as I have seen.
Putting an interface “in front of” or “above” the struct as an abstract defination is what most languages do - at least the languages I know (not so many).

I am beginning to like anytype.

Ok so anytype already works as a generic interface. Still, zero-cost interfaces (that then behave like anytype after compiler validation) make the code easier to understand than just having anytype, no? They would be named interfaces instead of looking all the same.

2 Likes

Yes true, but on the other side we add complication by having to define an interface. I agree in a way with your intent. But I still cannot visualize a really better way to do it than the current anytype way.
anytype is less work :slight_smile:

anytype violates the zen and the rest of zig clearly doesn’t care if you have to put in a little more work.
* Communicate intent precisely.
* Favor reading code over writing code.
* Reduce the amount one must remember.

10 Likes

The use of anytype is pretty much reserved for when there is no better option or as a helper to call an ugly looking comptime function. You should never “like anytype”, you should begrudgingly accept it as a necessary evil in certain situations. Why even use typed parameters at all if anytype is superior to literally anything else?

The ability to have the same freedom anytype offers, while also clearly expressing intent would be a very welcome change, even if all it ended up being was syntactic sugar over anytype.

4 Likes

I use anytype only when needed. Like an interface.
And in these cases I liked the zero-cost flexibility.
And yes I agree with the vulpesx violations.

(Disclaimer: I’m a big fan of Interfaces and use them in combination with Dependency Injection and Inversion of Control, primarily to allow easier testing by injecting a real implementation vs a test implementation, but there are other use cases as well).

I’ve been following this topic of interfaces for a while (and have implemented multiple types of “interfaces” in Zig based on people’s recommendations and current strategies). I do agree that there is a runtime cost with traditional interfaces (ala dynamic dispatch type), and I’m also fine with our current workarounds (vtables, tagged unions, anytype), but I have been convinced recently by people’s recommendations of basically having anytype still be the underlying mechanism for allowing this to happen, but having the interface keyword simply be syntactic sugar that the compiler can use to check that the function signatures match at compile time. This way there would be no compile time cost (at least the only cost would be actual type checking evaluations at compile time) wouldn’t really complicate things too much, still re-uses the existing anytype mechanism, but improves explicitness and intent. So going with the OP’s original example, it would look something like this. This could use a bit more clarity primarily around interface functions that take in some sort of struct that uses @This or that are not the same struct shared across all instances -
The “self: anytype” part - but things would need to be vague here given it’s an interface and the possible types are unknown at this stage… although Zig does have a single compilation unit so if my understanding is correct Zig would have full knowledge of all possibilities… so it may be able to know all the possible types of struct (including anonymous structs) that are using this interface and also what functions these interfaces are being used in), although in order to reduce the cost of this, we probably could skip this analysis and keep it more simple).

const Common = interface {
    pub fn printSelf(self: anytype)
};

const a = struct {
    a: u8 = 9,
    b: u8 = 4,
    c: u8 = 2,

    pub fn printSelf(self: @This()) void {
        ...
    }
}:

const b = struct {
    b: u8 = 9,
    a: u8 = 4,

    pub fn printSelf(self: @This()) void {
        ...
    }
};

pub fn main() void {
    ...
    p(a);
    p(b);
}

fn p(s: Common) void {
    s.printSelf();
}
1 Like

This sounds kind of like traits in Rust. Though maybe the C++ concept (basically constraints on template types) is a closer match to how it would work in Zig.

2 Likes

This looks very interesting. I like the simplicity of it.

One of the reasons for using interface is to store heterogeneous types in the same container, i.e. storing instances of ‘a’ and instances of ‘b’ in an array, or in a hashmap. Can you work out a scenario that supports this? Thanks.

1 Like

Using existing practices will likely be the only realistic way of doing that without fundamental changes to the language design and/or type system. By necessity it would likely require any implementing concrete type to be a pointer/reference, which would just further complicate matters.

I am open to be proven wrong by those with greater understanding than myself, but I can’t envision any way that it wouldn’t.

1 Like

Probably in my example I should have written anytype and not type as you did in the interface definition, I would still omit the parameter name though since it’s meant to be a prototype. Otherwise the example looks pretty much the same?

Another example, with an empty interface and composing multiple interfaces:

const std = @import("std");

const iAnimal = interface {}; // empty interface

const iPrintable = interface {
    pub fn printSelf(anytype);
};

const iCanSwim = interface {
    pub fn swim(anytype, u8);
};

const iCanFly = interface {
    pub fn fly(anytype, u8);
};

const bird = struct(iAnimal, iPrintable, iCanFly) {
    size: usize,
    pub fn printSelf(self: @Type()) { ... }
    pub fn fly(self: @Type(), speed: u8) { ... }
};

const fish = struct(iAnimal, iPrintable, iCanSwim) {
    size: usize,
    pub fn printSelf(self: @Type()) { ... }
    pub fn swim(self: @Type(), speed: u8) { ... }
};

const house = struct(iPrintable) {
    size: usize,
    pub fn printSelf(self: @Type()) { ... }
};

const b = bird{ .size = 5 };
const f = fish{ .size = 2 };
const h = house{ .size = 20 };

pub fn main() !void {
    print(b);
    printAnimalSize(f);
    printAnimalSize(h); // ERROR: house has a size, but doesn't implement
                        // iAnimal, even though this is just an empty
                        // interface!
}

fn print(s: iPrintable) {
    s.printSelf();
}

fn printAnimalSize(s: iAnimal) {
    // anytype would accept anything that has a `size` field, here we only
    // accept types that implement the empty iAnimal interface. Of course, the
    // type would still need a `size` field.
    std.debug.print("size: {}\n", .{s.size});
}