Programmation fonctionnelle en C++

| John Carmack | Traduction par Arzar |

Tous ceux qui nous lisent ont déjà probablement entendu parler de la « programmation fonctionnelle » présentée comme pouvant apporter des avantages au développement logiciel ou même vantée comme étant un remède miracle. Toutefois, une visite sur Wikipédia pour plus d'informations peut être au premier abord rebutant, avec dès le départ des références au lambda-calcul et aux systèmes formels. Il n'est pas clair immédiatement de voir ce que cela apporte pour écrire de meilleurs logiciels.
Retrouvez l'ensemble des articles de John Carmack sur la page d'index.

N'hésitez pas à commenter cet article ! Commentez

- Titre : Programmation fonctionnelle en C++

- Auteurs : John Carmack, Traduction par Arzar

- Parution : 5 décembre 2012

- Licence : Copyright ® 2012 John Carmack. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Introduction

- II. Fonctions pures

- III. La pureté en pratique

- IV. Programmation orientée objet

- V. Implications sur les performances

- VI. Mesures à prendre

- VII. Remerciements

I - Introduction

Tous ceux qui nous lisent ont déjà probablement entendu parler de la « programmation fonctionnelle » présentée comme pouvant apporter des avantages au développement logiciel ou même vantée comme étant un remède miracle. Toutefois, une visite sur Wikipédia pour plus d'informations peut être au premier abord rebutante, avec dès le départ des références au lambda-calcul et aux systèmes formels. Il n'est pas clair immédiatement de voir ce que cela apporte pour écrire de meilleurs logiciels.

Mon résumé pragmatique : une grande partie des erreurs dans le développement logiciel sont dues aux programmeurs qui ne comprennent pas bien tous les états possibles dans lequel leur code peut s'exécuter. Dans un environnement multithread, le manque de compréhension et les problèmes qui en résultent sont grandement amplifiés, presque sur le point de la panique si vous n'êtes pas attentif. Programmer dans un style fonctionnel rend explicite l'état manipulé par votre code, il devient plus aisé de raisonner à son sujet et, dans un système totalement pur, il ne peut plus y avoir de situation de compétition entre threads.

Je crois qu'il y a une réelle valeur à utiliser la programmation fonctionnelle, mais il serait irresponsable d'encourager tout le monde à abandonner son compilateur C++ et à commencer à coder en Lisp, Haskell, ou, pour être franc, tout autre langage marginal. Au grand dam des concepteurs de langage, il y a de nombreux facteurs externes pouvant être plus importants que les bénéfices d'un langage et le développement de jeux vidéo en présente de nombreux. Nous avons des problèmes multiplateformes, des chaînes d'outils propriétaires, des barrières de certification, des technologies sous licence, des exigences de performance strictes et par-dessus le marché des bases de code ancien ainsi que la disponibilité en main-d'œuvre dont tout le monde fait face.

Si vous êtes dans des circonstances où vous pouvez entreprendre un développement significatif dans un langage non traditionnel, je vous y encourage, mais soyez prêt à prendre quelques coups au nom du progrès. Pour tous les autres : peu importe dans quel langage vous travaillez, la programmation dans un style fonctionnel offre des avantages. Vous devriez le faire partout où c'est pratique. Et là où ce n'est pas pratique, vous devriez réfléchir longuement à votre décision. Vous pouvez en apprendre davantage sur les lambdas, les monades, la curryfication, la composition de fonctions évaluées paresseusement sur des ensembles infinis et tous les autres aspects des langages explicitement orientés fonctionnels plus tard si vous le choisissez.

Le C++ n'encourage pas la programmation fonctionnelle, mais il ne l'empêche pas non plus, tout en gardant la possibilité de faire du bas niveau, d'appliquer des instructions SIMD sur des données agencées la main et conservées dans des fichiers mappés en mémoire. Ou toutes autres bonnes choses sérieuses et difficiles dont vous avez besoin.

