Discussion: A potential solution to the anytype problem

Polymorphism is hated as much as it’s loved, but one thing is for sure: it’s not going anywhere, and if zig wants to spread, it should probably be possible in some way. However, polymorphism almost always adds implicit behavior and “magic”, which is strictly against the zig philosophy. For this reason, polymorphism in zig relies almost entirely on the anytype keyword, which does not provide any way to explicitly define what members the type is required to include. Here’s an example:

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

pub fn main() !void {
    const dog: Dog = .{ .age = 4 };
    const cat: Cat = .{ .age = 5 };
    const brick: Brick = .{ .mass = 200, .pain = 1_000_000_000 };
    feed(dog);
    feed(cat);
    feed(brick);
}

fn feed(animal: anytype) void {
    animal.eat();
}

const Dog = struct {
    age: u8,
    pub fn eat(self: Dog) void {
        _ = self;
        print("Dog ate!\n", .{});
    }
};

const Cat = struct {
    age: u8,
    pub fn eat(self: Cat) void {
        _ = self;
        print("Cat ate!\n", .{});
    }
};

const Brick = struct {
    mass: u8,
    pain: u64,
};

After zig build run, we get an error that looks like this:
src/main.zig:13:11: error: no field or member function named 'eat' in 'main.Brick'.
This is great and all, but it’s not quite as clear as it could be, and, more importantly, it’s not very easy to tell what members the animal: anytype parameter require unless you were to read the body of the function itself, which is not ideal. To fix this, we can do something like:

fn feed(animal: anytype) void {
    if (comptime !@hasDecl(@TypeOf(animal), "eat")) {
        @compileError("Type '" ++ @typeName(@TypeOf(animal)) ++ "' does not satisfy the requirements for 'feed()': no member function named 'eat'");
    }
    animal.eat();
}

Which gives us this error instead:
src/main.zig:14:9: error: Type 'main.Brick' does not satisfy the requirements for 'feed()': no member function named 'eat'.
This is quite verbose, and repeating it for every function that accepts anytype makes for a lot of boilerplate. At the very least, now the requirements for the parameter are near the top of the function and easier to read, and the error message is a bit clearer. However, one issue still remains: tooling.

Simply put, a static analysis is unable to tell whether or not a type passed to feed() meets the requirements or not, which means the programmer will have to either hunt down the function definition, or find out the hard way when the code doesn’t compile. Whether you like it or not, good tooling can make-or-break a language, which is why I think this problem is an important one to solve; therefore, my idea: the satisfies keyword.

The intent of the satisfies keyword is not to add an interface system or any new runtime functionality; it only exists as an easy-to-parse shorthand for the comptime checks shown above. Here’s an example:

fn feed(animal: anytype satisfies { fn eat void }) void {
    animal.eat();
}

This example is functionally equivalent to:

fn feed(animal: anytype) void {
    if (comptime !@hasDecl(@TypeOf(animal), "eat")) {
        @compileError("Type '" ++ @typeName(@TypeOf(animal)) ++ "' does not satisfy the requirements for 'feed()': no member function named 'eat'");
    }
    animal.eat();
}

The only differences are that it’s easier to read and can be statically analyzed. I would put this in the same category as the for loop: while the same functionality can be recreated entirely with a while loop and an iterator, the use-case is so common that it became its own keyword. In the same spirit, the satisfies keyword adds nothing that couldn’t be achieved before, and (I think) it maintains the simplicity that the zig language is trying to achieve. In short: it’s syntactic sugar, and nothing more.

The following are a few more ideas I have for this keyword:

Multiple requirements (this one’s a no-brainer)

// Now *both* the 'eat' function *and* the 'age' field are required.
fn feed(animal: anytype satisfies { fn eat void, age: u8 }) void {
    animal.eat();
}

Splitting declaration and usage (great for reusing long lists of requirements)

// This is **not** a type and cannot be used anywhere other than function declarations.
const Animal = satisfies {
    fn eat void,
    age: u8,
};

// 'Animal' effectively just gets replaced with '{ fn eat void, age: u8 }' during compilation.
fn feed(animal: anytype satisfies Animal) void {
    animal.eat();
}

