-
Notifications
You must be signed in to change notification settings - Fork 207
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
Lightweight extension types #1426
Comments
I'm not proposing that For instance, if we were to consider The other things are basically working as you describe them: void main() {
StringExt hello = "hello"; // OK, implicit upcast.
hello.bar(); // OK.
hello.length; // Error.
("hello" as StringExt).bar(); // OK.
("hello" as StringExt).length; // Error.
hello.runtimeType; // OK, returns the `Type` that reifies `String`.
hello as String; // Evaluates to the plain string "hello".
hello is String; // Evaluates to true.
StringExt anotherHello = "hello";
identical(hello, anotherHello); // No promises.
hello == anotherHello; // True (`operator ==` is available on `Object?`).
} The run-time representation of Given that one of the main motivations for extension types is to allow developers to specify a disciplined way to work on very flexible objects (say, We could easily make it harder to enter the extension type (e.g., by requiring an explicit cast when the target type is an extension type), and we could easily make it harder to exit the extension type (perhaps outlawing all type casts and type tests away from an extension type). But I suspect that the proper goal would be to help developers who wish to maintain the discipline associated with a specific extension type, such that they don't switch to the underlying representation type by accident. There could be cases where an extension type is used to perform almost all of a task, but some remaining bits of work will be done in terms of the underlying representation (because it's too tedious to write methods in the extension to cover it all), and in those cases it might actually be both common and reasonable to switch to the representation type at some point. |
Does this proposal access to the issue mentioned in "Dart string manipulation done right 👉 | by Tao Dong | Dart | Medium" ? |
An extension type as proposed here cannot (conveniently) hide a single method in the interface of an existing class. If we're willing to use a not-so-convenient approach then we could simply declare every single method in the interface of However, that's a very unreliable approach, because string literals have type |
I think the problem is that
|
By the way, from view point of Japanese, |
Are generics of extension types covariant? I would consider allowing |
This proposal has assignment from the on-type to the extension-type, but not in the other direction. That's clever, but also problematic for non-base types. It means that a One of the potential uses of extension types is as aliases that provide a different view, but with the supertype/subtype relation here, that becomes much more cumbersome. extension Chars on String {
// A grapheme cluster based String API.
}
List<Chars> split(Chars text, int partCount) {
/// splits text into partCount parts of roughly equal length, counted in grapheme clusters.
} This function cannot be used by someone who wants to see strings as I an see some use-cases negatively affected by that design. What are the advantages? What is better than if we just made the extension type and its on-type mutual subtypes? Or, with a different view, static aliases for the on-type. (A "static alias" would be a different name for an existing type, one which is a mutual subtype of the type it's aliasing, and which is propagated like a separate type during type inference, and which may act differently wrt. static analysis and statically resolved invocations. At run-time there is only the original type, the static aliases do not reify at run-time because they have no run-time effect. We could even say that If it was aliases, a class could simply replace all |
Nit: ... is treated as Because the extension does not apply to itself as specified (it's a supertype of its own
That would require extension Flint on int {
int floo() => this + 42;
}
extension Grint on Flint {
int groo() => floo() + 37;
int gree() => this + 2; // INVALID!
} I'd say it's perfectly fine for |
Consider allowing an extension to "inherit" the Strawman: extension Flist<T> extends List<T> {
int foo() => this.length + 42;
}
Flist<int> x = [1, 2];
print(x.foo() - x.length); // Works, because `x.length` is visible. I think that many extensions are really intended as extending the API, not restricting it, so if possible it should be the default behavior to allow access to the So Maybe we can use show/hide. extension Chars on String hide length, operator[] {
...
}
extension Field<T extends num> on T show operator+, operator-, operator* {
// ...
} with shorthand |
@tatumizer wrote:
True. But So I don't think it will be helpful to eliminate that syntax, and it will surely be a massively breaking change. |
@lrhn wrote:
I believe it's fair to say that they are invariant: For any given extension type That said, with an extension type Given that One thing is different, though. In a class |
@lrhn wrote:
It is definitely working as intended. ;-) The point is that it is safe to "enter" an extension type (because usage of an object is expected to be conceptually safer when accessing it under an extension type), but it is then a loss of safety to "exit" the extension type, that is, to cast it down to anything, including the on-type. We might want to use some specialized syntax to perform a cast to the on-type, however, because that's guaranteed to succeed. So it's a safe way to start doing unsafe things, and we might want to help developers avoid a downcast to
That would be added safety. For the So wouldn't we actually want to have a tiny fence protecting us from going from |
@lrhn wrote:
Indeed, fixed, thanks!
I believe it is consistent to allow that. Note that promotion of |
[Edit: I added this to the discussion section.] Note that we can use extension types to handle extension void on Object? {} This means that This approach does not admit any member accesses for a receiver of type However, compared to the treatment of today, we would get support for voidness preservation, i.e., it would no longer be possible to forget voidness without a cast in a number of higher order situations: List<Object?> objects = <void>[]; // Error.
void f(Object? o) { print(o); }
Object? Function(void) g = f; // Error, for both types in the signature. |
@lrhn wrote:
That might certainly be useful, but we already get very much the same effect from simply using the extension in the way which is supported today (so the receiver would have a type that matches the on-type, not the extension type). Presumably, the benefit derived from an Does that carry its own weight? However, a similar thing that could be useful would be to allow extensions to offer a set of members which is created by declaring some members and obtaining other members from other extensions. This is similar to inheritance. We could resolve conflicts simply be not inheriting any conflicting declarations; this is basically the same thing as overriding. However, we do not have to enforce any override relationships, because all invocations are resolved statically. |
@tatumizer wrote:
extension StringExt on String {
void bar() {}
}
StringExt hello = "hello"; // OK, upcast.
hello is String; // OK, type test for subtype, evaluates to true.
"hello" is StringExt; // Evaluates to true.
"hello" as StringExt; // Succeeds at run time.
That's still true, of course. But they will operate on the run-time representation of the given objects and types, and they erase the extension type down to the corresponding on-type. dynamic h="hello";
if (h is String) { // Evaluates to true.
//...
}
// BUT
if (h is ExtString) { Evaluates to true.
//...
} The question about promotion is in the 'discussion' section. It seems rather likely that it would be useful to change the type of However, it gets more tricky in other cases: String s = "hello";
s as StringExt; // Change the type to `StringExt`? In this case we are actually demoting the type of It is also possible that this kind of promotion is simply too weird, on the developer should have written |
Broadly, this is in line with what I've been thinking, thanks for starting the discussion! A couple of comments.
|
Two additional comments I forgot to add:
|
Sorry my stupid question, |
Ad 6. Recursive types are bad when they have no finite representation. The underlying representation of an extension type is the Ad 5. Variance. Yes, we should just follow the variance of the |
(Edit: Added "subtype" as option) There are multiple possible approaches here: Member visiblitly:
Type relations
Creating instances of the extension type
We can support multiple of these behaviors, we just need separate syntax to declare them. The most restrictive is probably the opaque separate type with constructors. It looks the most like a completely different class. |
I prefer opake, supertype, assignment + casting + constructors,
|
We could support both "assignment" and "constructors" by defaulting to assignability if there is no constructor declared, and forcing you to go through a constructor if there is one. Then we wouldn't need a different top-level syntax. That would make it a breaking change to add a constructor, so another alternative is to treat no constructor as a "default constructor" of If we don't have assignment at all, so you always have to go through a constructor, then we should definitely have the default constructor above. Then So, "most backwards compatible" approach is opaque, no-assignment/constructor only (with default constructors) and any type relation (because there is no type no, so we can't be incompatible). We can apply that to the current typedef FooList<T> on List<Foo<T>> {
// Implicit: FooList(List<Foo<T>> value) => value;
Foo<List<T>> collect() => ...;
} (Then we can allow This can work with any type relation, but probably best with a completely separate type, because if you have constructors (which can choose to throw on some arguments), you don't want to be able to cast just any value into the type. Casting out is fine. Assignment out - potentially fine, but it's safer tot disallow it. So, this describes opaque/partially transparent, constructors (with default embedding constructor), and separate type. |
I rethinked my opinion to make myself neutral. |
I understand the meaning of "Lightweight" / "minimal enhancement". |
I like the idea of supporting multiple behaviours, giving both flexible and restrictive abstractions. |
Can we discuss them separately? |
@tatumizer wrote, about the boxing class of an extension type:
That is exactly what I aimed to do with #308, so you can see a lot of info about my thoughts in that direction by looking there. |
If |
No, we can't use
Yeah, hiding member is not safe or even possible, even with subclasses I recommending.
Yes, that is a big problem.
Really? |
@tatumizer It's not a bad idea, it's just not perfect (but then, nothing ever is). You probably need ~44 bits for the object reference (if objects are 16-byte aligned in a 48-bit address space, maybe one more bit for a tag). That leaves 19 bits for the V-table ref. It's cheaper to have a direct V-table pointer than a reference into a indirection table (one less level of indirection), and it costs extra to mask out bits when using the object pointer, but it would avoid using 128 bits per reference. Using V-table ref zero for "not an interface" would work, but since everything in Dart is an interface, it's probably better spent on something like smis. Also, if we are doing this, we can put the class description into the pointer and improve the speed of class-ID checks. The real issue is that if your program contains more than 2^19 different interfaces/class IDs, you have a problem. I wouldn't rule that out for programs with large generated class structures like proto-buffers. |
@tatumizer IIRC, the AMD64/x64 architecture specifies 48 bit addresses (but potentially more physical address lines), and the underlying memory controller in the CPU allows you to map memory to any position in this space. You might be able to ensure that your heap is contained in some lower-bit-sized initial chunk of the memory, but that's up to the operating system to decide. (Assuming that you can, and then failing, might be a used as a security exploit. I don't see how, but I'm not a security expert, they are devious people!) You can use fewer address bits by using addressing relative to the heap start. As for the instructions, clearing the upper bits of A requires a mask. You don't want to have too many 64-bit literals in your machine code (don't even think the intel For B, the shift should be enough (one cycle on Coffee Lake chips), then you need to use that relative to the V-table lookup table (another register you want to reserve). In many cases, you can probably unbox the combined pointer early in a function and use the two pre-computed values, but worst case, this overhead occurs on every iteration of an inner loop. One It's definitely going to be a trade-off consideration, not a clear win to either approach. (Mobile devices are obviously smaller, and usually ARM based. I have less experience with those, so there might be things you can do more efficiently there. I think they still have 48 bit addresses, though.). |
@tatumizer wrote:
I'm proposing that |
If |
@lrhn wrote:
The proposal currently does almost exactly that: It allows
The difference is syntactically explicit in the proposal: Constructors are allowed in
We could certainly do that. I believe it would be a small change (but, as we know, default constructors can have many forms ;-). I'm not quite sure in which situations it would be helpful, though. If we do not establish any invariants (by having a constructor that does at least something) then an upcast into the extension type would behave in a way which is very similar to the behavior of that default constructor, so the difference is basically only the syntax, |
About the fat pointer discussions: I think it's worth noting the main properties of Rust traits:
Let's consider these features one by one:
We could play around with fat pointers, but that would be a very deep perturbation of the runtime of Dart, and we already have ways to achieve a very large portion of the features offered by Rust traits. Dart just isn't going to be as optimizable at the low level as Rust. For instance, nobody is pushing for getting rid of the garbage collector and introducing something like ownership types to handle memory bugs statically. I think that makes sense: We just don't want to reason about memory management at that level of detail in all Dart programs, so having a garbage collector is a feature in Dart, not a shortcoming. I think the use of fat pointers is similar: If we call The point is that we are already covering a very large portion of the affordances offered by fat pointers, and then some. The Rust version may be optimized a bit more, but they pay a heavy price in that the runtime and the type system must keep track of which pointers are fat and which ones aren't, and they can't abstract away from that fat-ness. For instance, if we cast |
That might be possible, and it might be a very useful optimization. However, those fat pointers (we need to call them something, even though it couldn't possibly have anything to do with Rust ;-) would most likely need to be known statically: It is probably far too costly to mask out the "other data" part of every single pointer, and or-ing it with whatever is needed in order to create a suitable 64 bit pointer that the hardware can dereference. But we have to do such things on the fat pointers. Next, how should we handle a cast to a top type (or I think the actual wrapper object is attractive because it provides a full-fledged object: It works in all the ways that we would expect for an object (including I think it's cleaner to use the extension type in a completely static manner, with added optimization opportunities, and using the companion class via Another matter is that we might want to use those unused pointer bits in different ways. If it is actually possible to reserve 24 bits or so for other purposes, why wouldn't we use them to carry information about gc, or taintedness of the referred object, or simply to make objects smaller by packing k pointers in less than 64*k bits? Extension types might be widely used at some point, but it still seems likely that the impact would be greater if this idea could be used to reduce the space consumption of the general object layout.
extension E on int {}
void f(E x) {}
void g(E.class x) {}
void main () {
E x = 42;
E.class c = x.box;
f(x); // OK, but `f(c)` is an error.
g(c); // OK, but `g(x)` is an error.
f(c.unbox); // OK.
} The conversion is explicit in both directions, and that makes the distinction between It is also causes the code to be somewhat more verbose, but I think it is useful to maintain a strict and explicit distinction between the extension type |
This assumes that the arguments to the constructors can only be the extension Foo<T> on List<T> {
factory Foo.rep(int count, T value) => List<T>.filled(count, value);
...
} It can mostly be done with static methods instead, but it does matter where you put the type arguments, |
What happens with "trait casting" is that an object with runtime-type
If you just have a If neither, you can't just assign a Then you can try to dynamically cast it. Dynamic casts are something completely different. How, and whether, they work is going to be a touch design question. C++ doesn't have to support If you assign the (
All types know how to cast themselves to a super-type, including All objects know their own run-time type too. That's the one thing that can help you do dynamic casts. Downcasting is always hard. With traits, not all information is available on the run-time type itself, which makes things potentially slightly harder. |
Not an argument for or against, just exploration (some'd say exposition, but explaining helps me think 😁). As for the wrapper object, I agree. If I didn't say that in an earlier post, a fat pointer is just an inlined/unboxed wrapper object (object header with a V-table + one field of wrapped data). The tricky bit is always the downcast. It's tricky no matter how you do it, you need run-time type information of some sort to do it. With traits, that information is no longer just in the run-time type of the object being cast, so potentially extra tricky. Rust has proven it's not impossible (with some constraints, one of theirs being that traits and classes are separate types, something Dart's classes and interfaces can't claim). |
@tatumizer wrote:
I think it's important to keep the distinction very visible, and then I hope that the visibility of the distinction will make developers aware of the difference: I think that distinction is subtle enough that many developers wouldn't necessarily think it matters. Yet, it makes such a profound difference that there will be frustrating bug hunts if the distinction is ignored.
Even with a Rust-like approach to traits, we presumably would not use fat pointers everywhere. Some pointers are simply references to entities in the heap, and they have a different representation than fat pointers. In particular, if the fat pointer is represented by storing a bit pattern which is a class ID or a similar entity, and those missing bits can be restored safely because the heap is known to be a lot smaller than 2^64, then the non-fat pointer would be obtained from the fat pointer by replacing those bits with something that works. If the given bit pattern will cause SIGFAULTs when used as a non-fat pointer then we must perform that transformation at the cast. Conversely, if we really think we can use fat pointers everywhere, why wouldn't we just reduce the size of all programs by 30% rather than using all that unused space to store vtables? ;-) |
One thing I'm still wondering about: Why would anyone want to use fat pointers in Dart? ;-) A fat pointer is suitable for the situation where an instance of a type
I can't see how this can be compatible with Dart outside some very narrow special cases. For instance, when a class implements two interfaces, do you plan to have super-fat pointers to hold two additional vtables?: abstract class I {... int get foo; ...}
class J {... bool get bar => e1; ...}
class C implements I {... int get foo => e2; ...} // No `bar`.
class D implements J {... bool get bar => e3; ...} // No `foo`.
class E implements I, J {...}
extension DI on D implements I {
int get foo => ...;
}
extension CJ on C implements J {
bool get bar => ...;
}
void f(I i) => print(i.foo);
void g(J j) => print(j.bar);
void main() {
var c = CJ(C()).box;
var d = DI(D()).box;
var e = E();
f(c); f(d); f(e);
g(c); g(d); g(e);
g(J());
} How would the invocation of To me it looks like a fat pointer could only be used in some very special cases where we have a lot of compile-time knowledge about how a given class acquired a given member. I'm just not convinced that this situation occurs frequently enough to make fat pointers worthwhile. The situation is different in Rust, because Rust is optimized for maintaining a larger amount of information about the execution at compile time. This enables static resolution of methods in many cases (such that we can just jump directly to an address at run time, rather than looking up the method implementation in a vtable or similar). But we are not likely to want to compile 4 type-specialized versions of |
@tatumizer @eernstg I wonder whether the discussion of fat pointers might better be split off into a separate issue, it's moving pretty far afield? Independently, I'm not seeing anything about compile to JS here. If this discussion is going to be relevant to the design of a general purpose Dart language feature, there needs to be a discussion of what "fat pointer" means in our JS backends (and realistically, if "fat pointer" means compile every reference to a JS object containing two properties then this seems like a clear non-starter). |
Separately, it may be useful to take into account for this discussion that I believe no Dart implementation currently uses vtables in the traditional sense. Some discussion here, though I believe this does not cover the AOT compiler which uses a sort of global vtable. |
In so far as this is viewed entirely as an optimization which doesn't bear on the practicality of the feature, I would suggest then that this is not really the right forum for discussing it. |
Just like @leafpetersen suggests I think it is worth separating specification and implementation concerns, at least on the initial stages. I would even suggest that back and forth on GitHub might not be the best medium for discussing implementation - I'd suggest doing end-to-end design doc or something similar. This thread mashes all of it together and as a result I gave up on following the discussion even though I am keenly interested in the feature itself (e.g. I would like to use these types to build As a final remark I would like to point out that not all systems / VM configurations have 64-bit pointers, it is highly likely that VM is going to switch to compressed pointers on mobile devices in the future - meaning that you don't really have a luxury of hiding any sort of tagging information in the pointer. In any case, just like Leaf, I strongly encourage moving this discussion somewhere else. |
@tatumizer To be clear, I think (and I have heard feedback from other team members) that you are often a valuable contributor here, so please don't take this as a rejection of your input. My primary concern is that this thread is now 143 comments long, the majority of which have only tangentially to do with the main subject. The absence of threading in github issues means that this this issue is essentially useless for its original purposes - I can't refer people here to get an overview of the lightweight extension types proposal, since this issue is primarily now the "fat pointers" issue. For this reason, I have been trying to impose a discipline of splitting side discussions into separate issues. If you and others feel there is more to discuss on this subject, I'd suggest opening a separate issue? I've also considered trying out the new github "discussions" feature, so perhaps that's another direction we can go.
I actually quite agree with this, which is why I brought up compile to JS, and why I was surprised that your response was that this is "just an optimization". This discussion seems mostly pointless to me because it does not address making the feature zero-cost on JS. From my perspective, this feature has hard requirements for the cost-model across all of our platforms, and so discussing the cost of boxing only on the VM is not very useful to help drive the design. |
I added a comment at the top of the initial text of this issue, to deliver a message which is now given here as well: This thread is too long and has too many different topics. Please use separate issues labeled 'extension-types' in order to discuss a topic in the area of extension types. |
This proposal has now been further developed in many steps, and it has been renamed to 'inline class'. The feature specification has been accepted, cf. https://github.com/dart-lang/language/blob/master/accepted/future-releases/inline-classes/feature-specification.md. Today's 'feature' issue for this feature is #2727. |
[Jun 16 2021: Note the proposal for extension types and the newer proposal for views.]
[Feb 24th 2021: This issue is getting too long. Please use separate issues to discuss subtopics of this feature.]
[Editing: Note that updates are described at the end, search for 'Revisions'.]
Cf. #40, #42, and #1474, this issue contains a proposal for how to support static extension types in Dart as a minimal enhancement of the static extension methods that Dart already supports.
In this proposal, a static extension type is a zero-cost abstraction mechanism that allows developers to replace the set Sinstance of available operations on a given object
o
(that is, the instance members of its type) by a different set Sextension of operations (the members declared by the specific extension type).One possible perspective is that an extension type corresponds to an abstract data type: There is an underlying representation, but we wish to restrict the access to that representation to a set of operations that are completely independent of the operations available on the representation. In other words, the extension type ensures that we only work with the representation in specific ways, even though the representation itself has an interface that allows us to do many other (wrong) things.
It would be straightforward to achieve this by writing a class
C
with members Sextension as a wrapper, and working on the wrapper objectnew C(o)
rather than accessingo
and its methods directly.However, creation of wrapper objects takes time and space, and in the case where we wish to work on an entire data structure we'd need to wrap each object as we navigate the data structure. For instance, we'd need to wrap every node in a tree if we wish to traverse a tree and maintain the discipline of using Sextension on each node we visit.
In contrast, the extension type mechanism is zero-cost in the sense that it does not use a wrapper object, it enforces the desired discipline statically.
Examples
A major application would be generated extension types, handling the navigation of dynamic object trees (such as JSON, using something like numeric types,
String
,List<dynamic>
,Map<String, dynamic>
), with static typedynamic
, but assumed to satisfy a specific schema.Here's a tiny core of that, based on nested
List<dynamic>
with numbers at the leaves:Proposal
Syntax
This proposal does not introduce new syntax.
Note that the enhancement sections below do introduce new syntax.
Static analysis
Assume that E is an extension declaration of the following form:
It is then allowed to use
Ext<S1, .. Sm>
as a type: It can occur as the declared type of a variable or parameter, as the return type of a function or getter, as a type argument in a type, or as the on-type of an extension.In particular, it is allowed to create a new instance where one or more extension types occur as type arguments.
When
m
is zero,Ext<S1, .. Sm>
simply stands forExt
, a non-generic extension. Whenm
is greater than zero, a raw occurrenceExt
is treated like a raw type: Instantiation to bound is used to obtain the omitted type arguments.We say that the static type of said variable, parameter, etc. is the extension type
Ext<S1, .. Sm>
, and that its static type is an extension type.If
e
is an expression whose static type is the extension typeExt<S1, .. Sm>
then a member access likee.m()
is treated asExt<S1, .. Sm>(e as T).m()
whereT
is the on-type corresponding toExt<S1, .. Sm>
, and similarly for instance getters and operators. This rule also applies when a member access implicitly has the receiverthis
.That is, when the type of an expression is an extension type, all method invocations on that expression will invoke an extension method declared by that extension, and similarly for other member accesses. In particular, we can not invoke an instance member when the receiver type is an extension type.
For the purpose of checking assignability and type parameter bounds, an extension type
Ext<S1, .. Sm>
with type parametersX1 .. Xm
and on-typeT
is considered to be a proper subtype ofObject?
, and a proper supertype of[S1/X1, .. Sm/Xm]T
.That is, the underlying on-type can only be recovered by an explicit cast, and there are no non-trivial supertypes. So an expression whose type is an extension type is in a sense "in prison", and we can only obtain a different type for it by forgetting everything (going to a top type), or by means of an explicit cast.
When
U
is an extension type, it is allowed to perform a type test,o is U
, and a type check,o as U
. Promotion of a local variablex
based on such type tests or type checks shall promotex
to the extension type.Note that promotion only occurs when the type of
o
is a top type. Ifo
already has a non-top type which is a subtype of the on-type ofU
then we'd use a fresh variableU o2 = o;
and work witho2
.There is no change to the type of
this
in the body of an extension E: It is the on-type of E. Similarly, extension methods of E invoked in the body of E are subject to the same treatment as previously, which means that extension methods of the enclosing extension can be invoked implicitly, and it is even the case that extension methods are given higher priority than instance methods onthis
, also whenthis
is implicit.Dynamic semantics
At run time, for a given instance
o
typed as an extension typeU
, there is no reification ofU
associated witho
.By soundness, the run-time type of
o
will be a subtype of the on-type ofU
.For a given instance of a generic type
G<.. U ..>
whereU
is an extension type, the run-time representation of the generic type contains a representation of the on-type corresponding toU
at the location where the static type hasU
. Similarly for function types.This implies that
void Function(Ext)
is represented asvoid Function(T)
at run-time whenExt
is an extension with on-typeT
. In other words, it is possible to have a variable of typevoid Function(T)
that refers to a function object of typevoid Function(Ext)
, which seems to be a soundness violation. However, we consider such types to be the same type at run time, which is in any case the finest distinction that we can maintain. There is no soundness issue, because the added discipline of an extension type is voluntary, it is still sound as long as we treat the underlying object according to the on-type.A type test,
o is U
, and a type check,o as U
, whereU
is an extension type, is performed at run time as a type test and type check on the corresponding on-type.Enhancements
The previous section outlines a core proposal. The following sections introduce a number of enhancements that were discussed in the comments on this issue.
Prevent implicit invocations: Keyword 'type'.
Consider the type
int
. This type is likely to be used as the on-type of many different extension types, because it allows a very lightweight object to play the role as a value with a specific interpretation (say, anAge
in years or aWidth
in pixels). Different extension types are not assignable to each other, so we'll offer a certain protection against inconsistent interpretations.If we have many different extension types with the same or overlapping on-types then it may be impractical to work with: Lots of extension methods are applicable to any given expression of that on-type, and they are not intended to be used at all, each of them should only be used when the associated interpretation is valid.
So we need to support the notion of an extension type whose methods are never invoked implicitly. One very simple way to achieve this is to use a keyword, e.g.,
type
. The intuition is that an 'extension type' is used as a declared type, and it has no effect on an expression whose static type matches the on-type. Here's the rule:An extension declaration may start with
extension type
rather thanextension
. Such an extension is not applicable for any implicit extension method invocations.For example:
Allow instance member access:
show
,hide
.The core proposal in this issue disallows invocations of instance methods of the on-type of a given extension type. This may be helpful, especially in the situation where the main purpose of the extension type is to ensure that the underlying data is processed in a particular, disciplined manner, whereas the on-type allows for many other operations (that may violate some invariants that we wish to maintain).
However, it may also be useful to support invocation of some or all instance members on a receiver whose type is an extension type. For instance, there may be some read-only methods that we can safely call on the on-type, because they won't violate any invariants associated with the extension type. We address this need by introducing
hide
andshow
clauses on extension types.An extension declaration may optionally have a
show
and/or ahide
clause after theon
clause.We use the phrase extension show/hide part, or just show/hide part when no doubt can arise, to denote a phrase derived from
<extensionShowHide>
. Similarly, an<extensionShow>
is known as an extension show clause, and an<extensionHide>
is known as an extension hide clause, similarly abbreviated to show clause and hide clause.The show/hide part specifies which instance members of the on-type are available for invocation on a receiver whose type is the given extension type.
A compile-time error occurs if an extension does not have the
type
keyword, and it has a hide or a show clause.If the show/hide part is empty, no instance members except the ones declared for
Object?
can be invoked on a receiver whose static type is the given extension type.If the show/hide part is a show clause listing some identifiers and types, invocation of an instance member is allowed if its basename is one of the given identifiers, or it is the name of a member of the interface of one of the types. Instance members declared for object can also be invoked.
If the show/hide part is a hide clause listing some identifiers and types, invocation of an instance member is allowed if it is in the interface of the on-type and not among the given identifiers, nor in the interface of the specified types.
If the show/hide part is a show clause followed by a hide clause then the available instance members is computed by first considering the show clause as described above, and then removing instance members from that set based on the hide clause as described above.
A compile-time error occurs if a hide or show clause contains an identifier which is not the basename of an instance member of the on-type. A compile-time error occurs if a hide or show clause contains a type which is not among the types that are implemented by the on-type of the extension.
A type in a hide or show clause may be raw (that is, an identifier or qualified identifier denoting a generic type, but no actual type arguments). In this case the omitted type arguments are determined by the corresponding superinterface of the on-type.
For example:
Invariant enforcement through introduction: Protected extension types
In some cases, it may be convenient to be able to create a large object structure with no language-level constraints imposed, and later working on that object structure using an extension type. For instance, a JSON value could be modeled by an object structure containing instances of something like
int
,bool
,String
,List<dynamic>
, andMap<String, dynamic>
, and there may be a schema which specifies a certain regularity that this object structure should have. In this case it makes sense to use the approach of the original proposal in this issue: The given object structure is created (perhaps by a general purpose JSON deserializer) without any reference to the schema, or the extension type. Later on the object structure is processed, using an extension type which corresponds to the schema.However, in other cases it may be helpful to constrain the introduction of objects of the given extension types, such that it is known from the outset that if an expression has a type
U
which is an extension type then it was guaranteed to have been given that type in a situation where it satisfied some invariants. If the underlying representation object (structure) is mutable, the extension type members should be written in such a way that they preserve the given invariants.We introduce the notion of extension type constructors to handle this task.
An extension declaration with the
type
keyword can start with the keywordprotected
. In this case we say that it is a protected extension type. A protected extension type can declare one or more non-redirecting factory constructors. We use the phrase extension type constructor to denote such constructors.An instance creation expression of the form
Ext<T1, .. Tk>(...)
orExt<T1, .. Tk>.name(...)
is used to invoke these constructors, and the type of such an expression isExt<T1, .. Tk>
.During static analysis of the body of an extension type constructor, the return type is considered to be the on-type of the enclosing extension type declaration.
In particular, it is a compile-time error if it is possible to reach the end of an extension type constructor without returning anything.
A protected extension type is a proper subtype of the top types and a proper supertype of
Never
.In particular, there is no subtype relationship between a protected extension type and the corresponding on-type.
When
E
(respectivelyE<X1, .. Xk>
) is a protected extension type, it is a compile-time error to perform a downcast or promotion where the target type isE
(respectivelyE<T1, .. Tk>
).The rationale is that an extension type that justifies any constructors will need to maintain some invariants, and hence it is not helpful to allow implicit introduction of any value of that type with no enforcement of the invariants at all.
For example:
The run-time representation of a type argument which is a protected extension type
E
resp.E<T1, .. Tk>
is an identification ofE
resp.E<T1, .. Tk>
.In particular, it is not the same as the run-time representation of the corresponding on-type. This is necessary in order to maintain that the on-type and the protected extension type are unrelated.
For example:
Boxing
It may be helpful to equip each extension type with a companion class whose instances have a single field holding an instance of the on-type, so it's a wrapper with the same interface as the extension type.
Let E be an extension type with keyword
type
. The declaration of E is implicitly accompanied by a declaration of a class CE with the same type parameters and members as E, subclass ofObject
, and with a final field whose type is the on-type of E, and with a single argument constructor setting that field. The class can be denoted in code byE.class
. An implicitly induced getterE.class get box
returns an object that wrapsthis
.In the case where it would be a compile-time error to declare such a member named
box
, the member is not induced.The latter rule helps avoiding conflicts in situations where
box
is a non-hidden instance member, and it allows developers to write their own declaration ofbox
if needed.Non-object entities
If we introduce any non-object entities in Dart (that is, entities that cannot be assigned to a variable of type
Object?
, e.g., external C / JavaScript / ... entities, or non-boxed tuples, etc), then we may wish to allow for extension types whose on-type is a non-object type.This should not cause any particular problems: If the on-type is a non-object type then the extension type will not be a subtype of
Object
.Discussion
It would be possible to reify extension types when they occur as type arguments of a generic type.
This might help ensuring that the associated discipline of the extension type is applied to the elements in, say, a list, even in the case where that list is obtained under the type
dynamic
, and a type test or type cast is used to confirm that it is aList<U>
whereU
is an extension type.However, this presumably implies that the cast to a plain
List<T>
whereT
is the on-type corresponding toU
should fail; otherwise the protection against accessing the elements using the underlying on-type will easily be violated. Moreover, even if we do make this cast fail then we could cast each element in the list toT
, thus still accessing the elements using the on-type rather than the more disciplined extension typeU
.We cannot avoid the latter if there is no run-time representation of the extension type in the elements in the list, and that is assumed here: For example, if we have an instance of
int
, and it is accessed asextension MyInt on int
, the dynamic representation will be a plainint
, and not some wrapped entity that contains information that this particularint
is viewed as aMyInt
. It seems somewhat inconsistent if we maintain that aList<MyInt>
cannot be viewed as aList<int>
, but aMyInt
can be viewed as anint
.As for promotion, we could consider "promoting to a supertype" when that type is an extension type: Assume that
U
is an extension type with on-typeT
, and the type of a promotable local variablex
isT
or a subtype thereof;x is U
could then demotex
to have typeU
, even thoughis
tests normally do not demote. The rationale would be that the treatment ofx
as aU
is conceptually more "informative" and "strict" than the treatment ofx
as aT
, which makes it somewhat similar to a downcast.Note that we can use extension types to handle
void
:This means that
void
is an "epsilon-supertype" of all top types (it's a proper supertype, but just a little bit). It is also a subtype ofObject?
, of course, so that creates an equivalence class of "equal" types spelled differently. That's a well-known concept today, so we can handle that (and it corresponds to the dynamic semantics).This approach does not admit any member accesses for a receiver of type
void
, and it isn't assignable to anything else without a cast. Just likevoid
of today.However, compared to the treatment of today, we would get support for voidness preservation, i.e., it would no longer be possible to forget voidness without a cast in a number of higher order situations:
Revisions
Feb 8, 2021: Introduce 'protected' extension type terminology. Change the proposed subtype relationship for protected extension types. Remove proposal 'alternative 1' where protected extension types are completely unrelated to other types. Add reification of protected extension types as part of the proposal (making 'alternative 2' part of the section on protected extension types).
Feb 5, 2021: Add some enhancement mechanism proposals, based on the discussion below: Keyword
type
prevents implicit invocations; construction methods; show/hide; non-objects.Feb 1, 2021: Initial version.
The text was updated successfully, but these errors were encountered: