-
Notifications
You must be signed in to change notification settings - Fork 209
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
More capable Type
objects
#4200
Comments
That sounds like a Kotlin companion object.
Can we have class C {
// Before any static member:
static class extends A.class with HelperMixin1 implements HelperType2;
// Following static members are membes of this static class.
} and you can access the type as You can only extend or implement a
We probably still want to ensure that tear-offs from a static-member type is a canonicalized constant. Somehow. (That's one of the reasons I don't want normal classes to be able to implement the special singleton classes.) To make this equivalent to the current behavior, I assume constructors have access to the type parameters of the (Also, it's currently possible to have
Rather than needing this intersection, just let the generated mixin be
Probably want both a non-static and static type bound, say This will be yet another reason to allow static and instance members with the same name. How will this interact with So, dynamic x;
// ...
... (Object? o) {
...
x = o.runtimeType;
...
...
x.foo(); we may have to retain a lot of static methods that could be tree-shaken today, because we can't tell statically whether they're invoked or not. |
print((int).runtimeType); // _Type
print((int).runtimeType.runtimeType); // _Type
print((String).runtimeType); // _Type But... if (I think, the explanation is that Maybe a function like |
I would say that Then Or maybe that's a bad idea, because of what it does to tree shaking. That is:
A generic type's class objects have instances for each instantiation, and the constructor members can access those. The getters and setters of static variables are not represented by instance variables, they all access the same global variable's state. |
Great comments and questions, @lrhn!
It's similar to Kotlin (and Scala) companion objects, but also different in other ways: I'm not suggesting that Dart classes should stop having the static members and constructors that we know today, I'm just proposing that we should allow developers to request access to some of the static members and constructors (determined by the interface of the This differs from the companion objects in that there is no support for obtaining a companion object from a given actual type argument (because the type argument was erased). In contrast, a Dart type argument will always be able to deliver the corresponding For example: abstract class A<X> { X call(); }
class B1 static implements A<B1> {}
class B2 {}
void main() {
var type = someExpression ? B1 : B2;
if (type is A) {
var newObject = type();
}
} This means that the Kotlin/Scala developer must predict the need to invoke static members or constructors up front (when the concrete type is known) and must pass a reference to the required companion object(s) along with the base data. In contrast, Dart programs can pass type arguments along in the same way they do today, and then it will be possible to get access to the static members and constructors arbitrarily late, as long as the given type is available as the value of a type parameter. In the case where a class does not have a
This seems to imply that we would implicitly generate a static member or a constructor from the instance method implementations. This may or may not be doable, but I'm not quite sure what the desired semantics should be. When it comes to code reuse I would prefer to have good support for forwarding, possibly including some abstraction mechanisms (such that we could introduce sets of forwarding methods rather than specifying them one by one). This could then be used to populate the actual class with actual static members as needed, such that it is able to have the desired
I think the current approach to constants based on constructors and static members is working quite well. The ability to access static members and constructors indirectly via a reified type is an inherently dynamic feature, and I don't think it's useful to try to shoehorn it into a shape where it can yield constant expressions. There's no point in abstracting over something that you already know at compile time anyway.
Good point! Done.
I'm not quite sure what In that case,
A class that doesn't have a A class that does have a static implements clause makes some of its static members and constructors callable from the implicitly generated forwarding instance members, but this is no worse than the following: class A {
static int get foo => 1;
}
class B {
void foo() {
print(A.foo);
}
} We may still be able to detect that With "more capable It may be harder, but it does sound like a task whose complexity is similar to that of tracking invocations of instance members. That is, if we're able to determine that |
@tatumizer wrote:
If you evaluate a type literal (such as In particular, it is certainly possible for an implementation to evaluate Those reified objects may then have different interfaces, that is, they support invocations of different sets of members, so certainly it's possible for On the other hand, the reified There's nothing special about this (and that's basically the point: I want this mechanism to use all the well-known OO mechanisms to provide flexible/abstract access to the static members and constructors which are otherwise oddly inflexible, from an OO perspective).
If we have So we can certainly do abstract interface class MyInterface {
int get foo;
}
class D static implements MyInterface {
static int get foo => 1;
}
void f<X extends D>() {
var reifiedX = X;
if (reifiedX is MyInterface) {
print(reifiedX.foo);
}
} |
Could the following code similar to C#'s syntax: abstract class Json {
static fromJson(Map<String, dynamic> json);
Map<String, dynamic> toJson();
}
class User implements Json { ... } be syntatic sugar for this proposal's syntax? abstract class Json$1 {
Map<String, dynamic> toJson();
}
abstract class Json$2 {
fromJson(Map<String, dynamic> json);
}
class User implements Json$1 static implements Json$2 { ... } To keep EDIT: I can't imagine any piece of code implementing the same interface for instance methods and static methods. However, I can imagine most use cases implementing an interface with instance methods and static methods. That's why I would prefer if instance and static interfaces were merged. |
@eernstg wrote:
This can't be! Today (An argument against |
The That doesn't mean that (I chose Spoiler: void main() {
static(C).static(C.static.static.C).static(C).static;
}
class C {
static C get static => C();
C call(Object? value) => this;
}
C static(Type value) => C();
typedef _C = C;
extension on _C {
_C get static => this;
_C get C => this;
} |
Just to clarify: speaking of Object methods, I didn't mean
That's not the reason to disqualify the word - in practice, it won't hurt anyone. Most people believe Still, it's not clear what |
Class |
If A is a regular class, we can always say Q: Can class say |
Kotlin's companion object model is worth looking into, it won't take long: https://kotlinlang.org/docs/object-declarations.html#companion-objects Main difference is that the companion object has a name and a type, and - most importantly - Kotlin has a word for it, which is (predictably) "companion object". It would be very difficult to even talk about this concept without the word, so I will use it below. The idea is that you can extract a companion object from the object, e.g., (using dart's syntax), I don't know if there's a simple way to express the same concept in dart without introducing the term "companion object". Does anyone have issues with this wording? Interestingly, you very rarely need to extract the companion object explicitly, but the very possibility of doing so explains a lot: it's a real object; consequently, it has a type; this type can extend or implement other types - the whole mental model revolves around the term "companion object". |
@Wdestroier wrote:
abstract class Json {
static fromJson(Map<String, dynamic> json);
Map<String, dynamic> toJson();
}
class User implements Json { ... } Desugared: abstract class Json$1 {
Map<String, dynamic> toJson();
}
abstract class Json$2 {
fromJson(Map<String, dynamic> json);
}
class User implements Json$1 static implements Json$2 { ... } I agree with @mateusfccp that it is going to break the world if a clause like In other words, it's crucial for this proposal that abstract class StaticInterface1 { int get foo; }
abstract class StaticInterface2 { void bar(); }
class B static implements StaticInterface1 {
final int i;
B(this.i);
static int get foo => 10;
}
class C extends B static implements StaticInterface2 {
C(super.i);
static void bar() {}
} This also implies that there is no reason to assume that a type variable void f<X extends Y, Y static extends StaticInterface1>() {
Y.foo; // OK.
X.foo; // Compile-time error, no such member.
} |
I must say I am in love with this proposal. It solves many "problems" at once (although it may introduce more? let's see how the discussion goes), and it's a very interesting concept. I agree that it's not as straightforward to understand it, but once you understand, it makes a lot of sense. I also understand the appeal in having the dynamic and static interfaces bundled together, as @Wdestroier suggests, so if we could come with a non-breaking and viable syntax for doing it (while still providing the base mechanism), I think it would be valuable. |
@tatumizer wrote:
Today If you want to test whether the reified type object for void main() {
var ReifiedString = String; // Obtain the reified type object for the type `String`.
if (ReifiedString is Object) { // Same operation as `String is Object`.
// Here we know that `ReifiedString` is an `Object`, but
// we knew that already, so we can't do anything extra.
ReifiedString.toString(); // Can do, not exciting. ;-)
}
} Testing that It's the instance members of the tested type This means that if The reason why I'm emphasizing that it's all about instance members is that we already have the entire machinery for instance members: Late binding, correct overriding, the works. The static members and the constructors are just used to derive a corresponding instance member that forwards to them, and all the rest occurs in normal object land, with normal OO semantics. |
@eernstg : I understand each of these points, but they don't self-assemble in my head to result in a sense of understanding of the whole. The problem is terminological in nature. See my previous post about Kotlin. |
About Kotlin's companion object, I already mentioned it here. The main point is that you cannot obtain the companion object based on a type argument, you have to denote the concrete class. This reduces the expressive power considerably, because you can pass the type along as a type parameter and you can pass the companion object on as a regular value object, and then you can use the type as a type and the companion object as a way to access static members and constructors of that type. This is tedious because you have to pass the type and the object along your entire call chain whenever you want to use both. You could also pass the type alone, but then you can't get hold of the companion object. Or you could pass the companion object alone, but then you can't use the type (which is highly relevant, e.g., if you're calling constructors). I spelled out how it is possible to write a companion object manually in Dart in this comment. With this proposal, we can obtain the "companion object" (that is, the reified type object) for any given type (type variable or otherwise) by evaluating that type as an expression at any time we want, and we can test whether it satisfies a given interface such that we can call the static members / constructors of that type safely. |
@tatumizer wrote:
The role of the companion object in Kotlin and Scala is played by the reified type object in this proposal. We could of course also introduce an indirection and just add a |
@eernstg : that's where I have to disagree. It's an extra step for cognitive reason, which is a hell of a reason. :-) (Main property of a companion object, apart of its very existence, is that it has a clear type, which is visibly distinct from the type of the object itself, and it's a part of an (explicitly) different hierarchy. The difference is in explicitness). |
If we just use the phrase 'companion object' rather than 'reified type object', would that suffice? I don't think there's any real difference between the members of a companion object in Scala and Kotlin, and the forwarding instance members in this proposal, so the reified type object is the companion object. There is a difference in that this proposal only populates the companion object with forwarding instance members when the target class has a We could also choose to populate every reified type object with all static members and all constructors. However, I suspect that it's a better trade-off to avoid generating so many forwarding methods because (1) they will consume resources (time and space) at least during compilation, and perhaps the unused ones won't be fully tree-shaken, and (2) they aren't recognized by the static type system if the reified type object doesn't have any useful denotable supertypes (so all invocations would then have to be performed dynamically).
Good point! It is indeed an element of implicitness that this proposal works in terms of a 1:1 connection between each targeted static member / constructor and a forwarding instance member of the reified type object. We don't have that kind of relationship anywhere else in the language. The connection is created by specifying exactly how we will take a static member declaration and derive the corresponding instance member signature, and similarly for each constructor. I don't know how this could be made more explicit without asking developers to write a lot of code that would be computed deterministically anyway (which is basically the definition of redundancy). For the type, the |
I don't know if that will suffice, but it would certainly be a step in the right direction. When I see the expression "reified type object", my head refuses to operate further - though I can guess what this means, I'm not sure the guess is correct, and the word "reified" especially scares me off (like, WTF: how this reified type object differs from just type object?). I'll respond to the rest of your comments here later, need time to process :-). |
True, it's important to be an opt-in feature. abstract class MyClass {
// No effect on subtypes.
static String name() => 'MyClass';
// Has effect on subtypes.
abstract MyClass();
// Has effect on subtypes.
abstract static MyClass fromMap(Map<String, dynamic> json) =>
MyConcreteClass(property: json['property']);
} |
Here's an arrangement I could understand:
print(String.companionObject is StringCompanion); // true
print(String.companionObject.runtimeType == StringCompanion); // true This is achieved by adding one method to the Type class: class Type {
Object companionObject; // just an Object
// etc...
}
class Foo<T static implements SomeKnownInterface> {
bar() {
T.companionObject.methodFromKnownInterface(...);
}
}
class Foo static implements FromJson<Foo> {
// no changes to the existing syntax
static Foo fromJson(String str) { ... }
} The companion object will only include the methods from the
The difference of this design and the original one is that, given a type parameter T, you can write WDYT? (Possible alternative for "companionObject": "staticInterfaceObject" or something that contains the word "static") |
Very good! I think I can add a couple of comments here and there to explain why this is just "the same thing with one extra step" compared to my proposal. You may prefer to have that extra step for whatever reason, but I don't think it contributes any extra affordances. Also, I'll try to demystify the notion of a 'reified type object'.
Right, that's exactly what I meant by 'We could of course also introduce an indirection' here.
You'd have to use print((String).companionObject is StringCompanion); // true
print((String).companionObject.runtimeType == StringCompanion); // true But the value of evaluating a type literal like The core idea in this proposal (meaning "the proposal in the first post of this issue") is that this object should (1) have instance members forwarding to the static members and constructors of the type which is being reified, and it should (2) have a type that allows us to call those static members and constructors (indirectly via forwarders) in a type safe manner, without knowing statically that it is exactly the static members and constructors of Returning to To compare, this proposal will do exactly the same thing in the following way (assuming that print(String is Interface); // true
print((String).runtimeType == Interface); // false The second query is false because the run-time type is not exactly
Here's how to do it in this proposal: class Foo<T static extends SomeKnownInterface> {
bar() {
(T).methodFromKnownInterface(...);
}
} The proposal has an extra shortcut: If we encounter
These statements are true for this proposal as well.
The corresponding statement for this proposal is that if the class doesn't declare
In your proposal you can write I hope this illustrates that the two approaches correspond to each other very precisely, and the only difference is that the |
@eernstg: I think I can pinpoint the single place where our views diverge, and it's this: |
Great, I think we're converging!
That's generally not a problem. In my proposal, the RTO for a given class has a type which is a subtype of Currently, we already have a situation where the result returned from the built-in This implies that it isn't a breaking change to make those evaluations yield a result whose type isn't Next step, the static type of an expression that evaluates a type literal that denotes a class/mixin/etc. declaration can include the meta-member mixin. In other words, the static type of This implies that we can safely assign this RTO to a variable with the abstract class StaticFooable { void foo(); }
class A static implements StaticFooable {
static void foo() => print('A.foo');
}
void main() {
StaticFooable companion = A;
companion.foo(); // Prints 'A.foo'.
} However, we can not use an overriding declaration of This means that we don't know anything more specific than It might seem nice and natural if we could make the static interface co-vary with the interface of the base object itself (such that we could use However, note that it would certainly be possible for a type argument to use subsumption in static interface types: abstract class StaticFooable { void foo(); }
abstract class StaticFooBarable implements StaticFooable { void bar(); }
class B extends A static implements StaticFooBarable {
static void foo() => print('B.foo');
static void bar() => print('B.bar');
}
void baz<X static extends StaticFooable>() {
X.foo(); // OK
X.bar(); // Compile-time error, no such member.
(X as dynamic). bar(); // Succeeds when `X` is `B`.
}
void main() {
baz<B>(); // OK, `StaticFooBarable <: StaticFooable` implies that `B is StaticFooable`.
} |
Why do you need this Compare with this: we can declare some method as returning To be sure if we are on the same page, please answer this question. The static type of expression (After re-reading your response, I am not even sure we disagree on anything important, And I do acknowledge the benefits of your proposal, assuming we agree that |
After considering it more, the idea of including in RTO only the references to methods from the declared static interfaces might be unnecessary. Whenever the tree-shaker decides to preserve class C, it will most likely have to preserve its noname constructor, too (otherwise, no one can create an instance). Apart from constructors, the classes rarely have static methods, and those that are potentially used could be identified by name (e.g. if someone says Another point: I think static interfaces don't make much sense. I can't imagine the situation where a class expects type parameter to implement some static methods without also expecting the instances to implement some specific regular methods. abstract class Ring<T> {
abstract T operator+(T other);
abstract T operator*(T other);
// ...etc
abstract static T zero; // SIC!
abstract static T unity;
}
class Integer implements Ring<Integer> {
final int _n;
Integer(this._n);
// ...implementation of operators,
static const zero = Integer(0);
static const unity = Integer(1);
} |
@eernstg: Consider the predicate What we want is this: if Then, how to make a type implement a regular interface? We have to use (It's not always possible to statically implement any given interface - e.g. if the interface contains operators, there's no syntax to statically implement them, but it's OK). If the class says it |
@tatumizer wrote:
I think we as a community will (and should!) assign authoritativity to each other in a subtle and continuously evolving manner, so let's avoid making that a concrete thing. ;-) Anyway, I recently commented on Dart and recursive type aliases here. |
@tatumizer wrote:
Agreed, this is a description of some of the properties of the proposal in this issue. Let's use In particular, if We may then do object things with Note that The direct syntax for If we consider a type variable abstract class Foo { void foo(); }
class C static implements Foo { static void foo() {}}
void main() {
(C).foo(); // OK.
C.foo(); // OK, using said syntactic sugar, same as previous line.
void f<X>() {
final reifiedX = X;
if (reifiedX is Foo) reifiedX.foo();
}
f<C>();
} We could also do
Right, there's nothing new here at all when it comes to void main() {
final reifiedType = int;
print(reifiedType.toString());
} The only difference is that today's reified type objects have static type
Yes, I think we agree on that. Let me restate the situation: The reason why The syntactic sugar I mentioned would be applicable (only) in the case where the receiver is a type literal |
A small (self-)nitpick. The type will automatically static-implement Object regardless of anything, because today, Yes, it looks like a consistent concept, congrats! 😄 |
What's also pretty cool about this proposal is that you could write a code generator that looks for |
Thanks for the kind words, @benthillerkus! It's very tempting to introduce a way to generalize the behaviors of the reified type objects. One way to do it would be to support an |
I developed the idea about allowing the reified type objects to inherit behavior a bit further. You might love or hate the following kind of function (more details in the original post, in the section 'Customized behavior'): void main() {
String v1 = build('Hello!');
List<num> v2 = build(1);
Set<List<int>> v3 = build(2);
print('v1: ${v1.runtimeType} = "$v1"'); // 'v1: String = "Hello!"'.
print('v2: ${v2.runtimeType} = $v2'); // 'v2: List<num> = [1]'.
print('v3: ${v3.runtimeType} = $v3'); // 'v3: _Set<List<int>> = {[2]}'.
} |
@eernstg: EDIT: the same functionality can be achieved by a syntax like if (map case Map<var K, var V>) ... In the previous discussion, the counterargument was that there's not much you can do with K and V. But now you can do something. Not clear how much though. I think the problem you are trying to solve (illustrated by build method) is: (again illustrated y example, otherwise it becomes too abstract): // returns an initialized instance of Addable
Addable f(T static implements ProducerOf<A extends Addable<A>> type, A value) {
var a = type(); // returns Addable<A>
a.add(value);
return a;
}
var addable = f(List<int>, 5);
addable.add(10); // must work Is this correct? 1 (Assuming we can somehow make Footnotes
|
That's a good question! I do think it makes a difference in the case where anything gets more expensive because a particular kind of information is available. So if every reified type object We will definitely get more flexibility and more expressive power if anything (such as Also, if we're using a feature like 'more capable type objects' to express something like
I'd claim that you can do a lot with
void main() {
final xs = <int>[2, 3];
List<num> ys = xs;
print(xs.reduce((a, b) => a + b)); // OK.
print(ys.reduce((a, b) => a + b)); // Throws.
} I tend to think that these things are important. ;-)
Let me try: class A {}
abstract class HasNoArgConstructor<X> {
X call();
}
class AType static implements HasNoArgConstructor<AType> {
// The implicitly induced constructor will do, but w can also write it explicitly.
AType();
void add(A value) {...}
}
class ASubType extends AType static implements HasNoArgConstructor<ASubtype> {
// The constructor is implicitly induced, `add` is inherited, so we don't
// have to write anything here. But we could of course override, if needed.
}
Y foo<Y static extends HasNoArgConstructor<Y>() => Y();
void bar<Y extends AType static extends HasNoArgConstructor<Y>>() {
final Y y = Y();
y.add(A());
}
void main() {
AType x = foo();
y.add(A());
ASubtype y = bar();
} OK, we can do those things.
I can't quite see how some of the details would work. For instance, I wouldn't put a Perhaps it could be expressed as follows?: abstract class Addable<X> {
void add(X value);
}
abstract class ProducerOf<X> {
X call();
}
Addable<A> f<A, T static implements ProducerOf<Addable<A>>(A value) {
var a = T(); // Returns an `Addable<A>`.
a.add(value);
return a;
}
void main() {
var addable = f<int, List<int>>(5);
addable.add(10); // OK.
} We have to assume that extension<E> on List<E> {
static extends ProducerOf<Addable<E>>;
} We would then have to use Again, I think it's reasonable to say that the 'more capable type objects' feature can do it. |
About the 'existential open' operation: Granted, that's not so easy to look up. You can find it on page 5 of this paper: http://lucacardelli.name/Papers/SRC-056.pdf (where it is actually a language construct using the keyword The intuition is that we have a type (like void foo(List<num> xs) {
num n = xs.first; // If the list isn't empty, this will _always_ work.
} So we just treat It only works, though, as long as we are using the list in ways that are compatible with the variance that makes In any case, "for some The only missing bit is that we can do more if we can denote the secret type that "exists" by a name (in particular, we can safely use it in a read-write manner): void foo(List<num> xs) {
if (xs is List<final T>) { // Guaranteed to succeed, and binds `T`.
// It is now known that `xs` has type `List<T>`, and `T` is the
// _precise_ value of the type argument (no subtype will work).
T x = xs.first; // Assuming that it isn't empty.
xs..add(x)..add(x); // Safe!
}
}
void fooWithoutExistentials(List<num> xs) {
num x = xs.first; // Assuming that it isn't empty.
xs..add(x)..add(x); // Adding a `num` to `xs` may throw.
} The traditional way to do this involves the keyword I hope this illustrates that existential types may be theoretically heavy, but the core idea is rather accessible. |
Thanks for the explanation! I even thought for a moment that I understood it, but this part is a bit baffling: void fooWithoutExistentials(List<num> xs) {
num x = xs.first; // Assuming that it isn't empty.
xs..add(x)..add(x); // Adding a `num` to `xs` may throw. ???
} Why can it throw? The method I checked it: void f(List<num> list) {
num x = list.first;
print(identical(x, 5)); // true
list.add(x);
}
main() {
var list=<int>[5];
f(list);
} It doesn't throw. Here's an example I can understand: bool tryToAddElement(List<Object> list, Object value) {
if (list is List<final T> && value is T) {
list.add(value);
return true;
}
return false;
} This one is impossible to implement without existential types. |
You're right that the concrete example won't throw: The element which is added twice is obtained from the list, and this implies that it has a run-time type which is a subtype of the actual value of the type argument of that list. However, adding a value whose static type is void foo(List<num> list) {
list.add(1.5); // `1.5` has type `double` which is a subtype of `num`: OK!
}
void main() {
foo(<int>[]); // But it throws when we try to add `1.5`.
} So the void fooWithoutExistentials(List<num> xs) {
num x = xs.first; // Assuming that it isn't empty.
xs..add(x)..add(1.5); // _Will_ throw if `xs` is a `List<int>`.
} According to the type system this is just as safe as the previous version, but it throws. With the existential open operation, the actual type argument is denoted by the type variable
Yes, that's a better example. You can do it, if needed: bool canAdd(List<Object> list, Object value) {
final emptyClone = list.take(0).toList();
try {
emptyClone.add(value);
} catch (_) {
return false;
}
return true;
} But one thing which is not so easy to do is to create a new object that needs to have a type that depends on the unknown type variable: void addInListIfPossible(List<List<Object?>> list, Object? value) {
if (list is List<List<final T>>) {
if (value is T) list.add(<T>[value]);
}
} In this case we can't use |
I think I understand it now, thanks a lot! Q: have you ever considered a shorter notation for "extends", "static implements" etc. Using a strawman symbol Addable<A> f<A, T ~~ ProducerOf<Addable<A>>(A value) {
var a = T(); // Returns an `Addable<A>`.
a.add(value);
return a;
} It looks more algebraic this way, no? |
True, and that's probably the main reason why more-punctuation-fewer-keywords is so controversial. It's less verbose, but it is often considered significantly less readable (above all, for developers who are new to the language, and especially if the alternative is punctuation which isn't already well-known from other languages as a notation with a similar purpose). On the other hand, it could be argued that it takes a day or two to get used to a punctuation based notation for a well-known mechanism (e.g., In any case, there's no reason to expect that there will be a lot of agreement on this type of proposal. ;-) |
There's one form that is well-known from other languages, based on the colon notation: |
Yea, |
Here's a place to have that discussion: #4275. |
@eernstg I'm trying to understand the More capable type objects as an extension section you recently added and I created the below example: abstract class A {
String get foo;
}
class C static implements A {
static String get foo => 'fooC';
}
abstract class _StringA implements A {
@override
String get foo => 'fooString';
}
extension E on String {
static extends _StringA;
}
void main() {
void f<X>() {
final reified = Type.reify<X, A>();
if (reified != null) {
print(reified.foo);
} else {
print('null');
}
}
f<C>(); // Prints 'fooC'.
f<String>(); // prints 'fooString'.
f<int>(); // prints 'null'.
} Does this example accurately demonstrate how the feature is intended to work?
extension E<X> on List<X> static extends _CallWithOneTypeParameter<X> {} |
I also have an issue with the syntax |
It does look nicer (we're "asking However, We can (and will!) make it a compile-time error to tear off If we use the syntax abstract class Reify {
Type reify<X>();
}
class C static implements Reify {
static Type reify<X>() => int;
}
void doit<X>() {
var f = X.reify<A>; // It's a `Type Function()`.
print(f()); // 'int'.
}
void main() {
doit<String>();
} This means that there can be conflicts between mechanisms that already give syntax like Moreover, So we'd have to give |
@halildurmus wrote:
abstract class A {
String get foo;
}
class C static implements A {
static String get foo => 'fooC';
}
/* The implicitly induced class of the reified type object is something like this:
class ReifiedTypeForC implements Type, A {
String get foo => C.foo;
}
*/
abstract class _StringA implements A {
@override
String get foo => 'fooString';
}
extension E on String {
static extends _StringA; // This is like a member of the extension, should be in the body.
}
/* The implicitly induced "reified type extension" would be something like this:
class ExtensionE_ReifiedTypeForString extends _StringA implements Type {}
*/
void main() {
void f<X>() {
final reified = Type.reify<X, A>();
if (reified != null) {
print(reified.foo);
} else {
print('null');
}
}
f<C>(); // Prints 'fooC'.
f<String>(); // prints 'fooString'.
f<int>(); // prints 'null'.
}
Yes, that's exactly how it would work.
I'm sure that syntax could be given a similar meaning. In any case, the intention is that a The reason why I put it in the body of the extension is that, firstly, we may wish to have several of these declarations (because the given type, e.g., extension E on String {
static extends _DeserializeForString;
static extends _StringA;
static extends _CallWithZeroTypeParameters;
}
void foo<X>() {
final myDeserializer = Type.reify<X, Deserializable<X>>();
final myAThing = Type.reify<X, A>();
final myCallWithTypeParametersThing = Type.reify<X, CallWithTypeParameters>();
X x = myDeserializer.readFrom(someReader);
print(myAThing.foo);
assert(myCallWithTypeParametersThing.numberOfTypeParameters == 2);
}
// Somewhat reminiscent of the following:
void fooLookingInAlicesMirror<X>() {
X x = X.asDeserializer.readFrom(someReader);
print(X.asA.foo);
assert(X.asCallWithTypeParameters.numberOfTypeParameters == 0);
}
void main() {
foo<String>();
} The difference is that It might be quite common for the However, it is also possible to use This is pretty much like being able to declare post-hoc that a given class |
Got it! Thanks for the detailed explanation. |
@eernstg: First off, why do we need it at all? If type X statically implements A, I can say Some hint to the root cause of my confusion can be discerned in your phrase:
Is it not? How come? What kind of type is that? And how can the runtime know that this non-reified type statically implements A? |
The point is that If you're just reifying a type directly (as in We may then investigate that reified type object and discover that it has various types (that's the Returning to This means that we could have 10 different ways to create a reified type object for any given value of The point is that reified type objects from extensions will allow us to handle more than one task, and it will allow us to do this with classes that we don't have edit rights on, and it will allow those extensions to be independent of each other. So we could import one extension that makes If you can't specify the desired type of the reified type object (and we can't do that with a plain reification as in
In the first case the semantics of |
Oh, the device tries to incorporate the information from the extensions into runtime, right? |
For each The typical case will surely be that Next, we need to check whether the given type to reify ( The remaining If there are none, then So there are some elements which are handled at compile time, but some other elements will necessarily be handled at run time. However, the run-time work consists exclusively of finding the applicable cases among a finite (and small) set of cases which is known at compile time. The final step will be to create an instance of the selected implicitly induced reified type object class, and that's exactly the same job as it is when we're use the implicitly induced reified type object class of the class itself. The only difference is that |
@eernstg: I think I understand the rationale for Here's a summary of my impressions so far.
|
This issue is a response to #356 and other issues requesting virtual static methods or the ability to create a new instance based on a type variable, and similar features.
Static substitutability is hard
The main difficulty with existing proposals in this area is that the set of static members and constructors declared by any given class/mixin/enum/extension type declaration has no interface and no subtype relationships:
As a fundamental OO fact,
B
is an enhanced version ofA
when it comes to instance members (even in this case where we don't enhance anything), but it is simply completely unrelated when it comes to constructors and static members.In particular, the relationship between the constructors
A();
andB();
is very different from an override relationship.A
has a constructor namedA.named
butB
doesn't have a constructor namedB.named
. The static memberB.foo
does not overrideA.foo
.B
does not inheritA.bar
. In general, none of the mechanisms and constraints that allow subtype substitutability when it comes to instance members are available when it comes to "class members" (that is, static members and constructors).Consequently, it would be a massively breaking change to introduce a rule that requires subtype substitutability with respect to class members (apart from the fact that we would need to come up with a precise definition of what that means). This means that it is a highly non-trivial effort to introduce the concept which has been known as a 'static interface' in #356.
This comment mentions the approach which has been taken in C#. This issue suggests going in a different direction that seems like a better match for Dart. The main difference is that the C# approach introduces an actual static interface (static members in an interface that must be implemented by the class that claims to be an implementation of that interface). The approach proposed here transforms the static members into instance members, which means that we immediately have the entire language and all the normal subsumption mechanisms, we don't have to build an entirely new machine for static members.
What's the benefit?
It has been proposed many times, going back to 2013 at least, that an instance of
Type
that reifies a classC
should be able to do a number of things thatC
can do. E.g., if we can doC()
in order to obtain a new instance of the classC
then we should also be able to doMyType()
to obtain such an instance when we havevar MyType = C;
. Similarly forT()
whenT
is a type variable whose value isC
.Another set of requests in this topic area is that static members should be virtual. This is trivially true with this proposal because we're using instance members of the reified
Type
objects to manage the access to the static members.There are several different use cases. A major one is serialization/deserialization where we may frequently need to create instances of a class which is not statically known, and we may wish to call a "virtual static method".
Proposal
We introduce a new kind of type declaration header clause,
static implements
, which is used to indicate that the given declaration must satisfy some subtype-like constraints on the set of static members and constructors.The operand(s) of this clause are regular class/mixin/mixin-class declarations, and the subtype constraints are based on the instance members of these operands. In other words, they are supertypes (of "something"!) in a completely standard way (and the novelty arises because of that "something").
The core idea is that this "something" is a computed set of instance members, amounting to a correct override of each of the instance members of the combined interface of the
static implements
types.These declarations have no compile-time errors. The static analysis notes the
static implements
clause, computes the corresponding meta-member for each static member and for each constructor, and checks that the resulting set of meta-members amount to a correct and complete set of instance members for a class that implementsA<B>
. Here is the set of meta-members (note that they are implicitly created by the tools, not written by a person):The constructor named
B
becomes an instance method namedcall
that takes the same arguments and returns aB
. Similarly, the constructor namedB.named
becomes an instance method namednamed
. Static members become instance members, with the same name and the same signature.The point is that we can now change the result of type
Type
which is returned by evaluatingB
such that it includes this mixin.This implies that for each constructor and static member of
B
, we can call a corresponding instance member of itsType
:This shows that the given
Type
object has the required instance members, and we can use them to get the same effect as that of calling constructors and static members ofB
.We used the type
dynamic
above because those methods are not members of the interface ofType
. However, we could change the typing of type literal expressions such that are not justType
. They could beType & M
in every situation where it is known that the reified type has a given mixinM
. We would then be able to use the following typed approach:Next, we could treat members invoked on type variables specially, such that
T.baz()
means(T).baz()
. This turnsT
into an instance ofType
, which means that we have access to all the meta members of the type. This is a plausible treatment because type variables don't have static members (not even if and when we get static extensions), soT.baz()
is definitely an error today.We would need to consider exactly how to characterize a type variable as having a reified representation that has a certain interface. Let us use the following, based on the syntax of regular type parameter bounds:
Even if it turns out to be hard to handle type variables so smoothly, we could of course test it at run time:
Customized behavior
The behavior of the reified type objects can be customized, that is, they can do other things than just forwarding a call to a static member or a constructor.
One possible approach could be to have
static extends C with M1 .. Mk
in addition tostatic implements T1 .. Tn
on type introducing declarations (like classes and mixins), and then generate the code for the reified type object such that it becomes a subclass thatextends C with M1 .. Mk
and alsoimplements T1 .. Tn
. However, we could also apply those mixins outside thestatic extends
clause, so we only consider a simpler mechanism:A type introducing declaration can include a
static extends C
clause. This implies that the reified type object will be generated such that the givenC
is the superclass. Compile-time errors occur according to this treatment. E.g., ifC
is a sealed class from a different library thenstatic extends C
is an error, based on the fact that it would give rise to a subclass relation to that class, which is an error.This mechanism allows the reified type object to have arbitrary behaviors which can be written as code in the class which is being used as the
static extends
operand.Use case: An existential open mechanism
One particular kind of feature which could be very useful is a flexible mechanism that delivers the behaviors otherwise obtained by an existential open mechanism. In other words, a mechanism that allows the actual type arguments of a given class to be accessed as types.
This would not involve changes to the type system (so it's a much smaller feature than a real existential open would be). It is less strictly checked at compile time, but it will do the job—and it will presumably be used sparingly, in just that crucial bit of code that allows a given API to be more convenient to use, and the API itself would be statically typed just like any other part of the system.
For example, the reified type object could implement this interface:
A class
C
with a single type parameterX
could usestatic extends _CallWithOneTypeParameter<X>
, which would make it implementCallWithTypeParameters
:For example, assume that the standard collection classes will use this (note that this is a non-breaking change):
The 'existential open' feature is so general that it would make sense to expect system provided classes to support it. It also makes sense for this feature to be associated with a very general interface like
CallWithTypeParameters
, such that all the locations in code where this kind of feature is needed can rely on a well-known and widely available interface.If we have this feature then we can deconstruct a type of the form
List<T>
,Set<T>
, ..., and use the actual type argument:Obviously, this involves some delicate low-level coding, and it needs to be done carefully. However, the resulting API may be considerably more convenient than the alternatives.
In particular, an API could use regular objects that "represent" the composite types and their type arguments. Those type representations would then be handled by an interpreter inside
build
. For example, with this kind of approach it is probably not possible to obtain a helpful return type, and it is certainly not possible to use type inference to obtain an object that "represents" a type likeSet<List<int>>
.Running code, emulating the example above.
Type parameter management
With this mechanism, a number of classes will be implicitly induced (that is, the compiler will generate them), and they will be used to create the reified type object which is obtained by evaluating the corresponding type as an expression.
The generated class will always have exactly the same type parameter declarations as the target class: If class
C
has 3 type parameters with specific bounds then the generated class will have the same type parameter declarations with the same bounds. This implies thatT
instatic implements T
orstatic extends T
is well defined.More capable type objects as an extension
We may well wish to equip an existing type that we aren't able to edit (say,
String
) with reified type object support for a particular class (say,Serializable
).We can do this by adding a "magic" static member on the class
Type
as follows:This is, at first, just a less convenient way to reify a type (passed as the type argument
TypeToReify
) into a reified type object. With an actual type argument ofType
(or any supertype thereof), the returned object is simply going to be the reified type object that you would also get by simply evaluating the givenTypeToReify
as an expression.However, if
DesiredType
is not a supertype ofType
then the reified type object may or may not satisfy the type constraint (that is, it may or may not be an instance of a subtype ofDesiredType
). If it is not an instance of the specifiedDesiredType
thenreify
will attempt to use an extension of the reified type ofTypeToReify
.Such extensions can be declared in an
extension
declaration. For example:At the location where
Type.reify<List<int>, CallWithTypeParameters>()
is invoked, we gather all extensions in scope (declared in the same library, or imported from some other library), whose on-type can be instantiated to be the givenTypeToReify
. In an example where the givenTypeToReify
isList<int>
, we're matching it withList<X>
.For this matching process, the first step is to search the superinterface graph of the
TypeToReify
to find the class which is the on-type of the extension. In the example there is no need to go to a superinterface, theTypeToReify
and the on-type of the extension are already both of the formList<_>
.Next, the value of the actual type arguments in the chosen superinterface of the
TypeToReify
is bound to the corresponding type parameter of the extension. WithList<int>
matched toList<X>
,X
is bound toint
.Next, it is checked whether there is a
static implements T
orstatic extends T
clause in the extension such that the result of substituting the actual type arguments for the type parameters inT
is a subtype ofDesiredType
.In the example where
TypeToReify
isList<int>
andDesiredType
isCallWithTypeParameters
, we find the substitutedstatic implements
type to be_CallWithOneTypeParameter<int>
, which is indeed a subtype ofCallWithTypeParameters
.If more than one extension provides a candidate for the result, a compile-time error occurs.
Otherwise, the given reified type object is returned. In the example, this will be an instance of the implicitly generated class for this
static implements
clause:The result is that we can write
static implements
andstatic extends
clauses in extensions, and as long as we're using the long-form reificationType.reify<MyTypeVariable, CallWithTypeParameters>()
, we can obtain an "alternative reified object" which is specifically tailored to handle the task thatCallWithTypeParameters
was designed for.If we're asking for some other type then we might get it from the reified type object of the class itself, or perhaps from some other extension.
Finally, if the reified type object of the class/mixin/etc. itself doesn't have the
DesiredType
, and no extensions will provide one, thenType.reify
returns null.It is going to be slightly less convenient to use
Type.reify
than it is to simply evaluate the type literal as an expression, but the added expressive power will probably imply thatType.reify
will be used for all the more complex cases.Revisions
on Type
.The text was updated successfully, but these errors were encountered: