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 a properties 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 is to declare the property private, and only expose a getter publicly:

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

This doesn't actually make the property readonly, but at least the scope where a modification could happen is tightened to a single class declaration, which is easier to audit.

Unfortunately, this forces you to use private properties together with public getter boilerplate, which makes for worse ergonomics for the consumer. Support for first-class readonly properties allows you to directly expose such properties, without fear:

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 be more confusing than simply making it an error condition, and letting the programmer explicitly opt-in by specifying 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. (Open question?)

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. (Open question?) 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. If covariance is allowed, we should probably only permit initialization from B, as it would be strictly correct then.

Unset

TBD

Rationale

TBD

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.1622646812.txt.gz · Last modified: 2021/06/02 15:13 by nikic