rfc:readonly_properties_v2

This is an old revision of the document!


PHP RFC: Readonly properties 2.0

Introduction

This RFC introduces a readonly property modifier, which prevents modification of the property after initialization.

Value objects are often immutable: Properties are initialized once in the constructor, and should not be modified afterwards. PHP currently has no way to enforce this constraint. The closest alternative is to declare the property private, and only expose a public getter:

class User {
    public function __construct(
        private string $name
    ) {}
 
    public function getName(): string {
        return $this->name;
    }
}

This doesn't actually make the property readonly, but it does tighten the scope where modification could occur to a single class declaration. Unfortunately, this requires the use of getter boilerplate, which results in worse ergonomics for the consumer.

Support for first-class readonly properties allows you to directly expose public readonly properties, without fear that class invariants could be broken through external modification:

class User {
    public function __construct(
        public readonly string $name
    ) {}
}

Proposal

A readonly property can only be initialized once, and only from the scope where it has been declared. Any other assignment or modification of the property will result in an Error exception.

class Test {
    public readonly string $prop;
 
    public function __construct(string $prop) {
        // Legal initialization.
        $this->prop = $prop;
    }
}
 
$test = new Test("foobar");
// Legal read.
var_dump($test->prop); // string(6) "foobar"
 
// Illegal reassignment. It does not matter that the assigned value is the same.
$test->prop = "foobar";
// Error: Cannot modify readonly property Test::$prop

This variant is not allowed, as the initializing assignment occurs from outside the class:

class Test {
    public readonly string $prop;
}
 
$test = new Test;
// Illegal initialization outside of private scope.
$test->prop = "foobar";
// Error: Cannot initialize readonly property Test::$prop from global scope

Modifications are not necessarily plain assignments, all of the following will also result in an Error exception:

class Test {
    public readonly int $prop = 0;
}
 
$test = new Test;
$test->prop += 1;
$test->prop++;
++$test->prop;
$ref =& $test->prop;
$test->prop =& $ref;
byRef($test->prop);
foreach ($test as &$prop);

However, readonly properties do not preclude interior mutability. Objects (or resources) stored in readonly properties may still be modified internally:

class Test {
    public function __construct(public readonly object $obj) {}
}
 
$test = new Test(new stdClass);
// Legal interior mutation.
$test->obj->foo = 1;
// Illegal reassignment.
$test->obj = new stdClass;

Restrictions

The readonly modifier can only be applied to typed properties. The reason is that untyped properties have an implicit null default value, which counts as an initializing assignment, and would likely cause confusion.

Thanks to the introduction of the mixed type in PHP 8.0, a readonly property without type constraints can be created using the mixed type:

class Test {
    public readonly mixed $prop;
}

The alternative would be to not use an implicit null default value for untyped readonly properties. However, this would make the rules for implicit property default values more complex and confusing. Simplify making it an error condition let's the programmer explicitly opt-in by specify the mixed type.

Specifying an explicitly default value on readonly properties is allowed. However, the default value is considered an initializing assignment, so that further modification is not possible:

class Test {
    public readonly int $prop = 42;
 
    public function __construct() {
        // Error, as property is already initialized.
        $this->prop = 24;
    }
}

This does not appear particularly useful at present, but could be more meaningful once objects are allowed as property initializers. In any case, the behavior in this case is clear and unambiguous, in that the programmer has gone out of their way to request a default value.

It is worth reiterating here that default values on promoted parameters only apply to the parameter, not the property:

class Test {
    public function __construct(
        public readonly int $prop = 0,
    ) {}
}
 
// Desugars to:
 
class Test {
    public readonly int $prop;
 
    public function __construct(int $prop = 0) {
        $this->prop = $prop;
    }
}

As the property has no default value, the assignment in the constructor is initializing, and thus legal. The constructor property promotion feature was specifically design for forward-compatiblity with readonly properties.

Readonly static properties are not supported. This is a technical limitation, in that it is not possible to implement readonly static properties non-intrusively. In conjuction with the questionable usefulness of readonly static properties, this is not worthwhile at this time.

Inheritance

It is not allows to override a read-write parent property with a readonly property in a child class:

class A {
    public int $prop;
}
class B extends A {
    // Illegal: readwrite -> readonly
    public readonly int $prop;
}

However, the converse is legal:

class A {
    public readonly int $prop;
}
class B extends A {
    // Legal: readonly -> readwrite
    public int $prop;
}

It is interesting to consider how property redeclaration interacts with the restriction that initializion can only occur in the declaring class:

class A {
    public readonly int $prop;
}
class B extends A {
    public readonly int $prop;
}

Here, initialization of B::$prop would be permitted both from inside A and B, as both classes declare the property. A possible alternative would be to allow initialization only from B, though that would mean that a redeclaration in a child class could break usage in a parent class.

When the same property is imported from two traits, the readonly modifiers must match:

trait T1 {
    public readonly int $prop;
}
trait T2 {
    public int $prop;
}
class C {
    // Illegal: Conflicting properties.
    use T1, T2;
}

One could argue that it should be possible to merge with a readonly and a readwrite property into a readwrite property. However, other modifiers currently also require strict equality, for example it is not possible to merge a public and a protected property. If these rules should be relaxed, they should be relaxed consistently.

