Author: Sigmund Cherem (@sigmundch)
Stakeholders:
Note about background: This proposal was derived from design discussions with several Dart team members from an idea initially suggested by @mraleph. Some contents below were adapted from text written by @gbracha and @lrhn.
We propose an extension to the Dart language to support intercepting top-level and class members using an interceptor.
Interceptors provide a way to statically add behavior to members without incurring lots of boilerplate code. The definition of interceptors are constant objects, so the semantics of the program are still understood at compile time. Interceptors are applied to members by decorating these members using annotations. This separation allows frameworks to define what the behavior of an interceptor is, while users just focus on decorating their members with the features they wish to use.
Note about terminology: The term "interceptor" used in this document is similar to the concept of python decorators or advice in Lisp. We use the term intercepted member to refer to a member that is decorated with an interceptor. We may also use the term redirected member for the same purpose, as using the member redirects the implementation to the interceptor.
This proposal is heavily motivated by a specific use case in data observability. A feature widely used in UI frameworks, including Angular and Polymer.
Many UI frameworks provide a lot of declarative features that help developers focus on building web applications without having to worry about low-level details, like how information has to be plumbed from data models to UI widgets. Some of these features include templates, dependency injection, and data observability. These features can be expressed very succinctly, but under the hood they may be implemented using reflection (typically for development time in Dartium) and with code generation (typically with transformers for deployment).
Data observability is a feature that lets users listen for changes that occur on their data models. This is typically used by the framework to automatically react to changes and reflect them in the UI layer.
While working with data observability in Polymer, we run into these challenges:
-
It is not possible to guarantee a synchronous delivery of change notifications without requiring users to write additional boilerplate code (see more in the examples section below). The Dart language is not expressive enough to achieve this goal.
-
As a result, our implementation must deliver observable changes asynchronously using dirty-checking at development-time.
-
To achieve performance at deployment time, we elminate dirty-checking by rewriting observable fields into properties with write barriers. What is especially peculiar about this transformation is that it modifies the source files in place. This adds additional complexity to our build system. All other code generation done by Polymer can be done so that the original source code is unmodified.
-
Composition of observable expressions is hard and we were forced to move compound expressions into a domain-specific language written in strings and annotations (more examples below).
All these challenges led us to brainstorm about possible ways to address these limitations in the language. Other languages have opted to include observability as a first-class concept (e.g. ES6's Object Observe). This proposal takes a different angle: improve the expressiveness of the Dart language to be able to implement synchronous observability as a library.
One of the benefits that this proposal entails is that the use of
mirrors and code-generation in Polymer will be restricted to APIs that can be
handled by the reflectable package, and on-the-side code generation of
Dart
files from HTML
files. So, there would be no need to modify Dart files
in place anymore.
Member interceptors can be used to implement read barriers, write barriers, or trapping method calls. Below are some examples where interceptors can be useful.
Note: In these examples we use the standard annotation syntax to decorate members with an interceptor. Further down in this proposal we discuss this and other syntax options and their tradeoffs.
A simple interceptor can be used to memoize results of function calls and property getters. For example, one could write a fibonacci implementation with memoization as follows:
@memoize int fibonacci(int n) =>
n <= 1 ? 1 : fibonacci(n - 1) + fibonacci(n - 2);
Internally, the memoize
annotation declares an interceptor that traps calls
to fibonacci
and returns a cached result if one is available. In other words,
the code above is equivalent to writing something like:
Map<int, int> _fibonacci_cache = {};
int fibonacci(int n) =>
_fibonacci_cache.putIfAbsent(n, () => _fibonacci(n));
int _fibonacci(int n) =>
n <= 1 ? 1 : fibonacci(n - 1) + fibonacci(n - 2);
Interceptors can be used to require that certain code is only executed in a specific context. For example, this can be used for:
- turning on logging at development time.
- instrumenting while investigating performance bottlenecks.
- making functions visible only for testing.
For example, a visibleForTesting
interceptor can be used as follows:
class MyEncapsulatedLogic {
int _name;
@visibleForTesting
set nameForTest(n) { _name = n; }
}
which is equivalent to something like:
class MyEncapsulatedLogic {
int _name;
set nameForTest(n) {
if (!const bool.fromEnvironment('test')) {
throw "Invalid: using test-only feature in application!!";
}
_nameForTest = n;
}
set _nameForTest(n) { _name = n; }
}
Users can express and check invariants or pre- and post-conditions using interceptors. For example:
class MyValue {
@nonnegative int x;
}
internally the nonnegative
interceptor can check that x
is never set to have
a negative value. Which would be equivalent to write:
class MyValue {
int _x;
int get x => _x;
int set x(v) {
if (v < 0) throw "x can't be negative!";
_x = v;
}
}
Coming back to our motivating use-case. Consider this code from Polymer, where an annotation is already used to indicate that a field is observable:
class Person implements Observable {
@observable String firstName;
}
Today, Polymer performs dirty-checking and delives notifications at the end of every event loop. With this proposal, the annotation above would become an interceptor, which would let us detect changes when they happen, and would allow us to deliver these notifications synchronously. In other words, it would be as if the user had written:
class Person implements Observable {
String _firstName;
String get firstName => _firstName;
set firstName(String newValue) {
var oldValue = _firstName;
_firstName = newValue;
notifyChanges(#firstName, oldValue, newValue);
}
}
Now suppose we want to observe not just a field, but an actual property composed
of other fields. For example, let's combine firstName
and lastName
to create
fullName
. Without interceptors, the dependency between observable properties
needs to be encoded somehow in a domain specific language, this could be
annotations that encode dependencies directly, for example. In Polymer, we
reused an expression language that was used in other parts of the system
instead, so a composed property looks like this today:
class Person implements Observable {
@observable String firstName;
@observable String lastName;
@ComputedProperty('fullName + " " + lastName')
String get fullName => readValue(this, #fullName);
}
This code looks a bit magical and, well, it sort of is. From the
ComputedProperty
annotation Polymer reflectively evaluates the expression, the
readValue
function is used to avoid duplicating the expression again in the
body of the getter, but also to help the framework in two ways: first, to encode
the dependency between the computed property and the underyling fields, second,
to prevent users from seeing an inconsistent state that may arise due to the
asynchornous nature of the change notificaitons.
Interceptors would let us encode computed expressions directly in Dart without resorting to some sort of DSL. For example, one could simply write:
class Person implements Observable {
@observable String firstName;
@observable String lastName;
@observable get fullName => '$firstName $lastName';
}
besides issuing notifications on write operations, the observable
interceptor
can detect read operations and automatically establish the dependency between
properties.
As part of this proposal, we have provided a prototype implementation using transformers. See the example/observe/ folder to see the observable interceptor in action.
Localized messages in the Intl package look like the following:
String foo(String n, String k) => Intl.message(
"Selected $k out of $n items",
description: "Description for translators",
name: "foo",
args: [n, k]);
The user provides the text in the default locale, possibly with interpolated values, along with information for translators and for the localization machinery. For each translation we generate a deferred-loaded library with a singleton object that has a "foo" method and map from the name to the method. The implementation finds the map for the current locale and the foo method within it, then does a Function.apply with the arguments.
There are two issues for which this proposal might be helpful. First, users must duplicate the name and arguments of the function. The name is used as a lookup into the map of translated messages, and the arguments are passed to the translated function. When we extract the text for translation we have this information, but without modifying the user's original source code we have no way to get it at runtime. Second, the runtime lookup could be more efficient. With an InvokeInterceptor we could improve both of these. It could be more like
@IntlMessage
String foo(String n, String k) => Intl.message(
"Selected $k out of $n items",
description: "Description for translators");
where
const IntlMessage = const IntlMessageInterceptor();
class IntlMessageInterceptor implements InvokeInterceptor {
const IntlMessageInterceptor();
invoke(target, positionalArgs, namedArgs, member) {
var locale = findLocale();
var catalog = findCatalog(locale);
return member.invoke(catalog, positionalArgs, namedArgs);
}
}
The user is forced to annotate message functions, but in exchange they don't have to provide and verify the function name and arguments. The implementation of member.invoke can presumably be better-optimized than a general Function.apply.
It might even be possible to make more use of the annotation, pulling out some of the optional information into it. This includes the description, as well as other fields example and meaning that we've omitted here. e.g.
@IntlMessage(
description: "Description goes here",
examples: const {"n" : "3", "k" : "2"})
foo(n, k) => "Selected $k out of $n items.";
Member interceptors are a shorthand notation for creating indirect access to properties and methods, where access goes through the interceptor first.
The process of writing member interceptors consists of declaring an interceptor object and decorating a member with such interceptor.
An interceptor object is a constant expression that implements one or more of the interceptor interfaces:
abstract class ReadInterceptor {
get(target, Member member);
}
abstract class WriteInterceptor {
set(target, value, Member member);
}
abstract class InvokeInterceptor {
invoke(target, List positionalArguments, Map<Symbol,dynamic> namedArguments,
Member member);
}
abstract class Interceptor implements
ReadInterceptor, WriteInterceptor, InvokeInterceptor {
}
If an interceptor implements ReadInterceptor
, it can be used to intercept
getters and reading fields. Similarly, if it implements
WriteInterceptor
it can be used on setter calls, and if it implements
InvokeInterceptor
it can be used on method calls.
The Member
type is a constant object defined as:
abstract class Member {
final Symbol name;
const Member(this.name);
get(target);
set(target, value);
invoke(target, List positionalArguments, Map<Symbol, dynamic> namedArguments);
}
Member
objects are created automatically by language implementations (VM,
dart2js). Note: this class simplifies how we explain this proposal, but a viable
alternative would be to desugar the Member
object and pass the relevant
information on the Interceptor
API directly. See the alternatives section
for details.
The Member
and Interceptor
interfaces would be added to a dart:
library
known to the Dart VM and dart2js.
The decoration process is how we tell that a field, getter, setter, or method should be redirected to an interceptor. Dart annotations already provide syntax to decorate members, so we propose reusing the annotation syntax for the purpose of annotating members with interceptors.
Note: we have also considered other ideas requiring syntactic changes to the language. Please see the alternatives section below for details and discussion about tradeoffs.
Using an interceptor in a class or a library is considered syntactic sugar for decorating every member of that class or library.
Sometimes users wish to intercept members of classes that they use, but that that they don't control. For example, code loaded from a third-party package.
We propose adding a side-annotation that calls out which member is being annotated, for example:
library mylibrary;
@ApplyInterceptorTo(observable, MyClass, #name)
import 'other.dart' show MyClass;
This is similar to the side-annotation style that is used by the reflectable package.
Because interceptors are constant objects, we can determine before the program starts whether a member is decorated, and expand it accordingly. A member is expanded by creating a new declaration where the original member is made private, and the original name is used for a new member that intercepts access to the original one.
An intercepted getter
@interceptor get name <body>;
is equivalent to:
get name => interceptor.get(target, const _$nameMember());
get _$name <body>;
where:
_$name
is a unique private name not used elsewhere,target
is eitherthis
if the getter is an instance member, ornull
if it is a top-level or static member, and- the class
_$nameMember
is defined as:
class _$nameMember extends Member {
const _$nameMember() : super(#name);
get(target) => target._$name;
set(target, value) { target._$name = value; }
invoke(target, positional, named) =>
Function.apply(target._$name, positional, named);
}
More validation can be added to ensure that target is one where the Member
applies, depending on which error message is desired for misuse cases.
Similarly, an intercepted setter
@interceptor void set(value) <body>
is equivalent to:
set name(value) => interceptor.set(target, value, const _$nameMember());
set _$name(value) <body>
If a class declares both a getter or a setter, the corresponding private name
_$name
is the same for both.
An intercepted field:
@interceptor var name;
is equivalent to:
var _$name;
get name => interceptor.get(target, const _$nameMember());
set name(value) => interceptor.set(target, value, const _$nameMember());
A final field will not have the setter, and the _$name
field will be final.
Initialization does not go through the interceptor, so all initializers are updated to write directly the private symbol. Also, to keep the paramenter name in an initializer formal, we change them to be a normal parameter and move the initialization to the initializer list. For example:
class MyClass {
@incerceptor String name1 = "1";
@incerceptor String name2;
@incerceptor String name3;
MyClass(this.name2) : name2 = "2";
would become:
class MyClass {
String _$name1 = "1";
get name => interceptor.get(this, const _$nameMember());
set name(value) => interceptor.set(this, value, const _$nameMember());
String _$name2;
get name => interceptor.get(this, const _$nameMember());
set name(value) => interceptor.set(this, value, const _$nameMember());
String _$name3;
get name => interceptor.get(this, const _$nameMember());
set name(value) => interceptor.set(this, value, const _$nameMember());
MyClass(String name3) : _$name2 = "2", _$name3 = name;
}
One possible extension for this proposal would be to allow an interceptor to run during initialization. We discuss this idea in more detail in the alternatives section below.
Finally, an intercepted method:
@inteceptor method(args) <body>
is equivalent to:
method(args) => interceptor.invoke(target, positionalArgs, namedArgs,
const _$nameMember());
_$method(args) <body>
where the list of arguments and map of named arguments are the same kind that
would be part of the Invocation
passed to noSuchMethod
.
It's important to note that unlike noSuchMethod
, these invocations are fully
resolved and known at compile-time. This means that with proper inlining,
compilers like dart2js should be able to eliminate the indirection and the use
of Function.apply
in Member.invoke
.
Multiple interceptors applied on the same member are expanded in the reverse order they are written. For example:
class MyClass {
@interceptor1
@interceptor2
get name => body;
}
is equivalent to:
class MyClass {
@interceptor1
get name => interceptor2.get(this, const _$nameAMember);
get _$nameA => body;
which is then equivalent to:
class MyClass {
get name => interceptor1.get(this, const _$nameBMember);
get _$nameB => interceptor2.get(this, const _$nameAMember);
get _$nameA => body;
An alternative to using annotations would be to introduce a new syntax to denote when interceptors are applied. Some ideas we've discussed include:
Alternative A: Adding a special >>
operator that goes before async
,
async*
, sync*
if those are present. For example:
String name >> interceptor = "";
String get name >> interceptor => "";
set name(v) >> interceptor { … };
void name(v1, v2) >> interceptor { … };
Alternative B: Adding a special with
keyword that goes in the same
location. Some have suggested that interceptors feel like a mixin at the level
of a member, so the keyword with
could be used for this purpose as well. For
example:
String name with interceptor = "";
String get name with interceptor => "";
set name(v) with interceptor { … };
void name(v1, v2) with interceptor { … };
Alternative C: A new annotation syntax. For example:
@@interceptor String name = "";
@@interceptor String get name => "";
@@interceptor set name(v) { … };
@@interceptor void name(v1, v2) { … };
One concern with the alternative (A) is that it can be hard to read and that
users already have an understanding that >>
means R-shift. For instance, this
example is especially hard to read:
int get value >> interceptor => 1 >> 8;
Alternative (B) reads better than (A). Alternative (C) seems to add a tax without much benefits compared to just using plain annotations.
There are a few benefits of going with traditional annotations (as this proposal suggests):
-
There are no syntax changes required in the language. The new types will be added to a
dart:
library so the change would be backwards compatible. The challenge is that now language implementors need to resolve the type of annotations in order to distinguish plain annotations from interceptors. -
Frameworks can encapsulate whether or not they use interceptors. This also means that a framework like Polymer can switch to use interceptors internally without exposing a breaking change to their users (fields annotated
@observable
will continue to work). -
It doesn't require additional changes to also support decorating classes, libraries, or providing side-annotations.
The main point against using annotation is that it will adds semantic meaning to annotations, but until now the language didn't have any feature that directly did so. However, it is worth nothing that annotations have been given semantic meaning by frameworks in the past. In particular, annotations are visible from the mirror system and frameworks like Polymer and Angular already use that information. Users of these frameworks are familiar with this and understand that many annotations have a semantic purpose.
Readers of this proposal might also find interesting the trade-off discussion about syntax from the original python decorators proposal.
One concern about the Member
abstraction is that it introduces an object in
the intercepting API. Since this object is constant, we believe language
implementors can provide it with little overhead.
If performance is a concern, we could revisit the interceptor interfaces and
inline the information directly on the call. A detailed look at the semantics
without the Member
class are available in a suplemental document.
One possible extension to this proposal is to allow interceptors to run at the
time fields are initializated. This may be a separate interceptor than
ReadInterceptor
, since its intent is fairly different.
Such interceptor could have several important applications. For instance, it could be used to implement a static dependency injection system in Dart. That is, a user could write:
class MyClass {
@inject final MyService service;
MyClass();
}
And the inject
interceptor will initialize the service
field with the
corresponding implementation.
Under the current proposal we treat implicit tear-off of methods, for example in
the expression o.m
, as a getter, so ReadInterceptors
will be used on such
operation. With the introduction of tear-offs, we could conceively do the
same for o#m
, or have a separate kind of interceptor for this purpose.
Depending on the actual design, partial classes (see bug 8547) is a different language proposal that could help with data observability. With partial classes we wouldn't eliminate the need for code generation, but we could do so in a way that code is generated in a separate file.
For example, we would ask users to write code like this:
part 'example.g.dart'; // auto-generated
class Person {
@observable String _firstName;
@observable String _lastName;
@observable String _fullName => '$_firstName $_lastName';
}
and autogenerate example.g.dart
to have:
partial class MyClass {
int get firstName => observable.get(this, const __firstNameMember());
int get lastName => observable.get(this, const __lastNameMember());
int get fullName => observable.get(this, const __fullNameMember());
}
class __firstNameMember() {
...
get(o) => o._firstName;
}
...
This is not as general as interceptors, though. In particular, it requires code-generation, it is not possible to intercept properties on the side, and it requires conventions (such as using a private name instead of a public name) to be able to correctly override the public behavior of objects.
Here are some important implications and limitations of this proposal, many of which we have mentioned thoroughout the document:
-
Interceptors are static: everything is analyzeable at compile-time.
-
Interceptors do not change the signature of methods, so APIs are consistent and for the purpose of type analysis, interceptors can be ignored.
-
Interceptors introduce a new private symbol that is not available anywhere else in the library. That means,
_$name
cannot be fabricated by a programmer and used in the same library to "reach inside" the intercepted element. -
If we use the annotation syntax, this would be the first DEP that introduces a semantic meaning to annotations outside of Dart's mirror system.
The sources of this github repo include several examples of intereceptors working. The code is organized as follows:
-
prototype/: contains a prototype implementation that only handles fields, getters, and setters. The implementation demonstrates 3 alternative syntaxes (annotations,
with
and>>
). To keep things simple, the prototype is mainly syntax based. That means, it doesn't resolve types and it will not figure out whether an annotation is an interceptor or not, it simply assumes that they are. However, this is good enough to use for the two examples below. -
example/observe/: contains an implementation of observability using interceptors (see example 4 above).
-
example/nonnegative/: contains an example of non-nullability checks implemented as interceptors (see example 3 above).