Hello Internals,
I'm going to try something new. I've been working on another RFC called "Typed Aliases" (https://wiki.php.net/rfc/typed-aliases). It is very much a draft and in-flux, and I've already worked out the technical mechanics of it ... however, I am very unhappy with the syntax, and while I have a few ideas about that; I assume this list will be much better at it than me. So, please bring your brushes and paint; I brought a bikeshed.
If you haven't read it already, here's the TL;DR:
-
This RFC expands the "use ... as ..." syntax to allow any type expression on the left side. These aliases are PER FILE and expire once the file is compiled.
-
This RFC also adds the ability for an alias to survive a file (currently using the "as alias" syntax that I don't like) which actually just creates a special kind of class. When this special class is encountered during type-checking, the alias is expanded and checked. It also allows this via a "type_alias()" function instead of the "use ... as alias ..." syntax.
How it works:
use string as alias MyString
gets virtually compiled into a special class that would look something like this to ReflectionClass (as it is currently):
class MyString extends PrimitiveAlias {
const PrimitiveType aliasOf = PrimitiveType::string;
}
-
Reflection is a bit weird here, and I'm not exactly happy with it; but I'm curious what the list thinks. I'm open to virtually anything that makes sense here; including not allowing ReflectionClass on the type aliases at all.
-
Since these are "technically" classes, I went with just "use"-ing them like normal classes. Originally, I had something different: "use alias ..." (like "use function ...") to make it more clear. I will probably go back to this, but I'm curious what others think.
I'm going to take a step back and listen/answer questions. But please, grab a brush and paint.
— Rob
Hello Internals,
I'm going to try something new. I've been working on another RFC called
"Typed Aliases" (https://wiki.php.net/rfc/typed-aliases). It is very
much a draft and in-flux, and I've already worked out the technical
mechanics of it ... however, I am very unhappy with the syntax, and
while I have a few ideas about that; I assume this list will be much
better at it than me. So, please bring your brushes and paint; I
brought a bikeshed.If you haven't read it already, here's the TL;DR:
This RFC expands the "use ... as ..." syntax to allow any type
expression on the left side. These aliases are PER FILE and expire once
the file is compiled.This RFC also adds the ability for an alias to survive a file
(currently using the "as alias" syntax that I don't like) which
actually just creates a special kind of class. When this special class
is encountered during type-checking, the alias is expanded and checked.
It also allows this via a "type_alias()" function instead of the "use
... as alias ..." syntax.How it works:
use string as alias MyString
gets virtually compiled into a special class that would look something
like this to ReflectionClass (as it is currently):class MyString extends PrimitiveAlias {
const PrimitiveType aliasOf = PrimitiveType::string;
}
Reflection is a bit weird here, and I'm not exactly happy with it;
but I'm curious what the list thinks. I'm open to virtually anything
that makes sense here; including not allowing ReflectionClass on the
type aliases at all.Since these are "technically" classes, I went with just "use"-ing
them like normal classes. Originally, I had something different: "use
alias ..." (like "use function ...") to make it more clear. I will
probably go back to this, but I'm curious what others think.I'm going to take a step back and listen/answer questions. But please,
grab a brush and paint.— Rob
Hi Rob.
First of all, I'm very much in favor of type aliases generally, so thank you for taking a swing at this.
Second, it looks like you've run into the main design issue that has always prevented them in the past: Should aliases be file-local and thus not reusable, or global and thus we need to figure out autoloading for them? It looks like your answer to that question at the moment is "yes". :-) While I can see the appeal, I don't think that's the best approach. Or rather, if we go that route, they shouldn't be quite so similar syntactically.
There seems to be two different implementations living in the same RFC, uncomfortably. In one, it's a compiler-time replacement. In the other, it's a special class-like. But the RFC seems to go back and forth on what happens in which case, and I'm not sure which is which.
However, you have demonstrated a working class-like for it, which is frankly the biggest hurdle. So I think the direction has promise, but should be adjusted to go all-in on that approach.
To wit:
typealias Stringy: string|Stringable;
typealias UserID: Int;
typealias TIme: Hour|Minute|Second;
typealias FilterCallback: callable(mixed $a): bool; (eventually...)
(etc.)
Each of those produces a class-like, which can therefore be autoloaded like a class. The syntax is also a bit closer to a class (or an Enum, I suppose), so it's much more self-evident that they are defining a reusable thing (whereas "use" does not do that currently). And the syntax is not stringy, like the proposed type_alias(), so it's easier to write. I wouldn't even include type_alias() at that point. It exists at runtime, so reflection is meaningful.
Aliases can then be used only in parameter, return, property, and instanceof types. Extends and implements are out of scope entirely.
(Whether the keyword is typealias or typedef, uses : or =, or whatever, is its own bikeshed I won't dive into at the moment.)
Then, as a separate, entirely optional, maybe even separate RFC (or second vote, or whatever), we have a use string|Stringable as Stringy
syntax. Like all other use
declarations, these are compile-time only, single-file only, and do not exist at runtime, so no reflection. They compile away just like all other use-statements now.
I'm not personally convinced the second is really necessary if we do a good enough job on the first, but I'd probably not stand in the way of having both.
Having typealias/typedef as a class-like also opens up some interesting potential in the future, because classes have all sorts of other things they do, but that is probably too complex scope creepy to get into here so I will not go further than that mention.
I suspect there's also other edge case bits to worry about, particularly if trying to combine a complex alias with a complex type, which could lead to violating the DNF rule. For example:
typealias Foo: (Bar&Baz)|Beep;
use (Bar&Baz)|Beep as Foo;
function narf(Foo&Stringable $s) {}
With the compile time approach, that would expand to (Bar&Baz)|Beep&Stringable
, which is not a valid type def.
With the runtime approach, I don't know if that could be handled gracefully or if it would still cause an error.
I'm not sure what the right solution is on this one, just pointing it out as a thing to resolve.
--Larry Garfield
Hi Rob.
First of all, I'm very much in favor of type aliases generally, so thank you for taking a swing at this.
Second, it looks like you've run into the main design issue that has always prevented them in the past: Should aliases be file-local and thus not reusable, or global and thus we need to figure out autoloading for them? It looks like your answer to that question at the moment is "yes". :-) While I can see the appeal, I don't think that's the best approach. Or rather, if we go that route, they shouldn't be quite so similar syntactically.
There seems to be two different implementations living in the same RFC, uncomfortably. In one, it's a compiler-time replacement. In the other, it's a special class-like. But the RFC seems to go back and forth on what happens in which case, and I'm not sure which is which.
However, you have demonstrated a working class-like for it, which is frankly the biggest hurdle. So I think the direction has promise, but should be adjusted to go all-in on that approach.
To wit:
typealias Stringy: string|Stringable;
typealias UserID: Int;
typealias TIme: Hour|Minute|Second;
typealias FilterCallback: callable(mixed $a): bool; (eventually...)(etc.)
Each of those produces a class-like, which can therefore be autoloaded like a class. The syntax is also a bit closer to a class (or an Enum, I suppose), so it's much more self-evident that they are defining a reusable thing (whereas "use" does not do that currently). And the syntax is not stringy, like the proposed type_alias(), so it's easier to write. I wouldn't even include type_alias() at that point. It exists at runtime, so reflection is meaningful.
Aliases can then be used only in parameter, return, property, and instanceof types. Extends and implements are out of scope entirely.
(Whether the keyword is typealias or typedef, uses : or =, or whatever, is its own bikeshed I won't dive into at the moment.)
Then, as a separate, entirely optional, maybe even separate RFC (or second vote, or whatever), we have a
use string|Stringable as Stringy
syntax. Like all otheruse
declarations, these are compile-time only, single-file only, and do not exist at runtime, so no reflection. They compile away just like all other use-statements now.I'm not personally convinced the second is really necessary if we do a good enough job on the first, but I'd probably not stand in the way of having both.
That's a really good point and would clear up quite a bit of confusion and complexity.
Having typealias/typedef as a class-like also opens up some interesting potential in the future, because classes have all sorts of other things they do, but that is probably too complex scope creepy to get into here so I will not go further than that mention.
I suspect there's also other edge case bits to worry about, particularly if trying to combine a complex alias with a complex type, which could lead to violating the DNF rule. For example:
Oh, DNF is the bane of my existence with this RFC—I don't want to mess this up. I'll see you at the end of the example, though.
typealias Foo: (Bar&Baz)|Beep;
use (Bar&Baz)|Beep as Foo;
function narf(Foo&Stringable $s) {}
With the compile time approach, that would expand to
(Bar&Baz)|Beep&Stringable
, which is not a valid type def.
I can see how you arrived at this, but I think you may have missed a step, since the entirety of Foo will be &'d with Stringable.
Foo = (Bar & Baz) | Beep
want: (Foo) & Stringable
expand Foo: ((Bar & Baz) | Beep) & Stringable
Which can be reduced to the following in proper DNF (at least, it compiles—https://3v4l.org/0bMlP):
(Beep & Stringable) | (Bar & Baz & Stringable)
It's probably a good idea to update the RFC explaining how expansion works.
With the runtime approach, I don't know if that could be handled gracefully or if it would still cause an error.
I'm not sure what the right solution is on this one, just pointing it out as a thing to resolve.
--Larry Garfield
— Rob
I suspect there's also other edge case bits to worry about, particularly if trying to combine a complex alias with a complex type, which could lead to violating the DNF rule. For example:
Oh, DNF is the bane of my existence with this RFC—I don't want to mess
this up. I'll see you at the end of the example, though.typealias Foo: (Bar&Baz)|Beep;
use (Bar&Baz)|Beep as Foo;
function narf(Foo&Stringable $s) {}
With the compile time approach, that would expand to
(Bar&Baz)|Beep&Stringable
, which is not a valid type def.I can see how you arrived at this, but I think you may have missed a
step, since the entirety of Foo will be &'d with Stringable.Foo = (Bar & Baz) | Beep
want: (Foo) & Stringable
expand Foo: ((Bar & Baz) | Beep) & Stringable
Which can be reduced to the following in proper DNF (at least, it
compiles—https://3v4l.org/0bMlP):(Beep & Stringable) | (Bar & Baz & Stringable)
It's probably a good idea to update the RFC explaining how expansion works.
Woof. We're not "fixingup" anyone's DNF elsewhere. I cannot speak for everyone, but I'd be perfectly fine not doing any magic fixing for now, and then debating separately if we should do it more generally. Just doing it for aliases doesn't seem like the best plan.
--Larry Garfield
I suspect there's also other edge case bits to worry about, particularly if trying to combine a complex alias with a complex type, which could lead to violating the DNF rule. For example:
Oh, DNF is the bane of my existence with this RFC—I don't want to mess
this up. I'll see you at the end of the example, though.typealias Foo: (Bar&Baz)|Beep;
use (Bar&Baz)|Beep as Foo;
function narf(Foo&Stringable $s) {}
With the compile time approach, that would expand to
(Bar&Baz)|Beep&Stringable
, which is not a valid type def.I can see how you arrived at this, but I think you may have missed a
step, since the entirety of Foo will be &'d with Stringable.Foo = (Bar & Baz) | Beep
want: (Foo) & Stringable
expand Foo: ((Bar & Baz) | Beep) & Stringable
Which can be reduced to the following in proper DNF (at least, it
compiles—https://3v4l.org/0bMlP):(Beep & Stringable) | (Bar & Baz & Stringable)
It's probably a good idea to update the RFC explaining how expansion works.
Woof. We're not "fixingup" anyone's DNF elsewhere. I cannot speak for everyone, but I'd be perfectly fine not doing any magic fixing for now, and then debating separately if we should do it more generally. Just doing it for aliases doesn't seem like the best plan.
--Larry Garfield
Oh, we're definitely not "fixingup" the expression to DNF... more like spending some time in the RFC showing how the expansion is the same execution as with a DNF expression to prove that it is a valid type expression.
— Rob
I suspect there's also other edge case bits to worry about, particularly if trying to combine a complex alias with a complex type, which could lead to violating the DNF rule. For example:
Oh, DNF is the bane of my existence with this RFC—I don't want to mess
this up. I'll see you at the end of the example, though.typealias Foo: (Bar&Baz)|Beep;
use (Bar&Baz)|Beep as Foo;
function narf(Foo&Stringable $s) {}
With the compile time approach, that would expand to
(Bar&Baz)|Beep&Stringable
, which is not a valid type def.I can see how you arrived at this, but I think you may have missed a
step, since the entirety of Foo will be &'d with Stringable.Foo = (Bar & Baz) | Beep
want: (Foo) & Stringable
expand Foo: ((Bar & Baz) | Beep) & Stringable
Which can be reduced to the following in proper DNF (at least, it
compiles—https://3v4l.org/0bMlP):(Beep & Stringable) | (Bar & Baz & Stringable)
It's probably a good idea to update the RFC explaining how expansion works.
Woof. We're not "fixingup" anyone's DNF elsewhere. I cannot speak for everyone, but I'd be perfectly fine not doing any magic fixing for now, and then debating separately if we should do it more generally. Just doing it for aliases doesn't seem like the best plan.
--Larry Garfield
Oh, we're definitely not "fixingup" the expression to DNF... more like spending some time in the RFC showing how the expansion is the same execution as with a DNF expression to prove that it is a valid type expression.
— Rob
My main struggle with this is readability. As much as I want custom types (and type aliases is a good chunk of the way there), the main issue I have is understanding what the valid inputs are:
function foo(Status $string): void { }
How do I know that Status is a) not a class, b) that I can fulfill the requirement with a string, and/or maybe any object with __toString(), or maybe it’s ints? Or objects or enums?
Even with file-local aliases (which I would definitely prefer to avoid) we will most likely rely on developer tooling (e.g. IDEs and static analyzers) to inform the developer what the right input types are.
I would very much prefer to either go all in with an Enum-like (which means that we can hang methods on to the value) or we need to distinguish between type hints for class-likes and type hints for not-class-likes (*Bar anyone?).
Expanding on type-class-likes: within the type methods, $this->value would refer to the original value, any operators would follow the same rules as either the original values type (e.g. $int = 4; $string = “foo”; $int . $string == “4foo", or call __toString() in all the normal places for strings if defined).
type Stringable: string|int {
public function __toString(): string
{
return (string) $this->value; // original value
}
// Add Stringable methods here
}.
So, with that in mind… I’d also like to open up the ability for Enums to be fulfilled by the backed value, that is:
function foo(Bar $bar): void { }
Where Bar is:
enum Bar: string {
case BAZ = 'baz';
case BAT = ‘bat';
}
And you can call foo()
like: foo(‘baz’) and Bar::BAZ will be passed in.
I realize I’m opening a barn down here, but I just don’t see file-local type aliases as that useful, and while I like the functionality of type-class-likes, I think they would add more non-class behavior (in addition to enums) for things that look like classes if we don’t add some sort of identifier. I’d much rather that we add backed-value to enum casting, and at least make that more consistent with this functionality if we’re going to conflate the syntax.
- Davey
Aliases can then be used only in parameter, return, property, and instanceof types. Extends and implements are out of scope entirely.
Is there a strong technical reason why extends and implements should be out of scope?
There is definite utility for this, to create a local alias in a namespace that can be used throughout the namespace rather than having to refer to the external namespace in many different places.
I would very much prefer to either go all in with an Enum-like (which means that we can hang methods on to the value) or we need to distinguish between type hints for class-likes and type hints for not-class-likes (*Bar anyone?).
Allowing methods also have definite value as most use-cases I have seen in other languages alias in order to add methods, especially for enabling support of interfaces.
Which, however, brings up an important distinction that other languages have made and which I think PHP would benefit from addressing:
- Type Alias => Different Name for Same Type
- Type Def => New Type which has all the same properties and methods of other type
e.g. (being hypothetical with the syntax; bikeshed away):
typealias LocalWidget: Widget
typedef MyWidget: Widget {
function foo() {...}
}
function doSomething(Widget $widget) {...}
$w = new LocalWidget;
doSomething($w); // This works, no problem as LocalWidget === Widget
$w = new MyWidget;
doSomething($w); // This throws an error as MyWidget !== Widget
-Mike
Aliases can then be used only in parameter, return, property, and instanceof types. Extends and implements are out of scope entirely.
Is there a strong technical reason why extends and implements should be
out of scope?There is definite utility for this, to create a local alias in a
namespace that can be used throughout the namespace rather than having
to refer to the external namespace in many different places.
Because it quickly can produce nonsensical syntax.
class A {}
class B {}
typealias AB: A|B;
// This is logically nonsensical.
class C extends AB {}
While there are edge cases where that might be logical (if A and B are interfaces and &-ed together, then it's kinda sorta the same as C implements A, B), separating those out and allowing just that subset sounds like a lot of work for dubious gain, and introducing surprise inconsistency. Better to just avoid that entirely.
--Larry Garfield
Aliases can then be used only in parameter, return, property, and instanceof types. Extends and implements are out of scope entirely.
Is there a strong technical reason why extends and implements should be out of scope?
There is definite utility for this, to create a local alias in a namespace that can be used throughout the namespace rather than having to refer to the external namespace in many different places.
I would very much prefer to either go all in with an Enum-like (which means that we can hang methods on to the value) or we need to distinguish between type hints for class-likes and type hints for not-class-likes (*Bar anyone?).
Allowing methods also have definite value as most use-cases I have seen in other languages alias in order to add methods, especially for enabling support of interfaces.
Which, however, brings up an important distinction that other languages have made and which I think PHP would benefit from addressing:
- Type Alias => Different Name for Same Type
- Type Def => New Type which has all the same properties and methods of other type
e.g. (being hypothetical with the syntax; bikeshed away):
typealias LocalWidget: Widget
typedef MyWidget: Widget {
function foo() {...}
}function doSomething(Widget $widget) {...}
$w = new LocalWidget;
doSomething($w); // This works, no problem as LocalWidget === Widget$w = new MyWidget;
doSomething($w); // This throws an error as MyWidget !== Widget-Mike
Hey Mike,
Keep in mind there is already single-class aliasing with well-known behavior for both local and project-wide aliases. Local aliases are done through 'use' statements, and project-wide aliases can be created by using the class_alias()
function.
I feel like any aliasing for primitive/intersection/union types should feel like an extension of that for local aliases. For 'project-wide' aliases, I'm open to much more different syntax, like typealias or even 'alias'.
As far as extends/implements go, I plan to keep it the same for simple class aliases as there is utility there and the RFC already covers this.
— Rob
Keep in mind there is already single-class aliasing with well-known behavior for both local and project-wide aliases. Local aliases are done through 'use' statements, and project-wide aliases can be created by using the
class_alias()
function.
Good point.
While I'd prefer to be able to use a typedef/typealias syntax, I certainly would not bikeshed over it.
There is definite utility for this, to create a local alias in a
namespace that can be used throughout the namespace rather than having
to refer to the external namespace in many different places.Because it quickly can produce nonsensical syntax.
class A {}
class B {}typealias AB: A|B;
// This is logically nonsensical.
class C extends AB {}
Gotcha. That issue had not occurred to me as my use of type aliases is in other languages that do not have union types. So I was more concerned with this:
namespace A\Really\Long\Namespace {
class A {}
}
namespace My\Current\Namespace {
typedef A: \A\Really\Long\Namespace\A {}
class B extends A {}
}
However, as Rob pointed out there are already ways to accomplish this that I was not thinking of, so my concern for extends and implements is effectively moot.
Methods on typedefs was the sort of "other stuff classes do" that I was trying to avoid getting into right now. :-) Mainly because I can totally see how it's tempting, but also have no idea what it would mean from a type-theoretic perspective. It would only make sense if we're talking type DEFs, not type ALIASes.
That latter sentence is spot-on.
-Mike
My main struggle with this is readability. As much as I want custom
types (and type aliases is a good chunk of the way there), the main
issue I have is understanding what the valid inputs are:function foo(Status $string): void { }
How do I know that Status is a) not a class, b) that I can fulfill the
requirement with a string, and/or maybe any object with __toString(),
or maybe it’s ints? Or objects or enums?Even with file-local aliases (which I would definitely prefer to avoid)
we will most likely rely on developer tooling (e.g. IDEs and static
analyzers) to inform the developer what the right input types are.I would very much prefer to either go all in with an Enum-like (which
means that we can hang methods on to the value) or we need to
distinguish between type hints for class-likes and type hints for
not-class-likes (*Bar anyone?).Expanding on type-class-likes: within the type methods, $this->value
would refer to the original value, any operators would follow the
same rules as either the original values type (e.g. $int = 4; $string
= “foo”; $int . $string == “4foo", or call __toString() in all the
normal places for strings if defined).type Stringable: string|int {
public function __toString(): string
{
return (string) $this->value; // original value
}// Add Stringable methods here
}.
Methods on typedefs was the sort of "other stuff classes do" that I was trying to avoid getting into right now. :-) Mainly because I can totally see how it's tempting, but also have no idea what it would mean from a type-theoretic perspective. It would only make sense if we're talking type DEFs, not type ALIASes. I'm not against that, and it could be fun to try and think through the type theoretical implications, but I don't think that's what Rob was going for so I didn't want to take that side quest just yet. (Though if he's OK with it, I'm OK with it.)
So, with that in mind… I’d also like to open up the ability for Enums
to be fulfilled by the backed value, that is:
This is
- Off topic for type aliases.
- Has been discussed numerous times before. Enums are not equivalent to their backing value. The backing value is a standardized-serialization value. Nothing more. A string-backed enum is not a string, and a string is not a string-backed enum. Trying to use an enum as transparently equivalent to its backing value is a categorical error and belies a misunderstanding of what Enums are.
cf: https://peakd.com/hive-168588/@crell/on-the-use-of-enums
--Larry Garfield
My main struggle with this is readability. As much as I want custom
types (and type aliases is a good chunk of the way there), the main
issue I have is understanding what the valid inputs are:function foo(Status $string): void { }
How do I know that Status is a) not a class, b) that I can fulfill the
requirement with a string, and/or maybe any object with __toString(),
or maybe it’s ints? Or objects or enums?Even with file-local aliases (which I would definitely prefer to avoid)
we will most likely rely on developer tooling (e.g. IDEs and static
analyzers) to inform the developer what the right input types are.I would very much prefer to either go all in with an Enum-like (which
means that we can hang methods on to the value) or we need to
distinguish between type hints for class-likes and type hints for
not-class-likes (*Bar anyone?).Expanding on type-class-likes: within the type methods, $this->value
would refer to the original value, any operators would follow the
same rules as either the original values type (e.g. $int = 4; $string
= “foo”; $int . $string == “4foo", or call __toString() in all the
normal places for strings if defined).type Stringable: string|int {
public function __toString(): string
{
return (string) $this->value; // original value
}// Add Stringable methods here
}.
Methods on typedefs was the sort of "other stuff classes do" that I was trying to avoid getting into right now. :-) Mainly because I can totally see how it's tempting, but also have no idea what it would mean from a type-theoretic perspective. It would only make sense if we're talking type DEFs, not type ALIASes. I'm not against that, and it could be fun to try and think through the type theoretical implications, but I don't think that's what Rob was going for so I didn't want to take that side quest just yet. (Though if he's OK with it, I'm OK with it.)
To be fair, I should probably mention that I've already explored it some (which I alluded to in another thread a couple of weeks ago). 😉 So... I guess it is 'on-topic' for no other reason than it is interesting.
Here are my notes thus far:
Sugary Generics
Since we could attach behaviors (at least at the engine level) we could use this to implement generics. Imagine we have a Box<T> and want to instantiate a Box<int|float>. To do this, when we define a Box<T>, we actually define an alias internally.
This is what the definition of our generic box class in PHP might look like:
class Box<T> { public T $var; }
It would get compiled to something like this (though it would probably be invalid php, it perhaps illustrates my meaning):
class Box {
public BoxT $var;
public function __construct(private alias BoxT) {}
}
Then, to instantiate a Box<int|float >, the engine compiles the constructor, passing the types as arguments (still probably not ever valid php) and stealing a bit from python's "self":
$box = new Box<int|float>; // compiles to $box = new Box(alias int|float);
The beauty of this is that it automatically becomes an error if you forget the type argument (and if we made it the last argument, would allow setting default values for BC reasons like Collection<T = mixed>)
Even constraints on T could be expressed similarly:
class Box<T: int|float> { public T $var; }
which may get compiled into this not ever valid php:
class Box {
public BoxT $var;
private alias BoxTConstraint => int|float;
public function __construct(private alias BoxT) {
if (BoxT is not a BoxTConstraint) throw new RunTimeException();
}
Basically, it just needs to check that the BoxT alias is of the right type during construction, essentially making generics just a sugary layer over aliases.
Ambitious Type System Replacement
I've also explored it in the case of types, in general, by replacing the entire type system with this way of representing types (where a special class represents a real type of value and its behavior). This would allow for defining casting rules, operators, passing types as values (for pattern matching), etc on types themselves. I have no idea what that would look like "at scale", but it is interesting to think about because primitive types would have the same way of working as classes and everything else. It would also separate types from their implementation—whether we want to expose this to user-land is a different story.
I suspect this could be a 'technical-only' change and not affect user-land at all. zvals would probably get a lot simpler though...
I generally stop myself from thinking too much about it, because while interesting, it is a TON of work. Not that I'm afraid of doing that work, I just don't want to do it by myself. So, I'm cautiously optimistic as a >=9.0 type thing and finding the right people/support. I have no idea how to do that, but I can at least try.
Constraints
Another exploration is that it would potentially allow for setting some constraints on raw values:
alias EmailAddress => string {
if (!is_valid_email($value)) throw new RuntimeException('not a valid email address');
}
However, I think there is a better solution for that (classes/structs/records/etc)... except for
Typed Literals
This could easily allow typed literals for value types. After all, a typed literal can be expressed as an alias over a type with a constraint.
The Hammer and Screw Problem
There are probably other use-cases by making the type-system more 'class-like', but I will say that PHP's class-system is quite robust—if not as robust as its arrays—and should probably be relied on more. That being said, I've been working through this for a bit now, and it seems that I might be wielding this as a hammer and seeing nails everywhere, even when they are screws. So, while interesting, in many cases, it probably isn't the best way to do things.
— Rob
My main struggle with this is readability. As much as I want custom
<snip>
types (and type aliases is a good chunk of the way there), the main
issue I have is understanding what the valid inputs are:Methods on typedefs was the sort of "other stuff classes do" that I was trying to avoid getting into right now. :-) Mainly because I can totally see how it's tempting, but also have no idea what it would mean from a type-theoretic perspective. It would only make sense if we're talking type DEFs, not type ALIASes. I'm not against that, and it could be fun to try and think through the type theoretical implications, but I don't think that's what Rob was going for so I didn't want to take that side quest just yet. (Though if he's OK with it, I'm OK with it.)
I 100% agree, but I think type DEFs are something we need to consider before implementing ALIASes so that we don’t block one with the other.
So, with that in mind… I’d also like to open up the ability for Enums
to be fulfilled by the backed value, that is:This is
- Off topic for type aliases.
- Has been discussed numerous times before. Enums are not equivalent to their backing value. The backing value is a standardized-serialization value. Nothing more. A string-backed enum is not a string, and a string is not a string-backed enum. Trying to use an enum as transparently equivalent to its backing value is a categorical error and belies a misunderstanding of what Enums are.
- It’s on-topic insofar as it’s a potential option for a type hint, and therefore could cause confusion
- I do understand this, but I also see that what will happen is a million instances of:
typealias StatusValue: Status|string;
function (StatusValue $status): void {
$status = is_string($status) ? Status::tryFrom($status) : $status;
}
Possibly with a try/catch around it too if you want a custom exception to be thrown.
And yes, I know you can just do this today:
function (Status|string $status): void {
$status = is_string($status) ? Status::tryFrom($status) : $status;
}
In reality, if we have type DEFs, we’ll probably see a typedef for every backed enum that encapsulates this behavior, so why not just allow it on the enum itself?
My point is that if you talk about type DEFs, you now have this feature where you can input one type and get something that encapsulates it, and it seems weird that enums would LOOK similar In type hint usage and function differently.
Again, I’m mostly concerned about the cognitive overhead of type ALIASes AND type DEFs if they indistinguishable from classes and enums when looking at a function signature and trying to call it without the assistant of advanced auto-complete. At the very least,
It should be clear that there is somewhere else I have to look to understand what the possible inputs are, and would like to again point to the possibility of distinguishing the use of type ALIASes (and/or DEFs) in type hints:
public *StatusValue $status;
function (*StatusValue $status): *StatusValue { }
etc.
- Davey
My point is that if you talk about type DEFs, you now have this feature where you can input one type and get something that encapsulates it, and it seems weird that enums would LOOK similar In type hint usage and function differently.
Personally, I would prefer to go the other way: make typedefs, like enums, something you explicitly construct / cast to, rather than something that implicitly coerces any compatible value.
Like enums, I would want to use typedefs to prevent accidental mixing of values (e.g. a name where a reference number was expected, or a size in pixels where a size in centimetres was expected). That use is compromised if every scalar value is silently accepted for any matching typedef.
Regards,
Rowan Tommins
[IMSoP]
My point is that if you talk about type DEFs, you now have this feature where you can input one type and get something that encapsulates it, and it seems weird that enums would LOOK similar In type hint usage and function differently.
Personally, I would prefer to go the other way: make typedefs, like
enums, something you explicitly construct / cast to, rather than
something that implicitly coerces any compatible value.Like enums, I would want to use typedefs to prevent accidental mixing
of values (e.g. a name where a reference number was expected, or a size
in pixels where a size in centimetres was expected). That use is
compromised if every scalar value is silently accepted for any matching
typedef.Regards,
Rowan Tommins
[IMSoP]
There's a couple of different use cases floating around close to each other here.
One is a type alias, which is just "a way to type less."
The other is a type def, which creates a new for-reals type that you can check at runtime.
They are closely related, but serve different purposes. While an alias could make sense file-local or app-wide, in practice a def only makes sense app-wide.
Whether we want to have one or the other or both is a subjective question. Personally I'd be fine with both, as I see them serving different purposes.
eg:
typealias Foo: Bar|Baz;
Foo is now a compile time copy-paste for Bar|Baz, meaning this is totally valid:
class A {
public Foo $a;
}
class B {
public Bar|Baz $a;
}
The other direction is:
typedef UserId: int;
UserID is now an object that consists of just an int, but can be type checked against. What's unclear is if you can do other int-y things to them (add, subtract, etc.), or if it's really just a shorthand for
readonly class UserId {
public function __construct(public int $value) {}
}
I could see an argument for either. If we had operator overloads, I would absolutely go for the latter; make all of those other int-y things opt-in. Once we get pattern matching, as noted a few months ago, it could be quite powerful to allow patterns as a validation on a typedef of that sort.
typedef UserId: int is >0;
Though that opens up all kinds of interesting questions about a typedef based on another typedef, if that's a form of inheritance or not, etc. Again, I'm not sure if Rob wants to go there or not, but it's a place my brain has gone before. :-)
We may want to focus just on aliases for now, but design them in such a way that they do not cause an issue for typedefs in the future. (Eg, using the typealias
keyword instead of just type
.)
--Larry Garfield
The other direction is:
typedef UserId: int;
UserID is now an object that consists of just an int, but can be type checked against. What's unclear is if you can do other int-y things to them (add, subtract, etc.), or if it's really just a shorthand for
Referencing prior art (e.g. Go) PHP could allow int literals — e.g. 1
, 47
, etc. — to be passed to typedefs derived from ints but require int variables to be typecast to the required type. Same for string literals.
In Go you cannot add or subtract on a typedef without casting to the underlying type. I would definitely prefer that to be relaxed, but only if it is relaxed via an explicit opt-in, e.g. something maybe like this:
typedef UserId: int operations: +, -, *, /;
typedef UserName: string operations: .;
Or less cryptic:
typedef UserId: int operations: add, subtract, multiply, divide;
typedef UserName: string operations: concat;
Going with the named operations would allow other operations to be opt-in in the future, but would call into question defining operations as a first-class language element.
readonly class UserId {
public function __construct(public int $value) {}
}I could see an argument for either.
Typedefs enable a developer to write more robust code that they currently cannot do, whereas typealiases are really just syntax sugar, albeit sugar that probably tastes really good.
Said more explicitly, I would prefer both but if it is has to be only one to start, I would prefer starting with typedefs.
Though that opens up all kinds of interesting questions about a typedef based on another typedef, if that's a form of inheritance or not, etc. Again, I'm not sure if Rob wants to go there or not, but it's a place my brain has gone before. :-)
Given that a typedef can always just reference the original type(s) rather than basing a typedef on another typedef I would err on the conservative side and say initially no typedef of a typedef.
The current downside would be that a complex union typedef defined in one namespace could not easily be referred to in another namespace without repeating the union typedef. Whether that would become a frequent problem remains to be seen so it could wait for a future RFC if so.
Another limit would to the workaround would be if a typedef is defined as private for a namespace. This as namespace-private is not currently possible we could cross that typedef-on-a-typedef bridge on a future day if namespace-private ever becomes possible. #jmtcw
We may want to focus just on aliases for now, but design them in such a way that they do not cause an issue for typedefs in the future. (Eg, using the
typealias
keyword instead of justtype
.)
Another option is to use a different syntax:
type MyNewType: Foo
type MyAlias = Foo
Not arguing for or against any specific syntax, just pointing out that there are other potential options.
-Mike
Referencing prior art (e.g. Go) PHP could allow int literals — e.g.
1
,47
, etc. — to be passed to typedefs derived from ints but require int variables to be typecast to the required type. Same for string literals.
That's an interesting compromise, worth considering.
In Go you cannot add or subtract on a typedef without casting to the
underlying type. I would definitely prefer that to be relaxed, but only
if it is relaxed via an explicit opt-in, e.g. something maybe like
this:typedef UserId: int operations: +, -, *, /;
typedef UserName: string operations: .;
I think this would stray into some of the same complexity as operator
overloads on objects, in terms of the types and values allowed. For
instance:
typedef Metres: int;
assert( Metres(2) + Metres(1) === Metres(3) ); // most obvious
assert( Metres(2) + 1 === Metres(3) ); // seems pretty clear
$_GET['input'] = '1';
assert( Metres(2) + $_GET['input'] === Metres(3) ); // might be more
controversial
typedef Feet: int;
assert( Metres(2) + Feet(1) === Metres(3) ); // almost certainly a bad idea
Not unsolvable, but probably enough scope for nuance and debate that it
should be left well into Future Scope.
type MyNewType: Foo
type MyAlias = Foo
I know this was only an example, but as a general point, I think we
should avoid concise but cryptic differences like this. PHP is generally
keyword-heavy, rather than punctuation-heavy, and I think that's a valid
style which we should keep to.
--
Rowan Tommins
[IMSoP]
In Go you cannot add or subtract on a typedef without casting to the
underlying type. I would definitely prefer that to be relaxed, but only
if it is relaxed via an explicit opt-in, e.g. something maybe like
this:typedef UserId: int operations: +, -, *, /;
typedef UserName: string operations: .;
I think this would stray into some of the same complexity as operator overloads on objects, in terms of the types and values allowed. For instance:
I tend to agree that allowing operations may be too much for an initial scope given that it is unlike anything else in the current language and with no other languages offering an equivalent AFAIK.
I would however make the distinction that it is unlike operator overloading because the big concern was what constituted an operation for any given type could be too subjective. In your example of Metres
it is pretty obvious, but not at all obvious for a User
, for example. (BTW, thank you for not calling out my nonsensical example of operations on a UserId
; when I wrote that I clear was not thinking about if they were relevant, doh!)
However give the suggestion regarding operations with a typedef, the only operations that I suggested would be valid would be the ones already defined on the underlying type, (when I mentioned other operations I was thinking of methods — see my the following example with round — not operators so that is not the same as operator overload.) For example:
/**
- Currency is an int so for example in USD 1
- unit of currency not a dollar but a cent.
/
typedef Currency: int operations: +,-,,/,round;
function CalcTotal(Currency $subTotal, Currency $shipping, float $tax):Currency {
return round($subTotal*(1+$tax/100),0) + $shipping;
}
typedef Metres: int;
assert( Metres(2) + Metres(1) === Metres(3) ); // most obvious
assert( Metres(2) + 1 === Metres(3) ); // seems pretty clear
Both of those are in line with what I was suggesting.
$_GET['input'] = '1';
assert( Metres(2) + $_GET['input'] === Metres(3) ); // might be more controversial
I would not consider this appropriate as it has two levels of conversion and could thus end up with unintended edge cases. To do the above I think you would have to either convert or typecast:
assert( Metres(2) + intval($_GET['input']) === Metres(3) );
assert( Metres(2) + (int)$_GET['input'] === Metres(3) );
typedef Feet: int;
assert( Metres(2) + Feet(1) === Metres(3) ); // almost certainly a bad idea
This would be operator overloading where knowledge of the conversion between meters and feet would be required, and that is not in any way in scope with what I was suggesting.
As an aside, I am against userland operator overloading as I have seen in other languages that operator overloading gets abused and results in code that is a nightmare to maintain. OTOH, I would support operator overloading in specific cases, e.g. a Measurement
class in PHP core could allow adding meters to feet, assuming such a proposal were made and all other aspects of the RFC were of the nature to be voted in.
To reiterate on typedefs, what I was suggesting was that if an operation was explicitly allowed — e.g. + — then anything that would work with the underlying type — such as adding an int 1 would work without typecasting and yet still result in the typedef type, e.g. Meters(2) + 1 results in a value of type Meters. (note that I corrected your spelling of 'Meters' here. ;-)
But I agree, this is probably a bridge too far for a first RFC for typedefs.
type MyNewType: Foo
type MyAlias = Foo
I know this was only an example, but as a general point, I think we should avoid concise but cryptic differences like this. PHP is generally keyword-heavy, rather than punctuation-heavy, and I think that's a valid style which we should keep to.
Here, I also tend to agree WRT PHP. Was just pointing out for sake of laying out other options that were implied not to exist.
-Mike
In Go you cannot add or subtract on a typedef without casting to the
underlying type. I would definitely prefer that to be relaxed, but only
if it is relaxed via an explicit opt-in, e.g. something maybe like
this:typedef UserId: int operations: +, -, *, /;
typedef UserName: string operations: .;
I think this would stray into some of the same complexity as operator overloads on objects, in terms of the types and values allowed. For instance:I tend to agree that allowing operations may be too much for an initial scope given that it is unlike anything else in the current language and with no other languages offering an equivalent AFAIK.
I would however make the distinction that it is unlike operator overloading because the big concern was what constituted an operation for any given type could be too subjective. In your example of
Metres
it is pretty obvious, but not at all obvious for aUser
, for example. (BTW, thank you for not calling out my nonsensical example of operations on aUserId
; when I wrote that I clear was not thinking about if they were relevant, doh!)However give the suggestion regarding operations with a typedef, the only operations that I suggested would be valid would be the ones already defined on the underlying type, (when I mentioned other operations I was thinking of methods — see my the following example with round — not operators so that is not the same as operator overload.) For example:
/**
- Currency is an int so for example in USD 1
- unit of currency not a dollar but a cent.
/
typedef Currency: int operations: +,-,,/,round;
function CalcTotal(Currency $subTotal, Currency $shipping, float $tax):Currency {
return round($subTotal*(1+$tax/100),0) + $shipping;
}
This is very similar (behaviorally) to what I wanted to do with GMP. Overloading GMP would have given you int-like powers in your type. The biggest negative feedback I got was that people would abuse it still; so we need some way to prevent abuse. If you read Jordon's operator overloads RFC, and my GMP overloading RFC, you can see that users basically need a way to define how to operate over even just an integer.
For example, Dollar(1) + Euro(3) is what? Or even Dollar(1) + 1? How does a developer prevent someone from doing something nonsensical? The language needs to enforce some rules and/or the developer needs to be able to define them. These rules need to be intuitive and well reasoned, IMHO.
typedef Metres: int;
assert( Metres(2) + Metres(1) === Metres(3) ); // most obvious
assert( Metres(2) + 1 === Metres(3) ); // seems pretty clearBoth of those are in line with what I was suggesting.
$_GET['input'] = '1';
assert( Metres(2) + $_GET['input'] === Metres(3) ); // might be more controversialI would not consider this appropriate as it has two levels of conversion and could thus end up with unintended edge cases. To do the above I think you would have to either convert or typecast:
assert( Metres(2) + intval($_GET['input']) === Metres(3) );
assert( Metres(2) + (int)$_GET['input'] === Metres(3) );typedef Feet: int;
assert( Metres(2) + Feet(1) === Metres(3) ); // almost certainly a bad ideaThis would be operator overloading where knowledge of the conversion between meters and feet would be required, and that is not in any way in scope with what I was suggesting.
As an aside, I am against userland operator overloading as I have seen in other languages that operator overloading gets abused and results in code that is a nightmare to maintain. OTOH, I would support operator overloading in specific cases, e.g. a
Measurement
class in PHP core could allow adding meters to feet, assuming such a proposal were made and all other aspects of the RFC were of the nature to be voted in.To reiterate on typedefs, what I was suggesting was that if an operation was explicitly allowed — e.g. + — then anything that would work with the underlying type — such as adding an int 1 would work without typecasting and yet still result in the typedef type, e.g. Meters(2) + 1 results in a value of type Meters. (note that I corrected your spelling of 'Meters' here. ;-)
But I agree, this is probably a bridge too far for a first RFC for typedefs.
type MyNewType: Foo
type MyAlias = Foo
I know this was only an example, but as a general point, I think we should avoid concise but cryptic differences like this. PHP is generally keyword-heavy, rather than punctuation-heavy, and I think that's a valid style which we should keep to.Here, I also tend to agree WRT PHP. Was just pointing out for sake of laying out other options that were implied not to exist.
-Mike
In other news, I'm highly considering refactoring the records RFC to be a typedef RFC; the infrastructure is there; we just need more restrictions.
— Rob
Hi Rob,
This is very similar (behaviorally) to what I wanted to do with GMP. Overloading GMP would have given you int-like powers in your type. The biggest negative feedback I got was that people would abuse it still; so we need some way to prevent abuse. If you read Jordon's operator overloads RFC, and my GMP overloading RFC, you can see that users basically need a way to define how to operate over even just an integer.
For example, Dollar(1) + Euro(3) is what? Or even Dollar(1) + 1? How does a developer prevent someone from doing something nonsensical? The language needs to enforce some rules and/or the developer needs to be able to define them. These rules need to be intuitive and well reasoned, IMHO.
My arguments were that the use-cases where it is clear and objective what each operator would mean are few and far between[caveat, see below] and so rather than provide general operator overloading PHP should build those use-cases into a core extension.
For example, to add Dollar + Euro, having a Money or Currency extension as part of PHP core would make huge sense to me, but not giving everyone the ability to overload operators for every type.
I did not follow the GMP discussions as I have never needed to use that extension in any of my own PHP development so I am not familiar with the arguments made and may be mischaracterizing your proposal; correct me if I do.
BTW, where is your GMP RFC? I searched for it but could not find it. Did you propose operator overloading for GMP in core, or in userland?
For example, Dollar(1) + Euro(3) is what? Or even Dollar(1) + 1? How does a developer prevent someone from doing something nonsensical?
If the rules are built into core, then the compiler and/or runtime stops them from doing something nonsensical, right?
The language needs to enforce some rules and/or the developer needs to be able to define them.
My argument is that the language should enforce those rules as allowing the userland developer to overload operators will result in every developer defining different rules for their own classes, leading to a tower of babble. And the chances that many developers will do things nonsensical with their operators approaches 100%.
I do acknowledge not everyone agrees with me on this, and if so that is their right. If enough people disagree with me then PHP may eventually have general operator overloading. My hope is that there are not enough people who disagree.
These rules need to be intuitive and well reasoned, IMHO."
I hope anything that passes an RFC is intuitive and well reasoned because otherwise our governance model is flawed and maybe we need to address that first, if so?
-Mike
[caveat] — I am aware there are likely numerous use-cases in financial, scientific and/or related fields that could benefit from operator overloading but that would likely never be mainstream enough to make it into PHP core. For this I think making it easier to implement operator overloading in an extension would be a reasonable solution (if that is possible.) Yes, it would require people learn to develop extensions, but working groups for those use-cases could form, and if it could be done in a way that supports Zephir, then writing an extension should not be too hard. https://zephir-lang.com https://zephir-lang.com/
That, or come up with some way to limit operator overloading to only those things where overloads are objectively obvious, but I really have no idea what kind of limits could do that and I doubt it is even possible.
And if none of those things then those communities can get by with what they have always been able to do; use methods and parameters. One thing is clear to me, PHP is never going to overtake Python (or Julia, et. al.) in those communities, so why appeal to a base that is not really interested except on the periphery? OTOH, Python is not likely to overtake PHP for general web development no matter how "popular" it becomes.
BTW, why has nobody ever mentioned Zephir on this list (that I am aware of?)
BTW, why has nobody ever mentioned Zephir on this list (that I am aware of?)
Zephir is an interesting idea that has never quite fulfilled its aims. The Phalcon developers hoped that creating a more PHP-like language would allow more people to work on extensions such as their framework, but it doesn't seem to have worked out that way.
The worse problem was that Zephir itself had very few contributors. A few years ago, the project came close to shutting down as there was nobody left to maintain it; Phalcon was to be rewritten in PHP. https://blog.phalcon.io/post/the-future-of-phalcon Since then, somebody has stepped up, but Phalcon work is still focussed on the PHP rewrite, with the intention of a smaller, optional, extension providing performance-critical components. https://blog.phalcon.io/post/phalcon-roadmap
Meanwhile, PHP 7 and 8 have massively increased both the performance and the capability of code written in PHP, and even FFI to bridge to existing native binaries (although I gather there's a lot that could be improved to make that more useful).
The overall trend is to have only what's absolutely necessary in an extension, and there have even been suggestions that some built-in functions would be better off implemented in PHP itself, if the right low-level features were included.
All of which is drifting a long way off topic, except to say that I think we should be aiming to reduce the difference between what can be done in extensions and what in PHP code, rather than planning any new such differences.
Regards,
Rowan Tommins
[IMSoP]
All of which is drifting a long way off topic,
Yes, very true.
I will start another thread with the title "Zephir and other tangents" to reply to your comments so as not to hijack this thread on those tangents, and so others can ignore it unless they really want to follow it.
-Mike
Referencing prior art (e.g. Go) PHP could allow int literals — e.g.
1
,47
, etc. — to be passed to typedefs derived from ints but require int variables to be typecast to the required type. Same for string literals.That's an interesting compromise, worth considering.
I have concerns about this. Mainly, it depends on what we would want a typeef to do. Eg, if it's just an alternate name, then maybe. If, however, typedefs allow other functionality -- such as validation, additional methods, etc. -- then primitive -> typeef is not a guaranteed total function. Eg;
typedef UserId: string is /\s{3}-\s{4}/ {
public function groupId(): string {
return substr($this, 3);
}
}
Maybe that particular functionality makes sense to do, maybe not, that's a separate discussion. My point for now is just that there are typedef approaches where auto-up-casting would be frequently invalid, and probably also designs where auto-down-casting would be invalid (or possibly valid). For now, we should keep all options on the table until we decide which options we want to make impossible.
In Go you cannot add or subtract on a typedef without casting to the
underlying type. I would definitely prefer that to be relaxed, but only
if it is relaxed via an explicit opt-in, e.g. something maybe like
this:typedef UserId: int operations: +, -, *, /;
typedef UserName: string operations: .;
Not to go further down this rabbit hole than is necessary, but I would much rather see operator overloads adopted, along the lines of Jordan's previous RFC, and let typedefs implement that if them if sensible.
There's probably yet another research project to do here. I'd volunteer, but I now have a newborn taking up most of my time. :-)
--Larry Garfield
- This RFC expands the "use ... as ..." syntax to allow any type
expression on the left side. These aliases are PER FILE and expire
once the file is compiled.
Explicitly without expressing any opinion about the RFC, I'd just like
to remind you that use...
import statements for classes and such are
not actually per file, but per namespace and making the use ...
statements for types behave differently would be very inconsistent and
surprising behaviour.
These are the rules (as far as I know and based on extensive testing
from my side):
- No namespace -
use
imports apply to whole file. - Curly brace scoped namespace -
use
imports apply only to the code
within the current namespace scope. - Single unscoped namespace -
use
imports apply to whole file (as the
whole file is within the single unscope namespace) - Multiple unscoped namespaces -
use
imports apply only until the next
namespace declaration is encountered.
Having type use
behave differently to other import use
statements
would, I imagine, also bring up problems around "file contains 2 scoped
namespaces, type use is for the file, but - as things currently are - no
code is allowed in the file outside the scoped namespaces, so where to
place the import for the type ? and what would it then apply to ?"
Smile,
Juliette
- This RFC expands the "use ... as ..." syntax to allow any type expression on the left side. These aliases are PER FILE and expire once the file is compiled.
Explicitly without expressing any opinion about the RFC, I'd just like to remind you that
use...
import statements for classes and such are not actually per file, but per namespace and making theuse ...
statements for types behave differently would be very inconsistent and surprising behaviour.These are the rules (as far as I know and based on extensive testing from my side):
- No namespace -
use
imports apply to whole file.- Curly brace scoped namespace -
use
imports apply only to the code within the current namespace scope.- Single unscoped namespace -
use
imports apply to whole file (as the whole file is within the single unscope namespace)- Multiple unscoped namespaces -
use
imports apply only until the next namespace declaration is encountered.Having type
use
behave differently to other importuse
statements would, I imagine, also bring up problems around "file contains 2 scoped namespaces, type use is for the file, but - as things currently are - no code is allowed in the file outside the scoped namespaces, so where to place the import for the type ? and what would it then apply to ?"Smile,
Juliette
Thanks Juliette,
I meant "as they are now" and not literally "per file" as described in the RFC. I will make that more clear in the RFC. When I wrote it, I was thinking in terms of how I usually write namespaced code and thought of it as "per file" but that is probably the wrong mental model in any case. Thanks again for pointing this out.
— Rob