Dienstag, 11. September 2012

Schreiben was man meint

Nachdem mein letzter Artikel etwas in die "Metaprogrammierung" abgedrifftet ist, will ich diesemal einen sehr konkreten Einstieg in ein Lisp geben und an Beispielen gemeinsamkeiten und Unterschiede zu anderen Programmiersprachen herausarbeiten. Ich werde mich hierbei auf Scheme (einem Lisp Dialekt), C und Python beschränken, weil ich diese Sprachen programmieren kann und Python irgendwo in der Mitte zwischen beiden bewegt (Scheme-Code wurde mit GNU Guile ausgeführt, wobei auch andere Interpreter funktionieren sollten, Windows Nutzer könnten Racket benutzen.)


Zuerst einmal aber etwas Mathematik. Wir wollen zuerst einmal das "kanonische Beispiel" angehen, die Berechnung der Fakultät. Sie beschreibt Zahlen der Form 1 * 2 * 3 * 4 * 5 .... * n und man kann sie also etwas genauer als rekursive Funktion schreiben:

fak(n) = n * fak(n-1)  mit fak(0)

Man sieht also: Man kann eine Fakultät von n berechnen, wenn man die Fakultät von n-1 kennt, und diese mit n multipliziert.

In Python kann man die Fakultät wie folgt definieren (wer etwas programmieren kann sollte diesen Code lesen können):

def fak(n):
    if n == 1:
        return 1
    else:
        return n*fak(n)



In Scheme schreibt definiert man die Fakultät ganz ähnlich.

(define (fak n)
  (if (= n 1)
      1
      (* n (fak (- n 1)))))


Vergleicht man beide Schnipsel fallen 4 Dinge auf:
  • Scheme-Code wird mit Klammern strukturiert, für den Programmierer anschaulich ist jedoch die Einrückung!
  • Funktionsaufrufe werden als (f x) geschrieben statt f(x)
    (- n 1) bedeutet n-1 der Operator ist also eine Funktion
  • Es gibt kein return (Im Zweifelsfall ist alles ein Rückgabewert)
  • Der if-Ausdruck schaut genau wie ein Funktionsaufruf aus.
    (if bedingung wennja sonst)
Das war jetzt viel geballte information auf einem Fleck. Damit etwas deutlicher wird, wie das Programm funktioniert, sollte man es am besten ausprobieren. Dazu öffnet man die REPL, also einen interaktiven Interpreter. Wer andere Skriptsprachen kennt, kennt das Konzept. Man tippt Befehle ein und bekommt ihre Ergebnisse zurück. Also

scheme@(guile-user)> (fak 3)
$1 = 6


es lässt sich auch überraschend einfach als Taschenrechner verwenden, etwa wie hier:

scheme@(guile-user)> (+ 4 (* 3 2))
$2 = 10


Überaschend einfach (errinnert ihr Euch noch daran, wieviel Jahre Eure Lehrer im Mathematikunterricht benötigt haben, Euch die Punkt vor Strich Regel beizubringen?)

Die wahre Magie der REPL macht sich aber erst durch Zusatzbefehle bemerkbar. Wir wollen nähmlich erfahren, wie genau die Fakultät die wir oben definiert haben berechnet wird. Dazu verfolgen wir den Befehlsaufruf mit ,trace (Guile):

scheme@(guile-user)> ,trace (fak 5)
trace: (fak 5)
trace: |  (fak 4)
trace: |  |  (fak 3)
trace: |  |  |  (fak 2)
trace: |  |  |  |  (fak 1)
trace: |  |  |  |  1
trace: |  |  |  2
trace: |  |  6
trace: |  24
trace: 120


Hier sieht man also, dass die Lösung der Fakultät in der Berechnung der kleineren Fakultäten liegt. Erst wird die Fakultät von 1 berechnet (1), daraus die von 2 (2), dann die von 3 (3), usw.

Performance!

Dieser Ansatz hat jedoch einen gravierenden Nachteil. Denn mit jedem Funktionsaufruf wird der Stack vergrößert! Jeder Funktionsparameter n wird auf den Stack gelegt, dass Zwischenergebnis (* 5 (fak 4)) wird berechnet, was bedeutet, dass (fak 4) bestimmt werden muss, also insgesamt (* 5 (* 4 (fak 3))), usw. Das sieht mit ,trace zwar hübsch aus, ist aber ineffizient. Für große n kommt so das Programm schnell an seine Grenzen. Daher stellt sich die Frage Kann man das Programm so verändern, dass  sich die Funktionsberechnungen nicht so aufstauen wie hier?

Man könnte nun eine Schleife schreiben (keine Sorge, kommt noch) aber es gibt da auch andere Möglichkeiten, und hier zeigt sich nun ein interessanter Vorteil von Scheme.
 
(define (accumulating-fak accumulator countdown)
    (if (= countdown 0)
    accumulator
    (accumulating-fak (* accumulator countdown)
              (- countdown 1))))

 
Diese Funktion ruft man mit (accumulating-fak 1 n) auf, und erhält die n-te Fakultät. Der Verlauf des Programms wird spätestens dann klar, wenn man auf einem Blatt Papier mal das Verhalten für die Fakultät von 5 durchspielt.

(accumulating-fak 1 5) ->
(accumulating-fak (* 1 5) (5-1 )) ->
(accumulating-fak 5 4) ...

Und weil das von Hand so langsam geht, können wir wieder mit ,trace dem Interpreter bei der Arbeit zuschauen.

scheme@(guile-user)> ,trace (accumulating-fak 1 5)
trace: (accumulating-fak 1 5)
trace: (accumulating-fak 5 4)
trace: (accumulating-fak 20 3)
trace: (accumulating-fak 60 2)
trace: (accumulating-fak 120 1)
trace: (accumulating-fak 120 0)
trace: 120


Hier sieht man, dass der Parameter Akkumulator mit jedem Aufruf wächst, während der parameter Counter noch die fehlenden Multiplikationen angibt.

Was hier geschieht (und erfahrene C/Java/... Programmierer verwundern wird) ist, dass wir eine Rekursion haben, sich aber keine Funktionsaufrufe aufstauen. Weil accumulating-fak direkt das Ergebnis des nächsten Rekursiven Aufrufs zurückgibt, kann der Interpreter optimieren. Dies nennt sich Tail-Recursion. In vielen Sprachen (wie Python) gibt es diese nicht, man ist also gezwungen zu Schleifen zu greifen.

Schleifen

Nun will ich anhand von C zeigen, wie man die Fakultät ohne die Rekursion berechnet. Das ist an sich nicht schwierig und ich müsste das daher eigentlich nicht nochmal hier aufführen, aber ich möchte an dem Beispiel noch ein paar andere Dinge klarmachen. Hier also der Schnipsel (habe verzichtet es in eine Funktion zu packen, da wir ja auch nicht rekursiv arbeiten hier):

int accumulator = 1;
for (int i=1; i < n; ++i) {
   accumulator *= i;
}

Dies entspricht einer sehr effizienten Berechnung der Fakultät. Die Variable result wird mit Zahlen aufsteigend bis n multipliziert. Dabei ändert sich der Wert von result in jedem Schritt (Die Variable accumulator in der Scheme Funktion ändert  sich zwar von Funktionsaufruf zu Funktionsaufruf, aber strenggenommen ist es bei jedem Funktionsaufruf ja auch eine neue Variable, also bleibt sie in jedem Funktionsaufruf konstant, währrend sie sich im C Quelltext ändert).
 
die Operationen *= und ++ sind übrigens deshalb so notiert, weil für sie direkt Befehle der CPU existieren. C ist sehr nahe an der Hardware.

Nun gibt es prinzipiell auch die Möglichkeit den Code von oben umzuschreiben:

int accumulator = 1;
int i=1;
while ( i < n ) {
   accumulator *= i;
   ++i;
}

Wie man hier sieht, ist die For-Schleife nur eine Abkürzung für eine einfachere while-Schleife. Schemer nennen sowas gerne Syntax-Zucker. Weitaus weniger Bekannt dürfte aber die folgende Variante sein:

int accumulator = 1;
int i=1;
loopstart:
   accumulator *= i;
   ++i;
if (i < n)
  goto loopstart;

Die Variante mit goto dürfte einige Überraschen. Aber diese Variante ähnelt sehr dem, was im Prozessor tatsächlich ausgeführt wird wenn man auf die Schleifen zurückgreift. Der Grund warum man kaum Code mit goto Befehlen sieht ist, dass Programme leicht unübersichtlich werden, wenn man willkürlich herumspringen kann. Die Schleifen while und for sind da Strukturierter, sie abstrahieren den bedingen Sprung mit goto zu einer Aussage wie "solange dies gilt" oder "von 1 bis n".

