11min.

La guerre des clones PHP

This blog post is also available in 🇬🇧 English: PHP Clone All The Things.

À chaque fois que je vois clone dans du code PHP, je ne peux m’empêcher d’avoir un peu peur. Ce simple mot clef, en soit pourtant très clair, me met toujours face au doute.

Qu’est-ce qui va être cloné précisément ?

La question me ramène donc, dans un premier temps, aux fondamentaux du langage : le passage des variables par valeurs ou par référence. Puis dans un second temps, à la complexité de l’objet cloné : ses niveaux de profondeurs et autres méthodes magiques de PHP.

Rien de rassurant en somme, alors que j’étais sur une revue de code par ailleurs simple.

Aujourd’hui, pour moi, et pour vous aussi, nous allons tenter de tout mettre à plat et de pouvoir la prochaine fois approcher un clone sans peur.

Section intitulée rappel-des-basesRappel des bases

Dans un premier temps, notons que clone d’après la documentation va effectuer une copie superficielle.

Lorsqu’un objet est cloné, PHP effectue une copie superficielle de toutes les propriétés de l’objet. Toutes les propriétés qui sont des références à d’autres variables demeureront des références. documentation officielle

C’est là qu’une révision des bases me semble importante, d’autant plus que le vocabulaire utilisé par PHP pour parler des passages de variable par valeurs ou références, peut être confus.

En effet, si nous cherchons de la documentation sur ce qu’est une référence en PHP nous tombons sur cette page : Les références. Malheureusement le mot référence ici, semble mal choisi, le fait est qu’il a souvent une autre signification dans les autres langages de programmation.

Une référence en PHP n’est pas un pointeur vers une adresse mémoire, nous pouvons plutôt le voir comme un alias vers le même objet. La documentation de PHP s’y reprend d’ailleurs à plusieurs fois pour nous l’expliquer, sur la page précédemment indiquée, mais aussi ici : Objets et références.

Si vous souhaitez entrer plus dans les détails, je vous recommande de lire ce chapitre : memory management du livre PHP Internals.

En réalité, clone va faire une copie de l’objet demandé en copiant les références en tant que telles et non pas l’objet référencé. D’où copie superficielle.

La question est donc la suivante, est-ce que c’est vraiment ce que l’on veut lorsqu’on utilise clone ? Sans doute pas.

function objectClassId(mixed $object): string {
    return get_class($object) . ' #' . spl_object_id($object);
}

class Birthday {
    public function __construct(
        // These ints are values
        public int $year,
        public int $month,
        public int $day
    ) {
        echo objectClassId($this) . ' __construct called' . PHP_EOL;
    }

    public function __toString(): string {
        return sprintf('%d/%02d/%02d', $this->year, $this->month, $this->day);
    }
}

class Person {
    public function __construct(
        // This string is a value
        public string $name,
        // This Birthday object is a "reference"
        public Birthday $birthday
    ) {
        echo objectClassId($this) . ' __construct called' . PHP_EOL;
    }
}

$aliceBirthday = new Birthday(1985, 9, 21);
$alice = new Person('Alice', $aliceBirthday);
echo $alice->name . PHP_EOL;
echo $alice->birthday . PHP_EOL;
echo "Alice Birthday object hash: " . objectClassId($alice->birthday) . PHP_EOL;
echo PHP_EOL;

$dolly = clone $alice;
echo "Dolly's name is Alice as expected:" . PHP_EOL;
echo $dolly->name . PHP_EOL;
echo "Dolly's birthday representation is 1985/09/21 as expected:" . PHP_EOL;
echo $dolly->birthday . PHP_EOL;
echo "Dolly's birthday object is the same one as Alice, this can lead to errors:" . PHP_EOL;
echo "Dolly Birthday object hash: " . objectClassId($dolly->birthday) . PHP_EOL;
echo PHP_EOL;

echo "Let's say Dolly can now have a life of their own:" . PHP_EOL;
$dolly->name = 'Dolly';
$dolly->birthday->year = 1996;
$dolly->birthday->month = 7;
$dolly->birthday->day = 5;
echo $dolly->name . PHP_EOL;
echo $dolly->birthday . PHP_EOL;
echo PHP_EOL;

