Skip to content

Inferred interface / Structural type system (Golang-like interface) #281

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

Closed
truongsinh opened this issue Mar 20, 2019 · 12 comments
Closed

Comments

@truongsinh
Copy link

Consider the following example

void main() async {
  printClass(B(3,6));
}

void printClass(A cla) {
  print(cla.a + cla.b);
}
abstract class A {
  int a;
  int b;
}

class B {
  int a;
  int b;
    B(this.a, this.b);
}

The above code throw compile-time error The argument type 'B' can't be assigned to the parameter type 'A', but class B implements A it's working. My suggestion is for dart compiler to automatically infer the interface, similar to what Golang does.

@eernstg
Copy link
Member

eernstg commented Mar 20, 2019

Subtype relationships in Dart are nominal for class types and structural for function types and for the type arguments of parameterized types. This implies that B does not implement A, and it does not matter that B has members with similar typing properties as those of A. What you're proposing is that Dart should use (some variant of) structural subtyping for class types.

It can of course be debated whether that's a good choice (for any set of design goals that we might come up with), but nominal class subtyping is certainly such a deeply rooted property of the language Dart that we could not change the language as proposed without causing massive breakage.

That said, there are also a number of reasons why the structural subtyping rules might not be an improvement. For instance, there's the old issue of misinterpreting an interface:

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?".

Another issue is that structural typing would imply that basically no variable accesses could ever be compiled to read and write memory directly, they would always have to call a getter/setter: If we can show that the possible run-time types of the value of a given expression is a specific set of classes then we may determine that every one of them has a field x, and there are no (non-primitive) getters; so reading x from that receiver can be compiled down to a simple load instruction. However, with structural typing it would be considerably more difficult to prove that we only have such a small set of possible types, because a completely unrelated piece of code could declare a class that happens to match up structurally, and such an instance might flow to this location and be the actual receiver.

So there are both conceptual and technical reasons why structural typing might be a step backwards rather than forwards.

@munificent
Copy link
Member

This would be a fundamental change to the language, so isn't feasible at this point. Sorry.

@truongsinh
Copy link
Author

Thank you for the detail explanation, I really appreciate your thoroughness. That being said, I don't necessarily agree with the final conclusion that it is conceptual a step backward (I'm not arguing about technical reasons as I am the language users here, not the language/compiler engineer :D). As you already pointed out, it's up to for debate, and depends on the goals of of the designed language. The reason I brought up Golang is that it's also coming from Google (even though we can arguably say that there are many internal startups within Google).

I also understand the reason you brought up "old issue of misinterpreting an interface", and I believe that in Golang community, the judgement "can I trust this operation to do the right thing?" is up to the interface "user", while right now in Dart (and Java or other similar languages), is up to interface "implementer".

Moreover, with background in and tendency to use functional programming, I would say that at least myself can hardly be in such situation in which I have to wonder "can I trust this operation to do the right thing?", because I'm more interesting in getting values or computing values in pure manner (i.e. no side effects).

@truongsinh
Copy link
Author

truongsinh commented Mar 28, 2019

Actually, I think I can more or less achieve the behavior. Let's say we have

abstract class ToJsonable {
  String toJson();
}
class Explicit implements ToJsonable {
  @override
  toJson() {
    return "00";
  }
}

class Implicit {
  toJson() {
    return "FF";
  }
}

class NoJson {
}
final Explicit e = Explicit();
if(e is ToJsonable) {
  e.toJson(); // "00"
}
try {
  (i as dynamic).toJson(); // "00"
} catch(e) {}

final Implicit i = Implicit();
if(i is ToJsonable) {
  i.toJson(); // unreachable
}
try {
  (i as dynamic).toJson(); // "FF"
} catch(e) {}

final NoJson n = NoJson();
if(n is ToJsonable) {
  i.toJson(); // unreachable
}
try {
  (n as dynamic).toJson(); // not executed but throw error
} catch(e) {
  // error is thrown here, but we intentionally ignore
}

The question is whether we should go with this approach from the syntactic and performance perspective.

