chapel-lang / chapel

a Productive Parallel Programming Language
https://chapel-lang.org
Other
1.78k stars 420 forks source link

Request for type-method shorthand from calling inside the class/record #12613

Open BryantLam opened 5 years ago

BryantLam commented 5 years ago

Related #12612 only by use case.

Feature request / Question on design.

From the Language Spec, 23.3.1 -- "If a class or record defines a type alias, the class or record is generic over the type that is bound to that alias." So if I don't want my class/record to be generic over that type alias, I can't use type aliases.

One alternative is a type method. Are there other alternatives?

But if I create a type method, I have to call it on the fully instantiated record/class. Can a shorthand be enabled for implicitly using the innermost class/record as the method receiver?

record R {
  param size: uint;

  // I want to use a type alias, but I cannot. I do not want R to be generic over the type alias.
  //type t = uint(size);
  //var x: t;

  // So instead, I have to define a type method, but the type method has to be called on the instantiated type.
  proc type t type
    return uint(size);
  var x: R(size).t; // Omitting R(size) causes error: 't' undeclared (first use this function)
}
proc main() {
  var r: R(64);
}

chpl version 1.19.0 pre-release

BryantLam commented 5 years ago

I'm open to alternative designs. I really want to use a type alias, but I don't want the class/record to be generic over it. Maybe a keyword could disable that behavior of the type alias?

Defining an initializer doesn't appear to disable that behavior. I could be doing it wrong, though.

record R {
  param size: uint;
  type t = uint(size);
  var x: t;

  // error: invalid access of class member in initializer argument list
  proc init(param size: uint, x: t) {
    this.size = size;
    this.x = x;
  }
}
mppf commented 5 years ago

@BryantLam - I think creating such a method is reasonable but running into some compiler bugs.

This works for me now:

private proc t(param size) type {
  return uint(size);
}

record R {
  param size: uint;
  var x: t(size);
}
proc main() {
  var r: R(64);
  writeln(r);
}

I would expect at least one of these two to work, but right now neither do.

record R {
  param size: uint;
  var x: t(size);
  proc type t(param size) type {
    return uint(size);
  }
}
proc main() {
  var r: R(64);
  writeln(r);
}
record R {
  param size: uint;
  var x: t(size);
  proc t(param size) type {
    return uint(size);
  }
}
proc main() {
  var r: R(64);
  writeln(r);
}

Note that we have some bugs currently with paren-less type returning functions, e.g. #11216.

bradcray commented 5 years ago

I think @nspark ran into a frustration like this a few years ago relating to being able to use type t to create a "typedef" or alias in most contexts, but necessarily resulting in a generic class/record if used as a member field.

I think one possible way of dealing with this is that in a world where Chapel has private members and methods, it seems one could use private type t = ...; to define an alias that didn't affect the type signature of R. Does anyone see any holes in that proposal?

The other approach would be to have distinct concepts for creating type aliases and type arguments (e.g., typedef t = ... vs. type t = ...), but since the single concept works pretty well in most cases currently, that's what makes me lean toward smaller deltas like private type....

BryantLam commented 5 years ago

I think one possible way of dealing with this is that in a world where Chapel has private members and methods, it seems one could use private type t = ...; to define an alias that didn't affect the type signature of R.

One problem is that private is used for visibility, but the proposed solution is using private to affect the type signature of R, an entirely different concept. There are uses for why I would want the type alias to be visible outside the class (e.g., defining factory functions that use that type).

I do want to use type, but I don't want more intents. The type-method idiom might just be a quirk of Chapel and something a user has to learn about it. It's already available; just needs more scope-based awareness when being used in the same definition so a user doesn't have to type out the fully instantiated type each time.

bradcray commented 5 years ago

One problem is that private is used for visibility, but the proposed solution is using private to affect the type signature of R, an entirely different concept.

This interprets my proposal as more of an abuse or reinterpretation of private than I'd intended. My assumption was that if a type (or param) field was private, it would not be legal to name it in the type's signature (e.g., I couldn't say R(size=64, t=int) or R(64, int) because I don't have access to t from outside the type); and therefore it would also not be necessary to mention it in the type signature (suggesting I could just write R(size=64) or R(64)).

Technically, it does not prevent R from being generic w.r.t. t, but if the initializers never overrode the default type for t, it seems it would effectively not be.

All that said, I do agree that it seems unfortunate that in your original example, you have to write:

  var x: R(size).t; // Omitting R(size) causes error: 't' undeclared (first use this function)

rather than simply:

  var x: t;

I'd hoped that changing t from a method on types to a method that simply returns a type would help:

proc t type
    return uint(size);

but no dice...

mppf commented 5 years ago

Just for the record, I don't think we should make private fields work the way they do in C++ or Java. Rather, I think that declaring a field private should just mean that the field cannot be used outside of the module. This avoids the need to have things like "Friend Classes" or "Protected" etc.

Of course what "private" on a field means is another issue but I'm bringing it up now because if we chose my interpretation of "private", it would work differently if we wanted it to impact the type constructor arguments. In particular, the argument for the private field would be available within the module but not outside of it.

I personally think that we should fix the compiler so that something like proc t type return uint(size); works in this case and recommend that as the solution to this problem.

nspark commented 5 years ago

So if I don't want my class/record to be generic over that type alias, I can't use type aliases.

@bradcray Instead of private type t = ...;, why not const type t = ...;? I feel like this helps capture the intent expressed. That is, while type t is necessarily fixed at compile time, const type t defines a non-generic type alias because it's constant with respect to its assignment in the code. Maybe I'm just misspelling typedef though. :wink:

bradcray commented 5 years ago

My intuition is that const type t in a field context would still cause any default initializers to support an argument for t since const fields can be set in initializers in addition to in the declaration of the field. So this might still make the object more generic than intended.

I'll add that I don't disagree with this statement of Michael's:

I personally think that we should fix the compiler so that something like proc t type return uint(size); works in this case and recommend that as the solution to this problem.

and that in suggesting private type t, I was mostly exploring whether there was a shorthand available for it.

mppf commented 2 years ago

Another potential solution along the lines of using const type t would be for this:

record R {
  param size: uint;
  type t: uint(size);
}

to be defining a record with a type alias but that isn't generic on the type field (only on the param). (This doesn't currently compile because it does not parse).

bradcray commented 2 years ago

(This doesn't currently compile because it does not parse).

[warning, likely misunderstanding follows] @mppf, shouldn't that read:

record R {
  param size: uint;
  type t = uint(size);
}

var myR: R(size=32);

which seems to work as expected: https://tio.run/##S85ILEjN@f@/KDU5vyhFIUihmktBoSCxKDFXoTizKtVKoTQzr8QaKFZSWZCqUKJgCxbQAMlpWnPVcnGVJRYp5FYGWSkEgQVtjY00rf//BwA

[end of warning]

Or was your intention to interpret : uint(size) as being a constraint on what types t can take on (where the answer is "there is only one, so it's no longer generic")? That is, type t: uint(size); would be similar to type t: uint(size) = uint(size);? On reflection, I'm guessing this was the case (because I don't think there's anything about my rewrite that would prevent t from being assigned something else), but it wasn't clear to me from your comment.

FWIW, I generally like the idea of putting type constraints on type aliases (fields in this specific case, where config types would be another case that would benefit from it), as I think it's orthogonal with other features in the language, and could help with things like "this type alias must be a sub-class of this parent class." The one part of this I wonder about is whether the type signature of this would effectively be R(param size: uint, type t: uint(size) = uint(size)) such that one could still type something like R(64, uint(64)) even though they wouldn't need to. This makes the most sense to me in terms of orthogonality with other features, and seems potentially acceptable (particularly relative to other proposals on this issue), though I suppose people like @BryantLam and @nspark would ultimately need to [edit: help] make that call.

mppf commented 2 years ago

Or was your intention to interpret : uint(size) as being a constraint on what types t can take on (where the answer is "there is only one, so it's no longer generic")?

yes

The one part of this I wonder about is whether the type signature of this would effectively be R(param size: uint, type t: uint(size) = uint(size)) such that one could still type something like R(64, uint(64)) even though they wouldn't need to.

Yes, this is an interesting question. I was remembering this issue because I'm adjusting the implementation of type construction in the compiler rework. I'd probably argue that the type constructor would be R(param size: uint) in this case, because type t: uint(size); (or type t: uint(size) = uint(size);) don't define a generic type.

(A related but off-topic issue is whether or not the type constructor gets an argument for something like var x: integral; in some ways this is intuitive but it's a bit difficult to predict which fields are represented in the type constructor and that would be easier if we made everybody write a type t or a var x;. Another related issue is - what happens if you have something like record Q { type t; var x: t; } and then try to type construct it with Q(integral, int(8)). My understanding is that historically, we just don't allow a generic type to end up in one of these class type fields, so this case can't come up. But, we have started to allow a generic type to be passed to a type formal argument outside of type construction...)

bradcray commented 2 years ago

I'd probably argue that the type constructor would be R(param size: uint) in this case, because ... don't define a generic type.

I suppose I buy that. I was doing a much lazier pattern match of "every field typically becomes an argument in the type initializer and/or value initializer." But I agree that if it's not generic / there's only one possible value, there's no reason to follow that path so blindly.

A related but off-topic issue is whether or not the type constructor gets an argument for something like var x: integral;

Isn't this equivalent to a var x; field introducing an implicit type variable? I.e., I believe today, we effectively treat:

record R {
  var x;
}

as:

record R {
  type _;
  var x: _;
}

when it comes to the type constructor. So I'd expect:

record R {
  var x: integral;
}

to turn into:

record R {
  type _: integral;
  var x: _;
}

I agree with:

but it's a bit difficult to predict which fields are represented in the type constructor

and think of this as something you can do, but that any self-respecting type will not do. I.e., I imagine vaguely-constrained fields as being a tool for rapid prototyping and those who don't want to specify type signatures rather than a best practice for hardened libraries.

what happens if you have something like record Q { type t; var x: t; } and then try to type construct it with Q(integral, int(8)).

integral is special enough that I don't feel strongly about this in either way, though I understand the idea and find it somewhat attractive. Replacing integral with a partially instantiated type S(int, ?), say, feels like something we should ultimately support. It wouldn't bother me if we didn't until someone needed it, though (which I suppose is where we are today).

mppf commented 2 years ago

what happens if you have something like record Q { type t; var x: t; } and then try to type construct it with Q(integral, int(8)).

integral is special enough that I don't feel strongly about this in either way, though I understand the idea and find it somewhat attractive. Replacing integral with a partially instantiated type S(int, ?), say, feels like something we should ultimately support. It wouldn't bother me if we didn't until someone needed it, though (which I suppose is where we are today).

Right, but do we actually want x to get a type argument in that case (1 below)? Or just leave the type t argument still there (2 below)?

E.g.

// 1
type MyQ = Q(integral); // MyQ has t = integral, and now t isn't in type constructor
MyQ(x=int(8)); // since t is generic, x is generic, so specify its type

// vs 2
type MyQ = Q(integral); // MyQ has t = integral, but t stays in type constructor
MyQ(t=int(8))); // since t was generic, we can specify it again

I think what we have today is closer to 2 than 1.

lydia-duncan commented 2 years ago

Yes, this is an interesting question. I was remembering this issue because I'm adjusting the implementation of type construction in the compiler rework. I'd probably argue that the type constructor would be R(param size: uint) in this case, because type t: uint(size); (or type t: uint(size) = uint(size);) don't define a generic type.

Maybe that would be the ideal way to handle, but I don't think that's what we do today. I suspect we allow any type to be passed in via that argument but complain if they are incompatible.

One argument in favor of leaving it that way is that it is less complicated to describe to users ("all type fields will generate a corresponding argument in the type constructor" versus "only type fields that are not constrained to a single concrete type will generate a corresponding argument") but I don't really buy that, I'm more making sure there's an argument to consider all consequences.

bradcray commented 2 years ago

do we actually want x to get a type argument in that case (1 below)? Or just leave the type t argument still there (2 below)?

I prefer case 2 over case 1 (i.e., the partial specification acts as a constraint on further specifications of the type, and/or on the value that can be passed to x if the type is never fully concretized in the code). I don't like using x within the type signature to talk about the type of x. (Is there a precedent for that today that I'm not remembering? What I was calling _ in my pseudocode above? Ewww... there is: https://tio.run/##S85ILEjN@f@/KDU5vyhFIUihmktBoSyxSKHCmquWiwvEKrJSCNKosM3MK9G05iovyixJzcnTKNK0/v8fAA

How do I forget these things? My intuition would've been that the only way to refer to x's type in a type signature would be positional rather than via pass-by-keyword).

