Donnerstag, 4. Dezember 2008

entwickler.com Magazine Konferenzen Entwickler Akademie Entwickler-Forum Jobbörse Bücher
Software & Support Verlag




April 2006
aus Linux Enterprise Ausgabe: 09.2002
Weder Bob noch Thomas
Programmieren mit der DYnamic LANguage - Teil I
von Gabor Greif, Armin Röhrl und Stefan Schmiedl

Dylan ist eine moderne Programmiersprache, die Elemente der funktionalen und objektorientierten Paradigmen vereint. Dazu bedient sie sich einer eher konventionellen Syntax, ohne jedoch auf die Flexibilität zu verzichten, die ein leistungsfähiges Makrosystem bietet. Unterstützt werden diese Eigenschaften von einem Modulsystem, das Importe und Exporte von Modulen aus anderen Bibliotheken und Objekten aus anderen Modulen ermöglicht. Dabei können Umbenennungen vorgenommen werden, um Namenskonflikte zu umgehen.


Überblick
Dieser erste Teil unseres Beitrages wird in die Syntax von Dylan einführen und auf den funktionalen Aspekt der Sprache eingehen. Die weiteren Abschnitte im Teil zwei der nächsten Ausgabe von Linux Enterprise setzen sich dann mit dem leistungsfähigen Objektsystem und der flexiblen Syntax auseinander, die durch Neudefinition von Makros die Sprache erweiterbar macht.

Syntax
Die Syntax von Dylan birgt auf den ersten Blick kaum Überraschungen: Die von weitverbreiteten Sprachen bekannten Konstrukte findet man auch in Dylan:

define function fak (n)
let result = 1;
for (i from n above 1 by -1)
result := result * i;
end;
result
end;

Bezeichner sind bezüglich Groß/Kleinschreibung insensitiv und Einrückung oder Formatierung werden nicht vorgeschrieben. Allerdings gibt es Stilregeln, die allgemein befolgt werden. Klassen und Typnamen werden zwischen spitze Klammern gesetzt (), Funktionen, die eines ihrer Argumente verändern, werden mit einen Ausrufungszeichen dekoriert (remove-duplicates!), so genannte Prädikatfunktionen, die Wahrheitswerte (vom Typ ) zurückgeben, erhalten ein Fragezeichen (instance?). Konstantennamen beginnen mit dem Dollarzeichen ($pi) und globale Variablennamen werden mit Sternen versehen (*standard-output*).

Der Codekörper der Fakultätsfunktion (zwischen define function fak(n) und dem schließenden end) besteht aus Anweisungen, die durch Semikolon getrennt werden, daher darf das letzte Semikolon (vor end) fehlen. Ähnlich sind die Definitionen auf der obersten (oder Datei-) Ebene gegliedert. Das Kernwort end schließt die define-Form ab, zusätzlich ist das Wiederholen von Definitionsart (function) und/oder Definitionsname (fak) erlaubt.

Der Rückgabewert einer Funktion ist der Wert der letzten Anweisung in ihrem Codekörper (hier). Codeabschnitte (wie die Funktionsdefinition von) können aus Textdateien oder auch aus Datenbanken in den Compiler gespeist werden, Dylan schreibt hier kein Schema zwingend vor.

Quellcode
Da der Compiler alle Codeabschnitte einliest, bevor er diese zu einer Bibliothek übersetzt, ist die Reihenfolge von Definitionen auf der obersten Ebene im Allgemeinen nebensächlich. Bezeichnernamen, die erst weiter hinten definiert werden, können ohne spezielle forward Deklarationen verwendet werden. Nur bei evaluierbaren Ausdrücken auf oberster Ebene ist deren Reihenfolge von Bedeutung, denn sie werden in der Reihenfolge ihres Auftretens ausgewertet.

Bezeichnernamen sind wesentlich reichhaltiger als in anderen Sprachen, die selten mehr als alphanumerische Charakter zulassen. In Dylan können alle Operatorzeichen (z.B. *, /, +, -, <, >) und die meisten grafischen Zeichen ($, %, !, usw.) als Bestandteile von Bezeichnern auftreten. Folglich spielen Leerzeichen um Operatoren eine wichtige Rolle: prime-number ist ein Bezeichner, während prime - number ein (arithmetischer) Ausdruck ist.

Bestimmte syntaktische Elemente der Sprache wie Operatoren oder Makronamen lassen sich nicht isoliert verwenden, sie sind nur zusammen mit ihrem Kontext sinnvoll. Wenn man den Namen trotzdem einmal braucht, zum Beispiel für den Export, bietet Dylan eine Backslash-Escape Syntax: ein dem Bezeichner vorangestellter \ befreit diesen von allen syntaktischen Zusatzregeln und erlaubt z.B. den Aufruf der Multiplikationsfunktion in der Präfix-Notation: \*(6, 7) und 6 * 7 sind identisch.

Wahrheitswerte haben eine prägnante Sonderform: #t für wahr, #f für falsch; diese sind die einzigen Exemplare der Klasse . In Bedingungen zählt alles, was nicht #f ist, als wahr. Strings haben die bekannte Syntax in doppelten Anführungszeichen ("a string") und sind als Sequenzen von Zeichen (Klasse ) implementiert.

Syntax-Zucker
Einige syntaktische Abkürzungen sind in die Sprache direkt eingebaut. Die einfachsten sind die Punkt- und die Elementzugriff-Notation bei Kollektionen. Die Punkt-Notation ist eine Verallgemeinerung des Record-Feldzugriffs: object.slot ist identisch zu slot(object). Dies gilt universell, d.h. my-window.compute-height ruft die Funktion compute-height mit my-window als Argument auf. Wir werden später sehen, dass ein Feldzugriff exakt einem Funktionsaufruf gleichkommt.

Ganz ähnlich ist es mit Elementzugriff. In Dylan wird foo[index] stets in element(foo, index) umgewandelt und bar[index1, index2, ...] in aref(bar, index1, index2, ...). Der Funktionsname aref steht dabei für Array-Reference.

Noch interessanter ist die Umwandlung des Zuweisungsoperators in einen Funktionsaufruf: findet der Compiler einen Funktionsaufruf links vom := Operator, so erweitert er den Namen der Funktion um das Suffix -setter und setzt den Wert des Ausdrucks rechts von der Zuweisung auf den ersten Platz der Argumentliste. Somit werden die beiden Zeilen

my-window.height := 300;
array[9, 6] := 42;

umgewandelt in

height-setter(300, my-window);
element-setter(42, array, 9, 6);

Symbole ähneln Strings, allerdings sind sie atomar und unempfindlich gegen Groß- und Kleinschreibung. Diese Eigenschaft prädestiniert sie für Aufzählungen oder als benannte Argumente in Funktionen. Geschrieben werden sie entweder als String mit vorangestelltem # wie in #"monday" oder als Bezeichner mit nachgestelltem Doppelpunkt wie in height:.

Der zweite Fall ist typisch für den Einsatz als benanntes Argument, das typischerweise folgende Form hat:

range(from: 5, below: 12)

Funktionale Programmierung
Dylan ist eine Sprache, in die die langjährige akademische Forschung auf dem Gebiet der funktionalen Programmierung Einzug gehalten hat. Dies äußert sich schon darin, dass 169 von 243 exportierten Bezeichnern der Dylan-Bibliothek Funktionen sind. Praktisch alle Arbeit, die ein Dylan Programm verrichtet, wird von diesen Funktionen erledigt. Dem Benutzer der Sprache stehen überdies mächtige Werkzeuge zur Verfügung, um aus diesem Vorrat neue, seinen Bedürfnissen angepasste Funktionen zu konstruieren.

Die Grundlage dieses alten Paradigmas (die grundlegenden Sätze gehen auf die erste Hälfte des 20. Jahrhunderts zurück) ist, dass alle Berechnungen, die ein Computer ausführen kann, mit Funktionen als alleinigem Datentyp schon möglich sind. Also sind Datentypen wie oder unnötig, solange man -en hat.

Dabei handelt es sich um Funktionen im mathematischen Sinne, also eine Blackbox, die genau ein Resultat liefert, wenn sie auf einen Wert angewandt wird, und zwar stets dasselbe, solange der Inputwert derselbe ist: einen internen Zustand gibt es nicht.

Man kann mit dieser Art von Datenrepräsentation nicht besonders effizient rechnen, selbst einfache arithmetische Operationen wie eine 32-bit Multiplikation erfordern eine gigantische Aufblähung des Ausdrucks, nur um ihn danach wieder auf Minimalgröße zu reduzieren. Praktikable funktionale Sprachen nehmen folgerichtig von den puristischen Wurzeln Abstand und nutzen die von modernen Rechnerarchitekturen zur Verfügung gestellten Ressourcen genauso effizient wie die imperativen Sprachen, zu denen fast alle verbreiteten Sprachen zählen und die primär die vorherrschenden Architekturen geprägt haben.

Wie schon angedeutet, stehen (rein) funktionalen Sprachen bestimmte Elemente der Programmierung nicht zur Verfügung, die wichtigsten darunter sind Zuweisungen und Input/Output. Dylan bietet standardmäßig beide an (ist daher keine rein funktionale Programmiersprache und lässt sich auch durchaus angenehm als imperative Sprache verwenden), doch wir versuchen erst einmal auszuloten, wie weit man ohne diese Abstriche kommen kann.

Funktionen als Objekte erster Klasse
In Dylan sind Funktionen Staatsbürger erster Klasse, d.h. sie können wie normale Werte in Datenstrukturen gespeichert werden, an andere Funktionen als Argument gereicht und auch als Resultat zurückgegeben werden. Im Gegensatz zu anderen Objekten haben Funktionen aber keine innere Struktur. Die Erzeugung von Funktionen ist sehr einfach, denn sie können überall auftreten, wo ein Ausdruck erlaubt ist, indem sie mit dem Sprachkonstrukt

method(a, b, ...)
...
end

ins Leben gerufen werden. Solche Funktionen werden anonyme Methoden genannt und haben den Typ .

Beispielsweise berechnet der Ausdruck

(method(a) a * a end) (42)

das Quadrat von 42. Syntaktisch werden die Argumente in Klammern gesetzt und diesen der Funktionsausdruck vorangestellt. Die Klammern um method ... end im Beispiel sind redundant und nur zur besseren Lesbarkeit angegeben.

Closures mit anonymen Funktionen
Schließlich haben anonyme Methoden Zugriff auf alle lexikalischen Variablen (also Argumentnamen von umgebenden Funktionen und mit let definierte Variablen). Tatsächlich sind die folgenden Versionen semantisch äquivalent und demonstrieren, dass das einfache let var = ...; Konstrukt auch als syntaktische Vereinfachung gewertet werden kann:

begin // Version 1
let a = 23;
let b = 4;
a * 2 - b
end;

oder

begin // Version 2
method(a)
method(b)
a * 2 - b
end(4)
end(23)
end;

Rekursion
Schließlich unterstützt Dylan auch den direkten (oder über andere Funktionen erfolgte) Aufruf einer Funktion aus ihrem Codekörper, die Rekursion. Sie ist besonders dann nützlich, wenn sich Probleme auf einen (oder mehrere) trivialen Kern zurückführen lassen und dabei in einfachere zerlegt werden können. Unter diesen Voraussetzungen kann eine Funktion mit Hilfe ihrer selbst definiert werden. Listing 1 zeigt einige Variationen der fak-Funktion, die diese Technik verwenden.



Listing 1

// Variante 1: rekursiv
define function fak (n)
if (n <= 1)
1
else
n * fak(n - 1)
end
end;

// Variante 2: lokal rekursiv
define function fak (n)
let fak-inner
= method(n, more)
if (n <= 1)
1
else
n * more(n - 1, more)
end if;
end method;

fak-inner(n, fak-inner)
end;

