PHP RFC: Asymmetric Visibility v2
- Version: 2.0
- Date: 2024-05-09
- Author: Ilija Tovilo (tovilo.ilija@gmail.com), Larry Garfield (larry@garfieldtech.com)
- Status: In Draft
- First Published at: http://wiki.php.net/rfc/asymmetric-visibility-v2
- Implementation: https://github.com/php/php-src/pull/9257
Introduction
PHP has long had the ability to control the visibility of object properties -- public
, private
, or protected
. However, that control is always the same for both get
and set
operations. That is, they are “symmetric.” This RFC proposes to allow properties to have separate (“asymmetric”) visibility, with separate visibility for read and write operations. The syntax is mostly borrowed from Swift.
Proposal
This RFC provides a new syntax for declaring the “set” operation visibility of an object property. Specifically:
class Foo { public private(set) string $bar = 'baz'; } $foo = new Foo(); var_dump($foo->bar); // prints "baz" $foo->bar = 'beep'; // Visibility error
This code declares a property $bar
that may be read from public
scope but may only be modified from private
scope. It may also be declared protected(set)
to allow the property to be set from any protected
scope (that is, child classes).
The behavior of the property is otherwise unchanged, aside from the restricted visibility.
Asymmetric visibility properties may also be used with constructor property promotion:
class Foo { public function __construct( public private(set) string $bar, ) {} }
Abbreviated form
One of the most common use cases, we expect, will be a property that is publicly readable but has a reduced set-visibility. PHP properties are already implicitly publicly readable if not otherwise specified. To support this case, if a set visibility is specified then a public
get visibility may be omitted.
That is, the following pairs are equivalent:
public private(set) string $foo; private(set) string $foo; public protected(set) string $foo; protected(set) string $foo;
In practice, this means a “limited mutability” struct class could be implemented as follows:
class Book { public function __construct( private(set) string $title, private(set) Author $author, private(set) int $pubYear, ) {} }
Which is very similar to the common pattern of all-readonly
properties, but does allow for controlled mutation within the class's own methods.
References
While a reference to a property with restricted set
visibility may still be obtained, it may only be obtained from a scope that would allow writing. Put another way, obtaining a reference to a property follows the set
visibility, not the get
visibility. Trying to obtain a reference to a property with a more restrictive scope will result in an error.
For example:
class Foo { public protected(set) int $bar = 0; public function test() { // This is allowed, because it's private scope. $bar = &$this->bar; $bar++; } } class Baz extends Foo { public function stuff() { // This is allowed, because it's protected scope. $bar = &$this->bar; $bar++; } } $foo = new Foo(); // This is fine, because the update via reference is // inside the method, thus private scope. $foo->test(); // This is also fine. $baz = new Baz(); $baz->stuff(); // Getting this reference is not allowed here, because this is public // scope but the property is only settable from protected scope. $bar = &$foo->bar;
Array properties
As with property hooks, arrays require special consideration with asymmetric visibility, and for the same reason: writing to an array property technically involves obtaining a reference to it first, which as noted in the previous section means it will follow set
visibility, not get
visibility. That means an array property may not be appended or written to unless it's public-set.
class Test { public function __construct(public private(set) array $arr = []) {} public function addItem(string $v): void { // This is in private scope, so fully legal. $this->arr[] = $v; } } $t = new Test(); $t->addItem('beep'); // Legal var_dump($t->arr); // Legal $t->arr[] = 'boop'; // Not allowed.
This behavior should be less surprising than with hooks, since writing to an array with private set-visibility is already self-evidently wrong.
Object properties
If the property is an object, the restricted visibility applies only to changing the object referenced by the property. It does not impact the object itself. That is consistent with the behavior of the readonly
marker.
Example:
class Bar { public string $name = 'beep'; } class Foo { public private(set) Bar $bar; } $f = new Foo(); // This is allowed $f->bar->name = 'boop'; // This is NOT allowed $f->bar = new Bar();
Permitted visibility
The set
visibility, if specified explicitly, MUST be strictly lesser than the main (get
) visibility. That is, the set
visibility may only be protected
or private
. If the main visibility is protected
, set
visibility may only be private
. Any violation of this rule will result in a compile time error. (See the section on readonly
for an exception.)
Inheritance
PHP already allows child classes to redeclare parent class properties, if and only if they have the same type and their visibility is the same or wider. That is, a protected string $foo
can be overridden with public string $foo
but not private string $foo
. This RFC continues that rule, but independently for get
and set
operations.
That means, for instance, the following is legal:
class A { private string $foo; } class B extends A { protected private(set) string $foo; } class C extends B { public protected(set) string $foo; } class D extends C { public string $foo; }
As in each child class, the get
visibility is the same or wider than the parent, and the set
visibility is the same or wider than the parent. (Note that, technically, B::$foo
is a new variable from A::$foo
, as the latter is fully private. This is an existing feature of PHP. B::$foo
, C::$foo
, and D:$foo
are all the same property, however.)
Narrowing the visibility is not allowed.
class A { public string $foo; } class B extends A { // This is an error. public protected(set) string $foo; }
Interaction with property hooks
The Property Hooks RFC introduced the ability to insert arbitrary behavior into the get
or set
behavior of a property. Hooks have no impact on who may access a property, just on what happens when they do.
In contrast, asymmetric visibility allows varying who may read and who may write a property independently, but has no impact on what happens when they are accessed legally.
In short, the behavior of asymmetric visibility and property hooks do not interact at all, and both are fully independent of each other.
There is one caveat regarding virtual properties that have no set
operation. If there is no set
operation defined on a property, then it is nonsensical to specify a visibility for it. That case will trigger a compile error. For example:
// This will generate a compile error, as there is // no set operation on which to specify visibility. class Universe { public private(set) $answer { get => 42; } }
Interaction with interface properties
The Property Hooks RFC also introduced the ability for interfaces and abstract classes to declare a requirement for a public or protected (for abstract classes only) property, with get
and set
operations separate. It is a deliberately “lightweight” requirement. As noted in that RFC, it may be satisfied by either a hook or a traditional property, as long as the operation is available in the relevant scope. One reason for the separation of get
and set
requirements was to enable asymmetric visibility to satisfy the requirement as well.
For example, the following is fully legal:
interface Named { public string $name { get; } } class ExampleA implements Named { public protected(set) string $name; } class ExampleB implements Named { public string $name { get => 'Larry'; } } class ExampleC implements Named { public string $name; } class ExampleD implements Named { public readonly string $name; }
In each case, ExampleX::$name
can be read from public scope, so the interface is satisfied.
If a property on an interface requires public set
, however, then specifying asymmetric visibility is not permitted, except in the special case of readonly
(below). (Hooks, of course, are.)
interface Aged { public string $age { get; set; } } class ExampleE implements Aged { // Error, because it is not publicly settable. public protected(set) $age; }
Interaction with __set and __unset
In PHP 8.3, when a property is read or written and it is not defined and visible from the calling scope, the __get
and __set
magic methods are called instead, if defined.
Asymmetric visibility does not change that logic; it only allows the read and write sides to behave separately. That is, if a property is publicly readable but only private or protected writeable, and __set
is defined, then __set
will be called for that but __get
will never be called for that property. For example:
class Example { public private(set) bool $open = true; public private(set) string $name; public function setOpen(bool $open): void { $this->open = $open; } public function __set($var, $val): void { if ($var === 'name') { if ($this->open) { $this->name = $val; } else { throw new LockedException('I cannot do that, Dave.'); } } } public function forceName(string $name): void { $this->name = $name . ' (forced)'; } } $e = new Example(); // This triggers __set, and since the object // is $open, writes the property from private scope. $e->name = 'Larry'; $e->setOpen(false); // This triggers __set, and since the object is not $open // __set throws an exception. $e->name = 'Ilija'; // This calls a normal method, which has private write // to the property, so runs fine and sets "Ilija (forced)" $e->forceName('Ilija');
If __set()
is not defined, the write will fail with an error regardless.
The logic for calling unset()
externally (and thus triggering __unset()
) is the same.
Relationship with readonly
The readonly
flag, introduced in PHP 8.1, is, really two flags in one: write-once and private(set)
. While that is sometimes sufficient, there are cases where protected-set is desired, and while few, there are use cases for public-set-once.
To address these use cases, we have decided to treat readonly
exactly as described: Two flags in one, with one being overridable. That is, specifying a set-visibility on a readonly
property is legal, and will override the implicit private(set)
of readonly
. If no set-visibility is specified, then readonly
's implicit private(set)
remains the same.
This will naturally lead to some cases where the get and set visibility will be necessarily the same. Therefore, the prohibition against matching visibilities is lifted for readonly
properties, even if they would otherwise be redundant or unnecessary. Specifying a wider set-visibility than get is still disallowed.
In code:
// These two are identical. The second is redundant, but legal. public readonly string $foo; public private(set) readonly string $foo; // This creates a public-read, protected-write, write-once property. // This was previously impossible. // Both the full and abbreviated forms are shown. public protected(set) readonly string $foo; protected(set) readonly string $foo; // This creates a protected-read, protected-write, write-once property. // The double-protected is allowed, to override the implicit private(set) protected protected(set) readonly string $foo; // This creates a public-read, public-write, write-once property. // While use cases for this configuration are likely few, // there's no intrinsic reason it should be forbidden. public public(set) readonly string $foo; public(set) readonly string $food // This is illegal - set cannot be wider than get. protected public(set) readonly string $foo;
If a class is marked readonly
, then by design its impact is identical to if every property was individually marked readonly
. There is no special logic there.
Typed properties
Asymmetric visibility is only compatible with properties that have an explicit type specified. This is mainly due to implementation complexity. However, as any property may now be typed mixed
and defaulted to null
, that is not a significant limitation.
Static properties
This functionality applies only to object properties. It does not apply to static properties. For various implementation reasons that is far harder, and also far less useful. It has therefore been omitted from this RFC.
Reflection
The ReflectionProperty
object is given two new methods: isProtectedSet(): bool
and isPrivateSet(): bool
. Their meaning should be self-evident.
class Test { public string $open; public protected(set) string $restricted; } $rClass = new ReflectionClass(Test::class); $rOpen = $rClass->getProperty('open'); print $rOpen->isProtectedSet() ? 'Yep' : 'Nope'; // prints Nope $rRestricted = $rClass->getProperty('restricted'); print $rRestricted->isProtectedSet() ? 'Yep' : 'Nope'; // prints Yep
Additionally, the two constants ReflectionProperty::IS_PROTECTED_SET
and ReflectionProperty::IS_PRIVATE_SET
are added. They are returned from ReflectionProperty::getModifiers()
, analogous to the other visibility modifiers.
Modifying asymmetric properties via ReflectionProperty::setValue()
is allowed, just as it is for protected
or private
properties, even outside of the classes scope.
Syntax discussion
Asymmetric visibility exists as a feature in several languages, most notably Swift, C#, and Kotlin. The syntactic structure varies, however. Translated to PHP, the two models would look like:
// Prefix-style: class A { public private(set) string $name; } // Hook-embedded-style: class A { public string $name { private set; } }
In Prefix style, the visibility is an aspect of the property itself. In Hook-embedded style, the visibility is an aspect of the property's set
hook. We believe that, for PHP, Prefix-style (presented here) is the superior approach, for a number of reasons.
Prefix-style is logically consistent
As noted above in “Interaction with hooks”, visibility controls exist independently hooks. In fact, as implemented they do not interact at all. Using hook syntax for visibility controls, therefore, is surprising and confusing.
Hook-embedded is not necessary from the implementation
There is an important difference between PHP's hooks and accessors as implemented in C# or Kotlin. In those languages, properties effectively always have accessors, and sometimes have a backing value. If the accessor is the trivial implementation (implied if not specified), it can be well-optimized by the compiler and/or runtime. (The specific details are not relevant at the moment.) That is, the “logical property” (that code would see) and the “physical property” (the actual storage) are already separate from the start.
In PHP, by contrast, there is no distinction between the logical and physical property... unless a hook is defined. Hooks are rarely a factor, whereas accessors are always a factor in C# or Kotlin.
This is an extension of the previous point. Bringing hook implementations into the picture when they would not otherwise exists complicates both the logic for the reader and for the engine.
Prefix-style is more visually scannable
With Prefix-style, reading a property definition from left to right, one is presented with all visibility options together. By the time the user has reached the $
, they know all visibility information. With Hook-embedded style, set
visibility may or may not be known. It would appear at the end of the line, whereas get
visibility is at the start of the line.
Worse, if there are actual hook implementations, the set
visibility may be several lines later!
class PrefixStyle { // All visibility is together in one obvious place. public private(set) string $phone { get { if (!$this->phone) { return ''; } if ($this->phone[0] === 1) { return 'US ' . $this->phone; } return 'Intl +' . $this->phone; } set { $this->phone = implode('', array_filter(fn($c) => is_numeric($c), explode($value))) } } } class HookEmbeddedStyle { public string $phone { get { if (!$this->phone) { return ''; } if ($this->phone[0] === 1) { return 'US ' . $this->phone; } return 'Intl +' . $this->phone; } // The set visibility is 10 lines away from the get visibility! private set { $this->phone = implode('', array_filter(fn($c) => is_numeric($c), explode($value))) } } }
It's non-obvious in Hook-embedded style what hook behavior should be implied
One of the caveats of hooks is that, sometimes, references on properties must be prevented. (The reasons for that are explained at length in the hooks RFC.) For example, this would be illegal:
class A { public array $arr { set { if (array_filter(is_int(...), $value) === $value) { $this->arr = $value; } throw new \Exception(); } } } $a = new A(); // This is illegal, as it would bypass the set hook. $a->arr[] = 5;
So “arrays with hooks can do less” is already an established fact of the language. However, if the hook-style syntax is used for visibility:
class A { public array $arr { protected set; } } class B extends A { public function add(int $val) { // Should this be legal? $this->arr[] = $val; } } $b = new B(); $b->add(5);
The syntax suggests that there is a set hook, and thus the assignment should not be allowed. However, there isn't really a set hook, and thus assignment should be allowed. Or maybe it shouldn't be, because there is a hook defined, even if it's the default.
It is not at all obvious which interpretation of the syntax should be correct, and either one will result in unexpected behavior for someone. With the Prefix-style syntax, this problem does not exist.
Hook-embedded style has more edge cases
With “main” visibility on the property and set
visibility on the hook, it means the parser needs to be mindful that some hooks support modifiers and some do not. That is, it's not immediately obvious that the following wouldn't be legal:
class A { public string $name { public get; private set; } }
This would be an extra edge case for the parser to handle. Unless it is allowed, and then it has to be kept in sync manually.
Unless we decided to move both visibilities onto hooks:
class A { var string $name { public get; private set; } }
(var
is still a legal keyword, even if it's almost never used today.) Which would be yet another syntax edge case to handle.
The same challenge would exist for user-space static analysis tools, which would need more complex logic to determine if a given usage is legal or not. While it's natural for static analysis tools to need to change any time PHP's syntax changes, we should not make those changes harder than they need to be.
Hook-embedded style is less extensible
As noted in the future-scope below, this syntax allows for the operations that can be visibility-controlled to grow in the future, should a use case be found. Similarly, hooks were designed such that additional hooks could be defined in the future, should a use case be found. Both being extensible lists is a good thing.
However, there's no reason why those lists need to extend at the same time. Using the C#-style syntax would imply that some hooks have a possible visibility modifier and some do not, and that some visibility modifiers, which would be defined within the hook block, do not actually have a hook at all, despite being in the hook block. As a pathological example:
class A { public string $name { // No modifier allowed. get => ucfirst($this->name); // Modifier optional. private set => strtolower($value); // Modifier, but no hook? How do I know that? protected getRef; // How do I even know if this thing takes visibility? setRef => ...; } }
In fairness, it is unlikely that either list will be appreciably extended in the near future. However, if we can avoid a potential landmine now by keeping separate concepts separate, we should.
Summary
For all of the above reasons, we firmly believe that the proposed syntax, Prefix-style, is the objectively better approach for PHP. Trying to use the Hook-embedded style would result in a more complex implementation that is harder to read and harder for static analyzers to handle.
Use cases and examples
Between readonly
and property hooks, PHP already has a number of ways to do “advanced things” with properties. However, there are still gaps in capability, which this RFC aims to fill.
Readonly is limited
readonly
offered the potential to have public properties that are guaranteed to not change unexpectedly. This has been a major benefit, and allowed the removal of a lot of needless boilerplate code. However, it also somewhat over-shoots: It prevents a property from changing at all, rather than just “unexpectedly.” While fully immutable objects have their place, they are not always the answer. It is still often desireable to have a public property (for ease of read) without making it write-once.
For example:
class Record { private bool $dirty = false; private array $data = []; public function set($key, $val): void { $this->data[$key] = $val; $this->dirty = true; } public function isDirty(): bool { return $this->dirty; } public function save(): void { if ($this->dirty) { // Do something to save the object. $this->dirty = false; } } }
It's very tempting to make $dirty
a public property, as the dirty status of the object is a “property” of it. Especially with hooks, such a desire will become more common. However, that cannot be done with public
or readonly
. Making the property public
would open it up to modification from anyone at any time, whereas making it readonly
would make it impossible to unset in save()
, and require using “uninitialized or true” as a quasi-boolean state. Both options are bad.
With asymmetric visibility, it can be easily simplified to:
class Record { public private(set) bool $dirty = false; private array $data = []; public function set($key, $val): void { $this->data[$key] = $val; $this->dirty = true; } public function save(): void { if ($this->dirty) { // Do something to save the object. $this->dirty = false; } } }
Which now offers a publicly-readable marker, internally modifiable, with no opportunity for it to change in an uncontrolled way, without any need for odd code contortions.
Readonly cannot use a custom sentinel
A common technique in many languages is to have sentinel values to indicate that a value is unset, invalid, or otherwise not a real value. Using a value within the scope of the property's type (eg, 0, empty string, etc.) has a host of issues, discussed elsewhere. null
has historically been used for that in PHP, although that has its share of problems, as discussed in Much Ado about Null. More recently, it has become increasingly popular to use a custom single-value enum as a sentinel value instead, to provide more contextual information.
public ?int $val = null; enum Missing { case Missing; } public int|Missing $val = Missing::Missing;
However, neither of those techniques is usable on a readonly
property. Rather, readonly
relies on the implicit and rather weird “uninitialized” state, available only to typed properties, to indicate that it is not yet populated with a valid value. Assigning either null or a custom sentinel to a readonly
property would disallow it from being correctly set in the future. However, as of PHP 8.3, readonly
is the only way to have a public-read, private-write property. That means either relying on uninitialized as a sentinel (the tooling support for which is quite lacking) or foregoing a public-read property just to be able to assign and re-assign a sentinel value.
Asymmetric visibility completely solves that issue, as properties can then be defaulted to a sentinel value (null
or custom) while allowing only the object itself to fully-initialize the value later.
class A { public private(set) int|Missing $value = Missing::Missing; }
Readonly is incompatible with inheritance
As noted previously, the readonly
flag is, on its own, two flags in one: write-once and private(set)
. While both have their use cases, there are ample times where only one or the other is desired. For instance, the following code from Crell/Serde (slightly simplified for this example) wants to use readonly
, but because of the implied private(set)
it causes issues:
abstract class Serde { // ... protected readonly TypeMapper $typeMapper; protected readonly ClassAnalyzer $analyzer; } class SerdeCommon extends Serde { // This must be redefined here so that it // can be set from the constructor. protected readonly TypeMapper $typeMapper; public function __construct( // Normally repeating a property as a promoted // argument is an error, BUT because the property // is in the parent, this overrides it with a // new property definition that is now local // to this class. protected readonly ClassAnalyzer $analyzer = new Analyzer(), array $handlers = [], array $formatters = [], array $typeMaps = [], ) { // We just want to do this... $this->typeMapper = new TypeMapper($typeMaps, $this->analyzer); // ... } }
With asymmetric visibility, the readonly
usage here can be replaced with protected protected(set)
or readonly protected protected(set)
, avoiding the need to double-declare properties. (There's actually about 6 such properties in practice, so the simplification is larger than shown here.)
abstract class Serde { // ... protected protected(set) readonly TypeMapper $typeMapper; protected protected(set) readonly ClassAnalyzer $analyzer; }
Hooks can be verbose
While every effort has been made to make hooks as compact as reasonable, there are some use cases that are still more clumsy than they need to be. For example, asymmetric visibility can be emulated with hooks like so:
class NamedThing { private string $_name; public string $name { get => $this->_name; } public function __construct(string $name) { $this->_name = $name; } }
But that's a lot of non-obvious work, and does have a small performance impact. It is much more straightforward to do this:
class NamedThing { public function __construct(public private(set) string $name) {} }
Backward Incompatible Changes
None. This syntax would have been a parse error before.
Proposed PHP Version(s)
PHP 8.4
RFC Impact
Future Scope
This RFC is kept very simple. However, it does allow for future expansion.
Alternate operations
At this time, there are only two possible operations to scope: read and write. In concept, additional operations could be added with their own visibility controls. Possible examples include:
protected(&get)
- Vary whether a reference to a property can be obtained independently of getting the value. (Would override theset
visibility if used.)private(setref)
- Allows a property to be set by reference only from certain scopes.
This RFC does NOT include any of the above examples; they are listed only to show that this syntax supports future expansion should a use be found.
Additional visibility
Should PHP ever adopt packages and package-level visibility, this syntax would be fully compatible with it. For example, public package(set)
would be a natural syntax to use.
This RFC does NOT include any discussion of such expanded visibility definition, just notes that it in no way precludes such future developments.
Proposed Voting Choices
This is a simple yes-or-no vote to include this feature. 2/3 majority required to pass.
References
This syntax is borrowed directly from Swift's access control system.
Syntax decisions in this RFC are supported by a poll conducted in September 2022. The results were posted to the Internals list.