-
Notifications
You must be signed in to change notification settings - Fork 213
How should we handle repeated calls to the same methods in pattern matching? #2107
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
Comments
Do we actually have to support getters? Maybe we could limit ourselves to destructurring only "primitive" properties: meaning it must to be a field. We can't guarantee this now within a language, but maybe that should be a feature included first, e.g. it has to be a non-virtual/sealed field or a type of object that can have its fields overriden with getters. If class fields are virtual then I think a good alternative is to have "decomposition" method that returns a primitive object (e.g. a tuple/record/data class) in the same way as Scala |
I will really prefer not to introduce a new way to inspect objects. Getters is how you deconstruct objects. They already exist, and work, and they allow you to control which properties of our object are publicly visible, while hiding the implementation. The one potential feature that gets close to non-virtual fields is stable getters, #1518. Instead of special casing fields, it special-cases some of the getters. Not a feature I'm particularly fond of, I think it's too specialized and requires opt-in from people who are not in a position to know whether it's necessary, but it's better than breaking getter/field symmetry. So I want to support getters. Supporting anything on top of that is ... possible, but not really desirable. I'd be fine with a switch being allowed/required to cache access paths. I expect the switch cases to be deterministically checked from top to bottom, so it's detectable, if not predictable, which reads and checks have been done already. 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');
} The first line does:
The second line can then be completely naive and do:
or it can optimize and recognize shared patterns and just do
or even inline it in the first sequence after That's all optimizations, the point is that it never reads the same getter twice, based on "access path" (selector chain), as @munificent writes. Only access selectors, meaning getters, but treating I'm not sure I want to cache method calls, because that requires also determining if it's "the same" arguments. That's probably an identity check (only way to be sure it really behaves the same), which breaks with patterns otherwise using case C(c:D(d:var foo)) if (foo.contains("bar)): ... I think that's something we can meaningfully specify, and can implement fairly efficiently (and then optimize the crap out of), |
We could, but I think it is so so useful to be able to call any getter in a named record destructuring. It means that once we add this syntax, the entire universe of existing Dart getters can now be accessed through it. You could immediately be able to write code like: for (var (key:, value:) in someMap.entries) {
print('$key: $value');
}
var (hour:, minute:, second:) = someDateTime;
print('$hour:$minute:$second');
switch (someUri) {
case (scheme: 'http', path:): print('HTTP GET $path');
case (scheme: 'https', path:): print('HTTPS GET $path');
case (scheme: 'ftp', path:): print('FTP $path');
} Etc. This to me is a huge part of the value proposition of the feature.
I don't think there are currently any patterns that would require executing a
I believe it's both safe and important to support this. I think the only method calls needed are Yes, a particularly weird user-defined class could have a const constructor and a If that's really a problem, we could keep the same restriction as |
@lrhn wrote:
I'd like to make sure that the stable getters feature is understood the way it was intended, so I have to add some comments to this characterization. I hope I don't sound too grumpy, but a tiny bit of grump seems to be OK. ;-) The idea is that being immutable is a fundamental and API-worthy part of the declaration of a getter (any getter, be it an implicitly induced getter that comes with an instance variable declaration, or a getter declared with That's the reason why I have to respond to this:
So 'stable getters' makes it a language property. It can be detected by human beings and compilers alike, and both can use this information when they use the getter. In particular, when a getter is stable, it is safe to evaluate it several times, and reason about the code based on the assumption that it returns the same result every time it's called on the same receiver (or it throws, but it never returns different results). This is obviously crucial for the correctness of a large percentage of the source code that we're all reading and/or writing every single day. It just happens to be a property that we need, but currently can't know. We can only assume that we don't have to write tricky code to handle the situation where the value actually changed in the middle of an algorithm, and then we don't generally do that, and then it generally works, because lots of getters are stable. In practice. Just like null safety, this is a property which can be made precise and sound. 'Stable getters' is a proposal for doing just that. Just like null safety, we will be able to improve on the correctness of our software when we know which cases are trivial ("this is a stable getter, so I can just go ahead and assume that it won't change while I'm using it"), and then we can put extra effort into the remaining ones ("this getter is not stable, so I have to check it 5 times during the execution of this algorithm, and I have to do some tricky recovery work if it actually changes"). Just like null safety, a major point is that lots of references aren't null. Similarly, lots of getters are actually stable. So we could just assume that null never occurs, and the results returned by a getter will never change. But null safety was introduced because we assumed that it's better to have a precise and sound analysis, and then handle the nulls that we may actually encounter explicitly and exhaustively. This brings down the number of bugs. So, returning to one claim:
That's not a fair description. It equips every single getter with an API/type-like level property which is: This getter is stable, or this getter is not stable. No special cases, only a property that we will strictly (soundly) enforce, if the given declaration says so. It's just like
It is an integral part of the design of an abstraction (like an abstract class, or a concrete one for that matter, because they can also have any number of subtypes) whether any given getter in the interface is stable, because
In other words, stable getters vs. non-stable getters are similar to non-nullable expressions vs. expressions that may yield null: With a stable getter, we don't have to worry about changes to the value of the getter at arbitrary points in time, and with the non-nullable expression we don't have to worry about null. With a getter that isn't stable, and with a nullable expression, we need to write extra code that handles the changes at arbitrary times and the value null, just in case they occur. Saying that nobody would know whether a given getter should be designed to be stable is similar to saying that nobody knows which return type to use with a getter: Some subtypes might need to return something else, so we had better use the return type I believe that it is good for the software engineering properties (like readability, maintainability, correctness, and more) that we declare types as a matter of API design, not as an implementation detail that unfortunately leaked into the API. Similarly, immutability is an appropriate API design property that affects all clients of the given declaration, it is not an implementation detail. Having stable getters is of course a building block for other notions of immutability: We could include strict rules such that no stable getter can have side effects. (I do think that we shouldn't do that, because we'd want to allow caching, but I also specified that developers cannot rely on having a stable getter executed at run time if it was already executed on the same receiver, because it's OK for the compiler to cache it). Immutability for objects in total could be expressed by requiring that every getter is stable. On top of this, it's worth noting that a stable getter is known by the compiler to be stable, which means that stable expressions can be promoted (if
A non-virtual field wouldn't actually suffice: A? anA;
class A {
int? i = 1; // Let's say this one is non-virtual, so the compiler knows that it isn't overridden.
A() {
anA = this;
}
}
void innocentFunction() => anA?.i = null;
void main() {
var a = A();
if (a.i != null) {
innocentFunction();
print(a.i!.isEven); // Throws, so the compiler _must_ require a null-aware construct like `!` or `?.`.
}
} |
I think you are at an acceptable level of grump-ness. :D For what it's worth, I am personally interested in having field/getter immutability visible as a static property of the language specifically for the reason you suggest: because it will allow us to promote some fields. And I think stable getters or a similar feature could be a way to enable that without breaking field/getter symmetry (a property I consider very important in the language). It's something I'd like to explore more if we get time to work on capability modifiers for members. At the same time, I don't think this is the right feature to rely on for pattern matching. Consider: class BoolBox {
bool b;
}
test(BoolBox box) {
switch (box) {
case Box(b: true): ...
case Box(b: false): ...
}
} I want users to be able to write code like this and I want the language to treat this set of cases as exhaustive even though Here's maybe a more compelling example. At some point, I'd like to support test(List<bool> bools) {
switch (bools) {
case []: ..
case [true, ...rest]: ...
case [false, ...rest]: ...
}
} Intuitively, this switch is exhaustive. There are either no elements, or the first element must be That's why I suggest caching the results of every member called on the matched value. This preserves the mental model users have by having all cases operate on the same view of the matched object. We incrementally build an immutable snapshot of just the properties of the object that are actually seen by the cases. This makes cases more efficient (since we don't re-evaluate the same calls multiple times), makes exhaustiveness work, and allows matching over all sorts of mutable objects. |
@munificent wrote:
As you mention, we'd ensure soundness by specifying that the entire matching process for one switch statement/expression would rely on evaluating each "path" (here: However, the developer who is reading or writing this kind of code might need to be aware of the fact that even in the body of the case for class BoolBox {
bool b;
}
test(BoolBox box) {
switch (box) {
case Box(b: true): ...
case Box(b: false):
if (!box.b) {
... // Normal code for the case where `b` is false.
} else {
... // Error recovery: `x` was modified during pattern matching!
}
}
} I think this problem can be at an acceptable level (for most non-critical pieces of software we can just say "that won't happen"), but I still find it useful to have the language mechanism of strict, language enforced immutability in order to eliminate the need to deal with those "concurrent updates". Then, in widely used code where some applications could be life-and-death critical, we could have lints on this case, such that the developers of that kind of code could maintain this particular kind of correctness by only matching on stable getters. |
Consider:
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:
andb:
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:
But Bitbox could be defined like:
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:
The destructuring path to
3
is[".named", ".field0", "['key']"]
. (It's not directly visible, but list patterns also calllength
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.
[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.The set of methods called by patterns is:
field_n_
getters for record and extractor patterns.length
and the[]
operator with constant int indexes for list patterns.[]
operator with constant key arguments for map patterns.==
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?
cc @leafpetersen
The text was updated successfully, but these errors were encountered: