Cuprins

Racket: Întârzierea evaluării

Obiective

Scopul acestui laborator este înțelegerea diverselor tipuri de evaluare, respectiv a controlului evaluării în Racket.

Conceptele introduse sunt:

Evaluare aplicativă vs. evaluare leneșă

Fie următoarea aplicație, scrisă în calcul Lambda:

(λx.λy.(x + y) 1 2)

În urma aplicării funcției de mai sus, expresia se va evalua la 3. Ce se întâmplă însă dacă parametrii funcției noastre reprezintă alte aplicații de funcții?

(λx.λy.(x + y) 1 (λz.(z + 2) 3))

Intuitiv, expresia de mai sus va aduna 1 cu 5, unde 5 este rezultatul evaluării expresiei (λz.(z + 2) 3). În cadrul acestui raționament, am presupus că parametrii sunt evaluați înaintea aplicării funcției asupra acestora. Vom vedea, în cele ce urmează, că evaluarea se poate realiza și în altă ordine.

Evaluare aplicativă

În evaluarea aplicativă (eager evaluation), o expresie este evaluată imediat ce o variabilă se leagă la aceasta. Pe exemplul de mai sus, evaluarea aplicativă decurge astfel:

(λx.λy.(x + y) 1 (λz.(z + 2) 3))
(λx.λy.(x + y) 1 5)
6

Observații:

Evaluare leneșă

Spre deosebire de evaluarea aplicativă, evaluarea leneșă va întârzia evaluarea unei expresii până când valoarea ei este necesară. Exemplu:

(λx.λy.(x + y) 1 (λz.(z + 2) 3))
(1 + (λz.(z + 2) 3))
(1 + 5)
6

Observații:

Există avantaje și dezavantaje ale evaluării leneșe. O situație în care evaluarea leneșă se poate dovedi utilă este următoarea:

Fie funcția Fix:

Fix = λf.(f (Fix f))

Fix primește ca parametru o funcție f, care este aplicată asupra parametrului (Fix f). Să considerăm funcția constantă (care întoarce mereu valoarea b):

ct = λx.b

Să aplicăm Fix asupra funcției constante ct, folosind evaluarea aplicativă:

(Fix ct)
(λf.(f (Fix f)) ct)
(ct (Fix ct))
(ct (λf.(f (Fix f)) ct))
(ct (ct (Fix ct)))
(ct (ct (λf.(f (Fix f)) ct)))
(ct (ct (ct (Fix ct))))
...

Cum evaluarea este aplicativă, parametrul trimis lui ct, mai precis (Fix ct), va fi evaluat, în mod recursiv, la infinit.

Să încercăm aceeași aplicare, folosind de data aceasta evaluarea leneșă:

(Fix ct)
(λf.(f (Fix f)) ct)
(ct (Fix ct))
(λx.b (Fix ct))
b

Observăm că evaluarea leneșă a lui (Fix ct) se termină și întoarce valoarea b. Terminarea este posibilă datorită faptului că evaluarea expresiei (Fix ct), trimisă ca parametru lui ct, este întârziată până când este nevoie de ea (în acest caz, niciodată).

Construcții precum Fix pot părea ezoterice și nefolositoare. În realitate însă, ele au aplicabilitate. Fix este un combinator de punct fix („generator“ de funcții recursive).

Evaluarea în Racket

Fie următorul cod:

(+ 1 (+ 2 3))

Codul de mai sus este o rescriere din calcul Lambda în Racket a exemplului introductiv. Reamintim că evaluarea în Racket este aplicativă, așadar etapele parcurse sunt următoarele:

Pentru a obține beneficiile evaluării leneșe în Racket, putem întârzia evaluarea unei expresii în două moduri:

Evaluare leneșă folosind închideri

Fie următorul exemplu:

(define sum
  (lambda (x y)
    (lambda ()
      (+ x y))))
(sum 1 2)

Observați că (sum 1 2) nu evaluează suma parametrilor, ci întoarce o funcție. Mai precis, avem de-a face cu o închidere funcțională (funcție care își salvează contextul). Pentru a forța evaluarea este suficient să aplicăm rezultatul întors de (sum 1 2) asupra celor zero parametri pe care îi așteaptă, astfel:

((sum 1 2)) ; se va evalua la 3

Evaluare leneșă cu promisiuni

Fie definiția convențională a sumei între două numere:

(define sum
  (lambda (x y)
    (+ x y)))

Putem întârzia evaluarea unei sume astfel:

(delay (sum 1 2)) ; va afișa #<promise>

Pentru a scrie o funcție cu evaluare leneșă, asemănătoare celei scrise cu (lambda () …), procedăm astfel:

(define sum
  (lambda (x y)
    (delay (+ x y))))
 
(sum 1 2) ; va afișa #<promise>

Pentru a forța evaluarea, folosim force:

(force (sum 1 2)) ; va afișa 3

Fluxuri - aplicații ale evaluării leneșe

Folosind evaluarea leneșă, putem construi obiecte infinite sau fluxuri (streams). Exemplu de flux: șirul numerelor naturale: (0 1 2 3 … n … ). Un astfel de obiect se reprezintă ca o pereche între:

Exemple:

Șirul constant de 1

Fie șirul infinit:

a(n) = 1, n >= 0

Primele 5 elemente ale acestui șir sunt:

(1 1 1 1 1)

Șirul se poate genera astfel, folosind închideri funcționale:

(define ones-stream
  (cons 1 (lambda () ones-stream)))

Observăm că șirul este o pereche în care:

Folosind promisiuni, același șir este generat ca mai jos:

(define ones-stream
  (cons 1 (delay ones-stream)))

Limbajul Racket pune la dispoziție o interfață pentru manipularea fluxurilor, asemănătoare cu aceea pentru manipularea listelor:

În spiritul abstractizării, putem folosi această interfață fără să ne preocupe dacă funcțiile sunt implementate folosind închideri funcționale sau promisiuni (pentru curioși - sunt implementate folosind promisiuni). Șirul infinit de 1 se va genera astfel:

(define ones-stream
  (stream-cons 1 ones-stream))

Să scriem o funcție care întoarce primele n elemente din șir:

; extragerea primelor n elemente din șirul s
(define (stream-take s n)
  (cond ((zero? n) '())
        ((stream-empty? s) '())
        (else (cons (stream-first s)
                    (stream-take (stream-rest s) (- n 1))))))
 
; testare
(stream-take ones-stream 5) ; va afișa (1 1 1 1 1)

Șirul numerelor naturale

Fie următorul cod, care implementează șirul numerelor naturale:

; generator pentru numere naturale
(define (make-naturals k)
  (stream-cons k (make-naturals (add1 k))))
 
; fluxul numerelor naturale
(define naturals-stream (make-naturals 0))
 
; testare
(stream-take naturals-stream 4) ; va afisa (0 1 2 3)

Observații:

Șirul lui Fibonacci

Șirul lui Fibonacci este:

Fibo = t0 t1 t2 t3 ... tn ... 
 
unde:
t0 = 0
t1 = 1
tk = t(k-2) + t(k-1) pentru k >= 2

În cazul acestui șir, un nou element este calculat pe baza unor valori calculate anterior. Observăm că:

Fibo        = t0 t1 t2 t3 ... t(k-2) ... +
(tail Fibo) = t1 t2 t3 t4 ... t(k-1) ...
___________________________________________
Fibo  = t0 t1 t2 t3 t4 t5 ... tk ...  

Adunând elemente din șirurile (fluxurile) Fibo și (tail Fibo), obținem șirul t2 t3 … tk …. Dacă adăugăm la începutul acestui rezultat elementele t0 și t1, obținem exact Fibo.

Pentru a implementa expresia de mai sus, avem un singur obstacol de depășit, și anume trebuie să scriem o funcție care adună două fluxuri. O implementare posibilă este:

(define add
  (lambda (s1 s2)
    (stream-cons (+ (stream-first s1) (stream-first s2))
                 (add (stream-rest s1) (stream-rest s2)))))

Definim fluxul Fibo pe baza adunării de mai sus:

(define fibo-stream
  (stream-cons 0
               (stream-cons 1
                            (add fibo-stream (stream-rest fibo-stream)))))

Fluxul numerelor prime

Eratostene a conceput un algoritm pentru determinarea fluxului numerelor prime, care funcționează astfel: fie șirul numerelor naturale, începând cu 2:

2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 ...
_

Păstrăm primul element din șir (2) și eliminăm elementele care se divid cu el:

2 3   5   7   9    11    13    15    17    19    21 ...
_

Apoi păstrăm următorul element din șirul rămas (3) și eliminăm toate elementele care se divid cu el:

2 3   5   7        11    13          17    19       ...
  _

etc.

Algoritmul este implementat mai jos:

(define sieve
  (lambda (s)
    (let ((divisor (stream-first s)))
      (stream-cons
       ;; păstrează primul element
       divisor
       ;; și aplică recursiv algoritmul pe numerele care nu se divid cu el
       (sieve (stream-filter (lambda (x) (> (modulo x divisor) 0))
                             (stream-rest s)))))))
 
(define primes-stream
  (sieve
   ;; șirul numerelor naturale începând de la 2
   (stream-rest (stream-rest naturals-stream))))
 
; testare
(stream-take primes-stream 10) ; va afișa (2 3 5 7 11 13 17 19 23 29)

Resurse

Referințe