Pourquoi le concept d'évaluation paresseuse est-il utile?


30

Il semble qu'une évaluation paresseuse des expressions puisse faire perdre au programmeur le contrôle de l'ordre dans lequel son code est exécuté. J'ai du mal à comprendre pourquoi cela serait acceptable ou souhaité par un programmeur.

Comment ce paradigme peut-il être utilisé pour construire un logiciel prévisible qui fonctionne comme prévu, alors que nous n'avons aucune garantie quand et où une expression sera évaluée?


10
Dans la plupart des cas, cela n'a pas d'importance. Pour tous les autres, vous pouvez simplement appliquer la rigueur.
Cat Plus Plus

22
Le point des langages purement fonctionnels comme haskell est que vous n'avez pas à vous soucier de l'exécution du code, car il est sans effet secondaire.
bitmask

21
Vous devez cesser de penser à "exécuter du code" et commencer à penser à "calculer les résultats", car c'est ce que vous voulez vraiment dans les problèmes les plus intéressants. Bien sûr, les programmes doivent généralement interagir avec l'environnement d'une manière ou d'une autre, mais cela peut souvent être réduit à une petite partie du code. Pour le reste, vous pouvez travailler purement fonctionnel , et là, la paresse peut rendre le raisonnement beaucoup plus simple.
leftaroundabout

6
La question dans le titre ("Pourquoi utiliser l'évaluation paresseuse?") Est très différente de la question dans le corps ("Comment utilisez-vous l'évaluation paresseuse?"). Pour le premier, voir ma réponse à cette question connexe .
Daniel Wagner

1
Un exemple lorsque la paresse est utile: à Haskell, la complexité head . sortest O(n)due à la paresse (pas O(n log n)). Voir Évaluation paresseuse et complexité temporelle .
Petr Pudlák, le

Réponses:


62

Beaucoup de réponses portent sur des choses comme des listes infinies et des gains de performances de parties non évaluées du calcul, mais il manque la motivation plus grande pour la paresse: la modularité .

L'argument classique est exposé dans le document très cité «Pourquoi la programmation fonctionnelle est importante» (lien PDF) par John Hughes. L'exemple clé de cet article (Section 5) est de jouer au Tic-Tac-Toe en utilisant l'algorithme de recherche alpha-bêta. Le point clé est (p. 9):

[L'évaluation paresseuse] rend pratique la modularisation d'un programme comme un générateur qui construit un grand nombre de réponses possibles, et un sélecteur qui choisit la bonne.

Le programme Tic-Tac-Toe peut être écrit comme une fonction qui génère l'arbre de jeu entier à partir d'une position donnée, et une fonction distincte qui le consomme. Au moment de l'exécution, cela ne génère pas intrinsèquement l'ensemble de l'arborescence du jeu, uniquement les sous-parties dont le consommateur a réellement besoin. Nous pouvons changer l'ordre et la combinaison dans lesquels les alternatives sont produites en changeant le consommateur; pas besoin de changer le générateur du tout.

Dans une langue avide, vous ne pouvez pas l'écrire de cette façon car vous passeriez probablement trop de temps et de mémoire à générer l'arbre. Vous vous retrouvez donc soit:

  1. Combiner la génération et la consommation dans la même fonction;
  2. Écrire un producteur qui ne fonctionne de manière optimale que pour certains consommateurs;
  3. Implémenter votre propre version de la paresse.

Veuillez plus d'informations ou un exemple. Cela semble intrigant.
Alex Nye

1
@AlexNye: Le papier de John Hughes a plus d'informations. Bien qu'il s'agisse d'un article universitaire --- et donc sans aucun doute intimidant --- il est en fait très accessible et lisible. Sinon pour sa longueur, il conviendrait probablement comme réponse ici!
Tikhon Jelvis

Peut-être pour comprendre cette réponse, il faut lire l'article de Hughes ... sans l'avoir fait, je n'arrive toujours pas à voir comment et pourquoi la paresse et la modularité sont liées.
stakx

@stakx Sans une meilleure description, ils ne semblent liés que par hasard. L'avantage de la paresse dans cet exemple est qu'un générateur paresseux est capable de générer tous les états possibles du jeu, mais ne perd pas de temps / mémoire à le faire car seuls ceux qui se produisent seront consommés. Le générateur peut être séparé du consommateur sans être un générateur paresseux, et il est possible (quoique plus difficile) d'être paresseux sans être séparé du consommateur.
Izkata

@Iztaka: "Le générateur peut être séparé du consommateur sans être un générateur paresseux, et il est possible (quoique plus difficile) d'être paresseux sans être séparé du consommateur." Notez que lorsque vous empruntez cette voie, vous pouvez vous retrouver avec un ** générateur excessivement spécialisé ** - le générateur a été écrit pour optimiser un consommateur, et lorsqu'il est réutilisé pour d'autres, il n'est pas optimal. Un exemple courant est les mappeurs objet-relationnels qui récupèrent et construisent un graphique d'objet entier juste parce que vous voulez une chaîne de l'objet racine. La paresse évite beaucoup de ces cas (mais pas tous).
sacundim

32

Comment ce paradigme peut-il être utilisé pour construire un logiciel prévisible qui fonctionne comme prévu, alors que nous n'avons aucune garantie quand et où une expression sera évaluée?

Lorsqu'une expression est exempte d'effets secondaires, l'ordre dans lequel les expressions sont évaluées n'affecte pas leur valeur, donc le comportement du programme n'est pas affecté par l'ordre. Le comportement est donc parfaitement prévisible.

Maintenant, les effets secondaires sont une autre affaire. Si des effets secondaires pouvaient survenir dans n'importe quel ordre, le comportement du programme serait en effet imprévisible. Mais ce n'est pas vraiment le cas. Les langages paresseux comme Haskell se font un point d'être référentiellement transparent, c'est-à-dire de s'assurer que l'ordre dans lequel les expressions sont évaluées n'affectera jamais leur résultat. Dans Haskell, cela est obtenu en forçant toutes les opérations avec des effets secondaires visibles par l'utilisateur à se produire à l'intérieur de la monade IO. Cela garantit que tous les effets secondaires se produisent exactement dans l'ordre que vous attendez.


15
C'est pourquoi seuls les langages de "pureté renforcée" comme Haskell supportent la paresse partout par défaut. Les langages de "pureté encouragée" comme Scala ont besoin que le programmeur dise explicitement où ils veulent la paresse, et c'est au programmeur de s'assurer que la paresse ne causera pas de problèmes. Un langage paresseux par défaut et aux effets secondaires non suivis serait en effet difficile à programmer de façon prévisible.
Ben

1
des monades autres que IO peuvent également provoquer des effets secondaires
jk.

1
@jk Seul IO peut provoquer des effets secondaires externes .
dave4420

@ dave4420 oui mais ce n'est pas ce que dit cette réponse
jk.

1
@jk À Haskell, non. Aucune monade sauf IO (ou celles basées sur IO) n'a d'effets secondaires. Et ce n'est que parce que le compilateur traite les E / S différemment. Il considère IO comme "Immutable Off". Les monades ne sont qu'un moyen (intelligent) de garantir un ordre d'exécution spécifique (votre fichier ne sera donc supprimé qu'après que l'utilisateur aura entré "oui").
scarfridge

22

Si vous connaissez les bases de données, un moyen très fréquent de traiter les données est:

  • Poser une question comme select * from foobar
  • Bien qu'il y ait plus de données, faites: obtenez la prochaine ligne de résultats et traitez-la

Vous ne contrôlez pas la façon dont le résultat est généré et de quelle manière (index? Analyses complètes de la table?), Ni quand (toutes les données doivent-elles être générées en une seule fois ou de manière incrémentielle lors de la demande?). Tout ce que vous savez, c'est: s'il y a plus de données, vous les obtiendrez lorsque vous les demanderez.

L'évaluation paresseuse est assez proche de la même chose. Disons que vous avez une liste infinie définie comme ie. la séquence de Fibonacci - si vous avez besoin de cinq nombres, vous obtenez cinq nombres calculés; si vous avez besoin de 1000, vous obtenez 1000. L'astuce est que le runtime sait quoi fournir où et quand. C'est très, très pratique.

(Les programmeurs Java peuvent émuler ce comportement avec les itérateurs - d'autres langages peuvent avoir quelque chose de similaire)


Bon point. Par exemple Collection2.filter()(ainsi que les autres méthodes de cette classe) implémente à peu près l'évaluation paresseuse: le résultat "ressemble" à un ordinaire Collection, mais l'ordre d'exécution peut être non intuitif (ou du moins non évident). En outre, il existe yielden Python (et une fonctionnalité similaire en C #, dont je ne me souviens pas du nom) qui est encore plus proche de la prise en charge de l'évaluation paresseuse qu'un Iterator normal.
Joachim Sauer

@JoachimSauer en C # son retour de rendement, ou bien sûr vous pouvez utiliser les opérateurs linq pré-construction, dont environ la moitié sont paresseux
jk.

+1: Pour mentionner les itérateurs dans un langage impératif / orienté objet. J'ai utilisé une solution similaire pour implémenter des flux et des fonctions de flux en Java. En utilisant des itérateurs, je pourrais avoir des fonctions comme take (n), dropWhile () sur un flux d'entrée de longueur inconnue.
Giorgio

13

Pensez à demander à votre base de données une liste des 2 000 premiers utilisateurs dont les noms commencent par «Ab» et ont plus de 20 ans. Ils doivent également être des hommes.

Voici un petit diagramme.

You                                            Program Processor
------------------------------------------------------------------------------
Get the first 2000 users ---------->---------- OK!
                         --------------------- So I'll go get those records...
WAIT! Also, they have to ---------->---------- Gotcha!
start with "Ab"
                         --------------------- NOW I'll get them...
WAIT! Make sure they're  ---------->---------- Good idea Boss!
over 20!
                         --------------------- Let's go then...
And one more thing! Make ---------->---------- Anything else? Ugh!
sure they're male!

No that is all. :(       ---------->---------- FINE! Getting records!

                         --------------------- Here you go. 
Thanks Postgres, you're  ---------->----------  ...
my only friend.

Comme vous pouvez le voir par cette terrible et terrible interaction, la "base de données" ne fait rien tant qu'elle n'est pas prête à gérer toutes les conditions. Ce sont des résultats paresseux à chaque étape et l'application de nouvelles conditions à chaque fois.

Au lieu d'obtenir les 2000 premiers utilisateurs, de les renvoyer, de les filtrer pour "Ab", de les renvoyer, de les filtrer pour plus de 20, de les renvoyer, et de les filtrer pour les hommes et enfin de les renvoyer.

Chargement paresseux en bref.


1
c'est une explication vraiment moche IMHO. Malheureusement, je n'ai pas assez de représentants sur ce site SE particulier pour voter contre. Le vrai point de l'évaluation paresseuse est qu'aucun de ces résultats n'est réellement produit tant que quelque chose d'autre n'est pas prêt à les consommer.
Alnitak

Ma réponse postée dit exactement la même chose que votre commentaire.
sergserg

C'est un processeur de programme très poli.
Julian

9

L'évaluation paresseuse des expressions entraînera la perte de contrôle du concepteur d'un morceau de code donné sur la séquence d'exécution de leur code.

Le concepteur ne devrait pas se soucier de l'ordre dans lequel les expressions sont évaluées à condition que le résultat soit le même. En différant l'évaluation, il peut être possible d'éviter d'évaluer complètement certaines expressions, ce qui fait gagner du temps.

Vous pouvez voir la même idée à l'oeuvre à un niveau inférieur: de nombreux microprocesseurs sont capables d'exécuter des instructions dans le désordre, ce qui leur permet d'utiliser plus efficacement leurs différentes unités d'exécution. La clé est qu'ils examinent les dépendances entre les instructions et évitent de réorganiser où cela changerait le résultat.


5

Il y a plusieurs arguments pour une évaluation paresseuse, je pense, sont convaincants

  1. Modularité Avec l'évaluation paresseuse, vous pouvez diviser le code en plusieurs parties. Par exemple, supposons que vous ayez le problème de "trouver les dix premiers inverses d'éléments dans une liste, de sorte que les inverses soient inférieures à 1." Dans quelque chose comme Haskell, vous pouvez écrire

    take 10 . filter (<1) . map (1/)
    

    mais c'est juste incorrect dans un langage strict, car si vous le donnez, [2,3,4,5,6,7,8,9,10,11,12,0]vous diviserez par zéro. Voir la réponse de sacundim pour savoir pourquoi c'est génial dans la pratique

  2. Plus de choses fonctionnent Strictement (jeu de mots), plus de programmes se terminent avec une évaluation non stricte qu'avec une évaluation stricte. Si votre programme se termine par une stratégie d'évaluation «impatiente», il se terminera par une stratégie «paresseuse», mais l'opposé n'est pas vrai. Vous obtenez des choses comme des structures de données infinies (qui sont vraiment un peu cool) comme exemples spécifiques de ce phénomène. Plus de programmes fonctionnent dans des langues paresseuses.

  3. Optimalité L'évaluation de l'appel par besoin est asymptotiquement optimale par rapport au temps. Bien que les principaux langages paresseux (qui sont essentiellement Haskell et Haskell) ne promettent pas un appel par besoin, vous pouvez plus ou moins vous attendre à un modèle de coût optimal. Les analyseurs de rigueur (et l'évaluation spéculative) réduisent les frais généraux dans la pratique. L'espace est une question plus compliquée.

  4. Forces Purity utilisant une évaluation paresseuse rend la gestion des effets secondaires indisciplinée une douleur totale, car comme vous le dites, le programmeur perd le contrôle. C'est une bonne chose. La transparence référentielle facilite la programmation, la réfraction et le raisonnement sur les programmes. Les langues strictes cèdent inévitablement à la pression d'avoir des morceaux impurs - quelque chose que Haskell et Clean ont merveilleusement résisté. Cela ne veut pas dire que les effets secondaires sont toujours mauvais, mais les contrôler est si utile que cette seule raison suffit pour utiliser des langages paresseux.


2

Supposons que vous proposiez de nombreux calculs coûteux, mais ne savez pas lesquels seront réellement nécessaires, ni dans quel ordre. Vous pouvez ajouter un protocole mère-mai-i compliqué pour forcer le consommateur à déterminer ce qui est disponible et à déclencher des calculs qui ne sont pas encore effectués. Ou vous pouvez simplement fournir une interface qui agit comme si tous les calculs étaient effectués.

Supposons également que vous ayez un résultat infini. L'ensemble de tous les nombres premiers par exemple. Il est évident que vous ne pouvez pas calculer l'ensemble à l'avance, donc toute opération dans le domaine des nombres premiers doit être paresseuse.


1

avec une évaluation paresseuse, vous ne perdez pas le contrôle de l'exécution du code, c'est toujours absolument déterministe. Il est cependant difficile de s'y habituer.

L'évaluation paresseuse est utile parce que c'est un moyen de réduire le terme lambda qui se terminera dans certains cas, où une évaluation désirée échouera, mais pas l'inverse. Cela comprend 1) lorsque vous devez établir un lien avec le résultat du calcul avant d'exécuter réellement le calcul, par exemple, lorsque vous construisez une structure de graphique cyclique, mais que vous souhaitez le faire dans le style fonctionnel 2) lorsque vous définissez une structure de données infinie, mais que vous utilisez ce flux de structure pour n'utiliser qu'une partie de la structure de données.

En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.