Closed benmerckx closed 2 years ago
That introduces ambiguity for expressions like this:
var t = new Test();
Currently, it's being typed as Type<Unknown>
which is later get inferred to a specific type parameter.
I'm not proposing to change the old behaviour for types without defaults. But yes, if a default was declared it should be used instead of Unknown
there.
This would be super awesome. One could add multiple parameters without making the API more cumbersome for users.
Oh My Goodness, the number of times I've wished for this! Moreso in methods that accept type parameters than generic types, but both would be super nice. I REALLY need to devote some time to familiarizing myself with OCaml so I can fiddle with the compiler..
@DavisDevelopment could you share an example of such method? I've not run into enough situations there myself, type inference often suffices, so it's not mentioned in this proposal. It would probably make sense to include it though.
@benmerckx Yeah, if I'm understanding correctly, then just this should demonstrate:
static function magic<In, Out>(input:In, ?convert:In->Out):Out {
if (convert == null)
return magic(input, x->x);
else
// actually perform the magic
}
// using the magic
static function wizard(mw: String) {
return magic(mw).split('');
}
this example may not be exact, but as far as I can tell, this pattern (and its ambiguity) should be resolvable by Haxe's type-inference. I say that because ?convert
is marked as optional, which tells haxe that it's acceptable to simply omit it from the arguments, and its return-type is the only thing from which Out
's concrete type could be inferred in this instance. But, it seems to me that the first if
statement could be used for inference in the given example. Could one not somehow look to the if (convert == null) return ...
for the return-type, since the return expression is an invokation of 'magic', but with the second argument provided, thus providing the return type. Since you can check if an invokation expression for 'magic' has the second argument provided, then it stands to reason that you could also resolve the return-type of an invokation expression that didn't provide the second argument by checking for the return-type of code that will always be executed when the second argument is unspecified.
So, to further simplify, the compiler might handle patterns like:
static function default_f(x: String):String {
return x;
}
static function example<Out>(value:String, ?f:String->Out):Out {
if (f == null)
return example(value, default_f);
// rest of method body which only executes when [f] has been provided
}
and use the logic laid out above to infer the concrete type for Out
to be the return-type of default_f
.
That's what I think the solution to oddities like http://try-haxe.mrcdk.com/#9Ff3e are, but it might be easier or otherwise better to instead solve the problem this way:
class Test {
static function main() {
trace(magic('Hello').length);
}
static function magic<In, Out = In>(input:In, ?f:In->Out):Out {
if (f == null)
return magic(input, (v -> v));
return f(input);
}
}
I think that, either way, it'd be super nice to be able to check specifically for an argument being "unspecified" (not provided), that won't give a false-positive when the argument is explicity provided as null
I didn't see the edit to your post before and it took me quite some time to realize it was <In, Out = In>
you were after :) I guess that makes sense but it also feels like it ought to be possible without defining the default type, however that's another discussion. It would require access to the other type params to get there. I'll make a mention of it in the proposal as it was still an open question.
I tried implementing it last week. Got as far as parsing and storing the fields in type_param
. I got stuck trying to access that data in load_instance'
though so I gave up. I now think supporting defaults on methods is not that much harder than on types, if I interpret it right, so I'll add that too.
I'm a bit wary of implicit types like that. It can cause some confusion because it's not easy to tell where a type came from. Still, I'm not against this feature from a design point of view.
@benmerckx The example from the proposal:
class Component<Props: {} = {}, State: {} = {}> {}
class MyComponent extends Component {}
class MyComponentWithProps extends Component<MyProps> {}
class MyComponentWithPropsAndState extends Component<MyProps, MyState> {}
How would I declare Component<MyState>
?
If it's not possible, then it's just a partial solution to the problem, which is described in the motivation part.
Also, could you please add some examples for use cases, where you'd want this feature for the reasons other than completely ignoring a type parameter?
How would I declare
Component<MyState>
? If it's not possible, then it's just a partial solution to the problem, which is described in the motivation part.
It's an absolutely adequate solution, that can be expanded upon. Or not, which would be fine.
In languages that don't allow argument skipping for functions, people do the following (as most do in Haxe anyway, because our implicit argument skipping causes quite a lot of headaches): arrange parameters by decreasing likelihood of needing anything other than the default. In this case it's clearly Props
before State
, since stateless components with props are far more common than property-less statefull components (which kind of miss the point of composability). See github code search: 391 results vs 40 results.
Now we could use the default
keyword for explicit skipping, both in function calls and when specifying type parameters, in which case you'd do Component<default, MyState>
.
where you'd want this feature for the reasons other than completely ignoring a type parameter?
Uhm, I would start in tink_core with this:
enum Outcome<Data, Failure = Error> {
Success(data:Data);
Failure(failure:Failure);
}
Or simple examples like typedef Node<Attributes = haxe.DynamicAccess<String>> = ...
, in which case the default behavior is probably good enough for many cases, but can be specialized to something typed.
I could go on all night, but I don't want to waste your time and mine, so if you could please give a number for how many examples you want, I'd be happy to satisfy your curiosity.
How would I declare
Component<MyState>
?
Maybe kinda like bind, with Component<_, MyState>
?
Compared to manually adding the default parameter for Props
with Component<{}, MyState>
, this has the benefit of staying up to date if Component
's default type parameter changes.
How would I declare
Component<MyState>
? If it's not possible, then it's just a partial solution to the problem, which is described in the motivation part.
In that case you would have a Component<{}, MyState>
. Skipping default type parameters does not seem like something that is possible without being specific and naming them or using a keyword for ignoring one. That's however not something I'd like to add to this proposal. @back2dos suggested using Component<default, MyState>
which would work if this is a requirement for having this proposal succeed.
I think the current state of the proposal has enough use without having a mechanism of skipping over some defaults. What I'm proposing is pretty much the same feature as in flow or typescript where it is heavily used.
Also, could you please add some examples for use cases, where you'd want this feature for the reasons other than completely ignoring a type parameter?
I added a few more. If these are not convincing I'm hoping @back2dos could help me out.
Maybe kinda like bind,
with Component<_, MyState>
?
Totally forgot about _
🤦♂️
Could we have a vote on the proposal as is, excluding skipping of parameters that was mentioned? Skipping parameters doesn't seem like a requirement to me but can definitely be revisited if the need arises.
We are fine with the proposal, but have to check how to implement this in our type system. My intuition is that this is similar to constraints, but happens earlier. Not quite sure how much earlier though... do we unify constructor arguments first and then apply defaults, or the other way around?
Sorry, I confused proposals. The one I was thinking of was https://github.com/HaxeFoundation/haxe-evolution/pull/36. I don't think we came to a conclusion on this one.
One more case where this is useful is to help the compiler determine a @:generic
type which it can't infer:
@:generic class Loader<K, V, C = String> {
final load: K -> V;
final cacheKeyFn: K -> C;
public function new(load: K -> V, ?cacheKeyFn: K -> C) {
this.load = load;
this.cacheKeyFn = switch cacheKeyFn {
case null: Std.string;
case v: v;
}
}
// ...
}
// Currently: could not determine type for parameter C
var cache = new Loader((i: Int) -> i + 1);
@benmerckx but that looks that it shouldn't compile anyway, because if C
is anything other than String
, using Std.string
is incorrect...
@nadako If C
is anything other than String
it means you are passing a cacheKeyFn
(not ignoring the optional argument) and therefore not using Std.string
.
It all boils down to being able to tell the compiler what type a generic type parameter is in case you are manually assigning a default value to an optional (null) argument. The example might not be super illustrative because it keeps a lot of the details out but it is a use case I ran into.
You could say new Loader<Int, Int, Int>(i -> i + 1)
in which case the code would still use Std.string
. The clean solution here would be constructor overloading.
I'm still totally in favor, but this is probably not the best use case ;)
Right, I also just realized how a cast would be needed there for it to compile with any other C
(what @nadako said). That makes the example moot.
We didn't reach a consensus on this one in our haxe-evolution meeting yesterday.
We agreed that this works well for normal type-hints and there shouldn't be any obstacles supporting this feature there.
However, the constructor situation is unclear:
class C<T = String> {
public function new(t:T) {}
}
function main() {
new C(12); // C<Int> or compiler error?
}
The question becomes at which point the default type should be applied.
On a related note, the situation for default type parameters on methods is unclear. In this case, we don't even have a syntax for explicit type parameters. It might make sense to support default type parameters only at type-level, at least initially.
In TypeScript that works:
That's the behavior I would expect as well.
I'm leaning toward letting new C(12)
error. It's a pretty contrived example that diverges from all presented use cases.
The problem that this proposal sets out to solve is that often you have a pattern like this:
class FooOf<T:DefaultType> {}
typedef Foo = FooOf<DefaultType>;
Where >90% of the time the user will be using Foo
and in specific cases, they'll want to narrow the type - and not substitute it entirely by go from String
to Int
. Currently, the cost of this is a 2nd type that needs be remembered and clutters completion.
Consider this:
import js.html.*;
class Test {
static function main() {}
}
class Component<T> {
public function new(t:T) {}
}
interface Factory {
function getComponent():Component<Element>;
}
class MyFactory implements Factory {
var someImage:ImageElement;
public function getComponent()
return new Component(someImage);
}
This produces an impressive error:
Test.hx:17: lines 17-18 : Field getComponent has different type than in Factory Test.hx:12: characters 2-45 : Interface field is defined here Test.hx:17: lines 17-18 : Void -> Component
should be Void -> Component Test.hx:17: lines 17-18 : Component should be Component Test.hx:17: lines 17-18 : Type parameters are invariant Test.hx:17: lines 17-18 : js.html.ImageElement should be js.html.Element
The point of this proposal is that users who don't care to narrow the type don't have to pay for it being narrowable. With class Component<T = Element> {}
the above code compile without making the user write new Component<Element>(someImage)
.
I don't think default type-parameters should be used to constrain types (any more than default values constrain values)
@back2dos I think your example can be solved by improving existing type-constraint behavior: The error is a type-parameter invariance issue js.html.ImageElement should be js.html.Element
, which can be solved by constraining the input type (which haxe doesn't currently do). Maybe in this case the ideal solution would be:
class Component<T: Element> {
public function new(t: T);
}
var someImage: ImageElement
var c: Component<Element> = new Component(someImage);
// Internally the compiler would see this as `new Component((someImage: Element))` because of the constraint
// (currently it doesn't which causes the error)
When you have
class Component<T> {
public function new(x:T)
}
And you do
var anything: SomeType
new Component(anything) // -> Component<SomeType>
I argue the default type parameter shouldn't come into play because you've specified the type parameter by passing the argument
The default type parameter should only be used when no type information has been supplied
For example
class Vector<T = Float> {
public function new(length: Int);
public function get(index: Int): T;
}
new Vector() // -> Vector<Float>
new Vector<Int>() // -> Vector<Int>
To implement a component that takes any html element as input, but if none is supplied it generates its own element might look like this
class Component<T: Element = Element> {
var element: T
public function new(?el: T)
}
So on new C(12);
I feel it should be C<Int>
; the default parameter should do nothing to constrain the type of T, only pick a type when none is given
Let's go back to Simn's statement:
The question becomes at which point the default type should be applied.
There are two options, how to treat omitted optional type parameters:
You seem to be suggesting the second option, where the ability to infer the type parameter from constructor usage is simply a fortunate edge case. This however is the actual behavior:
class Test {
static function main() {
var v = new haxe.ds.Vector(100);
$type(v);// haxe.ds.Vector<Unknown<0>>
v[0] = 1;
$type(v);// haxe.ds.Vector<Int>
}
}
Thus the assertion new Vector() // -> Vector<Float>
is wrong.
I'm equally confused by var c: Component<Element> = new Component(someImage);
. It's agreed that for type hints Component
is fully equivalent to Component<Element>
. And it thus can be omitted. Which leads us to the following:
var c:Component = new Component(someImage);
$type(c);//Component<Element> - ideally this would just print Component
var c = new Component(someImage);
$type(c);//Component<ImageElement>
I don't find that particularly intuitive, let alone practical.
Furthermore, you're free to verify that constraining Component.T
to Element
does not change the error in the above example I have provided ;)
A default type parameter should be used when said default is useful in the vast majority of cases. It is therefore impractical to then make the user explicitly specify it anyway, to avoid being outsmarted by the compiler.
I think I didn't fully clarify my examples – none will work in current haxe, they're examples for how things could work
new Vector() // -> Vector<Float>
, I was suggesting what would happen if you gave Vector a default type parameter of Float, which would resolve the Unknown to Float (which is the behavior in TypeScript). You'd want to do this because there is no type-hint for Vector.T from the constructorI'm aware constraining Component.T
to Element
doesn't resolve the error, what I was trying to get at is that with improvements to how constraints are handled it could resolve the error, and the error is fundamentally a type-constraint error and should be fixed that way
The example I gave
var c: Component<Element> = new Component(someImage);
Was meant to be a reduced example of your case in getComponent()
, where you have the compiler trying to unify new Component(someImage)
with the return type of Component<Element>
. I was suggesting that with improved constraint handling in the compiler, new Component(someImage)
i.e. : Component<ImageElement>
could be made to unify with : Component<Element>
(which it currently doesn't because of type-parameter variance)
I think the thing to do is go over a different use case examples and see if one approach is obviously better than the other
So far I can’t find a strong example for one over another but the explicit (new C(12)
is an error) approach is more strict and simpler to implement. I imagine we could always extend it to handle inference later if the error becomes a clear problem
(Apologies for the close; I probably shouldn’t try to use GitHub on a phone)
This proposal has been accepted, see https://haxe.org/blog/evolution-meeting-2021/ for details.
Follow up of haxefoundation/haxe#7304
Optionally declare a default type for generic type parameters.
Rendered version