Banner

Programmation fonctionnelle

Posted by alpheccar - Feb 11 2005 at 22:37 CEST

Pourquoi les langages comme LISP, Scheme, Ocaml ou Haskell sont infiniments supérieurs à C, C++, Java ? Ou, qu'est-ce qu'un langage fonctionnel ?

Avant de démarrer une guerre de religion, je précise que si ce texte se focalise essentiellement sur Ocaml ce n'est pas parce que les autres langages fonctionnels seraient mauvais. Ils ont chacun des avantages et des inconvénients. Selon le type d'application que l'on vise, on pourra être amené à faire un choix différent. J'ai choisi Ocaml et donc ce texte parle surtout des langages fonctionnels (purs ou non) statiquement et fortement typés.

Fonctionnel

En maths,


f(x) + f(x) = 2f(x)

De même,


f(x) + g(x) = g(x) + f(x)

Avec des langages comme Java ou C, ce n'est pas forcément vrai. En effet, toutes les fonctions, dans ces langages, sont implicitement dépendantes de l'état du monde (de la machine). Une fonction f(x) doit ainsi être considéré comme signifiant f(x,W)W est l'état de la machine (variable globales, contenu du heap ...).

Après exécution de f, l'état global peut avoir été modifié : on appelle cela un effet de bord. C'est pourquoi f(x) + f(x) n'est pas forcément égal à 2f(x).

Ainsi, le problème fondamental dans les langages comme le C est la notion d'état qui rend la mise au point des programmes très difficile. Cette notion d'état est directement liée à la notion de temps. Dans les langages traditionnels (C, Java etc...) on interprète une fonction (au sens C ou Java) comme un processus s'exécutant dans le temps et modifiant peu-à-peu l'état de la machine.

Or, en maths, une fonction f ne dépendant pas implicitement d'un état ou du temps:


f(x) + f(x)  

est


2f(x) 
f(x) + g(x) 

est


g(x) + f(x)

Il n'y a pas d'effet de bord. Le passage de f(x) + f(x) à 2f(x) ne s'interprète plus comme un processus s'exécutant dans le temps mais comme une simplification algébrique. La notion de temps disparaît.

Un langage fonctionnel est un langage qui permet de manipuler des objets qui se comportent comme des fonctions mathématiques au sens où il n'y pas d'effets de bord et où il ne faut plus interpréter l'exécution d'un programme comme un processus qui s'exécute dans le temps mais comme un processus de simplification algébrique (même si au niveau implémentation on a vraiment un processus s'exécutant dans le temps. Dans ce texte, je ne m'intéresse pas à la façon dont les langages fonctionnels sont implémentés mais juste à leur sémantique).

Certains langages fonctionnels (Haskell) sont purs et la notion d'état est totalement absente du langage. D'autres langages (Ocaml) permettent, lorsque le programmeur le décide, d'utiliser la notion d'état avec tous les risques liés aux effets de bord que cela comporte.

Lorsque on utilise la partie pure du langage (sans état) on a la propriété de transparence réferentielle : on peut remplacer f(x) par sa définition n'importe où et sans risque. C'est une façon différente de dire qu'"exécuter" un langage fonctionnel c'est procéder à une simplification algébrique.

Voyons sur des exemples ce que cela implique:

Si je définis la fonction add par:


 let add x y = 2 + x + y ;;

add 3 4 peut-être remplacé par sa définition ce qui donne : 2 + 3 + 4

Le premier + peut-être remplacé par sa définition, ce qui donne 5 + 4

Ce dernier + peut-être remplacé par sa définition, ce qui donne 9.

Si j'insiste sur la vision simplification algébrique plutôt que sur une vision classique de processus s'executants dans le temps c'est parce que c'est la bonne façon de voir les choses. En effet, si j'écris:


let g = add 3 ;;

Comment interpréter g ? C'est la fonction obtenue en simplifiant add 3. Donc, g est équivalent à la fonction suivante:


let g y = 5 + y ;;

On a une évaluation partielle de add qui permet de construire g. Cette possibilité d'évaluation partielle explique aussi qu'en programmation fonctionnelle on écrira add x y plutôt que add (x y).

Essayons quelque chose de plus compliqué:


let incr a = function x -> x + a ;;
let add3 = incr 3 ;;

Que font donc ces fonctions ?

La première retourne une fonction dont le rôle est d'additioner a à son argument. Ainsi, incr 3 est une fonction qui ajoute 3 à son argument.

On peut le vérifier en utilisant une simplification algébrique:


 add3 4 

est équivalent à


(incr 3) 4 

(incr 3) 4 

