Conversation
|
|
||
| ## Summary | ||
|
|
||
| Allow `alias` declarations to take type parameters, so an alias body can refer to those parameters and resolve to a different concrete type at each use site: |
There was a problem hiding this comment.
polish: I don't think "alias body" is an established term. We should consider if we want to use that here or prefer a different one.
"alias value" could be an alternative (following Crystal::Alias#value).
| 1. **As a metaclass expression.** `Maybe(Int32)` evaluates to the metaclass `(Int32 | Nil).class`. | ||
| 2. **As a restriction or type declaration.** `: Pair(K, V)` in an argument restriction, type declaration, or `@x : Maybe(Int32)` instance var declaration. Matching proceeds against the substituted type. | ||
| 3. **Inside generic instantiations.** `Hash(String, Maybe(V))` resolves to `Hash(String, V | Nil)`. | ||
| 4. **Inside `forall T` restrictions.** When the alias body itself contains a free variable bound by the surrounding `forall`, the matcher expands the alias before unification. |
There was a problem hiding this comment.
question: Does this enumeration add relevant information?
Particularly the last point is confusing. The rest seems redundant.
| ### How it composes with existing features | ||
|
|
||
| - **Restrictions.** A generic alias appearing in a restriction expands inside the matcher _before_ the existing restriction algorithm runs. The matcher sees only the substituted form, so `def f(x : Maybe(T)) forall T` behaves identically to `def f(x : T | Nil) forall T`. | ||
|
|
||
| - **Generic instantiations.** A generic alias passed as a type argument to another generic (`Array(Maybe(Int32))`) is substituted first, then the outer generic is instantiated with the result. | ||
|
|
||
| - **`typeof`.** Already substitutes through aliases; generic aliases just extend that substitution to take arguments. | ||
|
|
||
| - **`is_a?` / `as` / `responds_to?`.** Operate on the substituted type. `x.is_a?(Maybe(Int32))` is equivalent to `x.is_a?(Int32 | Nil)`. | ||
|
|
||
| - **`{% ... %}` macros.** A generic alias is a `TypeNode` like any other. `Maybe(Int32).resolve` returns the resolved type of the substituted body. Macros that walk type expressions see the alias name and its arguments, not the expanded body, until they call `.resolve`. | ||
|
|
||
| - **Documentation.** A generic alias is documented as `Maybe(T)` with `T` as a free type parameter, exactly as a generic class would be. |
There was a problem hiding this comment.
question: I believe this section could be summarized as "generic aliases resolve in the same order as non-generic ones"
|
|
||
| ## Unresolved questions | ||
|
|
||
| - **Variadic type parameters.** Whether `alias Foo(*T) = ...` should mean "splat into a tuple body" or something more general. Punted to a follow-up RFC. |
There was a problem hiding this comment.
question: What's particularly problematic about splat parameters? I don't understand the challenges with that.
beta-ziliani
left a comment
There was a problem hiding this comment.
I would really like to have this feature! Thanks Stephen!
While I work through it, I wrote down a few small comments that I prefer to submit prior to the full review.
| alias MaybeUser = User | Nil | ||
| ``` | ||
|
|
||
| This is the central case raised in [#2803](https://github.com/crystal-lang/crystal/issues/2803). It also shows up wherever a project ports a generic shape from another language or stdlib idiom (`Optional<T>`, `Result<T, E>`, `Map<K, V>`-style wrappers) — without generic aliases, the standard workaround is to introduce an empty subclass or module, which carries dispatch and runtime cost that an alias would not. |
There was a problem hiding this comment.
... without generic aliases, the standard workaround is to introduce an empty subclass or module, which carries dispatch and runtime cost that an alias would not.
Is this true? The dispatch will be there for an alias as well when it's a union.
|
|
||
| ## Drawbacks | ||
|
|
||
| - **More syntax to learn.** Today `alias` is a one-shape declaration; this adds a second shape. The notation is the same one already used for generic classes, so the cost is modest, but it is non-zero. |
There was a problem hiding this comment.
Just as a comment, for me learning that aliases were not generic was the surprise, so in my head this proposal simplifies syntax instead of making it harder.
|
|
||
| ## Rationale and alternatives | ||
|
|
||
| - **Just use a class or module.** Today's workaround is `class Maybe(T) < (T | Nil); end` (which doesn't compile because you can't inherit a union), or a wrapper class with delegation. The first doesn't work; the second introduces runtime overhead and breaks reference equality with the underlying value. A purely substitutional generic alias is the only way to give the shape a name without changing the runtime model. |
There was a problem hiding this comment.
I don't get why show a workaround that doesn't work.
|
|
||
| Recursive _non-generic_ aliases remain supported (they are how recursive structural types like `Json` are expressed in Crystal today). The alias body is evaluated lazily and the recursion bottoms out where the body refers to the alias name in a position that doesn't immediately require the resolved type. | ||
|
|
||
| A generic alias that recursively references itself with the same type parameters expands like any other recursive alias. A generic alias that references itself with _different_ type parameters expands the substitution at each use; if no fixed point exists, the compiler reports a "recursive alias can't be expanded" error at the use site, mirroring the existing behaviour for non-generic aliases that can't be resolved. |
There was a problem hiding this comment.
What exactly happens if I write:
alias Y(F) = F(Y(F))
alias Erase(T) = EraseThere was a problem hiding this comment.
The second is ill-formed, as it leads to an infinite substitution. It's no different from alias Erase = Erase or alias Erase(T) = Erase(T). Right?
AFAIK, the first one should be OK as long as there's progress (F has a head type constructor, like a Union or a type that itself has a head type constructor).
| wrong number of type vars for Pair (given 1, expected 2) | ||
| ``` | ||
|
|
||
| A non-generic alias used with type arguments (`Maybe(Int32)` where `Maybe` was declared without parameters) is also an error, with the same wording. |
There was a problem hiding this comment.
What about the opposite? Is it valid at all to use Maybe in restrictions or runtime code, just as you can sometimes use Array alone without instantiating it?
|
|
||
| ### Syntax | ||
|
|
||
| `alias` declarations gain an optional parenthesised list of type parameters after the name: |
There was a problem hiding this comment.
Because this is a syntax addition, all the required changes to the Alias AST node's macro interface must also be included.
| A generic alias works as a method-argument restriction in the same way the substituted type would: | ||
|
|
||
| ```crystal | ||
| alias Pair(K, V) = Tuple(K, V) |
There was a problem hiding this comment.
The problem with these snippets is that I can simply write alias Pair = Tuple and everything will just work as before; the only added value is arity checking.
| alias Maybe(T) = T | Nil | ||
| alias StringKeyed(V) = Hash(String, V) | ||
| alias Pair(K, V) = Tuple(K, V) | ||
| ``` |
There was a problem hiding this comment.
Is alias Apply(F, T) = F(T) well-formed?
There was a problem hiding this comment.
Also what happens if an alias resolves to another uninstantiated generic type? If I write Boxed(Array) then it would resolve to Array, but Boxed(Array)(Int32) is currently invalid syntax. What do you think about these types?
| # Pair(Symbol, Maybe(Int32)) ≡ Tuple(Symbol, Int32 | Nil) | ||
| ``` | ||
|
|
||
| No new type is introduced; in particular, `Maybe(Int32)` and `Int32 | Nil` are the _same_ type for every purpose the compiler cares about (`is_a?`, `==`, virtual dispatch, generic instantiation cache keys). |
There was a problem hiding this comment.
What about 1.is_a?(Maybe) or Maybe(Int32).is_a?(Maybe.class)? (see my other comment about using Maybe alone)
|
|
||
| ### With `forall` | ||
|
|
||
| Type parameters of a generic alias can themselves be left unbound and unified by a `forall T` clause. The alias body is expanded inside the matcher before unification, so the surrounding `forall` sees the substituted form: |
There was a problem hiding this comment.
The way free variables are bound in the compiler is not a unification algorithm, yet.
| alias Pair(K, V) = Tuple(K, V) | ||
| ``` | ||
|
|
||
| A generic alias is usable everywhere a regular type is: as a type restriction, in type declarations, in generic instantiations, in metaclass expressions, and as the restriction of a `forall T` method. |
There was a problem hiding this comment.
Probably not everywhere, since they generally do not make sense when reopened, whereas non-generic aliases do:
# implies generic specialization for `Hash(String, Int32)`? (#3298)
class StringKeyed(Int32)
end
# implies partial generic specialization for `Hash(String, V)`?
class StringKeyed(V)
end
# ?
class StringKeyed
endCo-authored-by: Johannes Müller <straightshoota@gmail.com>
Co-authored-by: Johannes Müller <straightshoota@gmail.com>
Co-authored-by: Johannes Müller <straightshoota@gmail.com>
Co-authored-by: Johannes Müller <straightshoota@gmail.com>
Co-authored-by: Johannes Müller <straightshoota@gmail.com>
Co-authored-by: Johannes Müller <straightshoota@gmail.com>
Co-authored-by: Johannes Müller <straightshoota@gmail.com>
Co-authored-by: Johannes Müller <straightshoota@gmail.com>
Rendered: https://github.com/stakach/rfcs/blob/rfc/generic-aliases/text/0028-generic-aliases.md
Summary
Allow
aliasdeclarations to take type parameters so a single declaration can stand in for a family of substituted types:A generic alias is usable everywhere a regular type is — as a restriction, in type declarations, inside generic instantiations, as a metaclass expression, and as a
forall Trestriction — and is purely substitutional (no new runtime type, no new dispatch).Motivation
#2803 — today there's no way to name a parameterised shape without either copy-pasting per-variant aliases (
MaybeInt32,MaybeString, …) or introducing a wrapper class with runtime cost. The RFC body covers prior art (Rust, Scala 3, TypeScript, Haskell) and the rationale for choosing transparent substitution over a nominal wrapper.Status
Drafted at the level of user-visible semantics; intentionally avoids prescribing compiler internals so the implementation has room to land however reviewers think is cleanest. Splat parameters and parameter constraints are explicitly listed as out of scope / unresolved for follow-up RFCs.