but I don't think that's what we do today.

To be clear, we don't handle type constraints on type alias declarations at all today, right? (i.e., the type_alias_decl_stmt_inner: statement in the parser doesn't have a TCOLON token in it). So the compiler would never generate such an initializer argument today anyway?

Or maybe the thing that needs to be clarified is that I'm interpreting Michael's proposal as "the compiler would not add a formal argument for this type field because it would see that it's non-generic" where maybe Lydia interpreted it as "the compiler would add such an argument, but because it was so non-generic, the language would prevent the user from ever passing anything to it"? The Michael proposal is what I was envisioning anyway (and if we took the other approach, I agree with Lydia's interpretation of what would happen).

dlongnecke-cray commented 2 years ago

I ran into this recently and found myself wishing that we could just write const type t = ... as a way to express that a given type alias shouldn't be considered part of the type of an instance. I think that would be a cool and (seemingly intuitive) repurposing of the const keyword, since I don't think type can currently be const.

mppf commented 1 year ago

Two syntactical ideas for this that I recently thought of. With both of these, the idea is to write something slightly different to differentiate this case of making a shorthand from the case of defining a generic record with defaults.

(Note above we talked about const type for this but I'm not really a fan of that because I view all of the type fields as const; whether or not they make the type generic seems unrelated to me to const-ness).

let

record R {
  param size: uint;
  let type t = uint(size);
}

with

record R {
  param size: uint;
  with type t = uint(size);
}
vasslitvinov commented 1 year ago

Thinking about consistency: consider that type, param, const decls can appear in three contexts:

If we talk about having type declare a type alias in the "fields" contexts, there is the question of consistency with:

I like the let type t = syntax. Because of the above, we should at the very least extend it to param and const in the "fields" context.