PHP RFC: Clone with
- Date: 2022-10-24
- Author: Máté Kocsis kocsismate@php.net
- Status: Under Discussion
- Target Version: PHP 8.3
- Implementation: https://github.com/php/php-src/pull/9497
Introduction
With the advent of standards promoting “quasi-immutable” objects, like PSR-7, “wither” methods became increasingly widely used. This practice is the “de facto” standard to modify the object state without altering the currently existing references to an object. A “wither” method copy-pasted from Diactoros basically looks like this:
class Response implements ResponseInterface { public int $statusCode; public string $reasonPhrase; // ... public function withStatus($code, $reasonPhrase = ''): Response { $new = clone $this; $new->statusCode = $code; $new->reasonPhrase = $reasonPhrase; return $new; } // ... }
By using this approach, one can effectively implement immutable objects. As the “quasi” above indicates, these objects are not entirely immutable, since workarounds exist for modifying their state. As of PHP 8.1 though, PHP RFC: Readonly properties 2.0 provides a foundational building block for preventing these workarounds: by adding the readonly
modifier to a property, one can make sure that once initialized, it cannot be modified anymore. This would allow PSR-7 implementations to properly enforce their immutability.
Unfortunately, PSR-7 implementations cannot make use of readonly properties yet, unless they always instantiate a new class instance rather than using cloning as described above. This cumbersome workaround would be required because readonly properties have too strict constraints: they are readonly at the property level, while in order to be useful in practice, they should be readonly only at the object level. The former means that after initialization, a property is unmodifiable throughout its entire life span, while the latter means that it is unmodifiable during the life cycle of an object. The main difference between the two approaches is whether a property is modifiable after the object is cloned.
Since PHP RFC: Readonly amendments was (partially) accepted for PHP 8.3, readonly properties became modifiable during the execution of the __clone()
magic method, allowing deep cloning of readonly properties. This was the first step towards achieving object-level readonliness, however the general problem of supporting safe modification of readonly properties was not solved yet.
Proposal
The current proposal aims to add support for a new language construct called “clone with” by extending the clone
operator, that would make it possible to write “wither” methods for any kind of instance properties (declared/dynamic, typed/untyped, readonly/non-readonly) with less code.
Using “clone with”, the above example can be rewritten the following way:
class Response implements ResponseInterface { public readonly int $statusCode; public readonly string $reasonPhrase; // ... public function withStatus($code, $reasonPhrase = ''): Response { return clone $this with [ "statusCode" => $code, "reasonPhrase" => $reasonPhrase, ]; } // ... }
This syntax expresses that the Response::$statusCode
and Response::$reasonPhrase
properties are modified right after $this
is cloned, leaving the original object intact. This way, it becomes possible to clone and modify readonly properties, even multiple times:
$response = new Response(200); $response->withStatus(201)->withStatus(202);
Constraints
Furthermore, “clone with” respects all visibility rules and type constraints, just like how regular assignments do:
class Foo { public int $a; protected int $b; private $c; public readonly int $d; } class Bar extends Foo { public function withC() { return clone $this with ["c" => 1]; } public function withD() { return clone $this with ["d" => 1]; } } $bar = new Bar(); clone $bar with ["a" => "abc"];// Exception due to type mismatch clone $bar with ["b" => 1]; // Exception due to accessing a protected property from the global scope $bar->withC(); // Exception due to accessing a private property from a child class $bar->withD(); // Exception due to assigning a readonly property from a child class clone $bar with ["e" => []]; // Deprecation notice due to assigning to a dynamic property
Property name expressions
So far, all “clone with” examples introduced in the current RFC used literal strings for referencing property names, while the values to be assigned were expressions.
However, in some cases it would be useful to reference property names as expressions, e.g. when one needs to use “clone with” in a foreach loop where the index is the property name and the loop variable is the value to be assigned. This is also possible:
class Foo { private $a; private $b; private $c; /** * @param array<string, mixed> $properties */ public function withProperties(array $properties) { $self = clone $this; foreach ($properties as $name => $value) { $self = clone $self with [$name => $value]; } return $self; } } $foo = new Foo(); $foo->withProperties(["a" => 1, "b" => 2, "c" => 3]);
This time, both side of the assignment is a dynamic expression, separated by =>
. Not only variables, but any kind of expressions strictly evaluating to a string type are possible to use:
const PROPERTY_NAME = "foo"; $object = new stdClass(); $object = clone $object with ["foo" => 1]; // the property name is a literal $object = clone $object with [strtolower("FO") . "o" => 1]; // the property name is an expression $object = clone $object with [PROPERTY_NAME => 1]; // the property name is a named constant
Execution order
A “clone with” operation is executed in the following order:
- If the object has a
__clone()
magic method, then it is executed first - Then the assignments in the
with
clause are executed from left to right. Any properties - including the ones modified by the__clone()
method - are modifiable again.
class Foo { public int $bar = 0; public function increment(): static { return clone $this with [ strtolower("BAR") => $this->bar + 1, $this->bar => throw new Exception(), ]; } public function __clone(): void { $this->bar = 0; } } $foo = new Foo(); $foo->increment();
Calling Foo::increment()
in the above example will roughly do the following:
- Clone opcode
Foo
is cloned and thenFoo::__clone()
is called
- Clone with assignment #1 opcode
strtolower(“BAR”)
is evaluated tobar
- the right-hand side is evaluated to 1
Foo::$bar
is assigned to 1
- Clone with assignment #2 opcode
$this->bar
is not a string expression: aTypeError
is thrown- the right-hand side is not evaluated:
Exception
is not thrown
Reflection
The proposal doesn't have impact for reflection.
Backward Incompatible Changes
If the proposal gets accepted, with
becomes a semi-reserved keyword, just like “readonly”, meaning that it cannot be used as a class or a global constant name, as well as a trait method alias (after the “as” keyword).
Future scope
The shorthand syntax for the property name was removed from the current proposal due to its weak support. However, we could introduce it later on as an abbreviated way to refer to property names as literal strings. This syntax could use an identifier followed by :
on the left-hand side of the “clone with expressions”. The initial example would look like the following this way:
class Response implements ResponseInterface { public readonly int $statusCode; public readonly string $reasonPhrase; // ... public function withStatus($code, $reasonPhrase = ''): Response { return clone $this with [ statusCode: $code, reasonPhrase: $reasonPhrase, ]; } // ... }
Furthermore, it may be useful to add support for using either array expressions instead of the fixed array of “clone with assignments”, or the spread operator later on. An example:
class Foo { private $a; private $b; private $c; /** * @param array<string, mixed> $properties */ public function withProperties(array $properties) { // using an array expression after the "with": return clone $this with $properties; // or using the spread operator: return clone $this with [...$properties]; } } $foo = new Foo(); $foo->withProperties(["a" => 1, "b" => 2, "c" => 3]);
Clone callback
Alexandru Pătrănescu and Nicolas Grekas suggested using a “clone callback” instead of the “clone with” syntax so that the callback would be evaluated right after the clone opcode, using the scope where it was triggered. An example:
class Response implements ResponseInterface { public readonly int $statusCode; public readonly string $reasonPhrase; // ... public function withStatus($code, $reasonPhrase = ''): Response { return clone $this with function (Response $clone) use ($code, $reasonPhrase): void { $clone->statusCode = $code; $clone->reasonPhrase = $reasonPhrase; }); } // ... }
This idea could be implemented as a followup syntax.
Vote
The vote requires 2/3 majority.