ziglang / zig

General-purpose programming language and toolchain for maintaining robust, optimal, and reusable software.
https://ziglang.org
MIT License
32.84k stars 2.4k forks source link

Proposal: Prevent @typeInfo and @TypeOf from triggering compile errors #6620

Open SpexGuy opened 3 years ago

SpexGuy commented 3 years ago

An important use case in Zig is the ability to load a module and use reflection to inspect the decarations it exposes. Unfortunately, it's also common to see modules that use pub const foo = @compileError(..); for deprecation, specialization, or other purposes like translation errors from translate-c. These two approaches are at odds with each other. This proposal suggests a way to keep the current use of @compileError and still inspect reflection data without triggering errors.

  1. For a basic fix, we need the ability to identify when a declaration fails to compile without actually failing the compile. To do this, we can add an element to TypeInfo with tag .CompileError.
  2. .CompileError should be an empty tag, because allowing the metaprogram to inspect the error string could cause difficult to find dependencies on compiler versions.
  3. We also need a special type @Type(.CompileError) which will be used in the decls of the type info of the containing struct.
  4. Creating a variable of type @Type(.CompileError) is a compile error.
  5. This would identify not just calls to @compileError but also declarations that are invalid for other reasons.
  6. Guarantee that calls to @TypeOf cannot cause a compile error. Instead, @TypeOf(<code that causes compile error>) returns .CompileError. This could be useful for a lot of use cases that are currently difficult, like
    • checking if an instance of one type can coerce to another type
    • checking if a function signature is compatible with a set of arguments
    • checking if a combination of argument types is valid for a given generic function

All of these use cases are technically solvable with just (1) through (5), but require the use of an intermediate struct whose type info can be inspected. This last point makes that use case a bit cleaner, and unifies the behaviors of "figuring out the type of a declaration for reflection" and "using @TypeOf".

Rocknest commented 3 years ago

By the way we already have noreturn.

noreturn is the type of: break, continue, return, unreachable, while (true) {}

@TypeOf(@compileError("")) == .NoReturn

jcmoyer commented 3 years ago

I ran into a sort of interesting use-case for this. Like many projects, I have a type for a runtime-sized list with a comptime-known maximum length. However, mine is extern (ideally packed, but that has broken codegen at the time of writing) so that I can embed these lists in a larger structure and then use read/writeStruct to serialize the entire thing. This simplifies memory management and has some performance benefits, notably reducing the number of reads/writes that need to be issued.

Today I saw #9134, which looks great and would ideally replace my own container type, but it returns a standard struct (that is, non-extern, non-packed). So it couldn't be used with read/writeStruct. I set out to see if I could change the layout, and it turns out you can do this today:

fn makeLayout(comptime T: type, new_layout: std.builtin.TypeInfo.ContainerLayout) type {
    if (@typeInfo(T) != .Struct) {
        @compileError("expected struct, got " ++ @tagName(@typeInfo(T)));
    }
    const struct_info = @typeInfo(T).Struct;
    return @Type(.{ .Struct = .{
        .layout = new_layout,
        .fields = struct_info.fields,
        .decls = struct_info.decls,
        .is_tuple = struct_info.is_tuple,
    } });
}

However, I quickly noticed that a couple types I tested this on failed to compile because it triggers the compile errors on deprecated decls. So while this would work initially, if deprecated members were ever added to ArrayListFixed, my code would suddenly fail to compile. Without a way to futureproof against this, it seems that I would be better off maintaining my own implementation.

InKryption commented 1 year ago

I am interested in seeing at least the @TypeOf(expr) == noreturn and @TypeOf(incompatible, types) == noreturn component of this proposal realized.

In particular, being able to check whether two types can have peer type resolution performed on them would be very useful for being able to construct more complex type check error messages.

I have a particular use case, in wanting to modify std.hash_map.verifyContext: currently, it has hard checks, such that for adapted contexts, it will issue a compile error even if the inputted key can coerce to the expected PseudoKey (e.g. *const [n:0]u8, []u8, [:0]const u8, aren't accepted in map.getOrPutAdapted(string_literal, Adapted), where Adapted accepts []const u8 as PseudoKeys).

One way to solve that problem would be to just remove the checks, but then the resulting error message in the case where the types really aren't compatible, become much less useful than they currently are. The second way would be to implement a function in userland that checks whether the types are peer-type-resolvable, but that adds extra maintenance burden. The third, and imo simplest way to address this would be to allow @TypeOf given multiple values to return null, or noreturn, or whatever else.

I have a branch with the basic necessary modifications to Sema for the third option, and I would be happy to go through and update everything else if given the go-ahead.

nektro commented 1 year ago

Using .NoReturn for this case doesn't make sense because it represents an expression that causes a runtime exit of the target artifact. whereas .CompileError would mean an expression that halts compilation and cannot be allowed to exist in runtime or comptime.

one other solution to @TypeOf(incompatible, types) is also to modify it to return ?type instead of modifying std.builtin.Type.

InKryption commented 1 year ago

Making @TypeOf(incompatible, types) == null is actually what I've done in my branch. The bigger question becomes what do do with @TypeOf(val). It would be consistent to have it also return null, but that would then mean all code that uses it and wants to just assume current behavior has to be changed to things like fn foo(a: anytype, b: @TypeOf(a).?) void. It's not the worst, but it does seem odd to require for such a common use case.

nektro commented 1 year ago

well for that case in the meantime they would have to add .? but long term im hopeful for https://github.com/ziglang/zig/issues/9260 which would change that to fn foo(a: infer A, b: infer B) void or fn foo(a: infer T, b: T) void

InKryption commented 1 year ago

Another idea that just came to mind would be to make it return a specific type - either a primitive (compilationerror), or a declaration in std.builtin (e.g. std.builtin.CompileError) - whose only purpose is to be returned from a @TypeOf call that's given invalid peer types. This would allow essentially all code to remain as it is, but adds the ability to do things like @TypeOf(<expr>, ...) == std.builtin.CompileError.

mnemnion commented 1 month ago

A minimal fix here might be to add a .type field to Declarations.

There might be a good reason why this isn't already the case, but if not, this would allow introspection of container types, without taking a field access on them, and triggering a compile error. Whether a decl const oops = @compileError("do not touch!"); should have a special CompileError type, or just NoReturn, seems less important than providing a way to get at that type without a field access.