rfc:asymmetric-visibility-v2

PHP RFC: Asymmetric Visibility v2

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

Include this RFC?
Real name Yes No
Final result: 0 0
This poll has been closed.

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.

rfc/asymmetric-visibility-v2.txt · Last modified: 2024/05/18 16:23 by crell