Shallow vs Deep Copy en Javascript
On utilise chaque jour en JavaScript ou avec les frameworks frontend des méthodes comme map(), slice() ou encore filter() sans réllement savoir ce qui se passe derrière.
Parfois, on remarque des comportements surprenant et on se dit “c’est quoi ce
langage…”.
Par exemple :
const user = {
name: "alice",
preferences: {
theme: "dark"
}
};
const copy = { ...user };
copy.preferences = "light";
console.log(user.preferences.theme); // Output: 'light'
Mais pourquoi user a-t-il changé alors que j’ai modifié copy ?
Tout d’abord, c’est quoi « copier » en informatique ?
En général, quand on pense au fait de copier, on se dit que l’orignal ne se modifie pas, si on fait une modification sur sa copie.
En informatique, c’est un peu plus subtil que ca.
Quand on travaille avec des objets en JavaScript, deux variables peuvent faire références au même objet.
Par exemple :
const user = {
name: "Alice"
};
const copy = user;
copy.name = "foo";
console.log(user.name); // Output: foo
console.log(copy.name); // Output: foo
console.log(user === copy); // Output: true
Dans ce cas, modifier l’objet à travers une variable modifiera également la première variable, car elles ont la même référence en mémoire. Ce n’est pas une copie à proprement parler, ce sont juste deux variables qui font référence au même objet.
Valeurs primitives et objets
Pour comprendre les copies en JavaScript, il est important de distinguer les valeurs primitives des objets.
Il y a 7 types de valeurs primitives en JavaScript :
String
Number
Boolean
Symbol
Undefined
Null
BigInt
Avec les objets, ce sont les variables qui permettent d’accéder à un même objet via une référence.
C’est cette notion de référence qui est importante pour comprendre les shallow copies.
A propos de la Stack et du Heap
J’avais fait toute une partie sur la gestion en mémoire en JavaScript en disant que « les primitives sont stockées dans la Stack et les objets dans la Heap » mais c’est une simplification qui n’est pas très juste et ce n’est pas défini de cette manière dans les spécifications JavaScript.
Au final, on a pas besoin d’entrer dans ce niveau de détail d’implémentation pour comprendre les shallow et deep copies.
Shallow vs Deep Copy en pratique
Une fois que l’on comprend la différence entre une valeur primitive et une référence vers un objet, on peut définir les shallow et deep copies.
On parle également de « copie superficielle » et de « copie profonde ».
Shallow copy
Une shallow copy crée un nouvel objet et copie ses propriétés de premier niveau.
const lang = {
fr: "Bonjour",
de: "Hallo",
en: "Hello"
};
const foo = { ...lang };
console.log(foo === lang); // output: false
foo.fr = "Salut";
console.log(foo.fr); // output: Salut
console.log(lang.fr); // output: Bonjour
C’est ce qu’on appelle une Shallow Copy, mais ca peut être problématique quand on s’attend que l’objet original garde ses valeurs de base. Cette facon de faire est souvent proie à des erreurs et bugs « silencieux ».
En revanche, si une propriété contient elle-même un objet, cet objet n’est pas copié. La copie et l’original continuent alors de faire référence au même objet imbriqué.
Mais que se passe-t-il lorsqu’une propriété contient elle-même un objet ?
const lang = {
config: {
fr: "bonjour"
}
};
const foo = { ...lang };
console.log(foo === lang); // output : false donc 2 objets différents
console.log(foo.config === lang.config); // output: true donc réf au même objet
Ici, foo et lang sont bien deux objets différents. En revanche, foo.config
et lang.config font référence au même objet.
Si l’on modifie l’objet imbriqué à travers foo:
foo.config.fr = "salut";
console.log(foo.config.fr); // output: 'salut'
console.log(lang.config.fr); // output: 'salut'
La modification est également visible dans lang.
C’est caractéristique d’une shallow copy : l’objet de premier niveau est copié,
mais les objets imbriqués ne le sont pas.
Les 2 façons de shallow copy
1. Utiliser le Spread Operator (ES6/ES2015)
C’est le moyen le plus courant aujourd’hui pour créer une shallow copy d’un objet est d’utiliser le spread operator :
const lang = {
fr: "Bonjour",
de: "Hallo",
en: "Hello"
};
const foo = { ...lang };
foo.fr = "Salut";
console.log(foo.fr); // output: Salut
console.log(lang.fr); // output: Bonjour
2. Object.assign()
C’est l’ancienne façon de faire avant ES6
const lang = {
fr: "Bonjour",
de: "Hallo",
en: "Hello"
};
const foo = Object.assign({}, lang);
foo.fr = "Salut";
console.log(foo.fr); // output: Salut
console.log(lang.fr); // output: Bonjour
Mais on va avoir un problème ici, ce sont les objets imbriqués. En effet, si on change une donnée dans l’objet imbriqué, cela va aussi changer les données originales.
const lang = {
config: {
fr: "bonjour"
}
};
const foo = { ...lang };
lang.config.fr = "salut";
console.log(lang.config.fr); // output: salut
console.log(foo.config.fr); // output: salut
Ici, foo et lang sont deux objets différents, mais ils partagent le même
objet config:
console.log(foo === lang); // output: false
console.log(foo.config === lang.config); // output: true
C’est donc bien une Shallow Copy.
Maintenant, si on veut que l’objet copié soit complètement indépendant de l’original, y compris pour ses objets imbriqués. On va alors avoir besoin de faire une Deep Copy.
Deep Copy
Par ailleurs, une « copie profonde » ou deep copy est un objet dans lesquels ces propriétés ne partagent plus du tout de références avec son objet d’origine.
On retrouve une immutabilité des données, c’est-à-dire qu’on ne pourra plus modifier l’objet source si on modifie notre copie.
On peut reprendre notre exemple précédent, si on avait voulu faire une « copie
profonde », on peut utiliser structuredClone()
qui est une version plus moderne et plus fiable que la méthode avec JSON.parse(JSON.stringify(object))
où on pouvait perdre des éléments de notre objet et certains types n’était pas pris en compte.
On peut citer quelques exemples:
Les fonctions avec closures
Symbols
Les objets représentants des éléments HTML
Données recursives
etc.
C’est disponible sur tous les navigateurs modernes depuis 2022 et ça été introduit avec la version 17 de Node.js.
const lang = {
config: {
fr: "bonjour"
}
};
const foo = structuredClone(lang);
console.log(foo === lang); // output: false
console.log(lang.config === foo.config); // output: false
foo.config.fr = "salut";
console.log(lang.config.fr); // output: 'bonjour'
console.log(foo.config.fr); // output: 'salut'
Shallow, deep copy et immutabilité
J’ai parlé en introduction de méthodes de tableaux comme map(), slice() ou
filter(), je pensais que ces méthodes renvoyaient une copie mais pas du
tout…
Bah non, ce sont juste des fonctions pour manipuler des tableaux, tout
simplement. Cela ne veut pas dire que ces méthodes vont réaliser une copie
profonde.
Mais du coup, c’était important pour moi de faire cette distinction entre la shallow, deep copy lorsqu’on cherche à manipuler des données immutables.
Une shallow copy permet d’éviter de modifier directement l’objet original, mais les objets imbriqués peuvent toujours partager leurs références.
Une deep copy permet, elle, de rendre toute la structure indépendante.
Au final, ma compréhension de cette différence va surtout me permettre de mieux savoir ce que je manipule et d’éviter des modifications non voulues de mes données de base.