Les langages mauvais prétendent que les langages de programmation fonctionnels sont des «langages d'écriture factoriels». Ceci est le plus souvent défini comme le langage Haskell, mais nous commencerons par le langage fonctionnel qui a grandement influencé à la fois Haskell et un sous-ensemble des outils de programmation fonctionnelle pour de nombreux autres langages - Scheme. Au moins map
et for-each
, filter
et reduce
, ainsi eval
et eval
sont venus à nos langages de programmation préférés, sinon de Scheme, puis de là .
Examinez quelques façons possibles d'Ă©crire des calculs factoriels. En mĂȘme temps, vous obtenez une sorte d'ode au langage de programmation Scheme. Je pense que cette merveilleuse langue le mĂ©rite.
J'ai eu 10 options pour Ă©crire des dĂ©finitions de fonctions, qui peuvent ĂȘtre rĂ©duites Ă 3 mĂ©thodes de calcul principales: le processus de calcul rĂ©cursif linĂ©aire traditionnel, l'itĂ©ration, la gĂ©nĂ©ration d'une sĂ©quence de nombres, suivie d'une multiplication par convolution. Je propose d'examiner ces options plus en dĂ©tail. En cours de route, nous considĂ©rerons: l'optimisation de la rĂ©cursivitĂ© de la queue, les fonctions et mĂ©taprogrammations d'ordre supĂ©rieur, les calculs diffĂ©rĂ©s, les listes sans fin, la mĂ©morisation, un moyen de crĂ©er une variable statique dans Scheme et des macros d'hygiĂšne.
Pour les expériences, nous avons utilisé le bon vieux dialecte Schéma R5RS et le principe populaire des beaux-arts «moyens minimaux - impressions maximales».
Tous les exemples de schéma ont été préparés dans DrRacket 6.2 en mode R5RS. Les mesures d'exécution ont été effectuées dans Guile 2.0 dans l'environnement OpenSUSE Leap 15 OS.
Pour commencer, vous pouvez prendre une définition récursive de factorielle et simplement réécrire la formule sur Scheme:
(define (factorial-classic n) (if (zero? n) 1 (* n (factorial-classic (- n 1)))))
Le rĂ©sultat a Ă©tĂ© une dĂ©finition d'une fonction (en termes de Scheme - une procĂ©dure, bien qu'il s'agisse en fait d'une fonction) pour calculer la factorielle, qui peut ĂȘtre vue dans d'innombrables guides de programmation, Ă commencer par le livre immortel de H. Abelson et D. Sassman «Structure et interprĂ©tation des programmes informatiques» .
Vous pouvez lire et comprendre ce code comme ceci: factoriel n est lĂ 1 si n=0 sinon - n cdot(nâ1)! . Ainsi, ce code correspond Ă la dĂ©finition rĂ©cursive de factorielle, adoptĂ©e en mathĂ©matiques. La seule chose que nous ne vĂ©rifions pas l'affiliation n nombres non nĂ©gatifs.
Ătant rĂ©cursif, le code ci-dessus contient une restriction Ă©vidente sur la valeur n : les donnĂ©es d'appel rĂ©cursives s'accumuleront sur la pile jusqu'Ă n n'atteindra pas 0. Cela peut entraĂźner un dĂ©bordement de pile dans son ensemble n .
Comment puis-je supprimer cette restriction? Il est nécessaire d'optimiser la récursivité de la queue: réécrivez le code afin que l'appel récursif devienne la queue (c'est-à -dire le dernier de la procédure). Cela permettra à l'interpréteur Scheme d'effectuer l'optimisation - remplacer le calcul récursif par le calcul itératif.
Si vous utilisez les recommandations des auteurs du livre ci-dessus, vous pouvez obtenir les éléments suivants:
(define (factorial-classic-tco n) (define (iteration product counter) (if (> counter n) product (iteration (* product counter) (+ counter 1)))) (iteration 1 1))
Ce code est un exemple courant, et à partir du livre «La structure et l'interprétation des programmes informatiques», c'est sur lui que l'on explique généralement l'optimisation de la récursivité de la queue.
C'Ă©tait un classique. Mais le schĂ©ma est la flexibilitĂ© elle-mĂȘme, est-il possible d'Ă©crire la mĂȘme chose d'une maniĂšre fondamentalement diffĂ©rente? Et de prĂ©fĂ©rence encore plus court? Par exemple, selon l'entrĂ©e n!=1 cdot2 cdot3 cdot cdots cdotn former une sĂ©quence Ă partir de 1 avant n (ou de n avant 1 ) puis l'effondrer par multiplication? Heureusement, dans Scheme, c'est assez simple grĂące Ă la procĂ©dure d' apply
intégrée, qui applique une procédure avec un nombre arbitraire d'arguments à la liste:
(define (iota n) (define (iteration sequence i) (if (> in) sequence (iteration (cons i sequence) (+ i 1)))) (iteration '() 1)) (define (factorial-fold n) (apply * (iota n)))
Scheme est célÚbre pour sa commodité pour les calculs symboliques en raison de «l'unité de code et de données» (comme on dit parfois sur les langages de la famille Lisp). Nous utilisons cette fonctionnalité: nous formons une expression pour calculer la factorielle d'un nombre n puis calculez-le:
(define (factorial-eval n) (define expression `(* ,@(iota n))) (eval expression (interaction-environment)))
Le symbole «retour guillemet simple» signifie quasiquotation. Sans quasi-citation, l'obtention d'une expression pour un calcul ultĂ©rieur pourrait ĂȘtre obtenue en utilisant le code (cons '* (iota n))
. Une citation simple (citation, citation) signifie que *
doit ĂȘtre substituĂ© dans l'expression exactement comme un nom (symbole), et non la valeur correspondante (ici - la procĂ©dure). Donc, avec n=3 nous obtenons (* 3 2 1)
. Cette liste est une expression dans Scheme. Sa valeur peut ĂȘtre rĂ©alisĂ©e dans un environnement appropriĂ©, le meilleur de tous - dans un environnement (interaction-environment)
contenant les procédures intégrées et les procédures définies par nous dans le programme. En fait, c'est ce que nous faisons dans le corps de l' factorial-eval
.
Le schéma prend en charge l'informatique différée. Haskell, qui a été fortement influencé par Scheme, utilise un modÚle de calcul paresseux, c'est-à -dire ne calcule pas la valeur de l'expression tant que le résultat de ces calculs n'est pas revendiqué. Cela permet aux programmes d'avoir des structures de données particuliÚres comme des listes sans fin. Si seule la partie nécessaire à d'autres calculs leur est prélevée, le programme ne se déroulera pas en cycles:
ghci> take 4 [1 ..] [1,2,3,4]
L'expression [1 ..]
génÚre une liste infinie d'entiers. L'expression take 4
obtient les 4 premiers Ă©lĂ©ments de cette liste. Ătant donnĂ© que les Ă©lĂ©ments de liste suivants ne sont pas rĂ©clamĂ©s, ils ne sont pas calculĂ©s.
Chez Haskell, n! à partir d'une liste sans fin, vous pouvez écrire comme ceci:
factorials :: [Integer] factorials = next 0 1 where next n fact = let n' = n + 1 in fact : next n' (fact * n') factorial :: Integer -> Integer factorial n = factorials !! fromIntegral n
ghci> take 7 $ factorials [1,1,2,6,24,120,720] ghci> factorial 6 720
En utilisant quelques formes de delay
/ force
du schéma force
essayons de faire quelque chose de similaire. Le mot clé delay
crée une promesse pour évaluer la valeur d'une expression. Le mot force
clé force
demande d'effectuer ces calculs, la valeur résultante est calculée et stockée. Lors d'un accÚs répété, de nouveaux calculs ne sont pas effectués, mais la valeur calculée précédemment est renvoyée.
Dans les langages de la famille Lisp, les listes sont construites à partir de paires. Afin de construire des listes infinies, nous introduisons le type de «paire paresseuse» - une paire dans laquelle le premier élément est la valeur calculée, et le second est la promesse de calculer la valeur. Pour ce faire, nous devons compléter la «sainte trinité» des langages de la famille Lisp ( cons
, car
, cdr
) avec leurs versions paresseuses:
(define-syntax lazy-cons (syntax-rules () ((_ first second) (cons first (delay second))))) (define lazy-car car) (define (lazy-cdr lazy-pair) (force (cdr lazy-pair)))
Le constructeur de la paire lazy lazy-cons
est implémenté sous forme de macro. Ceci est fait afin d'éviter de calculer la valeur du deuxiÚme élément de la paire lors de sa création.
L'idée est de créer une liste infinie de valeurs, puis d'en tirer ce dont vous avez besoin. Pour ce faire, définissez la version paresseuse de la procédure d'obtention de l'élément par index:
(define (lazy-list-ref lazy-list index) (if (zero? index) (lazy-car lazy-list) (lazy-list-ref (lazy-cdr lazy-list) (- index 1)))) (define (generate-factorials) (define (next nn!) (define n+1 (+ n 1)) (lazy-cons n! (next n+1 (* n! n+1)))) (next 0 1))
Ici n!
et n+1
sont les noms des variables. Dans Scheme, par rapport Ă d'autres langues, il y a trĂšs peu de caractĂšres qui ne peuvent pas ĂȘtre utilisĂ©s dans les identificateurs.
Notez que le générateur de listes infinies generate-factorials
ne contient aucun moyen de sortir de la rĂ©cursivitĂ©. Cependant, il ne sera pas bouclĂ©, car lors de son appel, seule la tĂȘte de liste sera calculĂ©e, tandis que la queue sera reprĂ©sentĂ©e par une promesse de calculer la valeur.
Vous pouvez maintenant définir n! comment arriver n e élément de la liste des factorielles:
(define lazy-factorials (generate-factorials)) (define (factorial-lazy n) (lazy-list-ref lazy-factorials n))
Ăa marche. Dans le mĂȘme temps, si des factorielles de nombres diffĂ©rents sont calculĂ©es en une seule session de l'interprĂ©teur, les calculs se produiront plus rapidement que dans les versions strictes, car certaines des valeurs de la liste paresseuse seront dĂ©jĂ calculĂ©es.
Soit dit en passant, le code sur Scheme est trÚs proche de celui sur Haskell. Donc, la déclaration de réception !!
correspond approximativement à la procédure lazy-list-ref
constructeur de lazy-list-ref
:
correspond Ă lazy-cons
. En conséquence, parce que Haskell, bien qu'il professe un modÚle de calcul paresseux, cependant, contrairement au delay
/ force
dans Scheme, il ne se souvient pas des valeurs calculées.
Par ailleurs, pour augmenter la productivité, vous pouvez appliquer la mémorisation de valeurs déjà calculées - mémorisation. Nous allons stocker les valeurs calculées dans une liste associative, dans laquelle les clés sont des nombres et les valeurs sont leurs factorielles. Une fois appelé, nous allons parcourir la liste pour les valeurs déjà calculées. Si la valeur est dans la liste, nous retournerons cette valeur stockée. Si la valeur n'est pas dans la liste, nous la calculerons, la mettrons dans la liste et la renverrons ensuite. Pour nous assurer que cette liste est toujours avec la fonction appelée, et non dans l'environnement global, nous la plaçons dans une variable statique:
(define factorial-memoized (let ((memo '())) (lambda (n) (let ((memoized (assq n memo))) (if memoized (cadr memoized) (if (zero? n) 1 (let ((computed (* n (factorial-memoized (- n 1))))) (set! memo (cons (list n computed) memo)) computed)))))))
Variables statiques dans le schémaAfficher le code
(define proc (let ((static-var initial-value)) (lambda args ...)))
est une mĂ©thode acceptĂ©e par Scheme pour crĂ©er une procĂ©dure avec une variable statique. Le principe d'une telle annonce peut ĂȘtre facilement expliquĂ© par un exemple plus court - une procĂ©dure qui renvoie le nombre d'appels:
(define count (let ((n 0)) (lambda () (set! n (+ n 1)) n)))
Dans une session d'interprĂšte, le premier appel (count)
renverra 1, le second - 2, le troisiÚme - 3, etc. Comment ça marche?
Sans sucre syntaxique, la définition du count
ressemble Ă ceci:
(define count ((lambda (n) (lambda () (set! n (+ n 1)) n)) 0))
Ainsi, la procédure sans arguments (lambda () (set! n (+ n 1)) n)
, qui inclut librement n
est associée au count
noms. Il s'avĂšre que n
défini dans l'environnement externe par rapport à (lambda () (set! n (+ n 1)) n)
, ce qui signifie que la valeur de n
sera stockée entre les appels à count
. La valeur n
initialisée à zéro au démarrage du programme, car (lambda (n) ...)
est appliquée à l'argument 0. Par conséquent, n
absent dans l'environnement global, mais est toujours accessible Ă partir de count
.
Cette implĂ©mentation promet Ă©galement des gains de performances en calculant Ă plusieurs reprises les factorielles de divers nombres dans une mĂȘme session d'interprĂ©teur.
Bien sûr, l'optimisation de la récursivité de queue est également possible ici:
(define factorial-memoized-tco (let ((memo '())) (lambda (n) (define (iteration product counter) (cond ((> counter n) product) (else (set! memo (cons (list counter product) memo)) (iteration (* product counter) (+ counter 1))))) (iteration 1 1))))
«Pourquoi ces danses au tambourin?», Dira le lecteur. Dans les langages de programmation impĂ©ratifs, la mĂȘme chose est Ă©crite simplement - Ă travers une boucle, cela fonctionne rapidement et sans coĂ»ts de mĂ©moire inutiles. Le schĂ©ma a un sous-ensemble pour la programmation impĂ©rative, il a Ă©galement un moyen d'organiser les boucles - une forme spĂ©ciale de do
. La procédure de calcul de la factorielle, écrite avec son aide, peut ressembler à ceci:
(define (factorial-do n) (define product 1) (do ((i 1 (+ i 1))) ((> in) product) (set! product (* product i))))
La construction do
est assez polyvalente, et c'est pourquoi elle n'est pas trop lisible. N'est-il pas préférable d'organiser son propre cycle dans un style impératif? Les macros aideront à cela:
(define-syntax for (syntax-rules () ((_ (variable init test step) . body) (let loop ((variable init)) (if test (begin (begin . body) (loop step)))))))
Grùce à l'optimisation de la récursivité de queue par l'interpréteur, nous obtenons une boucle à laquelle nous sommes habitués dans les langages de programmation impératifs. En optimisant la récursivité de la queue, la pile ne se développera pas.
Définition de la factorielle à l'aide for
:
(define (factorial-for n) (define product 1) (for (i 1 (<= in) (+ i 1)) (set! product (* product i))) product)
Comment ça marcheDans cet exemple, l'expression (for (i 1 (<= in) (+ i 1)) (set! product (* product i)))
sera mise en correspondance avec le modĂšle (_ (variable init test step) . body)
rÚgle de syntaxe. Les substitutions suivantes seront effectuées:
for â _ i â variable 1 â init (<= in) â test (+ i 1) â step (set! product (* product i)) â body
à partir d'ici, le code suivant sera généré par le modÚle de rÚgle de syntaxe:
(define (factorial-for n) (define product 1) (let loop ((i 1)) ; (if (<= in) ; (begin (begin (set! product (* product i))) ; (loop (+ i 1))))) ; for product)
Il existe une autre option qui ressemble à l'impératif for
loop - avec la procédure for-each
intégrée:
(define (factorial-for-each n) (define product 1) (for-each (lambda (i) (set! product (* product i))) (iota n)) product)
Langage Scheme grand et puissant! Et la performance?
Nous utiliserons GNU Guile pour mesurer les performances - dans cet environnement, vous pouvez mesurer le temps nécessaire pour évaluer une expression le plus simplement possible.
Guile fonctionne comme suit: compile le code source du programme en bytecode, qui est ensuite exĂ©cutĂ© par la machine virtuelle. Ce n'est qu'une des implĂ©mentations et l'une des nombreuses façons possibles d'exĂ©cuter un programme Scheme, il y en a d'autres: Racket (utilise la compilation JIT), Chicken Scheme (utilise une interprĂ©tation ou compilation «honnĂȘte» dans un sous-ensemble de C), etc. De toute Ă©vidence, les limitations et les performances des programmes dans ces environnements peuvent varier lĂ©gĂšrement.
Nous prendrons des mesures Ă une certaine valeur n . Que devrait-il ĂȘtre n ? Alors avec qui le plus grand n sera en mesure de "faire face" aux options proposĂ©es. Avec les paramĂštres par dĂ©faut de Guile 2.0, sur un PC avec Intel Core i5 et 4 Go de RAM, j'ai obtenu ce qui suit:
Procédure | Le problÚme |
---|
factorial-classic | débordement de pile sur n>10000 |
factorial-classic-tco | non ( n=100000 ) |
factorial-fold | débordement de pile sur n>10000 |
factorial-eval | débordement de pile sur n>8000 |
factorial-lazy | Ă n=100000 utilise la partition de swap et se fige |
factorial-memoized | débordement de pile sur n>10000 uniquement au premier démarrage |
factorial-memoized-tco | Ă n>1000 utilise la partition de swap et se fige |
factorial-do | non ( n=100000 ) |
factorial-for | non ( n=100000 ) |
factorial-for-each | non ( n=100000 ) |
De lĂ , des tests de performance ont Ă©tĂ© effectuĂ©s Ă n=8000 . Les rĂ©sultats sont prĂ©sentĂ©s dans le tableau ci-dessous, oĂč trun - dĂ©lai tGC - durĂ©e d'exĂ©cution du ramasse-miettes en secondes.
Pour toutes les procédures, sauf paresseux et mémorisés, les plus petites valeurs de l'exécution et l'heure correspondante du garbage collector sont obtenues, obtenues à partir des résultats de trois démarrages à n=8000 .
Pour les procédures mémorisées et paresseuses, le temps d'exécution du premier appel est indiqué, puis le plus petit des trois appels.
Procédure | trun avec | tGC avec | Remarques |
---|
factorial-classic | 0,051 | 0,034 | |
factorial-classic-tco | 0,055 | 0,041 | |
factorial-fold | 0,065 | 0,059 | |
factorial-eval | 0,070 | 0,040 | |
factorial-lazy | 0,076 | 0,036 | premier appel |
factorial-lazy | 0,009 | - | appels suivants |
factorial-memoized | 0,077 | 0,041 | premier appel |
factorial-memoized | 0,002 | - | appels suivants |
factorial-memoized-tco | 0,077 | 0,041 | premier appel |
factorial-memoized-tco | 0,002 | - | appels suivants |
factorial-do | 0,052 | 0,025 | |
factorial-for | 0,059 | 0,044 | |
factorial-for-each | 0,066 | 0,042 | |
Nous avons 4 options qui peuvent fonctionner avec de grandes n . à n=100000 ils ont les temps de calcul et de récupération de place suivants:
Procédure | trun avec | tGC avec |
---|
factorial-classic-tco | 8 468 | 6 628 |
factorial-do | 8 470 | 6 632 |
factorial-for | 8 440 | 6 601 |
factorial-for-each | 9,998 | 7 985 |
Vous pouvez voir qu'avec pas trop grand n le plus rapide et, en mĂȘme temps, le plus court est le premier. La mĂȘme option correspond le mieux Ă la dĂ©finition mathĂ©matique de factorielle. L'option d'optimisation de la rĂ©cursivitĂ© de queue ne lui est pas trĂšs infĂ©rieure en termes de performances. Ces deux options sont idiomatiques recommandĂ©es par les auteurs de la langue. La conclusion est Ă bien des Ă©gards Ă©vidente: sauf indication contraire, l'approche, qui est «typique» du langage, est prĂ©fĂ©rĂ©e, au moins pour la premiĂšre implĂ©mentation d'un algorithme ou d'une mĂ©thode.
Dans le mĂȘme temps, le langage Scheme nous a permis d'Ă©crire de nombreuses options pour implĂ©menter le calcul factoriel, en utilisant un ensemble trĂšs limitĂ© de primitives (les trĂšs «moyennes minimales - impressions maximales»). Par consĂ©quent, malgrĂ© son Ăąge vĂ©nĂ©rable et pas trop rĂ©pandu, ce langage peut toujours ĂȘtre recommandĂ© pour la programmation de recherche: il semble que vous pouvez implĂ©menter n'importe quoi dessus de n'importe quelle maniĂšre (et de n'importe quelle maniĂšre).