dart-lang / language

Design of the Dart language
Other
2.65k stars 201 forks source link

Allow some kind of structural typing #1612

Open a14n opened 3 years ago

a14n commented 3 years ago

In several classes you can find methods with the same signature but those classes usually don't share a common interface defining this methods. For instance int get length is available on String, Ìterable, and Map.

If you define a function that only need as parameter an object with a int get length you have to use dynamic for this parameter. For instance:

// super useless function
bool hasEvenLength(dynamic object) => object.length.isEven;

Today there is no way to express this contraint : the parameter object needs to have a length getter returning an int.

A straw-man solution could be to allow dynamic to be parametrized with a type that the object should conform to. Thus dynamic<HasLength> would mean a class that would be valid if it would implement HasLength :

abstract class HasLength {
  int get length;
}

bool hasEvenLength(dynamic<HasLength> object) => object.length.isEven;

class A { int get length => ...; }
class B { int get length => ...; }

main() {
   // OK because A and B have the members expected in HasLength
  hasEvenLength(A());
  hasEvenLength(B());
}

Reusing dynamic makes some sense as it's still a dynamic call but on something that has a known shape.

Another solution without langage enhancement could be to have an annotation to express the shape of object that is expected and let the analyzer trigger errors when the provided type is not conform. This solution would be less powerful as the shape will not be carry by the type.

a14n commented 3 years ago

dynamic<Null> and dynamic<Object> will be the same thing

Indeed but there are no problems because they both describe the same shape of object.

it's not clear whether parameterized types can be used as type parameters - e.g. dynamic<List<int>> - what does it mean

dynamic<List<int>> will refer to any type having all methods that a List<int> has (e.g. a void add(int value) whereas dynamic<List<String>> will have void add(String value)).

I my mind dynamic<T> means any class able to implement T without modification and error. In my initial example the class A could have been defined as class A implements HasLength without any additional change. So A is valid as dynamic<HasLength>.

lrhn commented 3 years ago

Duck typing would be equivalent to dynamic (invocations can't be v-table based), but with non-subtype based restrictions on which types of values can flow into them. That makes it potentially inefficient, and we might be better off with proper Rust-like traits. Alternatively, the compiler can recognize which types are actually assigned to a duck type and implement dispatch tables for those, giving us the same good performance, but costing more memory/code size.

If we take dynamic<T> as strawman syntax, only objects satisfying the signatures of T can be used. It's true that dynamic<Null> and dynamic<Object> would be completely equivalent (and mutually assignable).

How would subtyping work otherwise? I'd expect:

  1. X \<: dynamic<X> for all types X
  2. dynamic<X> \<: dynamic for all types X (including X = dynamic!)
  3. dynamic<X> \<: dynamic<Y> if X would be a valid structural subtype of Y (adding implements Y to X would not introduce an error).

From 1., 3. and transitivity we get that Bar \<: dynamic<Foo> if Bar could implement Foo.

It would introduce yet another top type since Object? \<: dynamic<Object?> \<: dynamic. Nothing new there.

So, what about the special types in the Dart type system (we have quite a lot of those!)

So, all in all, we can just disallow all special types and require that X in dynamic<X> is an interface type (declared by a class or mixin declaration, discounting Future and FutureOr which are not really classes, they just have class-looking placeholders in the platform library source). Maybe disallow Null too since it's redundant with Object.


(This feature differs from the idea of having dynamic Foo which allows only current subtypes of Foo, and does type-safe invocations on Foo methods, but allows unchecked/dynamic invocation otherwise, a "dynamically invocable subtype of Foo").

lrhn commented 3 years ago

@tatumizer Very good questions.

I'd personally disallow is and as since the rely on the run-time type of foo, and we don't want to do run-time structural subtype tests. The type parameter bound is iffy. It's probably doable, but it also suggests that a structural type can be a type parameter, and that's unlikely to fly. If I make a List<dynamic<Bar>> and cast it to List<dynamic> (allowed by subtyping relation and unsafe covariant generics), and then try to add a Foo to that list, it needs to do an is dynamic<Bar> in the add method (as required of covariance by generics). And I didn't want to allow that. So, no using it as a type parameter either.

Probably won't allow implements dynamic<Bar>, it's not an interface. If it was an interface, it would be the same interface as Bar, so you'd just have to write implements Bar. We can allow implements dynamic<Bar> to mean that your class must satisfy the interface of Bar, but doesn't have Bar as a superinterface. It's just an extra static requirement on the class (which will allow it to be assigned to dynamic<Bar>).

If we treat dynamic<Foo> as a Rust-like trait with run-time reification of the proof-of-implementation (fat pointers with a V-table for the trait along with the this pointer), then ... is still won't work, as might work if we have the evidence in scope so we can reify it, and the List<Object>.add problem is still real. We can only add "trait values" to that list (fat pointers), but we can't see from the type that we need that. Might be doable, not easy.

I don't see self-types coming from this approach, not any easier than what we have today, so dynamic<T extends dynamic<Addable<T>>> is probably not going to fly.

Levi-Lesches commented 3 years ago

It would be more natural (but more complicated to implement, I'm sure) if the type system allowed checking for this when all else fails. In other words, if String and List should be considered subtypes of a class HasLength so long as they both implement all of HasLength's members (supposedly just int get length). To adapt @tatumizer's example:


abstract class Addable<T> {
  T operator +(T other);
}

T add<T extends Addable<T>>(T a, T b) => a + b;

void main() {
  /// Strings are `Addable<String>` since [String.+] is defined as `String +(String other)`
  String one = "Hello"; 
  Addable<String> two = "World";  // both declarations are valid
  String three = add(one, two);  // T is String
}
venkatd commented 3 years ago

We have several data structures in our app that could work with anything that has a length and is indexable.

However, in these cases, we're forced to declare the type signature as a very broad List<T> because there's no way to say "this function accepts anything that's indexable with a length".

There may be a risk of misusing the interface, but having worked in languages with structural typing, I think the benefits of less boilerplate and more flexibility outweigh the risks of passing in the wrong thing.

Levi-Lesches commented 3 years ago

all the information is already there in the body of extension methods.

Dart doesn't check the body of methods for type information, that's all in the signature. Accessing members on an object in code and telling the compiler "this is okay" seems like a good use for dynamic. Otherwise, how would the compiler know the difference between an actual error and a getter that you know is there?

Levi-Lesches commented 3 years ago

Still doesn't answer the question -- we don't usually plan to make errors in our code, so a keyword saying "this code has no errors" isn't that accurate.

What's wrong with abstract classes? Abstract classes are meant to specify "here's what I want a type to have, and here's what I want to call that type". In your code, you want Dart to define an interface with .zero and .+, so why not just skip the middleman and do it ourselves? I feel like extensions are coming from an entirely different domain here.

venkatd commented 3 years ago

As far as developer UX, my preference would be for the syntax to be similar to abstract classes since we already use them as interfaces (class MyClass implements SomeAbstractClass).

The main differences I am aware of are:

Levi-Lesches commented 3 years ago

Abstract classes can have function bodies while an interface wouldn't

That's fine though, since you can (and often do) have abstract classes with only abstract methods

Abstract classes need to be implemented explicitly while a (structurally typed) interface wouldn't

That's why I would suggest that the type system automatically check if a class could implement an abstract class if all else fails. So String definitely does not implement HasLength, but since it could, Dart should treat it as if it does. This is kinda like extensions but for typing, in that you get to abuse the type system to use 3rd party code as if you wrote it yourself.

lrhn commented 3 years ago

@Levi-Lesches The problem with implicitly making a class implement a compatible interface is that its a static view of the world. The class might currently implement the interface, but if it didn't promise to, then either it or the interface might change in the future, and then the code relying on Foo implementing Bar will no longer work.

It doesn't even have to be a (big) breaking change. If the interface was not intended for anyone else to implement, then changing a method from void foo(int x); to void foo(num x);, and updating all the classes that were intended to satisfy that interface, will be a non-breaking change for clients.

If someone explicitly implemented the interface, even if they weren't supposed to, then they'd be broken, but would have only themselves to blame.

If a class was made to implicitly implement the interface, just because it had an int foo(int x, [int? y]) method (a valid implementation of the signature), and someone starts using it at that interface, then the compiler is to blame when the interface changes and the code breaks.

We'd need to have away to declare interfaces either "open" or "closed", and only implicitly implement "open" interfaces (with the default being "closed"). Being an open interface would be a promise to users.

Levi-Lesches commented 3 years ago

Right, this is definitely a fragile system.

What if the onus was on the client, not the original interface? Some keyword like as that tells Dart "do what you can to make this work, and I understand it may not work for long". My understanding is that whatever we do here is basically a replacement for dynamic, where the scenario you mentioned will cause an error anyway, so I think that's an acceptable risk.

ykmnkmi commented 3 years ago

Little case for checking Map, List and user defined collections. It's not pretty if you rely on an error.

abstract class  Collection<T> {
  int get length;

  T operator [](int index);
}

bool isCollection(dynamic value) {
  if (value is Collection) {
    return true;
  }

  try {
    value.length;
    value[0];
    return true;
  } on RangeError {
    return true;
  } catch (_) {
    return false;
  }
}

Also #736 .

venkatd commented 3 years ago

If a class was made to implicitly implement the interface, just because it had an int foo(int x, [int? y]) method (a valid implementation of the signature), and someone starts using it at that interface, then the compiler is to blame when the interface changes and the code breaks.

@lrhn to make sure I understand this right, how would it look from a developer experience perspective? I suppose you mean these situations?

Does it come down to error noise? Or am I missing some other considerations?

And I suppose this is less of a problem within the same codebase (easy to fix the errors), but once we start thinking cross-package--where you don't know who depends on you--it becomes more problematic.

What if it was possible for a developer to explicitly implement an interface but it didn't have to be in the class declaration itself? So I could define it like List implements HasLength interface and then use it.

And I believe Typescript interfaces are structurally typed. I wonder how it's handled with them.

That's fine though, since you can (and often do) have abstract classes with only abstract methods

@Levi-Lesches I think we're in agreement. I just meant that's as a small difference. So it feels like abstract classes are the closest concept to interfaces that we have today.

Levi-Lesches commented 3 years ago

After giving it more thought, I think dynamic is a valid language feature to use here. From Effective Dart:

The type dynamic not only accepts all objects, but it also permits all operations. Any member access on a value of type dynamic is allowed at compile time, but may fail and throw an exception at runtime. If you want exactly that risky but flexible dynamic dispatch, then dynamic is the right type to use.

@lrhn made the point that

The class might currently implement the interface, but if it didn't promise to, then either it or the interface might change in the future

And they're both correct. We don't have any guarantee that String and List have a common int get length, since they don't commit to it explicitly. Which means that the only reason we're okay with doing this is because we just "know" that they do. There's no reason why Dart 3.0 can't change List.length to List.size(). This is where that "risky but flexible dynamic dispatch" that Effective Dart was talking about. And again to @venkatd's point that no matter how we do this, if List.length turns into List.size(), we still have an error we need to fix.

And even if we could force the types to implement some common interface, that wouldn't be enough. To borrow from @eernstg in #281:

class Plant {
  void shoot() { /* ..break out of the seed and start creating a cute little plant.. */ }
}

class Cowboy {
  void shoot() { /* PULL A TRIGGER! */ }
}

main() {
  Plant rose = Cowboy(); // OK if using structural subtyping rules.
  standInFrontOf(rose);
  rose.shoot();
}

If we adopt the idea that an interface can imply a certain set of expectations about the meaning of each of the members (such as shoot), and each member name may not be similarly well-defined (shoot in different interfaces could mean different things) then the choice of structural typing is not just a matter of "I can do more", it is also about "can I trust this operation to do the right thing?".

In other words, we seem to all agree that this is inherently unsafe, and neither the API creator nor user can make this decision on their own and call it safe. IMO, dynamic is fine in this case, or to stay type-safe, use Object with manual checks for each type. This would allow changes such as .length --> .size() to happen seamlessly.

venkatd commented 3 years ago

Another use case to share. We have a sort of hacked implicit interface implements in part of our app. We generate types from a GraphQL schema and we want it to be possible to use some of the objects interchangeably.

For example a User with an id, name, photo should be allowed to be used in place of an id, name because one is a superset of the other. So if someone fetches more data than is necessary in a query, they are still allowed to use the response in widgets/functions that need less info.

So we end up with some long implements lists as such:

class User
    implements
        AuthUser,
        BasicUser,
        BasicUserWithTimezone,
        DocumentToken,
        FeedSource,
        GetChatsCurrentUser,
        GetCurrentUserProjectsCurrentUser,
        GetUnreadBadgesCurrentUser,
        ManagementUserWithProfile

Also

class AuthUser implements BasicUser

We loop over all the fields to figure out who could implement what and generate the implements based on that. This is because our widgets don't necessarily care about the name of the class that's being passed in.

We have control over the code-generation so we have the luxury to do this. The benefit is we get compile time type checking. We pass in a class that the fields and types don't match up, and we get an instant error in our editor.

As a similar thought experiment, suppose I wrote the following:

@Implicit()
abstract class HasLength {
  int get length;
}

Then, before compiling the app, I rewrote all the classes in the pubspec.lock file with int get length to also implement HasLength.

(Not suggesting this is the solution, but posing an idea to understand trade-offs better.)

What would be the main downsides of that in terms of the Dart compiler and developer experience?

dnfield commented 3 years ago

As an alternative -

what about having something like dart:core exposing a bunch of abstract interfaces for this? e.g. a Disposable class, a Summable class, etc?

For the length one, it'd be really nice to have something like abstract class Countable and a subtype abstract class EfficientCountable, so that you could tell whether calling length was expensive or needed to be cached or not.

dnfield commented 3 years ago

IOW - I'd find it really hard to reason about calling a method that just wants my type to implement some method without knowing why, but I'd find it really pleasant to label my type as having a method you can use with a well defined contract.

ykmnkmi commented 3 years ago

plus for EfficientCountable [] and .length

a14n commented 3 years ago

what about having something like dart:core exposing a bunch of abstract interfaces for this? e.g. a Disposable class, a Summable class, etc?

For the length one, it'd be really nice to have something like abstract class Countable and a subtype abstract class EfficientCountable, so that you could tell whether calling length was expensive or needed to be cached or not.

This seems orthogonal to this issue IMHO.

Imagine we have a Countable interface and our hasEvenLength(HasLength object) function. If a class A from another package has a length getter (but doesn't implement Countable) and you want to use it as parameter of hasEvenLength. You have 2 choises : (1) change hasEvenLength(HasLength object) function to hasEvenLength(dynamic object) but now any class can be used... even class without length getter. (2) make a PR to the foreign package to make this class implement Countable interface, wait for acceptation/merge, wait for release, update the dep and finally use it in hasEvenLength. And you have to do that for all classes in all packages you want to pass to hasEvenLength.

At the end of the day we will always have the dynamic type. The goal of this proposal is to bring some type check on dynamic calls and make it less error prone. This issue is quite similar to union types. That is dynamic<HasLength> == union of all classes with a int get length getter.

dnfield commented 3 years ago

Won't that do lots of nasty things to tree shaking though?

For example, once the compiler sees that you dynamically call operator==, it can't shake that operator from any remaining types in your library...

You could always decorate your unmarked type, although that does add some work/overhead.

venkatd commented 3 years ago

@dnfield I like the idea to have more granular interfaces introduced to the dart data structures.

However, I think this wouldn't solve the broader issue. This would help the narrow use case of built-in data structures, but it would be hard for the Dart/Flutter teams to anticipate all of the use cases that would arise. Similarly, any package author would have to anticipate all of the possible usages of a class and create a bunch of interfaces trying to account for all of these possibilities.

I'd find it really pleasant to label my type as having a method you can use with a well defined contract.

I agree with this. Part of the problem is that, if a class hasn't explicitly implemented an interface, I have to resort to dynamic or wrap that object if I want to get the benefits of static typing.

I'd want to allow to declare an interface after-the-fact. Here I'd be able to give it a proper name.

I acknowledge there is the downside where you may incorrectly pass in an object, but that is no worse than the dynamic keyword we are using. In both situations you can mistakenly pass in objects that shouldn't be passed in. The goal is to add more structure to the parts of the code where we're currently resorting to dynamic.

There are ways to do this such as wrapping the types and having the wrapper explicitly implement the interface, but the amount of boilerplate required leads me to reach for the lower friction dynamic option.

jodinathan commented 3 years ago

this could help with serializing packages (which is increasing the amount each day along with state management packages).

dynamic<JsonObjEncodable> foo;

class JsonObjEncodable {
  Map<String, dynamic> toJson();
}
avdosev commented 2 years ago

@lrhn , I would probably suggest considering this syntax.

bool hasEvenLength<T implements HasLength>(T object) => object.length.isEven;
abstract class HasLength {
  int get length;
}

bool hasEvenLength<T implements HasLength>(T object) => object.length.isEven;

class A { int get length => ...; }
class B { int get length => ...; }

main() {
  hasEvenLength(A());
  hasEvenLength(B());
}
Levi-Lesches commented 2 years ago

From @lrhn, above:

Probably won't allow implements dynamic<Bar>, it's not an interface. If it was an interface, it would be the same interface as Bar, so you'd just have to write implements Bar.

avdosev commented 2 years ago

Is that what this quote said?

class Foo implements dynamic<HasLength> {
...
}

But my version of the syntax is about something else. In this variant we say that the generic T type should implement HasLength without forcing the T type to have a v-table. This variant may remove some of the problems mentioned above (T isn't dynamic)

That said, commenting on the need for structural typing seems pointless, there are situations where we cannot influence external code and it is hard to do anything about it.

HosseinYousefi commented 10 months ago

What if we use abstract interface classes as equivalents to Rust's traits. Later on, we can use extensions to satisfy the interface:

abstract interface class Countable {
  int get length;
}

abstract interface class HasIsEmpty {
  bool get isEmpty;
}

class EmptyArray {
  final int length = 0;
}

extension E on EmptyArray implements Countable, HasIsEmpty {
  // No need to add the [length] getter, as [EmptyArray] already has it.
  // However, we need to add [isEmpty].
  bool get isEmpty => length == 0;
}

void f(Countable c) => print(c.length);

void main() {
  f(EmptyArray()); // prints "0"
}
FMorschel commented 2 months ago

Hi! Found this issue today. As mentioned above by HosseinYousefi:

What if we use abstract interface classes as equivalents to Rust's traits

I had created an issue for that without knowing that Rust had this implemented and what name it was: https://github.com/dart-lang/language/issues/3024.

Just commenting here so we can have all the links.

Edit:

Also potentially related to: https://github.com/dart-lang/language/issues/2166