The satisfies keyword has plenty of potential for more advanced features such as nesting and being able to specify function parameters instead of just the name and return type, but that goes beyond the scope of this post and potentially makes the feature too complex to realistically fit the language anymore.

I understand that language proposals are not welcome at this time, so the intent of this post was not to get a feature added but instead to have an interesting discussion and get some feedback on this idea. To that end, please feel free to critique my work and/or suggest improvements. I had a lot of fun writing this, and while I think it would be very cool to see this (or something similar) as a real feature someday, I have no intention of pushing for it or harassing the core maintainers about it, so please keep it civil and let’s have an interesting thought experiment together.

3 Likes

Brainstorming category fits better than Explain


There are a number of things this doesn’t address:

  • function parameters, in particular self parameters; currently it seems to assume functions will be called on the instance only and therefore implies a self parameter exists.
  • how would struct vs union vs enum and their corresponding fields which have different semantics be specified?
  • could this also be applied to type parameters?
  • what about more complex requirements?

A better example would be something like a generic writer.

This is the beginnings of an idea similar to one I have had, I am curious how if you will solve those problems differently.

6 Likes

Isn’t this just interfaces?

4 Likes

For whatever reason, the Brainstorming category doesn’t appear in the list of category options. I guess I don’t have permission to post there? If a mod sees this, then please change the category for me. Thanks!

See the following:

Originally I only scoped this to be able to specify that a function with a certain name and return type exist, just like how for loops can’t iterate backwards, because that use-case isn’t quite common enough to warrant its own syntax. However, for the sake of covering all the bases, I will provide a potential (and partial) solution to this. First, the current way to ensure that a type has a method called eat that takes itself as an argument and returns void is the following:

fn feed(animal: anytype) void {
    const T = @TypeOf(animal);
    if (comptime !@hasDecl(T, "eat") or
        @typeInfo(@TypeOf(T.eat)) != .@"fn" or
        @typeInfo(@TypeOf(T.eat)).@"fn".params.len != 1 or
        @typeInfo(@TypeOf(T.eat)).@"fn".params[0].type != T or
        @typeInfo(@TypeOf(T.eat)).@"fn".return_type != void) {
        @compileError("Type '" ++ @typeName(T) ++ "' does not satisfy the requirements for 'feed()': expected 'eat(self: " ++ @typeName(T)  ++ ") void'");
    }
    animal.eat();
}

Truly syntax hell at its finest. My proposal for an equivalent:

fn feed(animal: anytype satisfies { fn eat(@TypeOf(animal)) void }) void {
    animal.eat();
}

This, of course, requires the type of animal to be known from within the scope of the function declaration, and I am unsure if that is something easy to implement or not. Either way, calling @TypeOf() from within a function declaration is unorthodox and (as I said previously) out of scope for this post. Additionally, I have no solution at this time for the separated-declaration-and-usage syntax that does not require a second keyword. The TL;DR is that this is something that probably shouldn’t be covered by this feature, and if you want to ensure more than just the existence of a method, you’ll have to do it the hard way (just like for loops and reverse iterating).

I may be misunderstanding what you’re asking, but if you want to ensure that an anytype has an enum, for example, you would just do the following:

const Color = enum {
    red,
    blue,
    green,
};

// This now requires that 'animal' has a field of the 'Color' type, named 'color'.
fn feed(animal: anytype satisfies { fn eat() void, color: Color }) void {
    animal.eat();
}

I don’t see why not. Something like this?

fn fun(Animal: type satisfies { fn eat() void }) void {}

Yet again (correct me if I’m wrong), my understanding of the zig philosophy is that after a certain complexity is reached, the programmer should handle it, not the language.

If you consider comptime checks that ensure specific type requirements are met to be interfaces, then interfaces are already in the language. This just makes them prettier.

3 Likes

zig supports this, even on anytype parameters.

@This could be used, it already exists to refer to the encompassing type if it does not have a name.

Not has, is. the type passed to an anytype parameter could be anything, your examples assume it is only a struct.

:+1: I agree, just wanted an explicit answer.

3 Likes

There are already numerous ways to implement polymorphism in zig already it’s just not some magic implicit polymorphism. Many polymorphism implementations that are more ‘accessible’ make a very noticeable trade off in both control, and deductive reasoning of how it internally works.

I genuinely do not understand how this could be made any clearer.

We have doc comments that are a great escape hatch for things like this. IMO any usage of any type/anyvalue warrants a doc comment to explain constraints of T. And in situations where things need to be more rigid you probably should be using a type to describe the shape of the expected arg. To me anytype/anyvalue is actually better suited for using compile time reflection functionality than it is ‘polymorphism’. And would maybe insider that your opinion maybe derived from your personal usage of these features rather than their optimal and or intended usage.

What it seems to me you are specifically alluding to is the LSP guiding you towards the correct answer. And frankly that is You establishing a skill ceiling for everyone else. Fundamentally I actually believe that many of the newer systems level languages and their users, often coming from web ecosystems, advocate for such lsp integrations not realizing that You unjustly inhibit the languages ability to express complex ideas because an lsp fails to rise to the occasion, meaning if we had to wait all new features behind an LSP’s ability too adapt to novel solutions the language is entirely constrained to what is a current possibility in the LSP. Andrew has said purposefully set aside Lsp related development because of this reason, if I remember correctly.

As for your solution, as someone else pointed out this is just implicit interfaces, and I think that you haven’t fully developed the thought to realize that implicit interfaces hide control flow and allocation. IE if as part of your struct type you hold a pointer to an allocator or Io it is possible for you via this ‘satisfies’ to pass something without passing an allocator or Io to do those types of operations, and thus now introduced a hidden level of complexity not only in the compilers ability to optimize, but more importantly in others ability to reason about the intended explicit behavior of a function. I’m at least the case of anytype/anyvalue while the above is true in that it is entirely possible to pass structs with pointers to both Io and allocators, even if they shouldn’t, that I as the consumer know that I need to explore and fully come to realize the ‘Why’ it is anytype/anyvalue.

For instances where you really want true to form polymorphism, and or what it actually seems to me, interfaces, I can share a very simple implantation of compile time struct function signature checking that in definition satisfies what an ‘interface’ is. I wrote it and came to understand that ‘interfaces’ in some sense, without special casing for allocators and Io, are actually somewhat an an anti pattern because of their ability to hide control flow and allocations.

2 Likes

I was unaware. Nice to know.

Well a specifies {}; is explicitly not a type, so it’s up for debate whether or not it would be acceptable to re-use that builtin for this case as well. A new builtin might be warranted, however, the closer this gets to being a complex compiler pass, the further it gets from being a nice shorthand for boilerplate, so I think that should be avoided.

specifies is meant to make polymorphism syntax easier, and (feel free to prove me wrong) I can’t think of very many scenarios in which you wouldn’t use a struct for this. I guess I would add another limitation to the spec: satisfies implicitly enforces that the anytype be a struct, with a compile error if that is not the case. How often the use-case is needed should decide whether or not new syntax is warranted (which I believe is standard zig philosophy), and whether or not this use-case is common enough is up for debate. For now, my stance is “no, it’s not common enough”.

I am well aware that polymorphism in zig already exists. My point was really just to say that it shouldn’t be ignored, though the wording was certainly sub-optimal.

It says what the error was, but not where it came from. Yes, it would be trivial to find this information. This is why I said “it’s not quite as good as it could be”, emphasis on ‘quite’. The error message is really the least important part of this post.

I have no further comments. You make good points; thank you for the feedback.

Consumer of your code has a runtime known, discrete set, of implementations, a prime use case for a tagged union.

By forcing the type being a struct, you force a bunch of boilerplate to interact with polymorphic code, which defeats the purpose of making it easier.

It is not ignored, andrew/the team has yet to find or make a solution that is satisfactory.

They do not like the status quo.

Proposals similar to yours have been made and rejected, you ought to look into what has actually been going on before voicing such assumptions.

3 Likes

Sorry, it seems that my words are not doing a great job of expressing my opinions today. I don’t mean to say that it is being ignored. I just don’t think it should be. It was just an opener to the post, really; don’t think too much of it. Thanks HS English class.

It’s 1AM, so I will consider and research the rest of your comment later this morning and edit this reply with my response.

>final solution to the anytype question
>traits
The reason why I don’t like traits is cause it feels like a way to obfuscate the details of how an interface is implemented.
Zig is good cause it forces you to ask these questions. Runtime interfaces are done through function pointers, and comptime interfaces are done through anytype.

I believe there’s a simple, expressive way to solve your problem in current Zig:

fn feed(animal: anytype, feed_impl: fn(self: @TypeOf(animal)) void) void {
    feed_impl(animal);
}

pub fn main() !void {
    const dog: Dog = .{ .age = 4 };
    const cat: Cat = .{ .age = 5 };
    const brick: Brick = .{ .mass = 200, .pain = 1_000_000_000 };
    feed(dog, Dog.eat);
    feed(cat, Cat.eat);
	// Can't feed the brick anymore, without a feed function!
	// But, out of curiosity, let's try to feed it with another animal's 
	// feed function.
	// As you can probably already guess, this will compile-error.
	feed(brick, Dog.eat);
    // feed(brick);
}

Now, this is polymorphism (Or maybe monomorphism? I’m not very smart with these things), but it doesn’t feel like polymorphism anymore, because you’re essentially just manually calling the function from the desired type on the object of that type.
Which is what you were doing before, but then the details were obfuscated, and now they’re made clear.

9 Likes

You can most likely already implement this as a comptime function, e.g.:

fn feed(animal: anytype) void {
    assertInterface(Animal, animal);
    animal.eat();
}

…where the assertInterface function loops over the interface type properties and does the @hasDecl check for each (while recursing into nested structs etc…)

The devil might be in the details of course, but a similar function might be a good candidate for std.meta.

Also another downside is that the function user needs to look into the function body to see that animal is expected to satisfy Animal since the restriction isn’t in the function signature.

(PS: usually I’m a syntax sugar fan though, but you have to consider that other languages require keywords for such things because their comptime support isn’t powerful enough - in Zig the question of what should be syntax sugar and what should be comptime code is a bit trickier to answer).

6 Likes

you can do the same by just

fn satisfies(comptime T: type) bool {
    return @hasDecl(T, "eat") and @TypeOf(T.eat) == fn (T) void;
}
const std = @import("std");

fn satisfies(comptime T: type) bool {
    return @hasDecl(T, "eat") and @TypeOf(T.eat) == fn (T) void;
}

const Brick = struct {
    mass: u8,

    fn throw(self: @This()) void {
        _ = self;
    }
};

const Dog = struct {
    age: u8,

    fn eat(self: @This()) void {
        _ = self;
    }
};

pub fn main() !void {
    const dog: Dog = .{ .age = 10 };
    const brick: Brick = .{ .mass = 100 };
    std.log.debug("{}", .{satisfies(@TypeOf(dog))});
    std.log.debug("{}", .{satisfies(@TypeOf(brick))});
}

i don’t think it will be hard to take this approach for more complex examples. why introduce a new keywords and such?

10 Likes

Hi there, I am relatively new to Zig. I have noticed Zig’s lack of constraints on type variables (in signatures), and the impact it has on API readability, which I think is OP’s main concern. I am also aware that this topic has been discussed to death already, but alas I am clueless about the Zig maintainers’ opinions on potential paths forward / the future of comptime, so your comment has piqued my interest. Do you have any more information (either links or just your memory) on what the maintainers are unhappy about? And what is the likelihood of there being future design changes to comptime or Zig’s tooling to address that unhappiness?

Basically I am just looking for signs that comptime’s API documentation issue has been recognised as a real issue that should be addressed if/when possible. That would bring me comfort, personally. :slight_smile:

For discussions like these I think it’s best to use examples from the standard library where it could be useful, so here’s one:

From this code:

pub fn run(self: *Context, io: std.Io) std.Io.Cancelable!void { ... }