Dennoch wird der große Unterschied zur Denkweise in Scheme deutlich: Hier hatten wir formuliert: Eine Fakultät von n ist n mal die Fakultät von n-1. [*]
Die Strategie die dort verwendet wird ist: löse einen Basisfall (fak 1) -> 1 und führe die anderen Fälle auf den Basisfall zurück.

Zu Abstrakt?

Kann es also sein, dass Scheme zu Abstrakt ist? Was ist für unsere Gehirne natürlicher, die Rekursive Definition oder die über die Schleife?

Auf den ersten Blick erscheint uns sicher die Schleife vertrauter. Unzählige hat ein C-Java-C++-Programmierer schon geschrieben. Aberdann erinnert man sich vielleicht daran, wie man Lösungswege findet, bevor man am Code tippen ist.

Teile-und-Herrsche-Algorithmen zum Beispiel Teilen ein Problem solange in Teilprobleme auf, bis sie an Basisfällen zu 1 oder 2 Elemente kommen, für die die Lösung direkt klar ist. Dann werden die Teillösungen zusammengefügt. Dies entspricht im Grunde dem Ansatz der sich auch im Scheme Code wiederfindet.

Wer also die Fakultät über die Schleifen berechnet, der hat im Grunde im Kopf die Rekursionsgleichung für die Fakultät gelöst, also mehr Mathematik betrieben, bevor er eine Zeile Code schreiben konnte, als bei der Formulierung in der "mathematischeren" Programmiersprache.

Ausblick

Ich hoffe, dass mit diesem Tutorial ein erster Einblick in Scheme gewonnen wurde. Im Grunde wurde hier erst einmal funktionelles Programmieren vorgestellt und gezeigt, dass man Sprachelemente wie Schleifen oder Funktionsaufrufe Zerlegen und Umformen kann.

Tail-Recursion allein wäre sicherlich aber noch kein Grund allein Lisp oder Scheme zu lernen. Spannende Features gibt es zuhauf:
  • Warum ist die Scheme-Syntax so anders? Wie wäre es mit Syntax-Zucker?
  • Makros und Lisp/Scheme als programmierbare Programmiersprache
  • Das mächtige Lambda

PS: Klammern


Der Klammerdjungel bei Scheme-Programmen ist für viele am Anfang unübersichtlich und verwirrt. Dabei ist die Lösung recht einfach. Für Lisper sind Klammern vielmehr Einrückhilfen für einen guten Editor. Mit dem Typischen Einrückschema braucht man die Klammern im Grunde eigentlich kaum zu beachten, bzw. nicht mehr als in anderen Programmiersprachen. Deswegen werden auch die schließenden Klammern einfach direkt hintereinander ans Zeilenende gestellt. Kompakt auf einem Haufen stören sie nicht.

Montag, 10. September 2012

Lispeln

In letzter Zeit interessiere ich mich immer mehr für die Programmiersprachen Scheme/Lisp, bzw. Allgemein für Programmiersprachen. Mein Ziel ist es, einen Schritt zurückzutreten, um mit etwas Abstand zu betrachten, was Programmieren überhaupt bedeutet. Was ist das eigentlich, Programmieren? Wie schreiben wir Programme? Was bestimmt beim Programmieren unser Denken?

Insbesondere will ich ein bisschen wegkommen, von den kleinlichkeiten, die einen so oft ereilen, wenn man über Programmiersprachen redet (Ich erinnere mich an endlose Diskussionen über geschweifte Klammern {} vs. begin end in Pascal, oder über für und wieder vom Array-Indizieren ab 1 oder 0... Diese Fragen haben mittlerweile für mich keine Relevanz mehr).

Der Punkt, der mich viel mehr interessiert ist, dass wir beim Programmieren einer Maschine mit begrenztem Verständnis aber starker Rechenpower beibringen, eine unserer Ideen zur Lösung eines Problems (Algorithmus) vermitteln, indem wir sie mittels einer Programmiersprache ausdrücken. Das hat viel mit "Abstraktion" zu tun, da wir für ein konkretes Problem (etwa beste Bahnverbindung zwischen zwei Städten) eine verallgemeinerte Lösung finden und dann die Lösung des Problems für einen Rechner, der in 0en und 1en denkt formulieren.

Schreibt man ein C Programm (oder Java, C++, Pascal, etc), so ist man nahe an der Rechnerarchitektur. Das wird gerne etwas verdrängt, etwa, wenn man sich zwischen Datentypen wie int und double entscheidet, so begründet man dies meist mit "ganze Zahlen" oder "reelle Zahlen". In Wahrheit jedoch, ist int nur ein kleiner Ausschnitt der ganzen Zahlen (2 hoch 32 Stück auf einem Pentium) und eine Variable vom Typ float kann nicht mehr oder weniger Zahlen beschreiben, welche jedoch über einen groesseren Bereich verteilt sind - viele Programmierer werden dennoch eine Variable vom Typ float für wesentlich genauer halten, als eine Variable vom Typ int.

Der Interessante Vorgang ist also folgender: Wir nehmen einen Abstrakten Lösungsansatz (Algorithmus) und formulieren ihn in einer Programmiersprache (Implementierung). Der Compiler übersetzt dies in Maschinencode und dieser wird vom Rechner ausgeführt.

Dies führt mich zum Knackpunkt der Geschichte:

Wodurch unterscheiden sich Implementierung und Kompilierung?

Man könnte sagen, der Mensch ist ebenso ein Compiler wie der Compiler auf dem PC. Kompilieren heißt ursprünglich soviel wie "Zusammenstellen", insofern liegt diese Sichtweise auch sprachlich durchaus nahe.

In dem wir die Welt in der Programmiersprache in Variablen, Typen, Funktionen einteilen und formulieren, leisten wir einen wesentlichen Teil der Übersetzungsarbeit um eine Idee auf einen Rechner zu bekommen.

Abstrakte Sprachen

Auslöser für diesen Blogpost ist meine Begegnung mit Lisp.
Lisp ist - abstrakt. Die Sprache berücksichtigt auf den ersten Blick kaum, wie der Rechner aufgebaut ist und ermutigt zu einer sehr mathematischen Sichtweise des Programmierens. Im Gegensatz zu den imperativen Programmiersprachen wie C/C++ haben Funktionen in Lisp eine viel größere Bedeutung, sie sind so flexibel einsetzbar wie Variablen. Daher auch der Vorwurf: zu komplex, zu mathematisch, zu unintuitiv.

Aber: Ist nicht das wichtigste an einem Programm seine Funktion?
Wer länger in Lisp programmiert, wird erleben, dass sich der Fokus vom "Wie stelle ich Daten am Computer dar" mehr auf das "Wie berechne ich etwas" verschiebt. Und dies ist ja die Frage, mit der  man sich ohnehin schon bei der Lösungsfindung auseinandersetzt.

Ich empfehle den Selbstversuch. Ein guter Ausgangspunkt ist ein Scheme interpreter wie Guile oder Racket und bestenfalls verschiedene Scheme Tutorials parallel anzuschauen (Structure and Interpretation of Computer Programs ist nur ein Beispiel).

Donnerstag, 2. August 2012

Zueignung

Dieses Blog ist für all jene, die Freude am programmieren haben und verstehen, dass es dabei nicht nur um Abspulen von nichtverstandenen Codezeilen geht, sondern dass sie an einer Zwiebel arbeiten, und Schale um Schale anlegen, um sie zum Blühen zu bewegen und für alle, die noch lernen wollen.

Nach diesem schwülstigen Satz: Herzlich willkommen zu meinem Blog. Hier will ich meine Gedanken zum Programmieren, Programmiersprachen, Sprachen, und allerlei Kreativem was damit zusammenhängt breittreten. Dabei geht es mir unter anderem besonders über die Ausdrucksfähigkeit beim Programmieren, und um den Vorgang der Abstraktion, der es uns erlaubt mit einer Maschine, die kaum mehr kann, als bipolare Speicherzustände in kleinen Zellen hin- und herzuschalten, wundervolle Dinge zu bewirken.


Nächstes erklärtes Ziel ist, die Alphabetisierung der Programmierung einzuleiten oder bessergesagt, darüber nachzudenken, wie man das Programmieren aus seiner Nischenstellung ins breite Bewusstsein schaffen kann, denn ich frage mich: Ist die Fähigkeit zu Programmieren nicht genauso wichtig, wie zu rechnen oder zu lesen und zu schreiben? Brauchen wir eine Algorithmische Aufklärung?

Oder um Kant zum Vorbild zu nehmen: Die Herausführung des Menschen, aus seiner selbstverschuldeten Unmündigkeit. Vielleicht ist es an der Zeit die der Selbstverschuldung (App-Store) ein Ende zu bereiten und aus der Unmündigkeit herauszuführen.