rfc:clone_with

This is an old revision of the document!


PHP RFC: Clone with

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 fixed property names which were referenced as identifiers followed by a colon (:), while the values to be assigned were expressions. This syntax is a very straightforward and concise way for the most basic case when “clone with” operates on a known property.

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 using a slightly more verbose syntax:

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 $this with {$name => $value};
        }
 
        return $self;
    }
}
 
$foo = new Foo();
$foo->withProperties(["a" => 1, "b" => 2, "c" => 3]);

This time, both side of the assignment is an expression, separated by =>. Not only variables, but any kind of expressions strictly evaluating to a string type are possible to use:

$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

The last example demonstrates well why it's necessary to use different separator tokens (=> vs. :) for the two cases: otherwise it wouldn't be clear if one references the property name indirectly via the value of a global constant or directly as an identifier.

Execution order

A “clone with” operation is execution 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

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

Reflection

The proposal doesn't have impact for reflection.

Backward Incompatible Changes

If the proposal gets accepted, with becomes a reserved keyword.

Vote

The vote requires 2/3 majority.

rfc/clone_with.1682449489.txt.gz · Last modified: 2023/04/25 19:04 by kocsismate