Une Introduction à Haskell


par Adam Turoff - 2007
Traduction de la page An Introduction to Haskell

Partie 1: Pourquoi Haskell

Permettez-moi de commencer par être parfaitement clair : si vous êtes programmeur professionnel, alors Haskell fait partie de votre avenir.

En 1987, cette déclaration aurait été tout aussi vrai à propos de Smalltalk. Aujourd'hui, 20 ans plus tard, la programmation orientée objet, les hiérarchies de classe, l'architecture modèle-vue-contrôleur, et d'autres idées de Smalltalk sont devenus monnaie courante, même si Smalltalk n'a jamais vu son adoption généralisée.

Haskell est dans une situation similaire aujourd'hui. Il s'agit d'un langage ésotérique, mais stable et suffisamment fiables pour un travail sérieux. Peut-être que vous allez apprendre Haskell, ou même utiliser Haskell sur un projet dans le monde réel. Mais même si vous ne connaissez-pas les idées de Haskell, celles-ci sont lentement entrain de faire leur chemin dans les principaux languages, comme Java, C#, C++, Perl, Python et Ruby. Haskell est prêt à avoir une forte influence au cours des 20 prochaines années sur la programmation et dans la conception des langages, comme Smalltalk a eu une influence au cours des 20 dernières années.

Qu'est-ce que Haskell ?

Haskell est renommé, mais qu’est-ce qui le rend si spécial ? : La brève définition de Haskell par haskell.org dit :

Haskell est un langage de programmation purement fonctionnel mettant en vedette le typage statique, les fonctions d'ordre supérieur, le polymorphisme, les classes de type et les effets monadiques.

Si vous êtes universitaire ou concepteur de language, ces termes peuvent vous parler. Si vous êtes programmeur professionnel, des termes comme typage statique et polymorphisme peuvent vous sembler familiés, même si cette définition n'est pas particulièrement éclairante.

Fondamentalement, Haskell est un langage de programmation fonctionnelle, ce qui signifie qu'il est fondé sur une forme de lambda-calcul. Si cela vous semble intimidant, alors n'ayez pas peur, parce que le lambda-calcul n'est pas difficile du tout. Le lambda-calcul n'est rien de plus qu'un ensemble de règles précises qui décrivent la façon d'évaluer les fonctions. Il y a deux procédés en action (la définition de fonction et l'évaluation de fonction) et deux expressions en constructions (fonctions et valeurs). Si vous êtes un programmeur professionnel, vous avez déjà toutes les compétences dont vous avez besoin pour comprendre le lambda-calcul. Par exemple, dans n'importe quelle language de type C, la règle d'évaluation de cette déclaration est déjà présente profondément dans votre conscience :

printf("%.03f\n", sqrt(incr(1)));

La règle est d'évaluer l'ensemble des arguments d’une fonction d'abord, puis d'évaluer la fonction avec les valeurs résultantes. Ce processus est répété de façon récursive, jusqu'à ce que l'expression donne une valeur unique. Dans l'exemple ci-dessus, la fonction la plus intérieure, incr(1) est évaluée et produit la valeur 2. Ensuite, sqrt(2) est évalué et produit 1.414.... Enfin, printf est évaluée avec les deux arguments "%.03f\n" et 1.414... pour produire une sortie formatée.

Il y a un peu plus de fonctionnalités clé dans le lambda-calcul, comme la capacité à créer des fonctions, d’avoir des fonctions en résultat, et la capacité de définir des fonctions qui prennent des fonctions comme arguments. Ces caractéristiques permettent de définir les fonctions modulaires petites et de les coller dans des fonctions hautement spécialisées avec un minimum d'effort.

Par exemple, filter est une fonction d'usage général qui accepte comme arguments, une fonction de test (un prédicat), une liste de valeurs, et qui retourne une nouvelle liste contenant toutes les valeurs où le test est réussi.. La fonction even est un de ces essais, qui détermine si un nombre est divisible par 2. Voici comment ces deux fonctions sont utilisées ensemble dans Haskell, pour extraire les numéros paire d'une liste de valeurs. Cet exemple utilise ghci, l'interprète interactifs fournis avec le Glorieux Glasgow Haskell Compiler (ghc) :

Prelude> filtre even [1..10]    -- filtre les nombres pairs sur une liste
[2,4,6,8,10]

Si je veux définir une fonction qui prend une liste de valeurs, et ne renvoie que les valeurs pairs de cette liste, Elle ressemblera à ceci dans l'interpréteur :

Prelude> let evens = filter even   -- definie la fonction
Prelude> evens [1..10]             -- filtre les nombres de la liste
[2,4,6,8,10]

Cet exemple peut paraître trivial, mais il montre un des avantages du lambda-calcul dans un langage de programmation fonctionnel - la possibilité de créer des fonctions spécialisées simplement en assemblant un petit nombre de fonctions pré-existantes généralistes.

Cela est intéressant, c'est exactement comme un travail de pipeline opéré par un shell.

Écrire la fonction evens en C est beaucoup plus complexe. Tout d'abord, la liste de sortie doit être allouée, et pour ce faire, le nombre de valeurs de sortie doit être comptées. Ensuite, les valeurs doivent être copiés sur la nouvelle liste, et une à la fois. Cette simple expression Haskell devient la fonction C suivante :

int *evens(int *in, int count) {
    int matches = 0;
    int i, *out, *ptr;

    for (i = 0; i < count; i++) {
        if ((in[i] % 2) == 0) {
            matches++;
        }
    }


    out = malloc(sizeof(int) * matches);
    ptr = out;

    for (i = 0; i < count; i++) {
        if ((in[i] % 2) == 0) {
            *ptr = in[i];
            ptr++;
        }
    }

    return out;
}

Cette fonction définit le comportement combiné de la définition Haskell de evens. Ce bloc de code est équivalent à l'expression filter even en Haskell. S'il y avait une fonction dans la bibliothèque C qui traitait le comportement du filter, ce code serait plus simple. Toutefois, ce type de solution est malheureusement idiomatiques dans les langues de type C.

Écrire cette fonction dans un langage plus moderne comme Java, C++ ou C# n'est pas aussi odieux, parce que la gestion automatique de la mémoire se charge de la première moitié de cette fonction. Écrire l'expression « filter even [1..10] » dans des langages dynamiques tels que Perl, Python et Ruby est plus proches de la définition Haskell, parce qu'ils définissent des fonctions primitives qui agissent comme des filtres. Voici à quoi ressemble cette expression dans les langues suivantes :

## Perl
grep {$_ % 2 == 0} (1..10)


## Python
filter(lambda n: n % 2 == 0, [1,2,3,4,5,6,7,8,9,10])


## Ruby
[1,2,3,4,5,6,7,8,9,10].delete_if {|i| i % 2 == 1}

Pourquoi le Lambda-Calcul ?

La plupart des langages de programmation sont axée sur la gestion d'une machine, qu’elle soit un véritable ordinateur ou une machine virtuelle. La plupart des languages ont un caractère impératif, et le programmeur doit indiquer à la machine ce qu'elle doit faire étape-par-étape.

Les langages de programmation fonctionnels qui utilise le lambda-calcul ont une approche différente. Au lieu de dire à l'ordinateur comment résoudre un problème, ils permettent au programmeur de décrire quelle est la solution, et comment l'exécuter. Par exemple, il est plus facile de produire une nouvelle liste en utilisant une fonction comme filtre pour sélectionner les éléments que vous souhaitez conserver (ou, dans l'exemple de Ruby, en supprimant les éléments que vous voulez ignorer), que d'écrire une fonction C qui doit d'abord allouer de l'espace pour un résultat, puis placez chaque valeur sélectionnée dans la liste des résultats.

Bien sûr, il n'est pas nécessaire d'utiliser un langage de programmation fonctionnel pour utiliser ces techniques. Et c'est parce que les idées du monde de la programmation fonctionnelle apparaissent dans les languages principaux, qu'il est plus important que jamais de comprendre ces techniques. Tom Christiansen le dit mieux :

Un programmeur qui n'a pas été exposé aux quatres styles de programmation : impératif, fonctionnel, object, logique, a un ou plusieurs angles morts conceptuels....

Plusieurs langages de programmation offre un mélange de styles. La plupart des langages orientés objet ont un noyau essentiel, où les classes, les objets et les méthodes fournissent une mince couche sur un language qui n'est guère plus qu'une version légèrement améliorée du C. De nombreux langages de programmation fonctionnels ont une mixité fonctionnelle, impérative, et des styles orientée objet, de sorte qu'il est difficile de les ranger dans une catégorie précise.

Haskell, par contre, est un langage purement fonctionnel qui se limite au style fonctionnel de la programmation. L'étude et l'utilisation d'Haskell rend facile de voir la puissance et les avantages du lambda-calcul et de la programmation fonctionnelle.

Le clivage linguistique

L'observation de Tom décrit quatre pierres angulaires de l'informatique : la programmation impérative, la programmation orienté objet, la programmation fonctionnelle, et la programmation logique. Ces quatre familles peuvent être regroupées en deux styles : mécanique (impératif et objet) et mathématiques (fonctionnelle et logique).

Les languages "mécaniques" se concentrent sur l'expression d'un programme comme une série d'instructions visant à être exécutés, les unes après les autres. La programmation orientée objet est plus puissante que la programmation impérative simple, car elle définit une série supplémentaire de règles qui permet au compilateur ou à l'interpréteur de faire d'avantage de travail de création pour composer un programme.

Les languages "mathématiques" commencent par un modèle théorique sur la façon de résoudre un problème, et propose une mise en œuvre concrète de ce modèle. Quatre exemples de cette approche sont données par : les expressions rationnelles, SQL, make et, bien sûr, Haskell.

Les expressions régulières utilisent la théorie des automates pour décrire un mécanisme d'appariement des chaînes de caractères. Il est beaucoup plus facile d'écrire une expression régulière comme '^(NYC?|New York( City)?)$' que d'écrire du code qui reconnait ces quatre chaines "NY", "New York", "New York" et "New York City". En outre, on peut toujours employer une bonne d'expression régulière pour effectuer une recherche optimale, parcourant chaque caractère une fois seulement, tandis qu'avec une liste de chaines à rechercher, le procéder doit se répéter pour chacune d'elle et constitue un travail beaucoup plus grand et difficile.

SQL utilise la théorie des ensembles et l'algèbre relationnelle pour rechercher des données dans une base de données. Une seule instruction SQL peut se référer à de nombreux tableaux, de nombreuses contraintes, et de nombreuses expressions calculées. Un moteur de base de données relationnelle classique utilise un optimiseur de requête pour déterminer si l'une des données nécessaires est indexé, et si oui, se réfère à cet indice pour réduire la quantité de travail nécessaire et retourner un résultat. L'alternative à l'utilisation de SQL et aux bases de données relationnelles consiste à écrire des pages de code pour effectuer toutes les opérations d'entré/sortie (I/O) nécessaire à la gestion des tableaux de données (et de leurs indices) sur le disque. Cela pourrait être faisable pour des déclarations SQL très simples, mais c'est certainement plus fastidieuse. Certaines instructions SQL complexes sont encore apréhendables et peuvent être programmées et comprises sans trop de difficulté, tandis que d'autres peuvent devenir des pages de code difficiles à écrire, source d'erreurs, difficiles à comprendre et à optimiser.

make et les outils connexes comme ant, nant et rake, utilisent une série de recettes pour déterminer la façon de construire un projet logiciel. En interne, make lit ces recettes et les interprète employant un peu de théorie des graphes pour modéliser les dépendances décrites par ces recettes. Quand make est utilisé avec un but précis, comme make test , make docs , make all ou make debug , il trace le graphe des dépendances de ce but en remontant en arrière l'ensemble des dépendances de tout ce qui doit être reconstruits et continue à construire seulement ce qui est absolument nécessaire. Ce processus est beaucoup plus fiable et facile à gérer qu'un script shell fabriqué à la main pour effectuer ce même travail. De plus, make peut être exécuté dans un mode multiprocesseur, où de multiples appels indépendants peuvent s'exécuter simultanément afin d'accélérer le processus de construction, quelque chose qui est presque impossible à atteindre avec des scripts de construction codé à la main.

Haskell s'inscrit également dans cette catégorie de langages de programmation, et le lambda-calcul en est le fondement. make, SQL et les expressions régulières, tous décrivent des languages de but limitées, convenables pour une résolution d'un type spécifique de problème dans le cadre d'un projet plus vaste. Haskell est un language à usage général, et il apporte la même sorte de moyens pour soulever de lourdes charges dans la conception et la construction d'applications et de grands systèmes.

Haskell et l'avenir de la programmation

Le développement de logiciels est aujourd'hui à un point d'inflexion. Jusqu'à tout récemment, presque tous les logiciels ont été écrit pour fonctionner sur une seule machine avec un seul processeur. Aujourd'hui, de plus en plus de logiciels doivent être écrits pour s'accomoder au mode multiprocesseur, qu'il s'agisse de l'utilisation de plusieurs processeurs, de plusieurs nœuds sur un réseau, ou des deux. Pourtant, de nombreux langages et environnements travaillent aujourd'hui le mieux lorsqu'il sont utilisés sur une seule machine avec un seul processeur.

C'est une question ouverte, comment nous en tant que développeurs de logiciels devons-nous nous appuyer pour profiter de l'augmentation exponentielle de la puissance de calcul disponible.

Trois des idées les plus intéressantes sont disponibles dès maintenant, ou le seront bientôt, dans le compilateur ghc.

Un problème persistant dans le multitraitement de programmes tient dans gestion de l'état global partagé. Haskell permet d'éviter ces problèmes en posant toutes les valeurs immuables. En d'autres termes, les variables en Haskell ne varient pas. Chaque fois qu'une fonction retourne un résultat, il crée une nouvelle valeur, et laisse le garbage collector gérer l'allocation de mémoire et la destruction. Le résultat d'une fonction est renvoyé à l'appelant, et, par conséquent, il n'y a rien d'autre que l'état global, qui peut modifier le comportement d'un programme. Les programmes qui nécessitent un comportement statefull existent dans Haskell, mais ils sont strictement mis en quarantaine et peuvent être traitées séparément du reste du programme.

Les programmes Haskell employe le style "aucun partage" naturellement. C'est la même architecture qui est considéré comme une meilleure pratique pour écrire des applications web qui ont besoin dans leur developpement d'échelle d'intensifier l'utilisation de processeurs multiples ou de soutenir la réplication sur plusieurs nœuds du réseau.

Un autre problème dans le mode multiprocesseur est le conflits de ressources, où deux processus interfèrent l'un avec l'autre lorsqu'ils essaient de mettre à jour la même ressource partagée. Une solution consiste à utiliser des sémaphores et verrous de façon strictement coordonnée. Malheureusement, manipuler des processus et des verrous est très délicat, et il est très facile de se tromper. Une autre approche consiste à utiliser les transactions atomiques, où chaque émetteur réussit et écrit toutes ses modifications, ou échoue et n'écrit aucune d'entre elles.

Les moteurs de base de données ont utilisé des transactions pendant des décennies pour faire des mises à jour raisonnables multi-utilisateur. Jusqu'à récemment, ce mécanisme familier n'était pas disponible pour les développeurs de logiciels lors de l'écriture de logiciels en dehors de la portée d'une base de données. Ce modèle est disponible aujourd'hui dans le compilateur ghc, grâce à des logiciels de mémoire transactionnelle ou STM. Les bibliothèques soutenant STM apparaissent lentement pour d'autres langues aussi.

Un troisième problème porte sur l'utilisation efficace de plusieurs processeurs sur une même machine. Certains de ces problèmes "parallèles embarrassants " sont faciles à écrire en utilisant un ensemble d'instructions séquentielles à exécuter sur un seul processeur. Écrire du code qui répartit le travail sur plusieurs processeurs sur une même machine est beaucoup plus difficile.

Il y a du travail en cours pour remédier à cette situation dans ghc. Les données d'extension parallèle Haskell intègre une forme de calcul parallèle dans les programmes Haskell. Cette extension permet au programmeur de spécifier si les tableaux peuvent bénéficier d'opérations en parallèle qui sont alors répartis sur plusieurs processeurs dans une même machine. Cela permet aux programmeurs d'écrire un programme simple et normale Haskell, et d'identifier quelles sont les parties "parallèles embarrassantes" qui devront être parallélisée de manière transparente. "Data Parallel Haskell" est un travail en cours, et un domaine de recherche active par l'équipe de développement du ghc.

De toute évidence, Haskell est un langage important. Il mérite toute votre attention.

Partie 2: Fonctions Pures

Générale les langages de programmation objet peuvent être divisés en deux grandes catégories : Haskell et tout le reste.

Dans la partie 1, j'ai décrit comment tous les langages de programmation sont regroupés en quatre familles : impératif, objet, logique et fonctionnel. La plupart des langages de programmation commence par un noyau impératif auquel on ajoute sur le dessus des fonctionnalités orientées objet ou fonctionnelle. par exemple, C est un langage de programmation purement impératif. Les langages comme le C++, Java et C# s'appuyent sur C et enveloppe ce modèle impératif avec des objets. Perl, Python, Ruby ajoutent un peu de la programmation fonctionnelle dans le mélange.

Haskell est différent parce qu'il est un langage fonctionnel pure. Autrement dit, il ne supporte que le style de programmation fonctionnelle. Cette approche peu conventionnelle donne à Haskell la réputation d'être une langue difficile à apprendre et à utiliser, particulièrement parmi les programmeurs professionnels qui navigue négligemment autours entre les langues comme Java, Perl, Python, PHP et Ruby. La plupart des langues se concentrent sur les fonctionnalités à ajouter et à mélanger pour faire des développeurs plus productifs. Haskell prend la démarche inverse, en supprimant les particularités inutiles jusqu'à ce qu'il ne reste plus rien à emporter.

Pure contre Impures Fonctions

Les fonctions sont au cœur d'Haskell. Toutefois, les fonctions dans Haskell ne sont pas des fonctions analogues dans d'autres langues. Les fonctions Haskell sont pures, ce qui signifie qu'elles définissent une relation entre les entrées et les sorties, et n'ont pas d'effets secondaires (pas d'effets de bord). Ce comportement imite la notion de fonction que vous avez appris en algèbre il y a plusieurs années. Des opérations comme la racine carrée, le sinus, le cosinus, sont des fonctions pures ; la racine carrée de quatre vaudra toujours deux, peu importe comment, et où, ni quand il est calculé.

Les fonctions impures se référent à l'état externe, modifient leur comportement selon l'état externe, ou changent l'état externe. Comme fonctions C, fopen() , printf() , et malloc() sont des exemples. Appeler malloc() renvoie un pointeur vers un bloc de mémoire différents à chaque fois qu'elle est appelée, mais lorsque vous essayez d'attribuer plus que le système d'exploitation ne peut fournir, malloc() échoue. De même, si vous essayez d'affecter un grand bloc de mémoire, malloc() peut réussir, mais causer un dégrademment considérable des performances de la machine tout entière en changeant l'état du système d'exploitation. De toute évidence, les fonctions impures sont nécessaires, mais imprévisible, parce que leur comportement varie en fonction de comment, quand et où ils sont invoqués.

Les deux types de fonctions sont nécessaires pour modéliser le monde réel. La conversion des températures Fahrenheit en degrés Celsius est une fonction pure ; par définition, 212 ° F doit toujours se convertir à 100 ° C. La conversion des dolars en livres au Royaume-Uni est une fonction impure qui est déterminée même par un grand nombre de variables externes, et il ne faut pas s'attendre à ce que la scène, la même opération à deux moments différents ou deux endroits différents puissent produire le même résultat.

Bien entendu, pour être un véritable langage de programmation général, Haskell doit soutenir à la fois les pures et les impures fonctions. Haskell dispose de fonctions impures, mais les isole grâce à l'utilisation des monades, qui sont en fait construit à partir des fonctions pures. Les monades constitues un sujet profond et intéressant, et sont discutées dans la partie n°3.

Programmer avec des fonctions pures

Parce que des fonctions pures se réfèrent uniquement à leurs entrées, elles sont déterministes, faciles à comprendre et faciles à prédire. Par exemple, une fonction comme :

square x = x * x

recevrez toujours un seul argument, et retourne la valeur de la multiplication de cet argument avec lui-même. Par conséquent, une expression telle que square 2 revient toujours à 4. Une fonction qui calcule 2 * 2 et écrit un message à la console ou dans un fichier journal est une fonction impure dont le comportement ne peut être prédit, le calcul peut réussir, mais la fonction peut échouer, en raison de l'opération de connexion et d'enregistrement. L'opération de connexion et d'enregistrement peut réussir la plupart du temps, mais il n'existe aucun moyen de prédire si cette fonction va tenter d'écrire dans un fichier fermée, d'écrire dans un fichier dont le système n'est plus disponible, d'écrire dans un système de fichiers plein, ou échouer sur un certain nombre d'autres échecs d'Entré/Sortie (Input/Output) (I/O)

La clé de la compréhension des fonctions pure est de penser en termes de petites unités composables de code. Les opérations simples comme l'addition, la multiplication, le sinus, le cosinus, et la concatenation sont toutes de pures functions. Des fonctions pures peuvent être composées d'autres fonctions pures, comme ceux-ci ici :

f x = (3 * x * x) + (6 * x) + 9

De nombreuses fonctions dépendent de plus d'une valeur, et acceptent plusieurs arguments :

min a b = if a < b
            then a
            else b

Notez que avec Haskell vous pouvez utiliser l'indentation pour signaler que l'expression est définie sur plusieurs lignes. C'est ce qu'on appelle la règle du hors-jeu, et elle est similaire aux règles de Python pour la portée de bloc. Contrairement à Python, la règle du hors-jeu d'Haskell est simplement une convention pour déduire l'emplacement des accolades et des points-virgules autour d'une série d'expressions et de déclarations. L'une ou l'autre forme est valable, mais la règle du hors-jeu est la plus commode et la plus commune des formes. (Le Haskell 98 Rapport définit l'interprétation exacte de la règle de mise en page.)

Certaines fonctions sont complexes et se référent à des sous-expressions plus impliquées. Voici une fonction qui calcule le coût d'un panier, où certains articles sont imposables, et d'autres ne le sont pas :

taxRate = 0.06

total cart = subtotal + tax
  where
    subtotal = sum cart
    taxable = filter isTaxable cart
    tax = (sum taxable) * taxRate

Cet exemple définit deux fonctions, taxRate , qui retourne une valeur constante, et total , qui calcule le coût total de la liste des articles dans un panier. (Bien que la définition de taxRate semble être la définition d'une variable, il est préférable de penser que c'est une fonction constante, une fonction qui ne prend aucun paramètre et renvoie toujours la même valeur.) La définition de total est très expressive, et souligne l'intention de la fonction, en isolant et en nommant d'importantes sous-expressions dans le calcul. (total se réfère également à la fonction isTaxable non présentés ici).

La clause where dans total définit significativement les sous-expressions qui sont utilisés pour évaluer l'expression primaire, subtotal + tax. Le subtotal , comme on peut s'y attendre, n'est que la somme de tous les articles dans le panier. La définition de tax se rapporte à taxable , qui est le sous-ensemble de tous les articles dans le panier qui sont imposables (plus précisément, l'ensemble des articles dans le panier où isTaxable est vrai pour ce point). Le calcul de la taxe consiste à sommer le sous-ensembles des articles imposables, et à multiplier cette somme par le taux d'imposition. Les deux fonctions d'utilité sum et filter savent faire ce que leur nom indique et sont prédéfinies dans Prélude, la bibliothèque standard d'Haskell de fonctions utiles.

On pourrait aussi définir total en évitant les sous-expressions dans la clause where, et rédigeant le calcul entier dans une forme entièrement étendue. Cette version est tout aussi correct, mais beaucoup plus difficile à comprendre pour les gens :

total cart = (sum cart) +
             (sum (filter isTaxable cart)) * taxRate

Le compilateur ne se soucie pas comment vous écrivez ces fonctions, parce qu'ils sont des expressions équivalentes de la même idée. En fait, on permet au compilateur de récrire la première définition dans le deuxième. Cependant, l'utilisation des outils comme les clauses where et les bibliothèques de fonctions utiles et réutilisables peut aider à rendre les fonctions concises et expressives, et plus facile à lire pour les gens.

Programmation Fonctionnelle Réelle-du monde

Haskell a des racines profondes dans le monde des mathématiques, et il est facile de voir comment il peut être utilisé pour définir des fonctions mathématiques simples. Toutefois, les problèmes du monde réel ont très peu de chose en commun avec de simples fonctions mathématiques.

Ou bien ils faire?

Envisagons un programme qui doit trouver les 10 mots les plus utilisés dans un document. Supposons pour le moment que le document est disponible en tant que valeur de chaîne. (Lecture d'un fichier à partir du disque ou de l'entrée standard, il faudrait utiliser la monade I/O, qui sera discuté dans la partie n°3.)

Heureusement, il existe une variété de fonctions pré-définies dans Prélude qui peuvent faire ce simple travail. La première étape consiste à convertir un document dans une liste de mots. C'est précisément l'opération effectuée par la fonction words dans Prélude:

top10 doc = ....
  where
    listOfWords = words doc
    ...

Ensuite, la liste des mots doit être normalisée afin que les variations de casse ne puissent pas en infléchir les résultats. Une façon de faire consiste à convertir chaque mot en minuscules. Toutefois, il n'y a pas de fonction lowercase prédéfinie qui convertit tous les caractères majuscules d'une chaîne en son équivalent en minuscules. Mais il y a une fonction toLower dans la bibliothèque Data.Char qui convertie un caractère en minuscule. Avec l'utilisation de map, nous pouvons appliquer cette transformation à chaque caractère dans une chaîne :

import Data.Char

lowercase str = map toLower str

Ou, plus consisement :

import Data.Char

lowercase = map toLower

Le compilateur Haskell peut en déduire que lowercase doit prendre une chaîne (une liste de caractères) comme argument, parce qu'il sait que map prend deux arguments (une fonction et une liste), et toLower convertit un caractère dans une autre. Comme map toLower a besoin d'un argument, lowercase doit avoir besoin du même argument.

La mise à jour de la définition du top10 est facile. Au lieu de diviser le document en une liste de mots, nous pouvons diviser la version minuscule du document dans une liste de mots en minuscules :

import Data.Char

lowercase = map toLower

top10 doc = ....
  where
    listOfWords = words (lowercase doc)
    ...

Ensuite, les mots doivent être triés et groupés. Ces opérations peuvent être effectuées par les fonctions sort et group dans la bibliothèque Data.List. Le sort de fonction fait ce que vous attendez. La fonction group prend une liste de valeurs et retourne une liste de listes imbriquées, où chaque liste imbriquée contient voisine des valeurs égales. Par exemple :

$ ghci
Prelude> :module + Data.List
Prelude Data.List> group [1,3,3,1]
[[1],[3,3],[1]]
Prelude Data.List> group (sort [1,3,3,1])
[[1,1],[3,3]]

L'ajout de cette fonctionnalité pour top10 est simple:

import Data.List

top10 doc = ....
  where
    listOfWords = words (lowercase doc)
    wordGroups = group (sort listOfWords)
    ...

Obtenir la liste des mots ordonnée selon leur fréquence implique un peu de manipulations sur la liste des groupes de mots. Chaque groupe de mots est une liste d'une ou plusieurs instances d'un seul mot. Un moyen facile d'ordonner par fréquence est de convertir ces listes en une paire de valeurs, où le premier élément est le nombre de mots (fréquence) et le second élément est le mot lui-même. Une fonction simple peut effectuer cette action sur un groupe de mots :

makePair l = (length l, head l)

La conversion d'une liste de groupes de mots se fait simplement en utilisant map et en l'appliquant sur la liste complète :

-- convert [["a", "a"], ["b"], ...]
-- into [(2, "a"), (1, "b"), ...]

map makePair wordGroups

Toutefois, makePair est profondément lié au problème que nous essayons de résoudre et n'est donc pas très générique. Au lieu de créer un nom pour cette fonction jetables, on peut utiliser une expression lambda à la place :

-- same as above
map (\l -> (length l, head l)) wordGroups

La fonction lambda ou une fonction anonyme commence par un caractère \. Les paramètres nommés se situent à la gauche de la flèche et le corps de la fonction se situe à droite de la flèche.

Générer la liste des mots et leur fréquence n'est qu'une partie de la solution. Ensuite, les mots doivent être triés par ordre décroissant de leur fréquence :

reverse (sort (map (\l -> (length l, head l)) wordGroups))

Malheureusement, les emboitements de parenthèses compliquent la structure de cette expression. Nous pouvons utiliser l'opérateur $ pour nettoyer l'expression comme cela :

reverse $ sort $ map (\l -> (length l, head l)) wordGroups

L'opérateur $ est un peu de sucre syntaxique. Il prend tout à droit et le traite comme une seule expression, puis utilise ce côté droit comme le dernier paramètre de l'expression présente sur le côté gauche. Par conséquent, ces deux formes sont vues en réalité par le compilateur comme la même expression.

La mise à jour de la fonction top10 ressemble à ceci :

top10 doc = ....
  where
    listOfWords = words (lowercase doc)
    wordGroups = group (sort listOfWords)
    frequencies = reverse
                   $ sort
                   $ map (\l -> (length l, head l))
                         wordGroups
    ...

Ensuite, la liste des paires (fréquence, mot) doivent être réduit à une liste de mots. Les paires sont une structure de données si utile dans les programmes Haskell que Prélude comprend deux fonctions standart pour obtenir leur contenu : fst retourne le premier élément d'une paire, et snd renvoie le deuxième élément d'une paire. Pour obtenir les mots de ces couples, nous avons besoin d'utiliser snd. Pour convertir la liste de paires dans une liste de mots, nous avons juste besoin d'utiliser map :

top10 doc = ....
  where
    listOfWords = words (lowercase doc)
    wordGroups = group (sort listOfWords)
    wordPairs = reverse
      $ sort
      $ map (\l -> (length l, head l))
            wordGroups
    wordsByFreq = map snd wordPairs

Enfin, l'objectif de la fonction top10 est de trouver les dix premiers mots les plus fréquentes dans un document. Depuis que wordsByFreq ordonne du plus fréquent au moinss fréquent, tout ce que nous devons faire est de trouver les dix premiers éléments de la liste. Cela est assuré par la fonction standart take présente dans Prélude. take a deux paramètres : la taille désirée et une liste de valeurs, elle retourne une liste ne dépassant pas la taille désirée.

Avec cette dernière pièce du puzzle, voici la définition complète de la fonction top10 :

import Data.Char
import Data.List


lowercase = map toLower

top10 doc = take 10 wordsByFreq
  where
    listOfWords = words (lowercase doc)
    wordGroups  = group (sort listOfWords)
    wordPairs   = reverse
                   $ sort
                   $ map (\l -> (length l, head l))
                         wordGroups
    wordsByFreq = map snd wordPairs

Le chargement du texte intégral de la RFC 822 en une chaines de caractères nommée rfc822, et le lancement de cette chaine à travers la fonction top10 produit ce résultat :

$ ghci top10.hs

   ___         ___ _
  / _ \ /\  /\/ __(_)
 / /_\// /_/ / /  | |      GHC Interactive, version 6.6, for Haskell 98.
/ /_\\/ __  / /___| |      http://www.haskell.org/ghc/
\____/\/ /_/\____/|_|      Type :? for help.


Loading package base ... linking ... done.
[1 of 1] Compiling Main             ( top10.hs, interpreted )
Ok, modules loaded: Main.


*Main> rfc822 <- readFile "rfc0822.txt"
*Main> top10 rfc822
["the","of","to","a","is","and","be",";","for","in"]
*Main>

En utilisant cette approche pour définir une fonction comme top10, on pourrait écrire une série de fonctions similaires. Par exemple, une version qui enlève les caractères de ponctuation accolés sur chaque mot. Ou une version qui génère une liste unique de mots, pour être utilisée par un correcteur orthographique. Ou une version qui renvoie à la fois des mots et des fréquences. Ou une version qui ne tient pas compte des mots tel que "à", "de", "du".... L'écriture d'une série de tels fonctions identifiera probablement des composantes communes, comme une fonction qui convertit une liste de mots en une liste de paires (fréquence, mot), ou une fonction qui convertit une liste de paires en une liste de mots.

La série est longue, et je vous encourage à jouer avec cette petite fonction simple pour explorer Haskell plus loin.

Conclusion

De nombreux programmeurs expérimentés resentent une sorte de choc culturel lors de la première approche d'Haskell. Comment peut-on programmer sans variables affectable ? Où sont les objets ? Les hiérarchies de classes ? Les modèles de conception ? Comment pouvez-vous déboguer un programme sans émaille états d'impression à propos? Si les fonctions pures peuvent modèliser simplement des concepts mathématiques, peuvent-ils aussi modéliser des problèmes de programmation réel ?

Dans cet article, j'ai montré comment développer un code réel en utilisant seulement des fonctions pures. Cette approche est à la fois puissante et différente de ce que la plupart des programmeurs utilisent aujourd'hui. Haskell vous encourage à réfléchir en termes de petites fonctions puissantes qui font une chose et le font bien. Pour vous aider dans vos efforts, les bibliothèques standard fournissent une multitude de fonctions pour l'étude, la réutilisation, et se combiner dans des fonctions nouvelles et intéressantes qui vous permettent de mettre l'accent sur les problèmes que vous essayez de résoudre, et non sur la façon de rendre votre problème conformer à une API ou à un modèle de conception.

La programmation fonctionnelle en Haskell est très riche, est constitue un sujet très profond, qui ne peuvt pas être expliqué en détail dans un seul article. Visitez le wiki d'Haskell pour découvrir quelques-uns des sujets fort intéressants, comme :

Partie 3: Les monades

Dans la partie 1, j'ai décrit comment les langages de programmation sont regroupés en deux familles, mécanique et mathématique. Les programmeurs aussi se répartissent en deux catégories générales, ceux qui pensent mécaniquement sur ce qu'est un ordinateur et comment il exécute un programme, et ceux qui pensent mathématique ce que signifie un programme. Certains programmeurs sont doués et font le pont entre ces deux mondes si nécessaire.

Dans la partie 2 , j'ai décrit comment Haskell est un langage de programmation purement fonctionnelle et comment il utilise les fonctions pure pour exprimer des programmes. Malheureusement, les fonctions pures limitent, parce qu'elles interdisent les effets secondaires (effet de bord) et donc aussi les entrée-sorties de base.

Cet article décrit les monades, un mécanisme d'Haskell pour structurer le calcul, permettre des effets secondaires (des effets de bord) et communiquer avec le monde extérieur.

Le clivage linguistique revisité

Haskell est issu d'une longue et riche histoire de la recherche en mathématiques et en programmation fonctionnelle. Naturellement, de nombreux programmeurs à l'esprit mathématique sont très à l'aise avec Haskell, tandis que ceux à l'esprit mécanique sont généralement brouillés avec cette langue et les concepts mathématiques dont elle s'inspire.

Aucun concept ne stupéfie plus que la monade.

Les monades viennent d'une branche des mathématiques appelée la théorie des catégories (Pour en savoir plus sur la théorie des catégories, voir sur Wikipédia et Haskell.org). Malheureusement, la théorie des catégories est une branche des mathématiques obscure que la plupart n'ont jamais vu, même lorsque l'on étudie les fondements mathématiques de l'informatique.

Les nouvelles ne sont pas toutes mauvaises. Par exemple, SQL est construit sur une base mathématique de l'algèbre relationnelle. Un programmeur peut penser une requête SQL en termes de ce que les opérateurs relationnels veulent dire ou en termes de ce que le moteur de base de données fait. Que ce soit un programmeur incliné mathématiquement ou mécaniquement, il peut toujours formuler des requêtes SQL afin d'examiner des données dans une base de données.

De même, les programmeurs incliné mécaniquement n'ont pas besoin d'apprendre la théorie des catégories pour comprendre ce que signifie les monades et afin de comprendre comment elles fonctionnent. Mais avant que je puisse expliquer ce que font les monades et comment elles travaillent, j'ai besoin d'expliquer l'un des éléments les plus novateurs d'Haskell, son système de types.

Le système de Type

Parce que Haskell est si profondément liée aux mathématiques, ses concepteurs et ses praticiens sont intéressés à comprendre ce que signifie un programme. Le compilateur d'Haskell essaye de comprendre ce que votre programme signifie grâce à une analyse de type et de l'inférence de type.

Les l angages tels que C, Java et C# offrent une forme simple de typage statique. Le typage statique dans ces langues consiste à ajouter des annotations à chaque variable, fonction et déclaration de méthode pour indiquer au compilateur comment allouer de la mémoire et générer du code. Malheureusement, ce genre de frappe est très bavard et très vulnérable aux erreurs. Dans ces langues, le système de type peut également être subverti, par conversion brute de type ou en ignorant les erreurs de type par le compilateur. En conséquence, il n'est pas difficile d'écrire du code avec des erreurs de type, ce qui cause qu'un programme tombe en panne.

Les l angages comme Perl, Python, et Ruby offrent une certaine forme de typage dynamique. Dans ces langues, peu ou pas de vérification de type n'est faite lors de la compilation d'un programme. Au lieu de cela, les questions de type sont différés jusqu'à l'exécution, et un programme est supposé avoir un sens et être bien typé. Cela signifie que des annotations de type sont inutiles, et des programmes dans ces langues sont généralement plus concis que leurs homologues dans les langages C et similaires. Des erreurs de type, si elles se produisent, générent des exceptions à l'exécution, et peut entraîner le crash d'un programme de crash, ou elles peuvent être ignorés, conduisant à un comportement étrange et inattendue d'exécution.

Haskell a une approche très différente. Il commence par une analyse de type avec un correcteur de type, à comprendre votre programme au moment de la compilation. Le vérificateur de type interdit strictement la conversion brute de type et ne permet pas aux erreurs de type d'être ignorés. Parce que les types sont vérifiées à la compilation, et qu'il ne peut échapper au vérificateur de type, Haskell est souvent décrit comme la fois typé statiquement et fortement typé.

Tout programme Haskell avec une erreur de type n'est tout simplement pas compilable. Les programmes Haskell qui se compile sont exempts d'erreurs de type, et cette garantie élimine toute une classe d'accidents et de comportements inattendus d'exécution. (Les erreurs de type sont simplement un type de problème qui se glissent dans un programme. Il y a encore beaucoup de manières pour un programme Haskell de se compiler et de se mal conduire à l'exécution.)

En outre, Haskell utilise l'inférence de type pour déduire les types des fonctions et valeurs, éliminant le besoin d'annotations de type qui sont verbeuses dans la plupart des cas. Ces caractéristiques se combinent pour fournir plus de sécurité que le typage statique des langage C et similaires, et un code au moins aussi concis que ceux trouvés dans les languages dynamiques.

Par exemple, voici la définition de length , une fonction qui prend une liste et retourne le nombre de valeurs qu'il contient :

length [] = 0
length (x:xs) = 1 + length xs

Voici une annotation de type que le compilateur peut déduire de cette fonction :

length :: [a] -> Int

Cette annotation peut être lu comme suit :

Le compilateur peut déduire ce type parce que :

Bien que les annotations de type ne sont pas strictement nécessaire, c'est une bonne idée de les inclure directement au-dessus une définition de fonction. Cela permet au compilateur de vérifier que votre idée du type de la fonction correspond au type réel de la fonction. Plus important encore, l'annotation de type agit également à titre de documentation pour d'autres qui lisent votre code. Une meilleure façon de définir length serait :

length :: [a] -> Int
length [] = 0
length (x:xs) = 1 + length xs

Voici une définition de la fonction d'ordre supérieur map, qui applique une fonction à chaque élément d'une liste pour créer une nouvelle liste :

map :: (a -> b) -> [a] -> [b]
map f (x:xs) = (fx):(map f xs)

Cette annotation de type signifie ici ce qui suit :

Voici un exemple d'une fonction utilisant map :

ft = map length (lines t)

Le compilateur peut déduire le type de cette fonction :

f :: String -> [Int]

C'est parce que :

Par conséquent, le type de f doit être String -> [Int] (parfois formulée comme [Char] -> [Int] ).

Classes de Type

Jusqu'à présent, l'inférence de type semble utile pour déduire le type d'une fonction lorsque des types spécifiques ou des types génériques sont utilisés. Toutefois, cela laisse un vide énorme dans les fonctions intermédiaires où n'importe quel type d'un groupe de types liés peut être utilisés.

C'est là que les classes de type entrent en jeu. Une classe de type unifie un ensemble de types connexes, interchangeables. Voici un exemple typique, la class de type Num qui définit une série d'opérations qui peuvent être utilisés avec tout type de nombre :

class Num a where
  (+) :: a -> a -> a
  (-) :: a -> a -> a
  (*) :: a -> a -> a
  ...

Cette déclaration de classe signifie que n'importe quel type a qui est un membre de la classe de type Num doit mettre en œuvre les trois opérations + , - , et * (et quelques autres qui ne figurent pas ici). En outre, la mise en œuvre de + pour tout type qui implémente cette interface doit prendre deux valeurs et renvoyer une valeur. Les trois valeurs doivent également être du même type numérique.

Une déclarations de classe de type définit l'interface qu'un type doit mettre en œuvre pour être membre de cette classe de type. Une déclaration d'instance lie ensemble cette interface avec une mise en oeuvre spécifique pour un type simple. Voici les déclarations d'instance qui précisent que Int et Double sont tous deux membres de la classe de type Num :

-- low level multiplication of 2 integers
mulInt :: Int -> Int -> Int
mulInt a b = ...


instance Num Int where
    (*) = mulInt
    ...


-- low level multiplication of 2 doubles
mulDouble :: Double -> Double -> Double
mulDouble a b = ...


instance Num Double where
    (*) = mulDouble
    ...

Le vérificateur de type sait comment en déduire quand un type de classe est nécessaire pour décrire le type d'une fonction. Voici la simple fonction de l'article précédent que le nombre de places :

square x = x * x

L'inferencer de type déduit le type suivant pour cette fonction :

square :: (Num a) => a -> a

Cette signature de type est un peu différente, parce qu'il inclut une contrainte supplémentaire, à savoir l'implication (Num a) à la gauche du symbole => . Cela signifie que la fonction square est une fonction qui prend une valeur de type a , et retourne une valeur du même type a , et ce type doit être également membre de la classe type Num .

Par exemple, l'expression square 3 est valable, parce que 3 est de type Int et Int est un membre de la classe de type Num. Évaluer cette expression entraine le compilateur Haskell à regarder sous les couvertures pour voir comment la multiplication est définie pour les valeurs Int et de remplacer l'invocation de * par la mise en œuvre effective de la multiplication des Int , c'est à dire par la fonction mulInt. L'évaluation de l'expression square 2.5 est valable également, parce que la valeur 2.5 est de type Double et Double est un membre de la classe de type Num. Dans ce cas, le compilateur appelle la version de * pour le type Double , c'est à dire la fonction nommée mulDouble dans cet exemple.

L'expression square "this" n'est pas valide parce que square est définie uniquement sur les nombres, et la valeur "this" est une chaîne, et les chaînes ne sont pas des numéros. Tenter d'évaluer cette expression dans l'interpréteur produit l'erreur suivante :

$ ghci
...
Prelude> let square x = x * x
Prelude> :t square
square :: (Num a) => a -> a
Prelude> square "this"


<interactive>:1:0:
    No instance for (Num [Char])
      arising from use of `square' at <interactive>:1:0-12
    Possible fix: add an instance declaration for (Num [Char])
    In the expression: square "this"
    In the definition of `it': it = square "this"

Voici la preuve que cette définition de square fonctionne sur différents types de nombres :

Prelude> square 3
9
Prelude> square 2.5
6.25

Monades

Maintenant que vous avez vu comment les classes de types regroupent une série de types, il est temps de revenir sur la question des monades. Les classes de types sont importantes, parce que, au niveau le plus fondamental de l'implémentation, les monades ne sont que des conteneurs qui sont des instances de la classe de type Monad :

class Monad m where
    (>>=)  :: m a -> (a -> m b) -> m b
    (>>)   :: m a -> m b -> m b
    return :: a -> m a
    fail   :: String -> m a

Voici ce que cette déclaration de classe de type signifie :

Ces idées viennent de la théorie des catégories, qui définit également quelques propriétés importantes que les opérateurs monadiques doivent fournir :

Ces définitions sont tout à fait formelle et peuvent être quelque peu déroutante les premières fois que vous les lisez, mais l'objectif ici est de définir une série d'opérations mécaniques simples pour le raccordement des conteneurs ensemble. Les monades sont un moyen d'enchaîner une séquence d'opérations d'une manière qui fonctionne en utilisant seulement des fonctions pures. Et parce qu'elles sont définis en termes de fonctions pure, le compilateur Haskell peut les comprendre et traiter leur travaux pour vous.

Penser à ces opérations dans l'abstrait peut être un peu difficile. Voici un exemple simple. Ces deux fonctions fonctionnent dans la monade IO, le mécanisme de Haskell utilisé pour communiquer avec le monde extérieur.

getLine :: IO String
putStrLn :: String -> IO ()

Parce que toutes les opérations d'Entré/Sortie sont monadique, la monade IO agit comme un conteneur qui isole les effets secondaires externes dans un programme Haskell. La fonction getLine lit une ligne d'entrée de la console et renvoie cette chaîne à l'intérieur du container IO. La fonction putStrLn accepte une valeur de chaîne, imprime cette chaîne à la console (avec un saut de ligne), et retourne une valeur vide, également contenu dans la monade IO.

Voici comment ces fonctions sont reliées entre elles, en utilisant les opérateurs de monades :

echo :: IO ()
echo = getLine >>= putStrLn

Dans cet exemple, l'action monadique getLine est évaluée et produit un IO String en résultat. La fonction bind ( >>= ) obtient ce résultat, extraits la chaîne de son conteneur IO, et l'envoie à l'action monadique putStrLn . Enfin, l'action putStrLn consomme cette chaîne simple, il l'envoie à la console, et retourne la valeur vide dans le conteneur IO (de type IO () ). Ce résultat final est renvoyé par la fonction echo , aussi echo doit avoir le type IO ().

Utiliser les opérateurs monades de cette façon peut être puissant, mais c'est très encombrant. Heureusement, Haskell propose un autre moyen de décrire les opérations monadiques, la notation "monadic do" . Voici une façon équivalente d'écrire la fonction echo :

echo :: IO ()
echo = do str <- getLine
          putStrLn str

Ici, une fonction monadique est une série de déclarations, alignés selon la règle de mise en page. La fonction getLine retourne une valeur, de sorte que la valeur peut être capturé dans le symbole str et utilisées plus tard dans cette fonction. (Rappelez-vous, str n'est pas une variable, parce que sa valeur ne peut pas changer.) Parce que cette valeur est extraite à partir du conteneur monadique, la valeur de str ici est de type String, et pas IO String. Par conséquent, cette valeur peut être passée à la fonction putStrLn , qui s'attend à une String comme premier argument.

Parce que ces actions sont effectuées de gauche à droite (ou de haut en bas dans la notation "monadic do"), les monades exécutent une liste ordonnée d'instructions que constituent leurs déclarations. Avec des fonctions pures, les sous-expressions peuvent être évalués dans n'importe quel ordre, sans en changer le sens. Avec des fonctions monadique, l'ordre d'exécution est très important. Par exemple, il est inutile d'évaluer putStrLn dans cette fonction avant de recevoir son entrée à partir de getLine .

Conclusion

La monade IO (la monade d'Entré/Sortie) n'est pas la seul monade en Haskell, mais elle est l'une des plus fréquemment utilisés. D'autres monades proposent différents types de conteneurs et de structures de code. Par exemple, les listes sont de simples structures de données, mais elles peuvent aussi être utilisés comme des monades. La bibliothèque standard de GHC contient des séries de monades utiles qui sont simples, génériques, et utile dans une variété de domaines. L'une de ces bibliothèque est Parsec, une bibliothèque très pratique pour écrire des analyseurs syntaxiques.

Un grand nombre de nouveaux travaux passionnants en Haskell sont fait en utilisant des monades. Par exemple, la mémoire transactionnelle et l'imbrication de parallélisme de données sont deux techniques utiles qui aident lors de la distribution d'un programme Haskell sur plusieurs processeurs. Les parties du programme qui emploient ces particularités sont strictement isolées des parties du programme qui sont purement fonctionnel ou traitant avec IO. Cela rend la tâche plus facile pour le compilateur d'isoler les parties d'un programme qui sont transactionnel ou en parallèle, des parties qui ne le sont pas. En outre, le système de type applique cette séparation et met en lumière les bugs dans le code où les monades sont mal utilisés et ne font aucun sens.

Toutes ces structures ont une chose en commun : elles sont des types de conteneurs qui sont déclarés comme des instances de la classe de type Monad. Chaque type de monade définit sa version des opérations de base : bind ( >>= ), then ( >> ), return, et fail. Le compilateur Haskell peut alors composer une séquence d'actions monadiques en une seule grosse action monadique. Le compilateur remplace également ces opérations génériques par des implémentations spécifiques nécessaires pour une monade donnée, de même qu'il remplace la multiplication générique numérique avec l'opération spécifique nécessaire pour multiplier des valeurs Int ou Double . En outre, parce que toutes les monades utilisent les mêmes opérateurs de base, une actions monadique compliquée peut être exprimée par une simple notation "monadic do", ce qui rend ces opérations plus faciles à comprendre.