HaxeFoundation / haxe

Haxe - The Cross-Platform Toolkit
https://haxe.org
6.17k stars 656 forks source link

add RuntimeType<T> constraint #6732

Open nadako opened 6 years ago

nadako commented 6 years ago

What I often want is to allow using abstracts over specific type as type parameters without requiring them to be convertible to that type from the outside.

For example, imagine we wanted to provide an alternative to haxe.DynamicAccess that would allow abstracts over String for keys. Obviously we need to constrain the key type parameter to String, since it's required at run-time:

abstract JSObject<K:String,V>(Dynamic) {

    public function new() this = {};

    public function get(key:K) return Reflect.field(this, key);

    public function keys():Array<K> return Reflect.fields(this);
}

abstract ItemId(String) {}

new JSObject<ItemId,Int>();

Unfortunately this will not compile according to current rules, because: 1) The ItemId abstract is not actually compatible with String since there's no to String. 2) Relfect.fields returns Array<String> which is incompatible with Array<K> due to invariance.

Currently there's no way to represent this in type system without losing safety in some way (adding casts and/or to String to the abstract).

To solve this I propose adding a new compiler-handled constraint to haxe.Constraints, such as this:

abstract RuntimeType<T>(T) from T to T {}

Then we could use it as a constraint, e.g. abstract JSObject<K:RuntimeType<String>,V>.

Compiler would allow unifying RuntimeType<T> with any types that are represented as T at run-time even if they don't define direct casts to T. Also it would allow unifying type parameters constrained with RuntimeType<T> with them.

nadako commented 6 years ago

This is actually somewhat supported by Map keys in a hacky way (@:forwardWithAbstracts for the @:multitType meta), which could be reworked to RuntimeType if/when we implement this proposal.

ncannasse commented 6 years ago

I can understand the idea but this somehow breaks the abstraction of abstracts, since you now have code that depends on their internal representation.

On Sat, Nov 4, 2017 at 5:56 PM, Dan Korostelev notifications@github.com wrote:

This is actually somewhat supported by Map keys in a hacky way ( @:forwardWithAbstracts for the @:multitType meta), which could be reworked to RuntimeType if/when we implement this proposal.

— You are receiving this because you are subscribed to this thread. Reply to this email directly, view it on GitHub https://github.com/HaxeFoundation/haxe/issues/6732#issuecomment-341912382, or mute the thread https://github.com/notifications/unsubscribe-auth/AA-bwFZC4rjct761IW2EnofKZFk6Hb1Mks5szJdQgaJpZM4QSEyC .

back2dos commented 6 years ago

Regarding the second issue: you're not really solving the problem with Reflect.fields() simply being Array<String> and the compiler not having any reason to assume it's Array<ItemId>. Sure Array<String> is Array<RuntimeType<String>> but while K is RuntimeType<String> the converse doesn't hold.

As for the first matter, I can see the appeal. I wonder if something like this might solve your issue:

@:generic
abstract JSObject<K:{ function toString():String; static function ofString(s:String):K; },V>(Dynamic) {

    public function new() this = {};

    public function get(key:K) return Reflect.field(this, key.toString());

    public function keys():Array<K> return [for (f in Reflect.fields(this)) K.ofString(f)];
}

This hinges on @:generic contraints being allowed static functions and to constrain abstracts to structures even if they don't unify per se (not being objects).

nadako commented 6 years ago

this somehow breaks the abstraction of abstracts, since you now have code that depends on their internal representation

Yes, but that's the idea - know the internal representation from the inside without leaking it (via to T) from the outside. I think it's useful for generic collections like the JSObject example of Map or anything like that.

back2dos commented 6 years ago

I think the point is that when you change the internal representation of ItemId to { id: String } (for the lack of a better example) any code that uses it as a key for JSObject will break, because internal details did leak.

nadako commented 6 years ago

well it's the same as with abstract ItemId(String) to String.

back2dos commented 6 years ago

On that we agree. But what I don't get is what the advantage of RuntimeType would then be?

nadako commented 6 years ago

Allowing to implement runtime-type dependent collections (or other "low-level" parametrized types) without having that to String leaked just to make it work.

back2dos commented 6 years ago

Well, I think the point Nicolas raised is that you're leaking fully equivalent information instead.

In a way having the to String is better, because then you state explicitly in your public interface that your type is a subtype of String. It is therefore absolutely clear that if you change that type relationship, you're making a breaking change. Contrast that to RuntimeType<String>, where outside code asserts such a relationship, thus piercing the abstraction.

Example, why that can be problematic: assume there's some library which has abstract Path(String) { ... }. The author chose - for the time being - to make it fully opaque of how the type is internally represented. Now you start using Path as a key into your JSObject. In the next version of pathlib, the implementation is changed to e.g. abstract Path({ directory:String, basename:String, extension:String }) { ... }. The author did not change the public API and will release it as a minor change. Your code, however, breaks.

Admittedly, this problem can already be produced with Map<Path, V>. I'd say that's something to be fixed, rather than expanded on.

nadako commented 6 years ago

Yeah, that makes sense... I guess the "proper" way to handle this without having to String would be something like type classes (provided by e.g. #6616), but that seems to be a bit overcomplicated.