Request: Lint for near-guaranteed cast failure due to inference/covariance clash #57775
Labels
analyzer-linter
Issues with the analyzer's support for the linter package
area-analyzer
Use area-analyzer for Dart analyzer issues, including the analysis server and code completion.
linter-lint-request
P3
A lower priority bug or feature request
type-code-health
Internal changes to our tools and workflows to make them cleaner, simpler, or more maintainable
type-enhancement
A request for a change that isn't a bug
This is a proposal for a lint which is intended to flag some statically detectable situations in code where a failure at run-time is near-guaranteed.
Basic idea
To set the scene, here's a scenario with the structure that I'm recommending we should detect and flag:
The basic idea is that we have situations where inference is relying on a straightforward (invariant) type assumption to choose certain inferred types, and those inferred types are then used to create entities whose dynamic type is directly derived from the static assumption. The result of evaluating e at run time may then work in the single special case where the type was actually invariant, but it is guaranteed to fail in all other cases.
Lint criterion
Let e be an expression whose context type is covariant, and assume that inference provides actual type arguments or type annotations on the formal parameters or return value of a function literal in e. This lint shall then flag e if any of said inferred type annotations or actual type arguments occur covariantly or invariantly in the static type of e.
[Edit: A simple special case is described here].
Examples
In order to make it clearer what the lint criterion means, here's a bunch of examples. Further down there is an alternative explanation which uses existential types to show how it works in general.
In all these situations inference relies on the statically known type, which causes a fresh object to be created with elements from that statically known type baked in, but the target which receives said fresh object requires a subtype thereof.
This situation calls more loudly for static detection than ordinary covariance, because the fresh object(s) being created are guaranteed to violate the covariant constraints whenever we have any actual covariance.
The last line in
main
gives an example of a situation where no fresh objects are created. Regular downcasts are present, but they succeed at run time because said objects "fit together" (both the dynamic type ofc
and the actual type annotation forc.c
areC<S>
for some typeS
which is a subtype ofnum
, and please note that it is the sameS
!), which is likely to be true for many situations where objects are used in various interactions/collaborations via some covariant types.As a counter-counter-example, consider
c.fun = f_fun(c)
: This fails because we are creating a function object whose type hasnum
baked in as its return type, but it is required to beint
. It is true thatc
given as an argument tof_fun
andc.c
have a typeC<S>
for the sameS <: num
, but the function literal which is being created in the body off_fun
will still have the return type which is obtained from the statically known type ofc
.Hence this proposal to have a lint that flags this "over-optimistic static type baked in" scenario.
Spelling out the lint criterion using existential types
The notion of a context type which is a covariant type is not something that we have defined in the language specification or in any feature specification, but it might already ring a bell (which is the reason why I used it without introducing it ;-). To make it more precise, the most obvious approach is to make it explicit that dart generic types are existential:
Assume that
e
has static typeList<num>
but the actual value ofe
at some point during an execution is of typeList<int>
. This is perfectly normal in Dart, but we might describe that a bit more directly by saying so:A static type in Dart which is a parameterized type G<T1, .. Tk> where G is a generic class actually denotes the existential type ∃X1 <: T1, .. Xk <: Tk. G<X1, .. Xk>, that is, we can, at each point in time during execution find some actual type arguments which are subtypes of the statically known ones (which are now bounds in the existential type), and those actual type arguments yields the actual (dynamic) type of the value of an expression of that static type. One statement later we might need to find some other actual type arguments, but there will always be some actual type arguments that are subtypes of the bounds which will work.
In the following, we'll use 'Dart type notation' when we refer to static types where the existential nature is implicit (this is the kind of types that we can write in actual Dart programs), and a plain 'type' for the case where existential types are written explicitly, and a type which is not existential is invariant. That is, a variable
x
may have a type annotation which is the Dart type notationList<num>
, which means that its static type is ∃S <: num. List<S>, and its value at some point in time could be List<Null>.With that, let us consider an expression e whose context type is ∃X1 <: T1, .. Xk <: Tk. G<X1, .. Xk>. Assume that we run inference on e as if it had had the context type G<T1, .. Tk>.
With that, a successful inference step would provide actual type arguments and/or function literal type annotations such that the static type of e is a subtype of G<T1, .. Tk>. In some situations we may know for sure that a fresh object is created, and that this object has these actual type arguments baked in, and this is the situation where any non-trivial covariance (that is, some Xi is not equal to Ti) is guaranteed to cause a dynamic type error. So we should flag those situations.
Moreover, we may also have the situation where the inferred type arguments to an invocation of a generic function is passed along some number of steps and then used to create a fresh object whose type has some of said type arguments baked in, which will then similarly guarantee failure if such an object is returned. Of course, there may be any number of steps between those two points, e.g., we may call many functions and create many objects, where the overly optimistic actual type arguments are baked into objects and generic function calls in some long causal chain.
The crucial point here is that when inference on e has provided some actual type arguments and/or added some type annotations (parameters or return type) in some function literals, we need to consider whether it matters. So let T0 denote the static type of e with the given inference.
The criterion for flagging this situation is that if we replace one or more of the inferred types by the types that we would have obtained if inference had been based on G<S1, .. Sk> for some Sj where Sj <: Tj for all j, then we'll get another type for e as a whole, call it S0: Flag it if S0 is not a supertype of T0 (which includes the case where they are the same type). That means, we flag this situation if the actual covariance that we must expect will invalidate the subtype relationship that we expect statically when assuming that the Dart type notation is actually the type of e.
In short, we will flag this situation iff some of the inferred types occur in covariant or invariant positions in the static type of e, because we would then, with knowledge about the actual type arguments, have created a fresh object with a type which is not a supertype of the one that we are actually creating, that is, the one that we are actually creating would then not work (because it can masquerade as an instance of a supertype, but in other situations it can't masquerade as the type that we would have wanted).
Be strict or less strict?
We could make the choice to flag only the situations where it is guaranteed that some object will be created afresh with the over-optimistic static types baked in, that is, when inference is used to provide type annotations to a function literal, or it provides actual type arguments to the invocation of a generative constructor or a list or map literal.
We could also make the choice to flag the situations where a generic function is called with inferred type arguments, because that invocation might in turn give rise to creation of a fresh object/function with an over-optimistic type argument baked in at a covariant position. It would not be easy in general to mark any generic functions as being "fresh-safe" in the sense that they would never create an instance whose type has any of the type arguments given to the function invocation baked in. Hence, it might be an overly restrictive approach to flag generic function invocations.
Documenting the actual need for invariance
One extremely common reason for having covariant context types is that we are passing some actual argument a in an instance method invocation where the receiver has a generic type, and the formal parameter corresponding to a has a type annotation which is 'covariant from class'. For instance,
List<num> myList = ...; myList.add(42);
creates this situation even though there is no downcast statically, becausemyList
could be aList<double>
, so we must check.However, we can eliminate the notion of covariant context types entirely by introducing support for invariant types. I'll use the notation
List<exactly num>
as the Dart type notation for the type List<num> (that is, it cannot be aList<int>
).We may then change type annotations such that specific expressions get an invariant type, and if they are used as receivers of instance method invocations, it does not matter whether any of the formal parameters are covariant-from-class, because there is no class covariance, so there is no covariance caused by class covariance.
This, of course, offers safety, but takes away flexibility, because the code will no longer allow a
List<int>
, and it might be the case that almost all code paths down inmain
and whatever is called from there would work just fine with aList<int>
and only a couple of corner cases would actually need the invariance.A more powerful alternative to invariance based on statically known types is to have an existential open construct, that is, the ability to say "let me give a name to the actual type argument of this particular object at that particular type":
The text was updated successfully, but these errors were encountered: