ziglang / zig

General-purpose programming language and toolchain for maintaining robust, optimal, and reusable software.

Home Page:https://ziglang.org

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Proposal: maybe-unreachable switch cases

rohlem opened this issue · comments

In status-quo, Zig enforces all switch statements to (1) be exhaustive, and (2) (in the simple case) only include reachable cases.
These are both valuable properties that introduce friction in the form of compile errors on code changes, which point to to the places in code that are likely required to change.

However, requirement (2) that every case must be reachable (in the simple case) can hamper generic code (written to be compatible with a set of types),
because a case may be necessary for one type, but be unreachable for another.
I propose the option for source code to indicate such cases (via some special syntax, open to suggestions).
The compiler should allow cases marked this way to be statically-/always-unreachable based on the switch subject's type in any one instantiation.

Example:

// three example functions returning different error sets;
// note however that almost all switchable types can be affected: error sets, enums, unions, integers
fn faw() error{ A, W }!void {}
fn fal() error{ A, L }!void {}
fn fa() error{A}!void {}

///// (proposed:) a generic function that wants to be able to exhaustively deal with either error set
//fn errorMessage(e: anytype) []const u8 {
//  return switch (e) {
//    error.A => "error A: too tired",
//    // proposal: here I use `@?(expression)` as mock syntax, but I really don't care; it can be any symbol (`$`) or keyword (`maybeunreachable`) and as short or verbose as we want
//    @?error.W => "error: try again tomorrow (Windows-only)",
//    @?error.L => "error: try updating (Linux-only)",
//  };
//}
/// (status-quo:) demonstration of status-quo inefficiencies
fn errorMessageSQ(e: anytype) []const u8 {
  // If we change the argument error set to `anyerror`, we are forced to include an `else` clause, and give up all exhaustiveness checking.

  //disallowed by requirement (2) that all cases must be reachable
  //return switch(e) {
  //  error.A => "error A: too tired",
  //  error.W => "error: try again tomorrow (Windows-only)", //compile error for fal() and fa(), but compile error for faw() if we don't include it
  //  error.L => "error: try updating (Linux-only)", //compile error for faw() and fa(), but compile error for fal() if we don't include it
  //};

  // workaround: use if comparison
  if (e == error.W) return "error: try again tomorrow (Windows-only)";
  //if (e == error.L) return "error: try updating (Linux-only)";
  // workaround: convert using @errorCast, which isn't really the right tool for the job:
  // we won't get a compile error if a new error is added to the error set, and similarly only notice that we didn't handle error.L if we trigger the case at runtime.
  return switch (@as(error{A}, @errorCast(e))) {
    error.A => "error A: too tired",
  };
}

///// (proposed:) example of @?else ; @?_ for enums should also be supported
//fn criticalErrorMessage(e: anytype) ?[]const u8 {
//  return switch(e) {
//  error.A => "error A: too tired",
//  @?else => null, //allows all cases to be handled (for fa())
//  };
//}
/// (status-quo:) demonstration of status-quo inefficiencies
fn criticalErrorMessageSQ(e: anytype) ?[]const u8 {
  //disallowed by requirement (2) that all cases must be reachable
  //return switch(e) {
  //  error.A => "error A: too tired",
  //  else => null, //compile error for fa(), but compile error for faw() and fal() if we don't include it
  //};

  // workaround: use if comparison
  if (e == error.A) return "error A: too tired";
  if (e == error.W or e == error.L) return null;
  e catch unreachable; // no way to tell whether we've handled all cases until runtime.
}

test {
  const err = @import("std").log.err;
  if (faw()) |_| {} else |e| err("{s}", .{errorMessageSQ(e)});
  if (fal()) |_| {} else |e| err("{s}", .{errorMessageSQ(e)});
  if (fa()) |_| {} else |e| err("{s}", .{errorMessageSQ(e)});
  if (faw()) |_| {} else |e| if (criticalErrorMessageSQ(e)) |c| err("{s}", .{c});
  if (fal()) |_| {} else |e| if (criticalErrorMessageSQ(e)) |c| err("{s}", .{c});
  if (fa()) |_| {} else |e| if (criticalErrorMessageSQ(e)) |c| err("{s}", .{c});
}

Note: It's possible in status-quo to write helper functions in userland for optionally excluding errors from error sets/unions etc. .
These can then be used to construct code that is safe and has the same effect,
however that also involves additional if branching, use of these helper functions, and const temporaries;
essentially boilerplate that makes the code less readable than the proposed solution of directly supporting the use case in switch.

The main point of this proposal is to better communicate intent in generic code without introducing boilerplate which would hurt readability.