echo "The problem is that we changed the birthday of Alice too:" . PHP_EOL;
echo $alice->name . PHP_EOL;
echo $alice->birthday . PHP_EOL;
echo PHP_EOL;
Birthday #1 __construct called
Person #2 __construct called
Alice
1985/09/21
Alice Birthday object hash: Birthday #1

Dolly's name is Alice as expected:
Alice
Dolly's birthday representation is 1985/09/21 as expected:
1985/09/21
Dolly's birthday object is the same one as Alice, this can lead to errors:
Dolly Birthday object hash: Birthday #1

Let's say Dolly can now have a life of their own:
Dolly
1996/07/05

The problem is that we changed the birthday of Alice too:
Alice
1996/07/05

Je ne sais pas vous, mais moi quand je lis clone, j’aimerais un nouvel objet fraîchement copié, et je ne m’imagine pas qu’il y aura des effets de bords de ce type. C’est pour ça qu’à titre personnel, je n’aime pas trop ce mot clef qui à mon sens ne reflète pas sa signification.

Section intitulée la-magie-de-phpLa magie de PHP

Vous vous doutez qu’il y a une solution à ce problème. En effet, PHP dispose des méthodes magiques.

Note : la plus connue d’entre elle __construct() n’est d’ailleurs pas appelée lors du clonage d’un objet.

Ici, intéressons-nous en particulier à __clone(). Cette méthode magique est appelée une fois que le clone est prêt. On peut le voir comme un constructeur du clone. C’est l’occasion pour la personne qui propose une classe de faire le nécessaire concernant les objets référencés par sa classe.

Malheureusement cette implémentation est cachée, ça nous oblige à nous renseigner sur l’API de la classe et vérifier le comportement de l’objet lors du clonage.

C’est aussi, et surtout, pour ça que je n’aime pas tomber sur ce mot clé.

class Birthday {
    public function __construct(
        // These ints are values
        public int $year,
        public int $month,
        public int $day
    ) {
        echo objectClassId($this) . ' __construct called' . PHP_EOL;
    }

    public function __toString(): string {
        return sprintf('%d/%02d/%02d', $this->year, $this->month, $this->day);
    }
}

class Person {
    public function __construct(
        // This string is a value
        public string $name,
        // This Birthday object is a "reference"
        public Birthday $birthday
    ) {
        echo objectClassId($this) . ' __construct called' . PHP_EOL;
    }

    /**
     * As the maintainer of this class/API I took care of that
     */
    public function __clone(): void {
        // Most of the time, cloning manually the referred object will do
        $this->birthday = clone $this->birthday;
    }
}

$aliceBirthday = new Birthday(1985, 9, 21);
$alice = new Person('Alice', $aliceBirthday);
echo $alice->name . PHP_EOL;
echo $alice->birthday . PHP_EOL;
echo "Alice Birthday object hash: " . objectClassId($alice->birthday) . PHP_EOL;
echo PHP_EOL;

// As the user of this class/API I must check what is the behavior of it when cloned
$dolly = clone $alice;
echo "Dolly's name is Alice as expected:" . PHP_EOL;
echo $dolly->name . PHP_EOL;
echo "Dolly's birthday representation is 1985/09/21 as expected:" . PHP_EOL;
echo $dolly->birthday . PHP_EOL;
echo "Dolly's birthday object is not the same one as Alice anymore:" . PHP_EOL;
echo "Dolly Birthday object hash: " . objectClassId($dolly->birthday) . PHP_EOL;
echo PHP_EOL;

echo "Let's say Dolly can now have a life of their own:" . PHP_EOL;
$dolly->name = 'Dolly';
$dolly->birthday->year = 1996;
$dolly->birthday->month = 7;
$dolly->birthday->day = 5;
echo $dolly->name . PHP_EOL;
echo $dolly->birthday . PHP_EOL;
echo PHP_EOL;

echo "The problem is now fixed, Dolly and Alice have two different Birthday objects:" . PHP_EOL;
echo $alice->name . PHP_EOL;
echo $alice->birthday . PHP_EOL;
echo PHP_EOL;
Birthday #1 __construct called
Person #2 __construct called
Alice
1985/09/21
Alice Birthday object hash: Birthday #1

Dolly's name is Alice as expected:
Alice
Dolly's birthday representation is 1985/09/21 as expected:
1985/09/21
Dolly's birthday object is not the same one as Alice anymore:
Dolly Birthday object hash: Birthday #4

