TECHNIEKEN VOOR DE DEFINITIE VAN FUNCTIES

Top Down en Bottom Up Code Ontwikkeling

Er zijn verschillende technieken en stijlen waarin een idee kan worden omgezet in SCHEME. Sommige daarvan zijn meer bottom up, andere meer top down. Een typische manier van bottom up code-ontwikkeling is vertrekkend van een specificatie in natuurlijke taal van een probleem, stukken daarvan vertalen naar SCHEME code, deze stukjes code interactief uittesten en tenslotte stukjes werkende code in functiedefinities incorporeren. Een bottom up methode is aangewezen voor kleine, relatief eenvoudige problemen, of voor het coderen van kleine onderdelen (modules) in de oplossing van grotere problemen. In het bekende faculteit voorbeeld zou de vertaling van tekst naar code op de volgende manier kunnen verlopen.

Om de faculteit van een getal te berekenen: als het getal gelijk is
aan 0 dan is het resultaat 1, als het getal groter is dan 0 is het
resultaat het product van het getal met de toepassing van faculteit op
het getal min 1.

Als                             (if
het getal gelijk is aan nul       (zero? getal)
dan is het resultaat 1,           1
als                               (if
het getal groter is dan 0           (> getal 0)
dan is het resultaat 
het product                         (*
van het getal                        getal
met de toepassing van faculteit      (faculteit
op het getal min 1                      (- getal 1)

Tenslotte moet alleen de LAMBDA toegevoegd worden, de indentatie wat
verzorgd, en de procedure gebonden aan een symbool met DEFINE.

(define faculteit 
  (lambda (getal)
    (if (zero? getal) 
        1
        (if (> getal 0)
            (* getal (faculteit (- getal 1)))))))

> (faculteit 4)
24
> (faculteit 0)
1

Voor meer complexe problemen is een top down benadering meer aangewezen. In een top down perspectief wordt het probleem vertaald naar code door het te vertalen naar minder complexe sub-problemen, die op hun beurt weer verder uiteengerafeld worden tot nog meer eenvoudige sub-problemen, tot een niveau wordt bereikt waarin elke subtaak of sub-probleem via een bottom-up benadering kan worden gecodeerd als een relatief korte functie. Deze methode wordt wel eens functionele decompositie genoemd, of top down stepwise refinement.

Een tweede basisprincipe van een goede top down programmeerstijl is abstractie. Die abstractie speelt zowel op functie niveau als op data niveau. De code moet een abstracte, implementatie-onafhankelijke analyse van het op te lossen probleem weerspiegelen, en de ``ruige'' implementatiedetails moeten worden verborgen voor de gebruiker van de code achter een conceptuele representatie. Meestal zijn er verschillende alternatieve manieren waarop een specifiek probleem of sub-probleem kan worden gecodeerd. Het idee van abstractie is om de gebruiker van de code hiermee niet lastig te vallen. Wat telt is de conceptuele beschrijving van de oplossing van het probleem in termen van elkaar aanroepende SCHEME functies en datastructuren die de structuur van het probleem weergeven.

Functionele programmeertalen lenen zich goed tot een programmeerstijl waarin functionele decompositie en abstractie worden gecombineerd en elkaar wederzijds versterken.

Recursie

Een recursieve functie is een functie in de body waarvan de functie zelf aangeroepen wordt (met andere argumenten). De eerder besproken functie faculteit is een recursieve functie. Stel bijvoorbeeld dat je zelf de functie lengte zou willen definiëren. De lengte van de lege lijst is 0, en de lengte van elke andere lijst is gelijk aan de lengte van de lijst met een element minder (de rest) + 1.

(define my-length 
  (lambda (lijst)
    (if (null? lijst) 0
      (+ 1 (my-length (cdr lijst))))))

Stel dat de functie wordt aangeroepen met als argument de lijst (aap noot). Deze lijst is niet leeg, dus wordt 1 opgeteld bij het resultaat van de evaluatie van (my-length '(noot)). Die lijst is ook niet leeg dus wordt de oorspronkelijke 1 bijgeteld bij 1 + het resultaat van de evaluatie van (my-length '()), wat 0 oplevert. Het resultaat is dus 1+1+0=2. De meeste LISP systemen kennen een special form TRACE die een overzicht geeft van het controleverloop van (recursieve) functies. Het tracen van functies (al dan niet recursief) kan nuttig zijn bij het testen en debuggen van programma's.

(trace my-length)
(MY-LENGTH)
> (my-length '(het leven is hard))
1 Enter MY-LENGTH (HET LEVEN IS HARD)
| 2 Enter MY-LENGTH (LEVEN IS HARD)
|   3 Enter MY-LENGTH (IS HARD)
|   | 4 Enter MY-LENGTH (HARD)
|   |   5 Enter MY-LENGTH ()
|   |   5 Exit MY-LENGTH 0
|   | 4 Exit MY-LENGTH 1
|   3 Exit MY-LENGTH 2
| 2 Exit MY-LENGTH 3
1 Exit MY-LENGTH 4
4

De vorm van recursie in my-length is enkelvoudig en constructief. Bij enkelvoudige recursie wordt de functie slechts eenmaal opgeroepen in de body van de definitie. Bij meervoudige recursie wordt de functie twee of meer keer opgeroepen (met verschillende argumenten). Deze laatste vorm van recursie wordt vaak gebruikt wanneer een complexe expressie moet geanalyseerd worden op een of andere manier. Er zal dan in de body een recursieve aanroep van de functie gebeuren op de CAR en op de CDR van de complexe expressie. Bijvoorbeeld zoals in de volgende functie die het aantal getallen in een complexe expressie telt.

(define tel-getallen 
  (lambda (x)
    (if (number? x)
        1
        (if (null? x)
            0
            (if (list? x)
                (+ (tel-getallen (car x))
                   (tel-getallen (cdr x)))
                0)))))
TEL-GETALLEN
> (tel-getallen '(a (9)(e f ((6) d) 5)))
3

Bij constructieve recursie wordt een functie-aanroep ``omgezet'' naar een recursieve aanroep waarop daarna een bewerking wordt uitgevoerd (bij MY-LENGTH was dat 1 bijtellen, bij FACULTEIT was dat een getal vermenigvuldigen. Wanneer we er in slagen een recursieve functie te schrijven die een functie-aanroep reduceert tot een recursieve aanroep zonder dat er een bewerking op uitgevoerd wordt, hebben we te maken met staartrecursie. Deze vorm van recursie is veel efficiënter dan constructieve recursie omdat minder geheugen in beslag wordt genomen door tussenoplossingen.

> (define my-length-staart
    (lambda (lis accu)
        (if (null? x) 
            accu
            (my-length-staart (cdr x) (+ 1 accu)))))
MY-LENGTH-STAART

> (my-length-staart '(het leven is hard) 0)
4

Opstellen van een recursieschema

Er zijn een aantal stappen die doorlopen worden bij het opstellen van een recursieve definitie.

  1. Zoek de stopcondities. Wanneer moet de recursie ophouden, wat zijn de eindcondities? Typische eindgevallen zijn: het argument is een symbool, het argument is de lege lijst, het argument is nul, of kleiner dan 0, etc.
  2. Wat gebeurt er in die gevallen? Welke resultaten moeten geretourneerd worden? Het is belangrijk hierbij rekening te houden met het feit dat de waarde die je bij een stopconditie teruggeeft niet alleen correct moet zijn wanneer het initiële argument voldoet aan de eindconditie, maar tegelijk ook een rol speelt in de opbouw van het resultaat bij de andere gevallen. Bij my-length, bijvoorbeeld, is de eindconditie de test (null? lis), de lege lijst. Het resultaat daarvan is nul, maar het zou niet opgaan (if (null? lis) 'nul ...) te schrijven in plaats van (if (null? lis) 0) omdat in de recursie gebruik wordt gemaakt van die waarde. (+ 1 (+ 1 'nul)) zou een fout opleveren.
  3. Hoe geraak ik van een typisch geval naar de eindcondities? Meestal kan dit door gebruik van CDR bij lijstargumenten, of een combinatie van CAR en CDR bij dubbele recursie, of 1 optellen of aftrekken bij numerieke argumenten.
  4. Hoe bouw ik het resultaat van het geheel op in termen van de resultaten van de delen. Meestal door gebruik te maken van + bij numerieke problemen, of APPEND en CONS als het resultaat een lijst moet zijn.

Definieer LEAVES, met als argument een complexe expressie (boom), en
als resultaat een lijst van de bladeren van de boom.
Bijv. (leaves '(a (b (c d)) e)) -> (A B C D E)

1. Eindcondities 

(null? tree)                      ; Lege lijst
(not (list? tree))                ; Symbool of ander leaf

2. Resultaat eindcondities

(null? tree) => ()                ; geen leaves in de lege lijst
(not (list? tree)) => (list tree) ; 1 leaf in elke niet-lijst

3. Van typisch geval naar eindgeval

tree = lijst =>
combineer (leaves (car tree)) met (leaves (cdr tree)) 

4. Hoe combineren

(append (leaves (car tree))(leaves (cdr tree)))

5. Integratie

(define leaves 
  (lambda (tree)
    (if (null? tree)
        '()
        (if (not (list? tree))
            (list tree)
            (append (leaves (car tree))
                    (leaves (cdr tree)))))))

Walter Daelemans <Walter.Daelemans@kub.nl>
Last modified: Tue Mar 18 14:45:16 1997