Immutabilité au moment de la compilation dans TypeScript –
TypeScript nous permet de décorer ECMAScript conforme aux spécifications avec des informations de type que nous pouvons analyser et afficher en tant que JavaScript simple à l'aide d'un compilateur dédié. Dans les projets à grande échelle, ce type d'analyse statique peut détecter les bogues potentiels avant de devoir recourir à de longues sessions de débogage, sans parler du déploiement en production. Cependant, les types de références dans TypeScript sont toujours modifiables, ce qui peut entraîner des effets indésirables inattendus dans notre logiciel.
Dans cet article, nous examinerons les constructions possibles dans lesquelles l'interdiction de la mutation des références peut être bénéfique.
un rappel sur l'immuabilité en JavaScript? Lisez notre guide, Immutabilité en JavaScript .
Primitives vs types de référence
JavaScript définit deux groupes globaux de types de données :
- Primitives: valeurs de niveau bas qui sont immuable (par exemple, chaînes de caractères, nombres, booléens, etc.)
- Références: collections de propriétés, représentant une mémoire de tas identifiable, mutables (par exemple, objets, tableaux,
(carte)
etc.)
Disons que nous déclarons une constante à laquelle nous affectons une chaîne:
const message = 'hello';
Etant donné que les chaînes sont des primitives et sont donc immuables, nous ne pouvons pas modifier directement cette valeur. Il ne peut être utilisé que pour produire de nouvelles valeurs :
console.log (message.replace ('h', 'sm')); // 'smello'
console.log (message); // 'salut'
Malgré l’appel de replace ()
sur le message
nous ne modifions pas sa mémoire. Nous créons simplement une nouvelle chaîne en laissant le contenu original du message
intact.
La mutation des index du message
est un no-op par défaut, mais jette un TypeError
en mode strict :
'use strict';
const message = 'bonjour';
message [0] = 'j'; // TypeError: 0 est en lecture seule
Notez que si la déclaration du message
devait utiliser le mot clé let
nous pourrions remplacer la valeur à laquelle elle résout:
let message = 'hello ';
message = 'au revoir';
Il est important de souligner qu’il s’agit d’une mutation et non . Au lieu de cela, nous remplaçons une valeur immuable par une autre.
Mutable References
Comparons le comportement des primitives avec les références. Déclarons un objet avec quelques propriétés:
const me = {
nom: 'James',
âge: 29 ans,
};
Etant donné que les objets JavaScript sont modifiables, nous pouvons modifier ses propriétés existantes et en ajouter de nouvelles:
me.name = 'Rob';
me.isTall = true;
console.log (moi); // Objet {nom: "Rob", âge: 29 ans, isTall: true};
Contrairement aux primitives, les objets peuvent être mutés directement sans être remplacés par une nouvelle référence. Nous pouvons le prouver en partageant un seul objet entre deux déclarations:
const me = {
nom: 'James',
âge: 29 ans,
};
const rob = me;
rob.name = 'Rob';
console.log (moi); // {nom: 'Rob', âge: 29}
Les tableaux JavaScript, hérités de Object.prototype
sont également mutables:
const names = ['James', 'Sarah', 'Rob'];
names [2] = 'Layla';
console.log (noms); // Tableau (3) [ 'James', 'Sarah', 'Layla' ]
Envisagez-vous que nous avons un tableau mutable des cinq premiers nombres de Fibonacci : const fibonacci = [1, 2, 3, 5, 8];
log2 (fibonacci); // remplace chaque élément, n, par Math.log2 (n);
append Fibonacci (Fibonacci, 5, 5); // ajoute les cinq prochains nombres de Fibonacci au tableau d'entrée
const fibonacci = [1, 2, 3, 5, 8];
log2 (fibonacci); // remplace chaque élément, n, par Math.log2 (n);
append Fibonacci (Fibonacci, 5, 5); // ajoute les cinq prochains nombres de Fibonacci au tableau d'entrée
Ce code peut sembler anodin à la surface, mais depuis que log2
mute le tableau qu'il reçoit, notre tableau fibonacci
ne représentera plus exclusivement les nombres de Fibonacci comme son nom l'indique autrement. Au lieu de cela, fibonacci
deviendrait [0, 1, 1.584962500721156, 2.321928094887362, 3, 13, 21, 34, 55, 89]
. On pourrait donc faire valoir que les noms de ces déclarations sont sémantiquement inexacts, ce qui rend le flux du programme plus difficile à suivre.
Objets pseudo-immuables en JavaScript
Bien que les objets JavaScript soient modifiables, nous pouvons tirer avantage de constructions particulières pour: références de clones profondes, à savoir syntaxe étendue :
const me = {
nom: 'James',
âge: 29 ans,
adresse: {
maison: '123',
rue: 'Fake Street',
ville: 'Fakesville',
pays: 'États-Unis',
zip: 12345,
},
};
const rob = {
...moi,
nom: 'Rob',
adresse: {
... moi.adresse,
maison: '125',
},
};
console.log (me.name); // 'James'
console.log (rob.name); // 'Rob'
console.log (moi === rob); // faux
La syntaxe spread est également compatible avec les tableaux:
const names = ['James', 'Sarah', 'Rob'];
const newNames = [...names.slice(0, 2), 'Layla'];
console.log (noms); // Tableau (3) [ 'James', 'Sarah', 'Rob' ]
console.log (newNames); // Tableau (3) [ 'James', 'Sarah', 'Layla' ]
console.log (noms === newNames); // faux
Penser de manière immuable lorsque vous utilisez des types de référence peut rendre le comportement de notre code plus clair. En reprenant l'exemple de Fibonacci mutable antérieur, nous pourrions éviter une telle mutation en copiant fibonacci
dans un nouveau tableau:
const fibonacci = [1, 2, 3, 5, 8];
const log2Fibonacci = [...fibonacci];
log2 (log2Fibonacci);
append Fibonacci (Fibonacci, 5, 5);
Plutôt que d'imposer au consommateur la tâche de créer des copies, il serait préférable que log2
et appendFibonacci
traitent leurs entrées en lecture seule, créant de nouvelles sorties basées sur celles-ci :
const PHI = 1,618033988749895;
const log2 = (arr: number []) => arr.map (n => Math.log2 (2));
const fib = (n: nombre) => (PHI ** n - (-PHI) ** -n) / Math.sqrt (5);
const createFibSequence = (début = 0, longueur = 5) =>
new Array (length) .fill (0) .map ((_, i) => fib (début + i + 2));
const fibonacci = [1, 2, 3, 5, 8];
const log2Fibonacci = log2 (fibonacci);
const extendedFibSequence = [...fibonacci, ...createFibSequence(5, 5)];
En écrivant nos fonctions pour renvoyer de nouvelles références en faveur de la mutation de leurs entrées, le tableau identifié par la déclaration fibonacci
reste inchangé et son nom reste une source de contexte valide. En fin de compte, ce code est plus déterministe .
Papering Over the Cracks
Avec un peu de discipline, nous pourrons peut-être agir sur les références comme si elles étaient uniquement lisibles, mais qu'elles empêchent les mutations de se produire ailleurs. Qu'est-ce qui nous empêche d'introduire une déclaration frauduleuse visant à muter fibonacci
dans une partie éloignée de notre application?
fibonacci.push (4);
ECMAScript 5 a introduit Object.freeze ()
qui fournit une protection contre les objets en mutation:
'use strict';
const me = Object.freeze ({
nom: 'James',
âge: 29 ans,
adresse: {
// les accessoires de l'exemple précédent
},
});
me.name = 'Rob'; // TypeError: 'name' est en lecture seule
me.isTheBest = true; // TypeError: L'objet n'est pas extensible
Malheureusement, il n'interdit que de manière superficielle les mutations de propriétés. Les objets imbriqués peuvent donc toujours être modifiés:
// Aucun type d'erreur ne sera lancé.
me.address.house = '666';
me.address.foo = 'bar';
On pourrait appeler cette méthode sur tous les objets d'un arbre en particulier, mais cela s'avère rapidement difficile à manier. Peut-être pourrions-nous plutôt tirer parti des fonctionnalités de TypeScript pour leur immuabilité au moment de la compilation.
Expressions littérales profondément figées avec des assertions const
Dans TypeScript, nous pouvons utiliser const assertions une extension des assertions de type pour calculer un type en profondeur en lecture seule à partir d'une expression littérale:
const sitepoint = {
nom: 'SitePoint',
isRegistered: true,
adresse: {
ligne 1: 'PO Box 1115',
ville: 'Collingwood',
région: 'VIC',
code postal: '3066',
pays: 'Australie',
},
contentTags: ['JavaScript', 'HTML', 'CSS', 'React'],
} comme const;
En annotant cette expression littérale d’objet avec en tant que const
TypeScript calcule le type le plus spécifique en lecture seule qu’il puisse:
{
nom en lecture seule: 'SitePoint';
readonly isRegistered: true;
adresse en lecture seule: {
readonly line1: 'PO Box 1115';
ville en lecture seule: 'Collingwood';
en lecture seule région: 'VIC';
Code postal en lecture seule: '3066';
en lecture seule pays: 'Australie';
};
en lecture seule contentTags: en lecture seule ['JavaScript', 'HTML', 'CSS', 'React'];
}
En d'autres termes:
- Les primitives ouvertes seront restreintes à des types littéraux exacts (par exemple,
boolean
=>true
) - Les propriétés des objets littéraux seront modifiées avec
en lecture seule
- Les littéraux de tableau deviendront
en lecture seule
tuples (par exemple,string []
=>['foo', 'bar', 'baz']
)
Toute tentative d’ajout ou de remplacement de valeurs se traduira par Le compilateur TypeScript a généré une erreur:
sitepoint.isCharity = true; // isCharity n'existe pas sur le type inféré
sitepoint.address.country = 'Royaume-Uni'; // Impossible d'affecter à 'pays' car il s'agit d'une propriété en lecture seule
Les assertions Const résultent en des types en lecture seule, qui interdisent intrinsèquement l'invocation de méthodes d'instance qui muteront un objet:
sitepoint.contentTags.push ('Pascal'); // La propriété 'push' n'existe pas sur le type 'en lecture seule ["JavaScript", "HTML"...]
Naturellement, le seul moyen d'utiliser des objets immuables pour refléter des valeurs différentes est de créer de nouveaux objets à partir d'eux:
const microsoft = {
... sitepoint,
nom: 'Microsoft',
} comme const;
Paramètres de fonction immuables
Etant donné que les assertions const sont simplement un sucre syntaxique pour taper une déclaration particulière en tant qu’ensemble de propriétés en lecture seule avec des valeurs littérales, il est toujours possible de muter des références dans des corps de fonction:
interface Person {
nom: chaîne;
adresse: {
pays: chaîne;
};
}
const moi = {
nom: 'James',
adresse: {
pays: 'Royaume-Uni',
},
} comme const;
const isJames = (personne: Personne) => {
person.name = 'Sarah';
return person.name === 'James';
};
console.log (isJames (moi)); // faux;
console.log (me.name); // 'Sarah';
On pourrait résoudre ce problème en annotant le paramètre personne
avec Readonly
mais cela n'a d'incidence que sur les propriétés d'un objet au niveau racine:
const isJames = (personne: Readonly) ) => {
person.name = 'Sarah'; // Impossible d'affecter à 'nom' car il s'agit d'une propriété en lecture seule.
person.address.country = 'Australia'; // valide
return person.name === 'James';
};
console.log (isJames (moi)); // faux
console.log (me.adresse.country); // 'Australie'
Il n'y a pas de types d'utilitaires intégrés pour gérer l'immutabilité profonde, mais étant donné que TypeScript 3.7 introduit un meilleur support des types récursifs en différant leur résolution, nous pouvons maintenant exprimer un type infiniment récursif désignant des propriétés par en lecture seule
sur toute la profondeur d'un objet:
de type Immutable = {
en lecture seule [K in keyof T]: Immutable ;
};
Si nous devions décrire le paramètre personne
de isJames ()
comme Immutable
TypeScript nous interdirait également de muter des objets imbriqués:
const isJames = (personne: immuable ) => {
person.name = 'Sarah'; // Impossible d'affecter à 'nom' car il s'agit d'une propriété en lecture seule.
person.address.country = 'Australia'; // Impossible d'affecter à 'pays' car il s'agit d'une propriété en lecture seule.
return person.name === 'James';
};
Cette solution fonctionnera également pour les tableaux profondément imbriqués:
const hasCell = (cellules: Immutable ) => {
cellules [0][0] = 'non'; // La signature d'index dans le type 'chaîne en lecture seule []' permet uniquement la lecture.
};
Bien que Immutable
soit un type défini manuellement, des discussions sont en cours pour introduire DeepReadonly
Un exemple du monde réel
Redux la bibliothèque de gestion extrêmement populaire, exige que l'état soit traité immuablement afin de déterminer de façon triviale si le magasin doit être mis à jour. Nous pourrions avoir des interfaces d'état et d'action d'application ressemblant à ceci:
interface Action {
type: chaîne;
nom: chaîne;
isComplete: booléen;
}
interface Todo {
nom: chaîne;
isComplete: booléen;
}
Etat de l'interface {
todos: Todo [];
}
Etant donné que notre réducteur devrait renvoyer une toute nouvelle référence si l'état a été mis à jour, nous pouvons taper l'argument
avec Immutable
pour interdire toute modification:
const réducteur = (
état: Immuable ,
action: Immuable ,
): Immutable => {
commutateur (action.type) {
cas 'ADD_TODO':
revenir {
...Etat,
todos: [
...state.todos,
{
name: action.name,
isComplete: false,
},
],
};
défaut:
état de retour;
}
};
Avantages supplémentaires de l’immutabilité
Tout au long de cet article, nous avons observé comment le traitement d’objets aboutit immuablement à un code plus clair et plus déterministe.
Détecter les modifications avec l'opérateur de comparaison strict
En JavaScript, vous pouvez utiliser l'opérateur de comparaison strict ( ===
) pour déterminer si deux objets partagent la même référence. Prenons notre réducteur dans l'exemple précédent:
const réducteur = (
état: Immuable ,
action: Immuable ,
): Immutable => {
commutateur (action.type) {
cas 'ADD_TODO':
revenir {
...Etat,
// fusionne profondément les TODO
};
défaut:
état de retour;
}
};
Comme nous créons une nouvelle référence uniquement si un état modifié a été calculé, nous pouvons en déduire que l'égalité référentielle stricte représente un objet inchangé:
const action = {
... addTodoAction,
type: 'NOOP',
};
const newState = réducteur (état, action);
const hasStateChanged = state! == newState;
La détection des modifications par une égalité de référence stricte est plus simple que la comparaison en profondeur de deux arborescences d'objets, ce qui implique généralement une récursivité.
Mémorisation des calculs par référence
En corollaire, traiter les références et les expressions d'objet comme une relation un à un. (c’est-à-dire qu’une référence unique représente un ensemble exact de propriétés et de valeurs), nous pouvons mémoriser des calculs potentiellement coûteux par référence. Si nous voulions ajouter un tableau contenant les 2000 premiers nombres de la séquence de Fibonacci, nous pourrions utiliser une fonction d'ordre supérieur et un WeakMap
pour mettre en cache de manière prévisible le résultat d'une opération référence particulière:
const memoise = (func: Function) => {
const résultats = new WeakMap ();
return (arg: TArg) =>
results.has (arg)? results.get (arg): results.set (arg, func (arg)). get (arg);
};
somme de const = (nombres: nombre []) => nombres.reduire ((total, x) => total + x, 0);
const memoisedSum = memoise (somme);
const numbers = createFibSequence (0, 2000);
console.log (memoisedSum (nombres)); // cache miss
console.log (memoisedSum (nombres)); // cache frappé
L'immuabilité n'est pas une solution miracle
Comme tout paradigme de programmation, l'immuabilité a ses inconvénients:
- Copier des objets profonds avec la syntaxe spread peut être verbeux, en particulier lorsqu'on ne modifie qu'une seule valeur primitive dans un complexe
- La création de nouvelles références entraîne de nombreuses affectations de mémoire éphémères, que le ramasse-miettes doit par conséquent éliminer; Cela risque de ternir le fil conducteur, bien que les éboueurs modernes tels que Orinoco atténuent cela avec la parallélisation.
- L'utilisation de types immuables et d'assertions constantes requiert discipline et consensus. Des règles particulières en matière de peluchage sont en cours de discussion afin d'automatiser de telles pratiques, mais constituent des propositions très préliminaires.
- De nombreuses API de première et de tierce partie, telles que les bibliothèques DOM et d'analyse, sont modelé sur la mutation des objets. Bien que des résumés particuliers puissent aider, l'immuabilité omniprésente sur le Web est impossible.
Résumé
Le code chargé de mutations peut avoir une intention opaque et entraîner un comportement inattendu de notre logiciel. La manipulation de la syntaxe JavaScript moderne peut encourager les développeurs à opérer de manière immuable sur les types de référence – en créant de nouveaux objets à partir de références existantes au lieu de les modifier directement – et en les complétant avec des constructions TypeScript pour obtenir une immuabilité au moment de la compilation. Ce n'est certainement pas une approche infaillible, mais avec une certaine discipline, nous pouvons écrire des applications extrêmement robustes et prévisibles qui, à long terme, ne peuvent que faciliter notre travail.
Source link