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

tagged union switch prong capture doesn't coerce noreturn / uninstantiable field type #20187

Open
rohlem opened this issue Jun 4, 2024 · 3 comments
Labels
bug Observed behavior contradicts documented or intended behavior frontend Tokenization, parsing, AstGen, Sema, and Liveness.
Milestone

Comments

@rohlem
Copy link
Contributor

rohlem commented Jun 4, 2024

Zig Version

0.13.0-dev.365+332fbb4b0

Steps to Reproduce and Observed Behavior

In a tagged union type, uninstantiable field/payload types (such as noreturn, but also f.e. enum{}) signal that values of the union type will never be in these states (see also #15909 ).
As such, accesses to them are unreachable, including their switch prongs.

The issue is that when sharing a prong with capture between multiple cases, the compiler currently doesn't seem to perform the usual coercion logic that noreturn (and arguably all uninstantiable types) are never reachable, and so should coerce with values of all other types.
Test case:

test {
    var u: union(enum) { //tagged union type with an uninstantiable state payload type
        a: void,
        b: noreturn,
    } = .a;
    _ = &u;

    // if behavior, just for completeness
    var condition = true; //runtime condition to check type coercion logic in if
    _ = &condition;
    //const x = if(condition) "a" else 3; //proof this checks runtime coercion: compile error "incompatible types"
    //_ = x;
    const y = if (condition) u.a else u.b; //no compile error, coercion in if works
    _ = y;

    _ = switch (u) {
        .a => |p| p,
        .b => |p| p, //splitting the prongs is supported, the compiler generates this prong as unreachable
    };
    _ = switch (u) {
        .a, .b => {}, //shared prong without capture works
    };
    _ = switch (u) {
        .a, .b => |p| p, //shared prong with capture triggers "expected type 'void', found 'noreturn'"
    };
}

Output of zig test on the file:

main.zig:27:14: error: expected type 'void', found 'noreturn'
        .a, .b => |p| p, //non-inline shared prong with payload incorrectly triggers "expected type 'void', found 'noreturn'"
            ~^

Expected Behavior

The shared prong with capture should be allowed by the compiler by generating the cases with uninstantiable types as implicit unreachable, just as is done without capture.
This becomes useful when writing generic code, where a single switch in source code can handle values of various union types. A field can be instantiable for some types and not for others.

@rohlem rohlem added the bug Observed behavior contradicts documented or intended behavior label Jun 4, 2024
@nektro
Copy link
Contributor

nektro commented Jun 4, 2024

imo having a .b prong at all in this case should be a compile error for unreachable code

@rohlem
Copy link
Contributor Author

rohlem commented Jun 5, 2024

@nektro That would break the use case of generic code I mentioned in "Expected Behavior".
It also removes most benefits of even allowing uninstantiable tagged union fields in the first place, and goes against my use of the feature in a real project, as well as the rest of the given test case already working in status-quo.
See also discussion in #12462 , which implemented the accepted proposal #3257 (the predecessor of #15909 ).

@Vexu Vexu added the frontend Tokenization, parsing, AstGen, Sema, and Liveness. label Jun 5, 2024
@Vexu Vexu added this to the 0.14.0 milestone Jun 5, 2024
@rohlem
Copy link
Contributor Author

rohlem commented Jun 6, 2024

Here's my status-quo workaround until it's fixed if someone else needs it:

/// aka `noreturn`-like, aka `never` type
pub fn isTypeUninstantiable(comptime T: type) bool {
  return switch (@typeInfo(T)) {
    else => @compileError("TODO"),
    .Type, .Void, .Bool, .Int, .Float, .ComptimeFloat, .ComptimeInt, .Optional, .Fn, .Opaque, .EnumLiteral => false,
    .NoReturn => true,
    inline .Pointer, .Array, .Vector => |x| comptime isTypeUninstantiable(x.child), //note: We COULD categorize all 0-length arrays and vectors as instantiable... I'm not sure whether that's the sensible default though.
    .Struct => |s| inline for (s.fields) |field| {
      if (comptime isTypeUninstantiable(field.type)) break true;
    } else false,
    .Union => |u| inline for (u.fields) |field| {
      if (comptime !isTypeUninstantiable(field.type)) break false;
    } else true,
    .Undefined => @compileError("TODO (seems unlikely we want it, reconsider use case if encountered)"),
    .Null => @compileError("TODO (seems unlikely, but trivial to add if encountered)"),
    .ErrorUnion => |u| comptime (isTypeUninstantiable(u.error_set) and isTypeUninstantiable(u.payload)),
    .ErrorSet => |u| if (u) |e| (e.len == 0) else false,
    .Enum => |e| comptime isTypeUninstantiable(e.tag_type) or (e.is_exhaustive and (e.fields.len == 0)),
  };
}
pub fn InstantiableUnionStatesPayload(comptime Union: type, comptime states: []const @typeInfo(Union).Union.tag_type.?) ?type {
  const union_fields_info = @typeInfo(Union).Union.fields;
  comptime var found_type: ?type = null;
  comptime var next_union_field_index = 0;
  inline for(states) |state| {
    const state_name = @tagName(state);
    while (next_union_field_index < union_fields_info.len) {
      const next_union_field_info = union_fields_info[next_union_field_index];
      next_union_field_index += 1;
      //@compileLog(state_name, next_union_field_info.name, std.mem.eql(u8, next_union_field_info.name, state_name));
      if (!@import("std").mem.eql(u8, next_union_field_info.name, state_name)) continue;
      const field_type = next_union_field_info.type;
      if (comptime isTypeUninstantiable(field_type)) { // ignore this one, continue with the next state
        break;
      }
      
      if (found_type) |f| {
        if (f != field_type) @compileError("found differing field types: " ++ @typeName(f) ++ " != " ++ @typeName(field_type)); //alternative, if ever wanted: found_type = @TypeOf(f, field_type);
      } else found_type = field_type;
      break;
    } else @compileError("unreachable: argument 'states' is incorrectly ordered or contains duplicates"); //TODO: Check whether union_fields_info and states are both canonically sorted to provide a better error message.
  }
  return found_type;
}
pub fn instantiableUnionStatesPayload(union_instance: anytype, comptime states: []const @typeInfo(@TypeOf(union_instance)).Union.tag_type.?) (InstantiableUnionStatesPayload(@TypeOf(union_instance), states) orelse @compileError("all given states have uninstantiable payload types")) {
  const UnionTag = @typeInfo(@TypeOf(union_instance)).Union.tag_type.?;
  const instance_state_tag = @as(UnionTag, union_instance);
  inline for(states) |state| {
    if (instance_state_tag == state) return @field(union_instance, @tagName(state));
  } else unreachable; //the union instance is in none of the given states
}

test instantiableUnionStatesPayload {
    var u: union(enum) { //tagged union type with an uninstantiable state payload type
        a: void,
        b: noreturn,
    } = .a;
    _ = &u;
    _ = switch (u) {
        //.a, .b => |p| p, //shared prong with capture triggers "expected type 'void', found 'noreturn'"
        .a, .b => {
            const p = instantiableUnionStatesPayload(u, &.{.a, .b});
            _ = p;
        },
    };
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Observed behavior contradicts documented or intended behavior frontend Tokenization, parsing, AstGen, Sema, and Liveness.
Projects
None yet
Development

No branches or pull requests

3 participants