PROJET AUTOBLOG


Sam & Max: Python, Django, Git et du cul

Site original : Sam & Max: Python, Django, Git et du cul

⇐ retour index

Le débat sur la programmation fonctionnelle en Python 75

mardi 28 avril 2015 à 09:35

L’écosystème Python est en ébullition depuis la 3.4 et asyncio, tout le monde se sent pousser des ailes de faire évoluer la stack. La 3.5 vient réintroduire le fameux opérateur % pour les bytes, sa disparition ayant fait bien chier tous les développeurs réseaux, on rajoute l’opérateur @ (qui ne fait rien, mais qui servira à numpy pour les opérations sur les matrices), il y a bien entendu le débat sur async/await et enfin le gros morceau : le type hinting.

C’est une période formidable pour la communauté car il rappelle que Python, un langage plus vieux que Java, un langage qui est maintenant considéré comme main stream – utilisé par des sociétés corporate et l’éducation nationale – ne restera pas sur ses acquis et va continuer à se réinventer.

Pour cette même raison, il existe un débat parallèle qui a lieu plutôt sur reddit, hackernews, la mailling list de Python-dev et Python-idea ou par PR github interposées sur des projets type pyrsistent. Il s’agit de faire de Python un langage fonctionnel.

La programmation fonctionnelle, c’est quoi ?

Ce n’est pas juste utiliser des fonctions.

Il y a plusieurs caractéristiques à la prog fonctionnelle, mais la plus importante est que tout est immutable (les données ne peuvent être modifiées), et par corollaire, que rien n’a d’effet de bord.

Ce qui signifie que dans un langage purement fonctionnel, une structure comme la liste n’aurait pas de méthode qui ne retourne rien comme append() ou pop().

liste.append(element) serait remplacé par liste + [element].

element = liste.pop() serait remplacé par liste, element = liste[:-1], liste[-1]

Vous notez qu’on ne modifie pas la liste initiale, on recrée de nouvelles structures de données à chaque fois.

Une alternative serait que ces méthodes retournent une nouvelle liste plutôt que None.

Bref, c’est comme si on avait que des tuples :)

Bien qu’on puisse créer des formes de programmations orientées objet qui n’impliquent pas de mutabilité, dans les faits les implémentations sont pour la plupart basées justement sur le fait qu’on puisse modifier les objets, aussi on oppose souvent FP et OOP.

Une autre caractéristique est qu’on doit pouvoir remplacer toute expression par son résultat suivant sans changer le sens du programme. C’est très élégant :

[1, 2, 3] + [4] peut être réécrit [1, 2, 3, 4], et le programme aura toujours le même sens. Il n’y a pas de notion de référence dont il faut se soucier, seulement les valeurs. Cela élimine naturellement un paquet de bugs et il n’y a pas de bonne pratique à appliquer ou apprendre sur la modification des données puisque le langage vous force à coder proprement.

En programmation fonctionnelle, la récursion est donc souvent mise en avant. Par exemple, supposons qu’on calcule la taille d’une liste ainsi en programmation traditionnelle :

def size(l):
    s = 0
    for x in l:
        s += 1
    return s

Voici une forme possible en programmation fonctionnelle :

def size(l):
    if not l:
        return 0
    return 1 + size(l[1:])

La raison pour cela est qu’on peut substituer chaque expression à son résultat. Avec l = [1, 2, 3]:

size(l) devient size([1, 2, 3]), qui devient 1 + size([2, 3]) qui devient 1 + 1 + size([3]) qui devient 1 + 1 + 1 + size([]) qui devient 1 + 1 + 1 + 0 qui devient 4.

Quelle que soit la forme remplacée, le fonctionnement du programme est rigoureusement identique.

Il devient alors plus facile de prouver qu’un programme marche, de le tester, et surtout, de travailler de manière concurrente : si rien ne se modifie, nul besoin de lock et de synchronisation car rien n’est partagé et toute instruction a toujours le même comportement quel que soit le contexte.

Python possède déjà des features fonctionnelles

Comme en Python on peut programmer en utilisant plusieurs paradigmes, il n’y a rien d’étonnant à ce qu’on retrouve des amateurs de plusieurs écoles dans les core devs et les outils qui vont avec.

Bien que les listes, les sets, les objets et les dictionnaires soient mutables, les tuples, les chaînes et les entiers ne le sont pas. Par ailleurs, les fonctions sont “des citoyens de première classe”, ce qui est considéré comme un pré-requis pour la programmation fonctionnelle. Cette expression signifie qu’on peut manipuler les fonctions elles-mêmes, les créer dynamiquement, les retourner et les passer en paramètre (c.f: les décorateurs).

Même si ce n’est pas explicitement nécessaire pour faire de la programmation fonctionnelle, tout langage fonctionnel sérieux vient avec 5 outils de base :

Python possède ces outils : map() et filter() sont des builtins, reduce() peut être trouvé dans le module functools et les fonctions anonymes peuvent être créés avec le mot clés lambda. Et bien sûr, une fonction peut s’appeler elle-même.

Voyons comment créer une liste de carrés de tous les nombres impairs de 0 à 9 :

carres = []
for x in range(10):
    if x % 2:
        carres.append(x * x)

La version fonctionnelle avec map()/filter() et des fonctions anonymes :

list(map(lambda x: x * x,  filter(lambda x: x % 2, range(10))))

Néanmoins Python va plus loin, puisqu’il propose les listes/sets/dictionnaires en intension, qui ne sont ni plus, ni moins que que map()/filter() intégrés sous forme de syntaxe :

[x * x for x in range(10) if x % 2]

Ce qui est non seulement plus concis mais plus clair et plus performant que les deux versions précédentes.

Donc, malgré des limites que nous verrons ensuite, Python est plutôt bien armé pour la programmation fonctionnelle. La présence des générateurs et tous les outils associés (itertools, functools, yield, yield from) viennent renforcer cet aspect orienté transformation plutôt que modification.

Il est par ailleurs déjà considéré comme une bonne pratique d’éviter les effets de bord le plus possible, d’utiliser des générateurs et des intensions. Bref, Python n’est pas le benêt de la programmation fonctionnelle.

Les limites de Python et de la programmation fonctionnelle

Malgré cela, il existe de sérieuses limites pour faire de Python un langage purement fonctionnel.

D’abord, les listes, les dicos et les sets sont mutables. Ensuite, la récursion est limitée par la taille de la stack (par défaut 1000 récursions), il n’y a aucune tail call optimisation. Enfin, les lambdas sont bridées à une expression.

Ceci n’est pas arrivé par accident, mais est le résultat d’un design du langage imposé par Guido Van Rossum, son créateur et BDFL.

D’abord, il faut bien se souvenir que raisonner pour modifier une structure de données, c’est beaucoup plus facile pour la machine ET l’humain que d’en recréer une.

Les algos de tris in place sont toujours plus rapides, les transformations in place prennent moins de mémoire, et l’accumulation est bien plus facile à concevoir, relire et debugger que la récursion.

J’ai déjà entendu des gens me soutenir que la FP était simple. Ces gens n’ont soit jamais essayé de former d’autres personnes à la programmation, soit sont des formateurs de merde. Je pense qu’au moins ce blog me donne un minimum de crédibilité sur mes qualités de pédagogue et je m’érige donc en tant que prophète de l’intelligibilité :

En vérité, je vous le dis, la programmation fonctionnelle, c’est dur à faire comprendre.

Erlang, Lisp et Haskel ne sont pas user friendly.

Ne me faites pas dire ce que je n’ai pas dis. Ce sont de beaux langages, des outils performants et utiles, mais Python est 100 fois plus facile à utiliser. Il n’y a aucune comparaison possible, ce n’est même pas sur la même échelle sur le graphe.

Ensuite, le monde n’est pas immutable : les choses ont des états à un instant t. En informatique, une connexion réseaux a un état, un système de fichier a un état, une session a un état, etc. Les langages fonctionnels ont donc recours à tout un tas d’astuces et d’enrobages pour les manipuler comme si ce n’était pas les cas. La fameuse “simplicité”, “élégance” et “pureté” qu’ils prônent en prend un coup.

Les meilleurs design patterns fonctionnels peuvent devenir un enfer pour travailler avec, les monades étant l’exemple le plus couramment cité. Déjà que c’est pas évident de faire passer la OOP, les générateurs, les classes et les décorateurs… Bonne chance pour les monades, et merci pour le poisson !

La limite de récursion et de la taille des lambdas est également parfaitement volontaire. Comprenez bien : tous les développeurs n’ont pas (et n’ont pas à avoir) le même niveau. Mettre un géographe devant une récursion avec injection de dépendance est aussi productif que de demander à un programmeur Web de calculer lui-même sa projection mercator.

Si demain la récursion devient plus facile, les dev chevronnés vont massivement l’utiliser même quand ce n’est pas indispensables : donnez un jouet à un geek, et il va s’en servir. Mais la lisibilité est primordiale, c’est une feature de Python. Les codes deviendront alors soudainement significativement plus durs à lire et à débugger, pour un gain minime. En effet, la récursion ne m’a JAMAIS manqué en Python. Les développeurs ne sont pas raisonnables, ils ne vont jamais se dire, je vais m’assurer que ce code sera facile à lire pour un débutant ou moi sans café, ils vont utiliser tous les outils à leur disposition. Guido a donc volontairement limité les outils à ce qui garde un code facile à appréhender.

C’est ce qui a fait le succès de Python jusqu’à présent. C’est un langage facile à prendre en main, et productif. Lire le code d’un autre est simple.

Lire le code Lisp d’un autre n’est PAS simple.

Une stack trace d’une erreur en pleine récursion est pleinement inutile.

Le callback hell de Javascript plein de fonctions anonymes en cascade est dégueulasse.

Vous allez me répondre : “les gens n’ont qu’a programmer proprement” où “il y a des outils pour ça”

Outre le fait que pouvoir coder en Python avec juste notepad et la console est une des ses meilleures features, c’est ignorer la nature humaine.

On pourrait penser que quelque chose d’aussi simple qu’une indentation propre et un espacement consistant est la base.

Mais non.

Jetez un œil à n’importe quel programme JS ou PHP de petite ou moyenne taille, et vous trouverez invariablement des blocs de traviole, des variables globales, des espaces qui dégueulent à droite et à gauche et des saloperies en tout genre.

Python force l’indentation. Python a un PEP8.

J’adore ça.

Ca amène les porcs à coder plus proprement.

C’est pareil pour les lambdas et la récursion. On force la main aux gens déraisonnables pour qu’ils ne fassent pas de connerie. Désolé, je n’ai pas confiance dans les barbus pour créer des choses user friendly. Et moi, j’aime ça, le code accessible. Fuck la pureté.

Si vous voulez des trucs tarabiscotés, Lisp le fera très bien.

Mais ce n’est pas Lisp qui a autant de succès, c’est Python. Et vous savez pourquoi ?

Parce qu’il est productif, facile, lisible, et avec une énorme communauté pleine de diversité car il est accessible à tout ce monde.

Bref, vous l’avez compris, je suis contre faire de Python un langage purement fonctionnel si c’est au prix de tout cela.

Maintenant, il existera peut être des moyens techniques pour rendre des implémentations d’outils fonctionnels aussi performants et lisibles que leurs versions non fonctionnelles.

Ca a été le cas avec les intensions : de longues chaines de map() et filter() illisibles deviennent claires avec une belle intension. Et c’est clairement un gain par rapport au for + append() traditionnel. Mais pour les cas moins simple, on a toujours la possibilité d’utiliser cette méthode, qui est facile à lire, à comprendre et à débugger.

Je gage qu’un compromis intelligent, voire carrément malin, puisse être trouvé en la matière.

Mais s’il vous plait, ne cassez pas Python juste pour avoir un nouveau jouet, vous scierez la branche sur laquelle nous sommes tous assis en train de boire l’apéro en rigolant.

C’est un reproche qui a été fait au type hinting : si on offre cette possibilité, elle sera utilisée, et tout code avec ces annotations est significativement plus moche. Si demain la majorité des projets en font un pré-requis, beurk.

Je ne pense pas que ça arrivera car c’est un sacré effort supplémentaire de les écrire. Donc on le fera uniquement si ça vaut le coup : par exemple un gros projet ou une lib avec une bonne base d’utilisateur.

Ce n’est pas le cas pour la programmation fonctionnelle : son coût d’écriture est beaucoup plus bas que celui de la relecture. On paie plus tard. Et ça les gens ne savent pas gérer. L’humain est naze pour investir dans le futur, c’est pour ça que nous consommons les ressources de notre planète comme des goinfres, qu’on pollue à foison et qu’on laisse des dirigeants voter des lois liberticides.

Bonnes pratiques

Bien que je suis clairement en faveur de mettre un frein à l’enthousiasme des programmeurs FP tant qu’un terrain d’entente intelligent ne sera pas trouvé, ça ne veut pas dire qu’on ne peut pas bénéficier de leurs propositions.

En effet, sachez que vous serez un meilleur programmeur, et que votre programme gagnera en qualité, si vous appliquez quelques principes de programmation fonctionnelle et utilisez les outils déjà existant en Python pour en tirer parti.

Le tout est de ne pas en abuser, et surtout, de ne pas s’interdire de pondre un code bête et simple quand c’est plus rapide ou plus clair.

D’abord, limiter les effets de bord est toujours une bonne chose.

Si vous vous trouvez à modifier une structure de données dans une fonction alors qu’elle l’a reçue en paramètre, réfléchissez à deux fois. Est-ce vraiment nécessaire ? Ne pouvez-vous pas faire autrement. A part pour quelques cas très précis (tri sur place, passes multiples, cache, etc), il rare d’avoir à le faire.

Utilisez les intensions et les générateurs. Utilisez itertools. Utilisez functools. Ces outils demandent un peu d’apprentissage, mais ils sont formidables : plus performants, plus expressifs, plus lisibles.

Il vous faudra un peu de pratique pour trouver un bon équilibre. Arrive un point où on les utilise partout, ce qui rend le debuggage plus difficile. Passé ce stade, revenez en arrière, calmez-vous, respirez, retourner à l’impératif, ce n’est pas non plus retourner au visual basic.

Quand vous faites de la programmation concurrente, voyez si vous ne pouvez pas isoler vos états derrière des services, et faites communiquer les services entre eux via des messages immutables. Votre application sera plus claire, plus stable.

D’une manière générale, il est bon de diviser son code en petites unités, fonctionnelles justement, qui se passent des valeurs les unes aux autres.

Mais au final, n’oubliez pas la définition d’un bon code :

Le reste n’est que drosophilie sodomite.