Closed bendmorris closed 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.
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.
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:
using X
for that trait implementation, i.e. that class automatically unifies as a trait implementor would without using
.implement I for T
form, it requires the explicit using
to take effect.Then we would have a single unified concept, interoperability, and explicit scoping when types are extended. Any downsides?
- 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.
one might want to look into similar kotlin proposal: https://github.com/Kotlin/KEEP/pull/87
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
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...
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 ;)
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.
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:
"implementing outside of the type" is not really a problem, you can always extend a class that is part of third party library and implement an interface of your own. In worse case scenario when you really want to inject some code you don't control, there's ways to do that using macros.
"implementing for basic types" is partially feasible with abstracts, although it's a single basic type. Having different basic types can be done using object wrappers which are more explicit when it comes to allocating memory.
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.
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.
Proposal for adopting Rust traits for Haxe.
Rendered version