Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
SpexGuy opened this issue Oct 9, 2020 · 10 comments
Open
Labels
proposal This issue suggests modifications. If it also has the "accepted" label then it is planned.
Milestone

Comments

@SpexGuy
Copy link
Contributor

SpexGuy commented Oct 9, 2020

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".

@Vexu Vexu added the proposal This issue suggests modifications. If it also has the "accepted" label then it is planned. label Oct 9, 2020
@Vexu Vexu added this to the 0.8.0 milestone Oct 9, 2020
@Rocknest
Copy link
Contributor

Rocknest commented Oct 9, 2020

By the way we already have noreturn.

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

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

@andrewrk andrewrk modified the milestones: 0.8.0, 0.9.0 May 19, 2021
@jcmoyer
Copy link
Contributor

jcmoyer commented Jun 17, 2021

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.

@andrewrk andrewrk modified the milestones: 0.9.0, 0.10.0 Nov 23, 2021
@andrewrk andrewrk modified the milestones: 0.10.0, 0.11.0 Apr 16, 2022
@InKryption
Copy link
Contributor

InKryption commented Mar 16, 2023

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
Copy link
Contributor

nektro commented Mar 16, 2023

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
Copy link
Contributor

InKryption commented Mar 16, 2023

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
Copy link
Contributor

nektro commented Mar 16, 2023

well for that case in the meantime they would have to add .? but long term im hopeful for #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
Copy link
Contributor

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
Copy link

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.

@TheHonestHare
Copy link

It sounds like the @TypeOf builtin would be overloaded too much

  • @TypeOf(single) returns what is says: the type of single, which could be a "compile error" type
  • @TypeOf(multiple, types) returns the peer type of its operands, or returning a "compile error" type if that's impossible, OR returning a "compile error" type if one of the operands would lead to a compile error

It's fundamentally 2 different behaviours wrapped into 1 builtin. Also, notice how one can't tell the difference between "these 2 types don't have a peer" and "the peer type is a compile error"

What if a new builtin, @PeerTypeOf(...) ?type (name could be changed), was introduced, that returns the peer type of its operands, or null if that's impossible, or returning a "compile error" type if one of its operands would lead to a compile error.

Apologies that this is basically reposting #3641, but since this new @TypeOf behaviour of checking for compile error would sort of allow a userland version of @PeerTypeOf anyways I thought it would be relevant.

@mnemnion
Copy link

There was a recent discussion, a short one, on Ziggit, which touches on this issue. Mostly I wanted to point out that the issue this is tracking relates to "bottom types", and the ambiguity and differences of approach to it can be clarified by bringing that concept into focus.

In sound type systems, Bottom is uninhabited: if type inference derives bottom, then compiling fails. Zig is not interested in building a sound type system, I'm sure, that would make for a very different language. But it's consequential for this discussion, because noreturn is an inhabited bottom type, meaning the actual type of runtime code which compiles just fine. Like it says on the label, you can't return from it, and the documentation explains two consequence of this: it's the realized type of certain expressions, and it also coerces to any other type, so that in the not-uncommon case of two conditional branches, one of which panics, exits, or otherwise won't return a value, the resulting type of that conditional is the one which allows the program to continue. I believe that makes it problematic as the return type of @TypeOf(A,B) for incompatible types, since it implies that for two incompatible types A and B, @TypeOf(@TypeOf(A,B),C)) would be C. But as soon as code is emitted based on that conclusion, that code won't compile.

An answer of null would improve on the current compile error, but is unsatisfactory in other ways. It would pollute every use of two-argument @TypeOf, and it would mean a separate path for incompatible types: that is correct for runtime code, but I don't see it as ideal for type reflection.

Nektro raises a good point about the difference between CompileError and NoReturn, namely that CompileError is the comptime 'no return', and NoReturn is the runtime noreturn. I don't see either as suitable for incompatible peer coercion. Since we're facing the difficulty posed by an inhabited bottom type, a good solution is what other type systems where bottom is inhabited do, which is to have an other bottom type, the empty type, which is like noreturn except peer coercion of Empty and T produces Empty, not T. Empty is when you have an inhabited Bottom type and also need an uninhabited one: that's what we're seeing here.

It doesn't have to have a type-theoretic name like Empty or Bottom, I'd say that Type.NoType is most like the rest of Zig's type system. I do think we need three types here, because NoType isn't even an error, it's informative, telling comptime reflection that it can't emit code which expects the two types to coerce to a common type.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
proposal This issue suggests modifications. If it also has the "accepted" label then it is planned.
Projects
None yet
Development

No branches or pull requests

9 participants