@truongsinh truongsinh changed the title Inferred interface (Golang-like interface) Inferred interface / Structural type system (Golang-like interface) Mar 28, 2019
@eernstg
Copy link
Member

eernstg commented Mar 28, 2019

in Golang community, the judgement "can I trust this operation to do the right thing?"
is up to the interface "user", while right now in Dart (and Java or other similar
languages), is up to interface "implementer".

There's one difficulty with the decision to put that responsibility on the shoulders of the "user":

In terms of the Plant/Cowboy example: When an expression of type Plant is used as in plant.shoot(), the developer has no way to know the history of the actual objects that will be denoted by plant at run time. In particular, the cast from Cowboy to Plant could have occurred billions of instructions ago, in some file that this developer never looked at.

This means that the person who thought "a Cowboy will work just fine as a Plant" and the person who thought "I can just call the shoot method of that Plant!" could very well be different people who might not know each other and never communicate, and on top of that it's an undecidable problem in general to detect exactly which pieces of code could be connected like that (that is: location 1 in the code performs the cast, and that object flows to location 2 where shoot is called).

On top of that, again, the person who wrote the code with the cast might not even notice, if the type system says "No explicit cast needed here, a Cowboy is obviously just as good as a Plant!". ;-)

But, returning to the motivation, why couldn't you just do this:

abstract class ToJsonable { String toJson(); }
class Explicit implements ToJsonable { toJson() => "00"; }
class Implicit { toJson() => "FF"; }
class NoJson {}

// Bridge the gap with a wrapper class.
class ImplicitJsonable implements ToJsonable {
  final Implicit implicit;
  ImplicitJsonable(this.implicit);
  String toJson() => implicit.toJson();
}

main() {
  final e = Explicit();
  e.toJson(); // "00".

  final i = Implicit();
   // We know that we want a `ToJsonable`, and at this point we know how to get it.
  final ij = ImplicitJsonable(i);
  ij.toJson(); // "FF".

  final NoJson n = NoJson();
  // Can't help `n`, unless we write a wrapper class for `NoJson`.
  // We can still try it dynamically, of course, in case we know
  // that the actual value of `n` _does_ have a `toJson()`.
  try {
    (n as dynamic).toJson();
  } catch(e) {
    // error is thrown here, but we intentionally ignore
  }
}

@munificent
Copy link
Member

That being said, I don't necessarily agree with the final conclusion that it is conceptual a step backward

No one said it's a step backwards, just that what you're asking for is a different language.

It's as if we'd opened a burger restaurant and now you are asking us to turn it into a dessert bakery. There's nothing wrong with cake and pie, but... we sell burgers. Our kitchen is only equipped to cook burgers. Everyone already visiting our restaurant is coming here for the burgers. All of our expertise is in cooking burgers. We can't just stop being a burger joint.

@truongsinh
Copy link
Author

That being said, I don't necessarily agree with the final conclusion that it is conceptual a step backward

No one said it's a step backwards, just that what you're asking for is a different language.

Well I was respectfully replying to eernstg's conclusion

So there are both conceptual and technical reasons why structural typing might be a step backwards rather than forwards.

About your analogy

It's as if we'd opened a burger restaurant and now you are asking us to turn it into a dessert bakery.

I think it's somewhat true, and as in the previous reply

(I'm not arguing about technical reasons as I am the language users here, not the language/compiler engineer :D)

I'm just the language users here, and have been invited by my lover, Flutter, to this restaurant. Even though it's a burger restaurant, there's no harm in asking whether you serve vegan food or not 😆

@truongsinh
Copy link
Author

@eernstg for the Cow/Plant example, maybe it's just my perspective, but I'm more a fan of functional programming, and side-effect-free, thus I don't think that's my problem. But I understand it's a problem for a lot of other people.

Anyway, going back to my example, the reason I would like to having it, is because ToJsonable interface was unknown to Implicit class. Think of it as a weird Inversion of Control but in this case we don't even have the interface to implements, or the interface is equivalent, but not the same, e.g.

File A

abstract class ToJsonable {
  String toJson();
}

File B

abstract class ToJsonable {
  String toJson();
}

My class implements ToJsonable in file A, while other code expect ToJsonable in file B.

@eernstg
Copy link
Member

eernstg commented Mar 29, 2019

<aside>
@truongsinh wrote:

side-effect-free, thus I don't think that's my problem

You mentioned functional programming earlier as well, and this time I will comment on it. ;-)

