"Effets algébriques" dans le langage humain

Commentaire du traducteur: Il s'agit de la traduction d'un excellent article de Dan Abramov, un contributeur de React. Ses exemples sont écrits pour JS, mais ils seront également clairs pour les développeurs dans n'importe quelle langue. L'idée est commune à tous.

Avez-vous entendu parler des effets algébriques?


Mes premières tentatives pour découvrir qui ils sont et pourquoi ils devraient m'exciter ont échoué. J'ai trouvé plusieurs PDF , mais ils m'ont encore plus dérouté. (Pour une raison quelconque, je m'endors en lisant des articles académiques.)


Mais mon collègue Sebastian a continué à les appeler le modèle mental de certaines des choses que nous faisons dans React. (Sebastian travaille dans l'équipe React et a proposé beaucoup d'idées, y compris Hooks et Suspense.) À un moment donné, c'est devenu un mème local dans l'équipe React, et beaucoup de nos conversations se sont terminées par ce qui suit:



Il s'est avéré que les effets algébriques sont un concept cool, et ce n'est pas aussi effrayant qu'il me semblait au début après avoir lu ces PDF. Si vous utilisez simplement React, vous n'avez pas besoin de savoir quoi que ce soit à leur sujet, mais si vous, comme moi, êtes intéressé, lisez la suite.


