Skip to content

Instantly share code, notes, and snippets.

@lrhn
Last active November 8, 2022 11:49
Show Gist options
  • Save lrhn/ab2b5d81c22d0fc0ad8926b5743a50ed to your computer and use it in GitHub Desktop.
Save lrhn/ab2b5d81c22d0fc0ad8926b5743a50ed to your computer and use it in GitHub Desktop.
Another views feature proposal

Dart view specification, reductionist

Author: lrn@google.com
Version: 0.11

This is an attempt at (yet) another specification of the “views” feature.

It is not intended to be revolutionary. Instead it’s more reductionist, reducing the feature to a core set of concepts that it defines for itself, without any attempt to make it work like a class or like extension methods, or be defined in terms of either.

Any similarity is entirely necessitated by the actual use-cases being similar.

The use-cases we are trying to solve for are:

  • Providing a way to add to, or replace, the exposed API of a value. (Say, change the members available on a String, or provide members for a native value.)
  • In a way that can be ensured and propagated by another API. (Say, a function which returns a native DOM node with the new API.)
  • Which has zero per-instance memory overhead (otherwise we could just use wrapper classes).
  • Has time complexity comparable to using static helper functions/extension methods, and no worse than normal instance methods (so basically zero).
  • Allows subtyping and implementation inheritance matching an existing type hierarchy (like DOM classes).

This all points towards introducing a new type with the new API, a type which has no runtime representation on the object.

So that’s what we do.

TL;DR:

view N<X1>(R id) is S1, S2 {
  members // no instance variables, no constructors.
}

Not using existing names, like class or implements, since everything here is a new concept.

The id is only visible inside members (it does not become a public getter, you have to make one if you want to expose it). The S1/S2 can be other views allowed on the same representation type, or supertypes of the representation type. The N type is an immediate subtype of precisely S1 and S2, and inherits implementation from those. (Conflicts mean no inheritance.) Defaults to is Object or is Object? if omitted, depending on whether representation type R is nullable. Members can use this and super where applicable, and refer to id. The N type is erased at runtime, becoming the R type in every way.

So, no big differences from other proposals, just not pretending to be similar to any existing declaration kind. The declaration is not a class. The id is not an instance variable. The relation to supertypes is not implements, extends or with.

Syntax

<view-declaration> ::=
  <metadata>
  'view' <identifier> <typeParams>? <parameterList> ('is' typeList)? '{'
     <member-declaration>*
  '}'

Example:

view MyString(String _string) is String {
  int operator[](int index) => _string.codeUnitAt(index);  
}

The <parameterList> must have precisely one parameter, for what we call the representation object . The parameter be positional or named, cannot be optional. The name of the parameter is called the representation object name

The type of that parameter is called the representation type of the view.

If the view is generic (has a <typeParams>), the representation type can depend on the type parameters, as can the is-clause’s type list.

  • The <member-declaration>s must not contain any instance variable declarations or constructors.
  • The view must not declare a member with the same name as the representation object name.

Static semantics

Declaration

The declaration

view I<X1 extends B1, ..., Xn extends Bn>(T<X1, ..., Xn> name) is S1<X1, ..., Xn>, ..., Sk<X1, ..., Xn> { … }

introduces a new type I per type instantiation of X1 ..Xn. This is a view type with representation type T<X1,..,Xn>. (The representation type can itself be a view type.)

The Si<X1,…,Xn> types represent some type expression which can be parameterized by any of X1..Xn. They must not be dynamic or Never.

  • If Si<X1,…,XN> is a view type, with representation type Ti, then T<X1,..,Xn> must be a subtype of Ti.
  • Otherwise, T<X1,..,Xn> must be a subtype of Si<X1,..,XN>.

If a view declaration has no is clause, treat it as if the is clause was instead either is Object? (if T<X1,..,Xn> is potentially nullable) or is Object (if T<X1,..,Xn> is definitely non-nullable). From here-on we’ll assume k > 0.

There must not be any cycles in the is-clause types. The types must form a DAG (the same type can occur more than once, we allow diamond hierarchies).

The same generic type (view or non-view) must not occur multiple times in the supertype DAG with different type instantiations. (No view Foo(List<int> x) is List<num>, List<Object>, not view C<X>(X x){} and view D(int) is C<num>, C<Object>, and not even if the types are only transitive is-super-types.)

The type I<X1, …, Xn> is then an immediate subtype of each Si<X1, …, Xn>.

Further, I<T1, …, Tn> is a subtype of I<R1, …, Rn> iff T<T1, …, Tn> is a subtype of T<R1, …, Rn>. (That is, generics of view types vary with the representation type, just like a type alias.)

A view type defines a set of instance members.

The instance members of I have two different forms: View members (refers to a concrete view member declaration of some view declaration), and forwarding members (has only a member signature which is always a supertype of the signature of the same member on the representation type).

Each such member is either inherited or from the super-view types S1..Sk, or declared in the view itself.

The is super-types S1..Sk define a set of inheritable view members, with at most one per member name, as follows:

  • For each name n where any of S1, …, Sk has a member of that name, or where Object? has a member of that name (the names ==, hashCode, toString, noSuchMethod and runtimeType):

  • Let M be the set of declarations of n of S1..Sk and Object?, defined as:

    • If Si does not have a member of that name, there is no declaration for Si.
    • If Si is a view type with a member named n, then M contains the declaration of that view member from Si.
    • If Si is a non-view type with a member named n, then M contains the member signature for n in Si.

    (So the set always contains interface member signatures for the members of Object?.)

  • If M contains precisely one element (either because only one of Si..Sk had an n member, or because multiple ones had a member, but they all referred to the same view member declaration, or all had the same member signature), that element is the element for n in the inheritable view member set.

  • If M contains two distinct view member declarations, there is no member named n in the inheritable view member set.

  • If M contains a view member declaration and an interface signature, there is no member named n in the inheritable view member set.

  • If M contains no view members, only multiple distinct interface member signatures, use the algorithm used for resolving multiple interface member signatures in an interface. If that succeeds and finds a signature which is a valid override of all the signatures of M, then that is the interface signature for n in the inheritable view members. If not, there is no member named n in the inheritable view member set.

The instance members of I itself is then:

  • For each member declaration in I:

    • If the member declaration is not abstract (it’s a view member), the member must not be an instance variable declaration, and parameters cannot be declared covariant. Such members are view declarations referring to themselves. It is a compile-time error if the member has the same base name as a member of Object?. (Cannot make those members into view members, they must stay as forwarding members which forward to the underlying representation.)

    • If the member is abstract (no body, abstract instance variable), the member signature(s, if non-final field) must (each) be a supertype of the signature of the same-named member of the representation type, which must exist. Such members are forwarding members with the same signature as the declaration.

  • For each inheritable view member from the is-super types:

    • If I does not declare a member with the same name, that inheritable member becomes a view member of I.

That is: The members of a view type are those that are either directly declared by the view type, or which are inherited unambiguously from a super-type.

Construction

An expression of the form V(e) where V is either id or id<typeArgs>, where id denotes a view declaration, is type-inferred as follows:

  • It’s a compile-time error if TypeArgs are present and are not valid type arguments to the view denoted by id.
  • Let T be a type scheme derived from the declaration of id, like we would do for an unnamed constructor of class with similar type arguments and parameter list.
  • Infer the static type of e with T as context type scheme, to the type S.
  • Infer any missing type arguments of the view denoted by id from S, like we would for a similar constructor invocation, and let W be the type of that instantiated view.
  • Let R be the representation type of W.
  • It’s a compile-time error if S is not assignable to W. (Perform any coercions allowed for an S with a context type W.)
  • The static type of V(e) is then W.

This is the only automatically provided affordance for creating expressions which has a view type from values of the representation type. You can always cast representationValue as ViewType, but that does not provide any inference and risks the cast failing at runtime.

This is not a constructor invocation. It’s an explicit type-safe type coercion which looks like a constructor invocation, the same way explicit extension member invocation uses Ext(v).foo().

Member declarations

The view can declare static member as usual.

It can declare instance members except for instance fields.

When such a view member declaration is invoked, the methods parameter list is bound in a scope where this is bound to the representation object with the view type as its static type, the representation object name is bound to the representation object with the representation type as its declared type, and type parameters are bound to the type arguments of the static (view) type of the receiver of the invocation.

Example:

view Rep<X>(int y) {
  List<X> list(X value) => List.filled(y, value);
}
///
  Rep<String>(4).list("a")

The list method exists in a “type parameter scope” containing the type variable X, and y having static type int.

When list is invoked, the invocation introduces a scope with a this bound to the integer 4 with a static type of Rep<X>, X bound to String, and y bound to the value 4 with a static type of int. Then that scope is extended with the parameter list (X value) bound to the argument list ("a"), and the body is evaluated in that scope.

Invocation

The type of a view declaration is, statically, like any other nominal type. It’s a subtype of the types of the is clause, and of either Object or Object? depending on whether the representation type is potentially nullable, and potentially of other generic instantations of itself if it's generic.

An expression of the form o.member(args) is type-inferenced as follows:

  • Infer types for o and Let v bet its static type.
  • If v is not a view type, proceed as normal.
  • Otherwise, if the declaration of member in v is:
    • An forwarding member signature s, continue as an invocation of that interface member signature.
    • A view member declaration m, continue as an invocation of that member’s interface signature.
  • If there is no declaration of member in v, continue as normal (extension methods may apply).

(Similarly for all the other applicable invocation forms. There is no super.foo which can resolve to a view method.)

Super-invocations

An invocation of the form super.member… inside an view instance member declaration, is allowed if there is a member named member in the inheritable view members of the current view declaration. If so, the invocation targets that member (and the invocation should obviously be valid). Type inference happens normally as for the signature of the invoked member.

Runtime semantics

The view type is entirely erased at runtime, replaced by its (transitive) representation type, as if it was a type alias.

We still refer to the view type in the semantics, but only when referring to the static type of an expression. Choices made based on that can all be performed at compile-time.

We can choose retain the type at runtime, for documentation purposes, but it becomes a mutual subtype of the representation type then.

Invocation

Member invocations o.member(args) on receivers, o, with a view type as static type, V are evaluated as follows:

  • Evaluate o to a value v.
  • Evaluate args to an argument list.
  • If V has a member named member, let m be that member.
    • While m is a forwarding signature, let R be the representation type of V.
      • If R is a view type, let m be the member named member of R and let V be R, and repeat this step.
      • Otherwise invoke the member member of v with args as argument as a normal instance member invocation, and the result of the member invocation o.member(args) is the result of that invocation.
    • Otherwise, the member named member on V is a view method declaration. Invoke that method declaration with this bound to v and the representation object name bound to v, and with the argument list bound to the method parameters. The result of that invocation is the result of o.member(args).
  • Otherwise proceed as normal (possible extension member invocation, or error).

(Equivalent definitions apply to getter/setter/operator invocations).

Super-invocations

An invocation of the form super.member… inside an view instance member declaration targets the unique inheritable view member with that names.

If the target member is a view member declaration, it’ll just be invoked directly at the super-view type it’s inherited from. (If we have view V1(R r) is V2 { foo() => super.foo(); }, then super.foo() is equivalent to V3(r).foo(), where V3 is the type that originally declared the inherited foo.)

If the target member is a forwarding signature, it’s instead invoked on the representation type, as a normal invocation on the representation object (which can again be a view type, so we unwrap until we find either a view member declaration or end up with an invocation on a non-view type.)

Casts/type checks

Any runtime subtype check of the form is ViewType/as ViewType/on ViewType performs the same check using the (transitive) representation type of ViewType.

Because the type is erased/made an alias

Discussion

Conflicts

A conflict between super-type members only matters if not shadowed. If it happens, a member is simply not inherited.

We do not promise that a subtype has the same members as a supertype. We already do not guarantee that the subtype has compatible members, it can shadow a supertype member with a completely different member signature. In case of conflicts, we simply do not inherit any member, and it becomes a compile-time error to try to use such a member at the subtype’s type.

The alternative is to make it an early error, requiring the view declaration to declare members which resolve the conflict, and ensure that the subtype at least has a member with the same name. Even if it does something completely unrelated. Because that’s not actually guaranteed to help, we just make it a non-error to have a conflict which isn’t used.

Forwarding

“Forwarding” to representation type members happens automatically using is InterfaceType or an abstract int member();. The semantics is not forwarding, but to call the representation type member directly (which maybe be unfolded further at compile-time if the representation type is itself a view type.)

There is no show/hide option to show part of an interface. You have to use abstract members for that. The is InterfaceType inherits every member which isn’t shadowed by another declaration or conflicted with another supertype.

You can restrict parameters in views, like

view SafeSet<T>(Set<T> _set) is Set<T> {
  // Shadows `bool Set.contains(Object?)`.
  bool contains(T value) => _set.contains(value);
}

or

view SafeSet<T>(Set<T> _) is Set<T> {
  // "Forwards" to `bool Set.contains(Object?)`.
  bool contains(T value);
}

In the latter case, the restricted type argument ensures that a valid invocation on SafeSet is also a valid invocation on the representation type.

You can even override Object members, but only through forwarding.

class SpecialClass {
  String toString([int radix]) { ... }
}
view SpecialView(SpecialClass _) {
  String toString([int radix]); // Valid forwarding.
}

It’s not possible to introduce view methods for Object member names, because those members get invoked by the language runtime in various ways where it would become very unclear whether the view method or the object instance method would apply.

Constructors

There are no constructors. The “primary constructor” syntax is used, but only to allow the syntax ViewType(value), not because it actually constructs any values. (We should use the same syntax for extension declarations too.) The parameter does not introduce any instance fields (it cannot, views cannot have instance fields), and it does not introduce a public getter with the same name. Instead, each view member gets access to the name, as if it was a final variable declaration, bound to the current representation object. It’s more like a custom-named this than like a normal parameter.

There are no other constructors either. It’s always possible to make static methods with the same behavior, but not necessarily the same convenience.

This is contentious, because it affects the exposed API.

A much more lenient approach would be to:

  • Treat the primary constructor similarly to an actual constructor (still don’t want to introduce a field, though).
  • Allows the primary constructor to be named. If so, it can also be made private.
  • Allow other constructors to be added, but only factory or redirecting constructors, so that the primary constructor is still the primary way to create a value of the view type. (Other than casting.)

This way the primary constructor is still the single source of truth about the representation type. It’s syntactically singled out in a way that highlights the representation type to any reader.

That would be acceptable too.

(Extensions)

We should update the extension declaration syntax to use "primary constructor" syntax as well, so instead of

extension Foo<T> on Bar<T> {
  foo() => ... this ...
}

it can be written as:

extension Foo<T>(Bar<T> id) {
  foo() => ... id ...
}

Then we may be able to make this inside such an extension only expose the extension methods, so that foo and this.foo works the same.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment