Typing of members of dynamic

Author: eernst@.

Version: 0.2 (2018-09-04)

Status: Background material. Normative text is now in dartLangSpec.tex.

This document is a Dart 2 feature specification of the static typing of instance members of a receiver whose static type is dynamic.

This document uses discussions in this github issue as a starting point.

Motivation

For Dart programs using a statically typed style, it is often helpful to use the most precise static type for an expression which is still sound. In contrast, if such an expression gets type dynamic it often causes subsequent type computations such as inference to make less useful decisions, or it may mask errors which are likely or guaranteed to occur at run time. Here is an example:

class A {
  String toString([bool b = true]) =>
      b ? 'This is an A!' : 'Whatever';
}

foo(List<String> xs) {
  for (String s in xs) print(s);
}

main() {
  dynamic d = new A();
  var xs = [d.toString()];
  foo(xs);
}

In this example, the actual type argument passed to the list literal [d.toString()] by inference depends on the static type of the expression d.toString(). If that expression is given the type dynamic (as it would be in Dart 1) then the resulting list will be a List<dynamic>, and hence the invocation of foo would fail because it requires an argument of type List<String>.

In general, a receiver with static type dynamic is assumed to have all members, i.e., we can make the attempt to invoke a getter, setter, method, or operator with any name, and we can pass any list of actual arguments and possibly some type arguments, and that will not cause any compile-time errors. Various checks may be performed at run time when such an invocation takes place, and that is the whole point: Usage of expressions of type dynamic allows developers to skip the static checks and instead have dynamic checks.

However, every object in a Dart program execution has a type which is a subtype of Object. Hence, for each member declared by Object, it will either inherit an implementation declared by Object, or it will have some implementation specified as an override for the declaration in Object. Given that overriding declarations must satisfy certain constraints, we do know something about the properties of a member declared in Object. This allows static analysis to give static types to some expressions which are more precise than dynamic, even for a member access where the receiver has type dynamic, and that is the topic of this document.

We will obey the general principle that an instance method invocation (including getters, setters, and operators) which would be compiled without errors under some typing of the receiver must also be without compile-time errors when the receiver has type dynamic. It should be noted that there is no requirement that the typing relies only on declarations which are in scope at the point where the invocation occurs, it must instead be possible to declare such a class that the invocation can be statically typed. The point in obeying this principle is that dynamic invocation should be capable of performing every invocation which is possible using types.

For instance, d.toString(42) cannot have a compile-time error when d has static type dynamic, because we could have the following declaration, and d could have had type D:

class D {
  noSuchMethod(Object o) => o;
  Null toString([int i]) => null;
}

Similarly, d.noSuchMethod('Oh!') would not be a compile-time error, because a contravariant type annotation on the parameter as shown above would allow actual arguments of other types than Invocation.

On the other hand, it is safe to assign the static type String to d.toString(), because that invocation will definitely invoke the implementation of toString in Object or an override thereof, and that override must have a return type which is String or a subtype (for String that can only be Null, but in general it can be any subtype).

It may look like a highly marginal corner of the language to give special treatment to the few methods declared in Object, but it does matter in practice that a number of invocations of toString are given the type String. Other members like hashCode get the same treatment in order to have a certain amount of consistency.

Moreover, we have considered generalizing the notion of “the type dynamic” such that it becomes “the type dynamic based on T” for any given type T, using some syntax, e.g., dynamic(T). The idea would be that statically known methods invoked on a receiver of type dynamic(T) would receive static checking, but invocations of other methods get dynamic checking. With that, the treatment specified in this document (which was originally motivated by the typing of toString) will suddenly apply to any member declared by T, where T can be any type (that is, any declarable member). It is then important to have a systematic approach and a simple conceptual “story” about how it works, and why it works like that. This document should be a usable starting point for such an approach and story.

Static Analysis

In this section, Object denotes the built-in class Object, and dynamic denotes the built-in type dynamic.

Let e be an expression of the form d.m, which is not followed by an argument part, where the static type of d is dynamic, and m is a getter declared in Object; if the return type of Object.m is T then the static type of e is T.

For instance, d.hashCode has type int and d.runtimeType has type Type.

Let e be an expression of the form d.m, which is not followed by an argument part, where the static type of d is dynamic, and m is a method declared in Object whose method signature has type F (which is a function type). The static type of e is then F.

For instance, d.toString has type String Function().

Let e be an expression of the form d.m(arguments) or d.m<typeArguments>(arguments) where the static type of d is dynamic, m is a getter declared in Object with return type F, arguments is an actual argument list, and typeArguments is a list of actual type arguments, if present. Static analysis will then process e as a function expression invocation where a function of static type F is applied to the given argument part.

So d.runtimeType(42) is a compile-time error, because it is checked as a function expression invocation where an entity of static type Type is invoked. Note that it could actually succeed: An overriding implementation of runtimeType could return an instance whose dynamic type is a subtype of Type that has a call method. We decided to make it an error because it is likely to be a mistake, especially in cases like d.hashCode() where a developer might have forgotten that hashCode is a getter.

Let e be an expression of the form d.m(arguments) where the static type of d is dynamic, arguments is an actual argument list, and m is a method declared in Object whose method signature has type F. If the number of positional actual arguments in arguments is less than the number of required positional arguments of F or greater than the number of positional arguments in F, or if arguments includes any named arguments with a name that is not declared in F, the type of e is dynamic. Otherwise, the type of e is the return type in F.

So d.toString(bazzle: 42) has type dynamic whereas d.toString() has type String. Note that invocations which “do not fit” the statically known declaration are not errors, they just get return type dynamic.

Let e be an expression of the form d.m<typeArguments>(arguments) where the static type of d is dynamic, typeArguments is a list of actual type arguments, arguments is an actual argument list. It is a compile-time error if m is a non-generic method declared in Object.

No generic methods are declared in Object. Hence, we do not specify that there must be the statically required number of actual type arguments, and they must satisfy the bounds. That would otherwise be the consistent approach, because the invocation is guaranteed to fail when any of those requirements are violated, but generalizations of this mechanism would need to include such rules.

For an instance method invocation e (including invocations of getters, setters, and operators) where the receiver has static type dynamic and e does not match any of the above cases, the static type of e is dynamic.

When a cascadeSection performs a getter or method invocation that corresponds to one of the cases above, the corresponding static analysis and compile-time errors apply.

For instance, d..foobar(16)..hashCode() is an error.

Note that only very few forms of instance method invocation with a receiver of type dynamic can be a compile-time error. Of course, some expressions like x[1, 2] are syntax errors even though they could also be considered “invocations”, and subexpressions are checked separately so any given actual argument could be a compile-time error. But almost any given argument list shape could be handled via noSuchMethod, and an argument of any type could be accepted because any formal parameter in an overriding declaration could have its type annotation contravariantly changed to Object. So it is a natural consequence of the principle mentioned in ‘Motivation’ that a dynamic receiver admits almost all instance method invocations. The few cases where an instance method invocation with a receiver of type dynamic is an error are either guaranteed to fail at run time, or they are very likely to be developer mistakes.

Dynamic Semantics

This feature has no implications for the dynamic semantics, beyond the ones which are derived directly from the static typing.

For instance, a list literal may have a run-time type which is determined via inference by the static type of its elements, as in the example in the ‘Motivation’ section, or the actual type argument may be influenced by the typing context, which may again depend on the rules specified in this document.

Revisions

  • 0.2 (2018-09-04) Adjustment to make d.hashCode() and similar expressions an error, cf. this github issue.

  • 0.1 (2018-03-13) Initial version, based on discussions in this github issue.