_ = try io.concurrent(Context.run, .{ context, io, 1 });

We get this error:

src/main.zig:195:41: error: expected at most 2 tuple fields; found 3
    _ = try io.concurrent(Context.run, .{ context, io, 1 });
                                       ~^~~~~~~~~~~~~~~~~~

We do get a different error if we call with .{ context, 1 } instead:

src/main.zig:195:52: error: expected type 'Io', found 'comptime_int'
    _ = try io.concurrent(Context.run, .{ context, 1 });
                                                   ^

concurrent already does a similar technique to the fn feed(animal: anytype, feed_impl: fn(self: @TypeOf(animal)) void) suggestion above, by encoding the expected signature in it’s caller args: std.meta.ArgsTuple(@TypeOf(function)).

In my eyes both messages are “good enough”, though potentially the former could be improved. Either way it helps reinforce the idea that moving the validation to the function interface is the way to go, and as demo’d by @tholmes and here in std, is already quite possible.

Does anyone have better or more complex examples where this kind of technique doesn’t work?

4 Likes

Please anytype is great, leave it be.
It’s a great escape hatch from “type safety” and you don’t have to use it anymore.

2 Likes

anytype does not escape you from type safety, the type still needs to support how you use it, and anything you assert about it.

It just lets you write code that depends not on a specific type, but on more specific features that could be fulfilled by a number of types.

This thread is about the desire to improve, in Op’s opinion, the ability to express those features, at least for the simple cases.

3 Likes

FWIW for some zig “fun” that can meet more complex examples of constraints, I’ve found the following pattern that forces the caller to think about the constraint (and makes visible at the call-site) useful:

pub fn EaterConstraints(T: type) type {
    if (!@hasDecl(T, "eat") or @TypeOf(T.eat) != fn (T) void) {
        @compileError(@typeName(T) ++ " not an eater");
    }
    // ... Perform any number of other checks
    return struct {
        const eater: @This() = .{ ._ = {} };
        _: void,
    };
}

pub fn feed(eater: anytype, constraint: EaterConstraints(@TypeOf(eater))) void {
    _ = constraint;
    eater.eat();
}

test feed {
    feed(brick, .eater);
}

Resulting in:

src/test.zig:13:9: error: test.Brick not an eater
        @compileError(@typeName(T) ++ " not an eater");
        ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
src/test.zig:43:57: note: called at comptime here
pub fn feed(eater: anytype, constraint: EaterConstraints(@TypeOf(eater))) void {
                                        ~~~~~~~~~~~~~~~~^~~~~~~~~~~~~~~~
src/test.zig:7:9: note: generic function instantiated here
    feed(brick, .eater);
    ~~~~^~~~~~~~~~~~~~~

Whether this is good or cursed is up for debate, but it does make the convention/constraint explicit in a way other methods sometimes don’t. There’s even more fun you can do here to specialise the constraint and allow feed to choose a path based on that.

Edit: realising the returned struct could contain a vtable and now I’ve nerdsniped myself and need to go refactor

Edit 2: Yup, way nicer:

pub fn SatisfiesEater(T: type) type {
    return struct {
        const eater: @This() = .{ ._ = {} };
        _: void,
        const eat: fn (T) void = T.eat;
    };
}

pub fn feed(eater: anytype, Eater: SatisfiesEater(@TypeOf(eater))) void {
    Eater.eat(eater);
}
5 Likes

I have been programming a long time in the languages delphi, c#, rust and zig.

In my feeling there is no easy solution for this (I encountered the thing myself a few times in Zig).
In delphi and c# I do not like at all the interface mechanisms at all.
The traits in rust even less.
The question: “Where is the f*ng implementation of this thing” will always be there, regardless what construct the language uses.
Adding sugar for “does this thing support this or that” does not necessarily make things more readable.

The extreme flexibility and shortness of anytype is up until now still the best thing I encountered.
Not 100% ideal but the best :slight_smile:

(I also think interfaces should be used as little as possible. Often we can read the phrase “always talk to interfaces, not classes”. Disagree!).

7 Likes