II - Fonctions pures

Une fonction pure ne porte que sur les paramètres qu'on lui passe et elle se borne à retourner une ou plusieurs valeurs calculées sur la base de ces paramètres. Elle n'a pas d'effets de bord logique. C'est une abstraction, bien sûr ; chaque fonction a des effets de bord au niveau du CPU et la plupart au niveau du tas, mais l'abstraction a toujours de la valeur.

Elle n'accède pas ou ne met pas à jour d'état global. Elle ne conserve pas d'état interne. Elle n'effectue aucune entrée ou sortie. Elle ne modifie aucun des paramètres d'entrée. Idéalement, elle ne prend en paramètre aucune donnée superflue – passer un pointeur allMyGlobals irait à l'encontre du but recherché.

Les fonctions pures ont de nombreuses propriétés intéressantes.

Thread safety. Une fonction pure avec des paramètres passés par valeur est complètement thread-safe. Avec des paramètres passés par référence ou par pointeur, même s'ils sont constants, vous devez être conscient du danger qu'un autre thread effectuant des opérations non pures puisse muter ou libérer ces données, mais ça reste quand même l'un des plus puissants outils à votre disposition pour écrire du code multithread correct.

Vous pouvez trivialement les remplacer par des implémentations parallèles ou exécuter de multiples implémentations afin de comparer les résultats. L'expérimentation et l'évolution sont beaucoup moins risquées avec des fonctions pures.

Réutilisation. Il est beaucoup plus simple de copier une fonction pure vers un nouvel environnement. Il faut encore se charger des définitions de type et des fonctions pures appelées, mais il n'y a pas d'effet boule de neige. Combien de fois avez-vous eu connaissance d'un bout de code provenant d'un autre système, qui fait ce dont vous avez besoin, mais le sortir de toutes ses dépendances environnementales donnait plus de travail que simplement le réécrire ?

Testabilité. Une fonction pure possède une transparence référentielle, ce qui signifie qu'elle produira toujours le même résultat pour un ensemble de paramètres donné, peu importe le moment auquel elle est appelée, ce qui la rend beaucoup plus facile à utiliser qu'une fonction entremêlée avec d'autres systèmes. Je n'ai jamais été très diligent pour écrire du code de test ; une grande partie du code interagit avec suffisamment de systèmes pour qu'il devienne nécessaire de créer des scripts de test sophistiqués pour fonctionner et je n'ai pas souvent réussi à me convaincre (probablement à tort) que ça en valait la peine. Les fonctions pures sont très simples à tester ; les tests semblent sortir tout droit d'un manuel, où l'on construit des entrées et examine la sortie. Maintenant, à chaque fois que je tombe sur un bout de code récalcitrant, je le sépare en plusieurs fonctions distinctes pures et écris des tests pour elles. C'est effrayant, mais je trouve souvent quelque chose de faux dans ces situations, ce qui veut probablement dire que je laisse passer trop de choses à travers les mailles du filet.

Compréhensibilité et maintenabilité. Le lien entre les entrées et sorties permet aux fonctions pures d'être plus facile à réapprendre lorsque cela est nécessaire et il y a moins de places pour que des conditions non documentées concernant l'état externe puissent se cacher.

Les systèmes formels et le raisonnement automatisé seront de plus en plus importants à l'avenir. L'analyse de code statique est importante aujourd'hui et transformer votre code dans un style plus fonctionnel aide les outils d'analyse – ou du moins permet aux outils rapides locaux de concurrencer les outils globaux plus lents et plus coûteux. Nous sommes dans une industrie du « faut que ça soit fait » et je ne vois pas les preuves formelles sur la justesse d'un programme entier devenir un objectif pertinent. Mais être capable de prouver que certaines catégories d'erreurs ne sont pas présentes dans certaines parties d'une base de code sera toujours très utile. Nous pourrions utiliser un peu plus de science et de mathématiques dans nos processus.

Quelqu'un assistant à un cours d'introduction à la programmation pourrait se gratter la tête et penser « tous les programmes ne sont pas censés être écrits comme ça ? ». La réalité, c'est que beaucoup plus de programmes sont des grosses boules de boue qu'autre chose. Les langages de programmation impérative traditionnels vous donnent des sorties de secours et celles-ci sont utilisées tout le temps. Si vous êtes en train d'écrire du code jetable, faites ce qui est le plus commode, ce qui implique souvent des états globaux. Si vous écrivez du code qui peut être encore en cours d'utilisation un an plus tard, trouvez un équilibre entre ce qui est commode et les difficultés dont vous allez inévitablement souffrir plus tard. La plupart des développeurs ne sont pas très bons pour prédire les souffrances futures que leurs changements vont générer.

III - La pureté en pratique

Tout ne peut pas être pur, à moins que le programme n'opère que sur son propre code source. À un moment donné, vous avez besoin d'interagir avec le monde extérieur. Il peut être amusant, comme lorsqu'on résout un puzzle, d'essayer de pousser la pureté jusqu'à son maximum, mais vient toujours un moment où les effets de bords sont nécessaires et il faut alors les gérer efficacement.

Il n'est même pas nécessaire d'imposer du tout ou rien au niveau de la pureté : une fonction peut être pure à différents degrés et l'écart entre une fonction parfaitement pure et une fonction presque pure est bien moindre que celui qui sépare cette dernière d'une fonction codée en "spaghetti". Faire avancer une fonction vers la pureté améliore le code, même si elle n'atteint pas la pureté complète. Une fonction qui incrémente un compteur global ou vérifie un flag de debug global n'est pas pure, mais si c'est le seul écart, elle bénéficiera tout de même de la plupart des avantages.

Dans un contexte plus large, éviter le pire est généralement plus important qu'atteindre la perfection dans des cas limités. Si vous examinez les fonctions ou les systèmes les plus toxiques que vous avez eu à traiter, ceux dont vous savez qu'ils doivent être manipulés avec des pinces et un masque, il y a fort à parier qu'ils reposent sur un réseau complexe d'état et d'hypothèses dont dépend leur comportement. Et cela ne se limite pas à leurs paramètres. Imposer une certaine discipline dans ces domaines, où tout au moins se battre afin d'éviter que davantage de code se transforme en pagaille similaire, va avoir plus d'impact que de nettoyer quelques routines mathématiques de bas niveau.

Le processus de refactorisation vers la pureté implique généralement de séparer les calculs de l'environnement dans lequel ils opèrent, ce qui signifie quasi invariablement de passer plus de paramètres. Cela peut sembler un peu curieux – une plus grande verbosité dans les langages de programmation est largement conspuée et la programmation fonctionnelle est souvent associée à une réduction de taille de code. Les facteurs qui permettent aux programmes écrits en langages fonctionnels d'être parfois plus concis que les implémentations impératives sont à peu près orthogonales à l'utilisation des fonctions pures – garbage collector, puissants types fondamentaux, pattern matching, list comprehensions, composition de fonctions, différents morceaux de sucre syntaxique, etc. Pour la plupart, ces facteurs de réduction de taille n'ont pas grand-chose à voir avec le fonctionnel et peuvent être trouvés également dans certains langages impératifs.

Vous devez vous irriter si vous avez à passer une douzaine de paramètres à une fonction, mais vous pouvez peut-être refactoriser le code d'une manière réduisant la complexité des paramètres.

L'absence de tout support par le langage C++ pour maintenir la pureté n'est pas idéale. Si quelqu'un modifie une fonction basique largement utilisée en la rendant non pure de manière pernicieuse, tous ceux qui utilisent la fonction perdent aussi leur pureté. Cela semble désastreux d'un point de vue des systèmes formels, mais encore une fois, ce n'est pas du tout ou rien, où l'on tombe en disgrâce dès le premier péché. Le développement logiciel à grande échelle est malheureusement statistique.

L'introduction d'un mot-clef « pure » au niveau des langages C et C++ semble très intéressante. Il y a des parallèles étroits avec const – un qualificatif optionnel qui permet la vérification à la compilation de l'intention de programmeur, sans ne jamais faire de mal et qui pourrait souvent aider la génération de codes. Le langage de programmation D offre le mot-clé « pure » : D Programming Language – Functions. Notez la distinction faite entre pureté faible et forte – vous devez également avoir des références d'entrée et de pointeurs const pour être fortement pur.

À certains égards, un mot-clé du langage est trop restrictif – une fonction peut encore être pure, même si elle appelle des fonctions impures, aussi longtemps que les effets de bord ne s'échappent pas de la fonction extérieure. Des programmes entiers peuvent être considérés comme des unités fonctionnelles pures s'ils ne traitent qu'avec des paramètres de la ligne de commande et pas avec des états aléatoires du système de fichiers.

IV - Programmation orientée objet

Michael Feathers @mfeathers : la programmation orientée objet rend le code compréhensible en encapsulant les parties mouvantes. La programmation fonctionnelle rend le code compréhensible en minimisant les parties mouvantes.

Les « parties mouvantes » sont des mutations d'états. Dire à un objet de se changer lui-même, c'est la leçon numéro un dans un livre élémentaire de programmation orientée objet et c'est profondément enraciné chez la plupart des programmeurs, mais c'est un comportement antifonctionnel. Clairement, il y a une certaine valeur dans l'idée de base de la POO qui est de regrouper les fonctions avec les structures de données sur lesquelles elles opèrent. Mais si vous voulez profiter des avantages de la programmation fonctionnelle dans certaines parties de votre code, vous devez faire marche arrière sur certains comportements orientés objet dans ces domaines.

Les méthodes de classe qui ne peuvent pas être constantes ne sont pas pures par définition, parce qu'elles modifient tout ou partie de l'ensemble potentiellement important des états contenus dans l'objet. Elles ne sont pas thread-safe et leurs manières de pousser par petit coup les objets dans des états inattendus sont en effet une source importante de bugs.

Les méthodes objet constantes peuvent encore être techniquement pures si vous ne comptez pas le pointeur implicite const this, mais beaucoup d'objets sont suffisamment larges pour qu'ils constituent une sorte d'état global qui leur est propre, obscurcissant quelques-uns des avantages de la clarté des fonctions pures. Les constructeurs peuvent être des fonctions pures et plus généralement devraient s'efforcer de l'être – ils prennent des arguments et retournent un objet.

Au niveau tactique de la programmation, vous pouvez souvent travailler avec des objets d'une manière plus fonctionnelle, mais cela peut exiger d'en modifier un peu les interfaces. Chez id, nous avons traversé plus d'une décennie avec une classe idVec3 contenant une méthode automodifiante void Normalize(), mais aucune méthode correspondante idVec3 Normalized() const. Beaucoup de méthodes de string avaient été définies de la même manière, en opérant sur elles-mêmes, plutôt qu'en retournant une nouvelle copie avec l'opération effectuée – ToLowerCase(), StripFileExtension(), etc.

V - Implications sur les performances

Dans presque tous les cas, la modification directe de blocs de mémoire est l'approche optimale pour atteindre la vitesse de la lumière et faire autrement consomme un peu de performance. La plupart du temps, cela ne présente qu'un intérêt théorique, car nous échangeons tout le temps des performances contre de la productivité.

La programmation avec des fonctions pures implique plus de copie de données et dans certains cas, cela rend cette stratégie d'implémentation clairement incorrecte pour des raisons de performance. À titre d'exemple extrême, vous pouvez écrire une fonction pure DrawTriangle() qui prend un framebuffer comme paramètre et retourne un framebuffer complètement nouveau avec le triangle dessiné dessus comme résultat. Ne le faites pas.

Tout retourner par valeur est le style naturel en programmation fonctionnelle, mais toujours se fier aux compilateurs pour effectuer la return value optimization peut être dangereux pour les performances, donc retourner une référence sur des structures de données complexes est souvent justifié. Cela a malheureusement pour effet de vous empêcher de déclarer la valeur retournée comme constante pour imposer l'affectation unique.

Il y aura de fortes envies dans de nombreux cas de simplement mettre à jour une valeur dans une structure complexe, plutôt que de faire une copie de celle-ci et retourner la version modifiée, mais cela ruine les garanties de thread safety et ne doit pas être fait à la légère. La génération de listes est souvent un cas où c'est justifié. La manière purement fonctionnelle d'ajouter quelque chose à une liste est de retourner une copie entièrement nouvelle de la liste plus le nouvel élément à la fin, laissant inchangée la liste originale. Les langages fonctionnels existants sont implémentés de manière à rendre cette manière de faire pas aussi catastrophique que cela puisse paraître, mais si vous le faites avec des conteneurs C++ typiques vous allez mourir.

De nos jours, un facteur atténuant important vient du fait que performance signifie programmation parallèle, ce qui nécessite généralement plus de copie et de combinaison que dans un environnement mono-thread, même dans un cas de performances optimales. La pénalité est donc plus petite, tandis que la réduction de la complexité et les avantages de justesse sont proportionnellement plus grands. Lorsque vous commencez à envisager de déplacer par exemple tous les personnages d'un monde de jeu en parallèle, il commence à s'installer en vous que l'approche orientée objet de mise à jour des objets présente de profondes difficultés dans des environnements parallèles. Peut-être que si tous les objets utilisent simplement une référence vers une version en lecture seule de l'état du monde et que nous copions par-dessus la version mise à jour à la fin de la frame… Hé, attendez une minute…

VI - Mesures à prendre

Faites une inspection de certaines fonctions non triviales dans votre base de code et traquez tous les éléments d'état externe auxquels elles peuvent accéder, ainsi que toutes les modifications potentielles qu'elles peuvent faire. Cela donne une remarquable documentation à placer dans un bloc de commentaires, même si vous n'en faites rien avec. Si la fonction peut déclencher, par exemple, une mise à jour de l'écran par le biais de votre système de rendu, vous pouvez jeter vos mains en l'air et déclarer l'ensemble de tous les effets possibles au-delà de la compréhension humaine.

Lors de la prochaine tâche que vous entreprendrez, essayez depuis le début de réfléchir en terme du calcul réel de ce qui se passe. Collectez vos entrées, passez-les à une fonction pure, puis prenez les résultats et faites quelque chose avec.

Lorsque vous deboguez du code, rendez-vous plus conscients des mutations d'états et des paramètres cachés qui jouent à obscurcir ce qui se passe.

Modifiez certains de vos codes d'objet utilitaire pour retourner de nouvelles copies au lieu de s'automodifier et essayez de flanquer du const en face de pratiquement toutes les variables non-itérateur que vous utilisez.

Références additionnelles :

- HaskellWiki - Introduction ;

- lisperati.com/ ;

- How to get started with functional programming ;

- Why Functional Programming Matters ;

- C9 Lectures: Functional Programming Fundamentals ;

- Yet Another Haskell Tutorial ;

- Can Programming Be Liberated from the von Neumann Style? A Functional Style and Its Algebra of Programs ;

- The Downfall of Imperative Programming.

VII - Remerciements

Cet article est une traduction autorisée de Functional Programming in C++ par John Carmack.

Merci à Ekleog, à JolyLoic et à Jean-Marc.Bourguet pour leur relecture technique, à zoom61, à Kalith et à ced pour leur relecture orthographique.