est équivalent à :


 (function x -> 3 + x) 4

qui est équivalent à


 3 + 4

Ainsi, dans les langages fonctionnels, les fonctions sont des types primitifs que l'on peut utiliser comme n'importe quel autre objet. Une fonction peut calculer (construire) une nouvelle fonction et la retourner comme résultat.

Mais, une fonction peut aussi utiliser des fonctions en arguments:


 let rond f g = function x - > f (g x) ;;

rond est la fonction qui à f et g associe la nouvelle fonction f o g définie par (f o g)(x) = f (g x). C'est une fonction d'ordre supérieur.

Ainsi, en langage fonctionnel : une fonction est non seulement un type primitif qui peut-être manipulé mais une fonction mais aussi un objet algébrique qui est simplifié pour parvenir au résultat final que le logiciel est censé calculer.

Là où cela devient intéressant c'est lorsqu'on essaie d'interpréter les structures de test (if/then/else) et la récursivité dans ce cadre. Tout ce que je viens de dire est en effet totalement généralisable et on peut définir une algèbre des programmes où le programme est un objet algébrique qui peut-être manipulé, simplifié etc...

Pour en savoir plus, il y a le fameux livre : Algebra of Programming.

Cette algèbre des programmes fait fortement usage de la théorie des catégories. Or, comme on le sait, les flèches en théorie des catégories sont typées. Ce qui nous mène au point suivant.

Fortement typé

Le but d'un type est de donner statiquement une approximation du comportement dynamique. C'est de pouvoir, rien qu'en regardant le source code, déterminer ce qui va se passer lorsqu'on va l'exécuter (le simplifier). Le but est bien entendu de pouvoir détecter des erreurs avant même que le logiciel soit exécuté.

En raison de résultats théoriques, on sait que l'analyse statique ne permettra jamais de savoir tout ce qui est susceptible de se produire en pratique. Donc, il faut trouver un compromis : un système de type trop faible laissera passer trop d'erreurs mais un système de type trop fort risque d'interdire certains programmes parfaitement corrects mais rejetés par le système de type car il n' a pas pu déterminer avec certitude que le programme fonctionnerait une fois exécuté. Dans ce dernier cas : le système de type va réduire l'expressivité du langage.

Le problème avec les types c'est qu'il faut souvent annoter le source code.

Mais, il existe un système de type particulier (Hindley-Milner) qui ne nécessite pas d'annotations : le compilateur est capable de déterminer les types tout seuls. C'est le système utilisé par Ocaml.

Ainsi, si je tape:


 let add x y = x + y ;;

Ocaml retourne:


 val add : int -> int -> int

La signification est la suivante : le premier argument est un entier, le deuxième aussi et le résultat aussi. Ocaml n'écrit pas le type sous la forme (int,int) -> int et nous en avons vu les raisons précédemment.

Si j'écris maintenant:


 let id x = x ;;

Ocaml va retourner :


 val id : 'a -> 'a 

Autrement dit, Ocaml génére une variable de type 'a et une contrainte : le résultat de la fonction a le même type que son argument. On dit qu'on a un type polymorphe. Ces contraintes vont être résolues ailleurs dans le source code.

Par exemple, si plus loin, Ocaml rencontre:

id 1, il va en déduire que 'a vaut int dans ce cas. Si plus loin il rencontre id "hello", il va en déduire que 'a vaut string dans ce cas.

Le vérificateur de type de Ocaml va donc résoudre des équations de type.

Ainsi, dans le système de type Ocaml, on n'a aucune annotation à écrire et les types sont inférés par le système. Grâce au polymorphisme on a une grande flexibilité et donc, tout en étant fortement typé, ce langage est beaucoup plus simple à écrire qu'un langage comme Java ou C ou les casts de type et les annotations font perdre en productivité.

Le système de type d'Ocaml est incroyablement efficace. A tel point que, en pratique, le plus difficile est de compiler le logiciel qui fonctionne en général du premier coup. Et si c'est le cas, c'est certes parce que le système de type est bien conçu. Mais, il ne détecte pas tous les problèmes possibles. L'autre raison de son efficacité est que, par les contraintes qu'il impose, il force en général le programmeur à mieux penser. Il le guide vers les bon concepts et les bonnes abstractions.

Le système de types d'Ocaml est donc consistant. Mais qu'est-ce que cela veut dire ?

On a vu que le type de id est 'a -> 'a.

En fait, le type de id est:


Forall x, x => x

Le type de id est un prédicat logique.

Appliquer id à un argument, cela revient à se servir d'un axiome particulier du calcul des prédicats qui permet de spécialiser la variable x.

Ainsi, chaque expression de type est une expression logique. Chaque construction du programme correspond à une règle de la logique correspondante. Le programme est donc l'encodage d'une preuve logique permettant de passer de certaines expressions de types à d'autres.

Faire une vérification de type du programme c'est vérifier que la preuve est correcte. Cela n'est possible que si la logique est consistante ce qui n'est pas le cas avec les systèmes de type du C par exemple.

Ainsi, chaque logique consistante peut-être vue comme un système de type particulier à partir duquel on peut construire un langage de programmation. C'est la correspondance de Curry-Howard mais c'est une autre histoire.

Conclusion

Ce qui fait donc la supériorité d'un langage fonctionnel c'est d'avoir les fonctions comme type primitif ce qui permet un plus haut niveau d'abstraction et une plus grande expressivité. Le programmeur n'a pas à se soucier de détails d'implémentation (comme le risque d'avoir des effets de bord) et il peut se reposer sur des propriétés algébriques vérifiées par son code.

Le style fonctionnel est plus déclaratif qu'impératif. Au lieu de devoir ordonner (style impératif) à l'ordinateur de changer des états dans un certain ordre, on se contente plus d'exprimer des relations (fonctionnelles) entre des grandeurs. Les moyens fournis pour exprimer ces relations sont plus variés et plus abstraits ce qui rend les algorithmes plus élégants et plus concis.

J'ai gardé le meilleur pour la fin : les langages fonctionnels comme Scheme ou Ocaml génèrent du code qui est rapide ! Ce n'est pas parce que les langages fonctionnels permettent de coder à un niveau plus élevé d'abstraction qu'ils sont plus lents.

Les logiciels

No tags

Comments

Add a comment...

Tout à fait d’accord

Posted by alpheccar - Sep 29 2004 at19:12 CEST

Tout à fait d’accord avec toi.

En l’occurrence

Posted by wapiti - Sep 29 2004 at12:06 CEST

La lisibilité : je me demande parfois si ce n’est pas un problème de formatage vu que ces langages ne sont pas forcément enseignés tout de suite. Mais comme tu dis que ce n’est pas à cause de raisons syntaxiques, j’avoue que je ne comprends pas trop ce point.

En l’occurrence, j’ai quasiment commencé la programmation avec caml, donc ce n’est pas une question de formatage. Ce que je veux dire, c’est qu’une séquence d’instruction simple est plus lisible qu’une combinaison complexe de fonctions. J’ai l’impression tu épures l’idée, et plus on doit réfléchir pour la comprendre à partir du code ... mais enfin de toutes façons, un algo complexe se comprend difficilement sans doc, qu’il soit en C ou en Caml, et c’est vrai que dans ce cas, l’usage du langage fonctionnel permet que le code ressemble plus à la description mathématique de l’algo.

Les GUIs : C’est vrai que c’est plutôt difficile à gérer avec des langages purs et je n’ai pas encore vu de belle interface purement fonctionnelle pour les GUIs. Néanmoins, ce n’est pas impossible : par exemple Haskell via le concept de Monade permet d’encapsuler les modifications d’états et les rendre invisibles au monde fonctionnel. Concurrent Clean utilise un système de types particulier. Mais de toute façon, avec Ocaml il n’y a pas de problèmes vu qu’on peut choisir entre la programmation fonctionnelle pure et celle avec effets de bord.

Là je m’interroge sur la pertinence de cacher les modifications d’état. Une GUI, c’est essentiellement ça : des modifications d’Etat reliées à des événements. Mais bon je ne connais pas les solutions fonctionnelles, les solutions trouvées sont sans doutes élégantes.

Mais franchement, quand je vois comment on code dans l’industrie, je me dis que finalement c’est probablement une qualité plus qu’un défaut. Si les gens réflechissaient un peu plus avant de coder, ça éviterait probablement de perdre un temps incroyable en debug et maintenance.

C’est en partie pour ça que j’ai abandonné l’info. J’ai l’impression que ça va trop vite, qu’on ne prend pas le temps de réfléchir avant de coder, mais le problème, c’est d’avoir suffisemment de codeurs compétents. N’importe qui n’est pas capable de pondre du beau code en Caml. Or l’info évolue à toute vitesse, il faut donc se contenter d’à peu près ... Je suppose qu’un jour ou l’autre, il faudra que l’informatique évolue vers quelque chose de plus rigoureux. Mais la facilité d’approche de cette technologie fait qu’elle se développe très vite et pas forcément très bien. Dans n’importe quel autre domaine, il y a une assez grosse barrière à l’entrée pour commencer à pondre quelque chose, et ça marche complètement ou ça ne marche pas du tout (enfin presque). Il n’y a qu’en informatique où on peut sans grandes connaissances, créer un produit qui marche à moitié. J’espère qu’un jour ça se calmera. Déjà, l’augmentation de la puissance du hardware commence un peu à se tasser, ce qui fait qu’on va peut-être recommencer à se dire qu’il faut optimiser les softs (car c’est quand même assez paradoxal de se rendre compte que bien que la puissance ait constamment augmenté, le temps de démarrage d’un ordinateur ou d’une application a aussi quasiment constamment augmenté).

Concernant le premier point

Posted by alpheccar - Sep 28 2004 at23:57 CEST

Concernant le premier point, je veux juste rajouter une remarque : c’est vrai que les langages fonctionnels demandent plus de reflexion. Mais franchement, quand je vois comment on code dans l’industrie, je me dis que finalement c’est probablement une qualité plus qu’un défaut. Si les gens réflechissaient un peu plus avant de coder, ça éviterait probablement de perdre un temps incroyable en debug et maintenance.

On pond beaucoup de code mais souvent par manque de conceptualisation justement. Les gens ne détectent pas les analogies entre différentes parties de leur code. Analogies qui ne sont pas toujours factorisables dans les langages impératifs alors qu’elles le sont plus facilement dans les langages fonctionnels.

Je pense qu’on arrive à un point où le soft devient tellement répandu et important qu’il faut que les programmeurs cessent d’être juste des exécutants que l’on évalue selon le nombre de lignes de code écrites ...

Mais, cela n’engage que moi...

D’accord sur le premier point

Posted by alpheccar - Sep 28 2004 at23:50 CEST

D’accord sur le premier point. Cela fait mal aux neurones au début :-)

La lisibilité : je me demande parfois si ce n’est pas un problème de formatage vu que ces langages ne sont pas forcément enseignés tout de suite. Mais comme tu dis que ce n’est pas à cause de raisons syntaxiques, j’avoue que je ne comprends pas trop ce point.

Les GUIs : C’est vrai que c’est plutôt difficile à gérer avec des langages purs et je n’ai pas encore vu de belle interface purement fonctionnelle pour les GUIs. Néanmoins, ce n’est pas impossible : par exemple Haskell via le concept de Monade permet d’encapsuler les modifications d’états et les rendre invisibles au monde fonctionnel. Concurrent Clean utilise un système de types particulier. Mais de toute façon, avec Ocaml il n’y a pas de problèmes vu qu’on peut choisir entre la programmation fonctionnelle pure et celle avec effets de bord.

Par contre, je suis d’accord que certains algos sont plus élégants en programmation fonctionnelle pure, et d’autres le sont plus en style impératif. D’où l’intérêt d’avoir les deux.

Pour le code au kilomètre, sans algos compliqués (donc le code industriel en général), à mon avis, le vrai problème ce sont les librairies et le code déjà écrit. Ainsi que l’intégration avec l’OS. Si l’OS fournit toutes ses APIs en C++, alors il sera forcément plus simple de programmer une application native en C++.

Je pense donc que le problème principal pour que les langages fonctionnels soient un jour adoptés plus largement dans l’industrie ce sont les librairies et l’investissement déjà effectué dans le code existant.

Pour les systèmes real-time, c’est une autre histoire à cause des garbage collectors qui ne sont pas encore assez déterministes (pour le hard realtime).

Le reproche courament fait aux langages fonctionnels

Posted by wapiti - Sep 28 2004 at23:33 CEST

Le reproche courament fait aux langages fonctionnels (et je le partage après rélfexion), c’est qu’ils demandent vraiment plus de compétence que les langages impératifs - ce qui est dû au fait qu’ils obligent effectivement une conceptualisation plus poussée des problèmes. Or actuellement, on pond beaucoup trop de code pour qu’il soit réaliste d’utiliser des langages fonctionnels. Un autre inconvénient à mon avis est la lisibilité. J’adore OCaml, je préfère écrire en OCaml qu’en Java par exemple, mais je dois avouer que je trouve la lecture du Caml beaucoup plus difficile (et pas pour des raisons syntaxiques) que celle de Java. De plus il y a des problèmes qui sont très peu propice à la programmation fonctionnelle pure, les GUI par exemple. Je pense donc que les langages fonctionnels sont intéressant pour les applications pointues qui nécessitent de programmer des algos complexes vite et sûrement. Par contre pour le code au kilomètre sans algos compliqués, les C++ et compagnie restent préférables à mon avis.