Types on readonly properties remain invariant. One could argue that types of readonly properties should be covariant instead:

class A {
    public readonly int|float $prop;
}
class B extends A {
    public readonly int $prop;
}

Covariance would hold for reads from the property, but not for the initializing assignment, which is permitted from both A and B here. I believe that covariance would only be truly correct if the parent property were abstract, which is currently not supported. A relaxation to covariant semantics would always be possible in the future.

Unset

Readonly properties cannot be unset once they are initialized:

class Test {
    public readonly int $prop;
 
    public function __construct() {
        $this->prop = 1;
        unset($this->prop);
        // Error: Cannot unset readonly property Test::$prop
    }
}

However, it is possible to unset a readonly property prior to initialization, from the scope where the property has been declared. Just like with normal typed properties, explicitly unsetting the property makes it visibile to magic methods. In particular, this enabled the usual lazy initialization pattern to work:

class Test {
    public readonly int $prop;
 
    public function __construct() {
        unset($this->prop);
    }
 
    public function __get($name) {
        if ($name === 'prop') {
            $this->prop = $this->lazilyComputeProp();
        }
        return $this->$name;
    }
}

Reflection

A ReflectionProperty::isReadOnly() method is added, which reports whether a property is declared as read-only. ReflectionProperty::getModifiers() will also report a ReflectionProperty::IS_READONLY flag.

Rationale

The readonly property concept introduced in this proposal provides strong immutability guarantees, which apply both inside and outside the class. Once a property has been initialized, it cannot be changed under any circumstances.

These guarantees are too strong for certain use-cases. For example, some classes may wish to have properties that are publicly readable, but can only be written from within the class. This is a much weaker guarantee, as the value of a property can change during the lifetime of an object. Both variants can be useful depending on the situation, and the addition of readonly properties neither precludes nor discourages the addition of asymmetric property visibility.

A special case worth mentioning are classes using clone-based withers:

class Point {
    public function __construct(
        public readonly float $x,
        public readonly float $y,
        public readonly float $z,
    ) {}
 
    public function withX(float $x): static {
        // This implementation works:
        return new static($x, $this->y, $this->z);
 
        // This implementation does not:
        $clone = clone $this;
        $clone->x = $x;
        return $clone;
    }
}

The clone-based implementation will result in an error, because the x property on the cloned object is already initialized, and modification is thus rejected. This is by design: The property does indeed get modified post-initialization, and the fact that this is only “temporary” is ultimately irrelevant.

In the future, a clone with construct, which allows setting properties during the cloning process, would make such implementation compatible with readonly properties. The important difference is that the new value for the property is assigned directly, without assigning the old cloned value first.

Alternatively, such properties could use a future asymmetric visibility concept, though it does not express the actual invariant as precisely.

This proposal is very similar to the previously declined Write-Once Properties RFC. I think that a key mistake of that RFC was specifically the “write-once” framing. While maybe technically accurate, it makes for confusing messaging, as the intended API contract is that of “read-only”, not “write-once”. It stands to reason that a readonly property still needs to be initialized at some point, and there is no need to put a particular focus on that initialization.

This proposal does deviate from the write-once properties RFC in one significant respect: Initialization is only allowed from within the declaring class (modulo the usual rebinding and reflection workarounds). This ensures that these properties are always read-only from a public API perspective, even if they don't get initialized in the class constructor. The “write-once” limitation is intended specifically for initialization, not as a one-shot communication channel.

This also ensures that a potential future “clone with” implementation will only be able to modify readonly properties from private scope and thus cannot bypass additional invariants imposed by the implementation when used from a different scope.

This RFC overlaps with the Property Accessors RFC. In particular, it implements the “only implicit get” aspect, though not with the exact same semantics. As mentioned on the RFC, I'm not convinced that the full complexity of accessors is truly warranted. Supporting readonly properties and asymmetric visibility would cover a signficant portion of the use-cases, at a lower language complexity cost.

It is worth noting that having a readonly property feature does not preclude introduction of accessors. C# supports both readonly properties and accessors. C# provides properties with implicit backing storage through accessor syntax, but this is not the only way to do it. For example, Swift has special syntax for asymmetric visibility, rather than specifying visibility on implicitly implemented accessors.

Even if we have property accessors, I believe it may be worthwhile to limit them to computed properties only, and solve use-cases that involve engine-managed storage through other mechanisms, such as readonly properties and property-level asymmetric visibility. This avoids confusion relating to the two kinds of accessors, and also allows us to make their behavior independent of accessor constraints. For example, a first-class asymmetric visibility feature would shield the user from considering distinctions such as get; vs &get; accessors. These are externalities of the general accessor feature and not needed for asymmetric visibility.

A separate implementation can also be more efficient. After initialization, a readonly property will have the same performance characteristics as a normal property. Accessor-based properties, even with implicit storage, still carry a performance penalty.

Backward Incompatible Changes

A new readonly keyword is reserved.

Assumptions of existing code that all accessible properties are also writable may be broken.

Vote

Yes/No.

rfc/readonly_properties_v2.1622722917.txt.gz · Last modified: 2021/06/03 12:21 by nikic