Un guide visuel des références en JavaScript
Pour partir sur des bases solides, une présentation super claire des pointeurs en JavaScript, ainsi que des Objets et Primitives, par Dave Ceddia.
a.k.a. "pointeurs pour les développeurs JavaScript".
Au premier jour de votre apprentissage du code, quelqu'un vous dit "Une variable c'est comme une boîte. Quand tu écris bidule = 5
, tu mets 5 dans la boîte bidule
". Bon, ce n'est pas vraiment comme ça que les variables fonctionnent, mais ça permet avancer. C'est comme en cours de maths, quand on vous ment sur l'image complète, parce que l'image complète vous ferait exploser le cerveau à ce moment précoce de votre apprentissage.
Mais quelque temps plus tard, vous commencez à voir surgir des problèmes bizarres. Des variables qui changent, alors que vous ne les avez pas modifiées. Des fantômes dans la machine.
"Je pensais avoir fait une copie de ce truc ! Pourquoi ça a changé ?" 👈 ça, c'est un bug de référence !
À la fin de ce billet, vous comprendrez pourquoi ça se produit et comment le corriger.
Qu'est-ce qu'une référence ?
Les références sont partout dans JS, mais elles sont invisibles. Elles ressemblent simplement à des variables. Certains langages, comme le C, les appellent explicitement des pointeurs, qui ont leur propre syntaxe. Mais JS n'a pas de pointeurs, du moins pas sous ce nom. Et JS n'a pas non plus de syntaxe spéciale pour eux.
Prenez cette ligne de JavaScript par exemple : elle crée une variable appelée word qui stocke la chaîne "hello".
let word = 'hello'
Remarquez comment word
pointe vers la boîte avec le "hello"
. Il y a une possibilité de confusion ici, donc soyons clair : La variable n'est pas la boîte. La variable pointe vers la boîte. Laissez-vous imprégner par cette idée pendant que vous continuez à lire.
Donnons maintenant une nouvelle valeur à cette variable en utilisant l'opérateur d'assignation =
:
word = 'world'
Ce qui se passe en fait ici n'est pas que le "hello" est remplacé par "world" — c'est plutôt qu'une boîte entièrement nouvelle est créée, et la variable word
est réassignée pour pointer vers la nouvelle boîte (ce qu'on a appelé "boîte" est une adresse dans la mémoire de l'ordinateur). Une conséquence liée à la réassignation est qu'à un moment donné, la boîte "hello" est nettoyée par le "ramasse-miettes", puisque rien ne l'utilise : votre ordi passe son temps à faire le ménage.
Si vous avez déjà essayé d'attribuer une valeur à un paramètre de fonction, vous avez probablement réalisé que cela ne change rien en dehors de la fonction.
La raison en est que la réassignation d'un paramètre de fonction n'affecte que la variable locale, et non la variable originale qui a été transmise. Voici un exemple :
function reassignFail(word) {
// this assignment does not leak out
word = 'world'
}
let test = 'hello'
reassignFail(test)
console.log(test) // prints "hello"
Au départ, seul test
pointe sur la valeur "hello".
Cependant, une fois que nous sommes à l'intérieur de la fonction, test et word pointent tous deux vers la même boîte.
Après l'assignation (word = "world"), la variable word
pointe sur sa nouvelle valeur "world". Mais nous n'avons pas modifié test
. La variable test indique toujours son ancienne valeur.
C'est ainsi que l'assignation fonctionne en JavaScript. La réassignation d'une variable ne modifie que cette variable. Elle ne modifie pas les autres variables qui pointent également sur cette valeur. Ça reste vrai, que la valeur soit une chaîne de caractères, un booléen, un nombre, un objet, un tableau, une fonction... tous les types de données fonctionnent de cette manière.
Deux types de types
JavaScript a deux grandes catégories de types, et ils ont des règles différentes autour de l'assignation et de l'égalité référentielle. Regardons cela.
Types primitives en JavaScript
Il y a les types primitives comme les chaînes de caractères, les nombres, les booléens (et aussi les Symbol
s, undefined
et null
). Ces types sont immuables, c'est-à-dire en lecture seule, ils ne peuvent pas être modifiés.
Lorsqu'une variable contient l'un de ces types primitives, vous ne pouvez pas modifier la valeur elle-même. Vous pouvez seulement réassigner à cette variable une nouvelle valeur.
La différence est subtile, mais importante !
En d'autres termes, lorsque la valeur contenue dans une boîte est une chaîne de caractères/un nombre/un booléen/un symbole/undefined/null, vous ne pouvez pas modifier cette valeur. Vous pouvez seulement créer de nouvelles cases.
Cela ne fonctionne pas comme ça...
C'est pourquoi, par exemple, toutes les méthodes sur les chaînes de caractères renvoient une nouvelle chaîne. Elles ne modifient pas la chaîne originale, et donc si vous voulez utiliser cette nouvelle valeur, vous devez la stocker quelque part.
let name = 'Dave'
name.toLowerCase()
console.log(name) // toujours Dave avec une majuscule "Dave"
name = name.toLowerCase()
console.log(name) // maintenant c'est "dave"
Tous les autres types : objets, tableaux, etc.
L'autre catégorie est le type objet. Elle englobe les objets, les tableaux (Array), les fonctions et d'autres structures de données comme Map et Set. Ce sont tous des objets.
La grande différence avec les types primitives est que les objets sont muables ! Vous pouvez modifier la valeur d'un objet.
Immuable => prévisible
Si vous passez une valeur primitive dans une fonction, la variable originale que vous avez passée est garantie non modifiable. La fonction ne peut pas modifier ce qui s'y trouve. Vous pouvez être assuré que la variable sera toujours la même après avoir appelé une fonction — n'importe quelle fonction.
Mais avec les objets et les tableaux (et les autres types d'objets), vous n'avez pas cette garantie. Si vous passez un objet dans une fonction, cette fonction peut modifier votre objet. Si vous passez un tableau, la fonction peut y ajouter de nouveaux éléments ou le vider entièrement.
C'est l'une des raisons pour lesquelles de nombreuses personnes de la communauté JS essaient d'écrire du code de manière immuable : il est plus facile de comprendre ce que fait le code lorsque vous êtes sûr que vos variables ne changeront pas de manière inattendue. Si chaque fonction est écrite pour être immuable par convention, vous n'avez jamais besoin de vous demander ce qui va se passer.
Une fonction qui ne modifie pas ses arguments, ou quoi que ce soit en dehors d'elle-même, est appelée une fonction pure. Si elle doit modifier quelque chose dans l'un de ses arguments, elle le fera en renvoyant une nouvelle valeur à la place. C'est plus flexible, car cela signifie que le code appelant peut décider de ce qu'il faut faire avec cette nouvelle valeur.
Récapitulation : Les variables pointent vers des boîtes, et les primitives sont immuables.
Nous avons vu comment l'assignation ou la réassignation d'une variable "pointe" effectivement vers une boîte qui contient une valeur. Et comment l'assignation d'une valeur littérale (par opposition à une variable) crée une nouvelle boîte et y dirige la variable.
let num = 42
let name = 'Dave'
let yes = true
let no = false
let person = {
firstName: 'Dave',
lastName: 'Ceddia',
}
let numbers = [4, 8, 12, 37]
C'est vrai pour les types primitives et les types objets, et c'est vrai qu'il s'agisse de la première assignation ou d'une réassignation.
Nous avons parlé de l'immuabilité des types primitives. Vous ne pouvez pas les modifier, vous pouvez seulement réassigner la variable à autre chose.
Voyons maintenant ce qui se passe lorsque vous modifiez une propriété d'un objet.
Modifier le contenu de la boîte
Nous allons commencer par un objet book
représentant un livre d'une bibliothèque qui peut être emprunté. Il possède un titre, un auteur et un indicateur isCheckedOut
.
let book = {
title: 'Tiny Habits',
author: 'BJ Fogg',
isCheckedOut: false,
}
Voici notre objet et ses valeurs sous forme de boîtes :
Et puis imaginons que nous exécutions ce code :
book.isCheckedOut = true
Voici ce que ce qui se passe au niveau de l'objet :
Remarquez comment la variable book
ne change jamais. Elle continue de pointer vers la même boîte, contenant le même objet. C'est seulement une des propriétés de cet objet qui a changé.
Remarquez que cela suit également les mêmes règles que précédemment. La seule différence est que les variables se trouvent maintenant dans un objet. Au lieu d'une variable isCheckedOut
de niveau supérieur, nous y accédons par book.isCheckedOut
, mais sa réassignation fonctionne exactement de la même manière.
La chose cruciale à comprendre est que l'objet n'a pas changé. En fait, même si nous faisions une "copie" du livre en l'enregistrant dans une autre variable avant de le modifier, nous ne créerions toujours pas un nouvel objet.
let book = {
title: 'Tiny Habits',
author: 'BJ Fogg',
isCheckedOut: false,
}
let backup = book
book.isCheckedOut = true
console.log(backup === book) // true!
console.log(backup.isCheckedOut) // également true!!
La ligne let backup = book
fera pointer la variable backup
sur l'objet book
existant. (il ne s'agit donc pas réellement d'une copie !)
Voici comment cela se passerait :
Le console.log
à la fin prouve encore plus le point : book
est toujours égal à backup
, parce qu'ils pointent vers le même objet, et parce que la modification d'une propriété sur book
n'a pas changé la coquille de l'objet, elle a seulement changé les données internes.
Les variables pointent toujours vers des boîtes, jamais vers d'autres variables. Lorsque nous assignons backup = book
, JS fait immédiatement le travail de recherche de ce vers quoi book
pointe, et pointe backup
vers la même chose. Il ne fait pas pointer backup
vers book
.
C'est une bonne chose : cela signifie que chaque variable est indépendante et que nous n'avons pas besoin de conserver dans notre tête une carte tentaculaire indiquant quelles variables pointent vers quelles autres. Il serait très difficile d'en garder la trace !
Mutation d'un objet dans une fonction
Dans l'introduction, j'ai fait allusion à la modification d'une variable à l'intérieur d'une fonction, et à la manière dont cette modification "reste parfois à l'intérieur de la fonction", tandis que d'autres fois, elle se propage dans le code appelant et au-delà.
Nous avons déjà expliqué que la réassignation d'une variable à l'intérieur d'une fonction n'entraîne pas de fuite, tant qu'il s'agit d'une variable de niveau supérieur comme book
ou house
et non d'une sous-propriété comme book.isCheckedOut
ou house.address.city
.
function doesNotLeak(word) {
// this assignment does not leak out
word = 'world'
}
let test = 'hello'
doesNotLeak(test)
console.log(test) // prints "hello"
Et de toute façon, cet exemple utilisait une chaîne de caractères, donc nous ne pourrions pas la modifier même si nous essayions (car les chaînes de caractères sont immuables, rappelez-vous).
Mais que se passerait-il si nous avions une fonction qui recevait un objet comme argument ? Et modifiait ensuite une propriété de celui-ci ?
function checkoutBook(book) {
// this change will leak out!
book.isCheckedOut = true
}
let book = {
title: 'Tiny Habits',
author: 'BJ Fogg',
isCheckedOut: false,
}
checkoutBook(book)
Voici ce qui se passe :
Ça vous semble familier ? Il s'agit de la même animation que précédemment, car le résultat final est exactement le même ! Peu importe que book.isCheckedOut = true
se produise à l'intérieur ou à l'extérieur d'une fonction, car cette assignation modifiera les éléments internes de l'objet book
dans les deux cas.
Si vous voulez éviter que cela ne se produise, vous devez faire une copie, puis modifier la copie.
function pureCheckoutBook(book) {
let copy = { ...book }
// this change will only affect the copy
copy.isCheckedOut = true
// gotta return it, otherwise the change will be lost
return copy
}
let book = {
title: 'Tiny Habits',
author: 'BJ Fogg',
isCheckedOut: false,
}
// This function returns a new book,
// instead of modifying the existing one,
// so replace `book` with the new checked-out one
book = pureCheckoutBook(book)
Si vous voulez en savoir plus sur l'écriture de fonctions immuables comme celle-ci, lisez mon guide sur l'immuabilité. Il est écrit en tenant compte de React et Redux, mais la plupart des exemples sont en JavaScript.
Les références dans le monde réel
Avec vos nouvelles connaissances sur les références, examinons quelques exemples qui pourraient poser problème. Voyez si vous pouvez repérer le problème avant de lire la solution.
Écouteurs d'événements DOM
Un bref aperçu du fonctionnement des fonctions d'écoute d'événements (event listener) : pour ajouter une écoute d'événements, appelez addEventListener avec le nom de l'événement et une fonction. Pour supprimer un écouteur d'événements, appelez removeEventListener avec le même nom d'événement et la même fonction, comme dans la même référence de fonction. (sinon, le navigateur ne peut pas savoir quelle fonction supprimer, puisqu'un événement peut avoir plusieurs fonctions qui lui sont attachées).
Jetez un coup d'œil à ce code. Utilise-t-il correctement les fonctions d'ajout/suppression ?
document.addEventListener('click', () => console.log('clicked'))
document.removeEventListener('click', () => console.log('clicked'))
...
...
...
...
Vous avez trouvé ?
Ce code ne supprimera jamais l'écouteur d'événements, car ces deux fonctions fléchées ne sont pas référentiellement égales. Ce sont deux fonctions distinctes, même si elles sont identiques du point de vue de la syntaxe.
Chaque fois que vous écrivez une fonction flèche () => { ... } ou une fonction ordinaire function whatever() { ... }
, vous créez un nouvel objet (les fonctions sont des objets, n'oubliez pas).
Facile à prouver avec la console :
let a = () => {}
let b = () => {}
console.log(a === b)
Ça imprimera false
! Chaque nouvel objet (tableau, fonction, Set, Map, etc.) vit dans une toute nouvelle boîte, distincte des autres boîtes.
Pour que l'exemple de l'écouteur d'événements fonctionne correctement, stockez d'abord la fonction dans une variable, et passez cette même variable à la fois à add et à remove.
const onClick = () => console.log('clicked')
document.addEventListener('click', onClick)
document.removeEventListener('click', onClick)
Mutation involontaire
Examinons-en une autre. Voici une fonction qui trouve le plus petit élément d'un tableau en le triant d'abord, puis en prenant le premier élément.
function minimum(array) {
array.sort()
return array[0]
}
const items = [7, 1, 9, 4]
const min = minimum(items)
console.log(min)
console.log(items)
Qu'est-ce que cela imprime ?
...
...
...
...
Si vous avez dit 1 et [7, 1, 9, 4], vous n'avez qu'à moitié raison ;)
La méthode .sort() sur les tableaux trie le tableau en place, ce qui signifie qu'elle change l'ordre sur le tableau d'origine sans le copier.
Cet exemple imprime 1 et [1, 4, 7, 9].
Maintenant, c'est peut-être ce que vous vouliez. Mais probablement pas, n'est-ce pas ? Lorsque vous appelez une fonction minimum, vous ne vous attendez pas à ce qu'elle réorganise les éléments de votre tableau.
Ce type de comportement peut être particulièrement déroutant lorsque la fonction se trouve dans un autre fichier, ou dans une bibliothèque, où le code n'est pas sous vos yeux.
Pour résoudre ce problème, faites une copie du tableau avant de le trier, comme dans le code ci-dessous. Ici, nous utilisons l'opérateur d'étalement, ou syntaxe de décomposition (spread) pour faire une copie du tableau (la partie [...array]
). Il s'agit en fait de créer un tout nouveau tableau, puis de copier chaque élément de l'ancien tableau.
function minimum(array) {
const newArray = [...array].sort()
return newArray[0]
}
On avance et on référence
Ce genre de choses arrive tout le temps, mais c'est aussi l'une de ces choses que l'on peut ignorer sans trop savoir comment elles fonctionnent.
Il faut parfois un certain temps pour comprendre le concept des "pointeurs", des variables qui pointent vers des valeurs et des références. Si vous avez l'impression que votre cerveau est dans le brouillard en ce moment, mettez cet article en signet et revenez dans une semaine.
Une fois que vous l'aurez compris, vous l'aurez compris, et ça rendra tout votre développement JS plus facile.
Cet article est le premier d'une série sur les structures de données et les algorithmes en JS. Le prochain article portera sur les listes liées en JavaScript ! Maintenant que vous savez comment fonctionnent les références, les listes liées seront beaucoup plus faciles à comprendre.