Skip to content

Case functions #148

@eernstg

Description

@eernstg

#145 states that there is a need to handle invocations with one-of-several types of parameters, such that a union type would match the needs pretty well, but no subtyping relation will specify just the acceptable types (example taken from #145):

void writeLogs(Object stringOrListOfString) {
  if (stringOrListOfString is String) {
    _writeLog(stringOrListOfString);
  } else if (stringOrListOfString is List<String>) {
    stringOrListOfString.forEach(_writeLog);
  } else {
    throw ArgumentError.value(stringOrListOfString, 'Not a String or List<String>');
  }
}

Several problems with this approach are discussed in #145, but lack of type safety is an obvious one.

We have considered a notion of case functions which could be used to handle such situations. Here is an example corresponding to the above:

void writeLogs(Object stringOrListOfString) {
  case(String s) => _writeLog(s);
  case(List<String> l) => l.forEach(_writeLog);
  default: throw ArgumentError.value(stringOrListOfString, 'Not a String or List<String>');
}

The point is that (1) this is a regular function, which means that it can be used in all the ways you'd expect for a function. The execution of the body visits the cases in declaration order and runs the first piece of code whose constraints are satisfied.

But, (2), it is also a set of specialized functions that static analysis can recognize, and if there is a call site where the statically known type of the argument list satisfies a case, then the static analysis will choose the first such case for the given call site, and code generation will generate an invocation of just that case (it's implicitly available as a separate function as well).

Each case may specify a return type, just like normal functions (and they may use async etc., if it is compatible with the signature of the enclosing function):

num foo(num n) {
  int case(int i) { return 2*i; } // Of course, the body can be a block.
  double case(double d) => d + 1;
}

main() {
  int i = foo(42); // No downcast.
  double d = foo(1.0); // No downcast.
  int j = foo(2.0); // Error.
}

If there is no default then statically checked invocations that do not match any case will be rejected (could be a warning or an error, whatever gets more support), and dynamic invocations which don't match a case will throw (ArgumentError is probably fine).

For a tear-off, a context type which is a supertype of a case would tear off that case. A context type which is a supertype of the signature of the whole function would tear off the whole function (again: with no default, and in a typed setting, that tear-off would be an error).

With sealed classes, there is no need to have a default because it is possible to write a complete list of cases, and this list can be checked for exhaustiveness (it's probably a bug to have an incomplete list), but it's of course possible to use non-leaf types in a sealed hierarchy in order to cover all possible types without listing every concrete type.

// Library L1.

// No subtypes of `A` can be declared outside L1, or its package, or whatever. ;-)
sealed class A { num get bar => 2.1; }
class B implements A { int get bar => 22; int baz(int i) => i; }
class C implements B { int get bar => 23; }

// Library L2.
import L1;

num getBar(A a) {
  int case(B b) => b.baz(b.bar);
  num case(A a) => a.bar;
  // No default needed because all of {A, B, C} are handled already.
}

An obvious complaint would be that it is error-prone to rely on the textual order of the cases (so we couldn't swap the case for A and B above, because then the case for B would be dead code), because it introduces the possibility that static analysis will choose a specific case, but then at run time the actual object will be some subtype that implements several types (including some cases which are earlier in the list).

But I think that the safety belts already in place will make that problem manageable. For instance, with a sealed type hierarchy it's easy to enforce that the dynamic and the static behavior are precisely identical; and with near-bottom types like int and String, there cannot be any subtypes so we get the same guarantee for free, and the upcoming value types are likely to offer similar guarantees.

It would probably be rather easy to extend case functions with matching on constants (aka literal types):

void baz(num n) {
  case(42) => print("Woohoo!");
  case(41) => print("Almost there!");
  case(int i) => print("OK, $i will do.");
  // etc.
}

With call sites where the literal type can be resolved, this could give rise to extensive inlining and other opportunities for optimization.

Metadata

Metadata

Assignees

No one assigned

    Labels

    featureProposed language feature that solves one or more problems

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions