Skip to content

replace anytype #17198

@the-argus

Description

@the-argus

EDIT: this issue is about both anytype and comptime T: type. All generics in zig, and how the types are constrained. for the most part, when I say anytype, just think "generics".

anytype is a tool to defer all type logic into the function, where it can be dealt with imperatively at compile time. The result is a much poorer experience with language servers and poor ordering of information: the type constraints of the arguments of a function are the first thing you want to know. And yet anytype leaves them for later.

The frustration of wanting to know what a function does and being greeted with args: anytype is what this issue is about. It sucks, and I know zig can do better. I'm going to try to summarize some previous issues on the subject, and offer some suggestions/proposals, but I don't want this issue to be closed if a suggestion is rejected, because the point of this issue is discussion, not proposal. If people like the idea then someone can make another issue marked as proposal. This issue will be closed if it's deemed not an issue but a feature, or if all solutions are out of scope.

Example

Bad example which was originally in the issue `std.Build.dependency` is a nightmare. The function signature is `pub fn dependency(b: *Build, name: []const u8, args: anytype) *Dependency` and it takes three go-to-definitions to eventually arrive at `std.Build.applyArgs` where we can actually learn what `args` does. There are no documentation comments above any of these stdlib functions. Perhaps this could be solved by using an options struct of some kind. But nonetheless `anytype` is what got used and it's a problem that zig made it available for use in place of other options.

Ideally, we'd like some sort of annotation in the function signature of dependency which makes it clear that args should contain a target and user input options. And give a helpful error message when it doesn't, checking at the call site of dependency instead of three functions down in applyArgs.

EDIT: the above example is a bad one, as pointed out here. Consider instead this snippet from the HashMap implementation:

// If you get a compile error on this line, it means that your generic eql
// function is invalid for these parameters.
const eql = ctx.eql(key, test_key.*);

Where ctx is a generic type. This constraint is quite difficult to find if you don't find it the hard way, as it is buried in the private API of the HashMap. I think the use of anytype in the HashMap implementation is smart but it suffers in readability because there is no separation between comptime type validation logic and runtime logic (the former of which is much more important when learning the API). I think the most conservative solution to this problem would be if zig had some way of annotating a block as "for the type validation logic". Then some comptime asserts with nice error messages or comments could replace the example seen above.

Precedent

First, let's take a look at an existing solution, Zig compile-time contracts. We know type constraints can be accomplished in the language already (and that's part of why a proposed solution needs to be really good) so it's worth looking at how it would be implemented given the status quo now. A sample from zig-ctc readme:

fn signature_contract(t: anytype) contracts.RequiresAndReturns(
    contracts.is(@TypeOf(t), u8),
    void,
) {}

Compile time logic has to be inserted as a weird wrapper function around the return type. This could be helped a bit with infer T from #9260, but it still ends up pretty weird to read. This feels like something that should be a native language feature. It could be a stdlib feature instead, and maybe it should be.

In #1669, a highly related issue, a user proposed using comptime functions which return bool as types. It's readable, offers a nice experience to a user with an editor (they go-to-definition on the type, see instead it's a function, read the logic). Maybe not so nice for someone reading the code on a webpage. And, if #9260 were to be added to the language, we could remove the implicit function call and get the type of the thing in scope of the function signature:

// some hypothetical function which takes a slice of types and an input type to compare against
const oneOf = @import("std").meta.traits.oneOf;

fn doSomethingWithAStringAndANumber(str: []u8, n: oneOf(&.{u8, u16}), infer T) void {
  // do something with n. it is a u8 or a u16!
}

An improvement which I think is worth considering, in the spirit of rethinking these old, still relevant issues. But it doesn't solve the real problem, as we'll see in a moment.
Another, extremely related proposal is #6615, which proposed something like this:

fn Constraint(comptime T: type, predicate: fn (type) bool) type {
    if (!comptime predicate(T)) @compileError("wrong type");
    return T;
}

fn write2(w: Constraint(@TypeOf(w), isWriter), data: []const u8) void {
    w.write(data);
}

Additionally there were #7232 and #8008 (the latter of which was quite well thought out, proposing an @trait builtin. However. all these proposals were ultimately rejected:

There are a lot of problems with generic code. Generic code is harder to read, reason about, and optimize than code using concrete types. Even if it compiles successfully for one type, you may see errors only later when a user passes a different type. Generic code with type validation code has an even worse problem - the validation code has to match with the implementation when it changes, and there’s no way to validate that. So the position of Zig is that code using concrete types should be the primary focus and use case of the language, and we shouldn’t introduce extra complexity to make generics easier unless it provides new tools to solve these problems.

Here we come to understand that Zig's fundamental focus on clarity conflicts with generics, not that there is some problem with the proposal, really. I said the earlier example of zig-ctc felt like "should be a native language feature." I thought "hey, with some added features to Zig, this could be much more readable, and maybe also in the stdlib." But it's clear now that the zig maintainers don't want to solve that problem in the compiler to have to add a bunch of complexity to zig only to get a slightly more readable version of the exact same functionality. However, anytype is still in the language and it still is used for things other than anonymous tuples and it still sucks.

Distinct types offer a partial solution

A big part of the goal here is just to provide more readable function signatures. Yes, it would be nice to do checking in the function signature, but maybe it's enough to just provide aliases for anytype? Like a slightly better doc comment. Maybe anytype could be replaced by something like anontuple and then only allow anytype when defining type aliases. Consider #5132 (typedef for zig) and #1595 (distinct types).

Do something!

anytype is a problem when used for anything other than anonymous tuples in string formatting. Is the best answer the zig language can give doc comments? We need to come up with some functionality which actually improves the experience of writing type-constrained generic code. Something that solves problems like the type checking code buried in applyArgs.

Conclusion/Suggestion

It doesn't make sense to say "generic code is bad, its hard to reason about, so we will not add facilities to make it easier because we don't want to encourage its use" when anytype still exists and still gets used. It seems to me that people would much rather write generic code with bad facilities like anytype and deferred type-constraining logic than maintain multiple well-tested but highly duplicated implementations of the same thing. Anything short of removing comptime T: type and anytype seems unable to stop this.

Maybe the reasons we have for anytype being bad are enough to warrant reconsidering the previously closed issues like #1669? The fact that constraints on anytype are evaluated later, and not at the function where they're passed in, leads to confusing error messages. Additionally, it makes the function signature harder to read, and obfuscates information from tooling like ZLS.

My only original suggestion is that Zig could automatically generate tests for functions with anytype or comptime T: type or equivalent. These tests would only test compilation of the function with all types, not actual functionality. It would require some new builtin @constrainedTypeCompileError or something along those lines, to skip tests for types you know shouldn't work with the function. Doing such an out-of-language solution means sort of giving up and saying "there's no way to make a language with maintainable generic code, so let's just test most of the possible inputs to these bad functions." I am hoping that someone else can come up with something better.

Metadata

Metadata

Assignees

No one assigned

    Labels

    proposalThis issue suggests modifications. If it also has the "accepted" label then it is planned.

    Type

    No type

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions