Modern Frontend With HTMX

Grokking Simplicity

(par Eric Normand)

Fiche de lecture

Avant-Propos (par Guy Steele)

Dans les années 60, la plupart des programmes étaient d'abord représentés visuellement, puis traduits en langage informatiques. Les calculs était représentés par des rectangles, les décisions par des losanges, et les entrées/sorties par une autre forme quelconque. Ces formes étaient inter-connectées par des flèches représentant la direction du flux de l'une à l'autre.
L'écriture d'un programme consistait à écrire le contenu de chaque "boite" dans un certain ordre, puis lorsque une flèche pointait vers autre chose que la "boite" suivante, un goto était utilisé pour transférer le contrôle à cette autre boite.
Toutefois, ces flux en 2 dimensions, s'ils étaient bien compréhensibles, sous forme graphique, une fois traduits dans un programme à une dimension avec des goto, le devenaient moins. Si on reliait chaque goto à sa destination, le résultat ressemblait alors à un plat de spaghetti, les discussions allaient alors bon train à propos des problèmes induits pour comprendre et maintenir ce "spaghetti code".

Au début des années 70, un courant connu sous le nom de "structured programming" donna naissance à 2 grandes idées pour organiser le flux des programmes :

  • la plupart des flux devraient pouvoir être exprimés par une poignée de motifs :
    • exécution séquentielle
    • arbres de décisions (if-then-else et instructions switch)
    • exécution répétée (boucles while et for)
  • les instructions devraient être groupées par blocs, proprement imbriqués et les transferts de contrôle non-locaux devraient permettre de sauter à la fin d'un bloc ou en-dehors de celui-ci (break, continue), mais pas de l'extérieur ver l'intérieur du bloc.

Un peu plus tard, le courant de la programmation orientée objet réalisa la vision de quelques idées innovantes comme les objets, les classes, le masquage de l'information, et les types de données abstraits. De ce courant, on peut tirer 2 grandes idées, relatives à l'organisation de l'accès aux données :

  • les variables devraient être encapsulées, ou "contenues" pour permettre simplement de définir que seulement certaines parties d'un programme peuvent les lire ou les écrire.
    Concrètement, cela signifie de déclarer les variables localement dans un bloc, plutôt que globalement, ou bien, de manière plus élaborée, de les déclarer au sein d'une classe (ou un module), afin que les méthodes de cette classe (ou procédures de ce module) soient les seules parties du code a pouvoir y accéder.
    Les classes et modules permettent de spécifier qu'un jeu de données reste consistant, car il est alors possible de spécifier que si une variable est mise à jour, alors d'autres variables qui seraient liées devraient alors être modifiées à leur tour.
  • l'héritage, qui permet la création d'objets complexes à partir d'objets plus simples, en étendant à la fois leurs variables et méthodes, ou bien même en les remplaçants. Cette deuxième idée est rendu possible grâce à la première.

Parallèlement à la montée en popularité de la POO, la programmation fonctionnelle visait quant à elle, principalement à organiser les effets de bord, afin qu'ils ne surviennent pas juste à n'importe quel endroit. Encore une fois, 2 grandes idées en ressortent :

  • la distinction entre les calculs, qui n'ont pas d'effet sur le monde extérieur, et produisent toujours le même résultat, peu importe le nombre de fois où ils sont exécutés, et les actions, qui peuvent potentiellement avoir des résultats différents, à chaque nouvelle exécution, et qui peuvent avoir un impact sur le monde extérieur, comme afficher du texte sur un écran, ou bien lancer une fusée. Un programme est plus facile à comprendre si il est organisé en suivant des motifs standards qui permettent d'identifier clairement quelles parties produisent des effets de bords et quelles partie consistent principalement en l’exécution de calculs. Les motifs standard peuvent être subdivisés en 2 sous-catégories, ceux utilisés pour les programmes suivant un seul fil d'exécution (single-thread), et ceux s'exécutant en parallèle (multi-thread).
  • une série de techniques pour opérer sur des collections de données (listes, tableaux ou bases de données) en "une fois", plutôt que élément par élément. Ces techniques étant rendues possibles par l'absence d'effets de bord, qui est la première idée.

De nos jours, on peut considérer que le courant de pensée du "structured programming" a vaincu, car les goto, ainsi que les variables globales ont pratiquement disparus des pratiques modernes. On peut aussi constater que la plupart des langages populaires adhèrent à la POO.

Mais qu'en est-il de la programmation fonctionnelle ? Il existe certains langages purement fonctionnels qui sont largement utilisés, mais ils requièrent une discipline stricte, d'autre part on peu constater que les autres langages non-fonctionnels à l'origine adoptent peu à peu les idées de la programmation fonctionnelle, et cela les rend bien plus faciles à utiliser.

Nous faire comprendre les principes clés pour organiser les effets de bords, principes qui peuvent être utilisés pratiquement dans n'importe quel langage, tel est le but de ce livre.

Préface

Le paradigme de la programmation fonctionnelle repose principalement sur l'idée que les différentes parties d'un programme peuvent être distinguées en 3 sortes : les actions (effet de bord), les calculs (pur), et les données (immuables).

Cependant ce n'est pas nécessairement la manière dont ce paradigme est enseigné, ni l'idée reconnue comme primordiale par les pratiquants eux-mêmes.

Au final, on peut voir la programmation fonctionnelle comme une panoplie de compétences, et concepts qu'on ne retrouve pas en-dehors de celle-ci.

Ce livre ambitionne d'introduire certaines techniques fondatrices à un large public, de manière non-exhaustive, donnant un point de départ à tout développeur en quête d'apprentissage.

Chapitre 1 : La pensée fonctionnelle

Maîtriser comment distinguer tout morceau de code afin de savoir s'il peut être qualifié d'action, de calcul ou bien de donnée est la compétence principale nécessaire pour bien assimiler le paradigme fonctionnel. Cela est instinctif pour les développeurs expérimentés, mais nécessite un peu d'entrainement si on y est pas habitué.

En programmation fonctionnelle, nous divisons le code en 3 catégories :

  • les actions correspondent au code qui dépend soit du moment, soit du nombre de fois où on l’exécute. Par exemple l'envoi d'un email.
  • les calculs représentent les conversions d'entrées en sorties, elle donnent toujours le même résultat pour une même entrée. Elle peuvent être appelées à tout moment et autant de fois qu'on veut. Elle n'ont aucun effet en dehors d'elles-mêmes.
  • les données sont des fait enregistrés à propos événements. Elles n'ont pas besoin d'être exécutées, et peuvent être interprétées de multiples manières, par exemple un ticket de restaurant peut à la fois être utilisé par le client pour vérifier ses comptes ou bien par le restaurateur pour déterminer la popularité de ses plats.

Pourquoi cette distinction est-elle utile ? Parce que de nos jours, la plupart des systèmes sont distribués, lorsque les ordinateurs communiquent par le réseau, les choses deviennent chaotiques, les messages arrivent parfois dans le désordre, sont dupliqués ou juste perdus. Il devient alors important de savoir ce qu'il se passe pendant qu'on essaie, au fond de donner forme au changement au cours du temps, plus on arrive à supprimer une dépendance par rapport à l'instant ou bien le nombre de fois qu'un morceau de code est appelé, moins on aura de bugs potentiellement sérieux.

Les calculs et données ne dépendent pas de la temporalité, ni du nombre de fois où ils seraient exécutés, Le but devient alors d'isoler un maximum de code dans ces 2 catégories. Ensuite, le problème demeure dans les actions, mais au moins nous l'avons isolé, de plus la programmation fonctionnelle fournit certains outils pour travailler avec les actions d'une manière plus sûre.

Les fondations de la programmation fonctionnelle consistent en :

  • la distinction entre actions, calculs et données, reconnaître ces 3 catégories, être capable de factoriser certaines actions en calculs, identifier différents niveaux dans nos programmes.
  • l'utilisation d’abstractions de premier ordre, c'est à dire la réutilisation de procédures dans d'autres procédures.

Chapitre 2 : La pensée fonctionnelle en action

L'application d'un design stratifié consiste à organiser le code selon sa propension au changement. Le code est organisé en couches, avec tout en bas la stack technique, au milieu le domaine et au-dessus, les règles métier. Cette organisation permet d'améliorer la testabilité, la réutilisation du code, et sa maintenance.

Les diagrammes de timeline permettent de visualiser le déroulement des actions dans le temps, le fait de les "couper" permet certaines optimisations dans un système distribué.

Partie 1 : actions, calculs et données

Chapitre 3 : Distinguer actions, calculs et données

Les données correspondent à des faits liés à des événements, leurs avantages sont :

  • elles sont sérializables
  • on peut les comparer entre elles
  • ouvertes à l’interprétation

Elle sont représentées à l'aide des différentes structures de données existantes (integer, string, tableau..).

Les calculs correspondent au fait convertir des entrées en sorties, ils sont implémentés via des fonctions qui retournent toujours le même résultat pour une entrée donnée.

Les avantages d'utiliser des fonctions de calculs, plutôt que d'actions sont :

  • elles sont plus faciles à tester
  • elle permettent l'analyse statique
  • elle sont composables

Les fonctions de calculs sont aussi appelées fonctions pures, ou bien fonctions mathématiques. On programme en général dans cet ordre : données, calculs puis actions.

Une notion importante concernant les actions est qu'elles "contaminent" le code qui les appelle, ainsi une fonction appelant une action devient automatiquement une action elle-même. Savoir reconnaître une action est primordial, soit elle va influencer le monde extérieur à elle-même, ou bien elle dépend du monde extérieur, son résultat dépend du moment ou bien du nombre de fois où elle est exécutée.

Les actions sont également appelées fonctions impures, ou fonctions avec effets de bord.

L'objectif est d'avoir le moins de code correspondant à des actions, cependant pour qu'un programme soit utile, il doit bien utiliser des actions à un certain moment, pour gérer ce dilemme, il existe des techniques :

  • limiter les action en les transformant en calculs si possible
  • réduire leur taille au minimum (en extraire les calculs et les passer en arguments)
  • restreindre les actions au code qui interagit avec le monde extérieur (architecture en oignon)
  • limiter leur dépendance à la temporalité (moment de l'exécution, ou nombre d'exécutions)

Les actions, calculs et donnés correspondent à des besoins différents. Les calculs et données permettent la planification et la décision, les données représentent un plan ou une décision, et ensuite, l'exécution de cette décision est faite via une action.

Chapitre 4 : Extraire les calculs depuis des actions

Dans une action, les entrées et sorties peuvent être soit implicites, soit explicites. La sortie explicite est la valeur de retour, toute autre sortie est implicite .

Si on retire toutes les entrées et sorties implicites d'une action, cela deviens un calcul. Pour ce faire, d'abord on isole le code correspondant à un calcul, puis on transforme ses entrées et sorties implicite en arguments et valeurs de retour.

Pour isoler le code, on fait un simplement copié-collé du code dans une nouvelle fonction, appliquant la technique de factorisation extract-method.

Enfin, il est important de noter qu'à la fois les arguments et valeurs de retour doivent être immuables, cela nécessite que ces 2 types de variables soient des copies et non des références, sinon en cas de modifications, cela les rendrait implicites.

Chapitre 5 : améliorer le design des actions

Les "code smells" permettent parfois de détecter d’éventuelles optimisations, comme le duplicate-code par exemple.

Décomposer et catégoriser les calculs permet d'extraire des structures de données adaptées à notre métier, afin de rendre le code plus lisible, cela permet également de rendre le tout davantage composable.

Le but est aussi d'avoir le maximum de code réutilisable, si un calcul semble pouvoir être utile dans d'autres contextes, il appartient alors à un niveau plus bas en terme de design stratifié.

La catégorisation permet de faire ressortir les différentes couches de nos programmes, le fait qu'un calcul semble appartenir a plus d'une catégorie est le signe qu'il faut le décomposer.

Chapitre 6 : L'immuabilité

On a 2 types d'opérations: la lecture ou l'écriture, l'écriture devient délicate dans certains langages, car on ne veut pas modifier une valeur qui pourrait être utilisée ailleurs (en dehors d'une fonction de calcul). Si notre langage ne le fait pas par défaut, on devra implémenter nous-même la copy-on-write.
Au final le fait de ne pas modifier les variables, mais d'en retourner de nouvelles, revient à transformer des opération d'écritures en lecture.

Les lectures de données modifiables sont des actions, mais celles de données immuables sont des calculs.

La conversion des opérations d'écritures en lectures nous donne plus de code dans les fonctions de calculs et moins dans celles concernant des actions.

Chapitre 7 : La copie défensive

Dans les langages ne disposant pas de copy-on-write, il est nécessaire d'utiliser certaines techniques lorsqu'on interagit avec d'autres librairies potentiellement mutables. La solution est faire des copies à la fois des données entrantes et sortantes.

Chapitre 8 : Le design stratifié I

Il est basé sur 4 concepts :

  • simplicité de l'implémentation : la signature d'une fonction devrait refléter son contenu, aucun détail supplémentaire ne devrait apparaître dans son body.
  • utiliser des abstractions pour fournir des interfaces cachant les détails d'implémentation.
  • les différentes opération doivent pouvoir être effectuées via des interfaces minimales
  • pragmatisme dans la création des différents niveaux d'abstraction, ils doivent avant tout faciliter le reste du travail.

Pour faire ressortir les différentes couches, on peut utiliser des diagrammes de call graphs. Le but est que chaque couche soit dédiée à un même niveau de détail, les liens entre les couches doivent avoir si possible un seul niveau d’écart. Les noms des fonctions permettent aussi de savoir a quel niveau elles appartiennent.

Chapitre 9 : Le design stratifié II

Utiliser une "barrière d'abstraction" permet d'ignorer les détails d'implémentation. Par exemple une API.

Le principe d'interface minimale stipule qu'on doit essayer de rajouter les nouvelles fonctionnalités au niveau le plus haut possible, si besoin en rajoutant des fonctions plus générales à un niveau plus bas. Une interface minimale représente un niveau d'abstraction qui dans l'idéal doit rester stable dans le temps.

Enfin le dernier indicateur est le fait d'être plus ou moins à l'aise avec les niveaux qu'on a créé, si ce n'est pas le cas, il ne faut pas hésiter à réitérer les différentes techniques.

En visualisant le call graph, on peut tirer plusieurs enseignements :

  • le code des niveaux les plus hauts est plus facile a changer
  • plus le chemin d'une fonction vers le haut est important, plus elle aura d'impact (sera réutilisable) et plus difficile son code est à modifier, il faut donc faire "remonter" le code s'il change trop souvent.
  • tester le code des niveaux les plus bas est plus important, car plus il y a de niveaux au-dessus de celle-ci, plus elle aura d'impacts.

Partie 2 : Les abstractions de première classe

Chapitre 10 : fonctions de première classe I

Pour trouver à quels endroits, il serait nécessaire de créer de nouvelles abstraction, on peut utiliser certaines techniques.

Pour détecter un besoin de définir une nouvelle variable, qui serait une "valeur de première classe", on peut chercher les fonctions qui possèdent un argument implicite dans leur nom. C'est à dire que :

  • il existe plusieurs implémentations similaires
  • le nom de la fonction indique la différence d'implémentation

La factorisation consiste alors à rendre cet argument explicite, afin de mieux exprimer l'intention, et aussi de réduire la duplication du code. Une autre optimisation, est d'extraire le code dans un callback, qui sera ensuite passé en argument.

Chapitre 11 : fonctions de première classe II

Les fonctions de première classe permettent de réduire la duplication, mais parfois rendent le code plus dur à lire, il ne faut pas trop en abuser et seulement les utiliser là où c'est vraiment nécessaire.

Chapitre 12 : Itérations fonctionnelles

Pour opérer sur les séquences de données, plutôt que de faire boucle, on utilise filter, map et reduce (fold).

Chapitre 13 : Enchaînement des outils fonctionnels

Pour améliorer la lisibilité, on peut donner des noms aux callbacks ou bien découper la chaîne en plusieurs étapes. On applique aussi les outils vus précédemment pour tendre vers un design stratifié. Pour optimiser, plutôt que de chaîner, on peut appliquer plusieurs opération dans un seul callback, cela est appelé "stream fusion".

Chapitre 14

Chapitre 15