language icon indicating copy to clipboard operation
language copied to clipboard

Rust traits in Dart

Open FMorschel opened this issue 2 years ago • 12 comments

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.

FMorschel avatar Apr 27 '23 19:04 FMorschel

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

lrhn avatar Apr 28 '23 21:04 lrhn

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 avatar May 02 '23 14:05 FMorschel

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?

FMorschel avatar May 10 '23 14:05 FMorschel

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.

eernstg avatar May 10 '23 15:05 eernstg

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 avatar Mar 22 '24 11:03 FMorschel

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

FMorschel avatar Jul 08 '24 14:07 FMorschel

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.

TekExplorer avatar Jul 08 '24 16:07 TekExplorer

Note: This is somewhat similar to https://github.com/dart-lang/language/issues/736.

FMorschel avatar Apr 24 '25 20:04 FMorschel

Node: This is somewhat similar to https://github.com/dart-lang/language/issues/736.

Similar yes, though id also mention that rust traits have the limitations that you must own either the object or the trait - important in our case because we'd need to box the objects.

In this case, since we can already implement interfaces on objects we own, it's down to implementing a trait we own on types we don't own.

Maybe it can look a bit like an extension type.

Rust traits also have the ability to have the same generic interface implemented with different type values - though that may be out of scope.

TekExplorer avatar Apr 26 '25 14:04 TekExplorer

You know... we have all of the pieces we need to make this work.

  • Augmentations: to add trait implementations directly to the classes (name-mangled, to avoid conflict)
    • Also, to add the trait to the list of interfaces, though a bit of special treatment would be necessary (for the name mangling) - not a ton, though.
  • Extensions: To let us add to the static member list without conflicting
  • Extension types: to implicitly "wrap" (box?) classes to access their name-mangled trait implementations when you cast/assign
  • Rust's orphan rules: Are already defined, so the Dart team would "just" need to implement them

After that, it just takes using them in places like jsonEncode and we should be set.

compared to adding "proper" untagged union types A | B which may (will) balloon into adding intersection types A & B, as powerful as it would be, this is way less work, and imo much cleaner and much easier to maintain, since the rest of the language doesnt get affected... at all.

In the end, i imagine that

  • Casting just checks for the added trait fragment and "wraps" it in a named or nameless extension[type] in the compiler.

  • Accessing a conflicting trait-added member directly on the class is treated identically to a conflicting extension

  • If you accept a trait, anything that implements it is welcome. which is always available since a traits implementation is always owned either by the definer of the trait, or the definer of the class, so we dont even need to add import-for-implementation. it just works, since its already there from the import of either library.

  • It may appear to break dynamic... but honestly, dynamic should be used as little as possible.

  • traits dont have the same recursivity issues that untagged unions do, since they're semi-normal named interfaces

  • We will want generic traits, which might get a bit weird when it comes to implementing a class that has traits, but i think that owned-class trait implementation probably trumps the owned-trait one, which... i think we get that for free, so it should be fine.

    • We probably want a lint rule so that mocking can let you know that some traits need mocking too, or the name mangling could be exposed in some way for overriding.

I'm not sure if we'd require trait implementations to be named - actually, no, we do so that we can hide the implicit extension it creates, or access it for use like a normal extension.

Remember! Syntax could be adjusted

// syntax tbd - will use rust-ish syntax for demonstration
// sealed, to prevent implementation by new users, and also exhaustive switching, hell yeah!
// we _could_ remove `trait` but they need to be able to conflict
// "trait" could be renamed, if we want.
sealed trait class Json {}

// resembles extension type
// an extension trait _extends_ an existing class with an implementation for this trait
// `extension trait` only available in the same library as the trait or object it implements for
extension trait JsonNullImpl(Null nil) implements Json {}
extension trait JsonStringImpl(String string) implements Json {}
extension trait JsonNumImpl(num number) implements Json {}
extension trait JsonBoolImpl(bool boolean) implements Json {}
extension trait JsonListImpl(List<Json> list) implements Json {}
extension trait JsonMapImpl(Map<String, Json> map) implements Json {}

// we could probably use `on` for a simpler syntax, but this is probably enough - we can just do it multiple times if we need to apply it on multiple types.
// ex:
extension trait JsonPrimitive on Null, String, num, bool, List<Json>, Map<String, Json> implements Json {}
// but like i said, probably better to have multiple, since marker types that don't use the object are rare.


//We could use `augment` in an alternative syntax, if you choose to come up with one.
// The extension syntax makes more sense to me right now

// the fun part, if we go all out
// abstract class, so we can extend or implement it
// mixin, just to show it's totally normal sometimes
// implements the trait, because it's just a normal interface if we want.
// we can limit this initially, if you prefer
abstract mixin trait class JsonObject implements Json {
  Json toJson();
}

// another file //

