HaxeFoundation / haxe-evolution

Repository for maintaining proposal for changes to the Haxe programming language
111 stars 58 forks source link

Traits #40

Closed bendmorris closed 6 years ago

bendmorris commented 6 years ago

Proposal for adopting Rust traits for Haxe.

Rendered version

nadako commented 6 years ago

I haven't read the whole proposal yet, but I must say right away: I'm very much in favor of adding type classes (or traits, if you like) in Haxe. I miss them very often in my work (and I do regular gamedev, not some fancy FP or systems programming).

I know @ncannasse will probably play the "let's keep Haxe simple" card again, but the traits concept is very actually easy to understand for an "average OO programmer", since they are basically interfaces. The only real difference is that when you want to provide an implementation for some type, you don't create wrapper objects implementing that interface for every value, but instead have a single "vtable" object with methods that are used for all values. Depending on the implementation it all can even be optimized to static methods call. So performance-wise, traits are much better than adapter objects.

One of the most important benefit of having type classes/traits, I believe, is that it will allow libraries to define clear interfaces for interoperation without having to worry about run-time overhead too much, which will allow for easier re-use of different haxelib libraries in a code base, which I feel is a big problem in Haxe right now.

So please, let's consider having this implemented in one way or another.

back2dos commented 6 years ago

Something like this would be great.

That said using being scoped is actually a wonderful thing, that avoids a lot of conflicts. Given that modifying types once they were typed is usually considered a nono anyway, I don't quite see how the particular proposal here would be made to work. So let me sketch a counter proposal.

Let's treat traits truly as types of their own. Say we can define a trait like so:

trait Serializable {
  public function toString():String;
}
//this will implicitly define something like
typedef SerializableImplementor<T> = {
  public function toString(value:T):String;
}

And then implement it like so, in Conversions.hx:

implement Serializable for {name: String} {
  public function toString():String return this.name;
}
//this will implicitly define something like
class Serializable_name_String {//probably a better name is in order
  static public funtion toString(value:T):String;
}

This way, per using Conversions; we get a toString for { name : String }, which is not an improvement in and on itself. The interesting part is polymorphism:

function print(s:Serializable) { ... }
//becomes under the hood
function print<T>(s:T, i:SerializableImplementor<T>) { ... }

The implicit extra argument is why traits need to be separate from interfaces. When calling print({ name: "foo" }) this is resolved to print({ name: "foo" }, Serializable_name_String), which achieves the polymorphism without resorting to any kind of wrapper (no allocation, yay!) or modifying any existing type (no conflicts, yay!). We get the same scoping as with using, meaning we can apply it to whole class paths per import.hx which guarantees isolation between libraries.

Incidentally, this would work with abstracts just as well, since the abstract implementation class (that thingy with all the static methods) is structurally a suitable implementor. This would finally give us a way to abstract over abstracts (what a lovely sentence).

Interoperability between traits and interfaces needs to be solved too. I don't see that as particularly problematic, but I don't want to bloat this comment any further.

bendmorris commented 6 years ago

Thanks for the alternative, and I would love to get the feature in regardless of implementation details. From a design perspective, I agree with @nadako that interfaces are simple for most people to understand and have very close conceptual overlap with traits/typeclasses - so if we can avoid adding a new feature where a closely related one already exists, I think that would be ideal.

What if we added to your proposal:

Then we would have a single unified concept, interoperability, and explicit scoping when types are extended. Any downsides?

back2dos commented 6 years ago
  • All interface definitions are now traits, including the implicit typedef contract you gave an example of.

The reason I decided to draw a line between traits and interfaces is that traits require special treatment, namely for the implicit "implementor" to be determined at the call site and passed to the callee. Treating all interfaces this way would add an indirection that would only cost performance without any further benefits.

  • When a class directly implements an interface in its declaration, it's like any other trait implementation except that it implies a universal using X for that trait implementation, i.e. that class automatically unifies as a trait implementor would without using.

That's what I explicitly left out here:

Interoperability between traits and interfaces needs to be solved too. I don't see that as particularly problematic, but I don't want to bloat this comment any further.

One option is to say that every trait implicitly yields the trivial implementor for the corresponding anonymous structure and that one is added to global using. That way classes that implement a compatible interface will unify with the trait.

There should probably also be a way to "copy" structure between traits and interfaces, e.g. interface ISerializable is Serializable {} and trait Serializable {>ISerializable } or something like that.

nadako commented 6 years ago

one might want to look into similar kotlin proposal: https://github.com/Kotlin/KEEP/pull/87

francescoagati commented 6 years ago

i have extensively played with partials in haxe. https://github.com/FuzzyWuzzie/haxe-partials the nice things of this library is that can inject also static methods and var and resolve the types in the scope of local file where is injected

bendmorris commented 6 years ago

Interestingly the Kotlin proposal also extends interfaces into type classes. I think that's a very natural way to think about type classes in an object oriented language.

The implicit extra argument is why traits need to be separate from interfaces.

The way I've proposed is to use wrapper objects, which would require allocation but for most usage could be optimized into a cached singleton; then the class of the wrapper object serves the purpose of the extra argument. A benefit is language simplicity (and therefore likelihood that this feature is accepted at all) because these wrapper objects can implement interfaces, so we can just declare our traits as interfaces (or even reuse existing interfaces.) I also proposed an a @:generic tradeoff to eliminate the runtime cost where it matters.

Would love to hear more feedback if anyone has it. I'll start playing with an implementation, so if I'm full of it, we'll find out soon enough...

back2dos commented 6 years ago

The way I've proposed is to use wrapper objects, which would require allocation but for most usage could be optimized into a cached singleton

Any kind of caching requires thread safety.

Also, it really should not be a singleton, or else you can't upcast two objects of the same kind:

function compare(c1:Comparable, c2:Comparable) {
}
class CompareA implements Comparable for A {}
compare(new A(), new A());

Unless you can guarantee that for any object, the wrapper is always the same, then physical comparison (and by proxy using objects as keys for ObjectMap, using objects for Array::indexOf) becomes tricky. Not making this guarantee means that any code written against interfaces cannot rely on physical comparison. Making it has it's own implementation challenges, especially if you don't want to fill memory with long lived wrappers.

I wish you the best of luck with your implementation, but still think that what wrappers run a very real risk of becoming either a leaky or an expensive abstraction ;)

Simn commented 6 years ago

I'm very much in favor of this, but I'm worried about implementation complexity. I think this would require a proof of concept implementation before we can discuss it further and vote on it.

ncannasse commented 6 years ago

Following the discussion I had with @bendmorris:

I'm very worried about all the wrapper allocations for traits, it makes simple code very inefficient, without the developer actually understanding why.

Using @:generic is a very local optimization but suddenly you have to propagate it to all methods using traits with potential explosion of cases combination. Also, as soon as you use a container (Array or Map) you're back to wrappers which imply allocating wrappers.

Pooling doesn't work very well, especially with threads, it can be counter productive depending on GC algorithms and leak memory in some cases. That's very hidden "magic" that should not occur without the developer being in control of it.

Also, I fail to see which problem traits are trying to solve:

Traits might provide marginal improvements on some specific things but corresponding to its complexity and drawbacks, it is not life-changing enough to justify adding it to the language, imho.

bendmorris commented 6 years ago

On further reflection I think @back2dos's implicit arg suggestion is the way to go. I'm withdrawing from the discussion; anyone interested can open their own proposal.

Performance considerations regarding wrapper objects are implementation details; I think the consideration for whether these would be a good addition to the language should be discussed separately.