Abstraction over names
This is a proposal to introduce a minimal amount of abstraction over names in Dart declarations. It is a kind of static meta-programming, but at a very modest level. Consider the following example:
class LatePoint {
int? _x;
int? _y;
int get x => _x!;
int get y => _y!;
set x(int newX) => _x = newX;
set y(int newY) => _y = newY;
bool get xIsInitialized => _x != null;
bool get yIsInitialized => _x != null;
}
This class emulates an enhanced kind of late instance variables (allowing us to query whether or not the variable has been initialized). We can express the same thing in a way that is slightly more abstract as follows:
mixin LateX<T extends Object> {
T? _x;
T get x => _x!;
set x(T newX) => _x = newX;
bool get xIsInitialized => _x != null;
}
mixin LateY<T extends Object> {
T? _y;
T get y => _y!;
set y(T newY) => _y = newY;
bool get yIsInitialized => _y != null;
}
class LatePoint with LateX<int>, LateY<int> {}
Expressed in this manner, it is obvious that that there is a substantial amount of duplication in a form that resembles a Dart abstraction. We can unify those two very similar mixins if we introduce support for abstraction over names:
mixin Late<{name}><T extends Object> {
T? _#name;
T get name => _#name!;
set name(T new#Name) => _#name = new#Name;
bool get name#IsInitialized => _#name != null;
}
class LatePoint with Late<{x}><int>, Late<{y}><int> {}
There are several strawman syntactic elements here, that is, several syntactic forms and rules which can be debated forever, but I've just chosen a particular form in order to get started talking about the general idea and the underlying semantics. Here is the syntax:
Parameterization of a declaration by one or more names is specified using a name parameter list of the form <{...}>. The angle brackets are used because they remind us that this is not a base-level parameter list, it's a bit more abstract than that (e.g., we use () for parameters whose binding is an object, but we use <> for parameters whose binding is a type). Moreover, we use {} in order to remind the reader that this is about names (similarly to the case where {} is used to declare named value parameters).
The name parameter list introduces names (in the example name). These names can be used in the body of the declaration. Every name introduces both capitalizations of the given identifier, denoted by the corresponding capitalization of the name (so if we're passing the name xAndMore to the name parameter name then name is bound to xAndMore and Name is bound to XAndMore).
Identifiers can be concatenated using #. So _#name becomes _x when name is bound to x. This is achieved by parsing _#name as a single token which is like an identifier but also allows the character # (so, e.g., _ # name is a syntax error).
If the name parameter list declares a name parameter followed by ?, this name parameter is optional (positionally optional). If it is passed then it is bound to the given actual name, but if it is omitted then it is bound to a fresh, private name. All optional name parameters must be declared at the end of the name parameter list (that is, it is an error if name parameter k is optional, and name parameter k+m is not, where k and m are positive integers). Here is a variant of the above example that uses an optional name parameter:
mixin Late2<{name, backingVariable?}><T extends Object> {
T? backingVariable;
T get name => backingVariable!;
set name(T new#Name) => backingVariable = new#Name;
bool get name#IsInitialized => backingVariable != null;
}
class LatePoint2 with Late2<{x}><int>, Late2<{y}><int> {}
The name parameterization mechanism introduces entities that do not have a Dart semantics, only the instantiations have that. For example, Late is not a mixin and cannot be used as a type, but Late<{x}> is a mixin and can be used as a type. Equality for these instantiations is structural, that is, Late<{x}> is the same as Late<{x}>, but Late<{x}> and Late<{y}> are unrelated entities.
In any case, every instantiation is subject to static analysis in the binding environment of the name-parameterized declaration. This means that all the identifiers in the name parameterized declaration which are not name parameters will have the same meaning in every instantiation. If we encounter something like Late<{x}><int> then Late<{x}> is resolved at the declaration of Late, but int is resolved at the location where Late<{x}><int> occurs.
In order to avoid surprising effects, it is a compile-time error if an instantiation yields a declaration where an occurrence of an identifier is bound differently than it would be in the case where all name parameters are fresh names. For example:
String s = "Top level variable";
class A<{name}> {
String name = "Captured!";
bool get isFrobnicated => s.length < 10;
}
var a = A<{s}>(); // Error!
This is an error because it makes s in the body of isFrobnicated a reference to the instance variable, not a reference to the top level variable. In other words, this substitution captures that identifier, which is not allowed.
One obvious comparison to consider for this proposal, in particular with the example given above, is the notion of delegated properties in Kotlin. They allow for the read and write operations on an instance variable to be handled by a delegate object that has a setValue and a getValue member. When the property is read, the result is obtained by means of delegate.getValue(receiver, property), and when it is assigned a new value newValue it is done by calling delegate.setValue(receiver, property, newValue). The property is from kotlin-stdlib/kotlin.reflect (which makes the mechanism very general, but also potentially costly), but otherwise this would be something that the name abstraction proposed here could emulate. The code would be quite different, and it is not obvious which approach is more powerful, but they do have a lot of overlap as well.
Name parameterization can do other things as well. For example, it can be used to express the shared parts of structures that are unrelated according to the regular static analysis, such as record types with some similarities, but using different names:
typedef MyRecord<{n}><T> = (int, int, {T n});
void foo (MyRecord<{name}><String> r) {
print('r.name: r.$2');
}
void bar(MyRecord<{length}><double> r) {
print(r.length + r.$2);
}
It could improve the readability and consistency of the code when we are able to express the fact that those two record types have something in common, which cannot be expressed using the existing type system.
Note that https://github.com/dart-lang/language/issues/4457 has a discussion which may seem related. However, I don't think there is much of an overlap.
The main issue is that this lacks any form of:
- type manipulation
- mean to iterate over an unknown number of variable
For instance, I don't see how one could write ==/toString/copyWith with this.
For instance, I don't see how one could write ==/toString/copyWith with this.
I don't think those are use-cases for this feature.
Right, this is an attempt to explore the language design space for minimal static meta-programming features. A plain identifier substitution mechanism will only help in cases where we need to express commonalities among declarations where some names differ, but are used in the same manner. This is inherently something that typing related abstractions do not capture, which is also a major reason why I think it will provide a kind of expressive power which is currently completely absent from Dart.
The other side of the coin is that this mechanism is well-understood, both the semantics of the mechanism itself and the required implementation effort, and that makes it a rather safe bet.
As you may have noticed, the by Delegate() mechanism in Kotlin has been an important source of inspiration for this proposal. They describe it and mention some use cases here.
However, I didn't want to bake in a dependency on reflection (so we couldn't have anything corresponding to the KProperty parameter of getValue and setValue in the Kotlin approach), and I also wanted to make sure that the abstraction could be just as fast as the plain code where the name substitution has been achieved by a copy/paste operation plus manual editing. Similarly, I'd want the mechanism to be covered by static analysis just as well as other parts of Dart. The name parameter mechanism has these properties.
When it comes to ==/toString/copyWith (that is, essentially, data classes), name parameterization won't help because we'd want those members to have an implementation which is computed based on other parts of the enclosing membered entity (e.g., a class). This could be done by compile-time or run-time reflection, but it requires a way to model Dart programs (similar to the element model of the analyzer, or the mirrors in 'dart:mirrors'), and that's a much, much heavier beast to pull into the language. That would be the end of the safe bet. ;-)
We definitely still want good data classes in Dart, but I agree that it is not in the intended scope for this feature.
Comments, in no particular order.
-
About:
In order to avoid surprising effects, it is a compile-time error if an instantiation yields a declaration where an occurrence of an identifier is bound differently than it would be in the case where all name parameters are fresh names.
I'd rather make resolution happen in the uninstantiated template, so that the existing
swould keep resolving to what it resolved to at the template declaration point, and thefoo#namenames resolve to themselves. Only the publicly visible names of the instantiation are important and visible at the instantiation site, and must not cause conflicts where they occur.In any case, every instantiation is subject to static analysis in the binding environment of the name-parameterized declaration. This means that all the identifiers in the name parameterized declaration which are not name parameters will have the same meaning in every instantiation.
Yep, hygienic macros, but that should go both ways.
#nameshould be a different identifier thansin the source environment of the template, even ifnameends up being bound tosat instantiation. -
The syntax seems to work for mixins and typedefs when they are instantiated to be applied. The typedef becomes a type, the mixin becomes a mixin application of another declaration. For a class, the
A<{s}>()example (if it wasn't an error) suggests creating an instance of the typeA<{s}>.- Is it the same class/type as
A<{s}>written in another location? - Can you do
if (o is A<{s}>) ...? (Not impossible, each name instantiation remembers its names as part of the type, and is considered the same type as another instantiation with the same names. Names are remembered much the same way as invariant type parameters.) - Can you implement two different name-instantiations of the same interface? (Are they distinct interfaces
for LUB calculation? Probably yes. And then having the same generic instantiation of different name-instantiations
shouldn't be a problem.
Again, name-instantiations are like invariant type parameters, they're unrelated if they're different. And they do not exist as interfaces prior to instantiation, so we'll never need to ask "which instantation ofabstract interface class Var<{v}><T> { abstract T v; } abstract class MultiVar implements Var<{x}><int>, Var<{y}><int> {}Vardoes this value have?", always "which instantiation ofVar<{x}>does this value have?". So it's probably fine.
- Is it the same class/type as
-
Could we abstract over the name of a declaration. The examples allow instantiating a "class template" declaration to create a class instance, or probably a type/interface, but not a new declaration with a new name. That would be tricky, since we need a name to refer to the template, so if its name was templated, how would we refer to it to begin with?
Could we have a general:
template Foo<{x, y}> = declaration;feature, allowing you to write
template BoxClass<{C, v}> = class #C<T> { ... }and you can instantiate that as:
BoxClass<{LengthBox, length}>; BoxClass<{WidthBox, width}>;to declare two classes,
LengthBoxandWidthBox?That would allow instantiation can introduce a declaration. (Or two?
template BuiltThing<N> = { class #N extends BuiltValue<#N, Built#N> {} class #{N}Builder extends Builder<#N, #{N}Builder> {} } -
You can only prefix the name, not suffix it. So maybe
#{N}Builderlike above? -
Can I do
#{name1}2#name2to use two name parameters in the same name? (Just plain#x#yif no#{...}notation.) -
Can I haz default-names?
mixin ObsValue<{value=value}><T>{ T value; }, and thenclass SomeValue with ObsValue<int> {}will just usevalueas name, andclass ObsCoordinates with ObsValue<{x}, int>, ObsValue<{y}, int> { ... }will usexandy. -
Can declarations be recursive in the name?
class Cons<{car, cdr}><T>(final T #car, final Cons<{#car, #cdr}><T>? #cdr);(Probably the usual rules: Not if the recursion is structural after extension type erasure.)
-
Can it be expansive?
class Annoy<{x}><T>(final T #x, final List<Annoy<{#x#x}><List<T>> next); Annoy<#x> buildAnnoy<{x}><T>(T value, int depth) { return Annoy<#x>(value, [if (depth > 0) buildAnnoy<{#x#x}><List<T>>([value], depth - 1)]); } void main() { var annoy = buildAnnoy<{q}><String>("Hah", 5); print(annoy.q); // "Hah" print(annoy.next[0].qq); // ["Hah"] print(annoy.next[0].next[0].qqqq); // [["Hah"]] print(annoy.next[0].next[0].next[0].qqqqqqqq); // [[["Hah"]]] print(annoy.next[0].next[0].next[0].next[0].qqqqqqqqqqqqqqqq); // [[[["Hah"]]]; print(annoy.next[0].next[0].next[0].next[0].next); // [] }Seems like it introduces an unbounded number of interfaces, depending on the runtime value
5. Which means that if it's allowed, the interfaces cannot be known at compile-time. And they're not unusable, you can process them recursively too:List<T> collectAnnoy<{x}><T>(Annoy<{x}><T> annoy) => [annoy.#x, for (var next in annoy.next) for (var list in collectAnnoy<{#x#x}><List<T>>(next)) ...list)]; print(collect<{q}><String>(buildAnnoy<{q}><String>("Huh", 7))); // ["Huh", "Huh", "Huh", "Huh", "Huh", "Huh", "Huh"]I think the types match up.
But an unbounded number of interfaces means that they must be created dynamically at runtime, which can make tree-shaking harder, and I don't know how it will affect
dynamicinvocation. -
I assume you can't abstract over name instantiation. A name-abstract declaration is not a function, it's a template for creating a function. That is, no
R callWithX<R>(R Function<{_}>() callback) => callback<{x}>();. That's not a function type.Otherwise, watch me spell names at runtime using:
final _letters = {'a': <R>(R Function<{_}>() callback) => callback<{a}>(), ... 'z': ... 'A':... ... 'Z':...}; R withStringName<R>(R Function<{_}>() callback, String name) { assert(name.isNotEmpty); // And must contain only letters. R rec<{acc}>(int i) => if (i == name.length) return callback<{#acc}>(); return letters[name[i]](<{l}>() => rec<#acc#l>(i + 1)); } return letters[name[0]](<{l}>() => rec<{#l}>(1)); // No empty identifier? }We probably don't want that. 😉
- About:
In order to avoid surprising effects, it is a compile-time error if an instantiation yields a declaration where an occurrence of an identifier is bound differently than it would be in the case where all name parameters are fresh names.
I'd rather make resolution happen in the uninstantiated template, so that the existing
swould keep resolving to what it resolved to at the template declaration point, and thefoo#namenames resolve to themselves. Only the publicly visible names of the instantiation are important and visible at the instantiation site, and must not cause conflicts where they occur.
This is very nearly the same thing in different words. The notion of "resolve to themselves" would need to be defined, though. It seems to imply that every name in the name-parameterized declaration must resolve to something as declared (before any actual names are passed), and every identifier including a name parameter must resolve to a declaration in the name-parameterized declaration. This would get somewhat awkward if we have cases like this:
class A<{name1, name2}> {
int name1 = 1;
Object foo(String name2) {
return name1;
}
}
If we consider A<{x, x}> then we'd get an error (according to the rule I proposed) because name1 in return name1; will resolve to the formal parameter x after substitution, not the instance variable x. So this is a different binding than the one which is obtained if we consider return name1 in the original declaration.
I think we're going to get a really weird semantics if we simply insist that return name1; is a reference to the instance variable, and we don't care that the parameter has the same name as the instance variable in this particular instantiation, so we'd violate the normal scope rules just to be "stable" or "hygienic" (in a sense that doesn't match the usual definition).
So, basically, I'd prefer to have a rule where it is possible to look at the name-parameterized declaration and conclude something about the meaning of the code, and then (1) be able to trust the semantics to be preserved, and (2) be able to reason about the code using a non-magic textual substitution and considering the result to have standard Dart semantics.
This leaves one corner case unexplained: What do we do if a name parameter does not resolve to anything? I suspect we'll have to make that an error.
It is a potentially rather powerful mechanism:
class A<{Name}><X> extends Name<X> {
...
}
This allows A<{B}> to be a subclass of B and A<{C}> to be a subclass of a different class C. Could be fun, but is probably too hard to handle. We also won't allow instantiations where a name is replaced by a composite term:
class A<{Name}> extends Name {}
class B extends A<{List<num>}> {} // Error! A name parameter can only be bound to an identifier.
In any case, every instantiation is subject to static analysis in the binding environment of the name-parameterized declaration. This means that all the identifiers in the name parameterized declaration which are not name parameters will have the same meaning in every instantiation.
Yep, hygienic macros, but that should go both ways.
#nameshould be a different identifier thansin the source environment of the template, even ifnameends up being bound tosat instantiation.
I don't think we should make name a different identifier than s if we pass s as the actual argument to s, but I do think that we should ensure that this will not change the binding from name application to name declaration. In particular, we can pass s as the actual name to the formal name name, but this won't make an s in the body of the declaration refer to a different thing than it does if name is bound to a fresh name, because it's an error if it does.
- The syntax seems to work for mixins and typedefs when they are instantiated to be applied. The typedef becomes a type, the mixin becomes a mixin application of another declaration. For a class, the
A<{s}>()example (if it wasn't an error) suggests creating an instance of the typeA<{s}>.
Right, which is the same type as A<{s}> even if it occurs as two different pieces of syntax.
- Is it the same class/type as
A<{s}>written in another location?
Yes. We don't even have to worry about the meaning of s because it is just an identifier. If we're passing a private name then it differs from a private name with the same spelling in a different library, as usual (you could think that it's always replaced by a name that has a unique identifier of the library appended, so _x really means _x19a8bc2787 where 19a8bc2787 is the suffix that denotes the enclosing library).
- Can you do
if (o is A<{s}>) ...?
Certainly. A<{s}> is just a denotation of a class declaration (presumably a non-generic one, or it'll have type arguments computed by instantiation to bound, as usual).
(Not impossible, each name instantiation remembers its names as part of the type, and is considered the same type as another instantiation with the same names. Names are remembered much the same way as invariant type parameters.)
Each name instantiation is a result which can be created repeatedly by passing the same identifiers as actual name arguments.
- Can you implement two different name-instantiations of the same interface? (Are they distinct interfaces for LUB calculation? Probably yes. And then having the same generic instantiation of different name-instantiations shouldn't be a problem.
They would be completely independent. You could do the same thing by means of a copy-paste plus manual editing to substitute the name parameters. The results might be incompatible, according to all the normal rules, but this is true for manually written superinterfaces as well.
abstract interface class Var<{v}><T> { abstract T v; } abstract class MultiVar implements Var<{x}><int>, Var<{y}><int> {}
Yes, that's fine.
Again, name-instantiations are like invariant type parameters, they're unrelated if they're different.
Exactly. And superinterfaces don't have to be related in order to have conflicts, just like they don't have to have conflicts just because they are related.
(Exception: Same class, different type arguments, that's a conflict whether it's hand-written or the result of a substitution).
And they do not exist as interfaces prior to instantiation, so we'll never need to ask "which instantation of `Var` does this value have?", always "which instantiation of `Var<{x}>` does this value have?". So it's probably fine.
True.
- Could we abstract over the name of a declaration. The examples allow instantiating a "class template" declaration to create a class instance, or probably a type/interface, but not a new declaration with a new name. That would be tricky, since we need a name to refer to the template, so if its name was templated, how would we refer to it to begin with? Could we have a general: template Foo<{x, y}> = declaration;
I don't think the substitution mechanism could change the name of the declaration itself, because that's the thing which is parameterized.
class Name<{Name}> {}
You might try to use Name<{A}> meaning class A {} and Name<{B}> meaning class B {}, but I don't think it's worth the trouble and confusion.
So, preferred rule: The name parameters are in scope everywhere in the name-parameterized declaration except for the name of the declaration itself.
feature, allowing you to write template BoxClass<{C, v}> = class #C<T> { ... }
and you can instantiate that as: BoxClass<{LengthBox, length}>; BoxClass<{WidthBox, width}>; to declare two classes,
LengthBoxandWidthBox?
If we really want that then we can consider it. For now, I'd prefer to say "just don't do that!" ;-D If you want a specific instantiation to have a specific name then you can probably use a type alias.
class BoxClass<{name}> {...}
typedef LengthBox = BoxClass<{length}>;
typedef WidthBox = BoxClass<{width}>;
That would allow instantiation can introduce a declaration. (Or two? template BuiltThing<N> = { class #N extends BuiltValue<#N, Built#N> {} class #{N}Builder extends Builder<#N, #{N}Builder> {} }
This involves nested declarations, I haven't considered that. I was just thinking about name parameterized declarations. If you want to use the same name for several declarations then use the same actual name arguments in multiple instantiations of individual name-parameterized declarations.
Looking at it, I'm afraid I don't know what class #N extends BuiltValue<#N, Built#N> {} would mean, or how it would be used...
- You can only prefix the name, not suffix it. So maybe
#{N}Builderlike above?
No, name#Suffix is one identifier and prefix#Name is another, there's nothing impossible about switching the order. But if name is a name parameter and Suffix and prefix are not then those "#-identifiers" can become actualSuffix and prefixActual if the value passed to name is actual.
- Can I do
#{name1}2#name2to use two name parameters in the same name? (Just plain#x#yif no#{...}notation.)
That would be name1#2#name2, which would become a2b if the identifier bound to name1 is a, and the identifier bound to name2 is b.
- Can I haz default-names?
mixin ObsValue<{value=value}><T>{ T value; }, and then>class SomeValue with ObsValue<int> {}will just usevalueas name,> andclass ObsCoordinates with ObsValue<{x}, int>, ObsValue<{y}, int> { ... }will usexandy.
That shouldn't be hard to do. However, I do think it's useful to have support for fresh names. But it might be very convenient to have support for both.
- Can declarations be recursive in the name? class Cons<{car, cdr}><T>(final T #car, final Cons<{#car, #cdr}><T>? #cdr);
Great question! ;-)
class Cons<{car, cdr}><T>(final T car, final Cons<{car, cdr}><T>? cdr);
I think this would simply work. In particular Const<{car2, cdr2}> denotes the following declaration:
class Cons_generatedNameForCar2Cdr2<T>(
final T car2,
final Cons_generatedNameForCar2Cdr2<T>? cdr2
);
(Probably the usual rules: Not if the recursion is structural after extension type erasure.) 8. Can it be expansive?
Hopefully not. ;-D
Actually, it looks like it can be expansive. We should probably just make that an error. There's no way to stop.
class Annoy<{x}><T>(final T #x, final List<Annoy<{#x#x}><List<T>> next); Annoy<#x> buildAnnoy<{x}><T>(T value, int depth) { return Annoy<#x>(value, [if (depth > 0) buildAnnoy<{#x#x}><List<T>>([value], depth - 1)]); } void main() { var annoy = buildAnnoy<{q}><String>("Hah", 5); print(annoy.q); // "Hah" print(annoy.next[0].qq); // ["Hah"] print(annoy.next[0].next[0].qqqq); // [["Hah"]] print(annoy.next[0].next[0].next[0].qqqqqqqq); // [[["Hah"]]] print(annoy.next[0].next[0].next[0].next[0].qqqqqqqqqqqqqqqq); // [[[["Hah"]]]; print(annoy.next[0].next[0].next[0].next[0].next); // [] }
class Annoy<{x}><T>(final T x, final List<Annoy<{x#x}><List<T>> next);
So Annoy<{x2}> denotes the following declaration:
class Annoy_generatedNameForX2<T>(
final T x2,
final List<Annoy<{x2x2}>><List<T>> next,
);
class Annoy_generatedNameForX2x2<T>(
final T x2x2,
final List<Annoy<{x2x2x2x2}>><List<T>> next,
);
// ... etc ...
Seems like it introduces an unbounded number of interfaces, depending on the runtime value
5. Which means that if it's allowed, the interfaces cannot be known at compile-time.
It should terminate, the set of declarations must be finite. It is not a good idea to try to handle infinite programs.
And they're not unusable, you can process them recursively too: List<T> collectAnnoy<{x}><T>(Annoy<{x}><T> annoy) => [annoy.#x, for (var next in annoy.next) for (var list in collectAnnoy<{#x#x}><List<T>>(next)) ...list)]; print(collect<{q}><String>(buildAnnoy<{q}><String>("Huh", 7))); // ["Huh", "Huh", "Huh", "Huh", "Huh", "Huh", "Huh"]
Perhaps, but I'd prefer to get things done rather than diving into infinite recursions. ;-)
I think the types match up.
Static analysis will in principle start from scratch with every (unique) application of a name-parameterized declaration. If the types don't match up then it's just a wrong application.
But an unbounded number of interfaces means that they must be created dynamically at runtime, which can make tree-shaking harder, and I don't know how it will affect
dynamicinvocation.
- I assume you can't abstract over name instantiation. A name-abstract declaration is not a function, it's a template for creating a function.
Right.
That is, no
R callWithX<R>(R Function<{_}>() callback) => callback<{x}>();. That's not a function type.
I don't understand how Function would be turned into a name-parameterized declaration, so this would be a compile-time error right there.
Of course, we could declare a name-parameterized declaration and give it the name Function, but I assume that's not the idea.
Otherwise, watch me spell names at runtime using: final letters = {'a': <R>(R Function<{}>() callback) => callback<{a}>(), ... 'z': ... 'A':... ... 'Z':...}; R withStringName<R>(R Function<{_}>() callback, String name) { assert(name.isNotEmpty); // And must contain only letters. R rec<{acc}>(int i) => if (i == name.length) return callback<{#acc}>(); return letters[name[i]](<{l}>() => rec<#acc#l>(i + 1)); } return letters[name[0]](<{l}>() => rec<{#l}>(1)); // No empty identifier? }
We probably don't want that. 😉
Agreed, whatever it is! 😁
I guess the goal is similar to flutter_hooks then? In that it enables "mixins-like" that don't have name-clash issues?
If so, I think we'd need the ability to use the same mixin with different "name" args:
mixin StreamListener<{name}><T> on State<StatefulWidget> {
Stream<T> get {name}Stream;
late StreamSubscription<T> _{name}Subscripton;
AsyncSnapshot<T> {name} = AsyncSnasphot.empty();
@override
void initState() {
super.initState();
_{name}Subscription = {name}Stream.listen((value) => /* update {name} */);
}
@override
void dispose() {
_{name}Subscription.close();
super.dispose();
}
}
Then used as:
class MyState extends State<MyStatefulWidget> with StreamListener<isLoggedIn><bool>, StreamListener<appState><AppState>, {
@override
Stream<bool> get isLoggedInStream => ...;
@override
Stream<AppState> get appStateStream => ...;
@override
Widget build() {
print(isLoggedIn);
print(appState);
}
}
What do we do if a name parameter does not resolve to anything?
I think I'd prefer to not allow a templated name to occur freely in the template.
The name parameters derived names, must declare or denote a name declared by the template itself.
If the template has to refer to an interface member, it has to declare that member somehow. Maybe as @override #name();
Which also means that the lexical scope for the template code can be used to resolve all the templated names before instantiating them, and the instantiated names are only visible in the instantiated interface.
@rrousselGit wrote:
I guess the goal is similar to
flutter_hooksthen?
Name parameterization and flutter_hooks are of course completely different things, but you could say that there are some likely overlaps when it comes to situations where each of them could be considered relevant.
For example, name parameterization can definitely enable a systematic creation of mixin declarations that differ only in the choice of certain identifiers, and this can be used to avoid the name clashes which are mentioned, e.g., here.
It may not be the goal of the name parameterization mechanism as such (a language mechanism should probably always be more broadly applicable than anything which can be spelled out as a single goal), but it's a very useful example of a possible way to use this mechanism.
we'd need the ability to use the same mixin with different "name" args:
mixin StreamListener ...
Right, and that's a very nice example!
By the way, this technique is used in the original posting, too, in the declaration class LatePoint with Late<{x}><int>, Late<{y}><int> {}.
It is also already possible to use the same mixin multiple times in the same superclass chain. It's just that we need the renaming support in order to be able to achieve the desired interface (that is, LatePoint should have an int getter named x and another one named y, and so on, and we want to reuse the same logic and structure with those different sets of names).
The name parameterization mechanism can do a lot of other things as well. I already mentioned a type alias in the original posting:
typedef MyRecord<{n}><T> = (int, int, {T n});
void foo (MyRecord<{name}><String> r) {
print('r.name: r.$2');
}
void bar(MyRecord<{length}><double> r) {
print(r.length + r.$2);
}
This makes it possible to express (and enforce) some structural regularities that the type system cannot capture (in the example it is maintained that the given record types have two positional components of type int plus a named component). There's no way to express and enforce a similar kind of regularity using a hook, or using any other abstraction mechanism in current Dart.
The mechanism would obviously be more powerful if it would support other syntactic categories (for example, we could allow actual arguments to be expressions or statements). However, I didn't want to go there because this is an attempt to find something which is simple and well understood, and then see how far it takes us.
@lrhn wrote:
I think I'd prefer to not allow a templated name to occur freely in the template.
It does take a couple of steps to determine what this would mean.
A name parameter will never occur free in a name-parameterized declaration because it is by definition a reference to the name parameter declaration in the <{...}> parameter list. So this would necessarily be concerned with the situation after the name-parameterized declaration has been applied to a list of actual names. I'll assume that we make it an error for the substitution step to capture existing identifiers.
var x = 1;
class A<{name}> {
Object name() => x;
}
var y = A<{x}>(); // Error!
This is an error because the substitution yields a class where the method x returns a tear-off of itself, rather than returning the value of the top-level variable x, and that kind of rebinding of names that are (apparently) already resolved will make it pretty hard to read and understand a name-parameterized declaration like A.
The opposite situation occurs when an identifier x is substituted into a location where it resolves to a declaration whose name is a name parameter which was turned into the same identifier x by the substitution.
class A<{name}> {
final String name = "Hello!";
Object bar(int x) => name;
}
void main() => A<{x}>(); // Error!
This would be an error for a similar reason.
However, we definitely need to allow a name parameter to give a customizable name to a declaration in the body, as in the examples where this specific behavior is the whole point. This includes anything that resembles the Kotlin delegated properties, e.g., the Late mixin in the original posting.
In the case where such a declaration with a customizable name is an instance member of a class/enum, the name parameter can be a reference to another declaration that it overrides. We may not be able to detect this relationship before the substitution has taken place because the superinterfaces may depend on the substitution as well. So this kind of name may or may not be a reference to a different declaration, but it is not looked up using the lexical scope, which makes it kind of free.
If a name parameter is used as a member access (e.g., myReceiver.name()) it is again not found in the lexical scope (so it's not a free occurrence in the traditional sense), but it may still have completely different properties after substitution with different actual name arguments.
I think the conclusion must be that we can (1) embrace the syntactic mechanism as such, and allow it to express a lot of things that we cannot abstract over today. In that case we wouldn't try to hunt down every single one of all these cases where it can be argued that an occurrence of a name parameter is "free" (to mean substantially different things with different instantiations). With this option, static analysis would be performed separately for each list of actual name arguments.
We could also (2) find a tiny set of things which are allowed (everything else is an error), such that the static analysis of the name-parameterized declaration can be performed once and for all. I'm not convinced that (2) is worth the trouble, if we can even find a set that is tiny enough to have this property.
This looks related to https://github.com/dart-lang/language/issues/4138.
This looks related to #4138.
Right—one thing that comes to mind is that the name abstraction which is proposed here could be used to obtain a variant of the solution to the initial example of #4138. I'm using name parameters to allow subclasses of Pair to use different names (for the named constructor parameters as well as the instance variables). This implies that first and lower will not have to coexist, and we don't have to consider changes to the notion of a function type (which is heavy lifting, so it's a lot better if we don't have to do that).
I adjusted the example to avoid a couple of issues. I've added bounds to T and U in Pair because first ?? this.first may not have the intended semantics in a situation where T is a nullable type. I adjusted the bound on T in Range (if we use T extends Comparable then we're promising that a T can be compared to anything whatsoever, but the actual parameter type of compareTo would probably be T rather than dynamic, and my change turns myT.compareTo(1) into a compile-time error rather than a run-time error).
Finally, I'm using declaring constructors (because they are being added to the language right now, and that's cool ;-).
We get this result:
class Pair<{first, second}><T extends Object, U extends Object> {
this(final T first, final U second);
Pair<T, U> copyWith({T? first, U? second}) => Pair(
first ?? this.first,
second ?? this.second,
);
}
class Range<T extends Comparable<T>> extends Pair<{lower, upper}><T?, T?> {
this({super.lower, super.upper});
}
Note that we are using a name-instance of Pair (namely Pair<{lower, upper}>) whose instance variables have the names lower and upper, we aren't hiding any other names (for example, Pair<{lower, upper}> does not use the name first or second at all, there are no named parameters with those names and no instance variables with those names.
It's important to note that we don't have a Pair class at all, but for every choice of actual names n1 and n2 we can obtain a corresponding Pair<{n1, n2}> class, which is a full-fledged, normal class.
The comparison shows that there is a dilemma: The solution that I've just described allows for a Range class that has the desired naming throughout, and doesn't have unwanted leftovers (for example, it doesn't have an inherited instance variable named first). In this sense it is a clean solution. On the other hand, it means that we cannot use a reference of type Pair<{n1, n2}> to refer to an instance of Range unless n1 is lower and n2 is upper. Range is simply unrelated to Pair<{n1, n2}> for any other choice of n1 and n2.
In contrast, the approaches discussed in #4138 will preserve Pair as a type and it makes Range a subclass of Pair, but this implies that it must be possible to use the interface of Pair (with no notion of name parameterization), which means that it must be possible to do (myRange as Pair<SomeType>).first and (most likely) even myRange.first, which is definitely a potential source of confusion when there is no conceptual justification for a Range to even have a member whose name is first.
mixin Late<{name}><T extends Object> { T? _#name; T get name => _#name!; set name(T new#Name) => _#name = new#Name; bool get name#IsInitialized => _#name != null; } class LatePoint with Late<{x}><int>, Late<{y}><int> {}
As a occassional Dart user I find this proposition as introduction of very magical syntax. I am strongly against that, as with verbose name previously it is almost quite obvious what the code is doing. Introducing such: T? _#name or such: name#IsInitialized totally breaks this clarity. I vote for reducing verbosity via simplification which is obvious for reader at first glance and, not via adding magic syntax which for average programmer looks very unfamiliar.
Take a look at Kotlin. Its syntax is very conscise and simple while still gives a lot of control. The true advantage which Dart has is AoT compilation on every platform and lack of JVM. It just needs to get rid of verbosity (abstract interface class, required in params, allowing passing via kwargs only at callee side (IMO caller shouls decide) and so on. Magic syntax won't solve these problems, but will add new (lack of clarity)
The syntax may seem magical, but I think it's mainly a specialist's job to write those name-parameterized declarations, and most Dart developers probably won't ever see them (but they might use it indirectly without worrying about the detailed mechanism that makes it work).
In particular, it's intended to provide general language-level support for mechanisms that resemble delegated properties in Kotlin.
The point is that you cannot abstract over names in Dart, you just have to copy the code and search-and-replace each of the declared names that you wish to change. This proposal allows us to avoid maintaining the many separate copies of otherwise identical snippets of code that we'd get if it is done using copy and paste. As usual, DRY.
I agree with the purpose, but maybe it will be worthwhile to reconsider this syntax (maybe it can be expressed in more natural way)?
The actual syntax of any mechanism is always the topic of long debates. Expressing any given mechanism in a way that makes sense is definitely a goal for all members of the language team. This doesn't mean that everybody agrees on which syntax is more meaningful. ;-)