Let's say Dolly can now have a life of their own:
Dolly
1996/07/05

The problem is now fixed, Dolly and Alice have two different Birthday objects:
Alice
1985/09/21

Tout ceci pose selon moi des problèmes.

Le premier problème est, à mon sens, le fait que l’implémentation du clonage est cachée à l’utilisateur. J’imagine que ça peut être acceptable et peut être vu comme un détail d’implémentation dont l’utilisateur n’a pas à se soucier, mais à titre personnel j’aime bien savoir ce qui se passe.

Une fois que __clone est défini alors tout semble réglé !?

Eh bien non, l’approche naïve de cloner les objets référencés dans notre méthode __clone est vite limitée. En effet, si plusieurs objets référencent un même objet, cloner le parent créera autant de clone que de références.

Ok, je vous ai perdu, j’avoue que moi-même je suis perdu. Un petit exemple parle plus que mille mots.

class Skin {
    public function __construct(
        // This string is a value
        public string $color
    ) {
        echo objectClassId($this) . ' __construct called' . PHP_EOL;
    }

    public function __toString(): string {
        return 'Skin color: ' . $color;
    }
}

class Arm {
    public function __construct(
        // This Skin object is a "reference"
        public Skin $skin
    ) {
        echo objectClassId($this) . ' __construct called' . PHP_EOL;
    }

    /**
     * As the maintainer of this class/API I took care of that
     */
    public function __clone(): void {
        // Most of the time, cloning manually the referred object will do
        $this->skin = clone $this->skin;
    }
}

class Person {
    public function __construct(
        // This string is a value
        public string $name,
        // Theses objects are "references"
        public Arm $leftArm,
        public Arm $rightArm,
    ) {
        echo objectClassId($this) . ' __construct called' . PHP_EOL;
    }

    /**
     * As the maintainer of this class/API I took care of that
     */
    public function __clone(): void {
        // Most of the time, cloning manually the referred object will do
        $this->leftArm = clone $this->leftArm;
        $this->rightArm = clone $this->rightArm;
    }
}

$aliceSkin = new Skin('Blue');
$aliceLeftArm = new Arm($aliceSkin);
$aliceRightArm = new Arm($aliceSkin);
$alice = new Person('Alice', $aliceLeftArm, $aliceRightArm);
echo $alice->name . PHP_EOL;
echo "Alice left arm skin object hash: " . objectClassId($alice->leftArm->skin) . PHP_EOL;
echo "Alice right arm skin object hash: " . objectClassId($alice->rightArm->skin) . PHP_EOL;
echo PHP_EOL;

// As the user of this class/API I must check what is the behavior of it when cloned
$dolly = clone $alice;
echo "Dolly's name is Alice as expected:" . PHP_EOL;
echo $dolly->name . PHP_EOL;
echo "Dolly skin must be two equal objects but different from Alice " .
     "but they are not, our __clone logic is too dumb for that purpose:" . PHP_EOL;
echo "Dolly left arm skin object hash: " . objectClassId($dolly->leftArm->skin) . PHP_EOL;
echo "Dolly right arm skin object hash: " . objectClassId($dolly->rightArm->skin) . PHP_EOL;
echo PHP_EOL;
Skin #1 __construct called
Arm #2 __construct called
Arm #3 __construct called
Person #4 __construct called
Alice
Alice left arm skin object hash: Skin #1
Alice right arm skin object hash: Skin #1

Dolly's name is Alice as expected:
Alice
Dolly skin must be two equal objects but different from Alice but they are not, our __clone logic is too dumb for that purpose:
Dolly left arm skin object hash: Skin #7
Dolly right arm skin object hash: Skin #9
// composer require myclabs/deep-copy
require('vendor/autoload.php');

class Skin {
    public function __construct(
        // This string is a value
        public string $color
    ) {
        echo objectClassId($this) . ' __construct called' . PHP_EOL;
    }

    public function __toString(): string {
        return 'Skin color: ' . $color;
    }
}

class Arm {
    public function __construct(
        // This Skin object is a "reference"
        public Skin $skin
    ) {
        echo objectClassId($this) . ' __construct called' . PHP_EOL;
    }
}

class Person {
    public function __construct(
        // This string is a value
        public string $name,
        // Theses objects are "references"
        public Arm $leftArm,
        public Arm $rightArm,
    ) {
        echo objectClassId($this) . ' __construct called' . PHP_EOL;
    }
}

$aliceSkin = new Skin('Blue');
$aliceLeftArm = new Arm($aliceSkin);
$aliceRightArm = new Arm($aliceSkin);
$alice = new Person('Alice', $aliceLeftArm, $aliceRightArm);
echo $alice->name . PHP_EOL;
echo "Alice left arm skin object hash: " . objectClassId($alice->leftArm->skin) . PHP_EOL;
echo "Alice right arm skin object hash: " . objectClassId($alice->rightArm->skin) . PHP_EOL;
echo PHP_EOL;

$copier = new \DeepCopy\DeepCopy();
$dolly = $copier->copy($alice);

echo "Dolly's name is Alice as expected:" . PHP_EOL;
echo $dolly->name . PHP_EOL;
echo "Dolly skin must be two equal objects but different from Alice:" . PHP_EOL;
echo "Dolly left arm skin object hash: " . objectClassId($dolly->leftArm->skin) . PHP_EOL;
echo "Dolly right arm skin object hash: " . objectClassId($dolly->rightArm->skin) . PHP_EOL;
echo PHP_EOL;
Skin #3 __construct called
Arm #2 __construct called
Arm #4 __construct called
Person #5 __construct called
Alice
Alice left arm skin object hash: Skin #3
Alice right arm skin object hash: Skin #3

Dolly's name is Alice as expected:
Alice
Dolly skin must be two equal objects but different from Alice:
Dolly left arm skin object hash: Skin #22
Dolly right arm skin object hash: Skin #22

Section intitulée doit-on-etre-pour-ou-contreDoit-on être pour ou contre ?

Évidemment ce n’est pas si simple.

Si vous êtes le mainteneur de la classe et qu’elle est simple, ne contenant que des variables passées par valeurs et pas de références, alors le clone est la façon la plus élégante d’obtenir une copie de l’objet. En tant que mainteneur, je prendrai soin d’implémenter et de commenter la méthode __clone(). Et en tant qu’utilisateur de l’API, j’ajouterai un commentaire lors de l’utilisation de clone pour rassurer la personne qui s’occupe de la relecture et surtout mon moi du futur.

Dans ce sens, une piste d’amélioration pour les IDEs pourrait être de lier la PHPDoc de la méthode __clone() et l’afficher lors de l’utilisation du mot clef clone. Ce serait une bonne chose pour expliciter le comportement de la classe.

Nous trouvons un exemple de ce type de clone dans le code de Symfony sur un objet simple tel que Component/String/ByteString.php#L220. Ici, clone est utilisé sans crainte car l’objet en question ne contient que des variables de type string et boolean.

S’il est nécessaire de faire une copie complète d’un objet complexe alors je passerai par une bibliothèque qui s’en charge. Il est par exemple possible d’utiliser github.com/myclabs/DeepCopy, une bibliothèque spécialisée dans cette tâche. Elle se charge des détails et évite les problèmes soulevés dans cet article.

Il est aussi possible et parfois plus simple de re-construire un objet de zéro, c’est sûrement plus de lignes, mais au moins c’est très explicite.

Dans tous les cas, je vous invite à éviter la simpliste solution que nous pouvons trouver partout sur internet :

function deep_clone($object) {
    return unserialize(serialize($object));
}

D’une part les performances ne sont pas au rendez-vous, mais la sécurité de cette approche peut aussi être un problème si vos objets contiennent des données provenant de l’utilisateur par exemple.

Section intitulée conclusionConclusion

Alors que PHP semble avoir le nécessaire pour cloner un objet, nous avons vu que ce n’est pas aussi simple qu’il y paraît. Comme toujours, il existe plusieurs solutions et il faut utiliser l’outil adapté pour la situation donnée.

J’espère que ce rappel vous a rassuré, il n’y a donc pas à avoir peur du clonage des objets en PHP, par contre il faut se renseigner sur son implémentation. C’est une étape nécessaire pour comprendre le clone qui vous sera retourné.

Happy cloning !

Commentaires et discussions

Nos articles sur le même sujet

Ces clients ont profité de notre expertise