`anyerror` is a bad practice

Here’s a thought. One could write down a generic function

fn checkLike(real: type, dummy: type) void {
    // requires implementation
    // either no-op or produces informative compileError 
}    

that would allow your code to be replaced with this:

checkLike(Context, struct { // must be a struct
    fn hash(self: Context, k: K) u64 { // must have a function with this signature
        @compileError("checking only"); // functions are never called by checkLike
    }
    fn eq(self: Context, k1: K, k2: K) bool {
        @compileError("checking only");
    }
});

This isn’t quite the same, because it doesn’t allow self to be a pointer, but there may be ways around that.

The term ‘voidspace’ is not the point - it was a (slightly unserious) aid to exposition. I think it did improve the clarity of my bullet points.

What I’m saying is this. I think that using namespacing and decls you can already achieve a similar level of efficiency and clarity in terms of type-management as you could using with and infer. In fact, if you have multiple functions that have some type management in common, the namespacing+decls strategy starts looking clearer and more efficient.

It seems to me like your example discusses a separate point, it does nothing to separate the type-calculation/checking from the value that uses those types, which is worse if we want to display some of that type-checking logic to the programmer via lsp, it also keeps anytype as this special case it currently is.

I think in a discussion about status quo your post would make more sense, but here the question is whether there is some construct that would make writing and reading certain things better.

I think another reason why

doesn’t quite fit (to the discussion about whether with/infer would be useful) is that you could do the same thing with with to have it apply to multiple functions:

const V = with {
    T:infer type,
    const R = MyTypeCalculatingFunction(T);
    // do some type checking and explaining that results in nice lsp info
    // and error reporting without increasingly more noisy syntax clutter
} struct {
    fn vfunc(val: T) R {
        var res: R = undefined;
        for(std.meta.fields(R)) |f| @field(res, f.name) = .empty;
        return res;
    }
    ... // more functions
};

But with this syntax you get some things you don’t get without it:

  • inferred vs not uses syntax that is similar so it is easy to switch between needing to pass types explicitly or not and switching between those doesn’t change the signature of the inner function that is called, which makes it easy to move those functions between places where the type is inferred or explicit
  • lsp info and type checking have their own syntactic zone making it easier for people to see what is required by a type (provided they actually place their type checks/construction of dependent types in the with)
  • generics don’t require more levels of indentation (this is a small benefit but I think it might be good)
  • you don’t need a bunch of wrappers and helpers / or repeat a lot of things
    you can directly write V.vfunc(myval) because T is inferred
  • editors could show you the with as context information making those variables more visible and also display what type was inferred
  • if all with parameters can be inferred you don’t have to specify them, so it becomes an alternative to anytype that also comes with zig code that describes what is allowed for the type (which seems to be the biggest complaint about anytype)

One problem in current Zig is that you can’t use a file struct when it is supposed to be a generic. With with we could have a syntactic rule that it can appear as the first non-comment in a file, which would mean that the with applies to the file-struct, thus we could define and use file-structs that are generics.


I think to really know whether this could be worth it, you would have to change a significantly big codebase towards this and then see whether it brings any further benefits, or creates its own problems.

For example I wonder whether separating comptime and runtime parameters could allow to add additional tooling for example @compileLog (or something similar) that would also display all the context of comptime parameters the value depends on.

Technically I think you could do this with status quo Zig, but it might make it clearer when you can show types in a way where all the dependency information is bundled in the with, which basically serves as a bundle of preconditions for the construction of the type/function/value, with current Zig you always have everything intermixed and it can be sometimes beneficial (if certain conditions are close to runtime things using it) and sometimes a negative (when you have to search the entire type (including nested types) to understand all type requirements).

2 Likes

Agreed.

I see your point and agree.
But a bear that wants to behave like a duck maybe can’t behave like a duck, hence the comptime check.
E.g. if I write some generic function, I’ll check at comptime if the attributes/functions I need are present in the struct: you can be a bear and use that API, but I also check that you are not going to shoot yourself in the foot by accidentally trying to do something you are not able to do. This leads me to ask: is there really a downside in enforcing this behavior other than more code need to be written? I can’t think of any.

Also there’s value in forcing a dev to think what functions/attributes/properties he expects the args to have, and in my (limited) experience leads to better code.

One could also argue that it’s not really the job of the language to enforce this kind of behavior.

Btw the more I look at the with construct, the more I like it.
E.g. std.debug.print which I believe to be the perfect use-case for anytype:

with {
     const TypeInfo = @typeInfo(@TypeOf(params));
     std.debug.assert(TypeInfo.is_tuple);
     // Checks if `params` matches type identifier in `fmt`.
} pub fn print(fmt: comptime []const u8, params: T) void {
     // Implementation details.
}

Which favours readability, intent communication and separation of concers IMHO, plus solves any DX issues with being forced to pass generic types explicitly as an alternative to anytype, which in cases like std.debug.print would just suck.
Did you image with’s scope to be comptime?

I’ve tried to do some simple encapsulation of the Context pattern. I haven’t figured out how to handle generic function declarations yet, so for now I simply don’t support all requirements for generic function declarations, which might lead to some inference errors set not being judged as expected.

pub fn noCallingConvertionRestriction(comptime calling_convertion: std.builtin.CallingConvention) bool {
    _ = calling_convertion;
    return true;
}

pub const FnOrMethodDeclaration = union(enum) {
    // I haven't yet figured out how to use generic function declarations,
    // so for now I'm adopting a simple "disallow generics" strategy.
    // This could potentially cause function declarations using the inference error set to fail the checks.
    @"fn": struct {
        name: [:0]const u8,
        callingConvertionRestrictionFn: fn (comptime calling_convertion: std.builtin.CallingConvention) bool = noCallingConvertionRestriction,
        is_var_args: ?bool = false,
        return_type: type,
        params: []const Param,
    },
    method: struct {
        name: [:0]const u8,
        callingConvertionRestrictionFn: fn (comptime calling_convertion: std.builtin.CallingConvention) bool = noCallingConvertionRestriction,
        // `null` means do not care whether it is var args.
        is_var_args: ?bool = false,
        return_type: type,
        params_except_self: []const Param,
    },
    pub const Param = struct {
        // `null` means do not care whether it is noalias.
        is_noalias: ?bool = null,
        type: type,
    };
};

pub fn validateContextWithFnOrMethodDeclarations(
    comptime Context: type,
    comptime allowed_container_types_option: struct {
        @"struct": bool = false,
        @"enum": bool = false,
        @"union": bool = false,
        @"opaque": bool = false,
    },
    comptime expected_fn_or_method_declarations_info: []const FnOrMethodDeclaration,
) void {
    switch (@typeInfo(Context)) {
        .@"struct" => if (!allowed_container_types_option.@"struct") @compileError(std.fmt.comptimePrint("`{s}` is not allowed to be a `struct`", .{@typeName(Context)})),
        .@"enum" => if (!allowed_container_types_option.@"enum") @compileError(std.fmt.comptimePrint("`{s}` is not allowed to be an `enum`", .{@typeName(Context)})),
        .@"union" => if (!allowed_container_types_option.@"union") @compileError(std.fmt.comptimePrint("`{s}` is not allowed to be a `union`", .{@typeName(Context)})),
        .@"opaque" => if (!allowed_container_types_option.@"opaque") @compileError(std.fmt.comptimePrint("`{s}` is not allowed to be an `opaque`", .{@typeName(Context)})),
        else => @compileError(std.fmt.comptimePrint("`{s}` must be a container", .{@typeName(Context)})),
    }
    for (expected_fn_or_method_declarations_info) |expected_fn_or_method_declaration_info| {
        const name: [:0]const u8, const callingConvertionRestrictionFn: fn (comptime calling_convertion: std.builtin.CallingConvention) bool, const maybe_expected_is_var_args: ?bool, const ExpectedReturnType: type, const params_except_self_start_from: usize, const expected_params_except_self_info = blk: {
            switch (expected_fn_or_method_declaration_info) {
                .@"fn" => |@"fn"| break :blk .{ @"fn".name, @"fn".callingConvertionRestrictionFn, @"fn".is_var_args, @"fn".return_type, 0, @"fn".params },
                .method => |method| break :blk .{ method.name, method.callingConvertionRestrictionFn, method.is_var_args, method.return_type, 1, method.params_except_self },
            }
        };
        if (!@hasDecl(Context, name)) @compileError(std.fmt.comptimePrint("`{s}.{s}` does exist", .{ @typeName(Context), name }));
        const decl_type_info: std.builtin.Type.Fn = blk: switch (@typeInfo(@TypeOf(@field(Context, name)))) {
            .@"fn" => |decl_type_info| break :blk decl_type_info,
            else => @compileError(std.fmt.comptimePrint("`{s}.{s}` is not a function", .{ @typeName(Context), name })),
        };
        if (decl_type_info.is_generic) @compileError(std.fmt.comptimePrint("`{s}.{s}` does not support generic yet.", .{ @typeName(Context), name }));
        if (!callingConvertionRestrictionFn(decl_type_info.calling_convention)) return false;
        if (maybe_expected_is_var_args) |expected_is_var_args| {
            if (expected_is_var_args != decl_type_info.is_var_args) @compileError(std.fmt.comptimePrint(
                "`{s}.{s}` should {s}be var args",
                .{ @typeName(Context), name, if (expected_is_var_args) "" else "not " },
            ));
        }
        if (decl_type_info.return_type) |ReturnType| {
            if (ReturnType != ExpectedReturnType) @compileError(std.fmt.comptimePrint("The return type of `{s}.{s}` is not `{s}`.", .{ @typeName(Context), name, @typeName(ExpectedReturnType) }));
        } else @compileError(std.fmt.comptimePrint("`{s}.{s}` does not support generic yet.", .{ @typeName(Context), name }));
        const expected_params_num: usize = expected_params_except_self_info.len + params_except_self_start_from;
        if (decl_type_info.params.len != expected_params_num) @compileError(std.fmt.comptimePrint("The params num of `{s}.{s}` is not `{d}`.", .{ @typeName(Context), name, expected_params_num }));
        switch (expected_fn_or_method_declaration_info) {
            .@"fn" => {},
            .method => {
                const SelfHandle: type = if (decl_type_info.params[0].type) |S| S else @compileError(std.fmt.comptimePrint("`{s}.{s}` does not support generic yet.", .{ @typeName(Context), name }));
                switch (@typeInfo(SelfHandle)) {
                    .pointer => |SelfPtr| switch (SelfPtr.size) {
                        .one => if (SelfPtr.child != Context) @compileError(std.fmt.comptimePrint("`{s}.{s}` is not a method.", .{ @typeName(Context), name })),
                        .many, .slice, .c => @compileError(std.fmt.comptimePrint("`{s}.{s}` is not a method.", .{ @typeName(Context), name })),
                    },
                    else => if (SelfHandle != Context) @compileError(std.fmt.comptimePrint("`{s}.{s}` is not a method.", .{ @typeName(Context), name })),
                }
            },
        }
        const LoggerHelper = struct {
            fn printOrdinal(comptime id: usize) []const u8 {
                return switch (id) {
                    0 => "1st",
                    1 => "2nd",
                    2 => "3rd",
                    else => std.fmt.comptimePrint("{d}th", .{id + 1}),
                };
            }
        };
        for (
            decl_type_info.params[params_except_self_start_from..],
            expected_params_except_self_info,
            params_except_self_start_from..,
        ) |
            param,
            expected_param_info,
            param_id,
        | {
            if (expected_param_info.is_noalias) |expected_is_noalias| {
                if (param.is_noalias != expected_is_noalias) @compileError(std.fmt.comptimePrint(
                    "The {s} param of `{s}.{s}` should {s}be noalias",
                    .{ LoggerHelper.printOrdinal(param_id), @typeName(Context), name, if (expected_is_noalias) "" else "not " },
                ));
            }
            if (param.type) |ParamType| {
                if (ParamType != expected_param_info.type) @compileError(std.fmt.comptimePrint("The {s} param of `{s}.{s}` is not `{s}`", LoggerHelper.printOrdinal(param_id), @typeName(Context), name, @typeName(expected_param_info.type)));
            } else @compileError(std.fmt.comptimePrint("`{s}.{s}` does not support generic yet.", .{ @typeName(Context), name }));
        }
    }
}

