dart-lang / language

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

`extended` (temporary naming) classes #3024

Open FMorschel opened 1 year ago

FMorschel commented 1 year ago

Sometimes, I find some classes created by some package/core dart (such as DateTime which I'll use as an example below), that have everything I need, but some extra methods/parameters that are useless for some specific cases.

For example, if I want to have a Date class, that only has year, month and day parameters and let's say compareTo, isAfter and isBefore methods.

If later I want to create another class that extends it, I'll need to only implement these methods and not toLocal and toUtc for example, which, in my case wouldn't even make sense, and also, no need to always fill all time fields to 0, since for Date they wouldn't even exist.

But, I would like to have an easier way of using DateTime objects where my Date class goes. I know that I could create an extension method for DateTime that gives me the Date or even create a Date constructor that receives a DateTime parameter. My only goal here is to ask if this could be made easier.

I've seen #884 and #2166 that would maybe handle possible extra parameters/methods (or we could simply make that impossible, but I'm not sure that's easier because we would need to write two classes for solving that).

I thought to create something like:

class Date extended DateTime {
  @overrided
  int year;
  @overrided
  int month;
  @overrided
  int day;

  @overrided
  bool isAfter(Date date) { 
    //... 
  }

  @overrided
  bool isBefore(Date date) { 
    //... 
  }

  @overrided
  int compareTo(Date date) { 
    //... 
  }

  bool sameMonth(Date date) {
    return year == date.year && month == date.month;
  }

}

I'm not sure how we would handle the transition from DateTime to Date if there is a certain processing that needs to be done, (like, for example always transforming it toUtc to apply to dates in some specific cases) but I'm sure if you find this proposal interesting we can come up with a way of doing that.

lrhn commented 1 year ago

This sounds like you want to make a third-party class, DateTime, implement an interface that it's author didn't add to it. Let's call that interface injection. (Or declaring a "Trait".)

Let's say you can just do that, just declare DateTime implements Date;. Then your Date class is just a normal class. You can also declare-implements two class where you haven't written either yourself. (But that's probably going to be a problem, so let's require it to be declared in the same library as the superclass for now. In the subclass library you can just use implements normally. And it'll be weird if it's not in either.)

What would it mean?

Well, statically we'd have to determine whether it's a sound subclassing, so DateTime must be a valid implementation of the Date interface. That's something we can check at the declaration of Date, because DateTime is imported, otherwise you couldn't write declare DateTime implements Date;. Not a problem, easily checked.

We also need to check that Date and DateTime don't implement interfaces that are incompatible with each other, say something like Iterable<num> and Iterable<int>. That's still doable. But hints at a deeper problem.

What if one library declares a superclass which implements Iterable<int> and another a superclass which implements Iterable<num>. Independently those superclasses are valid, but if both are considered, the results is inconsistent. A class cannot (and must not, because soundness depends on it) implement more than one instantiation of the same generic interface.

Or what if we declare that B is a superclass of A, B implements Iterable<int> and A doesn't. All is well. But A has a subclass C which implements Iterable<num>.

Most of our soundness rules depend on knowing all superclasses. OO type systems with subclassing declares a subclass in terms of what it is-a of. Having a way to add a superclass (or more than one) to a class that doesn't know about them, is not something the type system is built for. It means the class doesn't know what itself is.

So, this is not very likely to fly unless we can somehow remove the "only one implementation of an interface" rule, which is unlikely.

Could we just say that the subclass doesn't really implement the super-interface, it's just assignable to it. We just pretend. We allow you to assign a DateTime to Date, but we all know that it's not really a Date. It's not something we can just do locally. We have to treat Date specially everywhere, because it might be a Date, or it might be a DateTime pretending to be a Date.

That's more like Rust trait than a Dart interface, so maybe it should be:

trait Date { .... }
declare DateTime implements Date;

and traits are special (and cannot implement interfaces, only other traits, which are less strict about being more than one thing than interfaces are). One extra concept, on top of interfaces. A hard sell.

Or we could make all Dart interfaces act like that. Fat pointers, implementation provided on the side. I think it's at least it should be somehow consistent, but probably not entirely consistent with the current design. So another hard sell.

(Sure, if we had just done traits from the start, everything would be easier.)

But Rust traits are really just unboxed implicit wrapper objects. We could do implicit wrapper objects and forwarding instead. That's more likely to fit into the Dart model. Instead of making DateTime be a Date, we create an implicit wrapper, forwarding methods of Date to the similar methods of DateTime, and we apply the wrapper when you assign a DateTime to Date. Creating wrappers with implicit forwarding could be useful anyway. Implicitly wrapping too (if that's not just the implicit constructors feature used on the wrapper class).

