Skip to content

Allow some kind of structural typing #1612

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

Open
a14n opened this issue May 3, 2021 · 27 comments
Open

Allow some kind of structural typing #1612

a14n opened this issue May 3, 2021 · 27 comments
Labels
feature Proposed language feature that solves one or more problems

Comments

@a14n
Copy link

a14n commented May 3, 2021

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 a14n added the feature Proposed language feature that solves one or more problems label May 3, 2021
@a14n
Copy link
Author

a14n commented May 4, 2021

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
Copy link
Member

lrhn commented May 4, 2021

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!)

  • The type dynamic<dynamic> should probably be considered equivalent to just dynamic. It's a supertype of dynamic, all objects are assignable to it. What members does it have then? Likely ... all possible members, dynamically invoked. So, dynamic. Or maybe disallow it and require you to just write dynamic.

  • The type dynamic<void> should also allow any object, and maybe not have any members at all (not even Object members). Or just be equal to void. Generally, dynamic<top-type> seems to be equivalent to just top-type - it has the same members, and all objects are already assignable to it. Or again, disallow and require you to write it directly.

  • The type dynamic<Function> probably won't work without a hack. We'd want any subtype of Function to be assignable to it, but it has no signature for its call method. So, we'll require a call member and do dynamic invocation of it. Or disallow it, and just make people use Function directly.

  • The type dynamic<int Function(int)> (or any other function type) could require having a call method of that function signature. That could work, but would mean that callable classes satisfy the same interface. Just using the function type itself would force a tear-off when assigning a callable class instance to it, which is better. Allowing both to share the same type gets us back to the problem we tried to avoid during Dart 2.0 design, where we don't know if a callable value is a function or an object. So again, prefer to disallow. Just use the function type itself, it's already structural!

  • The type dynamic<Never> is unsatisfiable by any type other than Never. Just make it equivalent to Never, we don't need a second un. That's fair. Or disallow and require Never.

  • The type dynamic<int?> should require all members that can be invoked on int?, but that's just dynamic<Null>/dynamic<Object?>. So we can disallow it.

  • The type dynamic<FutureOr<X>> is likewise equivalent to dynamic<Object?>. If we ever add general union types with intersection members, we might fine tune that. For now, just don't do it.

  • The type dynamic<T> where T is a type variable with bound B ... should probably be disallowed. We don't want to do structural type-assignability checks at run-time. If it's anything, it's dynamic<B>, and then you can just write that.

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
Copy link
Member

lrhn commented May 5, 2021

@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
Copy link

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
Copy link

venkatd commented May 9, 2021

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
Copy link

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
Copy link

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
Copy link

venkatd commented May 10, 2021

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:

  • Abstract classes can have function bodies while an interface wouldn't
  • Abstract classes need to be implemented explicitly while a (structurally typed) interface wouldn't

@Levi-Lesches
Copy link

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
Copy link
Member

lrhn commented May 10, 2021

@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
Copy link

Levi-Lesches commented May 10, 2021

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
Copy link

ykmnkmi commented May 10, 2021

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
Copy link

venkatd commented May 10, 2021

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?

  • I explicitly break a contract for a HasLength interface: my editor tells me "missing the length property for the HasLength interface
  • I break an implicit contract: my editor has a lot of errors with "length property doesn't exist"

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
Copy link

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
Copy link

venkatd commented May 10, 2021

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
Copy link

dnfield commented Jun 14, 2021

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
Copy link

dnfield commented Jun 14, 2021

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
Copy link

ykmnkmi commented Jun 15, 2021

plus for EfficientCountable [] and .length

@a14n
Copy link
Author

a14n commented Jun 15, 2021

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
Copy link

dnfield commented Jun 15, 2021

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
Copy link

venkatd commented Jun 15, 2021

@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
Copy link

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
Copy link

avdosev commented Apr 4, 2022

@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
Copy link

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
Copy link

avdosev commented Apr 5, 2022

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
Copy link
Member

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
Copy link

FMorschel commented Jul 8, 2024

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: #3024.

Just commenting here so we can have all the links.

Edit:

Also potentially related to: #2166

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature Proposed language feature that solves one or more problems
Projects
None yet
Development

No branches or pull requests

10 participants