Objets et immutabilité
Dans le chapitre précédent, nous avons parlé d'écrire nos fonctions en utilisant le style sans point ou tacite, où l'argument principal de notre fonction, la donnée (NDT: the main data argument to our function, où data est invariable), n'est pas montrée explicitement.
Nous étions alors incapables de convertir toutes nos fonctions dans ce style parce qu'il nous manquait des outils. Il est temps de voir ces outils.
Lire les propriétés d'un objet
Rappelons-nous l'exemple du droit de vote que nous avons revisité dans le dernier chapitre:
const wasBornInCountry = person => person.birthCountry === OUR_COUNTRY
const wasNaturalized = person => Boolean(person.naturalizationDate)
const isOver18 = person => person.age >= 18
const isCitizen = either(wasBornInCountry, wasNaturalized)
const isEligibleToVote = both(isOver18, isCitizen)
Comme vous pouvez le voir, nous avons rendu isCitizen
et isEligibleToVote
sans point, mais nous n'avons pas pu le faire pour les trois premières fonctions.
Nous avons appris dans le chapitre Programmation déclarative que nous pouvions rendre les fonctions plus « déclaratives » en utilisant equals
et gte
. Commençons par là.
const wasBornInCountry = person => equals(person.birthCountry, OUR_COUNTRY)
const wasNaturalized = person => Boolean(person.naturalizationDate)
const isOver18 = person => gte(person.age, 18)
Pour rendre ces fonctions sans point, nous avons besoin d'un moyen de créer une fonction finalement applicable à person
. Le problème est que nous devons accéder aux propriétés de person
, et que la seule manière que nous connaissons est l'impérative.
prop
Heureusement, Ramda peut nous aider. Elle fournit la fonction prop
pour accéder aux propriétés d'un objet.
Avec prop
, nous pouvons transformer person.birthCountry
en prop('birthCountry', person)
. Commençons par ça.
const wasBornInCountry = person => equals(prop('birthCountry', person), OUR_COUNTRY)
const wasNaturalized = person => Boolean(prop('naturalizationDate', person))
const isOver18 = person => gte(prop('age', person), 18)
Ouahou! Ça a l'air encore pire, maintenant. Mais continuons ce refactoring. D'abord, inversons l'ordre des arguments que nous passons à equals
, pour que prop
soit à la fin. equals
rend le même résultat, quel que soit l'ordre des argument, nous ne prenons donc pas de risque.
const wasBornInCountry = person => equals(OUR_COUNTRY, prop('birthCountry', person))
const wasNaturalized = person => Boolean(prop('naturalizationDate', person))
const isOver18 = person => gte(prop('age', person), 18)
Ensuite, grâce à la nature curryfiée d'equals
et gte
, nous pouvons créer de nouvelles fonctions à appliquer au résultat de l'appel à prop
.
const wasBornInCountry = person => equals(OUR_COUNTRY)(prop('birthCountry', person))
const wasNaturalized = person => Boolean(prop('naturalizationDate', person))
const isOver18 = person => gte(__, 18)(prop('age', person))
C'est encore un peu moins bien, mais allons plus loin. Tirons avantage de la curryfication une fois de plus avec tous les appels à prop
.
const wasBornInCountry = person => equals(OUR_COUNTRY)(prop('birthCountry')(person))
const wasNaturalized = person => Boolean(prop('naturalizationDate')(person))
const isOver18 = person => gte(__, 18)(prop('age')(person))
Encore pire. Mais nous retrouvons un motif familier. Nos trois fonctions ont la même forme que f(g(person))
et nous savons depuis le chapitre Combinaison de fonctions que c'est l'équivalent de compose(f,g)(person)
.
Tirons parti de cela. Utilisons compose
.
const wasBornInCountry = person => compose(equals(OUR_COUNTRY), prop('birthCountry'))(person)
const wasNaturalized = person => compose(Boolean, prop('naturalizationDate'))(person)
const isOver18 = person => compose(gte(__, 18), prop('age'))(person)
Maintenant nous arrivons quelque part. Les trois fonctions ressemblent à person => f(person)
. Nous savons depuis le chapitre Style sans point que nous pouvons rendre ces fonctions tacites.
const wasBornInCountry = compose(equals(OUR_COUNTRY), prop('birthCountry'))
const wasNaturalized = compose(Boolean, prop('naturalizationDate'))
const isOver18 = compose(gte(__, 18), prop('age'))
Il n'était pas évident quand nous avons commencé que nos méthodes faisaient deux choses différentes. Elles accédaient toutes les deux à une propriété d'un objet et appliquaient une opération sur la valeur de cette propriété. Ce refactoring en style tacite (NDT: ou sans point) a rendu cela très explicite.
Voyons d'autres outils que Ramda propose pour travailler sur les objets.
pick
Là où prop
lit une seule propriété d'un objet et renvoie sa valeur, pick
lit plusieurs propriétés d'un objet et renvoie un nouvel objet contenant seulement ces propriétés.
Par exemple, si on veut seulement les nom et âge d'une person
, on peut utiliser pick(['name', 'age'], person)
.
has
Si on veut seulement savoir si un objet possède une propriété sans lire sa valeur, on peut utiliser has
pour vérifier ses propriétés, et hasIn
pour remonter la chaîne des prototypes: has('name', person)
.
path
Là où prop
lit une propriété d'un objet, path
plonge à l'intérieur d'objets imbriqués. Par exemple, pour accéder au code postal d'une structure plus profonde, on peut faire path(['address', 'zipCode'], person)
.
Notez que path
est plus tolérant que prop
. path
renverra undefined
dès qu'un élément le long du chemin (l'argument original inclus) est null
ou undefined
, alors que prop
lèvera une erreur (NDT: error en anglais, mais ce pourrait être exception?).
propOr / pathOr
propOr
et pathOr
ressemblent à prop
et path
combinées à defaultTo
. Elles permettent de fournir une valeur par défaut à utiliser quand la propriété ou le chemin (NDT: path) ne sont pas trouvées dans l'objet ciblé.
Par exemple, on peut fournir une valeur par défaut quand nous ne connaissons par le nom d'une personne: propOr('<Unnamed>', 'name', person)
. Remarquez que contrairement à prop
, propOr
ne lèvera pas d'erreur si person
est null
ou undefined
; à la place, elle retournera la valeur par défaut.
keys / values
keys
renvoie un tableau contenant les noms de toutes les propriétés propres à un objet. values
renvoie les valeurs de ces propriétés. Ces fonctions sont utiles combinées à des fonctions d'itération de collection comme celles que nous avons vues au premier chapitre.
Ajouter, mettre à jour et supprimer des propriétés
Nous avons maintenant de nombreux outils pour lire les objets de manière déclarative, mais qu'en est-il pour faire des changements?
Comme l'immutabilité est importante, nous ne voulons pas changer les objets directement. Au lieu de cela, nous voulons retourner de nouveaux objets qui ont été modifiés de la manière que nous voulons.
Une fois de plus, Ramda est d'une grande aide.
assoc / assocPath
En programmation impérative, nous pouvons initialiser (NDT: set) ou modifier le nom d'une personne avec l'opérateur d'affectation: person.name = 'New name'
.
Dans notre monde fonctionnel et immutable, il nous faut utiliser assoc
à sa place: const updatedPerson = assoc('name', 'New name', person)
.
assoc
renvoie un nouvel objet avec la valeur de propriété ajoutée ou modifiée, laissant l'objet original inchangé.
Il y a aussi assocPath
pour mettre à jour une propriété imbriquée: const updatedPerson = assocPath(['address', 'zipcode'], '97504', person)
.
dissoc / dissocPath / omit
Et pour supprimer des propriétés? En programmation impérative, nous pourrions vouloir dire delete person.age
. En Ramda, nous utiliserions dissoc
: const updatedPerson = dissoc('age', person)
.
dissocPath
est semblable, mais va plus loin à l'intérieur de la structure de l'objet: dissocPath(['address', 'zipCode'], person)
.
Il y a aussi omit
, qui peut retrancher plusieurs propriétés à la fois. const updatedPerson = omit(['age', 'birthCountry'], person)
.
Remarquez que pick
et omit
sont très similaires et complémentaires l'un de l'autre. Ils sont pratiques pour le white-listing (NDT: de white list, liste blanche, garder uniquement cet ensemble de propriétés en utilisant pick
), ou le black-listing (NDT: de black list, liste noire, enlever cet ensemble de propriétés en utilisant omit
).
Transformer des propriétés
Nous en savons assez pour travailler avec des objets d'une manière déclarative et immutable. Écrivons une fonction, celebrateBirthday
, qui met à jour l'âge d'une personne à son anniversaire.
const nextAge = compose(inc, prop('age'))
const celebrateBirthday = person => assoc('age', nextAge(person), person)
C'est un motif assez habituel. Plutôt que de mettre à jour une propriété avec une nouvelle valeur connue, nous voulons vraiment transformer la valeur en appliquant une fonction à l'ancienne valeur, comme nous l'avons fait ici.
Je ne connais pas de bonne manière d'écrire ceci avec moins de répétition, tout en restant dans le style tacite, avec les seulement les outils que nous connaissons.
Ramda vient à notre secours avec sa fonction evolve
.
evolve
prend un objet qui spécifie une fonction de transformation pour chaque propriété à transformer. Réécrivons celebrateBirthday
en utilisant evolve
:
const celebrateBirthday = evolve({ age: inc })
Ce code demande de faire évoluer (NDT: evolve) l'objet cible (non montré ici à cause du style tacite) en faisant un nouvel objet avec les mêmes propriétés et valeurs, mais dont l'age
est obtenu en appliquant inc
à la valeur originale d'age
.
evolve
peut transformer plusieurs propriétés d'un coup et à plusieurs niveaux d'imbrication. L'objet de transformation peut avoir la même forme que l'objet à transformer, et evolve
traversera alors récursivement les deux structures, appliquant les fonctions de transformation au passage.
Notez qu'evolve
n'ajoutera pas de nouvelle propriété; si vous spécifiez une transformation pour une propriété qui n'est pas présente dans l'objet cible, evolve
l'ignorera tout simplement.
Je me suis aperçu qu'evolve
est rapidement devenu un cheval de bataille (NDT: workhorse) dans mes applications.
Fusionner des objets
Quelquefois, vous voudrez fusionner deux objets. Un cas courant est d'avoir une fonction qui prend des options nommées et que vous voulez combiner ces options avec un ensemble d'options par défaut. Ramda fournit merge
pour cet usage.
function f(a, b, options = {}) {
const defaultOptions = { value: 42, local: true }
const finalOptions = merge(defaultOptions, options)
}
merge
renvoie un nouvel objet contenant toutes les propriétés et valeurs des deux objets. Si les deux objets ont un propriété commune, c'est la valeur du deuxième argument qui prime.
Que le deuxième argument l'emporte a du sens quand on utilise merge
tout seul, mais beaucoup moins dans un pipeline. Souvent, dans ce cas, vous ferez une série de transformations sur un objet, et une de ces transformations est de fusionner de nouvelles valeurs de propriétés. Dans ce cas, vous voudrez que ce soit le premier argument qui l'emporte.
Utiliser simplement merge(newValues)
dans le pipeline ne produira pas l'effet escompté.
Pour cette situation, je définit une fonction utilitaire nommée reverseMerge
. Elle peut s'écrire const reverseMerge = flip(merge)
. Rappelez-vous que flip
inverse les deux premiers arguments de la fonction à laquelle elle s'applique.
merge
effectue une fusion superficielle. Si les objets fusionnés possèdent tous deux une propriété dont la valeur est un sous-objet, ces sous-objets ne seront pas fusionnés. Ramda n'a pas de fonctionnalité fusion profonde, où les sous-objets sont fusionnés récursivement.
Remarquez que merge
ne prend que deux arguments. Si vous voulez fusionnez plusieurs objets en un seul, il existe mergeAll
qui prend un tableau des objets à fusionner.
Conclusion
Voilà qui nous a donné une belle boîte à outils pour travailler sur les objets de manière déclarative et immutable. Nous pouvons maintenant lire, ajouter, mettre à jour, supprimer et transformer des propriétés dans des objets sans changer les objets originaux. Et nous pouvons même faire ça en combinant des fonctions.
Chapitre suivant
Maintenant que nous pouvons travailler sur des objets d'une façon immutable, pouvons-nous faire de même sur les tableaux? Tableaux et immutabilité nous montre comment.