14 KiB
Dart Language and Library Newsletter
2017-09-01 @floitschG
Welcome to the Dart Language and Library Newsletter.
The Case Against Call
Dart 1.x supports callable objects. By adding a call
method to a class, instances of this class can be invoked as if they were functions:
class Square {
int call(int x) => x * x;
toString() => "Function that squares its input";
}
main() {
var s = new Square();
print(s(4)); // => 16.
print(s); // => Function that squares its input.
print(s is int Function(int)); // => true.
}
Note that Square
doesn't need to implement any Function
interface: as soon as there is a call
method, all instances of the class can be used as if they were closures.
While we generally like the this feature (let's be honest: it's pretty cool), the language team is trying to eventually remove it from the language. In this section, we explain the reasons for this decision.
Wrong Name
Despite referring to the feature as the "call operator", it is actually not implemented as an operator. Instead of writing the call operator similarly to other operators (like plus, minus, ...), it's just a special method name.
As an operator we would write the Square
class from above as follows:
class Square {
int operator() (int x) => x * x;
}
Some developers actually prefer the "call" name, but the operator syntax wouldn't just be more consistent. It would also remove the weird case where we can tear off call
methods infinitely:
var s = new Square();
var f = s.call.call.call.call.call.call;
print(f(3)); // => 9;
If the call
operator was an actual operator, there wouldn't be any way to tear off the operator itself.
Tear-Offs are Too Good
Tearing off a function is trivial in Dart. Simply referring to the corresponding method member tears off the bound function:
class Square {
int square(int x) => x * x;
}
main() {
var s = new Square();
var f = s.square;
print(f(3)); // => 9.
}
The most obvious reason for a call-operator is to masquerade an instance as a function. However, with easy tear-offs, one can just tear off the method and pass that one instead. The only pattern where this doesn't work, is if users need to cast a function type back to an object, or if they rely on specific hashCode
, equality or toString
.
The following contrived example shows how a program could use these properties.
// The `Element` class and the `disposedElements` getter are provided
// by some framework.
/// An element that reacts to mouse clicks.
class Element {
/// The element's click handler is a function that takes a `MouseEvent`.
void Function(MouseEvent) clickCallback;
}
/// A stream that informs the user of elements that have been disposed.
Stream<Element> disposedElements = ...;
// ============= The following code corresponds to user code. =====
// Attaches a click handler to the element of the given name
// and writes the clicks to a file.
void logClicks(String name) {
var sink = new File("$name.txt").openWrite();
var element = screen.getElement(name);
element.clickCallback = sink.writeln;
}
main() {
logClicks('demo');
logClicks('demo2');
disposedElements.listen((element) {
// Would like to close the file for the registered handlers.
// ------
});
}
In the beginning of main
the program registers some callbacks on UI elements. However, when these elements are disposed of, the program currently does not know how to find the IOSink
that corresponds to the element that is removed.
One easy solution is to add a global map that stores the mapping between the elements and the opened sinks. Alternatively, we can introduce a callable class that stores the open file:
// A class that connects the open output file with the handlers.
class ClickHandler {
final IOSink sink;
ClickHandler(this.sink);
void call(Object event) {
sink.writeln(event);
}
}
// Attaches a click handler to the element of the given name
// and writes the clicks to a file.
void logClicks(String name) {
var sink = new File("$name.txt").openWrite();
var handler = new ClickHandler(sink);
var element = screen.getElement(name);
// Uses the callable object as handler.
element.clickCallback = handler;
}
main() {
logClicks('demo');
logClicks('demo2');
disposedElements.listen((element) {
// ============
// Casts the function back to a `ClickHandler` class.
var handler = element.clickCallback as ClickHandler;
// Now we can close the sink.
handler.sink.close();
});
}
By using a callable class, the program can store additional information with the callback. When the framework tells us which element has been disposed, the program can retrieve the handler, cast it back to ClickHandler
and read the IOSink
out of it.
Fortunately, these patterns are very rare, and usually there are many other ways to solve the problem. If you know real world programs that require these properties, please let us know.
Typing
A class that represents, at the same time, a nominal type and a structural function type tremendously complicates the type system.
As a first example, let's observe a class that uses a generic type as parameter type to its call
method:
class A<T> {
void call(T arg) {};
}
main() {
var a = new A<num>();
A<Object> a2 = a; // OK.
void Function(int) f = a; // OK.
// But:
A<int> a3 = a; // Error.
void Function(Object) f2 = a; // Error.
}
Because Dart's generic types are covariant, we are allowed to assign a
to a2
. This is the usual List<Apple>
is a List<Fruit>
. (This is not always a safe assignment, but Dart adds checks to ensure that programs still respect heap soundness.)
Similarly, it feels natural to say that a
which represents a void Function(T)
, with T
equal to num
, can be used as a void Function(int)
. After all, if the method is only invoked with integers, then the num
is clearly good enough.
Note that the assignment to a2
uses a supertype (Object
) of num
at the left-hand side, whereas the assignment to f
uses a subtype (int
). We say that the assignment to a2
is covariant, whereas the assignment to f
is contravariant on the generic type argument.
Our type system can handle these cases, and correctly inserts the necessary checks to ensure soundness. However, it would be nice, if we didn't have to deal with objects that are, effectively, bivariant.
Things get even more complicated when we look at subtyping rules for call
methods. Take the following "simple" example:
class C {
void call(void Function(C) callback) => callback(this);
}
main() {
C c = new C();
c((_) => null); // <=== ok.
c(c); // <=== ok?
}
Clearly, C
has a call
method and is thus a function. The invocation c((_) => null)
is equivalent to c.call((_) => null)
. So far, things are simple. The difficulty arises when c
is passed an instance of type C
(in this case c
itself).
The type system has to decide if an instance of type C
(here c
) is assignable to the parameter type. For simplicity, we only focus on subtyping, which corresponds to the intuitive "Apple" can be assigned to "Fruit". Usually, subtyping is written using the "<:" operator: Apple <: Fruit
. This notation will make this text shorter (and slightly more formal).
In our example, the type system thus wants to answer: C <: void Function(C)
? Since C
is compared to a function type, we have to look at C
's call
method and use that type instead: void Function(void Function(C))
. The type system can now compare these types structurally: void Function(void Function(C)) <: void Function(C)
?
It starts by looking at the return types. In our case these are trivially assignable: both are void
. Next up are the parameter types: void Function(C)
on the left, and C
on the right. Since these types are in parameter position, we have to invert the operands. Formally, this inversion is due to the fact that argument types are in contravariant position. Intuitively, it's easy to see that a fruit function (Function(Fruit)
) can always be used in places where an apple function (Function(Apple)
) is required: Function(Fruit) <: Function(Apple)
because Apple <: Fruit
.
Getting back to our example, we had just concluded that the return types of void Function(void Function(C)) <: void Function(C)
matched and were looking at the parameter types. After switching sides we have to check whether C <: void Function(C)
.
If this looks familiar, you paid attention: this is the question we tried to answer in the first place…
Fundamentally, this means that Dart (with the call
method) features recursive types. Depending on the resolution algorithm of the type system we can now either conclude that:
C <: void Function(C)
, if we use a co-inductive algorithm that tracks recursion (which is just fancy wording for saying that we assume everything works and try to see if things break), orC </: void Function(C)
, if we use an inductive algorithm that tracks recursion. (Start with nothing, and build up the truth).
This is just one out of multiple issues that call
methods bring to Dart's typing system. Fortunately, we are not the first ones to solve these problems. Recursive type systems exist in the wild, and there are known algorithms to deal with them (for example Amadio and Cardelli http://lucacardelli.name/Papers/SRT.pdf), but they add lots of complexity to the type system.
Conclusion
Given all the complications the call
method, the language team intends to eventually remove this feature from the language.
Our plan was to slowly phase call
methods out over time, but we are now investigating, if we should take the jump with Dart 2.0, so that we can present a simpler type system for our Dart 2.0 specification.
At this stage we are still collecting information, including looking at existing programs, and gathering feedback. If you use this feature and don't see an easy work-around please let us know.
Limitations on Generic Types
A common operation in Dart is to look through an iterable, and only keep objects of a specific type.
class A {}
class B extends A {}
void main() {
var itA = new Iterable<A>.generate(5, (i) => i.isEven ? new A() : new B());
var itB = itA.where((x) => x is B);
}
In this example, itA
is an Iterable
that contains both A
s and B
s. The where
method then filters these elements and returns an Iterable
that just contains B
s. It would thus be great to be able to use the returned Iterable
as an Iterable<B>
. Unfortunately, that's not the case:
print(itB is Iterable<B>); // => false.
print(itB.runtimeType); // => Iterable<A>.
The dynamic type of itB
is still Iterable<A>
. This becomes obvious, when looking at the signature of where
: Iterable<E> where(bool test(E element))
(where E
is the generic type of the receiver Iterable
).
It's natural to wonder if we could improve the where
function and allow the user to provide a generic type when they want to: itA.where<B>((x) => x is B)
. If the user provides a type, then the returned iterable should have that generic type. Otherwise, the original type should be used:
// We would like the following return types:
var anotherItA = itA.where(randomBool); // an Iterable<A>.
var itB = itA.where<B>((x) => x is B); // an Iterable<B>.
The signature of where
would need to look somehow similar to:
Iterable<T> where<T>(bool test(E element));
This signature would work for the second case, where the user provided a generic argument to the call, but would fail for the first case. Since there is no way for the type inference to find a type for the generic type, it would fill that type with dynamic
. So, anotherItA
would just be an Iterable<dynamic>
and not Iterable<A>
.
The only way to provide "default" values for generics is to use the extends
clause such as:
Iterable<T> where<T extends E>(bool test(E element));
This is because Dart's type inference uses the bound of a generic type when no generic argument is provided.
Running our tests, this looks promising:
var anotherItA = itA.where(randomBool);
print(anotherItA.runtimeType); // => Iterable<A>.
var itB = itA.where<B>((x) => x is B);
print(itB.runtimeType); // => Iterable<B>.
Clearly, given the title of this section, there must be a catch...
While our simple examples work, adding this generic type breaks down with covariant generics (List<Apple>
is a List<Fruit>
). Let's try our new where
function on a more sophisticated example:
int nonNullLength(Iterable<Object> objects) {
return objects.where((x) => x != null).length;
}
var list = [1, 2]; // a List<int>.
print(nonNullLength(list));
The nonNullLength
function just filters out all elements that are null
and returns the length of the resulting Iterable
. Without our update to the where
function this works perfectly. However, with our new function we get an error.
The where
in nonNullLength
has no generic argument, and the type inference has to fill it in. Without any provided generic argument and no contextual information, the type inference uses the bound of the generic parameter. For our improved where
function the generic parameter clause is T extends E
and the bound is thus E
. Within nonNullLength
the provided argument objects
is of type Iterable<Object>
and the inference has to assume that E
equals Object
. The compiler statically inserts Object
as generic argument to where
.
Clearly, Object
is not a subtype of int
(the actual generic type E
of the provided Iterable
). As such, a dynamic check must stop the execution and report an error. In Dart 2.0 the nonNullLength
function would therefore throw.
Type inference is only available in strong mode and Dart 2.0, and, so far, only DDC supports the new type system. (Also, this particular check is only implemented in a very recent DDC.) Eventually, all our tools will implement the required checks.
Without actual default values for generic parameters, there isn't any good way to support a type-based where
. At the moment, the language team has no intentions of adding this feature. However, we are going to add a new method on Iterable
to filter for specific types. A new function, of<T>()
or ofType<T>
, will allow developers to filter an Iterable
and get a new Iterable
of the requested type.