It's not obvious to me why it wouldn't be a bug if you perform a completely unexpected computation, because two entities have the same type, but very different meaning in the application domain, be it ever so side-effect free. Your pure function call would just return a value representing "You were just hit by a bullet!" rather than a value representing "A charming little rose was just born".

I do acknowledge, cf. Theorems for Free, that there's some truth to the claim that pure function types are surprisingly informative (e.g., if you know that a function has type (a -> b) -> [a] -> [b] then it essentially has to be the map function).

But for non-trivial application programming I suspect that there's going to be some overlaps, for example, some functions of type int -> int where one function f1 would compute the right thing in the given situation, and some other function f2 with the same type would compute a result which is a bug, even though it is dutifully an int.

You can use mechanisms like Haskell newtype to introduce a distinction (such that f1 and f2 would have different types, even though it's just mapping integers to integers at run time), but then we've just re-introduced a nominal typing discipline.

</aside> ;-)

ToJsonable interface was unknown to Implicit class

This wouldn't stop anyone from creating a ImplicitToJsonable class. The difficult part could be that there's a need to wrap every Implicit which is, at some point, going to be used as a ToJsonable, but (unless that's done dynamically, in which case the Implicit will "just work" already) this is in any case going to start with a situation where there is a cast from Implicit (or some supertype thereof) to ToJsonable. So if you can edit the code where that happens, you could wrap it.

Still, that might not be easy in practice.

But there is still a nominal approach which could be applied: For some time, Dart-the-language used to have a mechanism called 'interface injection'. I believe it was never implemented, and it was already commented out when the language specification was added to the github repository in 2013. I think it was dropped because of serious performance implications.

That mechanism would allow a developer to add a subtype relationship 'on the side':

class Implicit implements ToJsonable;

This declaration could be added anywhere (as long as Implicit and ToJsonable are in scope), but it would (of course) only be known during static analysis in locations where the above declaration is in scope. The checks on this declaration would include a verification that Implicit is actually an implementation of ToJsonable (so if it has any methods from ToJsonable then they must have a suitable signature, and since it is concrete Implicit must have all methods from ToJsonable, declared or inherited).

This would enable developers to say, once and for all, that Implicit is indeed a suitable type to use where a ToJsonable is expected. So you don't have to go all the way to structural subtyping to get this. But Dart is not likely to get such a mechanism (again), because of the implications for the rest of the language, e.g., separate compilation.

@truongsinh
Copy link
Author

Thanks again for such deep explanation and the reference to 'interface injection'. For now I think we can leave this as is (from my perspective it has been a great and knowledgable exchange). Later on I might come back with a more concrete examples, but it might be that as you suggested, "if you can edit the code where that happens, you could wrap it". I mean it could be convenient not needed to edit the code and it "just works", but on the other hand "just works" with so much magic can come back to the cowboy/plant example.

@a14n
Copy link

a14n commented Sep 24, 2020

How about extending dynamic to support structural typing. For instance dynamic<T> could mean that the value should be like T.

abstract class HasLength { int get length; }
bool isEmpty(dynamic<HasLength> value) => value.length == 0;

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

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

@venkatd
Copy link

venkatd commented Mar 21, 2021

In our case, having structural typing for pure data would be useful.

For example...

Our backend GraphQL API returns a user entity. This user can contain various fields such as id, name, photo, timezone, email, and so on. Some widgets, such as UserAvatar only require the photo and name while others require a different set of fields.

One option is we can declare a giant User object with a lot of nullable fields. (This is the route we went.). However, this is error prone because each property is nullable. Another option is to declare a bunch of classes with permutations of these fields. Such as UserWithTimezone, UserWithPhotoAndEmail, and so on. But the number of "implements" statements to emulate structural typing gets out of hand quickly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

5 participants