dart-lang / language

Design of the Dart language
Other
2.67k stars 205 forks source link

[extension-types, views] Allow variable show #1648

Open eernstg opened 3 years ago

eernstg commented 3 years ago

Cf. the extension types proposal and the semantically very similar views proposal.

The show clause in an extension type or view Foo<X1 extends B1, .. Xk extends Bk> enables invocations of members of the underlying on-type. For instance:

view Foo on int show num { // or `extension type Foo ...`: same thing.
  void bar() {}
}

void main() {
  Foo foo = 30;
  foo.floor(); // OK, member of on-type which is included by `show`.
  foo.isEven; // Error, not included.
  foo.bar(); // OK, declared in the view.
}

We may wish to compute the members included by the show clause based on the on-type, and this might actually be tractable in spite of the fact that it seems to be similar to "class C<X> implements X ...":

view Foo<X> on X show X hide substring {
  String substring() => "Substring!";
}

void main() {
  Foo<int> i = 10;
  i.isEven; // OK, member of the on-type, and shown.
  Foo<String> s = 'String!';
  print(s.substring()); // OK, prints 'Substring!'.
}

This feature makes it possible to bundle a set of view members with any type (when X has no bound) or with any of the set of subtypes of a given type T (with Foo<X extends T> ...), and it allows us to preserve the full interface of the on-type.

It is indeed a quirky feature, but it might be quite useful.

@lrhn, @leafpetersen, @natebosch, @stereotype441, @jakemac53, @munificent, WDYT?

lrhn commented 3 years ago

Nitpickery: A class cannot declare a member with the same name as itself, but it can inherit one, so:

class X {
  int get Y => 42;
}
class Y extends X {}
view Z on Y show Y {} // means show the member or the interface?

If you can write show Y.Y for the member, then defaulting to the interface is probably fine.

I'd like to be able to on int show num.remainder, to avoid showing int.remainder (bad example, same signature, but the general principle).

I'm not sure allowing you to hide/show individual members is really where we should put our complexity budget. I think I'd prefer to just show/hide members of the on-type only, anything else and you'll have to write it yourself. The only thing we don't get for free using the normal show/hide semantics is showing every member (what imports do by default, and an import never needs to hide every member, you do that by not importing at all). So, consider show * as shorthand for show all.

view Foo<X> on X {...} // shows nothing, declared members only.
view Foo<X> on X show * {...} // Exposes all members of `X` except where overridden by declaration.
view Foo<X> on X hide substring {...}  // same as `show * hide substring`

Since views are inherently static (no run-time reification!), we can allow the signature of Foo<X> to depend on the signature of X. You might be able to assign Foo<int> to Foo<num>, but then the exposed signatures match num. And you can do:

foo<T extends num>(T value) =>
  Foo<T> foo = value;
  foo.exposedMemberOfNum();
}

I think it can work.

eernstg commented 3 years ago

@lrhn wrote:

show the member or the interface? [when it's ambiguous]

The proposals specify that a show/hide identifier which is a type as well as the name of a member of the interface of the on-type (as known in the declaration of the view/extension_type, i.e., based on the bound if the on-type is a type variable), it denotes the member. But that's probably not a very important name clash, because members typically have names whose first letter is lower-case.

I'd like to be able to on int show num.remainder, to avoid showing int.remainder (bad example, same signature, but the general principle).

It sounds like you want to specify that a given member m has a different signature than that of m in the interface of the on-type?:

class A { void foo() {} }
class B extends A { void foo([_]) {} }
view V on B show A.foo {}

void main() {
  V v = B();
  v.foo(); // OK.
  v.foo(true); // Error, the signature is `void foo()` (as in `A`, not `void foo([dynamic])` as in `B`).
}

The current proposal does not allow the show/hide part to change the signature of a member, it's always using the signature of the member in the interface of the on-type. So the show/hide part is only enabling or disabling the members of the on-type interface, it is not changing them in any way.

Do you have an example where it's helpful to be able to specify that the signature should be taken from a superinterface rather than from the on-type? (Or, in general, that the signature is any other signature than the one from the on-type?)

And you can do:

foo<T extends num>(T value) {
  Foo<T> foo = value;
  foo.exposedMemberOfNum();
}

Yes, that's exactly what I intended!

I think it can work.

Sounds good!

eernstg commented 3 years ago

what's the point of hiding methods?

The main motivation for views/extension_types (I'll just say 'views' now) is to provide a zero-cost abstraction (that is: no wrapper object) that allows developers to access a given underlying object using a different interface. So we have a representation object o whose run-time type T has a certain set of members MT, and we wish to use that representation object for a specific purpose which is served well by a different set of members MV. With regular types and subtyping the available members on o will always be a subset of MT, but if we declare a view V with members MV then there is no constraint on the relationship between MT and MV, that is, we can choose the desired interface freely.

The show and hide clauses of a view are used to enable members of the on-type (that is: members of the interface of the representation object o), avoiding the need to write forwarding members.

Using hide to hide a member (or just not showing it) allows us to enable specific members, rather than just none of them or all of them.

For instance, if we wish to use an int as an IdNumber then we might not want to enable operator + because it does not make sense to add two IdNumbers. So, compared to the situation where we are just using an int to hold the ID number, we're helping ourselves to remember that an addition doesn't make sense for ID numbers, and that can be helpful when writing or maintaining complex expressions.

We could of course achieve a similar effect by wrapping the int ..

class IdNumber {
  int _idNumber;
  IdNumber(this._idNumber);
  operator ==(other) => other is IdNumber && other._idNumber = _idNumber;
  hashCode => _idNumber.hashCode;
}

.. but using a view saves both space and time because there is no such wrapper object:

view IdNumber on int {} // Members of `Object` are shown by default.

restrictions are impossible to enforce as long as the underlying on-object is accessible with no restrictions

That's true. The view is not a security mechanism, it's very easy to break any level of protection that views could be considered to provide: If the view is intended to protect a mutable representation from "unauthorized" mutation then we can just keep a separate reference to the on-object o and break the (non-)protection by accessing o directly, using its run-time type as the static type or some non-trivial supertype, or using dynamic invocation.

But I believe that views can still be useful: They will help a group of developers maintain restrictions that they wish to maintain. They will know that it isn't a good idea to keep a reference to a mutable object which is accessed using a view and mutating it directly, and I'm sure we will be able to develop programming patterns where such things rarely happen.

it suffices to just replace the class id of the object

That's a fun idea, and it would of course work quite well to transfer an object from a subclass B to a superclass A when this just amounts to removing some members (we can't remove storage unless we know that it is unused after the removal).

However, it is a tricky operation: You have to guarantee that there are no references in the heap or on the stack typed by the subclass B, and this means that we'd need to track operations that might leak a reference of this (they could be typed as a B, or they could be subject to a downcast x is B in some other piece of code that gets to run at this time).

eernstg commented 3 years ago

It would be inconsistent to suddenly break this trend with "view types" that provide no guarantees whatsoever.

Sounds like you think we have similar guarantees already? ;-)

Note that we are talking about using aliasing to violate an invariant which is otherwise maintained by a well-defined amount of code. For instance, let's say that we have an object that contains two lists, and they must have the same length. We store those two lists in private instance variables, and make sure that every method in the class will preserve the invariant.

class PairedLists<X> {
  final List<X> _xs1, _xs2;
  PairedList(this._xs1, this._xs2): assert(_xs1.length == _xs2.length);
  void add(X x1, X x2) { _xs1.add(x1); _xs2.add(x2); }
  ...
}

But it's obviously easy to break that invariant. Client code could keep an alias to one of those lists and use it to break the invariant:

void main() {
  var xs1 = [1];
  var pl = PairedList(xs1, ['a']);
  xs1.add(2); // Invariant broken!
}

We can of course add aliasing control to Dart (for instance: ownership types), and we could also add a bunch of rules like "the first argument to this constructor must be created when it is passed as an actual argument, it can't be a pre-existing object". It's a huge project, of course, but it is not unthinkable that Dart could have statically safe aliasing control.

However, I don't think that the need to maintain invariants will go away, and I also don't think that it is realistic (or even useful) to insist that we can only support invariant maintenance if there are no loopholes.

Views have two loopholes in particular: You can access the underlying on-type instance via a downcast, and you can keep an alias to the underlying on-type instance. In both cases, you can use the interface of the on-type instance with no restrictions, and this may allow us to break invariants that are otherwise maintained by the view.

If you're willing to pay for a wrapper object (in terms of time and space) then you can eliminate the loophole with the downcast, but the loophole associated with the alias still exists.

My conjecture is that it is worthwhile to support views; they have two loopholes rather than one, but they can save a lot of time and space, and they are essentially just as safe to use as a wrapper class, for developers who wish to maintain those invariants.

eernstg commented 3 years ago

how do I know when I'm writing code whether to pass a boxed or an unboxed version?

That's determined by the software design: In the view proposal, the boxed and unboxed representation have types that are unrelated, so there is no way you can get it wrong according to the declarations without having compile-time errors. I even made the choice to require an explicit boxing operation, because I find it important to be aware of this step. (That's a controversial part of the proposal, so we may end up having implicit boxing after all, or user-specified support/non-support for implicit boxing.)

It is up to the designer of the software that contains usages of the view type and/or the associated boxed type to make the choice.

I find it likely that the view type (hence, the unboxed representation) will be used consistently whenever that is possible. No need to pay for boxing, no need to deal with the identity confusion that may arise if the same on-type instance is boxed twice.

However, in the case where boxing is required (say, in a case where the object must be the value of a variable/parameter whose type is some class type that the view implements, or in the case where clients will invoke methods on the object using receiver type dynamic), boxing may be the best way ahead even though it carries those extra costs.

Note that the identity confusion that may arise when we invoke the .box getter of a view is a property of any technique that uses wrapper objects in a language with object identity, so we will need to handle those issues with the wrapper objects associated with a view, just like we must handle them in every other situation where an object is wrapped.

We could canonicalize the wrapper objects, but that may well be overkill: It isn't that hard to write an extra member of any given view V that looks up the V-boxed version of a given object and returns that, and creates and registers a new box if none exists already.