After using the above encapsulation, verifying the Context pattern becomes much simpler:

pub fn HashMapUnmanaged(
    comptime K: type,
    comptime V: type,
    comptime Context: type,
    comptime max_load_percentage: u64,
) R: {
    validateContextWithFnOrMethodDeclarations(Context, .{ .@"struct" = true }, &[_]FnOrMethodDeclaration{
        .{ .method = .{
            .name = "hash",
            .return_type = u64,
            .params_except_self = &[_]FnOrMethodDeclaration.Param{.{ .type = K }},
        } },
        .{ .method = .{
            .name = "eql",
            .return_type = bool,
            .params_except_self = &[_]FnOrMethodDeclaration.Param{ .{ .type = K }, .{ .type = K } },
        } },
    });
    break :R type;
} {
...
}

It would be nice to be able to define custom comptime types that work just like anytype except with comptime validation code attached. For example:

const anyinteger = @CustomType(struct {
    validate(T: type) void {
        if(@typeInfo(T) != .Int and @typeInfo(T) != .ComptimeInt) {
            @compileError("Not an integer", T);
        }
    }
});

fn half(x: anyinteger) @TypeOf(x) {
    return x >> 1;
}

That’s just a sketch, maybe it would be possible to support an arbitrarily complex type system as a comptime library if that’s what someone would like to have.

So predefined constraints…
It’s not quite the same, but you can use comptime functions for this currently.

And with the syntax from earlier

const isInt = with {
    T: type,
    const info = @typeInfo(T);
} info == .int or info == .comptime_int;

//...

with {
    T: infer type,
    assert(isInt{T});
} fn half(x: T) T {
    return x >> 1;
}

I think zig would much prefer the constraints to be visible, which is not the case when they are part of the type.

2 Likes