Description
Consider:
switch (something) {
case A(a: B(b: C(c: D(d: true)): print('held true');
case A(a: B(b: C(c: D(d: false)): print('held true');
}
So we've got two patterns that are deeply recursing into some object and destructuring it. The current proposal says that each of these named field destructurers like a:
and b:
are compiled to calling getters on the matched value. There are two problems here:
-
It's potentially slow. Say the first case doesn't match because
d:
isfalse
. Then we have to try the second case. That means doing the samea
,b
,c
, andd
getter calls all over again. Those can be arbitrarily complex or slow operations. -
It defeats exhaustiveness checking. Since each case re-evaluates those getters, there's no guarantee that they are actually reliably stable and return the same result on each invocation. This means it's not safe to rely on these values for exhaustiveness checking. A contrived getter could return differing values such that even an apparently exhaustive set of cases never actually matches one.
For example, you might expect this to be soundly exhaustive:
var n = switch (something) { case Bitbox(b: true): 1; case Bitbox(b: false): 2; }
But Bitbox could be defined like:
class Bitbox { bool get b => Random().nextBool(); }
I think we can solve both of those by specifying that a switch caches the result of any destructuring method call. Here's a sketch:
A known value is a pair of an object and a destructuring path. A destructuring path is a (possibly empty) series of method calls that led to producing that object. For example, in this pattern:
switch (2) {
case (named: ([{'key': 3})
}
The destructuring path to 3
is [".named", ".field0", "['key']"]
. (It's not directly visible, but list patterns also call length
on the list, and that gets cached too.)
The current path when evaluating a subpattern is the series of method calls that led to the value being matched by this subpattern. For the outermost pattern, the current path is empty.
- The initial value matched by the switch is added to the set of known values with an empty path.
- Before a pattern calls a method on the matched value, look for
[currentPath..., method]
in the set of known values. If found, use that value instead and don't call the method. Otherwise, call the method and store the result in the set of known values with that path. - When recursing into a subpattern with a subvalue, append the method used to acquire that subvalue to the current path.
The set of methods called by patterns is:
- Named getters and
field_n_
getters for record and extractor patterns. length
and the[]
operator with constant int indexes for list patterns.- The
[]
operator with constant key arguments for map patterns. - The
==
operator for literal and constant patterns with a constant value for the LHS argument.
This definitely adds some complexity to how patterns are compiled and executed, but I think it's worth it. I'm interested in other approaches.
Thoughts?