Idées fausses sur des langages purement fonctionnels?

39

Je rencontre souvent les déclarations / arguments suivants:

  1. Les langages de programmation purement fonctionnels ne permettent pas les effets secondaires (et sont donc peu utiles dans la pratique car tout programme utile a des effets secondaires, par exemple lorsqu’il interagit avec le monde extérieur).
  2. Les langages de programmation purement fonctionnels ne permettent pas d’écrire un programme qui maintient l’état (ce qui rend la programmation très délicate, car dans de nombreuses applications, vous avez besoin d’état).

Je ne suis pas un expert en langages fonctionnels mais voici ce que j'ai compris de ces sujets jusqu'à présent.

En ce qui concerne le point 1, vous pouvez interagir avec l'environnement dans des langages purement fonctionnels, mais vous devez marquer explicitement le code (fonctions) introduisant des effets secondaires (par exemple, en Haskell au moyen de types monadiques). Aussi, autant que je sache, l'informatique par effets secondaires (mise à jour destructive des données) devrait également être possible (en utilisant des types monadiques?) Même si ce n'est pas la méthode de travail préférée.

En ce qui concerne le point 2, autant que je sache, vous pouvez représenter un état en enchaînant les valeurs à travers plusieurs étapes de calcul (en Haskell, là encore, en utilisant des types monadiques), mais je n’ai aucune expérience pratique dans ce domaine et ma compréhension est plutôt vague.

Alors, les deux affirmations précédentes sont-elles correctes ou sont-elles simplement des idées fausses sur des langages purement fonctionnels? Si ce sont des idées fausses, comment sont-elles apparues? Pourriez-vous écrire un extrait de code (éventuellement petit) illustrant la méthode idiomatique de Haskell pour (1) implémenter des effets secondaires et (2) implémenter un calcul avec state?

Giorgio
la source
7
Je pense que cela dépend en grande partie de ce que vous définissez comme étant un langage fonctionnel «pur».
jk.
@jk: Pour éviter le problème de la définition de langages fonctionnels «purs», supposons la pureté au sens de Haskell (qui est bien défini). Dans quelles conditions un langage fonctionnel peut être considéré comme «pur» peut faire l’objet d’une question future.
Giorgio
Les deux réponses contiennent de nombreuses idées de clarification et il m'est difficile de choisir laquelle accepter. J'ai décidé d'accepter la réponse de sepp2k à cause des exemples supplémentaires de pseudo-codes.
Giorgio

Réponses:

26

Dans le cadre de cette réponse, je définis «langage purement fonctionnel» comme un langage fonctionnel dans lequel les fonctions sont transparentes de manière référentielle, c'est-à-dire que l'appel d'une même fonction plusieurs fois avec les mêmes arguments produira toujours les mêmes résultats. C'est, je crois, la définition habituelle d'un langage purement fonctionnel.

Les langages de programmation purement fonctionnels ne permettent pas les effets secondaires (et sont donc peu utiles dans la pratique car tout programme utile a des effets secondaires, par exemple lorsqu’il interagit avec le monde extérieur).

Le moyen le plus simple d’atteindre la transparence référentielle serait en effet d’annuler les effets secondaires et c’est effectivement le cas dans certaines langues (principalement dans des domaines spécifiques). Cependant, ce n'est certainement pas le seul moyen et la plupart des langages purement fonctionnels (Haskell, Clean, ...) permettent des effets secondaires.

Dire également qu’un langage de programmation sans effets secondaires est peu utilisé en pratique n’est pas vraiment juste, à mon avis - certainement pas pour les langages spécifiques à un domaine, mais même pour les langages à usage général, j’imagine qu’un langage peut être très utile sans générer d’effets secondaires . Peut-être pas pour les applications en console, mais je pense que les applications à interface graphique peuvent être bien implémentées sans effets secondaires, par exemple dans le paradigme réactif fonctionnel.

En ce qui concerne le point 1, vous pouvez interagir avec l'environnement dans des langages purement fonctionnels, mais vous devez marquer explicitement le code (les fonctions) qui les introduit (par exemple en Haskell au moyen de types monadiques).

C'est un peu trop simplifier. Le simple fait de disposer d'un système dans lequel les fonctions affectant les effets secondaires doivent être marquées comme telle (similaire à la correction de const en C ++, mais avec des effets secondaires généraux) ne suffit pas à assurer la transparence référentielle. Vous devez vous assurer qu'un programme ne peut jamais appeler une fonction plusieurs fois avec les mêmes arguments et obtenir des résultats différents. Vous pouvez soit le faire en faisant des choses commereadLinesoit quelque chose qui ne soit pas une fonction (c'est ce que Haskell fait avec la monade IO) ou vous pouvez rendre impossible l'appel de fonctions ayant des effets secondaires plusieurs fois avec le même argument (c'est ce que fait Clean). Dans ce dernier cas, le compilateur s'assurera que chaque fois que vous appelez une fonction à effet secondaire, vous le ferez avec un nouvel argument et rejettera tout programme dans lequel vous passerez le même argument deux fois à une fonction à effet secondaire.

Les langages de programmation purement fonctionnels ne permettent pas d’écrire un programme qui maintient l’état (ce qui rend la programmation très délicate, car dans de nombreuses applications, vous avez besoin d’état).

Encore une fois, un langage purement fonctionnel peut très bien interdire l’état mutable, mais il est certainement possible d’être pur et d’avoir un état mutable, si vous le mettez en œuvre de la même manière que celle que j’ai décrite avec les effets secondaires ci-dessus. L’état réellement mutable n’est qu’une autre forme d’effets secondaires.

Cela dit, les langages de programmation fonctionnels découragent définitivement les états mutables - les purs, en particulier. Et je ne pense pas que cela rende la programmation difficile, bien au contraire. Parfois (mais pas très souvent), l'état mutable ne peut pas être évité sans perte de performance ou de clarté (c'est pourquoi des langues telles que Haskell disposent de fonctionnalités pour l'état mutable), mais le plus souvent.

Si ce sont des idées fausses, comment sont-elles apparues?

Je pense que beaucoup de gens lisent simplement "une fonction doit produire le même résultat quand ils sont appelés avec les mêmes arguments" et en concluent qu'il n'est pas possible d'implémenter quelque chose comme readLineun code qui maintient l'état mutable. Donc, ils ne sont tout simplement pas au courant des "astuces" que des langages purement fonctionnels peuvent utiliser pour introduire ces choses sans casser la transparence référentielle.

De plus, l'état mutable est fortement découragé dans les langages fonctionnels, il n'est donc pas si difficile de supposer que ce n'est pas permis dans les langages purement fonctionnels.

Pourriez-vous écrire un extrait de code (éventuellement petit) illustrant la méthode idiomatique de Haskell pour (1) implémenter des effets secondaires et (2) implémenter un calcul avec state?

Voici une application dans Pseudo-Haskell qui demande à l'utilisateur un nom et le salue. Le pseudo-Haskell est un langage que je viens d'inventer, qui possède le système IO de Haskell, mais utilise une syntaxe plus conventionnelle, des noms de fonction plus descriptifs et ne comporte pas de do-notation (car cela détournerait du fonctionnement exact de la monade IO):

greet(name) = print("Hello, " ++ name ++ "!")
main = composeMonad(readLine, greet)

L'indice ici est que readLinec'est une valeur de type IO<String>et composeMonadune fonction qui prend un argument de type IO<T>(pour un type T) et un autre argument qui est une fonction qui prend un argument de type Tet retourne une valeur de type IO<U>(pour un type U). printest une fonction qui prend une chaîne et retourne une valeur de type IO<void>.

