rfc:asymmetric-visibility

This is an old revision of the document!


PHP RFC: Asymmetric Visibility

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 specifics are 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;
}

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

In earlier versions of PHP, the visibility modifier was optional and if not specified defaulted to public. That is still technically the case if the var keyword is used instead, although in practice that is almost never done.

In the interest of compactness, this RFC allows the same default assumption if only a set visibility is specified. If a set visibility is specified but no get visibility, the get visibility defaults to public. That is, the following two declarations are semantically identical:

public protected(set) string $foo;
 
protected(set) string $foo;

A non-public get visibility must still be specified explicitly.

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, getting a reference to a property follows the set visibility, not the get visibility. Trying to get 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;

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 property.

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 it differs from the main (get) visibility, MUST be strictly lesser than the main 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.

There is one exception, that of a private readonly property. That would technically expand to private private(set) readonly, which is allowed.

Interaction with __set and __unset

In PHP 8.2, the behavior of __set has some subtleties to it due to readonly introducing asymmetric visibility through the back door. Specifically, when trying to assign to a property that has been defined, and the class has a __set method defined:

  • If the property is read-accessible
    • And the property has been unset() explicitly, then __set is called.
    • And the property has NOT been unset() explicitly
      • And the property is write-accessible, then the property will be set directly.
      • And the property is NOT write-accessible, then an error is thrown.
  • If the property is NOT read-accessible, then __set is called.

This RFC continues the rules above without change. A property with asymmetric visibility that is written to from a scope where it is readable but not writeable, __set will be called if and only if the property had previously been unset(). Otherwise, an error will be thrown.

If __set() is not defined, the write will fail with an error regardless.

class Example
{
    public private(set) string $pubpriv;
    protected private(set) string $protpriv;
 
    public function __set($name, $value): void
    {
        echo "__set($name, $value)\n";
        $this->$name = $value;
    }
 
    public function unsetPub()
    {
        unset($this->pubpriv);
    }
 
    public function unsetProt()
    {
        unset($this->protpriv);
    }
}
 
class Child extends Example
{
    public function test()
    {
        $this->pubpriv = 'a';  // Error, because the property is readable but not unset
        $this->protpriv = 'a'; // Error, because the property is readable but not unset
 
        $this->unsetPub();
        $this->unsetPriv();
 
        $this->pubpriv = 'a'; // Calls __set, because pubpriv has been unset
        $this->protpriv = 'a'; // Calls __set, because protpriv has been unset
    }
}
 
$ex = new Child();
 
$ex->pubpriv = 'a';  // Error, because the property is readable but not unset
$ex->protpriv = 'a'; // Calls __set
 
$ex->unsetPub();
 
$ex->pubpriv = 'a'; // Calls __set, because pubpriv has been unset
 
$ex->test();

The logic for calling unset() externally (and thus triggering __unset()) is the same.

Relationship with readonly

The readonly flag, introduced in PHP 8.1, has an implicit private(set) behavior. While there are ways to recast the meaning of readonly such that it will combine with asymmetric visibility cleanly, there's no clear consensus on which of those ways is best, nor some of the edge cases they introduce. Therefore, for the time being, mixing readonly with explicit asymmetric visibility is not allowed.

This restriction can and should be relaxed in a later, dedicated RFC where those details can be hashed out more explicitly.

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.

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('open');
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.

Backward Incompatible Changes

None. This syntax would have been a parse error before.

Proposed PHP Version(s)

PHP 8.3

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:

  • init - Allows a property to be set only from initialization operations, such as __construct, __clone, __unserialize, etc.
  • unset - Allows a property to be unset from a different scope than it can be set to a real value.

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.

readonly compatibility

As noted above, at this time asymmetric visibility cannot be combined with readonly properties. While the implementation of interlacing the two features is not difficult, there are some edge cases that need to be sorted out. For instance, it may require relaxing the “permitted visibility” rules in some cases. We felt that was best pushed to a separate RFC to minimize controversy on this RFC. The authors believe such interlacing can and should be done, just in a separate RFC.

Property accessors

Asymmetric visibility has been proposed before as a component of the Property Accessors RFC. That RFC models directly on C# syntax, which puts limited-operation visibility on the right of the property as part of the accessor definition.

This RFC uses a syntax borrowed from Swift, which provides similar functionality but with the visibility modifiers together on the left side of the property. The proposed syntax was slightly favored in a poll. It also allows asymmetric visibility to be proposed and discussed independently of the larger property accessor question.

While the authors do support (and intent to work on) property accessors generally, we feel it is better addressed separately from asymmetric visibility as the two are orthogonal considerations. Notably, this RFC does NOT preclude or limit the development of property accessors in the future.

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.

rfc/asymmetric-visibility.1669495694.txt.gz · Last modified: 2022/11/26 20:48 by crell