// Variante 3: lokal mit "local"
define function fak (n)
local method fak-inner(n)
n <= 1
& 1
| n * fak-inner(n - 1)
end method;

fak-inner(n)
end;

Variante 1 illustriert, dass Funktionsnamen der obersten Ebene stets rekursiv verwendet werden können. Da wir auf eine explizite Zuweisung verzichten können, wird der Algorithmus wesentlich leichter verständlich. Das Konstrukt

if (...)
...
else
...
end

kann ebenfalls überall auftreten, wo Werte verlangt werden.

Variante 2 zeigt, wie man die Rekursion mit einer lokal definierten Funktion erreichen kann, ohne einen eigenen Namen auf der obersten Ebene einzuführen. Dabei muss der lokalen Funktion auch ihr eigener Name als zweites Argument übergeben werden, damit der Selbstaufruf erfolgen kann, weil beim let var = ...; Konstrukt der gerade zu definierende Name in dem definierenden Ausdruck noch nicht sichtbar ist.

Um diese eher umständliche Methode zu vermeiden, stellt Dylan das local method-Konstrukt zur Verfügung, das in der Variante 3 gezeigt wird. Außerdem wird illustriert, wie mit den logischen Operatoren & (und) und | (oder) der Verzweigungsausdruck kompakter geschrieben werden kann. local method lässt auch mehrere gegenseitig rekursive Methoden zu, diese werden dann durch Kommata getrennt.

Tail-Call-Eliminierung
Als fünfte Säule der Unterstützung funktionaler Prinzipien gilt die so genannte Tail-Call-Optimierung. Diese ist zwingend, und einer der Hauptgründe, warum der Verzicht auf Zuweisungen überhaupt praktisch möglich ist. Der folgende Code zeigt eine weitere Variante von fak; diesmal ist das Ergebnis des rekursiven Aufrufs auch gleichzeitig das Resultat:

define function fak (n)
local method fak-inner(n, prod)
if (n <= 1)
prod
else
fak-inner(n - 1, n * prod)
end if
end method;

fak-inner(n - 1, n)
end;

Die wesentliche Bedeutung dieser Optimierung liegt darin, dass ein rekursiver Aufruf aus Endposition (Tail-Call) so optimiert werden kann, dass kein Stapelspeicher (Stack) für den weiteren Aufruf nötig ist.

fak-inner(n - 1, n * prod)

ist hier in Endposition, dessen berechneter Wert ist gleichzeitig der Rückgabewert der laufenden Berechnung. Damit kann der beim ersten Aufruf erzeugte Stack intern angepasst werden (n := n -1; prod := n * prod;) und ein Sprung an den Anfang der Funktion fak-inner beginnt die nächste Rekursion.
Durch Endrekursion haben wir also zwei Fliegen mit einer Klappe geschlagen: wir erhalten Zuweisungen und Sprünge! Dylan nutzt diesen Trick ausgiebig.



Listing 2

// original
for (i from n above 1 by -1)
result := result * i;
end;

// nach Expansion des "for"-Makros
local
method loop(i)
if (i > 1)
result := result * i;
loop(i - 1)
end if
end method;

loop(n);
#f

// nach Endrekursion-optimierung
// (pseudocode)
local
method loop(i)
start_of_loop:
if (i > 1)
result := result * i;
i := i - 1;
goto start_of_loop;
end if
end method;

loop(n);
#f

Wie Listing 2 demonstriert, werden alle Schleifen in einem Dylan-Programm in rekursive Funktionen übersetzt und mit Hilfe der Tail-Call-Optimierung in Zuweisungen und Sprünge überführt. Dies resultiert in Operationen, die auf aktuellen Prozessoren mit höchster Effizienz ablaufen können.

Link
www.gwydiondylan.org/


    Hat Ihnen dieser Artikel gefallen? Dann abonnieren Sie das Entwickler Magazin direkt über unser Online-Formular.

zur vorherigen Seite
zurück
an den Anfang der Seite
nach oben
Diesen Artikel drucken
drucken
Diesen Artikel weiterempfehlen
empfehlen

Software & Support Verlag GmbH