Une valeur de type IO<A>est une valeur qui "code" une action donnée qui produit une valeur de type A. composeMonad(m, f)produit une nouvelle IOvaleur qui code l'action de msuivie de l'action de f(x), où xest la valeur produite en effectuant l'action de m.

L'état Mutable ressemblerait à ceci:

counter = mutableVariable(0)
increaseCounter(cnt) =
    setIncreasedValue(oldValue) = setValue(cnt, oldValue + 1)
    composeMonad(getValue(cnt), setIncreasedValue)

printCounter(cnt) = composeMonad( getValue(cnt), print )

main = composeVoidMonad( increaseCounter(counter), printCounter(counter) )

Voici mutableVariableune fonction qui prend la valeur de tout type Tet produit un MutableVariable<T>. La fonction getValueprend MutableVariableet retourne un IO<T>qui produit sa valeur actuelle. setValueprend un MutableVariable<T>et a Tet retourne un IO<void>qui définit la valeur. composeVoidMonadest le même que composeMonadsauf que le premier argument est un IOqui ne produit pas de valeur sensible et que le second argument est un autre monade, pas une fonction qui retourne un monade.

En Haskell, il existe un sucre syntaxique qui rend toute cette épreuve moins pénible, mais il est toujours évident que l'état mutable est quelque chose que le langage ne veut pas vraiment que vous fassiez.

sepp2k
la source
Excellente réponse, clarifiant beaucoup d'idées. La dernière ligne de l'extrait de code doit-elle utiliser le nom counter, c'est increaseCounter(counter)-à- dire ?
Giorgio
@ George Oui, il le devrait. Fixé.
sepp2k
1
@Giorgio Une chose que j'ai oublié de mentionner explicitement dans mon message est que l'action IO renvoyée par mainsera celle qui sera réellement exécutée. Autre que le retour d'un E / S depuis mainil n'y a aucun moyen d'exécuter des IOactions (sans utiliser des fonctions horriblement mauvaises qui ont unsafepour nom).
sepp2k
D'ACCORD. Scarfridge a également mentionné des IOvaleurs destructrices . Je ne comprenais pas s'il faisait référence à la correspondance de modèle, c'est-à-dire au fait que vous pouvez déconstruire les valeurs d'un type de données algébrique, mais vous ne pouvez pas utiliser la correspondance de modèle pour le faire avec des IOvaleurs.
Giorgio
16

IMHO vous êtes confus parce qu'il y a une différence entre un langage pur et une fonction pure . Commençons par la fonction. Une fonction est pure si elle retourne toujours la même valeur (avec la même entrée) et ne provoque aucun effet secondaire observable. Des exemples typiques sont des fonctions mathématiques telles que f (x) = x * x. Considérons maintenant une implémentation de cette fonction. Il serait pur dans la plupart des langues, même dans celles qui ne sont généralement pas considérées comme des langages fonctionnels purs, par exemple ML. Même une méthode Java ou C ++ avec ce comportement peut être considérée comme pure.

Alors, quelle est une langue pure? À proprement parler, on pourrait s'attendre à ce qu'un langage pur ne vous laisse pas exprimer des fonctions qui ne sont pas pures. Appelons cela la définition idéaliste d'un langage pur. Un tel comportement est hautement souhaitable. Pourquoi? Eh bien, l’avantage d’un programme ne contenant que des fonctions pures est que vous pouvez remplacer l’application de fonction par sa valeur sans changer la signification du programme. Cela rend très facile de raisonner sur les programmes car une fois que vous connaissez le résultat, vous pouvez oublier la façon dont il a été calculé. Purity pourrait également permettre au compilateur d’effectuer certaines optimisations agressives.

Alors que faire si vous avez besoin d'un état interne? Vous pouvez imiter l'état dans un langage pur en ajoutant simplement l'état avant le calcul en tant que paramètre d'entrée et l'état après le calcul en tant que partie du résultat. Au lieu de Int -> Boolvous obtenir quelque chose comme Int -> State -> (Bool, State). Vous expliquez simplement la dépendance (ce qui est considéré comme une bonne pratique dans tout paradigme de programmation). BTW est une monade qui est un moyen particulièrement élégant de combiner de telles fonctions imitant l’état à des fonctions plus grandes imitant l’état. De cette façon, vous pouvez certainement "maintenir l'état" dans un langage pur. Mais vous devez le rendre explicite.

Cela signifie-t-il que je peux interagir avec l'extérieur? Après tout, un programme utile doit interagir avec le monde réel pour être utile. Mais les entrées et les sorties ne sont évidemment pas pures. Écrire un octet spécifique dans un fichier spécifique peut convenir pour la première fois. Mais exécuter la même opération une seconde fois peut renvoyer une erreur car le disque est plein. Clairement, il n’existe pas de langage pur (au sens idéaliste) capable d’écrire dans un fichier.

Nous sommes donc confrontés à un dilemme. Nous voulons surtout des fonctions pures, mais certains effets secondaires sont absolument nécessaires et ceux-ci ne sont pas purs. Maintenant, une définition réaliste d'un langage pur serait qu'il doit y avoir un moyen de séparer les parties pures des autres parties. Le mécanisme doit garantir qu'aucune opération impure ne s'infiltre dans les parties pures.

En Haskell, cela est fait avec le type IO. Vous ne pouvez pas détruire un résultat d'E / S (sans mécanismes non sécurisés). Ainsi, vous ne pouvez traiter que les résultats IO avec les fonctions définies dans le module IO eux-mêmes. Heureusement, il existe des combinateurs très flexibles qui vous permettent de prendre un résultat IO et de le traiter dans une fonction tant que cette fonction renvoie un autre résultat IO. Ce combinateur s'appelle bind (ou >>=) et a le type IO a -> (a -> IO b) -> IO b. Si vous généralisez ce concept, vous arrivez à la classe de la monade et IO en est un exemple.

scarfridge
la source
4
Je ne vois pas vraiment comment Haskell (en ignorant toute fonction avec unsafeson nom) ne correspond pas à votre définition idéaliste. Il n'y a pas de fonctions impures dans Haskell (encore une fois ignorer unsafePerformIOet co.).
sepp2k
4
readFileet writeFileretournera toujours la même IOvaleur, avec les mêmes arguments. Ainsi , par exemple les deux extraits de code let x = writeFile "foo.txt" "bar" in x >> xet writeFile "foo.txt" "bar" >> writeFile "foo.txt" "bar"fera la même chose.
sepp2k
3
@AidanCully Qu'entendez-vous par "fonction IO"? Une fonction qui retourne une valeur de type IO Something? Si tel est le cas, il est parfaitement possible d'appeler deux fois une fonction IO avec le même argument: putStrLn "hello" >> putStrLn "hello"- ici, les deux appels doivent putStrLnavoir le même argument. Bien sûr, ce n'est pas un problème car, comme je l'ai dit précédemment, les deux appels donneront la même valeur d'E / S.
sepp2k
3
@scarfridge L'évaluation writeFile "foo.txt" "bar"ne peut pas causer d'erreur car l'évaluation de l'appel de fonction n'exécute pas l'action. Si vous dites que dans mon exemple précédent, la version avec letn'a qu'une seule occasion de provoquer une défaillance d'E / S alors que la version sans en leta deux, vous vous trompez. Les deux versions ont deux possibilités d'échec d'IO. Comme la letversion évalue l'appel writeFileune seule fois alors que la version sans l' letévalue deux fois, vous pouvez voir que la fréquence à laquelle la fonction est appelée n'a pas d'importance.
Peu
6
@AidanCully Le "mécanisme de la monade" ne transmet pas de paramètres implicites. La putStrLnfonction prend exactement un argument, qui est de type String. Si vous ne me croyez pas, regardez son type: String -> IO (). Cela ne prend certainement aucun argument de type IO- cela produit une valeur de ce type.
sepp2k