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 atomically 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.
Alternatives
Clone method modifier
Nicolas Grekas proposed an alternative idea which could also make it possible to reinitialize readonly properties:
class Abc { private readonly $foo; public clone function withFoo($newFoo): static { $this->foo = $newFoo; return $this; } }
According to which, the clone
method modifier would implicitly clone $this
, therefore any assignments performed on it would act on the new instance only -- effectively preventing the modification of a property on an existing instance.
Even though this is a pretty clever way to solve the original issue, it has some serious drawbacks:
- One cannot control whether
$this
should really be cloned: e.g. if a property should only be modified based on certain conditions (e.g. validation), the object would potentially be cloned in vain, resulting in a performance loss. - The behavior of a “clone method” is counterintuitive for beginners as it needs some experience to be able to determine what
$this
actually refers to. - Sometimes one also needs access to the original instance, but doing so would only be possible via workarounds (e.g. by introducing
$that
or a similar construct).
class LinkedObject { public function __construct( private readonly LinkedObject $next, private readonly int $number ) { $this->number = 1; } public clone function next(): LinkedObject { $this->number++; $that->next = $this; // a hypothetical $that variable is needed here return $this; } }
The same code using “clone with”:
class LinkedObject { public function __construct( private readonly LinkedObject $next, private readonly int $number ) { $this->number = 1; } public function next(): LinkedObject { $self = clone $this with ["number" => $this->number + 1] $this->next = $self; return $self; } }
The biggest advantage of Nicolas' proposal over “clone with” is that it could be made part of the interface contract whether a method doesn't modify the object state. So the PSR-7 ResponseInterface
could look like the following:
interface ResponseInterface extends MessageInterface { // .. public clone function withStatus($code, $reasonPhrase = ''); // .. }
The position of this RFC is that cloning the current object and making sure that its state is not modified should be done separately as opposed to what the clone
modifier does: “clone with” deals with the former aspect, while a new readonly
method modifier could handle the latter in the future. This separation is advantageous because the two features could be used on their own. That's one of the main reasons why this RFC prefers and implements “clone with”.
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, function (Response $clone): void { $clone->statusCode = $code; $clone->reasonPhrase = $reasonPhrase; }); } // ... }
This idea is advantageous for its flexibility and due to the fact that its concept is already known. However, it falls short when it comes to the simplicity/expressiveness of the syntax, since the callback takes up many characters with its bells and whistles (“function”, type declarations, or the possible static
modifier). Implementation-wise, its complexity and performance should be similar to the “clone with” solution, except that the callback has quite some performance overhead. Additionally, the lack of callable types makes it somewhat more difficult to read and write such callbacks.
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 with 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 expressions” ([...]
), 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) { return clone $this with $properties; // or // return clone $this with [...$properties]; } } $foo = new Foo(); $foo->withProperties(["a" => 1, "b" => 2, "c" => 3]);
Vote
The vote requires 2/3 majority.