Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Default type parameters #50

Merged
merged 4 commits into from Nov 15, 2021
Merged

Default type parameters #50

merged 4 commits into from Nov 15, 2021

Conversation

benmerckx
Copy link
Contributor

Follow up of HaxeFoundation/haxe#7304

Optionally declare a default type for generic type parameters.

class Test<T = String> {}
$type((null: Test)); // Test<String>

Rendered version

@RealyUniqueName
Copy link
Member

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.

@benmerckx
Copy link
Contributor Author

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.

@skial skial mentioned this pull request Aug 8, 2018
1 task
@back2dos
Copy link
Member

back2dos commented Aug 9, 2018

This would be super awesome. One could add multiple parameters without making the API more cumbersome for users.

@DavisDevelopment
Copy link

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

@benmerckx
Copy link
Contributor Author

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

@DavisDevelopment
Copy link

DavisDevelopment commented Aug 17, 2018

@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

@benmerckx
Copy link
Contributor Author

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.

@Simn
Copy link
Member

Simn commented Sep 3, 2018

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.

@RealyUniqueName
Copy link
Member

@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?

@back2dos
Copy link
Member

back2dos commented Sep 22, 2018

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.

@kLabz
Copy link

kLabz commented Sep 24, 2018

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.

@benmerckx
Copy link
Contributor Author

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.

@back2dos
Copy link
Member

Maybe kinda like bind, with Component<_, MyState>?

Totally forgot about _ 🤦‍♂️

@benmerckx
Copy link
Contributor Author

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.

@Simn
Copy link
Member

Simn commented Apr 6, 2019

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?

@Simn
Copy link
Member

Simn commented Apr 6, 2019

Sorry, I confused proposals. The one I was thinking of was #36. I don't think we came to a conclusion on this one.

@benmerckx
Copy link
Contributor Author

benmerckx commented Apr 3, 2020

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

@nadako
Copy link
Member

nadako commented Apr 5, 2020

@benmerckx but that looks that it shouldn't compile anyway, because if C is anything other than String, using Std.string is incorrect...

@benmerckx
Copy link
Contributor Author

benmerckx commented Apr 5, 2020

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

@back2dos
Copy link
Member

back2dos commented Apr 5, 2020

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

@benmerckx
Copy link
Contributor Author

benmerckx commented Apr 5, 2020

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.

@Simn
Copy link
Member

Simn commented Jun 19, 2020

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.

@Gama11
Copy link
Member

Gama11 commented Jun 19, 2020

In TypeScript that works:

image

That's the behavior I would expect as well.

@back2dos
Copy link
Member

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<js.html.ImageElement> should be Void -> Component<js.html.Element>
Test.hx:17: lines 17-18 : Component<js.html.ImageElement> should be Component<js.html.Element>
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).

@haxiomic
Copy link
Member

haxiomic commented Jun 19, 2020

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

@back2dos
Copy link
Member

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:

  1. the default is applied
  2. the parameter is left open (as it's already the case for mandatory params)

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.

@haxiomic
Copy link
Member

haxiomic commented Jun 20, 2020

I think I didn't fully clarify my examples – none will work in current haxe, they're examples for how things could work

  • On 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 constructor

I'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)

@haxiomic
Copy link
Member

haxiomic commented Jun 20, 2020

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)

@haxiomic haxiomic closed this Jun 20, 2020
@haxiomic haxiomic reopened this Jun 20, 2020
@Simn Simn added this to the 2021-12 milestone Nov 8, 2021
@Simn
Copy link
Member

Simn commented Nov 15, 2021

This proposal has been accepted, see https://haxe.org/blog/evolution-meeting-2021/ for details.

@Simn Simn merged commit 3d99511 into HaxeFoundation:master Nov 15, 2021
@Simn Simn added accepted and removed lean-accept labels Nov 15, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

9 participants