// we implement it directly... because we want to, that's why.
// or use `with` (sure) or `extends` (why?)
class Foo implements JsonObject {
  final int id;
  final String name;
  Json toJson() => <String, Json>{'id': id, 'name': name};
}

// maybe `Something` already defines a "toJson", or maybe something else conflicts, or whatever reason we have - we can implement it separately, not too unlike an augmentation
class OtherClass extends Something {}
// JsonObject, not Json since Json is sealed, and doesn't have `toJson()`
extension trait OtherClassJson(OtherClass other) implements JsonObject {}

// we can have annotations like `@MustReimplementTraits()` or something like that so that subtypes are prompted to make their own trait implementation
// for example, `@MustReimplementTraits() base class Mock`

// we could rename:
// Json => JsonPrimitive
// JsonObject => Json

// alternatively
// JsonObject => JsonSerializable/Serializable/whatever

Time to check if I missed anything!

lmfaooooo i just went back to reread some previous comments to see if i missed any concerns, and it looks like i just re-imagined @lrhn 's comment (https://github.com/dart-lang/language/issues/3024#issuecomment-1528102172) entirely independently.

we could say that traits implement traits - but i think that if you're in a position where you have multiple traits that conflict, you probably shouldnt be allowed to implement it

  • then again, we could just "require" that "implementing a normal class" become a conversion method like toX within the trait.
  • then again again, i did just define a disambiguation mechanism - so it could be that anything a trait supplies is only available as extension members, and the actual "implements" bits only come into play once you have assigned to the trait directly.

to be clear i mean Remember! Syntax could be adjusted

trait Foo implements Iterable {}

// is-a iterable
class Bar implements Foo {}

// is-not-a iterable
class Bar2 {} 

// is-a iterable
// basically a wrapper class + extension for members
extension trait Bar2FooImpl(Bar2 bar) implements Foo {}

Bar bar = Bar();
assert(bar is Iterable);

Bar2 bar2 = Bar2();
assert(bar is! Iterable, 'Bar2 doesnt *actually* implement Iterable.');

Bar2FooImpl bar2_named = Bar2(); // implicit wrap, or we could manually wrap like an extension type
assert(bar is Iterable, 'We have the implementation now, since we implement Foo properly');

Foo barFoo = bar; // good! normal assignment
Foo bar2Foo = bar2; // good! implicit-cast/wrap using Bar2FooImpl. Tools can show this.

We could get a better syntax - removing the name could make it easier in some ways, and slightly harder in others

// implicitly declares an extension + extension type directly - not unlike rust, actually
trait Foo {
  void foo();
  void foo2() {}
}

class Bar {}

declare Bar implements Foo {
  void foo() => print('Bar');
}

final Bar bar = bar;
// explicit access
Foo(bar)
  ..foo()
  ..foo2();

Much simpler to reason about, but much tricker for using show and hide for the implicit extension.

  • We could just NOT have the implicit extension... but cmon.
  • Maybe we could just make a new show/hide syntax
// consistent with the extension type syntax
import 'foo.dart' hide Foo(Bar);
import 'foo.dart' as foo show Foo(Bar);
// alternatively, we could go with a form of name mangling.
// i think i prefer the above.
import 'foo.dart' hide Foo#Bar;
import 'foo.dart' as foo show Foo#Bar;

final foo.Foo fooExample = foo.Foo(bar); // explicit wrap
final foo.Foo fooExample2 = bar; // implicit wrap

Okay... im gonna rest my hands now.

TekExplorer avatar Aug 10 '25 23:08 TekExplorer

Foo barFoo = bar; // good! normal assignment
Foo bar2Foo = bar2; // good! implicit-cast/wrap 

These are the easy ones. The hard one, and why Rust traits are their own kind, is:

// Assume both Bar and Bar2 implements Baz.
void choose (Iterable foo) {
  if (foo is Baz) …
} 

This code can occur in a library that doesn't know that Bar2 or the Foo trait exists.

When you pass the value at the trait-add interface type, it must be wrapped. To get the wrapped Bar2 back, it needs to unwrap. That means it needs to know to unwrap.

If every type check in the program needs to check for a trait wrapping (or more than one), that's an extra cost everywhere.

lrhn avatar Aug 15 '25 07:08 lrhn

If every type check in the program needs to check for a trait wrapping (or more than one), that's an extra cost everywhere.

perhaps then type checking could be delegated to the object? Most would of course no-op back to the normal stuff, but a trait box would instead forward that to the inner object.

And maybe that might add cost anyway - though this time to memory instead of execution. is that more? less? i dont know, but i think that the experience of traits is worth having.

Of course, i'm not nearly familiar enough with that stuff to say how much effort that would take, or if thats even a viable solution, but i'm fairly certain it should be doable. intuitively anyway.

hell, maybe that cost you mention is acceptable as is. i wouldnt know.

TekExplorer avatar Aug 16 '25 23:08 TekExplorer