(Avertissement: je ne suis pas un chercheur dans le domaine des langages de programmation et j'ai peut-être gâché quelque chose dans mon explication. Alors, faites-moi savoir si je me trompe!)


Il est encore tôt dans la production


Les effets algébriques sont actuellement un concept expérimental du domaine de l'étude des langages de programmation. Cela signifie que contrairement à if , for ou même des expressions async/await , vous ne pourrez probablement pas les utiliser pour le moment. Ils ne sont pris en charge que par quelques langues créées spécifiquement pour étudier cette idée. Il y a des progrès dans leur implémentation dans OCaml, qui ... est toujours en cours . En d'autres termes, regardez, mais ne touchez pas avec vos mains.


Pourquoi cela devrait-il me déranger?


Imaginez que vous écrivez du code à l'aide de goto et que quelqu'un vous parle de l'existence de constructions if et for . Ou peut-être que vous êtes embourbé dans un enfer de rappel et que quelqu'un vous montre async/await . Assez cool, non?


Si vous êtes le genre de personne qui aime apprendre les innovations de programmation quelques années avant que cela ne devienne à la mode, il est peut-être temps de vous intéresser aux effets algébriques. Bien que ce ne soit pas nécessaire. Voici comment parler de l' async/await en 1999.


Eh bien, quels sont ces effets?


Le nom peut être un peu déroutant, mais l'idée est simple. Si vous connaissez try/catch blocs try/catch , vous comprendrez rapidement les effets algébriques.


Rappelons d'abord try/catch . Supposons que vous ayez une fonction qui lève des exceptions. Il existe peut-être plusieurs appels imbriqués entre lui et le catch :


 function getName(user) { let name = user.name; if (name === null) { throw new Error('  '); } return name; } function makeFriends(user1, user2) { user1.friendNames.add(getName(user2)); user2.friendNames.add(getName(user1)); } const arya = { name: null }; const gendry = { name: '' }; try { makeFriends(arya, gendry); } catch (err) { console.log(",   : ", err); } 

Nous lançons une exception à l'intérieur de getName , mais elle «apparaît» via makeFriends au catch le plus proche. C'est la propriété principale de try/catch . Le code intermédiaire n'est pas nécessaire pour se soucier de la gestion des erreurs.


Contrairement aux codes d'erreur dans des langages comme C, lorsque vous utilisez try/catch vous n'avez pas besoin de transmettre manuellement les erreurs à chaque niveau intermédiaire pour gérer l'erreur au niveau supérieur. Des exceptions apparaissent automatiquement.


Qu'est-ce que cela a à voir avec les effets algébriques?


Dans l'exemple ci-dessus, dès que nous verrons une erreur, nous ne pourrons plus continuer à exécuter le programme. Lorsque nous nous trouvons dans un catch , l'exécution normale du programme s'arrête.


C'est fini. C'est trop tard. Le mieux que nous puissions faire est de nous remettre de l'échec et peut-être répéter ce que nous faisions, mais nous ne pouvons pas par magie «retourner» là où nous étions et faire autre chose. Et avec des effets algébriques, nous le pouvons.


Ceci est un exemple écrit dans un dialecte JavaScript hypothétique (appelons-le ES2025 pour le plaisir), qui nous permet de continuer à travailler après l'utilisateur manquant.


 function getName(user) { let name = user.name; if (name === null) { name = perform 'ask_name'; } return name; } function makeFriends(user1, user2) { user1.friendNames.add(getName(user2)); user2.friendNames.add(getName(user1)); } const arya = { name: null }; const gendry = { name: '' }; try { makeFriends(arya, gendry); } handle(effect) { if (effect === 'ask_name') { resume with ' '; } } 

(Je m'excuse auprès de tous les lecteurs de 2025 qui recherchent «ES2025» sur Internet et tombent dans cet article. Si d'ici là les effets algébriques deviendraient une partie de JavaScript, je serais heureux de mettre à jour l'article!)


Au lieu de throw nous utilisons le mot clé hypothétique perform . De même, au lieu de try/catch nous utilisons l'hypothétique try/handle . La syntaxe exacte n'a pas d'importance ici - je viens de trouver quelque chose pour illustrer l'idée.


Alors qu'est-ce qui se passe ici? Examinons de plus près.


Au lieu de lancer une erreur, nous réalisons l'effet . Tout comme nous pouvons lancer n'importe quel objet, nous pouvons transmettre ici une valeur pour le traitement . Dans cet exemple, je passe une chaîne, mais il peut s'agir d'un objet ou de tout autre type de données:


 function getName(user) { let name = user.name; if (name === null) { name = perform 'ask_name'; } return name; } 

Lorsque nous lançons une exception, le moteur recherche le gestionnaire try/catch le plus proche dans la pile des appels. De même, lorsque nous exécutons un effet , le moteur recherche le gestionnaire d'effet try/handle le plus proche en haut de la pile:


 try { makeFriends(arya, gendry); } handle(effect) { if (effect === 'ask_name') { resume with ' '; } } 

Cet effet nous permet de décider comment gérer la situation lorsque le nom n'est pas spécifié. Nouveau ici (par rapport aux exceptions) est le resume with hypothétique resume with :


 try { makeFriends(arya, gendry); } handle(effect) { if (effect === 'ask_name') { resume with ' '; } } 

C'est quelque chose que vous ne pouvez pas faire avec try/catch . Cela nous permet de revenir à l'endroit où nous avons effectué l'effet et de transmettre quelque chose en retour du gestionnaire . : -O


 function getName(user) { let name = user.name; if (name === null) { // 1.     name = perform 'ask_name'; // 4. ...     (name   ' ') } return name; } // ... try { makeFriends(arya, gendry); } handle(effect) { // 2.    ( try/catch)  (effect === 'ask_name') { // 3. ,      (    try/catch!) resume with ' '; } } 

Cela prend un peu de temps pour se mettre à l'aise, mais conceptuellement, cela ne diffère pas beaucoup de try/catch avec un retour.


Notez, cependant, que les effets algébriques sont un outil beaucoup plus puissant que simplement try/catch . La récupération d'erreurs n'est qu'un des nombreux cas d'utilisation possibles. J'ai commencé avec cet exemple uniquement parce qu'il était plus facile à comprendre pour moi.



La fonction n'a pas de couleur


Les effets algébriques ont des implications intéressantes pour le code asynchrone.


Dans les langues avec async/await fonctions ont généralement une «couleur» ( russe ). Par exemple, en JavaScript, nous ne pouvons pas simplement rendre getName asynchrone sans infecter makeFriends et ses fonctions d'appel avec async. Cela peut être très difficile si une partie du code doit parfois être synchrone et parfois asynchrone.



 //       ... async getName(user) { // ... } //       ... async function makeFriends(user1, user2) { user1.friendNames.add(await getName(user2)); user2.friendNames.add(await getName(user1)); } //   ... async getName(user) { // ... } 

Les générateurs JavaScript fonctionnent de manière similaire : si vous travaillez avec des générateurs, alors tout le code intermédiaire devrait également connaître les générateurs.


Eh bien, qu'est-ce que cela a à voir avec ça?


Pour un instant, oublions async / wait et revenons à notre exemple:


 function getName(user) { let name = user.name; if (name === null) { name = perform 'ask_name'; } return name; } function makeFriends(user1, user2) { user1.friendNames.add(getName(user2)); user2.friendNames.add(getName(user1)); } const arya = { name: null }; const gendry = { name: '' }; try { makeFriends(arya, gendry); } handle(effect) { if (effect === 'ask_name') { resume with ' '; } } 

Que se passe-t-il si notre gestionnaire d'effets ne peut pas retourner le "nom de rechange" de manière synchrone? Et si nous voulons l'obtenir de la base de données?


Il s'avère que nous pouvons appeler resume with asynchrone à partir de notre gestionnaire d'effets sans apporter de modifications à getName ou makeFriends :


 function getName(user) { let name = user.name; if (name === null) { name = perform 'ask_name'; } return name; } function makeFriends(user1, user2) { user1.friendNames.add(getName(user2)); user2.friendNames.add(getName(user1)); } const arya = { name: null }; const gendry = { name: '' }; try { makeFriends(arya, gendry); } handle(effect) { if (effect === 'ask_name') { setTimeout(() => { resume with ' '; }, 1000); } } 

Dans cet exemple, nous appelons la resume with seulement une seconde plus tard. Vous pouvez envisager de resume with rappel, que vous ne pouvez appeler qu'une seule fois. (Vous pouvez également vous montrer à vos amis en appelant cette chose "une continuation limitée ponctuelle" (le terme continuation délimitée n'a pas encore reçu de traduction stable en russe - environ. Transl.).)


Maintenant, la mécanique des effets algébriques devrait être un peu plus claire. Lorsque nous lançons une erreur, le moteur JavaScript fait tourner la pile en détruisant les variables locales dans le processus. Cependant, lorsque nous exécutons l' effet, notre moteur hypothétique crée un rappel (en fait une «trame de continuation», environ Transl.) Avec le reste de notre fonction, et resume with l'appellera.


Encore une fois, un rappel: la syntaxe spécifique et les mots clés spécifiques sont entièrement inventés uniquement pour cet article. Le point n'est pas dedans, mais en mécanique.



Note de propreté


Il convient de noter que les effets algébriques sont nés de l'étude de la programmation fonctionnelle. Certains des problèmes qu'ils résolvent ne sont propres qu'à la programmation fonctionnelle. Par exemple, dans les langages qui n'autorisent pas les effets secondaires arbitraires (comme Haskell), vous devez utiliser des concepts comme les monades pour faire glisser les effets dans votre programme. Si vous avez déjà lu le tutoriel monade, vous savez qu'il peut être difficile à comprendre. Les effets algébriques aident à faire quelque chose de similaire avec un peu moins d'effort.


C'est pourquoi la plupart des discussions sur les effets algébriques sont complètement incompréhensibles pour moi. (Je ne connais pas Haskell et ses «amis».) Cependant, je pense que même dans un langage impur comme JavaScript, les effets algébriques peuvent être un outil très puissant pour séparer le «quoi» du «comment» dans votre code.


Ils vous permettent d'écrire du code qui décrit ce que vous faites:


 function enumerateFiles(dir) { const contents = perform OpenDirectory(dir); perform Log('Enumerating files in ', dir); for (let file of contents.files) { perform HandleFile(file); } perform Log('Enumerating subdirectories in ', dir); for (let directory of contents.dir) { //           enumerateFiles(directory); } perform Log('Done'); } 

Et plus tard, enveloppez-le avec quelque chose qui décrit le «comment» vous le faites:


 let files = []; try { enumerateFiles('C:\\'); } handle(effect) { if (effect instanceof Log) { myLoggingLibrary.log(effect.message); resume; } else if (effect instanceof OpenDirectory) { myFileSystemImpl.openDir(effect.dirName, (contents) => { resume with contents; }); } else if (effect instanceof HandleFile) { files.push(effect.fileName); resume; } } //  `files`     

Ce qui signifie que ces parties peuvent devenir une bibliothèque:


 import { withMyLoggingLibrary } from 'my-log'; import { withMyFileSystem } from 'my-fs'; function ourProgram() { enumerateFiles('C:\\'); } withMyLoggingLibrary(() => { withMyFileSystem(() => { ourProgram(); }); }); 

Contrairement aux asynchrones / attentes ou aux générateurs, les effets algébriques ne nécessitent pas la complication de fonctions «intermédiaires». Notre appel à enumerateFiles peut être profondément ancré dans notre programme, mais tant qu'il existe un gestionnaire d'effets pour chacun des effets qu'il peut exécuter quelque part ci-dessus, notre code continuera de fonctionner.


Les gestionnaires d'effets nous permettent de séparer la logique du programme des implémentations spécifiques de ses effets sans danses inutiles ni code passe-partout. Par exemple, nous pourrions redéfinir complètement le comportement dans les tests afin d'utiliser le faux système de fichiers et faire des instantanés de journaux au lieu de les afficher sur la console:


 import { withFakeFileSystem } from 'fake-fs'; function withLogSnapshot(fn) { let logs = []; try { fn(); } handle(effect) { if (effect instanceof Log) { logs.push(effect.message); resume; } } // Snapshot  . expect(logs).toMatchSnapshot(); } test('my program', () => { const fakeFiles = [ /* ... */ ]; withFakeFileSystem(fakeFiles, () => { withLogSnapshot(() => { ourProgram(); }); }); }); 

Étant donné que les fonctions n'ont pas de «couleur» (le code intermédiaire n'a pas à connaître les effets) et que les gestionnaires d'effets peuvent être composés (ils peuvent être imbriqués), vous pouvez créer des abstractions très expressives avec eux.



Remarque sur les types


Étant donné que les effets algébriques proviennent de langages typés statiquement, la plupart des débats à leur sujet se concentrent sur la façon de les exprimer en types. C'est sans aucun doute important, mais cela peut également compliquer la compréhension du concept. C'est pourquoi cet article ne parle pas du tout de types. Cependant, je dois noter que généralement le fait qu'une fonction peut effectuer un effet sera codé dans une signature de son type. Ainsi, vous serez protégé contre une situation où des effets imprévisibles sont effectués, ou vous ne pouvez pas suivre d'où ils viennent.


Ici, vous pouvez dire que les effets techniquement algébriques «donnent de la couleur» aux fonctions dans les langages typés statiquement, car les effets font partie d'une signature de type. Ça l'est vraiment. Cependant, la fixation de l'annotation de type pour une fonction intermédiaire pour inclure un nouvel effet n'est pas en soi un changement sémantique - contrairement à l'ajout d'async ou à la transformation d'une fonction en générateur. L'inférence de type peut également aider à éviter la nécessité de modifications en cascade. Une différence importante est que vous pouvez "supprimer" les effets en insérant un stub vide ou une implémentation temporaire (par exemple, un appel de synchronisation pour un effet asynchrone), ce qui vous permet si nécessaire d'empêcher son effet sur le code externe - ou de le transformer en un autre effet.



Ai-je besoin d'effets algébriques en JavaScript?


Honnêtement, je ne sais pas. Ils sont très puissants, et on peut affirmer qu'ils sont trop puissants pour un langage tel que JavaScript.


Je pense qu'ils pourraient être très utiles pour les langages où la mutabilité est rare et où la bibliothèque standard supporte pleinement les effets. Si vous effectuez d'abord la fonction perform Timeout(1000), perform Fetch('http://google.com') et la perform ReadFile('file.txt') , et votre langue a une "correspondance de modèle" et une saisie statique pour les effets, puis cela peut être un très bel environnement de programmation.


Peut-être que ce langage se compilera même en JavaScript!



Qu'est-ce que cela a à voir avec React?


Pas très gros. Vous pouvez même dire que je tire une chouette sur un globe.


Si vous avez regardé mon exposé sur Time Slicing et Suspense, la deuxième partie comprend des composants qui lisent les données du cache:


 function MovieDetails({ id }) { //         ? const movie = movieCache.read(id); } 

(Le rapport utilise une API légèrement différente, mais ce n'est pas le but.)


Ce code est basé sur la fonction React pour les échantillons de données appelée « Suspense », qui est actuellement en développement actif. La chose intéressante ici, bien sûr, est que les données peuvent ne pas encore être dans movieCache - dans ce cas, nous devons d'abord faire quelque chose, car nous ne pouvons pas continuer l'exécution. Techniquement, dans ce cas, l'appel à read () lance Promise (oui, lancez Promise - vous devez avaler ce fait). Cela suspend l'exécution. React intercepte cette promesse et se souvient qu'il est nécessaire de répéter le rendu de l'arborescence des composants une fois la promesse lancée remplie.


Ce n'est pas un effet algébrique en soi, bien que la création de cette astuce s'en soit inspirée . Cette astuce atteint le même objectif: une partie du code ci-dessous dans la pile d'appels est temporairement inférieure à quelque chose de plus élevé dans la pile d'appels (dans ce cas, React), tandis que toutes les fonctions intermédiaires n'ont pas besoin de le savoir ou d'être «empoisonnées» par async ou des générateurs. Bien sûr, nous ne pouvons pas «réellement» reprendre l'exécution en JavaScript, mais du point de vue de React, le réaffichage de l'arborescence des composants après l'autorisation Promise est presque le même. Vous pouvez tricher lorsque votre modèle de programmation assume l'idempotence!


Les crochets sont un autre exemple qui peut vous rappeler des effets algébriques. L'une des premières questions que les gens se posent est: où l'appel useState «sait» à quel composant il fait référence?


 function LikeButton() { //  useState ,    ? const [isLiked, setIsLiked] = useState(false); } 

Je l'ai déjà expliqué à la fin de cet article : dans l'objet React, il y a un état mutable «répartiteur actuel», qui indique l'implémentation que vous utilisez actuellement (par exemple, comme dans react-dom ). De même, il existe une propriété de composant actuelle qui pointe vers la structure de données interne LikeButton. Voici comment useState découvre quoi faire.


Avant de s'y habituer, les gens pensent souvent que cela ressemble à un hack sale pour une raison évidente. Il est faux de s'appuyer sur un état mutable général. (Remarque: comment pensez-vous que try / catch est implémenté dans le moteur JavaScript?)


Cependant, conceptuellement, vous pouvez considérer useState () comme un effet de l'exécution de State (), qui est traité par React lorsque votre composant est exécuté. Cela "explique" pourquoi React (ce que votre composant appelle) peut lui fournir un état (il est plus haut dans la pile des appels, il peut donc fournir un gestionnaire d'effets). En effet, la mise en œuvre explicite de l' état est l'un des exemples les plus courants dans les manuels sur les effets algébriques que j'ai rencontrés.


Encore une fois, bien sûr, ce n'est pas ainsi que React fonctionne réellement, car nous n'avons aucun effet algébrique en JavaScript. Au lieu de cela, il y a un champ caché dans lequel nous enregistrons le composant actuel, ainsi qu'un champ qui pointe vers le "répartiteur" actuel avec l'implémentation useState. En tant qu'optimisation des performances, il existe même des implémentations useState distinctes pour les montages et les mises à jour . Mais si vous êtes maintenant très tordu par ce code, vous pouvez les considérer comme des gestionnaires d'effets ordinaires.


En résumé, nous pouvons dire qu'en JavaScript, throw peut fonctionner comme une première approximation des effets d'E / S (à condition que le code puisse être réexécuté en toute sécurité plus tard, et tant qu'il n'est pas lié au CPU), et le champ variable " dispatcher "restauré dans try / finally peut servir d'approximation grossière aux gestionnaires d'effets synchrones.


Vous pouvez obtenir une implémentation des effets de bien meilleure qualité en utilisant des générateurs , mais cela signifie que vous devez abandonner la nature "transparente" des fonctions JavaScript et que vous devez tout faire avec des générateurs. Et c'est "bien, ça ..."


Où en savoir plus


Personnellement, j'ai été surpris de voir à quel point les effets algébriques des sens m'ont acquis. J'ai toujours fait de mon mieux pour comprendre les concepts abstraits, tels que les monades, mais les effets algébriques ont simplement pris et «allumés» dans la tête. J'espère que cet article les aidera à «se joindre à vous».


Je ne sais pas si elles commenceront jamais à être utilisées en vrac. Je pense que je serai déçu s'ils ne prennent racine dans aucune des principales langues d'ici 2025. Rappelez-moi de vérifier dans cinq ans!


Je suis sûr que vous pouvez faire beaucoup plus intéressant avec eux, mais il est vraiment difficile de ressentir leur force jusqu'à ce que vous commenciez à écrire du code et à les utiliser. Si cet article a éveillé votre curiosité, voici quelques ressources supplémentaires où vous pouvez lire plus en détail:



Beaucoup de gens ont également souligné que si vous omettez l'aspect typage (comme je l'ai fait dans cet article), vous pouvez trouver une utilisation antérieure d'une telle technique dans un système de conditions en Common Lisp. , , call/cc .

Source: https://habr.com/ru/post/fr470718/


All Articles