-
Notifications
You must be signed in to change notification settings - Fork 213
Problem: contravariant return types on instance members interact badly with covariant generics #1137
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
My gut reaction is that I would prefer to avoid weakening the runtime checking based on surrounding code unless the patterns are very narrow. I like that I can fully trust the static types, even the inferred ones. |
I don't have much to contribute here on the theoretical side, but I very much appreciate that we are investigating this. |
I like this approach in part because there's no performance or safety cost. In cases where a caller-side check has to be inserted, I think it's very unlikely that users today realize they are paying for it and that it can fail. So in some sense, the cognitive load is already there, they just don't know that they don't know it.
This reminds me of the loosening we do for tear-offs with covariant parameter types, so doesn't seem unreasonable to me. But it's kind of weird and magical.
This sounds like we'd end up playing an endless game of use case whack-a-mole.
I agree it's a real challenge. The cognitive load of generics is already quite high and most users only have an informal understanding of them. One aspect I very much like about declaration-site variance is that it shifts most of the cognitive burden off consumers of generic classes and onto their authors, who are more likely to have the sophistication to know how to use them. (This is in contrast to Java generics where they bleed out of the API into the consumer's code in weird ways.) An author can provide a generic class to users that Just Works™ in that it is sound and reports compile errors if users try to use it in invalid ways. In other words, the class author can define the rails and the user just has to stay on them. Users may need some expertise to understand those errors, but my experience is that they are pretty rare. Most generic classes are used in completely invariant ways. C# had zero support for anything but invariance for several years and it was surprisingly pain-free. |
This is largely my experience. Generally in my experience invariance is not confusing to people at all. If the thing says it takes a Sound covariance does have something of a cognitive load for people writing/implementing classes. That is, there is a load on the producer, because they have to write their API signatures to be valid covariant signatures. However, the load on end users is, conversely fairly low. It's no worse (and in fact better) than the experience of end users with unsound covariance. Unsound covariance relieves some of the cognitive load for class implementers because they can just write their classes any way they want. This comes at the expense of pushing more cognitive load onto end users though. Whereas with sound covariance everything just works for the end user, with unsound covariance they can hit unexpected runtime errors because they are "using it wrong". This is unavoidable if we want soundness. A Contravariance is definitely hard for people to reason about. People manifestly find it non-intuitive even on the consumer side, and it's harder for class implementers to reason about as well. Fortunately, it's very rare that you need it. So why not leave it out? Because if you want sound covariance, then in rare cases you need its dual, contravariance, or you're really SOL. This is confusing, but it is fortunately rare (except for function types, which we already have in abundance, so maybe it's not too, too bad?). |
In my experience that kind of reasoning doesn't actually pan out. A programmer needs to know all the tools in the toolbox because their colleagues will use them, whether they understand them or not, and you need to understand them to debug the resulting code. That said, I'm happy to reserve judgement until we have a concrete proposal and can do real usability tests with it. |
A small clarification about this:
@munificent wrote:
It's actually simpler than that. The tear-offs involve changing the run-time type of a function object and adding dynamic checks (that's magic), but this proposal doesn't involve changes to the run-time type of anything. It simply adjusts the static type of an expression from a type that may or may not be satisfied by the actual value of that expression to a supertype thereof that correctly describes all the possible values. With the new static type there is no need for a caller-side check, because we haven't promised more than we can deliver. The downside is that the expression now has a more general type, so there may be a need for an explicit downcast in order to allow that expression to occur at that location. So this is clearly a breaking change, though for a situation that may be rare in practice. However, if the caller-side check was |
Yeah, I get where you're coming from. C++ is particularly bad for this where it seems that everyone has their own idiosyncratic set of pet features so it's hard to avoid running into them once you work in a group. I don't think generic class declarations are so randomly scattered. Here's a histogram of the number of type parameters in all of the class declarations of the 1,000 most recently published pub packages (as of earlier this year):
So >95% of classes are not generic. This is published pub packages which are intended to be reusable library code and I think thus more likely to use generics. I would expect application code would have even fewer generic classes. That leads me to believe that a typical Dart programmer can probably get pretty far without having to understand maintaining a generic class. It still adds complexity to the language, definitely, so I'd like to see what we can learn from usability studies too. This is a hard problem. My experience is that as your type system gets more complex, it takes larger and larger complexity jumps to provide incremental expressiveness improvements. At some point, it's not worth it, though that point varies per user. |
I'm not sure if this is the same problem but I got a very similar error today. May problem is that I have to store class A<T>{
A(this.val,this.dispose);
T val;
FutureOr Function(T) dispose;
} But when I try to call for(final a in allA){
a.dispose(a.val);
} I get a type error because Full example in dartpad here: |
@escamoteur There is no good solution, any read of In this case, I'd add a method to class A<T> {
A(this.val, this.dispose);
T val;
FutureOr Function(T) dispose;
FutureOr invoke() => dispose(val);
} Then you can call Allowing you to write |
Recovering soundness in the presence of covariant generics requires runtime checks to be inserted in certain positions. In the case of a member the type of which contains a contravariant occurrence of a covariant type variable, a caller side check is currently inserted. This is a very brittle check and causes confusing errors. See for example this discussion among others for concrete examples and further discussion of the check.
It would be good if we could improve the programmer experience around this. There are a number of possible paths that have been proposed.
Sound variance
Using sound variance solves the issue, at some expense either in terms of cognitive load (if sound co and contravariance are used) or subtype polymorphism (if invariance is used). There are proposals on the table for adding one or more of declaration site variance, use site invariance or use site variance.
Weaken contravariant return types on getters
There is a proposal from @eernstg to weaken the return type of members with contravariant return types to a sound approximation of the actual type. This avoids the need for a read side check, at the expense of receiving an unexpectedly vague type at the call site.
Eliminate the check at sound use sites
There are a number of patterns where currently the check is inserted, and may fail, even though the result of the member access is immediately promoted to a sound super type (e.g. dynamic), or otherwise used in a safe way (e.g. simply compared to
null
). It would be possible to specify a set of conditions in which the check should not be performed that would make it less likely to encounter this.Wrap return values
An approach used in some gradual typing systems is to wrap return values in checking code. Either on the caller or on the callee side, we would wrap a returned value of type
int Function(int)
with guards on the parameters as required to ensure that soundness is preserved. This has a significant runtime expense and changes identity semantics, and so is unlikely to be feasible.This issue is intended to track the user issue, and centralize the list of proposals for addressing this. Additional proposed solutions should be linked into this thread.
The text was updated successfully, but these errors were encountered: