-
Notifications
You must be signed in to change notification settings - Fork 205
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
[views, extension types] Lightweight type conformance #2150
Comments
@chloestefantsova, do you think this looks like a scary 'feature' or more like a manageable 'small-feature'? ;-) |
As usual I'll point to a Rust traits-like functionality as another implementation strategy for structural polymorphism. If we use |
This is an implementation strategy, possibly, but not one that I would recommend, and I think it overly conflates things.
This would be my recommended implementation strategy for this feature, where the strategy object is just the type parameter itself. That is, we don't compile two versions of any functions, we just compile a protocol-polymorphic function to expect the type parameter to provide the methods for the protocol(s) to which it conforms. |
I like the idea of linking the strategy to the type variable (the wonders of reified type arguments), but I think we'd also want to be able to specify the "conforms to" type constraint directly on parameters. You can write (I also worry that if we allow |
The notion of "conforms to" on parameters looks similar to structural typing. In #1612 I proposed the syntax |
I probably did not put enough emphasis on the main purpose of this proposal: It is intended to enlarge the set of situations in code where the zero-cost nature of the view abstraction is preserved. (The starting point was a discussion via email about how we could improve the performance of for-in statements in the case where we have a view on an object that allows it to behave as an I consider performance to be a crucial feature for views, so it does matter whether we're staying within the boundaries of static resolution, or we're adding run-time dispatch mechanisms and other indirections. @lrhn wrote:
I don't agree that structural polymorphism is at the core of this proposal. The constraint So if we're considering an invocation of a function that declares a type parameter like So we do not have the situation where the given object is known to have a specific set of members with a specific signature (which would be structural polymorphism), we just don't know anything other than We could easily turn this into a matter of normal subtype polymorphism: Just introduce a rule which says "If an expression However, that defeats the whole purpose of this mechanism, which is to preserve the zero-cost nature of the views mechanism in a slightly broader set of situations. So that's the reason why I'm proposing to preserve the static resolution of the view methods, rather than adding dispatcher objects, of any kind. @leafpetersen wrote:
We can definitely do some very interesting and useful things with Haskell-typeclass-ish strategy objects (which could certainly be handled using the run time type representation), but that is again an approach that is based on a new and more complex/powerful kind of run-time dispatch. More expressive power at run time is always cool, but I don't think we should ignore performance. |
The rules for the for-in loop and the rules for conforming member overrides in views seem to be relatively easy to implement. The rules for the general and the customized versions of the copy can be a problem, especially for the modular compilation when we don't know all of the call sites. In first approximation the problem seems similar to lowering of the named optional parameters by generating function versions for each combination of the actual arguments, which proved to be unpractical AFAIK. |
@chloestefantsova wrote:
Right, that's exactly the 'code bloat' issue that I mentioned, which is well-known from C++ templates. We could end up generating a large amount of code with a lot of redundancy. So we'd definitely want to do the same thing that they've done in C++ for many years: Eliminate duplicates. With modular compilation this would be done at link time. But the other side of that coin is that we may obtain significant performance benefits from having a small number of carefully selected customized versions of a given performance critical piece of code. Part of the picture here is that views are not expected to be the new class mechanism, used everywhere for all purposes. They are expected to be a much more specialized device, offering improved performance based on a statically specified commitment to a specific underlying representation. So we're giving up on object-oriented encapsulation when we use a view type, and we'd only do that in specialized situations where we are willing to pay for that loss of abstraction because we get something in return that we definitely must have (and that would typically be improved performance). |
@eernstg Discussed this in person. I think we are in agreement that the question of whether protocol polymorphism is implemented via code duplication or via dictionary passing is largely irrelevant (though it might observable to users in that certain polymorphic recursion patterns would be rejected in the code duplication strategy since there is no finite monomorphism). The main outcome of the discussion though was that protocol polymorphism is largely irrelevant to the main thrust of this issue, which is really intended to be about supporting auto-generating a "boxing" class for views, with auto-generated forwarding stubs which translate between the boxed and unboxed versions. That is, I believe the main goal here was to propose that if we have a view That is, the auto-generated box class will add forwards which translate from the unboxed view type to the boxed view type. This cannot be done for composite types (e.g. |
@leafpetersen wrote:
Right, the point is that we often have an interface involving a set of interconnected classes (in the main example it's abstract class C {
C get next;
}
view V on T implements C boxed as B { // `T` could be anything, doesn't matter here.
V get next => ...;
// The compiler implicitly generates the following getter:
B get box => B(this);
}
// The compiler implicitly generates the following class.
class B implements C {
final V _this;
B(this._this);
C get next => _this.next.box;
} This means that we can work with instances of type However, this kind of parallel type hierarchies can only be supported with very limited generality, and in particular it doesn't carry over to higher order constructs. Just consider an abstract class C {
Set<C> get allNext;
}
view V on T implements C boxed as B {
Set<V> get allNext => ...;
// The compiler implicitly generates the following getter:
B get box => B(this);
}
// The compiler implicitly generates the following class.
class B implements C {
final V _this;
B(this._this);
// We would need to do something like the following:
Set<C> get allNext => _this.allNext.map((v) => v.box);
} It is not realistic to even start going down this path (which is basically the path of performing implicit auto-boxing in arbitrary object structures, and wrapping functions in a similar manner, etc). So that's the reason why I proposed to do it for return types only. It can be done with formal parameter types, but it's hardly useful. We can let the boxing class method box an actual argument when needed: abstract class E {
void m(V v);
}
view V on ... implements E boxed as F {
void m(E e) {}
}
class F implements E {
final V _this;
F(this._this);
void m(V v) => _this.m(v.box);
} This is the implementable relationship (we can use On the other hand, I don't think we can assume that there is an The conclusion is probably that we can establish support for "lifting" in this sense, but it is going to be a highly limited mechanism, and developers are likely to encounter the limitations. For example, there's nothing particularly surprising or unusual about having a getter with a return type like So we're probably going to have to live with the fact that a boxed view will typically have an interface that returns some view types (or some types that contains view types, like Alternatively, the view could be written to never mention view types in its member signatures, but in this case it is likely to need to perform a lot of boxing operations, which is most likely going to cause a performance hit. |
is this planned? |
This proposal has been changed many times since this issue was created. It is known as 'inline classes' today, and it is being implemented. Spec here. The release date has not been decided. I'll close this issue because discussions today should be concerned with the actual feature and specification, using the current name. |
Status: We've had several discussions about this issue, some of it in comments on this issue, and other parts in email and elsewhere. I think it's fair to say that we've concluded the following:
The generalization of
implements
on views as proposed is possible, but not sufficiently general to be worthwhile as an explicit language mechanism. The generalizations that come to mind all seem to involve widespread implicit boxing (and a lot of complexity), which would create similarly widespread issues with object identity and space consumption.This issue contains a proposal for how to generalize the
implements
clause of a view slightly, thus adding support for a lightweight type conformance mechanism.Current Rules about
implements
on a ViewThe views proposal (cf. also the very similar, but older extension types proposal) include a feature with the syntax
implements <typeList>
, which is used to specify that the given view has members that are correct overrides of the members of the types in theimplements
clause.In short, if we want to ensure that a view
V<X>
behaves like anIterable<X>
then we can declare it asWith this declaration, it is a compile time error unless
V
declares a memberIterator<X> get iterator
. In general, the view must satisfy the same requirements that any concrete classC<X>
must satisfy when it has animplements Iterable<X>
clause.An
implements
clause on a view does not introduce a subtype relationship. So we don't get the abstraction. The compiler will know that we're operating on an instance of the on-type ofV
with the view defined byV
on it, so we have no mechanism which is similar to object-oriented subtyping. This is crucial because it ensures that view method invocations can be resolved statically (so they might be inlined, etc).However, the
implements
clause is supported with the full, normal, object-oriented semantics with a "boxed" view.A view may support a
box
operation that creates an instance of a compiler-generated classV.class
whose instances work as wrappers for the on-type instance of the given viewV
.V.class
declares exactly the same methods asV
, with the same signatures. So ifmyView
is a variable of a view typeV
whose value is an instanceo
of the on-type ofV
,myView.box
yields an instanceoBox
ofV.class
that wrapso
.oBox
is a full-fledged Dart object, andV.class
has the sameimplements
clause asV
does, and this means thatoBox
is actually an object which can be passed around using any of the implemented types.Possible Generalization of
implements
on a ViewWe have had some discussions about the treatment of views when they are used with for-in statements. The main observation is that it is unnecessarily costly to use the standard semantics.
As an example, let's introduce a singleton collection and support the use of that type of object as an iterable that repeats the single object that it holds.
We have to box the singleton collection using
it.box
in order to obtain an object whose type implementsIterable<int>
which wraps the given singleton. This is required for it to be used in a for-in statement. Next, the methoditerator
also needs to box the singleton object, such that we obtain an object whose type implementsIterator<int>
.If we generalize the
implements
clause of a view as specified below then we can use the following:The main idea behind the generalization is that a for-in statement will require that the collection has a static type that conforms to
Iterable<T>
for someT
, and the conforms relation is a bit more flexible than the normal subtype relationship.In particular,
SingletonAsIterable<T>
conforms toIterable<T>
, andSingletonAsIterator<T>
conforms toIterator<T>
, but there is no corresponding subtype relationship between those types.The resulting code avoids both boxing operations, and the code in main will use statically resolved functions rather than object-oriented methods that are dispatched at run time.
If we make the choice to compile for-in statements where the collection is a view using the specified desugaring (compilers currently do something else for a normal for-in statement, but we can do this for that particular kind of for-in statement), then we'd get the following, after inlining:
In general, the notion of type conformance will allow code using views to have methods that return view types, and hence avoid boxing, while still maintaining the property that a boxed view will implement "normal" classes. This allows a larger set of method invocations to remain statically resolved.
For example, we may use code generation to obtain a set of views that represent safe navigation of specific JSON values. The underlying representation would be statically typed as
Object?
, and it would actually consist of instances ofMap<String, Object?>
,List<Object?>
,String
,bool
, and numeric types. The views would ensure that this dynamic object graph would be navigated safely, as long as we start off with a view that represents access to a JSON value with a specific schemaS
, and the actual representation object is a JSON value with schemaS
.We would then be able to navigate the JSON value safely without ever boxing any objects, we would just use the views to ensure that the "raw" operations (like casting
Object?
toMap<String, Object?>
and then looking up the value at key"someKey"
, etc.) are performed in a way that corresponds to the schemaS
.However, we would also be able to use
box
to obtain a boxed representation of the JSON value, and we could then use regular object-oriented coding to navigate the JSON value, because the boxed representation is a normal object with normal OO methods. The point is that the recursion is handled automatically: If we have a given boxed JSON value and extract a (smaller) JSON value from it, then the latter will also be boxed, and we do not have to have two near-identical copies of the code.So how is this possible? Details below!
A Type may 'Conform to' another Type
This is a new relationship between two Dart types. It is very simple:
V
conforms to a non-view typeT
ifV
is declared with animplements
clause that includes a typeT0
such thatT0 <: T
.S
conforms to a non-view typeT
ifS <: T
.At first, this concept is only used with for-in statements: We require that the
collection has a type that conforms to
Iterable<T>
for someT
, ordynamic
(we always alloweddynamic
here, because it is a built-in part of assignability). We continue to have the run-time semantics where a non-view type is statically known to be an iterable, or it is checked at run time (in the case where the collection has static typedynamic
), so everything is working in the same way as today, except for view types. This gives rise to the behavior with for-in statements that was described in the previous section.However, we can make type conformance explicit in the language, and we can use it to enable customized variants of a given abstraction:
The main idea is that when a function is generic, and one or more type parameters are specified with
conforms
rather thanextends
, it is given special treatment during compilation:This is similar to template function instantiation in C++, and it allows developers to write a piece of reusable code and still obtain the optimization that could be expected if they had written several variants of the function that only differ by having committed to specific values of specific type arguments.
Of course, this could give rise to serious code bloat problems, so it will have to be used with caution.
On the other hand, the notion of type conformance ensures that there will never be a compile-time error in the body of one of these "template functions": When the type arguments satisfy the requirements, the body will be type correct.
Rules about the Implements Clause of a View
As mentioned, the implements clause of a view differs from the implements clause of a class or mixin, because there is no subtype relationship between the view and the types in the implements clause (but the boxed form has these subtype relationships, as expected for a class).
Correspondingly, the rule that determines whether an instance member declaration in a view is a "correct override" of the member signatures of the types in the implements clause is different.
The return erased signature se corresponding to a signature s is such that se has return type
void
, and se is otherwise identical to s.Assume that
V
is a view with an implements clause containing the typesT1 .. Tk
. Assume thatV
has a declaration of a member namedm
with signature sV, andTj
has a member signature sT namedm
.We say that sV is a conforming override of sT iff:
In short, a conforming override is just like a normal override, except that the return type can use type conformance rather than subtyping.
The class
V.class
that defines boxed instances corresponding to a view typeV
is adjusted correspondingly: The member signatures ofV.class
are computed by taking the signatures of the member declarations ofV
and adjusting return types such that there is a correct (normal, not conforming) override relationship from each member inV.class
to the corresponding member in each of the implemented typesT1..Tk
. (If that relationship is violated thenV.class
is a compile-time error.)The implicitly induced implementations of the methods in the class
V.class
will still forward the call to the view method, but it will add.box
to this forwarding invocation as needed, such that the method body is type correct.This means that in the case where the implements clause makes the promise that a certain non-view type
T
is returned, and the view method returns a view typeV
, the returned value is auto-boxed. As a consequence, we get the effect that we can work on the underlying on-type including the case where view methods return view types, and we can also work on boxed representations, and in that case theV.class
methods will return the corresponding boxed types. This is as claimed earlier in connection with JSON values.Discussion
The main idea here is that we can propagate the performance benefits associated with views a bit further with this generalization than we could previously, and we can allow for a certain (encapsulated, and type safe) level of C++-template-function-ish customization, which could yield significant performance improvements, but which also carries a danger in terms of code bloat.
@leafpetersen, @lrhn, @natebosch, @stereotype441, @jakemac53, @munificent, WDYT?
The text was updated successfully, but these errors were encountered: