Een Minimaal Formalisme om te Programmeren We hebben gezien dat Turing machines beschouwd kunnen worden als universele computers. D.w.z. dat iedere berekening met natuurlijke getallen die met een computer gedaan kan worden, ook met een Turing machine uitgevoerd kan worden. Het omzetten van het algoritme naar een Turing machine is hierbij meestal geen eenvoudige zaak. In het algemeen is het oorspronkelijke algoritme ook niet meer te herkennen na de transformatie. Een Turing machine is weliswaar een abstracte computer maar kan in principe ook gebouwd worden en is daarom ook als een implementatie van het algoritme te beschouwen. Bedenk hierbij wel dat je voor ieder algoritme een aparte Turing machine moet bouwen. In dit hoofdstuk zullen we een heel andere benadering hanteren we het programmeerformalisme als uitgangspunt ipv een (abstracte) machine die het algoritme uitvoert. Het streven is om met een minimum aan formalisme een maximum aan expressie kracht te krijgen. Het verrassende is dat er een formalisme mogelijk is dat maar op 1 regel is gebaseerd. Het formalisme dat we hanteren is gebaseerd op de Lambda Caluclus. De Lambda Calculus werd in de jaren 30 van de vorige eeuw door Alonzo Church ontwikkeld als een alternatief fundament voor de wiskunde (i.p.v. logica). Al snel bleek dat de Lambda Calculus ook zeer geschikt is om algoritmen mee te beschrijven. Alan Turing bewees dat de Lambda Calculus dezelfde klasse van algoritmen kan beschrijven als zijn Turing machines. De Lambda Calculus is nogal abstract en lastig te leren. Daarom gebruiken wij hier een wat meer toegankelijke versie die meer op een gewone programmeertaal lijkt. Definitie van een minimale programmeertaal Een programma in onze programmeertaal bestaat uit een aantal functies met een start functie. Een functie is een voorschrift waarmee het ene patroon door een ander kan worden vervangen. Hier een aantal voorbeelden: f a b = b a g x y = f x y id x = x k x y = x c x y z = x (y z) s f g x = f x (g x) app f x = f x start = id (k (f c s) (g s c))
Een functie bestaat uit een naam gevolgd door een aantal variabele namen (0 of meer), gevolgd door ‘=’ gevolgd door een functie body. In de functie body mogen alleen namen van variabelen en namen van andere functies uit ons programma en haakjes om te groeperen verschijnen. Een functie body is altijd een functie applicatie (het toepassen van een functie op een aantal argumenten). Hierbij mag de functie die wordt toegepast ook een variabele zijn. De argumenten mogen functies, variabelen of andere functie toepassingen zijn. Bv. in de functie f wordt in de body a toegepast op b. In de functie c wordt x toegepast op de toepassing van y op z. Het ‘runnen’ van een programma bestaat nu uit, beginnende met de start functie, alle functie applicaties uit te voeren tot dit niet meer verder kan. Het uitvoeren van een functie applicatie bestaat uit het vervangen van een functie aanroep door de rechterkant van de bijbehorende functie waarbij op de plaats van de variabelen de actuele argumenten worden ingevuld. In ons voorbeeld wordt dit: start -> id (k (f c s) (g s c)) -> k (f c s) (g s c) -> f c s -> s c
De laatste functie applicatie kan niet meer herschreven worden omdat s 3 argumenten verwacht en er maar 1 krijgt. De afspraak is dan dat we niet verder herschrijven. Er zijn verschillende vragen te stellen. 1. Maakt het uit in welke volgorde je functie herschrijvingen uitvoert? 2. Eindigt het herschrijfproces altijd?
1
Bv. we hadden bij de tweede pijl ook kunnen kiezen om eerst het argument van id te herschrijven en daarna pas de id functie uit te voeren. Hetzelfde geldt voor de derde pijl. Hier hadden we eerst de argumenten van k kunnen herschrijven en daarna k toe te passen. In dit geval leidt dit tot extra werk omdat het tweede argument van k wordt weggegooid. Probeer zelf in het bovenstaande voorbeeld verschillende volgorden uit. Het zal blijken dat dit voor het eindresultaat niets uit maakt. Het is gemakkelijk voorbeelden te geven programma’s die niet eindigen. f x = f (f x) id x = x start = f id
Het herschrijf proces wordt nu: start = f id -> f (f id) -> f (f (f id)) -> ...
In dit geval eindigt de herschrijving niet. Voor het herschrijven zullen we de strategie hanteren dat we steeds de buitenste functie aanroep proberen te herschrijven tot dit niet meer verder gaat en dat we daarna de argumenten van de overgebleven functie van links naar rechts gaan herschrijven (waarbij we weer dezelfde regel hanteren). Deze herschrijfstrategie heet left-outer-most reductie. Er geldt nu de volgende stelling: Stelling (Church-Rosser) Als er een voor een programma een eindigende herschrijfstrategie bestaat, dan zal left-outer-most reductie ook eindigen en bovendien zal het resultaat hetzelfde zijn. Het bewijs van deze stelling is zeer technisch en zullen we daarom achterwege laten. Intuïtief is het resultaat wel te snappen omdat de buitenste functie applicatie nooit kan wegvallen en daarom op zeker moment altijd uitgevoerd moet worden. Het moment van herschrijven van de buitenste functie applicatie kan daarom het al of niet eindigen van het programma niet veranderen. De antwoorden op bovenstaande vragen zijn dus: 1. De volgorde maakt alleen uit als de ene volgorde eindigt en de andere niet. Alle eindigende herschrijf strategieën leiden tot hetzelfde resultaat. 2. Nee. Hiermee hebben we het basis formalisme gedefinieerd. Het lijkt op het eerste gezicht een weinig bruikbaar formalisme. Er zitten bv geen getallen en bewerkingen op getallen in het formalisme. Deze zullen we zelf moeten definiëren. De enige regel die we hebben is ‘het vervangen van linkerkanten door rechterkanten’. In deze zin is het formalisme erg minimaal. Alvorens verder te gaan maken een kleine uitbreiding op het formalisme, n.m.l. anonieme functies. Deze vereenvoudigen de notatie in veel gevallen maar veranderen de kracht van het formalisme niet. Een anonieme functie heeft de vorm \v1 v2 -> body en mag aan de rechter kant van functie definities verschijnen op iedere plek waar ook een functie aanroep mag staan. We geven een paar voorbeelden. h = (\x y -> x) c s f a b = app (\x -> c x a) b
We kunnen een definitie met anonieme functies altijd vervangen door een aantal definities zonder anonieme functies. In het eerste voorbeeld kunnen we de anonieme functie vervangen door k. Het tweede voorbeeld kunnen we vervangen door twee functies: f a b = app (f’ a) b f’ a x = c x a
2
Men kan eenvoudig nagaan dat de nieuwe functies samen hetzelfde resultaat opleveren als de oorspronkelijke definitie met anonieme functie. Let er op dat in de aanroep van de nieuwe functie f’ a als extra argument zit. Getallen als functies Om te kunnen rekenen hebben we in ieder geval getallen en hun operaties (optellen, aftrekken, vermenigvuldigen, delen, etc.) nodig. Voor de definitie van getallen vallen we terug op de Peano axioma’s. Peano was de eerste die een formele definitie van natuurlijke getallen gaf. Voor die tijd beschouwde men natuurlijke getallen als een (goddelijk) gegeven. Het principe van volledige inductie zoals dit in het dictaat discrete wiskunde wordt behandeld is gebaseerd op de Peano Axioma’s. Het idee van Peano was de natuurlijke getallen onder te verdelen in twee klassen. De ene klasse bevat alleen het getal 0. Deze andere klasse bevat alle getallen die een opvolger van een natuurlijk zijn (alle andere getallen). We zullen 0 in het Peano formalisme zero noemen en de opvolger van het getal n aanduiden met suc n (successor n). Op deze wijze is 1: suc zero, 2: suc (suc zero), etc. We beginnen al functies in het bovenstaande formalisme te herkennen. Maar we zijn er nog niet. Laten we eerst naar de optel operatie van natuurlijke getallen kijken. We kunnen deze als volgt definiëren: add n zero = n add n (suc pm) = suc (add n pm)
In woorden: Als we 0 bij n optellen is het resultaat n. Als we de opvolger van pm bij n willen optellen is het resultaat de opvolger van het resultaat van de som van n en pm. Omdat de laatste optelling eenvoudiger is dan de oorspronkelijke zal de recursie uiteindelijk eindigen als pm 0 is geworden. De definitie boven lijkt een aardige poging getallen en optelling in ons formalisme te definiëren, maar de definities hebben ook aan de linker kant een patroon (zero en suc m) en dit is niet toegestaan. Bovendien hebben we voor zero en suc nog geen definitie. We geven nu een definitie van zero, suc en add in bovenstaand formalisme en zullen m.b.v een aantal voorbeelden laten zien dat deze definities inderdaad tot de gewenste resultaten leiden. zero = \f g -> f suc n = \f g -> g n add n m = m n (\pm -> suc (add n pm))
We zien dat in de definities volop gebruik wordt gemaakt van anonieme functies. In zero en suc is dit niet echt nodig (we kunnen de definities vervangen door: zero f g = f en suc n f g = g n). add zouden we zonder anonieme functies naar 2 minder goed leesbare functies moeten transformeren. In add zien we dat we het argument m echt als functie gebruiken en toepassen op 2 argumenten. Bekijk nu het herschrijven van de expressie add (suc zero) (suc zero) add (suc zero) (suc zero) (suc zero)(suc zero)(\pm -> suc (add (suc zero) pm)) suc zero(suc zero)(\pm -> suc (add (suc zero) pm)) (\pm -> suc (add (suc zero) pm)) zero suc (add (suc zero) zero)) suc (zero (suc zero) ((\pm -> suc(add (suc zero) pm))) suc (suc zero)
-> -> -> -> -> ->
def add (f a) b == f a b def. suc toepassing def add def zero
Dit lijkt misschien allemaal heel ingewikkeld, maar het komt precies op hetzelfde neer als de definitie van optellen die we boven hebben gegeven. We maken handig gebruik van het feit dat een getal nu een functie is die je kunt toepassen op twee argumenten. Als het getal 0 is selecteert dit het eerste argument. Als het getal de opvolger is van een ander, dan selecteert dit het tweede argument en past dit toe op de ander. Daarom moet het tweede argument van m in add ook een functie zijn. Je kunt de definitie ook lezen als:
3
add n m = n, if m == 0 en add n m = suc (add n pm), if m == suc pm.
Opgave Definieer zelf vermenigvuldiging en uitgaande van optelling. Doe hetzelfde met machtsverheffen. Opgave Definieer de functie pred, die de voorganger van een getal geeft. Definieer hierbij de voorganger van 0 als 0. Gebruik deze definitie om aftrekken (sub) te definiëren. Probeer ook een rechtstreekse definitie van sub te geven. Opgave Definieer de faculteit functie gebruikmakend van bovenstaande definitie van getallen. We zien dat het formalisme krachtig genoeg is om natuurlijke getallen en de operaties daarop te definiëren. In het voorafgaande hebben we de functionele programmeertaal Amanda gebruikt. Programma’s in Amanda kunnen op een recht-toe-recht-aan manier naar dit formalisme geconverteerd worden. We hebben al gezien hoe we natuurlijke getallen representeren. We kijken nu naar de definitie van lijsten. In Amanda heeft een lijst de vorm: [] of (x:xs). We definiëren nu voor [] de functie Nil en voor (x:xs) de functie Cons. Nil = \f g -> f Cons x xs = \f g -> g x xs
We kunnen nu b.v de functie length als volgt definieren: length xs = xs zero (\x xs -> suc (length xs))
Andere data typen kunnen op analoge wijze geconverteerd worden. Opgave Geef zelf een implementatie van binaire bomen en de functie depth. Opgave Bedenk een representatie voor True, False en de if constructie. Amanda beschikt over een aantal syntactische constructies zoals lijstcomprehensies en pattern matching. Ook deze constructies zijn om te werken naar constructies binnen het minimale formalisme. Turing Machines en het minimale formalisme We hebben al gezien dat het eenvoudig is om een Turing machine in Amanda (en daarom ook in het minimale formalisme) te implementeren. Het omgekeerde is veel lastiger. Het minimale formalisme is weliswaar eenvoudig maar lastig m.b.v een Turing machine uit te drukken omdat we in feite de herschrijfregel (links door rechts vervangen) moeten uitdrukken. Omdat de herschrijfregels willekeurig kunnen zijn is dit lastig. We zullen daarom een programma niet rechtstreeks op een Turing machine afbeelden, maar via een tussenstap. De tussenstap is een vertaling van een programma naar een equivalent programma waarin maar 3 verschillende functies in voorkomen. We geven eerst de definities van deze 3 functies. I x = x K x y = x S f g x = f x (g x)
I is de identiteitsfunctie, K gooit zijn tweede argument weg en S past zijn eerste argument toe op het derde argument en op de toepassing van het tweede op het derde argument. Het blijkt nu dat we iedere functie kunnen om schrijven in termen van I, K en S. We beginnen met een aantal voorbeelden. De truc is dat we de rechterkant van de functie f x1 .. xn = rechts-expr net zolang herschrijven tot dat deze van de vorm SKI-expr x1 .. xn is, waarbij SKI-expr een uitdrukking is die alleen maar S,K, I en andere functie namen bevat en geen
4
variabelen meer. Bij het omschrijven werken we een voor een de variabelen weg te beginnen bij de laatste. h x y = y => I y => K I x y f x y z = x (y z) => S (K x) y z => S (K S) K x y z t x y z = y z (x z) => S y x z => S S (K x) y z => S (K (S S)) K x y z
Er geldt dus: h = K I f = S (K S) K t = S (K (S S)) K
Als een functie nog andere functies bevat kunnen deze op het eind vervangen worden door hun SKI representaties. Hoewel het omzetten van een functie naar het SKI formaat ingewikkeld lijkt is het algoritme wat deze omzetting uitvoert vrij kort. Een Turing machine die SKI expressie kan evalueren We schetsen nu een Turing machine die SKI expressies kan evalueren. We gebruiken een Turing machine met 6 tapes. Tape 0 bevat de invoer en uiteindelijk het eindresultaat. Er zijn 3 tapes (Arg0, Arg1 en Arg2) die de argumenten van een expressie bevatten (maximaal 3 voor een S expressie). Tape 4 is de to do expressie stack. Hierop kunnen meerdere expressies gescheiden door een separator geplaatst worden. De expressies op deze tape staan in de volgorde waarin ze geëvalueerd moeten worden. Tape 5 is de huidige expressie stack. Deze bevat de expressie die op dit moment geëvalueerd wordt. Het algoritme start met het kopiëren van de input naar de to do stack. Op de oorspronkelijke invoertape wordt het markeringssymbool # gezet. Op deze plaats moet straks het resultaat neergezet worden. 1. Het algoritme begint met een check of er nog iets op de to do tape staat. Zo nee, dan is het resultaat dat wat op de invoer tape staat. Staat er wel wat op de to do stack dan wordt de eerste expressie naar tape 5 gekopieerd. We gaan nu verder met wat op tape 5 staat. 2. Als dit begint met een I dan checken we eerst of er wat na de I staat. Als dit zo is dan kan I verwijderd worden waarna het algoritme opnieuw begint met de rest van de invoer (I is immers de identiteitsfunctie). Als er na de I niets meer staat dan is het resultaat I en wordt teruggeschreven naar de positie van de eerste # op de invoer tape. 3. Als deze begint met een K dan checken we eerst hoeveel argumenten er na de K op de invoer staan. Dit is een kwestie van tellen hoeveel geneste haakjes expressies er op de invoer na de K staan. a. Als dit er twee of meer zijn dan moeten de K en het tweede argument verwijderd worden. Waarna het algoritme opnieuw begint op de plaats waar K stond in de invoerstring. b. Als er 0 argumenten zijn de is het resultaat K en zijn we klaar. c. Als er 1 argument is dan moet de K blijven staan en herhalen we het algoritme voor het ene argument. 4. Als de expressie met een S begint dan checken we eerst het aantal argumenten na de S. a. Als dit er 3 of meer zijn dan kopiëren we de eerste 3 argumenten naar resp Arg0, Arg1 en Arg2, waarbij we ze samen met de S verwijderen van de invoer. Daarna kopiëren we de string Arg0 Arg2 (Arg1 Arg2) terug naar de positie waar S stond en herhalen het algoritme vanaf de positie waar S stond. b. Als er 0 argumenten zijn dan is het resultaat S. c. Als er 1 argument is Opmerking Als we ons echter beperken tot een vast programma dan is het wel mogelijk een Turing machine te maken die het programma kan uitvoeren zonder de tussenstap via SKI expressies te maken. Je hoeft
5
dan immers maar een vast aantal functies te kunnen executeren (net zoals voor de SKI machine). De constructie gaat op vergelijkbare wijze.
6