Then the only thing we need is a way to get back, so if we try to do an is DateTime or as DateTime on the wrapper object, it gets unwrapped.

Then there is no limit to how may "superclasses" we can let an object be wrapped as.

(But no easy solutions, that's for sure.)

FMorschel commented 1 year ago

Thank you @lrhn for being better at explaining my own request than I was (your comment explains precisely what I meant to suggest). I was unfamiliar with Rust's traits, so if you agree, we could change the name for this issue for better understanding.

FMorschel commented 1 year ago

About what you said about the wrapper objects, I'm completely guessing here, but is there any way for https://github.com/dart-lang/language/issues/2727 to be used for that?

eernstg commented 1 year ago

Rust traits are resolved statically in the case where the receiver type is known. In that case an invocation of a traits method will just be a function call to a piece of code which is known at compile time, and it is known statically that the given receiver is an appropriate receiver for that implementation.

In the case where a reference in Rust has a type which is a trait (so the parameter is declared like myParameter: &dyn MyTrait), the invocation must be performed based on some device that provides the binding of the implementations at run time. In other words, there must be something like a vtable. Rust does this by using a 'fat pointer' which is basically a tiny object containing two "words" (pointer to receiver, pointer to MyTrait vtable). This approach ensures that the implementations of methods declared by MyTrait are appropriate for the given type of receiver, even though there is no information available at the actual trait method call sites about the precise receiver type.

Inline class methods are always resolved statically, and this means that they may be used in a situation which is similar to the former, but they will not work in situations like the latter. That is, if you want run-time dispatch to occur then you need to use an object that actually creates the connection between receiver type and method implementation; this may or may not be called a wrapper object, depending on how it is implemented/modeled.

FMorschel commented 7 months ago

Just to point out, this could be, depending on how things are done, somewhat related to https://github.com/dart-lang/language/issues/83.

Meaning that either issue could be worked on to fit the other one.

If there was a way of creating a class and using it in the same place as a third-party class (not implementing/extending it), that would solve the use case mentioned in the OP.

FMorschel commented 4 months ago

Mentioned this issue on https://github.com/dart-lang/language/issues/1612 because of HosseinYousefi's comment:

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

TekExplorer commented 4 months ago

This issue, where Date only needs a subset of DateTime, could be resolved with extension types.

extension type Date._(DateTime inner) {
  const Date(DateTime inner) : inner = inner.copyWith(second: 0, minute: 0, hour: 0);
  ...
}

or otherwise a normal dart class that does the same, which could then implement Comparable as desired

I do like the idea of rust-like traits though, as they would have a ton of benefits. the problem is, because we have subtyping, and because we have dynamic, we could easily end up using a super-type's implementation of a trait instead of the subtype's.

I feel like this is already demonstratable with extensions.

otherwise, its just another way to talk about mixins, with the singular difference being that declaring our own "trait" allows us to implement it for any class we're aware of, which is something we can do with extensions, but cannot define an interface for.

If we can bring these two concepts together into "traits," it could make the language much more powerful It would make unions unnecessary, as you could simply define a shared trait and implement it for the types you want. It would make serialization much friendlier, as you could define exactly how certain "primitive" types serialize, and make the entire thing fully type-safe.

For instance, a Serializable trait which must return a Serialized (which the basic json types would implement, where serialize() 'returns this')

In my opinion, the more language features that just-so-happens to solve multiple problems at once, the better. Rust does this super well.