The
Pragmatic Programmers
Sieben Wochen, sieben Sprachen Verstehen Sie die modernen Sprachkonzepte
Deutsche Übersetzung von
O’REILLY
Bruce A. Tate Übersetzt von Peter Klicman
Sieben Wochen, sieben Sprachen Verstehen Sie die modernen Sprachkonzepte
Sieben Wochen, sieben Sprachen Verstehen Sie die modernen Sprachkonzepte
Bruce A. Tate Deutsche Übersetzung von Peter Klicman
Beijing · Cambridge · Farnham · Köln · Sebastopol · Tokyo
Die Informationen in diesem Buch wurden mit größter Sorgfalt erarbeitet. Dennoch können Fehler nicht vollständig ausgeschlossen werden. Verlag, Autoren und Übersetzer übernehmen keine juristische Verantwortung oder irgendeine Haftung für eventuell verbliebene Fehler und deren Folgen. Alle Warennamen werden ohne Gewährleistung der freien Verwendbarkeit benutzt und sind möglicherweise eingetragene Warenzeichen. Der Verlag richtet sich im Wesentlichen nach den Schreibweisen der Hersteller. Das Werk einschließlich aller seiner Teile ist urheberrechtlich geschützt. Alle Rechte vorbehalten einschließlich der Vervielfältigung, Übersetzung, Mikroverfilmung sowie Einspeicherung und Verarbeitung in elektronischen Systemen. Kommentare und Fragen können Sie gerne an uns richten: O’Reilly Verlag Balthasarstr. 81 50670 Köln E-Mail:
[email protected] Copyright der deutschen Ausgabe: © 2011 by O’Reilly Verlag GmbH & Co. KG 1. Auflage 2011 Die Originalausgabe erschien 2010 unter dem Titel Seven Languages in Seven Weeks bei Pragmatic Bookshelf, Inc. Bibliografische Information der Deutschen Nationalbibliothek Die Deutsche Nationalbibliothek verzeichnet diese Publikation in der Deutschen Nationalbibliografie; detaillierte bibliografische Daten sind im Internet über http://dnb.d-nb.de abrufbar.
Übersetzung und deutsche Bearbeitung: Peter Klicman Lektorat: Volker Bombien, Köln Fachliche Unterstützung: Sven Riedel, München Korrektorat: Eicke Nitz, Köln Satz: Andreas Franke, SatzWERK, Siegen; www.satz-werk.com Produktion: Karin Driesen, Köln Belichtung, Druck und buchbinderische Verarbeitung: Druckerei Kösel, Krugzell; www.koeselbuch.de ISBN 978-3-89721-322-7 Dieses Buch ist auf 100% chlorfrei gebleichtem Papier gedruckt.
Inhaltsverzeichnis Widmung
1
Danksagung
3
Vorwort
7
1
Einführung 1.1 1.2 1.3 1.4 1.5
2
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
Ein wenig Geschichte . . . . . . . . . . Tag 1: Ein Kindermädchen finden Tag 2: Vom Himmel herab . . . . . . Tag 3: Tiefgreifende Veränderung . Ruby zusammengefasst . . . . . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
22 24 32 45 53 59
Einführung in Io . . . . . . . . . . . . . . . . . . . . . . . Tag 1: Blaumachen und rumhängen . . . . . . . . Tag 2: Der Würstchenkönig . . . . . . . . . . . . . . . Tag 3: Die Parade und andere sonderbare Orte Io zusammengefasst. . . . . . . . . . . . . . . . . . . . .
. . . . .
. . . . .
. . . . .
. . . . .
Prolog 4.1 4.2
11 13 15 17 20 21
Io 3.1 3.2 3.3 3.4 3.5
4
. . . . .
Ruby 2.1 2.2 2.3 2.4 2.5
3
Wahnsinn mit Methode . . . . . Die Sprachen . . . . . . . . . . . . . Kaufen Sie dieses Buch . . . . . Kaufen Sie dieses Buch nicht . Ein letzter Punkt . . . . . . . . . .
11
59 60 74 83 92 97
Über Prolog . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Tag 1: Ein ausgezeichneter Fahrer . . . . . . . . . . . . . .
98 99
VI Inhaltsverzeichnis 4.3 4.4 4.5 5
Scala 5.1 5.2 5.3 5.4 5.5
6
. . . . . . . . 161 . . . . . . . . 176 . . . . . . . . 186 191
Einführung in Erlang . . . . . . . Tag 1: Menschlich erscheinen . Tag 2: Die Form ändern . . . . . Tag 3: Die rote Pille . . . . . . . . . Erlang zusammengefasst . . . .
. . . . .
.. .. .. .. ..
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
191 196 207 219 232 237
Einführung in Clojure . . . . Tag 1: Luke trainieren. . . . Tag 2: Yoda und die Macht Tag 3: Ein Auge für Böses . Clojure zusammengefasst .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
238 239 258 272 282 287
Einführung in Haskell. . . . . . . . Tag 1: Logisch. . . . . . . . . . . . . . Tag 2: Spocks große Stärke. . . . Tag 3: Gedankenverschmelzung Haskell zusammengefasst . . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
Zusammenfassung 9.1 9.2 9.3 9.4
A
. . . . . . . . 141 . . . . . . . . 146
Haskell 8.1 8.2 8.3 8.4 8.5
9
Über Scala . . . . . . . . . . . . . . . . . . . . . . . . Tag 1: Die Burg auf der Anhöhe . . . . . . . . Tag 2: Gesträuch beschneiden und andere neue Tricks. . . . . . . . . . . . . . . . . . . . . . . . Tag 3: Sich durch die Fusseln schneiden . Scala zusammengefasst . . . . . . . . . . . . . .
Clojure 7.1 7.2 7.3 7.4 7.5
8
141
Erlang 6.1 6.2 6.3 6.4 6.5
7
Tag 2: Fünfzehn Minuten für Wapner . . . . . . . . . . . . 112 Tag 3: Die Bank sprengen . . . . . . . . . . . . . . . . . . . . . 124 Prolog zusammengefasst . . . . . . . . . . . . . . . . . . . . . . 137
Programmiermodelle . . . Nebenläufigkeit . . . . . . . Programmierkonstrukte Ihre Sprache finden . . .
287 288 305 316 332 337
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
337 341 343 346
Bibliografie
347
Index
349
Widmung Die fünf Monate vom Dezember 2009 bis zum April 2010 waren mit die schwierigsten meines Lebens. Mein Bruder, nicht einmal 47, bekam in einer Notoperation einen Bypass. Niemand ahnte überhaupt, dass etwas nicht in Ordnung sein könnte. (Die Operation verlief ohne Komplikationen und es geht ihm gut.) Ende März wurde bei meiner Schwester Brustkrebs diagnostiziert, bei meiner Mutter Krebs im Endstadium. Nur wenige Wochen später verstarb sie. Sie werden erwarten, dass ich mit dem Schmerz eines unerwarteten Verlustes durch eine auf brutale Weise effiziente Krankeit kämpfe. Doch seltsamerweise war diese Erfahrung nicht nur negativ. Wissen Sie, meine Mutter war mit ihrem bemerkenswerten Leben, das sie lebte, im Reinen. Die Beziehung zur Familie war stark und erfüllend, und sie war mit ihrem Glauben genau da, wo sie sein wollte. Lynda Lyle Tate legte ihre gesamte kreative Energie in das Malen von Aquarellen. Ihre Kunst teilte sie hauptsächlich über ihre Kunstgallerie in der Madison Avenue und über ihren Unterricht. Bevor ich von zu Hause weg ging, hatte ich die Gelegenheit, von ihr einige Lektionen zu lernen. Für jemanden mit einem technischen Beruf war diese Erfahrung immer ein wenig verwirrend. Ich konnte das Meisterstück auf meiner weißen Leinwand erkennen. Während mein Bild langsam Formen annahm, entfernte ich mich immer weiter von meiner ursprünglichen Vision. Wenn ich daran verzweifelte, dass es nicht in meiner Macht läge, diese Dinge zu korrigieren, sah mir meine Mutter über die Schulter und erzählte mir, was sie sah. Nachdem ihre talentierten Hände hier ein Schwarz hinzugefügt hatten, um die Tiefe zu betonen, und dort ein wenig ein Weiß, um Klarheit und Details zu schaffen, erkannte ich, dass ich gar nicht so weit vom Weg abgekommen war. Es bedurfte nur
2 Widmung des richtigen Anstoßes, um mich vor dem Abgrund zu bewahren. Dann riss ich meine Arme im Triumph in die Höhe und erzählte jedem in meiner Klasse, was ich da geschaffen hatte, ohne zu erkennen, dass jeder seinen eigenen Freuden durchlebte. Nach einer Weile erkannte ich, dass Mutter noch an einem anderen Bildnis arbeitete. Durch ihren Glauben und ihren Beruf fand sie gebrochene Menschen. Sie entdeckte eine verlorene Ehefrau hier und eine zerrüttete Ehe dort, brachte sie in den Unterricht und nutzte Farbe und Papier, um eine Tür zu öffnen, die zugeschlagen worden war. Während wir die letzten Wochen miteinander verbrachten, besuchten sie immer wieder Menschen, die bei dem Gedanken, ihren Lehrer zu verlieren, am Boden zerstört waren. Doch Mutter machte immer den passenden Witz oder fand die richtigen Worte, um diejenigen zu trösten, die zu ihrem Trost gekommen waren. Ich lernte die menschlichen Bildnisse kennen, die die Meisterin auf den rechten Weg gebracht hatte und die nun Großes leisten würden. Es war eine demutsvolle Erfahrung. Als ich meiner Mutter sagte, dass ich ihr dieses Buch widmen würde, sagte sie mir, dass ihr das gefallen würde, dass sie aber nichts mit Computern zu tun hätte. Das ist allerdings wahr. Allein der Gedanke an „Fenster“ machte sie ratlos. Doch sie hatte genug mit mir zu tun. Die wohl bedachten unterstützenden Worte inspirierten mich, ihre Liebe zur Kreativität formten mich, und ihr Enthusiasmus und Lebensfreude leiten mich selbst heute noch. Während ich über diese Erfahrungen nachdenke, fühle ich mich tatsächlich ein wenig besser und stärker, da auch ich ein von der Meisterin geschaffenes Bildnis bin. Dieses Buch ist Lynda Lyle Tate (1936–2010) gewidmet.
Danksagung Dies war das anspruchsvollste Buch, das ich jemals geschrieben habe. Es war gleichzeitig aber auch das lohnendste. Dafür haben die Menschen gesorgt, die mir auf unterschiedliche Weise Hilfe angeboten haben. Zuallererst möchte ich meiner Familie danken. Kayla und Julia, euer Schreiben beeindruckt mich. Ihr könnt euch noch nicht vorstellen, was ihr erreichen könnt. Maggie, du bist meine Freude und Inspiration. In der Ruby-Community geht mein Dank an Dave Thomas, der mich zu dieser Sprache brachte, die meine Karriere auf den Kopf gestellt und mir den Spaß wiedergegeben hat. Dank auch an Matz für seine Freundschaft und die Möglichkeit, seine Gedanken mit den Lesern zu teilen. Er lud mich nach Japan an den Ort ein, an dem Ruby geboren wurde, und diese Erfahrung hat mich weit mehr inspiriert, als man sich vorstellen kann. Dank an Charles Nutter, Evan Phoenix und Tim Bray für die Unterhaltungen zu Themen in diesem Buch, die einem langweilig vorkommen mussten, mir aber dabei halfen, die Botschaft zu formen und zu verfeinern. In der Io-Community geht mein Dank an Jeremy Tregunna, der mir beim Einstieg half und einige coole Beispiele für das Buch mit mir teilte. Seine Rezensionen waren mit die besten. Sie kamen frühzeitig und sorgten für ein wesentlich besseres Kapitel. Steve Dekorte hat etwas Besonderes geschaffen, ganz egal, ob der Markt das jemals erkennt. Die Features zur Nebenläufigkeit sind großartig und die Sprache besitzt eine innere Schönheit. Ich kann nur bestätigen, dass sich der Großteil der Spache gut anfühlt. Der Neuling dankt für die Hilfe beim Debugging der Installation. Dank auch für die aufmerksamen Rezensionen und das Interview, das mir dabei half, den Kern von Io zu
4 Danksagung erfassen. Er hat die Phantasie der Beta-Leser angeregt und die Lieblingssprache vieler dieser Leser geschaffen. In der Prolog-Community geht mein Dank an Brian Tarbox, der seine bemerkenswerten Erfahrungen mit den Lesern geteilt hat. Die DelphinProjekte haben dem Prolog-Kapitel sicher etwas zusätzliche Dramatik verliehen. Meinen besonderen Dank an Joe Armstrong. Er wird sehen, wie sehr sein Feedback dieses Kapitel und das ganze Buch geprägt hat. Dank auch für das Beisteuern des Landkarte-einfärben-Beispiels und den Ideen zu Append. Das waren die richtigen Beispiele zur rechten Zeit. In der Scala-Community geht mein Dank an meinen guten Freund Venkat Subramaniam. Sein Scala-Buch ist sowohl umfassend als auch verständlich. Ich habe viel daraus gelernt. Auch die Rezensionen und die kleinen Hilfestellungen waren sehr willkommen. Diese kleinen Hilfen ersparten mir viel Leid und erlaubten es mir, mich auf das Lehren zu konzentrieren. Dank auch an Martin Odersky, der einem Fremden half, indem er seine Gedanken mit den Lesern teilte. Scala verfolgt einen einmaligen und mutigen Ansatz, funktionale mit objektorientierten Ansätzen zu verbinden. Diese Bemühungen sind sehr willkommen. In der Erlang-Community möchte ich erneut Joe Armstrong danken. Seine Freundlichkeit und Energie halfen mir dabei, die Ideen in diesem Buch zu formen. Seine unermüdliche Werbung für die Art und Weise, wie verteilte, fehlertolerante Systeme aufgebaut sein sollten, hat funktioniert. Mehr als jede andere Idee aller anderen Sprachen in diesem Buch ergibt Erlangs „Lass es abstürzen“-Philosophie einen Sinn für mich. Ich hoffe, dass diese Ideen eine weitere Verbreitung finden. In der Clojure-Community gilt mein Dank Stuart Halloway für die Rezensionen und Ideen, die mich dazu zwangen härter zu arbeiten, um den Lesern ein besseres Buch liefern zu können. Seine Einsichten in Clojure und sein Instinkt halfen mir die wichtigen Dinge zu verstehen. Sein Buch hatte einen großen Einfluss auf das Clojure-Kapitel und hat tatsächlich dazu geführt, dass ich einige Probleme in anderen Kapiteln anders angegangen bin. Sein Consulting-Ansatz ist sehr willkommen. Er bringt die so sehr benötigte Einfachheit und Produktivität in diesen Wirtschaftszweig. Dank auch an Rich Hickey für die wohlüberlegten Ideen zur Entwicklung der Sprache und was es bedeutet, ein Lisp-Dialekt zu sein. Einige Ideen in Clojure sind recht radikal und doch so praktisch. Glückwunsch. Er hat einen Weg gefunden, Lisp zu etwas Revolutionärem zu machen. Wieder einmal.
Danksagung 5 In der Haskell-Community geht mein Dank an Phillip Wadler. Er verschaffte mir einen Einblick in den Prozess, mit dem Haskell geschaffen wurde. Wir teilen die Leidenschaft für das Lehren, und er ist sehr gut darin. Dank auch an Simon Peyton-Jones. Ich habe das Interview sehr genossen, die neuen Einsichten und die für den Leser einmalige Perspektive. Die Gutachter haben hervorragende Arbeit geleistet. Dank an Vladimir G. Ivanovic, Craig Riecke, Paul Butcher, Fred Daoud, Aaron Bedra, David Eisinger, Antonio Cangiano und Brian Tarbox. Sie bildeten das effektivste Team, mit dem ich jemals zusammengearbeitet habe. Das Buch ist dadurch wesentlich besser. Ich weiss, dass das Korrigieren eines Buches auf dieser Ebene eine undankbare, anstrengende Arbeit ist. Diejenigen unter uns, die technische Bücher immer noch mögen, werden es ihnen danken. Das Verlagswesen wäre ohne sie unmöglich. Ich möchte auch denen danken, die ihre Ideen zur Auswahl von Sprachen und der Programmier-Philosophie mit mir geteilt haben. Zu unterschiedlichen Zeitpunkten lieferten Neal Ford, John Heintz, Mike Perham und Ian Warshak wichtige Beiträge. Diese Art der Unterhaltung lässt mich schlauer aussehen, als ich eigentlich bin. Den Beta-Lesern möchte ich danken, dass sie das Buch gelesen und mich am Arbeiten gehalten haben. Die Kommentare zeigten mir, dass die Sprachen tatsächlich durchgearbeitet und nicht nur überflogen wurden. Ich habe das Buch bisher basierend auf hunderten von Kommentaren korrigiert, und ich erwarte, dass in der Lebensspanne dieses Buches noch einiges dazukommt. Schlussendlich gilt mein Dank dem Team von Pragmatic Bookshelf. Dave Thomas und Andy Hunt hatten einen nicht zu ermessenden Einfluss auf meine Karriere als Programmierer und auch als Autor. Diese Publishing-Plattform hat das Schreiben wieder rentabel für mich gemacht. Wir können ein Buch wie dieses (das sich nicht an den Massenmarkt richtet) finanzell interessant machen. Dank an alle Mitglieder des Publishing-Teams. Jackie Carter reichte mir die freundliche Hand und gab mir die Führung, die dieses Buch brauchte. Ich hoffe, er hat unsere Unterhaltungen so sehr genossen wie ich. Dank an all diejenigen, die im Hintergrund an diesem Buch gearbeitet haben. Insbesondere möchte ich dem Team danken, dass dafür gesorgt hat, dass dieses Buch gut aussieht und die all meine schlechten Angewohnheiten ausgemerzt haben: Kim Wimpsett erledigte die Korrekturen, Seth Maislin, den Index, Steve Peter den Satz und Janet Furlow die Produktion. Das Buch wäre ohne sie nicht, was es ist.
6 Danksagung Wie immer bin ich für alle Fehler verantwortlich, die sich eingeschlichen haben. Diejenigen, die ich vergessen habe, bitte ich höflichst um Entschuldigung. Es war keine Absicht. Zum Schluß möchte ich allen Lesern danken. Ich denke, dass echte, gedruckte Bücher einen Wert haben und ich kann meiner Leidenschaft, dem Schreiben, fröhnen, weil Sie das auch so sehen. Bruce Tate
Vorwort Aus dem noch zu schreibenden „Wie Proust Sie zu einem besseren Programmierer macht“. Von Joe Armstrong, Entwickler von Erlang „Der Gmail-Editor kann mit typographischen Anführungszeichen nicht richtig umgehen.“ „Skandalös“, sagte Margery, „das Zeichen eines ungebildeten Programmierers und einer dekadenten Kultur.“ „Was sollen wir damit machen?“ „Wir müssen darauf beharren, dass die nächsten von uns angestellten Programmierer ,A la recherche du temps perdu‘ komplett gelesen haben.“ „Alle sieben Bände?“ „Alle sieben Bände.“ „Wird das ihre Interpunktion verbessern und die Anführungszeichen wieder in Ordnung bringen?“ „Nicht unbedingt, aber es wird sie zu besseren Programmierern machen. Das ist so ein Zen-Ding ...“ Programmieren lernen ist wie schwimmen lernen. Keine Theorie kann den Sprung in den Pool, das Strampeln im Wasser und das nach Luft schnappen ersetzen. Wenn Sie das erste Mal unter Wasser sinken, geraten Sie in Panik, doch sobald Sie die Oberfläche erreichen und etwas Luft einatmen, fühlen Sie sich ermutigt. Sie denken für sich „ich kann schwimmen“. Zumindest fühlte ich mich so, als ich schwimmen lernte.
8 Vorwort Mit der Programmierung ist es das gleiche. Sie brauchen einen guten Lehrer, der Sie ermutigt ins Wasser zu springen. Bruce Tate ist ein solcher Lehrer. Dieses Buch gibt Ihnen die Gelegenheit, den schwierigsten Teil des „Programmieren lernens“ zu lernen, nämlich anzufangen. Nehmen wir an, dass Sie die schwierige Aufgabe gemeistert haben, den Interpreter oder Compiler für die Sprache, an der Sie interessiert sind, herunterzuladen und zu installieren. Was sollen Sie als nächstes tun? Was wird Ihr erstes Programm sein? Bruce beantwortet diese Frage sehr geschickt. Geben Sie einfach die Programme und Programmfragemente aus diesem Buch ein und schauen Sie, ob Sie die Ergebnisse reproduzieren können. Denken Sie noch nicht an das Schreiben eigener Programme — versuchen Sie nur, die Beispiele in diesem Buch zu reproduzieren. Sobald Ihre Zuversicht gewachsen ist, können Sie eigene Programmierprojekte angehen. Der erste Schritt beim Erlernen neuen Fachwissens besteht nicht darin, eigene Dinge zu tun, sondern darin, das reproduzieren zu können, was andere Leute schon gemacht haben. Das ist der schnellste Weg, sich neues Fachwissen anzueignen. Wenn Sie mit der Programmierung in einer neuen Sprache beginnen, geht es nicht so sehr darum, die der Sprache zugrundeliegenden Prinzipien zu verstehen. Vielmehr geht es zuerst darum, die Semikola und Kommata an den richtigen Stellen zu setzen, und die komischen Fehlermeldungen zu verstehen, die erscheinen, sobald Sie einen Fehler machen. Erst wenn Sie die lästige Aufgabe gemeistert haben, ein Programm einzugeben und fehlerfrei durch den Compiler zu jagen, können Sie damit anfangen, über die Bedeutung der verschiedenen Sprachkonstrukte nachzudenken. Sobald Sie die Mechanik der Eingabe und Ausführung von Programmen gemeistert haben, können Sie sich zurücklehnen und entspannen. Ihr Unterbewusstsein erledigt den Rest. Während Ihr Bewusstsein ergründet, wo die Semikola hingehören, findet ihr Unterbewusstsein die tiefere Bedeutung der unter der Oberfläche liegenden Strukturen heraus. Dann wachen Sie eines Morgens plötzlich auf und verstehen die tiefere Bedeutung eines Logik-Programms oder warum eine bestimmte Sprache ein bestimmtes Konstrukt besitzt.
Vorwort 9 Ein wenig über viele Sprachen zu wissen ist eine nützliche Fähigkeit. Ich musste häufig ein wenig von Python oder Ruby verstehen, um ein bestimmtes Problem lösen zu können. Die Programme, die ich aus dem Internet herunterlade, sind in den unterschiedlichsten Sprachen geschrieben und verlangen eine gewisse Anpassung, bevor ich sie nutzen kann. Jede Sprache hat ihren eigenen Satz an Idiomen, Stärken und Schwächen. Indem Sie verschiedene Programmiersprachen lernen, sind Sie in der Lage zu erkennen, welche Sprache für die Art von Problemen am besten geeignet ist, die Sie lösen möchten. Ich freue mich zu sehen, dass Bruces Geschmack bei Programmiersprachen so vielseitig ist. Er behandelt nicht nur so etablierte Sprachen wie Ruby, sondern auch weniger bekannte Sprachen wie Io. Letztlich geht es beim Programmieren um das Verstehen, und beim Verstehen geht es um Ideen. Die Auseinandersetzung mit neuen Ideen ist daher wesentlich für ein tieferes Verständnis dessen, worum es beim Programmieren eigentlich geht. Ein Zen-Meister könnte Ihnen empfehlen, Latein zu lernen, um Mathematik besser zu verstehen. Das gilt auch für das Programmieren. Um das Wesentliche an der OO-Programmierung zu verstehen, sollten Sie sich die logische und funktionale Programmierung (FP) ansehen. Und um die FP besser zu meistern, sollten Sie sich Assembler anschauen. Programmiersprachen vergleichende Bücher waren populär, als ich Programmierer wurde. Doch meist handelte es sich um akademische Schinken, die nur wenige praktische Hinweise zur Nutzung einer Sprache lieferten. Das reflektierte die Technik jener Zeit. Sie konnten etwas über die Ideen einer Sprache nachlesen, doch sie auszuprobieren war nahezu unmöglich. Heute können wir nicht nur etwas über diese Ideen nachlesen, sondern sie auch gleich in der Praxis ausprobieren. Das macht den Unterschied aus: am Beckenrand stehen und sich zu fragen, ob schwimmen Spaß macht, oder einzutauchen und das Wasser genießen. Ich lege Ihnen dieses Buch wärmstens ans Herz und hoffe, dass Sie es so geniessen wie ich. Joe Armstrong, Entwickler von Erlang 2. März 2010 Stockholm
Kapitel 1
Einführung Menschen lernen Sprachen aus den unterschiedlichsten Gründen. Ihre Muttersprache haben Sie gelernt, um überhaupt leben zu können. Sie stellt das Werkzeug dar, das ihnen dabei hilft, den Alltag zu meistern. Wenn Sie eine zweite Sprache erlernt haben, kann das verschiedene Gründe gehabt haben. Manchmal muss man eine zweite Sprache der Karriere wegen lernen, oder um sich an eine veränderte Umgebung anzupassen. Doch manchmal lässt man sich auf eine neue Sprache ein, nicht weil man sie lernen muss, sondern weil man sie lernen will. Eine zweite Sprache kann Ihnen dabei helfen, neue Welten zu entdecken. Sie könnten sogar Erleuchtung suchen, da Sie wissen, dass jede neue Sprache auch das Denken formt. Das Gleiche gilt für Programmiersprachen. In diesem Buch werde ich Ihnen sieben verschiedene vorstellen. Dabei möchte Sie auf eine Reise mitnehmen, die Sie aufklären und Ihren Blick auf das Programmieren verändern soll. Ich werde Sie nicht zum Experten machen, aber ich werden Ihnen mehr beibringen als bloß Hallo Welt!
1.1
Wahnsinn mit Methode Wenn ich eine neue Programmiersprache oder ein neues Framwork erlerne, suche ich meist nach einem kurzen, interaktiven Tutorial. Mein Ziel ist es, die Sprache in einer komtrollierten Umgebung zu erleben. Bei Bedarf kann ich etwas eingehender untersuchen, doch im Wesentlichen geht es mir um eine schnelle Dosis Koffein, einen Schnappschuss des syntaktischen Zuckers und um die Kernkonzepte.
12 Kapitel 1: Einführung Aber üblicherweise ist diese Erfahrung nicht besonders erhellend. Wenn Sie den wahren Charakter einer Sprache kennenlernen wollen, die mehr ist als eine bloße Erweiterung einer Sprache, die Sie schon kennen, dann wird ein kurzes Tutorial niemals funktionieren. Sie müssen schnell und tief eintauchen. Dieses Buch bietet Ihnen diese Erfahrung nicht nur ein-, sondern gleich siebenmal. Sie finden Antworten auf die folgenden Fragen: 앫
Welches Modell der Typisierung wird verwendet? Die Typisierung kann stark (Java) oder schwach (C), statisch (Java) oder dynamisch (Ruby) sein. Die Sprachen in diesem Buch reichen vom stark typisierten Ende des Spektrums bis hin zu einem breiten Mix aus statisch und dynamisch. Sie werden sehen, wie sich die jeweiligen Kompromisse bzw. Nachteile für den Entwickler auswirken. Das Typisierungsmodell prägt die Art und Weise, in der Sie ein Problem angehen, und kontrolliert, wie die Progammiersprache funktionert. Jede Sprache in diesem Buch hat ihre eigenen Typisierungs-Eigenarten.
앫
Welches Programmiermodell wird verwendet? Ist es objektorientiert, funktional, prozedural oder irgendeine Art Hybrid? Dieses Buch behandelt Sprachen mit vier verschiedenen Programmiermodellen, manchmal auch Kombinationen mehrerer Modelle. Sie werden eine logikbasierte Programmiersprache (Prolog) vorfinden, zwei Sprachen, die vollständig objektorientierte Konzepte unterstützen (Ruby, Scala), vier Sprachen funktionaler Natur (Scala, Erlang, Clojure, Haskell) sowie eine Prototyp-Sprache (Io). Mehrere Sprachen unterstützen auch mehrere Paradigmen, z. B. Scala. Clojures Multimethoden erlauben es Ihnen sogar, ein eigenes Paradigma zu implementieren. Das Erlernen neuer Programmierparadigmen ist eines der wichtigsten Konzepte dieses Buches.
앫
Wie interagiert man mit ihr? Sprachen werden kompiliert oder interpretiert und einige verwenden virtuelle Maschinen, andere hingegen nicht. Ich beginne meine Erkundung mit einer interaktiven Shell, wenn es denn eine gibt. Ich gehe dann zu Dateien über, wenn es an der Zeit ist, größere Projekte anzugehen. Unsere Projekte werden aber nicht groß genug sein, um vollständig in die Paketmodelle einzutauchen.
Die Sprachen 13 앫
Was sind die Entscheidungskonstrukte und die Kern-Datenstrukturen? Sie werden überrascht sein, wie viele Sprachen Entscheidungen mit etwas anderem als Varianten von ifs und whiles treffen können. Sie werden die Mustererkennung (Pattern Matching) in Erlang und die Vereinigung (unification) in Prolog kennenlernen. Collections („Sammlungen“) spielen in nahezu jeder Sprache eine wichtige Rolle. Bei Sprachen wie Smalltalk und Lisp definieren Collections die Charakeristika der Sprache. Bei anderen, wie C++ und Java, sind Collections überall verstreut und definieren das Erleben des Benutzers durch ihr Fehlen und ein geringeres Maß an Stringenz. So oder so sollte man sich mit Collections gut auskennen.
앫
Welche Kerneigenschaften machen die Sprache einzigartig? Einige Sprachen besitzen fortgeschrittene Fähigkeiten für die nebenläufige Programmierung (concurrent programming). Andere bieten einzigartige High-Level-Konstrukte wie etwa Clojures Makros oder Ios Message-Interpretation. Andere bieten eine leistungsfähige virtuelle Maschine, wie etwa Erlangs BEAM: Dank ihr kann man mit Erlang fehlertolerante, verteilte Systeme wesentlich schneller aufbauen, als es mit anderen Sprachen möglich ist. Einige Programmiersprachen unterstützen Programmiermodelle, die sich auf ein bestimmtes Problem konzentrieren. Etwa die Verwendung von Logik zur Lösung von Beschränkungen (constraints).
Wenn Sie mit diesem Buch fertig sind, werden Sie kein Experte für eine dieser Sprachen sein, aber Sie werden wissen, welche einzigartigen Eigenschaften sie besitzen. Sehen wir uns die Sprachen an.
1.2
Die Sprachen Die Auswahl der Sprachen für dieses Buch war wesentlich einfacher, als Sie vielleicht glauben: Ich habe einfach potenzielle Leser gefragt. Nachdem wir uns alle Daten näher angesehen hatten, blieben acht Kandidaten übrig. Ich habe JavaScript gestrichen, weil es mir zu beliebt ist, und durch die zweitpopulärste Prototypsprache Io ersetzt. Ich habe auch Python gestrichen, weil ich nur eine objektorientierte Sprache wollte und Ruby höher auf der Liste stand. Das schuf Raum für einen überraschenden Kandidaten, Prolog, das auf der Liste unter den TopTen war. Hier die Sprachen, die es geschafft haben, sowie die Gründe für ihre Wahl:
14 Kapitel 1: Einführung 앫
Ruby. Diese objektorientierte Sprache erhält gute Noten wegen ihrer einfachen Verwendbarkeit und guten Lesbarkeit. Ich habe kurz daran gedacht, überhaupt keine objektorientierte Sprache aufzunehmen, aber ich wollte die verschiedenen Programmierparadigmen mit der objektorientierten Programmierung (OOP) vergleichen, weshalb es wichtig war, zumindest eine OOP-Sprache aufzunehmen. Ich wollte Ruby außerdem etwas mehr fordern, als das die meisten Programmierer tun, und den Lesern eine Vorstellung von den Grundentscheidungen vermitteln, die das Design von Ruby geprägt haben. Ich habe mich entschieden, in die RubyMetaprogrammierung einzutauchen, was es mir erlaubt, die Syntax der Sprache zu erweitern. Ich bin recht zufrieden mit dem Ergebnis.
앫
Io. Neben Prolog ist Io die umstrittenste Sprache, die ich aufgenommen habe. Sie ist kommerziell nicht erfolgreich, doch die Konstrukte zur Nebenläufigkeit mit ihrer Einfachheit und ihrer gleichförmigen Syntax sind wichtige Konzepte. Die minimale Syntax ist leistungsfähig und die Ähnlichkeit zu Lisp manchmal verblüffend. Io hat einen kleinen „Footprint“, ist eine Prototypsprache wie JavaScript und besitzt einen einzigartigen Mechanismus zum Message-Dispatch, den Sie (glaube ich) interessant finden werden.
앫
Prolog. Ja, ich weiß, sie ist alt, aber eben auch extrem leistungsfähig. Das Lösen eines Sudoku-Rätsels in Prolog war eine Erfahrung, die mir die Augen geöffnet hat. Ich habe hart daran gearbeitet, schwierige Probleme in Java oder C zu lösen, die in Prolog ohne besondere Mühe hätten gelöst werden können. Joe Armstrong, der Schöpfer von Erlang, half mir dabei, ein tieferes Verständnis für diese Sprache zu entwickeln, die Erlang stark beeinflusst hat. Wenn Sie noch nicht die Gelegenheit hatten, sie zu verwenden, werden Sie angenehm überrascht sein.
앫
Scala. Als Mitglied der neuen Generation von Sprachen für die Java Virtual Machine hat Scala starke funktionale Konzepte in das Java-Ökosystem eingeführt. Sie schließt auch OOP mit ein. Rückblickend sehe ich verblüffende Ähnlichkeiten mit C++, das entscheidend dazu beigetragen hat, eine Brücke zwischen prozeduraler Programmierung und OOP zu schaffen. Wenn Sie in die Scala-Community eintauchen, werden Sie sehen, warum Scala die reine Häresie für rein funktionale Programmier ist und ein wahrer Segen für Java-Entwickler.
Kaufen Sie dieses Buch 15 앫
Erlang. Als eine der ältesten Sprachen auf der Liste nimmt Erlang langsam als funktionale Sprache Fahrt auf, die Nebenläufigkeit, Verteilung und Fehlertoleranz gut im Griff hat. Die Schöpfer von CouchDB, einer der aufkommenden Cloud-basierten Datenbanken, haben sich für Erlang entschieden und es nie bereut. Sobald Sie ein wenig Zeit mit dieser verteilten Sprache verbracht haben, werden Sie sehen, warum Erlang den Entwurf nebenläufiger, verteilter und fehlertoleranter Systeme wesentlich einfacher macht, als Sie es jemals für möglich gehalten haben.
앫
Clojure. Als weitere JVM-Sprache nimmt dieser Lisp-Dialekt radikale Änderungen an der Art und Weise vor, wie wir uns Nebenläufigkeit auf der JVM vorstellen. Es ist die einzige Sprache in diesem Buch, die die gleiche Strategie in versionierten Datenbanken nutzt, um die Nebenläufigkeit zu verwalten. Als Lisp-Dialekt hat Clojure einiges zu bieten und unterstützt das vielleicht flexibelste Programmiermodell dieses Buches. Doch im Gegensatz zu anderen Lisp-Dialekten wurden die Klammern stark reduziert. Darüber hinaus können Sie auf ein riesiges Ökosystem bauen, einschließlich einer großen JavaBibliothek und weit verbreiteter Deployment-Plattformen.
앫
Haskell. Diese Sprache ist die einzige rein funkionale Sprache in diesem Buch. Das bedeutet, dass Sie an keiner Stelle einen veränderlichen Zustand vorfinden. Die gleiche Funktion mit den gleichen Eingabeparametern liefert immer die gleiche Ausgabe zurück. Von allen stark typisierten Sprachen verwendet Haskell das anerkannteste Typisierungsmodell. Wie bei Prolog dauert es eine Weile, bis man es versteht, doch die Ergebnisse sind es wert.
Es tut mir leid, wenn Ihre Lieblingssprache nicht auf der Liste steht. Sie können mir glauben, dass ich bereits Hass-E-Mails von nicht wenigen Sprachenthusiasten erhalten habe. Wir haben mehrere Dutzend Sprachen in die oben erwähnte Umfrage aufgenommen. Die von mir gewählten Sprachen sind nicht notwendigerweise die besten, doch jede davon ist einzigartig und Sie können etwas Wichtiges von ihr lernen.
1.3
Kaufen Sie dieses Buch ... wenn Sie ein kompetenter Programmierer sind, der wachsen will. Das klingt vielleicht ein wenig nebulös, aber zeigen Sie etwas Nachsicht mit mir.
16 Kapitel 1: Einführung
Lernen Sie zu lernen Dave Thomas ist einer der Gründer dieses Verlages. Er stellte sich der Herausforderung, jedes Jahr Tausenden von Studenten eine neue Sprache beizubringen. Wenn Sie Sprachen lernen, werden Sie zumindest neue Konzepte in den Code einfließen lassen, den Sie in der von Ihnen bevorzugten Sprache schreiben. Das Schreiben dieses Buchs hatte grundlegenden Einfluss auf den Ruby-Code, den ich schreibe. Er ist jetzt funktionaler und einfacher zu lesen (mit weniger Wiederholungen). Ich arbeite weniger mit veränderlichen Variablen und liefere mit Codeblöcken und Funktionen höherer Ordnung eine bessere Arbeit ab. Ich verwende auch einige Techniken, die in der Ruby-Community eher unkonventionell sind, die meinen Code aber kompakter und leserlicher machen. Im besten Fall können Sie eine neue Karriere starten. Etwa alle zehn Jahre ändern sich die Programmierparadigmen. Je einschränkender Java für mich wurde, desto mehr experimentierte ich mit Ruby, um seinen Ansatz der Webentwicklung besser zu verstehen. Nach ein paar erfolgreichen Nebenprojekten habe ich meine Karriere stark in diese Richtung orientiert und es nie bereut. Meine Ruby-Karriere begann mit grundlegenden Experimenten, aus denen sich mehr entwickelte.
Hilfe in schwierigen Zeiten Viele Leser dieses Buches werden nicht alt genug sein, um sich an das letzte Mal zu erinnern, als sich in unserer Branche die Programmierparadigmen änderten. Der Wechsel zur objektorientierten Programmierung ging mit einer Reihe von Fehlstarts einher, doch das alte strukturelle Programmierparadigma war einfach nicht in der Lage, die Komplexität zu handhaben, die bei modernen Webanwendungen verlangt wird. Die erfolgreiche Programmiersprache Java gab uns einen harten Stoß in diese Richtung, und das neue Paradigma blieb hängen. Viele Entwickler wurden mit veralteten Fähigkeiten auf dem falschen Fuß erwischt und mussten nicht nur ihre Denkweise komplett ändern, sondern auch die Werkzeuge, die sie verwendeten, und die gesamte Art und Weise, in der Sie Anwendungen entwarfen. Vielleicht befinden wir uns gerade mitten in einem weiteren Wandel. Diesmal werden neue Computerstrukturen die treibende Kraft sein. Fünf der sieben Sprachen in diesem Buch besitzen verlockende Modelle für die Nebenläufigkeit. (Ruby und Prolog sind die Ausnahmen.) Egal,
Kaufen Sie dieses Buch nicht 17 ob Sie auf eine andere Programmiersprache umsteigen oder nicht, möchte ich mich doch etwas aus dem Fenster lehnen und sagen, dass die Sprachen in diesem Buch einige verlockende Antworten zu bieten haben. Sehen Sie sich die Io-Implementierung von Futures an, Scalas Aktoren oder Erlangs „Lass es abstürzen“-Philosophie. Versuchen Sie zu verstehen, wie Haskell-Programmierer veränderliche Zustände hinter sich lassen oder wie Clojure Versionierung nutzt, um einige der schwierigsten Probleme der Nebenläufigkeit zu lösen. Sie können auch an überraschenden Stellen Einsichten finden. Erlang, die Sprache, die bei diversen Cloud-basierten Datenbanken hinter den Kulissen arbeitet, ist dafür ein großartiges Beispiel. Dr. Joe Armstrong begann die Sprache basierend auf einem Prolog-Unterbau.
1.4
Kaufen Sie dieses Buch nicht ... bis Sie diesen Abschnitt gelesen haben und mir zustimmen. Ich möchte einen Deal mit Ihnen machen. Sie sind damit einverstanden, dass ich mich auf die Programmiersprache konzentriere statt auf Installationsdetails. Mein Teil des Deals besteht darin, dass ich Ihnen in kürzerer Zeit mehr beibringe. Sie müssen etwas mehr googeln und können von mir keine Unterstützung bei der Installation erwarten, aber wenn Sie mit dem Buch durch sind, werden Sie wesentlich mehr wissen, weil ich tiefer eintauchen kann. Bitte machen Sie sich klar, dass sieben Sprachen für Sie und mich ein ambitioniertes Unterfangen sind. Als Leser müssen Sie Ihr Gehirn um sieben unterschiedliche Syntaxstile packen, vier Programmierparadigmen, vier Jahrzehnte der Sprachentwicklung und Vieles mehr. Als Autor muss ich eine enorme Menge an Themen abdecken. Um die wichtigsten Details jeder Sprache erfolgreich abdecken zu können, muss ich einige vereinfachende Voraussetzungen schaffen.
Ich gehe über die Syntax hinaus Um wirklich in den Kopf eines Sprachentwicklers einzutauchen, müssen Sie bereit sein, über die grundlegende Syntax hinauszugehen. Das bedeutet, dass Sie etwas mehr als das typische „Hallo, Welt“ oder eine Fibonacci-Folge programmieren müssen. Bei Ruby müssen Sie ein wenig Metaprogrammierung, vornehmen. In Prolog werden Sie ein Sudoku komplett lösen. Und inErlang werden Sie einen Monitor schreiben, der den Tod eines Prozesses erkennen und einen anderen starten oder den Benutzer informieren kann.
18 Kapitel 1: Einführung Um über die Grundlagen hinausgehen zu können, habe ich mich außerdem für eine Verpflichtung und einen Kompromiss entschieden. Die Verpflichtung: Ich werde es nicht bei einer oberflächlichen Betrachtung belassen. Der Kompromiss: Ich werde einige Grundlagen nicht behandeln können, die Sie in einem Referenzwerk zu einer Sprache erwarten würden. Ich werde nur selten die Ausnahmebehandlung durchgehen, es sei denn, sie ist ein grundlegendes Merkmal der Sprache. Ich werde nicht detailliert auf Paketmodelle eingehen, weil wir nur kleine Projekte angehen, bei denen wir sie nicht benötigen. Ich gehe nicht auf Primitive ein, die wir nicht brauchen, um die grundlegenden Probleme zu lösen, die ich Ihnen vorstelle.
Ich helfe nicht bei der Installation Eine meiner größten Herausforderungen ist die Plattform. Ich hatte direkten Kontakt mit Lesern verschiedener Bücher, die drei verschiedene Windows-Plattformen, OS X und mindestens fünf verschiedene Unix-Versionen nutzten. Ich habe in verschiedenen Foren Kommentare zu vielen anderen gesehen. Sieben Sprachen auf sieben Plattformen sind für einen einzelnen Autor eine kaum zu bewältigende Aufgabe, möglicherweise sogar auch für mehrere Autoren. Ich kann Sie bei der Installation von sieben Sprachen nicht unterstützen, also versuche ich es gar nicht erst. Ich nehme an, dass Sie nicht sonderlich daran interessiert sind, eine weitere veraltete Installationsanleitung zu lesen. Sprachen und Plattformen ändern sich. Ich sage Ihnen, wo Sie hingehen müssen, um eine Sprache zu installieren, und welche Version ich verwendet habe. Auf diese Weise werden Sie mit aktuellen Anweisungen aus denselben Listen arbeiten wie alle anderen auch. Ich kann Sie beim Installieren nicht unterstützen.
Ich bin keine Programmierreferenz Wir haben hart gearbeitet, um gute Korrektoren für dieses Buch zu finden. In einigen Fällen hatten wir das Glück, eine Korrektur von der Person zu erhalten, die die Sprache entworfen hat. Ich bin mir sicher, dass das Material den Geist jeder Programmiersprache recht gut widerspielt, nachdem es den gesamten Korrekturprozess durchlaufen hat. Gleichwohl müssen Sie verstehen, dass ich Ihre Bemühungen nicht in jeder Sprache umfassend unterstützen kann. Ich möchte dazu einen Vergleich mit gesprochenen Sprachen anstellen.
Kaufen Sie dieses Buch nicht 19 Eine Sprache als durchreisender Tourist zu sprechen, ist etwas ganz anderes, als Muttersprachler zu sein. Ich spreche Englisch fließend und Spanisch holprig. Ich kann ein paar Sätze in drei anderen Sprachen. Ich habe schon einmal Fisch in Japan bestellt und in Italien nach dem Weg zur Toilette gefragt. Doch ich kenne meine Grenzen. Was das Programmieren angeht, spreche ich Basic, C, C++, Java, C#, JavaScript, Ruby und einige andere Sprachen fließend. Einige Dutzend andere spreche ich eher holprig, einschließlich der Sprachen in diesem Buch. Für sechs der Sprachen auf unserer Liste bin ich kein qualifizierter Ratgeber. Ich schreibe Ruby in Vollzeit, und das seit nunmehr fünf Jahren. Doch ich könnte Ihnen nicht sagen, wie man einen Webserver in Io oder eine Datenbank in Erlang schreibt. Ich würde kläglich scheitern, wollte ich eine umfassende Referenz für eine dieser Sprachen schreiben wollen. Ich könnte für jede Programmiersprache in diesem Buch ein Programmierhandbuch schreiben, das mindestens so umfangreich ist wie dieses Buch. Ich gebe Ihnen ausreichend Informationen, um anfangen zu können. Ich werde mit Ihnen Beispiele in allen Sprachen durchgehen, und Sie werden Beispiele dieser Programme sehen. Ich werde mein Bestes tun, um alles zu kompilieren und sicherzustellen, dass es läuft. Doch ich könnte Sie bei Ihren Programmierbemühungen nicht unterstützen, selbst wenn ich wollte. Die Sprachen in unserer Liste haben alle ausgezeichnete Support-Communities. Das ist einer der Gründe dafür, dass ich sie ausgewählt habe. Bei allen Übungen gibt es einen Abschnitt, in dem Sie Ressourcen finden sollen. Das ist natürlich Absicht: Ich will, dass Sie selbständig werden.
Ich werde Sie hart rannehmen Dieses Buch bringt Sie einen Schritt über ihr 20-Minuten-Tutorial hinaus. Sie kennen Google so gut wie ich und sind in der Lage, eine einfache Einführung für jede der Sprachen auf unserer Liste zu finden. Ich gebe Ihnen eine kurze, interaktive Führung. Sie erhalten außerdem einige kleine Programmieraufgaben und ein Programmierprojekt pro Woche. Es wird nicht leicht, aber informativ, und es wird Spaß machen. Wenn Sie dieses Buch einfach nur lesen, werden Sie nur eine Ahnung von der Syntax erhalten, mehr nicht. Wenn Sie die Antworten online suchen, bevor Sie selbst versuchen, sie zu programmieren, werden Sie scheitern. Versuchen Sie sich zuerst an den Übungen. Dabei werden Sie merken, dass Sie an ein paar davon scheitern. Die Syntax zu lernen, ist immer einfacher, als Nachdenken zu lernen.
20 Kapitel 1: Einführung Wenn Sie diese Erläuterungen von mir nervös machen, empfehle ich Ihnen, dieses Buch wegzulegen und sich ein anderes auszusuchen. Sie werden nicht glücklich mit mir. Wahrscheinlich sind Sie mit sieben verschiedenen Programmierhandbüchern besser bedient. Doch wenn Sie die Vorstellung begeistert, besser und schneller programmieren zu können, dann sollten wir es jetzt angehen.
1.5
Ein letzter Punkt An dieser Stelle wollte ich eigentlich noch einige mitreißende und motivierende Worte sagen, aber es scheint sich alles auf zwei Wörter reduzieren zu lassen: Viel Spaß!
A spoonful of sugar makes the medicine go down Mary Poppins
Kapitel 2
Ruby Wenn Sie einfach stichprobenartig in dieses Buch hineinlesen, haben wir möglicherweise etwas gemeinsam: Das Lernen neuer Programmiersprachen fasziniert uns. Für mich ist das Lernen einer Sprache so, als würde ich eine Rolle erlernen. Während meiner gesamten Karriere habe ich viele Sprachen persönlich kennengelernt. Wie jeder Mensch hat auch jede Sprache eine eigene Persönlichkeit. Mit Java war es so, als hätte man einen reichen Anwalt zum Bruder: Er war lustig, als er noch jünger war, doch heute ist er ein schwarzes Loch, das den gesamten Spaß im Umkreis von 100 Kilometern aufsaugt. Visual Basic erinnert an eine blondierte Kosmetikerin. Sie wird die globale Erwärmung nicht aufhalten, aber sie ist für einen Haarschnitt gut und es macht viel Spaß, sich mit ihr zu unterhalten. Im Verlauf des gesamten Buches werde ich die vorgestellten Sprachen mit bekannten Typen vergleichen. Ich hoffe, das diese Vergleiche etwas von der Persönlichkeit enthüllen, die jede Sprache zu etwas Besonderem macht. Lernen Sie Ruby kennen, eine meiner Favoritinnen. Sie ist manchmal sonderbar, immer schön, ein wenig geheimnisvoll und absolut bezaubernd. Stellen Sie sich Mary Poppins1 vor, das britische Kindermädchen. Zu jener Zeit waren die meisten Kindermädchen wie die meisten Sprachen aus der C-Familie — drakonische Biester, gnadenlos effizient, aber in etwa so spaßig wie ein Löffel Lebertran vor dem Zubettgehen. Mit einem Löffel voll Zucker änderte sich alles. Mary Poppins machte die Hausarbeit effizienter, indem sie den Spaß hineinbrachte und auch noch die komplette Hingabe ihrer Schützlinge herauskitzelt. Ruby macht es genauso, und das mit mehr als nur einem Löffel syntak1 Mary Poppins. DVD. Directed by Robert Stevenson. 1964; Los Angeles, CA: Walt Disney Video, 2004.
22 Kapitel 2: Ruby tischem Zucker2. Matz, der Erfinder von Ruby, kümmert sich nicht um die Effizienz der Sprache. Er optimiert die Effizienz des Programmierers.
2.1
Ein wenig Geschichte Yukihiro Matsumoto schuf Ruby ungefähr im Jahr 1993. Die meisten Leute nennen ihn einfach Matz. Als Sprache betrachtet, ist Ruby eine interpretierte, objektorientierte, dynamisch typisierte Sprache aus der Familie der sogenannten Skriptsprachen. „Interpretiert“ heißt, dass Ruby-Code von einem Interpreter ausgeführt wird, und nicht durch einen Compiler läuft. „Dynamisch typisiert“ bedeutet, dass die Typen zur Ausführungszeit gebunden werden, und nicht während der Kompilierung. Generell geht es hierbei um einen Kompromiss zwischen Flexibilität und Ausführungssicherheit, aber darauf gehen wir später noch genauer ein. „Objektorientiert“ heißt, dass die Sprache Kapselung (Daten und Verhalten werden zusammengepackt), Vererbung durch Klassen (Objekttypen sind in Klassenbäumen organisiert) sowie Polymorphismus (Objekte können viele Formen annehmen) unterstützt. Ruby hat geduldig auf den richtigen Moment gewartet und trat um 2006 mit dem Aufkommen des Rails-Frameworks ins Rampenlicht. Nach zehn langen Jahren im Enterprise-Dschungel machte das Programmieren endlich wieder Spaß. Ruby ist nicht übermäßig effizient, wenn es um die Ausführungsgeschwindigkeit geht, aber es macht die Programmierer sehr produktiv.
Interview mit Yukihiro (Matz) Matsumoto Ich hatte das Vergnügen, in Matsumoto-Sans Heimatstadt Matsue in Japan reisen zu dürfen. Wir hatten die Gelegenheit, über die Grundlagen von Ruby zu reden, und er war bereit, mir einige Fragen für dieses Buch zu beantworten. Bruce: Warum haben Sie Ruby geschrieben? Matz: Kaum hatte ich begonnen, mit Computern herumzuspielen, begann ich mich für Programmiersprachen zu interessieren. Sie sind das Instrument der Programmierung, erweitern aber auch den Verstand in der Weise, wie man über das Programmieren denkt. Daher
2 Syntaktischer Zucker beschreibt ein Sprachfeature, mit dessen Hilfe Code einfacher zu lesen und zu schreiben ist, auch wenn es andere Möglichkeiten gibt, Code zu schreiben, der das Gleiche bewirkt.
Ein wenig Geschichte 23 habe ich lange Zeit als Hobby eine Vielzahl von Programmiersprachen studiert. Ich habe sogar verschiedene „Spielsprachen“ implementiert, aber keine echten. Im Jahr 1993, als ich Perl sah, inspirierte mich etwas zu der Annahme, dass eine objektorientierte Sprache, die die Charakteristika von Lisp, Smalltalk und Perl vereint, eine großartige Sprache wäre, um die Produktivität zu erhöhen. Also begann ich mit der Entwicklung einer solchen Sprache und nannte sie Ruby. Die Hauptmotivation war dabei, mich zu amüsieren. Am Anfang war es nur ein Hobby, eine Sprache zu entwickeln, die meinem Geschmack entsprach. Irgendwie entwickelten andere Programmierer auf der ganzen Welt Sympathie für diese Sprache und der dahinter stehenden Strategie. Und sie wurde sehr beliebt, weit über meine Erwartungen hinaus. Bruce: Was mögen Sie an ihr am meisten? Matz: Ich mag die Art und Weise, wie sie mein Programmieren angenehm macht. Technisch gesehen, mag ich besonders die Blöcke. Sie sind gezähmte Funktionen höherer Ordnung, eröffnen aber großartige Möglichkeiten bei DSLs und anderen Features. Bruce: Welches Feature würden Sie gerne ändern, wenn Sie die Zeit zurückdrehen könnten? Matz: Ich würde Threads entfernen und Aktoren oder andere fortschrittliche Funktionalitäten zur Nebenläufigkeit aufnehmen. Während Sie dieses Kapitel lesen (ganz egal, ob Sie Ruby schon kennen oder nicht), sollten Sie die Kompromisse im Auge behalten, die Matz eingehen musste. Achten Sie auf syntaktischen Zucker, diese kleinen Features, die die grundlegenden Regeln der Sprache brechen, um dem Programmierer das Leben etwas angenehmer und den Code etwas verständlicher zu machen. Achten Sie auf Stellen, an denen Matz Codeblöcke benutzt, um erstaunliche Effekte zu erzielen (etwa bei Collections, aber auch anderswo). Und versuchen Sie zu verstehen, welche Kompromisse er im Bezug auf Einfachheit und Sicherheit sowie Produktivität und Performanz eingegangen ist. Legen wir los. Werfen wir einen Blick auf etwas Ruby-Code: >> properties = ['object oriented', 'duck typed', 'productive', 'fun'] => ["object oriented", "duck typed", "productive", "fun"] >> properties.each {|property| puts "Ruby is #{property}."} Ruby is object oriented. Ruby is duck typed.
24 Kapitel 2: Ruby Ruby is productive. Ruby is fun. => ["object oriented", "duck typed", "productive", "fun"]
Ruby ist die Sprache, die mich wieder lächeln ließ. Dynamisch bis in den Kern, besitzt Ruby eine fabelhafte Support-Community. Die Implementierungen sind alle Open Source. Kommerzieller Support kommt meist von kleineren Unternehmen, was Ruby vor einigen der übersteigerten Frameworks geschützt hat, die andere Reiche plagen. Ruby ist nur langsam in den Unternehmensbereich vorgedrungen, kann nun aber auf die Stärke seiner Produktivität zurückgreifen, insbesondere im Bereich der Webentwicklung.
2.2
Tag 1: Ein Kindermädchen finden Lässt man alle Zauberei außer Acht, ist Mary Poppins in erster Linie ein großartiges Kindermädchen. Wenn Sie eine Sprache lernen, besteht ihre Aufgabe darin, zu lernen, wie man sie benutzt, um die Aufgaben zu erledigen, die Sie bereits erledigen können. Betrachten Sie diese erste Konversation mit Ruby als Dialog. Fließt die Unterhaltung locker dahin, oder ist sie eher schwerfällig? Wie sieht das Programmiermodell im Kern aus? Wie werden Typen behandelt? Lassen Sie uns einige Antworten suchen.
Schnelleinstieg Wie versprochen, werde ich mit Ihnen keinen veralteten Installationsprozess durchgehen, doch die Ruby-Installation ist ein Klacks. Besuchen Sie einfach http://www.ruby-lang.org/en/downloads/, wählen Sie Ihre Plattform und installieren Sie Ruby 1.8.6 (oder höher). Ich verwende in diesem Kapitel die Ruby-Version 1.8.7. Die Version 1.9 weist einige kleine Unterschiede auf. Wenn Sie unter Windows arbeiten, können Sie den Ein-Klick-Installer nutzen; bei OS X Leopard (und höher) wird Ruby auf den Xcode-Disks mitgeliefert. Um Ihre Installation zu testen, geben Sie einfach irb ein. Wenn Sie keine Fehler sehen, können Sie den Rest dieses Kapitels angehen. Wenn doch, nur zu: Nur die wenigsten Installationsprobleme sind einzigartig. Google weist Ihnen den Weg.
Tag 1: Ein Kindermädchen finden 25
Ruby über die Konsole nutzen Wenn Sie es noch nicht getan haben, geben Sie jetzt irb ein. Sie sollten sich nun in Rubys interaktiver Konsole befinden. Sie geben einen Befehl ein und erhalten eine Antwort. Probieren Sie Folgendes: >> puts 'hello, world' hello, world => nil >> language = 'Ruby' => "Ruby" >> puts "hello, #{language}" hello, Ruby => nil >> language = 'my Ruby' => "my Ruby" >> puts "hello, #{language}" hello, my Ruby => nil
Wenn Sie Ruby noch nicht kennen, gibt dieses kurze Beispiel viel über die Sprache preis. Sie wissen, dass Ruby interpretiert werden kann. Tatsächlich ist Ruby fast immer interpretiert, auch wenn einige Entwickler an virtuellen Maschinen arbeiten, die Ruby in Bytecode kompilieren, während es ausgeführt wird. Ich habe keine Variablen deklariert. Alles, was ich gemacht habe, hat einen Wert zurückgeliefert, auch wenn ich Ruby nicht darum gebeten habe. Tatsächlich gibt jedes Stück Code in Ruby etwas zurück. Sie haben zwei Arten von Strings gesehen. Ein einfaches Anführungszeichen um einen String bedeutet, das der String literal interpretiert werden soll. Doppelte Anführungszeichen führen zur String-Evaluierung. Zu den Dingen, die der Ruby-Interpreter evaluiert, gehört die String-Substitution. In unserem Beispiel hat Ruby den Wert, der vom Code language zurückgegeben wird, in den String eingefügt. Weiter geht’s.
Das Programmiermodell Eine der ersten Fragen, die man bei einer Sprache beantworten sollte, lautet: „Welches Programmiermodell hat sie?“ Darauf gibt es nicht immer eine einfache Antwort. Sie kennen wahrscheinlich prozedurale Sprachen wie C, Fortran oder Pascal. Die meisten von uns verwenden derzeit objektorientierte Sprachen, doch viele dieser Sprachen besitzen auch prozedurale Elemente. Beispielsweise ist 4 in Java kein Objekt. Sie haben dieses Buch vielleicht ausgewählt, um funktionale Sprachen kennenzulernen. Einige dieser Sprachen (wie Scala) vermischen Pro-
26 Kapitel 2: Ruby grammiermodelle, indem sie objektorientierte Konzepte einbinden. Es gibt Dutzende von anderen Programmiermodellen. Stack-basierte Sprachen wie PostScript oder Forth verwenden einen oder mehrere Stacks als zentrales Element der Sprache. Logikbasierte Sprachen wie Prolog bauen auf Regeln auf. Prototypsprachen wie Io, Lua und Self verwenden das Objekt statt der Klasse als Basis für die Objektdefinition und sogar die Vererbung. Ruby ist eine rein objektorientierte Sprache. In diesem Kapitel werden Sie sehen, wie weit Ruby dieses Konzept treibt. Sehen wir uns einige grundlegende Objekte an: >> 4 => 4 >> 4.class => Fixnum >> 4 + 4 => 8 >> 4.methods => ["inspect", "%", "<<", "singleton_method_added", "numerator", ... "*", "+", "to_i", "methods", ... ]
Ich habe in der Liste einige Methoden weggelassen, aber ich glaube, Sie haben verstanden. Bei Ruby ist nahezu alles ein Objekt, und zwar bis hinunter zu jeder einzelnen Zahl. Eine Zahl ist ein Objekt der Klasse Fixnum, und die Methode methods liefert ein Array von Methoden zurück (Ruby stellt Arrays in eckigen Klammern dar). Tatsächlich können Sie mit dem Punktoperator jede Methode auf ein Objekt anwenden.
Entscheidungen Programme werden geschrieben, um Entscheidungen zu treffen. Man kann also erwarten, dass die Art und Weise, in der eine Sprache Entscheidungen trifft, ein zentrales Konzept darstellt, das die Art und Weise formt, in der man in einer Sprache kodiert und denkt. Ruby ist in vielerlei Hinsicht wie die meisten objektorientierten und prozeduralen Sprachen auch. Sehen Sie sich die folgenden Ausdrücke an: >> => >> => >> => >> => >> =>
x = 4 4 x < 5 true x <= 4 true x > 4 false false.class FalseClass
Tag 1: Ein Kindermädchen finden 27 >> true.class => TrueClass
Ruby besitzt also Ausdrücke, die zu true (wahr) oder false (falsch) evaluieren. Passenderweise sind true und false Objekte erster Güte. Sie können mit ihnen konditionalen Code ausführen: >> x = 4 => 4 >> puts 'This appears to be false.' unless x == => nil >> puts 'This appears to be true.' if x == 4 This appears to be true. => nil >> if x == 4 >> puts 'This appears to be true.' >> end This appears to be true. => nil >> unless x == 4 >> puts 'This appears to be false.' >> else ?> puts 'This appears to be true.' >> end This appears to be true. => nil >> puts 'This appears to be true.' if not true => nil >> puts 'This appears to be true.' if !true => nil
4
Mir gefällt Rubys Entscheidung für einfache Conditionals wirklich gut. Sie können sowohl die Blockform (if bedingung, anweisungen, end) als auch die einzeilige Form (anweisung if bedingung) verwenden, wenn Sie if oder unless verwenden. Manche werden von der einzeiligen ifVariante abgeschreckt. Was mich betrifft, erlaubt sie es mir, einen (einzelnen) Gedanken in einer (einzigen) Zeile Code auszudrücken: order.calculate_tax unless order.nil?
Sicher, Sie können das obige Beispiel auch in einem Block ausdrücken, aber das würde diesen eigenständigen, schlüssigen Gedanken unnötig verwässern. Wenn Sie eine einfache Idee in einer einzigen Zeile zusammenfassen können, wird das Lesen des Codes leichter. Ich mag auch die Idee von unless („wenn nicht“). Sie können das Gleiche auch mit not oder ! beschreiben, aber unless drückt die Idee wesentlich besser aus. while und until sind gleich: >> => >> =>
x = x + 1 while x < nil x 10
10
28 Kapitel 2: Ruby >> => >> => >> >> >> >> 2 3 4 5 6 7 8 9 10 =>
x = x - 1 until x == nil x 1 while x < 10 x = x + 1 puts x end
1
nil
Beachten Sie, dass = der Zuweisung dient und == auf Gleichheit prüft. Bei Ruby hat jedes Objekt seine eigene Vorstellung von Gleichheit. Zahlen sind gleich, wenn ihre Werte gleich sind. Sie können auch andere Werte als true und false als Ausdrücke verwenden: >> puts 'This appears to be true.' if 1 This appears to be true. => nil >> puts 'This appears to be true.' if 'random (irb):31: warning: string literal in condition This appears to be true. => nil >> puts 'This appears to be true.' if 0 This appears to be true. => nil >> puts 'This appears to be true.' if true This appears to be true. => nil >> puts 'This appears to be true.' if false => nil >> puts 'This appears to be true.' if nil => nil
string'
Alles ausser nil und false evaluiert also zu true. C- und C++-Programmierer sollten beachten, dass 0 true ist. Logische Operatoren funktionieren (mit wenigen kleinen Ausnahmen) wie bei C, C++, C# und Java. and (alternativ &&) ist das logische UND, und or (alternativ ||) das logische ODER. Mit diesen Tests führt der Interpreter den Code nur aus, solange der Wert des Tests klar ist. Verwenden Sie & oder | bei Vergleichen für den gesamten Ausdruck. Hier sehen Sie diese Konzepte in Aktion:
Tag 1: Ein Kindermädchen finden 29 >> => >> => >> =>
true and false false true or false true false && false false
>> true && this_will_cause_an_error NameError: undefined local variable or method `this_will_cause_an_error' for main:Object from (irb):59 >> false && this_will_not_cause_an_error => false >> true or this_will_not_cause_an_error => true >> true || this_will_not_cause_an_error => true >> true | this_will_cause_an_error NameError: undefined local variable or method `this_will_cause_an_error' for main:Object from (irb):2 from :0 >> true | false => true
Das ist keine Zauberei. Üblicherweise verwenden Sie die Kurzversionen dieser Befehle.
Duck-Typing Sehen wir uns Rubys Typisierungsmodell an. Das Erste, was Sie wissen müssen, ist, wie viel Schutz Ruby Ihnen bietet, wenn Sie mit den Typen einen Fehler machen. Wir reden hier über Typsicherheit. Stark typisierte Sprachen überprüfen die Typen bei bestimmten Operationen und bevor Sie irgendwelchen Schaden anrichten können. Diese Überprüfung kann erfolgen, wenn der Code einem Interpreter oder Compiler präsentiert wird, oder wenn Sie den Code ausführen. Sehen Sie sich folgenden Code an: >> 4 + 'four' TypeError: String can't be coerced into Fixnum from (irb):51:in `+' from (irb):51 >> => >> =>
4.class Fixnum (4.0).class Float
>> 4 + => 8.0
4.0
30 Kapitel 2: Ruby Rubys starke Typisierung3 bedeutet also, das Sie eine Fehlermeldung erhalten, wenn Typen miteinander kollidieren. Ruby führt diese Typprüfungen zur Laufzeit durch, nicht während der Kompilierung. Ich zeige Ihnen etwas früher, als ich es normalerweise tun würde, wie man eine Funktion definiert, um diesen Punkt deutlich zu machen. Das Schlüsselwort def definiert eine Funktion, führt sie aber nicht aus. Geben Sie folgenden Code ein: >> def add_them_up >> 4 + 'four' >> end => nil >> add_them_up TypeError: String can't be coerced into Fixnum from (irb):56:in `+' from (irb):56:in `add_them_up' from (irb):58
Ruby führt also keine Typprüfung durch, bis der Code tatsächlich ausgeführt wird. Dieses Konzept wird dynamische Typisierung genannt. Es hat seine Nachteile: Sie können nicht so viele Fehler abfangen, wie es bei der statischen Typisierung möglich ist, weil Compiler und Tools bei statisch typisierten Systemen mehr Fehler erkennen können. Doch Rubys Typsystem hat auch verschiedene potenzielle Vorteile. Ihre Klassen müssen nicht vom selben Parent erben, um auf dieselbe Weise verwendet werden zu können: >> i = 0 => 0 >> a = ['100', 100.0] => ['100', 100.0] >> while i < 2 >> puts a[i].to_i >> i = i + 1 >> end 100 100
Sie haben gerade Duck-Typing in Aktion erlebt. Das erste Element des Arrays ist ein String, und das zweite ein Float. Der Code wandelt beide über to_i in einen Integer-Wert um. Duck-Typing kümmert sich nicht darum, welcher Typ zugrunde liegt. Wenn es wie eine Ente läuft und wie eine Ente schnattert, dann ist es eine Ente. In diesem Fall heißt die Schnattermethode to_i.
3 Ich lüge Sie ein bisschen an, aber wirklich nur ein bisschen. Noch zwei Beispiele, und Sie werden sehen, wie ich eine existierende Klasse zur Laufzeit ändere. Theoretisch kann man eine Klasse bis zur Unkenntlichkeit verändern und so die Typsicherheit umgehen. Doch in der Regel verhält sich Ruby wie eine stark typisierte Sprache.
Tag 1: Ein Kindermädchen finden 31 Duck-Typing ist für ein sauberes objektorientiertes Design extrem wichtig. Ein wichtiger Grundsatz der Designphilosophie ist, Interfaces zu kodieren, nicht bloß Implementierungen. Wenn Sie Duck-Typing nutzen, ist diese Philosophie ohne größeren Aufwand umzusetzen. Besitzt ein Objekt die Methoden push und pop, können Sie es wie einen Stack benutzen. Wenn nicht, dann nicht.
Was wir am ersten Tag gelernt haben Bisher sind wir nur die Grundlagen angegangen. Ruby ist eine objektorientierte Sprache. Fast alles ist ein Objekt, und man gelangt einfach an alle Teile eines Objekts, etwa Methoden und die Klasse. Ruby nutzt Duck-Typing und verhält sich meist wie eine stark typisierte Sprache, auch wenn einige Akademiker das etwas anders sehen. Sie ist eine freigeistige Sprache, mit der Sie nahezu alles anstellen können. Sie können sogar Kernklassen wie NilClass und String verändern. Nun ist es Zeit für ein wenig Selbststudium.
Tag 1: Selbststudium Sie hatten Ihr erstes Date mit Ruby. Nun ist es an der Zeit, etwas Code zu schreiben. Bei dieser Session werden Sie keine ganzen Programme schreiben, sondern irb benutzen, um einige Ruby-Fragmente einzugeben. Wie immer finden Sie die Antworten am Ende des Buches. Finden Sie 앫
die Ruby-API,
앫
die freie Onlineversion von Programming Ruby: The Pragmatic Programmer’s Guide [TFH08],
앫
eine Methode, um Teile eines Strings zu ersetzen,
앫
Informationen über Rubys reguläre Ausdrücke und
앫
Information über Rubys Wertebereiche („ranges“).
Machen Sie Folgendes: 앫
Geben Sie den String „Hallo, Welt“ aus.
앫
Ermitteln Sie für den String „Hallo, Ruby“ den Index des Wortes „Ruby“.
앫
Geben Sie Ihren Namen zehnmal aus.
32 Kapitel 2: Ruby
2.3
앫
Geben Sie den String „Das hier ist Satz Nummer 1“ aus, wobei sich die Zahl von 1 bis 10 ändert.
앫
Führen Sie ein Ruby-Programm aus einer Datei heraus aus.
앫
Zusatzaufgabe: Wenn es Ihnen nach ein wenig mehr gelüstet, schreiben Sie ein Programm, das sich eine Zufallszahl aussucht. Lassen Sie den Spieler die Zahl raten und sagen Sie ihm, ob er zu hoch oder zu niedrig liegt. (Tipp: rand(10) erzeugt eine Zufallszahl zwischen 0 und 9, und gets liest einen String von der Tastatur ein, den Sie in einen Integer-Wert umwandeln können.)
Tag 2: Vom Himmel herab Als der Film Mary Poppins herauskam, war eine der beeindruckendsten Szenen in ihm das Erscheinen der Hauptfigur: Sie schwebte mit dem Regenschirm in die Stadt ein. Meine Kinder werden nie verstehen, warum das so bahnbrechend war. Heute werden Sie ein wenig von der Magie kennenlernen, die Ruby so ansprechend macht. Sie werden die Verwendung der grundlegenden Bausteine wie Objekte, Collections und Klassen kennenlernen. Sie werden auch die Grundlagen des Codeblocks kennenlernen. Öffnen Sie Ihren Geist für ein wenig Zauberei.
Funktionen definieren Im Gegensatz zu Java und C# müssen Sie keine Klasse aufbauen, um eine Funktion zu definieren. Sie können eine Funktion direkt über die Konsole definieren: >> def tell_the_truth >> true >> end
Jede Funktion gibt etwas zurück. Geben Sie keinen expliziten Rückgabewert an, gibt die Funktion den Wert des letzten verarbeiteten Ausdrucks zurück. Wie alles andere auch, ist diese Funktion ein Objekt. Später werden wir an Strategien arbeiten, um Funktionen als Parameter an andere Funktionen zu übergeben.
Tag 2: Vom Himmel herab 33
Arrays Arrays sind bei Ruby geordnete Collections. Seit Ruby 1.9 gibt es auch geordnete Hashes, aber Rubys primäre geordnete Collections sind Arrays. Hier ein Beispiel: >> animals = ['lions', 'tigers', 'bears'] => ["lions", "tigers", "bears"] >> puts animals lions tigers bears => nil >> animals[0] => "lions" >> animals[2] => "bears" >> animals[10] => nil >> animals[-1] => "bears" >> animals[-2] => "tigers" >> animals[0..1] => ['lions', 'tigers'] >> (0..1).class => Range
Wie Sie sehen, bieten Ihnen Ruby-Collections einige Freiheiten. Greifen Sie auf ein undefiniertes Array-Element zu, gibt Ruby einfach nil zurück. Sie finden auch einige Features, die Arrays nicht leistungsfähiger machen, aber ihre Nutzung vereinfachen. animals[-1] gibt das erste Element vom Ende des Arrays zurück, animals[-2] das zweitletzte und so weiter. Ein solches Feature nennt man syntaktischen Zucker: ein der Bequemlichkeit dienendes zusätzliches Feature. Der Ausdruck animals[0..1] sieht auch nach syntaktischem Zucker aus, ist aber keiner. Tatsächlich ist 0..1 ein Wertebereich (range) und steht für alle Zahlen von 0 bis einschließlich 1. Arrays können auch andere Typen enthalten: >> a[0] = 0 NameError: undefined local variable or method `a' for main:Object from (irb):23 >> a = [] => []
Hoppla! Ich habe versucht, ein Array zu benutzen, bevor es ein Array war. Dieser Fehler gibt Ihnen einen Hinweis darauf, wie Arrays und Hashes bei Ruby funktionieren. [] ist tatsächlich eine Methode auf Array:
34 Kapitel 2: Ruby >> => >> => >>
[1].class Array [1].methods.include?('[]') true # [1].methods.include?(:[]) bei ruby
1.9
Also sind [] und []= nur syntaktischer Zucker, der den Zugriff auf ein Array erlaubt. Dementsprechend muss man erst ein leeres Array anlegen, bevor man damit herumspielen kann: >> => >> => >> => >> =>
a[0] = 'zero' "zero" a[1] = 1 1 a[2] = ['two', 'things'] ["two", "things"] a ["zero", 1, ["two", "things"]]
Arrays müssen nicht homogen sein. >> => >> => >> =>
a = [[1, 2, 3], [10, 20, 30], [40, 50, [[1, 2, 3], [10, 20, 30], [40, 50, 60]] a[0][0] 1 a[1][2] 30
60]]
Und mehrdimensionale Arrays sind einfach Arrays von Arrays. >> => >> => >> => >> => >> => >> =>
a = [1] [1] a.push(1) [1, 1] a = [1] [1] a.push(2) [1, 2] a.pop 2 a.pop 1
Arrays verfügen über eine unglaublich umfangreiche API. Sie können ein Array als Queue, verkettete Liste, Stack oder Menge (Set) verwenden. Sehen wir uns nun die andere wichtige Collection bei Ruby an, den Hash.
Hashes Denken Sie daran, dass es sich bei Collections um „Behälter“ für Objekte handelt. Im Hash-Behälter hat jedes Objekt ein Label. Dieses Label bildet den Schlüssel, und das Objekt ist der Wert. Ein Hash ist also ein Haufen von Schlüssel/Wert-Paaren:
Tag 2: Vom Himmel herab 35 >> => >> => >> => >> => >> =>
numbers = {1 => 'one', 2 => 'two'} {1=>"one", 2=>"two"} numbers[1] "one" numbers[2] "two" stuff = {:array => [1, 2, 3], :string => {:array=>[1, 2, 3], :string=>"Hi, mom!"} stuff[:string] "Hi, mom!"
'Hi, mom!'}
Das ist nicht allzu kompliziert. Ein Hash funktioniert fast wie ein Array, anstelle eines Integer-Index kann ein Hash aber einen beliebigen Schlüssel verwenden. Der letzte Hash ist interessant, weil ich erstmals ein Symbol verwende. Ein „Symbol“ ist ein Bezeichner, vor dem ein Doppelpunkt steht, z. B. :symbol. Symbole sind großartig, um Dinge zu benennen. Während zwei Strings mit demselben Wert unterschiedliche physikalische Strings sein können, stellen identische Symbole dasselbe physikalische Objekt dar. Sie können das erkennen, wenn Sie den eindeutigen Objekt-Identifier des Symbols wiederholt abrufen: >> => >> => >> => >> =>
'string'.object_id 3092010 'string'.object_id 3089690 :string.object_id 69618 :string.object_id 69618
Hashes tauchen manchmal unter seltsamen Umständen auf. Zum Beispiel unterstützt Ruby keine benannten Parameter, was Sie aber mithilfe eines Hash simulieren können. Mit ein wenig syntaktischem Zucker erreichen Sie interessante Verhaltensweisen: >> >> >> >> >> >> >> => >> => >> =>
def tell_the_truth(options={}) if options[:profession] == :lawyer 'it could be believed that this is almost certainly not false.' else true end end nil tell_the_truth true tell_the_truth :profession => :lawyer "it could be believed that this is almost certainly not false."
Diese Methode kennt einen einzelnen optionalen Parameter. Wenn Sie ihn nicht übergeben, wird options als leerer Hash festgelegt. Wenn Sie :profession mit :lawyer angeben, erhalten Sie etwas völlig anderes. Das Ergebnis ist eigentlich nicht „wahr“, aber doch so gut wie, weil es
36 Kapitel 2: Ruby das System zu true evaluiert. Beachten Sie, dass Sie die geschweiften Klammern nicht angeben müssen. Geschweifte Klammern sind für den letzten Parameter einer Funktion optional. Weil Array-Elemente, HashSchlüssel und Hash-Werte nahezu alles sein können, lassen sich mit Ruby unglaublich leistungsfähige Datenstrukturen aufbauen. Doch die wirkliche Macht bringen Codeblöcke.
Codeblöcke und yield Ein Codeblock ist eine Funktion ohne Namen. Sie kann als Parameter an eine Funktion oder Methode übergeben werden. Hier ein Beispiel: >> 3.times {puts 'hiya there, kiddo'} hiya there, kiddo hiya there, kiddo hiya there, kiddo
Der Code zwischen den geschweiften Klammern ist der Codeblock. times ist eine Fixnum-Methode, die einfach etwas x-mal macht. Das x ist der Codeblock, der dem Fixnum-Wert entsprechend wiederholt ausgeführt wird. Sie können Codeblöcke mit {/} oder do/end angeben. Die typische Ruby-Konvention verwendet geschweifte Klammern, wenn der Codeblock auf eine Zeile passt, und do/end, wenn sich der Codeblock über mehrere Zeilen erstreckt. Sie können Codeblöcken ein oder mehrere Parameter übergeben: >> animals = ['lions and ', 'tigers and', 'bears', 'oh => ["lions and ", "tigers and", "bears", "oh my"] >> animals.each {|a| puts a} lions and tigers and bears oh my
my']
Dieser Code zeigt ansatzweise die Stärke des Codeblocks. Der Code sagt Ruby, was es mit den Elementen der Collection anstellen soll. Mit minimaler Syntax geht Ruby alle Elemente durch und gibt sie aus. Damit Sie ein Gefühl dafür zu bekommen, was genau vor sich geht, sehen Sie hier eine eigene Implementierung der times-Methode: >> class Fixnum >> def my_times >> i = self >> while i > 0 >> i = i - 1 >> yield >> end >> end >> end
Tag 2: Vom Himmel herab 37 => nil >> 3.my_times {puts mangy moose mangy moose mangy moose
'mangy
moose'}
Dieser Code öffnet eine existierende Klasse und fügt eine Methode ein. In diesem Fall ist das die Methode my_times, die eine Schleife durchläuft und den Codeblock über yield ausführt. Blöcke können auch Parameter sein. Sehen Sie sich folgendes Beispiel an: >> def call_block(&block) >> block.call >> end => nil >> def pass_block(&block) >> call_block(&block) >> end => nil >> pass_block {puts 'Hello, block'} Hello, block
Mit dieser Technik können Sie also ausführbaren Code weiterreichen. Blöcke eignen sich nicht nur für die Iteration. Bei Ruby können Sie Blöcke auch verwenden, um die Ausführung zu verzögern ... execute_at_noon { puts 'Beep beep... time to get up'}
... etwas bedingt auszuführen ... ...some code... in_case_of_emergency do use_credit_card panic end def in_case_of_emergency yield if emergency? end ...more code...
... Regeln zu erzwingen ... within_a_transaction do things_that must_happen_together end def within_a_transaction begin_transaction yield end_transaction end
38 Kapitel 2: Ruby ... und noch einiges mehr anzustellen. Sie werden Ruby-Bibliotheken begegnen, die Blöcke benutzen, um die Zeilen einer Datei zu verarbeiten, die Arbeit in einer HTTP-Transaktion zu erledigen und komplexe Operationen mit Collections durchzuführen. Ruby ist ein Fest der Blöcke.
Ruby aus einer Datei ausführen Wenn die Codebeispiele etwas komplizierter werden, ist die Arbeit mit der interaktiven Konsole nicht mehr ganz so bequem. Sie werden die Konsole benutzen, um kleinere Codefragmente zu untersuchen, aber Sie werden ihren Code primär in Dateien ablegen. Legen Sie eine Datei namens hello.rb an. Sie können jeden beliebigen Ruby-Code eingeben: puts 'Hello, World'
Speichern Sie sie im aktuellen Verzeichnis und führen Sie sie dann über die Kommandozeile aus: batate$ ruby hallo.rb Hello, World
Einige Leute benutzen Ruby aus integrierten Entwicklungsumgebungen heraus, doch vielen reicht ein einfacher Texteditor, um die Dateien zu bearbeiten. Ich bevorzuge TextMate, aber auch für vi, emacs und viele andere beliebte Editoren gibt es Ruby-Plugins. Mit diesem Wissen in der Hinterhand können wir uns den wiederverwendbaren Bausteinen von Ruby-Programmen zuwenden.
Klassen definieren Wie Java, C# und C++ kennt auch Ruby Klassen und Objekte. Nach Schema F sind Klassen bloß Schablonen für Objekte. Natürlich unterstützt Ruby Vererbung. Im Gegensatz zu C++ kann eine Ruby-Klasse nur von einem Parent erben, der sogenannten Superklasse. Um das in Aktion zu sehen, geben Sie in der Konsole Folgendes ein: >> => >> => >> => >> => >> =>
4.class Fixnum 4.class.superclass Integer 4.class.superclass.superclass Numeric 4.class.superclass.superclass.superclass Object 4.class.superclass.superclass.superclass.superclass nil
Tag 2: Vom Himmel herab 39 Object Module Class
Numeric Integer Fixnum
4
Abbildung 2.1: Ruby-Metamodell So weit, so gut. Objekte leiten sich aus einer Klasse ab. Die Klasse für 4 ist Fixnum, die von Integer, Numeric und schließlich Object erbt. In Abbildung 2.1 können Sie sehen, wie das Ganze zusammenpasst. Letztlich erbt alles von Object. Eine Klasse ist auch ein Modul. Instanzen von Klassen dienen als Schablonen für Objekte. In unserem Fall ist Fixnum die Instanz einer Klasse und 4 eine Instanz von Fixnum. Jede dieser Klassen ist auch ein Objekt: >> => >> => >> =>
4.class.class Class 4.class.class.superclass Module 4.class.class.superclass.superclass Object
Fixnum leitet sich also aus der Klasse Class ab. Dann wird es verwirrend. Klassen erben von Module, und Module erbt von Object. Unterm Strich hat alles in Ruby einen gemeinsamen Vorfahren: Object. ruby/tree.rb
class Tree attr_accessor :children, :node_name def
initialize(name, children=[]) @children = children @node_name = name
end def
end
visit_all(&block) visit &block children.each {|c| c.visit_all &block}
40 Kapitel 2: Ruby def
visit(&block) block.call self
end end ruby_tree = Tree.new( "Ruby", [Tree.new("Reia"), Tree.new("MacRuby")] ) puts "Visiting a node" ruby_tree.visit {|node| puts node.node_name} puts puts "visiting entire tree" ruby_tree.visit_all {|node| puts node.node_name}
Diese Klasse implementiert einen sehr einfachen Baum. Sie umfasst die drei Methoden initialize, visit und visit_all sowie die beiden Instanzvariablen children und node_name. initialize hat eine besondere Bedeutung: Ruby ruft sie auf, wenn eine Klasse ein neues Objekt instanziiert. Ich werde jetzt ein paar Konventionen und Regeln für Ruby beschreiben. Klassen beginnen mit Großbuchstaben und verwenden üblicherweise CamelCase (Binnenmajuskeln). Instanzvariablen (ein Wert pro Objekt) muss ein @ vorangestellt werden, während vor Klassenvariablen (ein Wert pro Klasse) @@ steht. Instanzvariablen und Methodennamen beginnen mit Kleinbuchstaben im unterstrich_stil. Konstanten werden KOMPLETT_GROSS geschrieben. Unser Code definiert eine Klasse für einen Baum. Jeder Baum besitzt zwei Instanzvariablen: @children und @node_name. Testende Funktionen und Methoden verwenden üblicherweise ein Fragezeichen (if test?). Das Schlüsselwort attr definiert eine Instanzvariable. Es gibt verschiedene Versionen, von denen attr die gängigste ist. Es definiert eine Instanzvariable und eine Zugriffsmethode gleichen Namens. attr_accessor definiert eine Instanzenvariable sowie Methoden für den lesenden und schreibenden Zugriff auf diese Variable an. Unser Programm schlägt richtig zu: Es nutzt Blöcke und Rekursion, um jedem Benutzer den Zugriff auf jeden Knoten des Baums zu ermöglichen. Die initialize-Methode stellt Anfangswerte für children und node_name bereit. Die visit-Methode ruft den angegebenen Codeblock auf. Die visit_all-Methode ruft visit für den Knoten und dann rekursiv visit_all für alle Child-Knoten auf.
Tag 2: Vom Himmel herab 41 Der restliche Code nutzt die API. Er definiert einen Baum, besucht erst einen Knoten und dann alle Knoten. Wenn Sie sie ausführen, erhalten Sie die folgende Ausgabe: Visiting a node Ruby visiting entire tree Ruby Reia MacRuby
Klassen sind nur ein Teil der Gleichung. Module haben Sie kurz im Code auf Seite 39 gesehen. Sehen wir uns das mal genauer an.
Ein Mixin schreiben Objektorientierte Sprachen nutzen Vererbung, um Verhaltensweisen an ähnliche Objekte weiterzureichen. Sind die Verhaltensweisen nicht gleich, kann man entweder die Vererbung von mehr als einer Klasse erlauben (Mehrfachvererbung) oder nach einer anderen Lösung suchen. Die Erfahrung lehrt uns, dass Mehrfachvererbung kompliziert und problematisch ist. Java verwendet Interfaces, um dieses Problem zu lösen. Ruby verwendet dafür Module. Ein module ist eine Sammlung von Funktionen und Konstanten. Wenn Sie ein Modul als Teil einer Klasse einbinden, werden seine Verhaltensweisen und Konstanten Teil der Klasse. Nehmen Sie die folgende Klasse, die eine beliebige Klasse um die Methode to_f erweitert: ruby/to_file.rb
module ToFile def filename "object_#{self.object_id}.txt" end def
to_f File.open(filename, 'w') {|f| f.write(to_s)}
end end class Person include ToFile attr_accessor :name def end
initialize(name) @name = name
42 Kapitel 2: Ruby def
to_s name
end end Person.new('matz').to_f
Beginnen wir mit der Moduldefinition. Dieses Modul besitzt zwei Methoden. Die Methode to_f schreibt die Ausgabe der to_s-Methode in eine Datei, deren Dateiname durch die filename-Methode bereitgestellt wird. Das Interessante ist hier, dass to_s im Modul verwendet, aber in der Klasse implementiert wird! Die Klasse wurde noch nicht einmal definiert. Das Modul arbeitet eng mit der einbindenden Klasse zusammen. Module sind häufig von verschiedenen Klassenmethoden abhängig. Bei Java ist dieser Kontrakt explizit: Die Klasse implementiert ein formales Interface. Bei Ruby wird der Kontrakt hingegen implizit durch Duck-Typing erfüllt. Die Details zu Person sind völlig uninteressant, und das ist genau der Punkt. Person bindet das Modul ein, und das war’s. Die Fähigkeit, etwas in eine Datei zu schreiben, hat nichts damit zu tun, ob es sich bei der Klasse tatsächlich um Person handelt. Wir fügen die Möglichkeit, Inhalte in einer Datei zu speichern, einfach ein, indem wir die Fähigkeit einbinden. Wir können neue Mixins und Subklassen zu Person hinzufügen, und jede Subklasse verfügt über die Fähigkeiten aller Mixins, ohne dass wir etwas über deren Implementierung wissen müssen. Wenn alles fertig ist, können Sie eine vereinfachte Einfachvererbung nutzen, um den Kern einer Klasse zu definieren, und fügen zusätzliche Fähigkeiten mit Modulen hinzu. Dieser „Mixin-Programmierstil“ wurde bei Flavors eingeführt und in vielen Sprachen von Smalltalk bis Python genutzt. Das Vehikel, das den Mixin transportiert, wird nicht immer Modul genant, aber der Grundansatz ist klar: Einfache Vererbung in Kombination mit Mixins erlaubt es, Verhaltensweisen schön zu verpacken.
Module, Enumerable und Sets Mit die kritischsten Mixins bei Ruby sind enumerable und comparable. Möchte eine Klasse enumerable sein, muss sie each implementieren, und für comparable muss <=> implementiert werden. Der sogenannte Raumschiff-Operator („spaceship operator“) <=> ist ein einfacher Vergleich, der -1 zurückgibt, wenn b größer ist, 1, wenn a größer ist, und ansonsten 0. Im Tausch für die Implementierung dieser Methoden bieten enumerable und comparable viele praktische Methoden für Collections. Öffnen Sie die Konsole:
Tag 2: Vom Himmel herab 43 >> => >> => >> => >> => >> => >> => >> => >> => >> => >> => >> => >> => >> =>
'begin' <=> 'end' -1 'same' <=> 'same' 0 a = [5, 3, 4, 1] [5, 3, 4, 1] a.sort [1, 3, 4, 5] a.any? {|i| i > 6} false a.any? {|i| i > 4} true a.all? {|i| i > 4} false a.all? {|i| i > 0} true a.collect {|i| i * 2} [10, 6, 8, 2] a.select {|i| i % 2 == 0 } # gerade [4] a.select {|i| i % 2 == 1 } # ungerade [5, 3, 1] a.max 5 a.member?(2) false
any? gibt true zurück, wenn die Bedingung für eines der Elemente erfüllt wird; all? gibt true zurück, wenn die Bedingung für alle Ele-
mente erfüllt wird. Da der Raumschiff-Operator für diese Integer-Werte über Fixnum implementiert wird, können Sie auch sortieren sowie min oder max berechnen. Sie können auch Mengenoperationen durchführen. collect und map wenden eine Funktion auf alle Elemente an und geben ein Array der Ergebnisse zurück. find findet ein Element, das die Bedingung erfüllt. select und find_all geben alle Elemente zurück, die die Bedingung erfüllen. Sie können mit inject auch die Summe oder das Produkt errechnen: >> => >> => >> => >> =>
a [5, 3, 4, 1] a.inject(0) {|sum, i| sum + i} 13 a.inject {|sum, i| sum + i} 13 a.inject {|product, i| product 60
*
i}
inject sieht schwierig aus, ist aber nicht besonders kompliziert. Es
verlangt einen Codeblock mit zwei Argumenten und einen Ausdruck. Der Codeblock wird für jedes Element der Liste ausgeführt, wobei
44 Kapitel 2: Ruby inject jedes Listenelement als zweiten Parameter an den Codeblock übergibt. Das erste Argument ist das Ergebnis der vorangegangenen Ausführung des Codeblocks. Da es für das Ergebnis beim ersten Aufruf des Codeblocks keinen Wert gibt, übergeben Sie den Anfangswert als Argument an inject. (Geben Sie keinen Wert an, verwendet inject den ersten Wert der Collection.) Schauen wir uns das noch mal an: >> a.inject(0) do |sum, i| ?> puts "sum: #{sum} i: #{i} ?> sum + i ?>end sum: 0 i: 5 sum + i: 5 sum: 5 i: 3 sum + i: 8 sum: 8 i: 4 sum + i: 12 sum: 12 i: 1 sum + i: 13
sum + i: #{sum + i}"
Wie erwartet, ist das Ergebnis der vorangegangenen Zeile immer der erste Wert der nächsten. Mit inject können Sie die Anzahl der Wörter über viele Zeilen hinweg berechnen, das längste Wort in einem Absatz finden und Vieles mehr.
Was wir am zweiten Tag gelernt haben Das war Ihre erste Gelegenheit, ein wenig von Rubys Zucker und auch von seiner Magie kennenzulernen. Sie beginnen zu erkennen, wie flexibel Ruby sein kann. Collections sind sehr einfach: zwei Collections, über denen mehrere APIs liegen. Die Performance der Anwendung ist sekundär. Ruby geht es um die Performance des Programierers. Das enumerable-Modul gibt Ihnen eine Vorstellung davon, wie gut durchdacht Ruby sein kann. Die objektorientierte Struktur mit einfacher Vererbung ist nicht neu, aber die Implementierung steckt voller intuitiver Konstrukte und nützlicher Features. Diese Abstraktionsebene ergibt schon eine etwas bessere Programmiersprache, doch bald werden wir ernstzunehmenden Wundermitteln begegnen!
Tag 2: Selbststudium Die folgenden Aufgaben werden etwas anspruchsvoller. Sie kennen Ruby nun etwas länger, also ziehen wir die Samthandschuhe aus. Hier ist etwas mehr analytisches Denken gefragt. Finden Sie Folgendes heraus: 앫
Wie greift man mit Codeblöcken und ohne sie auf Dateien zu? Welchen Vorteil haben die Codeblöcke?
Tag 3: Tiefgreifende Veränderung 45 앫
Wie würden Sie einen Hash in ein Array umwandeln? Können Sie Arrays in Hashes umwandeln?
앫
Können Sie über einen Hash iterieren?
앫
Sie können Ruby-Arrays als Stacks verwenden. Welche weiteren gängigen Datenstrukturen unterstützen Arrays?
Tun Sie Folgendes: 앫
Geben Sie ein Array mit 16 Zahlen aus, jeweils vier in einer Zeile. Verwenden Sie dazu nur each. Machen Sie dann das Gleiche mit each_slice in Enumerable.
앫
Die Tree-Klasse war interessant, aber ich habe es Ihnen nicht ermöglicht, mit einer sauberen Benutzerschnittstelle einen neuen Baum anzulegen. Lassen Sie den Initialisierer eine verschachtelte Struktur mit Hashes und Arrays akzeptieren. Sie sollen auf folgende Weise einen Baum angeben können: {'grandpa' => { 'dad' => {'child 1' => {}, 'child2' => {} }, 'uncle' => {'child 3' => {}, 'child 4' => {}}} }.
앫
2.4
Schreiben Sie ein einfaches grep, das die Zeilen einer Datei ausgibt, in denen ein bestimmter Ausdruck vorkommt. Dazu müssen Sie ein einfaches Regex-Matching durchführen und Zeilen aus einer Datei einlesen. (Das ist mit Ruby überraschend einfach.) Wenn Sie wollen, können Sie Zeilennummern einfügen.
Tag 3: Tiefgreifende Veränderung Der wesentliche Punkt an Mary Poppins ist, dass sie den Haushalt als Ganzes verbessert, indem sie Spaß hineinbringt und die Herzen der Menschen mit Leidenschaft und Phantasie verändert. Sie können ein wenig zurückstecken und es sicher angehen, indem Sie Ruby so benutzen, wie Sie es von anderen Sprachen her kennen. Doch wenn Sie die Art und Weise ändern, wie eine Sprache aussieht und funktioniert, können Sie Magie entstehen lassen, durch die das Programmieren richtig Spaß macht. In diesem Buch zeigt Ihnen jedes Kapitel ein nicht triviales Problem, das die Sprache gut lösen kann. Bei Ruby ist das die Metaprogrammierung. Metaprogrammierung bedeutet, Programme zu schreiben, die Programme schreiben. Das ActiveRecord-Framework, Herzstück von Rails, verwendet die Metaprogrammierung, um eine freundliche Sprache zu implementie-
46 Kapitel 2: Ruby ren, die Klassen erzeugt, die auf Datenbanktabellen zugreifen. Eine ActiveRecord-Klasse für ein Department („Abteilung“) könnte so aussehen: class Department < ActiveRecord::Base has_many :employees has_one :manager end
has_many und has_one sind Ruby-Methoden, die alle Instanzvariablen und Methoden für eine has_many-Beziehung bereitstellen. Diese Klassenspezifikation liest sich wie Englisch und eliminiert das ganze Rauschen und Drumherum, das Sie bei anderen Datenbank-Frameworks vorfinden. Sehen wir uns verschiedene Tools an, die Sie zur Metaprogrammierung verwenden können.
Offene Klassen Sie haben bereits eine kurze Einführung in offene Klassen erhalten. Sie können die Definition jeder beliebigen Klasse jederzeit ändern, was Sie üblicherweise tun, um neue Verhaltensweisen einzufügen. Hier ein wunderbares Beispiel des Rails-Framworks, das NilClass: um eine neue Methode erweitert: ruby/blank.rb
class NilClass def blank? true end end class String def blank? self.size == 0 end end ["", "person", nil].each do |element| puts element unless element.blank? end
Der erste Aufruf von class definiert eine Klasse. Sobald eine Klasse einmal definiert ist, wird sie durch nachfolgende Aufrufe verändert. Dieser Code fügt eine Methode namens blank? in zwei existierende Klassen ein, NilClass und String. Wenn ich den Status eines gegebenen Strings überprüfe, möchte ich häufig wissen, ob er leer ist. Die meisten Strings können einen Wert besitzen, leer sein oder möglicherweise nil sein. Diese kleine Formulierung erlaubt Ihnen die schnelle Überprüfung der beiden „Leer“-Fälle auf einmal, da blank? true zurückliefert. Es spielt
Tag 3: Tiefgreifende Veränderung 47 keine Rolle, auf welche Klasse String zeigt. Solange sie die blank?Methode unterstützt, funktioniert sie. Wenn es wie eine Ente läuft und wie eine Ente schnattert, dann ist es eine Ente. Ich muss keine Zeit mit der Typprüfung verschwenden. Machen Sie sich klar, was hier passiert: Sie bitten um ein sehr scharfes Skalpell, und Ruby gibt es Ihnen gerne. Ihre offenen Klassen haben sowohl String als auch Nil umdefiniert. Es ist möglich, Ruby vollständig abzuschießen, indem man beispielsweise Class.new umdefiniert. Im Gegenzug erkaufen man sich Freiheit. Mit dieser Art Freiheit, die es einem erlaubt, jede Klasse und jedes Objekt umzudefinieren, kann man erstaunlich gut lesbaren Code erzeugen. Mit Freiheit und Macht kommt Verantwortung. Offene Klassen sind nützlich, wenn Sie Sprachen für Ihre eigene Domäne aufbauen. Es ist häufig sinnvoll, Einheiten in einer Sprache auszudrücken, die für Ihre Geschäftsdomäne funktioniert. Nehmen wir zum Beispiel eine API, die alle Distanzen in Zoll ausdrückt: ruby/units.rb
class Numeric def inches self end def feet self * 12.inches end def yards self * 3.feet end def miles self * 5280.feet end def back self * -1 end def forward self end end puts 10.miles.back puts 2.feet.forward
Offene Klassen machen diese Art der Unterstützung durch minimale Syntax möglich. Doch andere Techniken bringen Ruby noch weiter.
48 Kapitel 2: Ruby
Verwendung von method_missing Ruby ruft immer eine spezielle Debugging-Methode auf, wenn eine Methode fehlt, um bestimmte Diagnoseinformationen auszugeben. Dieses Verhalten vereinfacht das Debugging der Sprache. Doch manchmal können wir dieses Sprachfeature zu unserem Vorteil nutzen, um überraschend leistungsfähige Verhaltensweisen zu erreichen. Dazu müssen Sie nur method_mising überschreiben. Stellen Sie sich eine API für römische Zahlen vor. Sie können einfach mit einem Methodenaufruf arbeiten oder mit einer API wie Roman.number_for "ii". Tatsächlich ist das gar nicht so schlecht. Ihnen stehen keine Klammern oder Semikola im Weg. Mit Ruby geht das aber noch besser: ruby/roman.rb
class Roman def self.method_missing name, *args roman = name.to_s roman.gsub!("IV", "IIII") roman.gsub!("IX", "VIIII") roman.gsub!("XL", "XXXX") roman.gsub!("XC", "LXXXX") (roman.count("I")+ roman.count("V") * roman.count("X") * roman.count("L") * roman.count("C") * end end puts puts puts puts
5+ 10 + 50 + 100)
Roman.X Roman.XC Roman.XII Roman.X
Dieser Code ist ein sehr schönes Beispiel für method_missing in Aktion. Er ist klar und einfach. Zuerst überschreiben wir method_missing und erhalten den Namen der Methode und dessen Parameter als Eingabeparameter zurück. Wir sind nur am Namen interessiert. Zuerst wandeln wir diesen in einen String um. Dann ersetzen wir die Sonderfälle wie iv und ix durch Strings, die sich einfacher zählen lassen. Dann müssen wir die römischen Ziffern zählen und mit dem Wert dieser Zahl multiplizieren. Die API ist so wesentlich einfacher: Roman.i statt Roman.number_for "i". Beachten Sie aber auch den zu zahlenden Preis: Wir arbeiten mit einer Klasse, die wesentlich schwerer zu debuggen ist, weil Ruby uns nicht mehr sagen kann, welche Methode fehlt! Wir wünschen uns definitiv
Tag 3: Tiefgreifende Veränderung 49 eine strikte Typprüfung, um sicherzugehen, dass nur gültige römische Zahlen akzeptiert werden. Wenn man nicht weiß, wonach man sucht, kann es einige Zeit kosten, die Implementierung dieser ii-Methode für Roman zu finden. Trotzdem ist method_missing ein weiteres Skalpell in unserem Werkzeugkasten. Nutzen Sie es mit Bedacht.
Module Das mit Abstand populärste Mittel der Metaprogrammierung in Ruby ist das Modul. Sie können def oder attr_accessor mit nur wenigen Zeilen literal in einem Modul implementieren. Sie können Klassendefinitionen auf überraschende Art und Weise erweitern. Eine gängige Technik ermöglicht das Design eigener domänenspezifischer Sprachen („domain-specific languages“, DSL) zur Definition einer Klasse.4 Die DSL definiert Methoden in einem Modul, das alle zur Verwaltung der Klasse notwendigen Methoden und Konstanten einfügt. Ich teile das Beispiel auf und beginne mit einer gemeinsamen Superklasse. Der Typ von Klasse, den wir durch Metaprogrammierung aufbauen wollen, ist ein einfaches Programm, das eine CSV-Datei auf der Basis des Namens der Klasse öffnet. ruby/acts_as_csv_class.rb
class ActsAsCsv def read file = File.new(self.class.to_s.downcase + '.txt') @headers = file.gets.chomp.split(', ') file.each do |row| @result << row.chomp.split(', ') end end def headers @headers end def csv_contents @result end def initialize @result = [] read end 4 DSLs ermöglichen es Ihnen, eine Sprache für eine bestimmte Domäne anzupassen. Das vielleicht bekannteste Beispiel in Ruby ist das ActiveRecord-Framework, das DSLs benutzt, um eine Klasse auf eine Datenbanktabelle abzubilden.
50 Kapitel 2: Ruby end class RubyCsv < ActsAsCsv end m = RubyCsv.new puts m.headers.inspect puts m.csv_contents.inspect
einfache Klasse definiert vier Methoden. headers und csv_contents sind einfache Akzessoren, die den Wert einer Instanzvariablen zurückliefern. initialize initialisiert die Ergebnisse der Leseoperation. Die Hauptarbeit erledigt die read-Methode: Sie öffnet die Diese
Datei, liest die Bezeichnungen ein und legt sie in einzelnen Feldern ab. Dann geht sie alle Zeilen durch und legt den Inhalt in einerm Array ab. Diese CSV-Implementierung ist nicht vollständig, weil Sachen wie Anführungszeichen nicht verarbeitet werden, aber Sie bekommen eine Vorstellung davon, um was es geht. Der nächste Schritt besteht darin, diese Datei zu nehmen und ihr Verhalten über eine Modulmethode (die häufig als Makro bezeichnet wird) mit einer Klasse zu verbinden. Makros verändern das Verhalten von Klassen, oft auf der Basis von Veränderungen in der Umgebung. In diesem Fall öffnet unser Makro die Klasse und schüttet das ganze Wissen um CSV-Dateien hinein: ruby/acts_as_csv.rb
class ActsAsCsv def self.acts_as_csv define_method 'read' do file = File.new(self.class.to_s.downcase + '.txt') @headers = file.gets.chomp.split(', ') file.each do |row| @result << row.chomp.split(', ') end end define_method "headers" do @headers end define_method "csv_contents" do @result end define_method 'initialize' do @result = [] read end
Tag 3: Tiefgreifende Veränderung 51 end end class RubyCsv < ActsAsCsv acts_as_csv end m = RubyCsv.new puts m.headers.inspect puts m.csv_contents.inspect
Die Metaprogrammierung erfolgt im Makro acts_as_csv. Dieser Code ruft define_method für alle Methoden auf, die wir in die Zielklasse einfügen wollen. Ruft die Zielklasse nun acts_as_csv auf, definiert der Code die vier Methoden für die Zielklasse. Das Makro acts_as macht also nichts anderes, als einige Methoden einzufügen, was wir auch einfach mit Vererbung hätten erreichen können. Dieses Design sieht nicht nach einer großartigen Verbesserung aus, aber es wird noch interessant. Schauen wir uns an, wie das gleiche Verhalten in einem Modul funktionieren würde: ruby/acts_as_csv_module.rb
module ActsAsCsv def self.included(base) base.extend ClassMethods end module ClassMethods def acts_as_csv include InstanceMethods end end module InstanceMethods def read @csv_contents = [] filename = self.class.to_s.downcase + '.txt' file = File.new(filename) @headers = file.gets.chomp.split(', ') file.each do |row| @csv_contents << row.chomp.split(', ') end end attr_accessor :headers, :csv_contents def initialize read end
52 Kapitel 2: Ruby end end class RubyCsv # keine Vererbung! Sie können es mischen include ActsAsCsv acts_as_csv end m = RubyCsv.new puts m.headers.inspect puts m.csv_contents.inspect
Ruby ruft die Methode included auf, sobald dieses Modul eingebunden wird. Denken Sie daran, dass eine Klasse ein Modul ist. In unserer eingebundenen Methode erweitern wir die Zielklasse namens base (die RubyCsv-Klasse), und dieses Modul erweitert RubyCsv um Klassenmethoden. Die einzige Klassenmethode ist acts_as_csv. Die Methode öffnet wiederum die Klasse und fügt alle Instanzmethoden ein ... und wir haben ein Programm geschrieben, das Programme schreibt. Das Interessante an all diesen Techniken der Metaprogrammierung ist, dass sich Programme basierend auf dem Status der Anwendung ändern können. ActiveRecord benutzt Metaprogrammierung, um dynamisch Akzessoren einzufügen, die die gleichen Namen haben wie die Spalten in der Datenbank. Einige XML-Frameworks wie builder erlauben den Nutzern die Definition eigener Tags per method_missing, um eine passende Syntax zur Verfügung zu stellen. Wenn die Syntax besser passt, kann der Leser Ihres Codes die Syntax vernachlässigen und sich näher mit dem beschäftigen, was Sie bezwecken wollen. Das ist die Stärke von Ruby.
Was wir am dritten Tag gelernt haben In diesem Abschnitt haben Sie gelernt, Ruby zur Definition einer eigenen Syntax zu nutzen und Klassen „on the fly“ zu ändern. Diese Programmiertechniken gehören in die Kategorie „Metaprogrammierung“. Jede Zeile Code, die Sie schreiben, hat zwei Arten von Lesern: Computer und Menschen. Manchmal ist es bei der Entwicklung schwierig, einen Kompromiss zu finden, damit Code den Interpreter oder Compiler sauber durchläuft und gleichzeitig auch einfach zu verstehen ist. Mithilfe von Metaprogrammierung können Sie die Lücke zwischen gültiger Ruby-Syntax und -Sätzen schließen.
Ruby zusammengefasst 53 Einige der besten Ruby-Frameworks wie Builder und ActiveRecord setzen der Lesbarkeit halber stark auf Metaprogrammierung. Sie haben Klassen wieder geöffnet, um ein Duck-Typing-Interface aufzubauen, das die blank?-Methode für String-Objekte und nil implementiert, wodurch die Unordnung für ein gängiges Szenario deutlich reduziert wurde. Sie haben einigen Code gesehen, der viele der gleichen Techniken nutzt. Sie haben method_missing verwendet, um schöne römische Zahlen aufzubauen. Und schließlich haben Sie Module genutzt, um eine domänenspezifische Sprache aufzubauen, mit deren Hilfe Sie CSV-Dateien angeben können.
Tag 3: Selbststudium Machen Sie Folgendes: Erweitern Sie die CSV-Anwendung um eine each-Methode, die ein CsvRow-Objekt zurückgibt. Verwenden Sie method_missing für diese CsvRow, um den Wert der Spalte für einen gegebenen Bezeichner zurückzugeben. Für die Datei one, two lions, tigers
sollte die API etwa so arbeiten csv = RubyCsv.new csv.each {|row| puts row.one}
und "lions" zurückgeben.
2.5
Ruby zusammengefasst Wir haben in diesem Kapitel sehr viel Grundlegendes abgedeckt. Ich hoffe, Sie können den Vergleich mit Mary Poppins nachvollziehen. Ich habe auf Dutzenden von Ruby-Konferenzen gesprochen und von vielen Leuten gehört, dass sie Ruby toll finden, weil die Sprache Spaß macht. In einer Industrie, in der Sprachen der C-Familie wie C++, C#, Java und andere vorherrschen, ist das Programmieren mit Ruby wie das Einatmen frischer Luft.
54 Kapitel 2: Ruby
Stärken Rubys reine Objektorientierung erlaubt es Ihnen, Objekt in einer gleichförmigen und konsistenten Art und Weise zu behandeln. Das Duck-Typing erlaubt bessere polymorphe Designs, die darauf basieren, was ein Objekt unterstützen kann, und nicht auf seiner Vererbungshierarchie. Und Rubys Module und offene Klassen ermöglichen es dem Programmierer, Verhaltensweisen an die Syntax zu koppeln, die über die üblichen Definitionen von Methoden oder Instanzvariablen einer Klasse hinausgehen. Ruby eignet sich hervorragend zum Scripting und als Sprache zur Webentwicklung (wenn die Anforderungen an die Skalierung moderat sind). Die Sprache ist sehr produktiv. Einige Features, die diese Produktivität ermöglichen, lassen sich nur schwer kompilieren, worunter die Performance von Ruby leidet.
Scripting Ruby ist eine fantastische Scripting-Sprache. Ausgezeichnete Anwendungsszenarien für Ruby sind unter anderem die Entwicklung von Glue-Code zur Verbindung zweier Anwendungen, ein Spider zum Sammeln von Webseiten für einen Börsenkurs oder einen Buchpreis sowie der Betrieb lokaler Build-Umgebungen oder automatisierter Tests. Als Sprache, die bei den meisten wichtigen Betriebssystemen vorhanden ist, ist Ruby eine gute Wahl für Scripting-Umgebungen. Die Sprache umfasst in der Grundausstattung eine Vielzahl von Bibliotheken und es gibt Tausende von Gems (fertige Plugins), mit deren Hilfe Sie CSV-Dateien laden, XML verarbeiten und mit Low-Level-Internet-APIs arbeiten können.
Webentwicklung Rails ist schon heute eines der erfolgreichsten Webentwicklungs-Frameworks aller Zeiten. Das Design basiert auf gut verstandenen Model/ View/Controller-Paradigmen. Die vielen Namenskonventionen für die Elemente der Datenbank und der Anwendung erlauben es, eine typische Anwendung mit nur wenigen Konfigurationszeilen aufzubauen. Und das Framework besitzt Plugins, die einige schwierige Aspekte für die Produktion abdecken:
Ruby zusammengefasst 55 앫
Die Struktur von Rails-Anwendungen ist immer konsistent und wird gut verstanden.
앫
Migrationen behandeln Änderungen im Datenbankschema.
앫
Verschiedene gut dokumentierte Konventionen reduzieren die Gesamtmenge des Konfigurationscodes.
앫
Viele verschiedene Plugins stehen zur Verfügung.
Produkteinführungszeit (Time to Market) Ich glaube, dass die Produktivität von Ruby und Rails ein wichtiger Faktor für ihren Erfolg sind. Um 2005 herum konnten Sie in San Francisco keinen Stein werfen, ohne jemanden zu treffen, der nicht bei einem Startup arbeitete, bei dem Ruby verwendet wurde. Selbst heute ist Ruby bei dieser Art von Firmen (einschließlich meiner eigenen) erfolgreich. Die Kombination aus schöner Syntax, Programmier-Community, Tools und Plugins ist extrem mächtig. Sie finden Ruby-Gems, mit denen Sie die Postleitzahl eines Surfers bestimmen können, und andere, die Ihnen alle Adressencodes im Umkreis von 50 Meilen berechnen. Sie können Images und Kreditkarten verarbeiten, mit Webdiensten arbeiten und über viele Programmiersprachen hinweg kommunizieren. Viele große, kommerzielle Websites benutzen Ruby und Ruby on Rails. Die ursprüngliche Twitter-Implementierung war in Ruby geschrieben, und die außergewöhnliche Produktivität der Sprache erlaubte es der Website, zu riesiger Größe anzuwachsen. Im Endeffekt wurde der Twitter-Kern dann in Scala umgeschrieben. Daraus lernen wir zweierlei: dass Ruby eine gute Sprache ist, um ein brauchbares Produkt schnell auf den Markt zu bringen, und dass die Skalierbarkeit von Ruby etwas beschränkt ist. In großen Unternehmen mit verteilten Transaktionen, ausfallsicherem Messaging und Internationalisierung ist Ruby etwas weniger weit verbreitet, obwohl man auch all das mit ihm hinbekommt. Manchmal sind die Erwägungen zu richtigen Frameworks und Skalierbarkeit gut begründet, aber zu viele Leute konzentrieren sich bei der Entwicklung des „nächsten eBay“ auf Skalierbarkeit, ohne überhaupt rechtzeitig Software liefern zu können. Häufig wäre Ruby mehr als ausreichend, wenn man den Zeitdruck bedenkt, unter dem viele Unternehmen stehen.
56 Kapitel 2: Ruby
Schwächen Keine Sprache ist für alle Anwendungen perfekt. Auch Ruby besitzt einige Einschränkungen. Sehen wir uns die wichtigsten an.
Performance Rubys primäre Schwäche ist die Performance. Sicher, Ruby wird schneller. Die Version 1.9 ist in einigen Anwendungsfällen bis zu zehnmal schneller. Eine neue virtuelle Maschine für Ruby namens Rubinius (geschrieben von Evan Phoenix) bietet die Möglichkeit, Ruby-Code per Just-in-Time-Compiler zu kompilieren. Diese Technik sieht sich die Interpreter-Nutzungsmuster für einen Codeblock an, um vorauszuahnen, welcher Code wahrscheinlich erneut benötigt wird. Dieser Ansatz funktioniert bei Ruby gut, einer Sprache, bei der Syntaxhinweise üblicherweise nicht ausreichen, um eine Kompilierung zu erlauben. Denken Sie daran, dass sich die Definition einer Klasse jederzeit ändern kann. Trotzdem hat Matz eine entschiedene Meinung, was das angeht: Er arbeitet daran, den Programmierern das Leben zu erleichtern, nicht an der Performance der Sprache. Viele der Sprachfeatures, wie offene Klassen, Duck-Typing und method_missing, machen die Tools unmöglich, die die Kompilierung (und die damit verbundenen Performancesteigerungen) ermöglichen würden.
Nebenläufigkeit und OOP Die objektorientierte Programmierung weist eine kritische Einschränkung auf: Die ganze Prämisse des Modells basiert darauf, Verhaltensweisen um einen Zustand herum aufzubauen, aber so ein Zustand verändert sich häufig. Diese Programmierstrategie führt zu ernsthaften Problemen mit der Nebenläufigkeit. Bestenfalls sind signifikante Streitereien um Ressourcen vorprogrammiert. Im schlimmsten Fall lassen sich objektorientierte Systeme in nebenläufigen Umgebungen nahezu nicht mehr debuggen und zuverlässig testen. Als dieses Buch hier geschrieben wurde, begann das Ruby-Team gerade erst damit, die effiziente Verwaltung von Nebenläufigkeit anzugehen.
Typsicherheit Ich glaube an Duck-Typing. Diese Typstrategie führt ganz allgemein zu saubereren Abstraktionen mit präzisem, lesbarem Code. Doch DuckTyping hat auch seinen Preis. Statische Typisierung ermöglicht die
Ruby zusammengefasst 57 Nutzung einer ganzen Reihe von Werkzeugen, die den Aufbau von Syntaxbäumen und damit auch integrierte Entwicklungsumgebungen erlauben. IDEs für Ruby sind schwieriger zu entwickeln und werden von den meisten Entwicklern nicht verwendet. Schon oft habe ich das Fehlen eines IDE-artigen Debuggers bemängelt. Und ich weiß, dass ich damit nicht allein bin.
Abschließende Gedanken Rubys Hauptstärken sind also die Syntax und die Flexibilität. Die entscheidenden Schwächen liegen im Bereich der Performance, auch wenn die Performance für viele Einsatzgebiete ausreicht. Alles in allem ist Ruby eine ausgezeichnete Sprache für die objektorientierte Entwicklung. Bei geeigneten Anwendungen kann Ruby glänzen. Wie alle Werkzeuge sollte auch sie benutzt werden, um die passenden Probleme zu lösen. Dann werden Sie auch nicht enttäuscht. Und halten Sie dabei immer Ausschau nach ein wenig Magie.
The question isn’t, “What are we going to do?” The question is, “What aren’t we going to do?” Ferris Bueller
Kapitel 3
Io Jetzt möchte ich Ihnen Io vorstellen. Wie Ruby ist auch Io eine Regelbrecherin. Die Sprache ist jung, clever und einfach zu verstehen, aber unberechenbar. Denken Sie an Ferris Bueller.1 Wenn Sie eine gute Party mögen, sollten Sie sich von Io die Stadt zeigen lassen. Sie kann Ihnen alles auf einmal bieten. Sie könnte Ihnen die Spritztour Ihres Lebens bieten oder den Wagen Ihres Vaters kaputtmachen, oder beides. So oder so werden Sie sich nicht langweilen. Wie oben angedeutet, gibt es nicht viele Regeln, an die Sie sich halten müssen.
3.1
Einführung in Io Steve Dekorte hat die Sprache Io im Jahr 2002 entwickelt. Man schreibt sie immer mit einem großen I und einem kleinen o. Io ist eine Prototypsprache (wie Lua oder JavaScript), bei der jedes Objekt ein Klon eines anderen Objekts ist. Entwickelt als Übung, die Steve dabei helfen sollte, die Funktionsweise von Interpretern zu verstehen, begann sie als „Bastlersprache“ und ist bis heute sehr klein geblieben. Sie können die Syntax in etwa 15 Minuten lernen und die grundlegenden Mechanismen in etwa 30 Minuten. Es gibt keine Überraschungen. Doch die Bibliotheken verlangen etwas mehr Zeit. Die Komplexität und der Reichtum erklären sich aus dem Design der Bibliotheken. Heutzutage konzentriert sich der Großteil der Io-Community auf Io als einbettbare (embeddable) Sprache mit einer kleinen virtuellen Maschine 1 Ferris Bueller’s Day Off. DVD. Herausgegeben von John Hughes. 1986; Hollywood, CA: Paramount, 1999.
60 Kapitel 3: Io und guter Nebenläufigkeit. Die Kernstärken sind die umfassend anpassbare Syntax und Funktionsweise sowie ein mächtiges Modell für Nebenläufigkeit. Versuchen Sie, sich auf die Einfachheit der Syntax und das Prototyp-Programmiermodell zu konzentrieren. Durch Io habe ich ein wesentlich besseres Verständnis dafür entwickelt, wie JavaScript funktioniert.
3.2
Tag 1: Blaumachen und rumhängen Io kennenzulernen, ist wie das Kennenlernen jeder anderen Sprache auch: Sie müssen etwas Zeit an der Tastatur verbringen, um sich richtig miteinander vertraut zu machen. Es wäre wesentlich einfacher, wenn wir das außerhalb staubtrockener Diskussionen vor der Geschichtsstunde auf dem Flur machen könnten. Lassen Sie uns also die Schule schwänzen und direkt mit den schönen Dingen loslegen. Ein Name ist manchmal trügerisch, sagt in diesem Fall aber viel über Io aus. Er ist gleichzeitig gewagt (haben Sie mal versucht, nach Io zu googeln?)2 und brilliant. Er hat nur zwei Buchstaben, beides Vokale. Die Syntax der Sprache ist einfach und auf niedriger Ebene angesiedelt, genau wie der Name. Die Io-Syntax verknüpft einfach Nachrichten miteinander, wobei jede Nachricht ein Objekt zurückgibt und optional Parameter in Klammern übergeben kann. Bei Io ist alles eine Nachricht, die einen weiteren Empfänger zurückgibt. Es gibt keine Schlüsselwörter und nur eine Hand voll Zeichen, die sich wie Schlüsselwörter verhalten. Bei Io müssen Sie sich keine Gedanken um Klassen und Objekte machen. Sie arbeiten ausschließlich mit Objekten, die Sie bei Bedarf klonen. Diese Klone werden als Prototypen bezeichnet, und Io ist die erste und einzige prototypbasierte Sprache, die wir uns ansehen werden. Bei einer Prototypsprache ist jedes Objekt ein Klon eines existierenden Objekts, nicht einer Klasse. Io bringt Sie so nah an ein objektorientiertes Lisp heran, wie man es sich nur vorstellen kann. Es ist noch zu früh, um sagen zu können, ob Io nachhaltigen Einfluss haben wird, doch aufgrund der Einfachheit seiner Syntax ist es durchaus ein aussichtsreicher Kandidat. Die Bibliotheken zur Nebenläufigkeit (die Sie am dritten Tag kennenlernen werden) sind gut durchdacht, und die Nachrichtensemantik ist elegant und leistungsfähig. Reflexion findet sich überall.
2
Googeln Sie stattdessen Io Sprache.
Tag 1: Blaumachen und rumhängen 61
Aufwärmen Lassen Sie uns den Interpreter öffnen und mit der Party beginnen. Sie finden ihn unter http://iolanguage.com. Laden Sie ihn herunter und installieren Sie ihn. Öffnen Sie den Interpreter durch Eingabe von io und geben Sie ein traditionelles „Hallo, Welt“-Programm ein: Io> "Hi ho, Io" print Hi ho, Io==> Hi ho, Io
Sie können genau sagen, was hier vor sich geht. Sie senden die Nachricht print an den String "Hi ho, Io". Empfänger (Receiver) stehen links, Nachrichten (Messages) rechts. Syntaktischen Zucker werden Sie kaum finden. Sie senden schlicht Nachrichten an Objekte. Bei Ruby erzeugen Sie ein neues Objekt, indem Sie new für irgendeine Klasse aufrufen. Sie haben neue Arten von Objekten angelegt, indem Sie eine Klasse definiert haben. Io unterscheidet nicht dazwischen. Sie legen neue Objekte an, indem Sie vorhandene Objekte klonen. Das existierende Objekt ist ein Prototyp: batate$ io Io 20090105 Io> Vehicle := Object clone ==> Vehicle_0x1003b61f8: type = "Vehicle"
Object ist das Stammobjekt. Wir senden die clone-Nachricht, die ein neues Objekt zurückgibt. Wir weisen dieses Objekt Vehicle zu. Hier ist Vehicle keine Klasse. Es ist auch keine Schablone zur Generierung von Objekten. Es ist ein Objekt, das auf dem Object-Prototyp basiert. Lassen Sie uns mit ihm arbeiten: Io> Vehicle description := "Something to ==> Something to take you places
take
you places"
Objekte besitzen sogenannte Slots. Stellen Sie sich eine Ansammlung von Slots als Hash vor: Sie greifen auf jeden Slot mit einem Schlüssel zu. Sie können := verwenden, um einem Slot etwas zuzuweisen. Wenn der Slot nicht existiert, legt Io ihn an. Sie können auch = für die Zuweisung verwenden. Wenn der Slot nicht existiert, löst Io eine Ausnahme aus. Wir haben gerade einen Slot namens description angelegt. Io> Vehicle description = "Something to take you far ==> Something to take you far away Io> Vehicle nonexistingSlot = "This won't work." Exception: Slot nonexistingSlot not found. Must define slot using := operator before updating.
away"
62 Kapitel 3: Io --------message 'updateSlot' in 'Command Line' on line 1
Sie können den Wert eines Slots abrufen, indem Sie den Namen des Slots an das Objekt senden: Io> Vehicle description ==> Something to take you far away
Tatsächlich ist ein Objekt nur wenig mehr als eine Sammlung von Slots. Wir können uns die Namen aller Slots in Vehicle so ansehen: Io> Vehicle slotNames ==> list("type", "description")
Wir senden die Methode slotNames an Vehicle und erhalten eine Liste der Slotnamen zurück. Es gibt zwei Slots. Den description-Slot kennen Sie bereits, aber es gibt auch einen type-Slot. Jedes Objekt unterstützt type: Io> ==> Io> ==>
Vehicle type Vehicle Object type Object
Object
Vehicle description
Car Instance
ferrari description: Something...
Abbildung 3.1: Ein objektorientiertes Design Wir gehen gleich noch genauer auf Typen ein. Im Moment reicht es zu wissen, dass type die Art von Objekt repräsentiert, mit der Sie arbeiten. Denken Sie daran, das ein Typ ein Objekt ist, keine Klasse. Was wir bisher wissen:
Tag 1: Blaumachen und rumhängen 63 앫
Sie erzeugen Objekte durch das Klonen anderer Objekte.
앫
Objekte sind Ansammlungen von Slots.
앫
Sie erhalten den Wert eines Slots, indem Sie eine Nachricht senden.
Sie können bereits sehen, das Io einfach ist und Spaß macht. Doch lehnen Sie sich zurück. Wir haben nur an der Oberfläche gekratzt. Sehen wir uns die Vererbung an.
Objekte, Prototypen und Vererbung In diesem Abschnitt wollen wir uns mit Vererbung beschäftigen. Gegeben ist ein Auto (car), das auch ein Vehikel (vehicle) ist. Wie würden Sie nun ein ferrari-Objekt modellieren, das eine Instanz von car ist. Bei einer objektorientierten Sprache würden Sie so etwas wie in Abbildung 3.1 machen. Sehen wir uns an, wie man das gleiche Problem mit einer Prototypsprache löst. Wir werden einige zusätzliche Objekte benötigen, also erzeugen wir welche: Io> Car := Vehicle clone ==> Car_0x100473938: type ="Car" Io> ==> Io> ==>
Car slotNames list("type") Car type Car
In Io-Lesart haben wir ein neues Objekt namens Car erzeugt, indem wir die Nachricht clone an den Vehicle-Prototyp gesendet haben. Lassen Sie uns description an Car senden: Io> Car description ==> Something to take you far away
Es gibt keinen description-Slot in Car. Io leitet die description-Nachricht einfach an den Prototyp weiter und findet den Slot in Vehicle. Das ist total simpel, aber sehr leistungsfähig. Lassen Sie uns ein weiteres car -Objekt erzeugen, das wir diesmal aber ferrari zuweisen: Io> ferrari := Car clone ==> Car_0x1004f43d0: Io> ferrari slotNames ==> list()
64 Kapitel 3: Io Aha! Es gibt keinen type-Slot. Per Konvention beginnen Typen in Io mit Großbuchstaben. Wenn Sie den type-Slot nun aufrufen, erhalten Sie den Typ des Prototyps: Io> ferrari type ==> Car
So funktioniert das Objektmodell von Io. Objekte sind einfach Container für Slots. Sie rufen einen Slot ab, indem Sie seinen Namen an ein Objekt senden. Gibt es den Slot nicht, ruft Io den Parent auf. Mehr gibt es nicht zu verstehen. Es gibt keine Klassen und Metaklassen. Es gibt keine Interfaces oder Module. Es gibt nur Objekte, wie Abbildung 3.2 deutlich macht. Typen in Io dienen nur der Bequemlichkeit. Was die Schreibung angeht, ist ein Objekt ein Typ, wenn es mit einem Großbuchstaben beginnt, und dann setzt Io den type-Slot. Alle Klone dieses Typs, die mit einem Kleinbuchstaben beginnen, rufen einfach den type-Slot des Parent auf. Typen sind einfach nur Werkzeuge, die dem Io-Programmierer dabei helfen, den Code besser zu organisieren. Object
Vehicle Prototype: Object Description: Something to take you far away
Car Prototype: Vehicle
ferrari Prototype: Car
Abbildung 3.2: Vererbung in IO
Tag 1: Blaumachen und rumhängen 65 Wenn ferrari ein Typ sein soll, müssen Sie ihn mit einem Großbuchstaben anfangen lassen: Io> Ferrari := Car clone ==> Ferrari_0x9d085c8: type = "Ferrari" Io> Ferrari ==> Ferrari Io> ==> Io> ==> Io>
type
Ferrari slotNames list("type") ferrari slotNames list()
Beachten Sie, dass ferrari keinen type-Slot besitzt, Ferrari hingegen schon. Wir verwenden eine einfache Kodierungskonvention anstelle eines Sprachfeatures, um Typen von Instanzen zu unterscheiden. In anderen Fällen verhalten sie sich gleich. Bei Ruby und Java bilden Klassen Schablonen zur Erzeugung von Objekten. bruce = Person.new erzeugt ein neues person-Objekt aus der Person-Klasse. Eine Klasse und ein Objekt sind völlig verschiedene Entitäten. Nicht so in Io. bruce := Person clone erzeugt einen Klon namens bruce aus dem Prototyp namens Person. Sowohl bruce als auch Person sind Objekte. Person ist ein Typ, weil es einen type-Slot besitzt. In den meisten anderen Fällen ist Person mit bruce identisch. Wenden wir uns nun dem Verhalten zu.
Methoden In Io können Sie Methoden einfach auf diese Weise erzeugen: Io> method("So, you've come for an argument." println) ==> method( "So, you've come for an argument." println )
Eine Methode ist ein Objekt, genau wie jeder andere Objekttyp auch. Sie können seinen Typ bestimmen: Io> method() type ==> Block
Da eine Methode ein Objekt ist, können Sie sie einem Slot zuweisen: Io> Car drive := method("Vroom" ==> method( "Vroom" println
println)
66 Kapitel 3: Io ) Io> ferrari drive Vroom ==> Vroom
Ob Sie es glauben oder nicht, Sie kennen jetzt die organisatorischen Grundprinzipien von Io. Machen Sie es sich klar: Sie kennen die grundlegende Syntax. Sie können Typen und Objekte definieren. Sie können ein Objekt um Daten und Verhaltensweisen erweitern, indem Sie seinen Slots Inhalte zuweisen. Für alles andere muss man sich mit den Bibliotheken auskennen. Lassen Sie uns ein wenig tiefer graben. Sie können den Inhalt der Slots, egal ob Variablen oder Methoden, so abrufen: Io> ferrari getSlot("drive") ==> method( "Vroom" println )
getSlot liefert den Parent-Slot zurück, wenn der Slot nicht existiert: Io> ferrari getSlot("type") ==> Car
Sie können den Prototyp eines Objekts ermitteln: Io> ferrari proto ==> Car_0x100473938: drive = method(...) type = "Car" Io> Car proto ==> Vehicle_0x1003b61f8: description = "Something to take you far away" type = "Vehicle"
Das sind die Prototypen, die Sie zum Klonen von ferrari und Car verwendet haben. Sie sehen auch ihre Slots. Es gibt einen Master-Namensraum namens Lobby, der alle genannten Objekte enthält. Alle Zuweisungen in der Konsole (und ein paar weitere) finden sich in Lobby. Sie können sich das so ansehen: Io> Lobby ==> Object_0x1002184e0: Car = Car_0x100473938 Lobby = Object_0x1002184e0 Protos = Object_0x1002184e0 Vehicle = Vehicle_0x1003b61f8 exit = method(...) ferrari = Car_0x1004f43d0 forward = method(...)
Tag 1: Blaumachen und rumhängen 67 Sie sehen die exit-Implementierung, forward, Protos und die von uns definierten Dinge. Das Prototyp-Programmierparadigma sollte damit klar geworden sein. Hier die elementaren Regeln: 앫
Alles ist ein Objekt.
앫
Jede Interaktion mit einem Objekt ist eine Nachricht.
앫
Sie instanziieren keine Klassen, sondern klonen andere Objekte, die als Prototypen bezeichnet werden.
앫
Objekte merken sich ihre Prototypen.
앫
Objekte besitzen Slots.
앫
Slots enthalten Objekte, auch Methodenobjekte.
앫
Eine Methode gibt den Wert in einem Slot zurück oder ruft die Methode in einem Slot auf.
앫
Kann ein Objekt nicht auf eine Nachricht reagieren, sendet es sie an ihren Prototyp.
Und das war es eigentlich auch schon. Da Sie jeden Slot und jedes Objekt sehen und verändern können, ist eine ziemlich raffinierte Metaprogrammierung möglich. Doch zuerst müssen Sie das nächste wichtige Element kennenlernen: Collections.
Listen und Maps Io kennt verschiedene Collection-Typen. Eine Liste ist eine geordnete Collection von Objekten jedweden Typs. List ist der Prototyp für alle Listen, und Map ist der Prototyp für Schlüssel/Wert-Paare wie den Ruby-Hash. So erzeugen Sie eine Liste: Io> toDos := list("find my car", "find Continuum Transfunctioner") ==> list("find my car", "find Continuum Transfunctioner") Io> toDos ==> 2
size
Io> toDos append("Find a present") ==> list("find my car", "find Continuum Transfunctioner", "Find a present")
Es gibt eine Kurzform zur Darstellung von Listen. Object unterstützt die list-Methode, die aus ihren Argumenten eine Liste aufbaut.
68 Kapitel 3: Io Mithilfe von list können Sie bequem Listen erzeugen: Io> list(1, 2, 3, 4) ==> list(1, 2, 3, 4)
List besitzt außerdem praktische mathematische Methoden sowie
Methoden, die Listen wie andere Datentypen (etwa Stacks) behandeln: Io> list(1, 2, 3, 4) ==> 2.5
average
Io> list(1, 2, 3, 4) ==> 10
sum
Io> list(1, 2, 3) ==> 2
at(1)
Io> list(1, 2, 3) append(4) ==> list(1, 2, 3, 4) Io> list(1, 2, 3) ==> 3
pop
Io> list(1, 2, 3) prepend(0) ==> list(0, 1, 2, 3) Io> list() isEmpty ==> true
Die andere wichtige Collection-Klasse in Io ist die Map. Io-Maps sind wie Ruby-Hashes. Da es keinen syntaktischen Zucker gibt, arbeitet man über eine API mit ihnen, die wie folgt aussieht: Io> elvis := Map clone ==> Map_0x115f580: Io> elvis atPut("home", "Graceland") ==> Map_0x115f580: Io> elvis at("home") ==> Graceland Io> elvis atPut("style", ==> Map_0x115f580:
"rock and roll")
Io> elvis asObject ==> Object_0x11c1d90: home = "Graceland" style = "rock and roll" Io> elvis asList ==> list(list("style", "rock and roll"), list("home", "Graceland")) Io> elvis keys ==> list("style", "home")
Tag 1: Blaumachen und rumhängen 69 Io> elvis size ==> 2
Im Grunde ähnelt ein Hash strukturell einem Io-Objekt, bei dem die Schlüssel Slots sind, die an bestimmte Werte gebunden sind. Die Kombination aus Slots, die schnell in Objekte umgewandelt werden können, ist sehr interessant. Wo Sie jetzt die grundlegenden Collections kennen, wollen wie sie auch benutzen. Wir müssen Kontrollstrukturen einführen, und die sind von Booleschen Werten abhängig.
true, false, nil und Singletons Ios Bedingungen ähneln stark denen anderer objektorientierter Sprachen. Hier sehen Sie einige von ihnen: Io> ==> Io> ==> Io> ==> Io> ==> Io> ==> Io> ==> Io> ==> Io> ==> Io> ==>
4 < 5 true 4 <= 3 false true and false false true and true true true or true true true or false true 4 < 5 and 6 > 7 false true and 6 true true and 0 true
Das ist recht einfach. Beachten Sie, dass 0 wie bei Ruby wahr (true) ist, nicht falsch (false) wie bei C. Was ist also true? Io> true proto ==> Object_0x200490: = Object_() != = Object_!=() ... Io> ==> Io> ==> Io> ==>
true clone true false clone false nil clone nil
70 Kapitel 3: Io Ah, das ist interessant! true, false und nil sind Singletons. Sie zu klonen liefert einfach nur deren Singleton-Wert zurück. Das zu erreichen ist recht einfach. Sie erzeugen ihren eigenen Singleton wie folgt: Io> Highlander := Object clone ==> Highlander_0x378920: type = "Highlander" Io> Highlander clone := Highlander ==> Highlander_0x378920: clone = Highlander_0x378920 type = "Highlander"
Wir haben die clone-Methode einfach so definiert, das sie Highlander zurückgibt, statt die Requests im Baum weiterzuleiten, bis sie schließlich zu Object gelangen. Wenn Sie nun Highlander verwenden, erhalten Sie das folgende Verhalten: Io> Highlander clone ==> Highlander_0x378920: clone = Highlander_0x378920 type = "Highlander" Io> fred := Highlander clone ==> Highlander_0x378920: clone = Highlander_0x378920 type = "Highlander" Io> mike := Highlander clone ==> Highlander_0x378920: clone = Highlander_0x378920 type = "Highlander" Io> fred == ==> true
mike
Die beiden Klone sind gleich. Das ist aber nicht generell so: Io> one := Object clone ==> Object_0x356d00: Io> two := Object clone ==> Object_0x31eb60: Io> one == ==> false
two
Nun, es kann nur einen Highlander geben. Manchmal kann Io Ihnen ein Bein stellen. Diese Lösung ist einfach und elegant, wenn auch etwas unerwartet. Wir haben viele Informationen ausgelassen, doch Sie wissen jetzt genug, um einige radikale Dinge anzustellen, etwa die clone-Methode eines Objekts so zu verändern, dass sich ein Singleton ergibt.
Tag 1: Blaumachen und rumhängen 71 Doch Vorsicht! Egal ob Sie sie super oder schrecklich finden, müssen Sie zugeben, dass Io interessant ist. Genau wie zu Ruby kann sich auch zu Io eine Hassliebe entwickeln. Sie können jeden Slot in jedem Objekt ändern, selbst diejenigen, die die Sprache definieren. Hier sehen Sie ein Beispiel, dass Sie nicht nachmachen sollten: Object clone := "hosed"
Da Sie die clone-Methode für Object überschrieben haben, können keine Objekte mehr erzeugt werden. Sie können das nicht korrigieren. Sie können nur den Prozess abschießen. Aber Sie können auch in kürzester Zeit einige recht erstaunliche Verhaltensweisen hervorzaubern. Da Sie vollständigen Zugriff auf die Slots aller Objekte besitzen, können Sie domänenspezifische Sprachen mit einigen wenigen, faszinierenden Zeilen Code erzeugen. Bevor wir das bis hierhin Gesehene zusammenfassen, wollen wir hören, was der Erfinder der Sprache zu sagen hat.
Ein Interview mit Steve Dekorte Steve Dekorte ist unabhängiger Berater in der Gegend von San Francisco. Er erfand Io im Jahr 2002. Ich hatte das Vergnügen, ihn zu seinen Erfahrungen bei der Entwicklung von Io zu interviewen. Bruce Tate: Warum haben Sie Io geschrieben? Steve Dekorte: Im Jahr 2002 hatte mein Freund Dru Nelson eine Sprache namens Cel (inspiriert durch Self) geschrieben und mich um Feedback zu seiner Implementierung gebeten. Ich hatte das Gefühl, dass ich nicht genug von der Funktionsweise von Programmiersprachen verstand, um etwas Nützliches beitragen zu können, also begann ich damit, eine kleine Sprache zu entwickeln, um sie besser zu verstehen. Daraus wurde Io. Bruce Tate: Was mögen Sie an ihr am meisten? Steve Dekorte: Ich mag die einfache und konsistente Syntax und Semantik. Sie helfen dabei, zu verstehen, was vor sich geht. Man kann die Grundlagen sehr schnell lernen. Ich habe ein furchtbares Gedächtnis. Ich vergesse ständig die Syntax und die seltsamen semantischen Regeln von C und muss sie nachschlagen. (Anmerkung: Steve hat Io in C implementiert.) Das war etwas, was ich nicht tun müssen wollte, wenn ich Io verwende.
72 Kapitel 3: Io Zum Beispiel können Sie sich Code wie people select(age > 20) map(address) println ansehen und haben eine ganz gute Vorstellung davon, was passiert. Sie filtern eine Liste von Leuten nach ihrem Alter, ermitteln ihre Adresse und geben sie aus. Wenn man die Semantik genug vereinfacht, wird das Ganze flexibler. Sie können damit beginnen, Dinge aufzubauen, die Sie noch gar nicht absehen konnten, als Sie die Sprache implementierten. Hier ein Beispiel. Es gibt puzzleartige Videospiele, die eine Lösung erwarten, und es gibt welche mit offenem Ende. Die mit dem offenen Ende machen mehr Spaß, weil man damit Dinge machen kann, die die Entwickler des Spiels gar nicht im Sinn hatten. Io ist genau so. Andere Sprachen kennen syntaktische Kürzel. Das führt zu zusätzlichen Parsing-Regeln. Wenn Sie in einer Sprache programmieren, müssen Sie den Parser im Kopf haben. Je komplizierter eine Sprache ist, desto mehr vom Parser müssen Sie im Kopf haben. Je mehr Arbeit ein Parser erledigen muss, desto mehr Arbeit haben Sie. Bruce Tate: Wo sind die Beschränkungen von Io? Steve Dekorte: Der Preis für Ios Flexibilität ist, dass es bei vielen gängigen Aufgaben langsamer ist. Nichtsdestotrotz hat es auch einige Vorteile (wie Koroutinen, asynchrone Sockets und SIMD-Unterstützung), die es viel schneller machen als selbst C-Anwendungen mit traditioneller Thread-pro-Socket-Nebenläufigkeit oder Nicht-SIMD-Vektoroperationen. Es gab auch einige Beschwerden, dass die fehlende Syntax eine schnelle visuelle Überprüfung erschwere. Ich hatte ähnliche Probleme mit Lisp, weshalb ich diese Beschwerden nachvollziehen kann. Zusätzliche Syntax ermöglicht schnelles Lesen, aber üblicherweise gewöhnen sich die Leute daran. Bruce Tate: Was ist der seltsamste Ort, an dem Sie Io im Einsatz gesehen haben? Steve Dekorte: Über die Jahre habe ich Gerüchte über Io in einem Satelliten, in einer Router-Konfigurationssprache und als ScriptingSprache für Videospiele gehört. Pixar verwendet es auch. Die haben einen Blogeintrag darüber geschrieben. Es war ein harter erster Tag. Daher ist es Zeit für ein kleine Pause. Nehmen Sie sich eine Auszeit und probieren Sie etwas von dem, was Sie gelernt haben, in der Praxis aus.
Tag 1: Blaumachen und rumhängen 73
Was wir am ersten Tag gelernt haben Sie haben sich schon durch ein gutes Stück Io gearbeitet. Sie wissen schon recht viel über den grundlegenden Charakter von Io. Diese Prototypsprache hat eine sehr einfache Syntax, mit deren Hilfe Sie neue Basiselemente selbst aufbauen können. Selbst Kernelementen fehlt der einfachste syntaktische Zucker. In mancher Weise wird das Lesen der Syntax durch diesen Minimalansatz für Sie schwieriger. Eine minimale Syntax hat aber auch einige Vorteile. Da syntaktisch nicht allzu viel passiert, müssen Sie auch keine speziellen Regeln oder Ausnahmen lernen. Sobald Sie wissen, wie man einen Satz liest, können Sie alle lesen. Sie können die Lernzeit in Richtung des Vokabulars verlagern. Ihre Aufgaben als Lehrling haben sich deutlich vereinfacht: 앫
Sie müssen einige wenige, grundlegende syntaktische Regeln verstehen.
앫
Sie müssen wissen, was Nachrichten sind und wie sie funktionieren.
앫
Sie müssen wissen, was Prototypen sind und wie sie funktionieren.
앫
Sie müssen wissen, was Bibliotheken sind und wie sie funktionieren.
Tag 1: Selbststudium Wenn Sie nach Hintergrundinformationen zu Io suchen, wird es etwas schwieriger, Antworten zu finden, weil Io so viele verschiedene Bedeutungen hat. Ich empfehle, nach Io Sprache zu googeln. Finden Sie Folgendes: 앫
einige Io-Beispielprobleme,
앫
eine Io-Community, die Fragen beantwortet, und
앫
einen Style-Guide mit Io-Idiomen.
Beantworten Sie folgende Fragen: 앫
Evaluieren Sie 1 + 1 und dann 1 + "one". Ist Io stark oder schwach typisiert? Untermauern Sie Ihre Antwort mit Code.
앫
Ist 0 wahr oder falsch? Was ist mit dem Leerstring? Ist nil wahr oder falsch? Untermauern Sie Ihre Antwort mit Code.
74 Kapitel 3: Io 앫
Wie können Sie die von einem Prototyp unterstützten Slots ermitteln?
앫
Was ist der Unterschied zwischen = (Gleich), := (DoppelpunktGleich) und ::= (Doppelpunkt-Doppelpunkt-Gleich)? Wann benutzen Sie was?
Machen Sie Folgendes: 앫
Führen Sie ein Io-Programm aus einer Datei aus.
앫
Führen Sie den Code in einem Slot über dessen Namen aus.
Spielen Sie ein wenig mit Slots und Prototypen herum. Sorgen Sie dafür, dass Sie wissen, wie Prototypen funktionieren.
3.3
Tag 2: Der Würstchenkönig Denken Sie kurz an Ferris Bueller zurück. Im Film stellte sich der gutbürgerliche High-School-Schüler in einem klassischen Bluff als Würstchenkönig von Chicago dar. Er bekam einen großen Tisch in einem guten Restaurant, weil er bereit war, die Regeln zu beugen. Wenn Sie einen Java-Hintergrund haben und finden, dass das auch gut so ist, denken Sie darüber nach, was hätte passieren können: Zu viel Freiheit ist nicht immer eine gute Sache. Bueller hätte es wahrscheinlich verdient gehabt, hinausgeworfen zu werden. Bei Io müssen Sie sich ein wenig entspannen und die Stärke zu Ihrem Vorteil nutzen. Wenn Sie einen Perl-Hintergrund haben, wird Ihnen Buellers Bluff einfach wegen des Ergebnisses gefallen haben. Wenn Sie alles grundsätzlich schnell und locker angehen, werden Sie sich ein wenig zurücknehmen und etwas Selbstdisziplin üben müssen. Am zweiten Tag werden Sie sehen, wie man Ios Slots und Nachrichten benutzen kann, um die Grundverhaltensweisen festzulegen.
Bedingungen und Schleifen Alle Bedingungsanweisungen in Io sind ohne jedweden syntaktischen Zucker implementiert. Sie werden sehen, dass sie einfach zu verstehen und zu merken sind, aber auch etwas schwerer zu lesen, da es wenig syntaktische Hinweise gibt. Eine Endlosschleife zu erzeugen, ist einfach. Drücken Sie Control+C, um sie zu unterbrechen: Io> loop("getting dizzy..." println) getting dizzy... getting dizzy...
Tag 2: Der Würstchenkönig 75 ... getting dizzy.^C IoVM: Received signal. Setting interrupt flag. ...
Schleifen sind oft für die verschiedenen Nebenläufigkeits-Konstrukte nützlich, aber normalerweise werden Sie eines der bedingten Schleifenkonstrukte verwenden wollen, etwa die while-Schleife. Eine whileSchleife verlangt eine Bedingung und eine Nachricht. Denken Sie daran, dass ein Semikolon zwei unterschiedliche Nachrichten verkettet: Io> i := 1 ==> 1 Io> while(i <= 11, i println; i = i + 1 2 ... 10 11 This one goes up to 11
1); "This one goes up to 11" println
Sie können das auch mit einer for -Schleife erreichen. Die for -Schleife verlangt den Namen des Zählers, den Startwert, den Endwert, ein optionales Inkrement und eine Nachricht mit Absender. Io> for(i, 1, 11, i println); 1 2 ... 10 11 This one goes up to 11 ==> This one goes up to 11
"This one goes
up to
11"
println
Und mit optionalem Inkrement: Io> for(i, 1, 11, 2, i println); 1 3 5 7 9 11 This one goes up to 11 ==> This one goes up to 11
"This one goes
up to
11"
println
Tatsächlich ist oft eine beliebige Anzahl von Parametern möglich. Ist Ihnen aufgefallen, dass der optionale Parameter der dritte war? Io erlaubt Ihnen, zusätzliche Parameter anzuhängen. Das mag bequem erscheinen, doch Sie müssen vorsichtig sein, da kein Compiler den Babysitter spielt:
76 Kapitel 3: Io Io> 1 2 ==> Io> 2 ==>
for(i,
1, 2, 1, i println, "extra argument")
2 for(i,
1, 2, i println, "extra argument")
extra argument
In der ersten Form ist „extra argument“ tatsächlich „extra“. In der zweiten Form haben sie das optionale Inkrement-Argument ausgelassen, wodurch unterm Strich alles nach links verschoben wurde. Ihr „extra argument“ ist nun eine Nachricht, und Sie arbeiten in Schritten von i println, was i zurückgibt. Wenn diese Codezeile tief in einem komplexen Paket begraben ist, hat sich Io (bildlich gesehen) gerade in Ihrem Auto übergeben. Manchmal muss man das Schlimme wie das Gute hinnehmen. Io gibt Ihnen Freiheit, und Freiheit tut manchmal weh. Die Kontrollstruktur if ist als Funktion der Form if(bedingung, wahrcode, falsch-code) implementiert. Die Funktion führt wahr-code aus, wenn bedingung wahr ist, ansonsten wird falsch-code ausgeführt: Io> if(true, "It is true.", "It is false.") ==> It is true. Io> if(false) then("It is true.") else("It is false.") ==> nil Io> if(false) then("It is true." println) else("It is false." println) It is false. ==> nil
Sie haben jetzt etwas Zeit mit Kontrollstrukturen verbracht. Jetzt sollten wir sie zur Entwicklung eigener Operatoren einsetzen.
Operatoren Wie objektorientierte Sprachen erlauben viele Prototypsprachen syntaktischen Zucker, um Operatoren zu ermöglichen. Dabei handelt es sich um besondere Methoden wie + und /, die eine spezielle Form aufweisen. Bei Io können Sie sich die Operatortabelle direkt ansehen: Io> OperatorTable ==> OperatorTable_0x100296098: Operators 0 ?@ @@ 1 ** 2 % * / 3 + 4 << >> 5 < <=> >= 6 != == 7 &
Tag 2: Der Würstchenkönig 77 8 9 10 11 12 13 14
^ | && and or || .. = &= *= += -= /= <<= >>= ^= |= return
Assign Operators ::= newSlot := setSlot = updateSlot To add a new operator: OperatorTable addOperator("+", 4) and implement the + message. To add a new assign operator: OperatorTable addAssignOperator( "=", "updateSlot") and implement the updateSlot message.
Sie können erkennen, dass eine Zuweisung eine andere Art von Operator ist. Die Zahl links zeigt den Vorrang. Näher an 0 liegende Argumente binden zuerst. Wie Sie sehen, wird + vor == evaluiert, und * vor + (genau wie Sie es erwarten würden). Sie können den Vorrang mit () überschreiben. Lassen Sie uns einen Exklusiv-ODER-Operator definieren. Unser xor gibt true zurück, wenn genau eines der Argumente true ist, andernfalls false. Zuerst fügen wir den Operator in die Tabelle ein: Io> OperatorTable addOperator("xor", ==> OperatorTable_0x100296098: Operators ... 10 && and 11 or xor || 12 .. ...
11)
Wie Sie sehen, steht der neue Operator an der richtigen Stelle. Als Nächstes müssen wir die xor -Methode für true und false implementieren: Io> true xor := method(bool, if(bool, false, true)) ==> method(bool, if(bool, false, true) ) Io> false xor := method(bool, if(bool, true, false)) ==> method(bool, if(bool, true, false) )
Wir gehen hier mit dem Holzhammer vor, um die Konzepte einfach zu halten. Unser Operator verhält sich genau, wie Sie es erwarten: Io> true xor true ==> false
78 Kapitel 3: Io Io> ==> Io> ==> Io> ==>
true xor false true false xor true true false xor false false
Sobald alles erledigt ist, wird true xor true vom Parser als true xor(true) verarbeitet. Die Methode in der Operatortabelle bestimmt den Vorrang und die vereinfachte Syntax. Zuweisungsoperatoren stehen in einer anderen Tabelle und arbeiten etwas anders. Zuweisungsoperatoren arbeiten als Nachrichten. Sie werden ein Beispiel in Abschnitt , Domänenspezifische Sprachen auf Seite 83 in Aktion sehen. Für den Augenblick ist das alles, was wir über Operatoren sagen wollen. Machen wir mit Nachrichten weiter, wo Sie lernen werden, eigene Kontrollstrukturen zu implementieren.
Nachrichten Als ich dieses Kapitel durchging, half mit einer der Io-Committer durch einen Augenblick der Frustration. Er sagte: „Bruce, es gibt etwas, was du an Io verstehen musst. Fast alles in eine Nachricht.“ Wenn Sie sich Io-Code ansehen, sind alles Nachrichten, außer Kommentarmarkern und Kommata (,) zwischen den Argumenten. Alles. Io gut zu erlernen, bedeutet zu lernen, wie man es über den normalen Aufruf hinaus manipuliert. Eine der wesentlichen Fähigkeiten der Sprache ist die Nachrichten-Reflexion. Sie können sich jede Charakteristik jeder Nachricht ansehen und entsprechend handeln. Eine Nachricht besteht aus drei Komponenten: dem Sender, dem Ziel und den Argumenten. Bei Io sendet der Sender eine Nachricht an ein Ziel. Das Ziel führt die Nachricht aus. Die call-Methode gibt Ihnen Zugriff auf die Metadaten aller Nachrichten. Lassen Sie uns ein paar Objekte erzeugen: postOffice, das Nachrichten erhält, und mailer, das diese ausliefert: Io> postOffice := Object clone ==> Object_0x100444b38: Io> postOffice packageSender := method(call sender) ==> method( call sender )
Tag 2: Der Würstchenkönig 79 Als Nächstes erzeuge ich den Mailer, um eine Nachricht zuzustellen: Io> mailer := Object clone ==> Object_0x1005bfda0: Io> mailer deliver := method(postOffice ==> method( postOffice packageSender )
packageSender)
Es gibt einen Slot, den deliver -Slot, der eine packageSender -Nachricht an postOffice sendet. Nun kann ich mailer eine Nachricht zustellen lassen: Io> mailer deliver ==> Object_0x1005bfda0: deliver = method(...)
Die deliver -Methode ist also das Objekt, das die Nachricht sendet. Das Ziel bestimmen wir so: Io> postOffice messageTarget := method(call target) ==> method( call target ) Io> postOffice messageTarget ==> Object_0x1004ce658: messageTarget = method(...) packageSender = method(...)
Ganz einfach. Das Ziel ist postOffice, wie Sie aus den Slotnamen ersehen können. Den ursprünglichen Namen der Nachricht und die Argumente bestimmen Sie wie folgt: Io> postOffice messageArgs := method(call message arguments) ==> method( call message arguments ) Io> postOffice messageName := method(call message name) ==> method( call message name ) Io> postOffice messageArgs("one", 2, :three) ==> list("one", 2, : three) Io> postOffice messageName ==> messageName
Io bietet also eine Reihe von Methoden, die die Reflexion von Nachrichten ermöglichen. Die nächste Frage lautet: Wann berechnet Io eine Nachricht? Die meisten Sprachen übergeben Argumente als Werte auf Stacks. Zum Beispiel berechnet Java zuerst jeden Wert eines Parameters und legt
80 Kapitel 3: Io diese Werte dann auf dem Stack ab. Io macht das nicht. Es übergibt die Nachricht selbst sowie den Kontext. Dann evaluiert der Empfänger die Nachricht. Sie können tatsächlich Kontrollstrukturen mit Nachrichten implementieren. Erinnern Sie sich an Ios if. Die Form ist if(boolescherAusdruck, wahrBlock, falschBlock). Nehmen wir nun an, Sie wollen ein unless implementieren. Das geht so: io/unless.io
unless := method( (call sender doMessage(call message argAt(0))) ifFalse( call sender doMessage(call message argAt(1))) ifTrue( call sender doMessage(call message argAt(2))) ) unless(1 == 2, write("One is not two\n"), write("one is two\n"))
Dieses kleine Beispiel ist wundervoll, also lesen Sie es aufmerksam. Stellen Sie sich doMessage wie Rubys eval vor, aber auf niedrigerer Ebene. Während Rubys eval einen String als Code evaluiert, führt doMessage eine beliebige Nachricht aus. Io interpretiert die Parameter der Nachricht, verzögert aber die Bindung und Ausführung. Bei einer typischen objektorientierten Sprache würde der Interpreter oder Compiler alle Argumente einschließlich der Codeblöcke berechnen und ihre Rückgabewerte auf dem Stack ablegen. Bei Io passiert genau das nicht. Nehmen wir an, dass Objekt westley sendet die Nachricht princessButtercup unless(trueLove, ("It is false" println), ("It is true" println)). Das Ergebnis ist der folgende (Programm-)Fluss:
1. Das Objekt westley sendet die obige Nachricht. 2. Io nimmt die interpretierte Nachricht und den Kontext (call sender, target und message) und schiebt sie auf den Stack. 3. Nun evaluiert princessButtercup die Nachricht. Dort gibt es keinen unless-Slot, Io geht also die Prototypenkette durch, bis es unless findet. 4. Io beginnt mit der Ausführung der unless-Nachricht. Zuerst führt Io call sender doMessage(call message argAt(0)) aus, also vereinfacht ausgedrückt westley trueLove. Falls Sie den Film Die Braut des Prinzen gesehen haben, wissen Sie, dass westley einen Slot namens trueLove besitzt und dessen Wert true ist. 5. Die Nachricht ist nicht falsch, also führen wir den dritten Codeblock aus, vereinfacht ausgedrückt westley ("It is true" println).
Tag 2: Der Würstchenkönig 81 Wir nutzen die Tatsache aus, dass Io die Argumente nicht ausführt, um Rückgabewerte zu berechnen, um die unless-Kontrollstruktur zu implementieren. Dieses System ist extrem mächtig. Bisher haben Sie eine Seite der Reflexion gesehen: Verhalten mit Nachrichtenreflexion. Die andere Seite der Gleichung ist der Zustand. Wir sehen uns den Zustand mit den Slots eines Objekts an.
Reflexion Io bietet Ihnen eine Reihe einfacher Methoden, mit deren Hilfe Sie erkennen können, was in den Slots vor sich geht. Hier sehen Sie einige davon in Aktion. Der Code erzeugt eine Reihe von Objekten und arbeitet sich dann durch die prototype-Kette, wozu er eine Methode namens ancestors benutzt: io/animals.io
Object ancestors := method( prototype := self proto if(prototype != Object, writeln("Slots of ", prototype type, "\n---------------") prototype slotNames foreach(slotName, writeln(slotName)) writeln prototype ancestors))
Animal := Object clone Animal speak := method( "ambiguous animal noise" println) Duck := Animal clone Duck speak := method( "quack" println) Duck walk := method( "waddle" println) disco := Duck clone disco ancestors
Der Code ist nicht allzu kompliziert. Zuerst legen wir einen Animal-Prototyp an und verwenden diesen dann, um eine Duck-Instanz (Ente) zu erzeugen, die eine speak-Methode besitzt. discos Prototyp ist Duck. Die ancestors-Methode gibt die Slots des Prototyps eines Objekts aus und ruft dann ancestors für den Prototyp aus. Denken Sie daran, dass ein Objekt mehr als einen Prototyp haben kann, aber diesen Fall behandeln wir hier nicht. Um Papier zu sparen, halten wir die Rekursion an, bevor alle Slots des Object-Prototyps ausgeben werden. Führen Sie das Programm mit io animals.io aus.
82 Kapitel 3: Io Hier sehen Sie die Ausgabe: Slots of Duck --------------speak walk type Slots of Animal --------------speak type
Die Ausgabe bietet keine Überraschungen. Jedes Objekt hat einen Prototyp, und diese Prototypen sind Objekte mit Slots. Bei Io besteht der Umgang mit Reflexion aus zwei Teilen. Im postOffice-Beispiel haben Sie die Nachrichtenreflexion gesehen. Objektreflexion bedeutet den Umgang mit Objekten und den Slots in diesen Objekten. Klassen spielen aber nirgends eine Rolle.
Was wir am zweiten Tag gelernt haben Wenn Sie mir immer noch folgen können, dürfte Tag 2 so eine Art Durchbruch gewesen sein. Sie sollten genug über Io wissen, um grundlegende Aufgaben mit etwas Unterstützung durch die Dokumentation lösen zu können. Sie wissen, wie man Entscheidungen trifft, Methoden definiert und Datenstrukturen sowie die grundlegenden Kontrollstrukturen benutzt. In den Übungen werden wir Io auf Herz und Nieren prüfen. Machen Sie sich mit Io vertraut. Sie sollten die Grundlagen wirklich beherrschen, wenn wir mit Io in die Bereiche Metaprogrammierung und Nebenläufigkeit vordringen.
Tag 2: Selbststudium Machen Sie Folgendes: 1. Eine Fibonacci-Folge beginnt mit zwei Einsen. Jede nachfolgende Zahl ist die Summe der zwei vorangegangenen Zahlen: 1, 1, 2, 3, 5, 8, 13, 21 und so weiter. Schreiben Sie ein Programm, das die nte Fibonacci-Zahl zurückgibt. fib(1) ist 1 und fib(4) ist 3. Als Zusatzaufgabe können Sie das Problem mit Rekursion und Schleifen lösen. 2. Wie würden Sie / verändern, damit es 0 zurückgibt, wenn der Nenner null ist?
Tag 3: Die Parade und andere sonderbare Orte 83 3. Schreiben Sie ein Programm, das alle Zahlen in einem zweidimensionalen Array aufsummiert. 4. Fügen Sie in eine Liste einen Slot namens myAverage ein, der den Durchschnitt aller Zahlen in einer Liste berechnet. Was passiert, wenn es keine Zahlen in der Liste gibt? (Zusatzaufgabe: Lösen Sie eine Io-Ausnahme aus, wenn ein Element der Liste keine Zahl ist.) 5. Entwickeln Sie einen Prototyp für eine zweidimensionale Liste. Die Methode dim(x, y) soll dabei eine Liste mit y Listen erzeugen, die jeweils x Elemente lang ist. set(x, y, wert) soll einen Wert setzen und get(x, y) einen Wert zurückgeben. 6. Zusatzaufgabe: Schreiben Sie eine Austauschmethode, für die (new_matrix get(y, x)) == matrix get(x, y) gilt. 7. Schreiben Sie die Matrix in eine Datei und lesen Sie die Matrix aus einer Datei. 8. Schreiben Sie ein Programm, das ihnen 10 Versuche gibt, um eine zufällige Zahl zwischen 1 und 100 zu erraten. Wenn Sie wollen, können Sie Tipps wie „wärmer“ oder „kälter“ ausgeben.
3.4
Tag 3: Die Parade und andere sonderbare Orte Meine ersten Tage mit Io waren frustrierend, doch nach ein paar Wochen kicherte ich wie ein Schulmädchen, wenn es mich mal wieder an einen unerwarteten Ort führte. Genau wie Ferris, der in den Nachrichten, am Baseballplatz und in der Parade auftaucht: überall, wo man ihn nicht erwartet. Letztlich bekam ich von Io genau was ich wollte, nämlich eine Sprache, die mein Denken veränderte.
Domänenspezifische Sprachen Fast jeder, der tief in Io verstrickt ist, bestätigt die Leistungsfähigkeit von Io im Bezug auf DSLs. Jeremy Tregunna, einer der Kern-Committer für Io, erzählte mir von einer Implementierung einer Teilmenge von C in Io, die aus nur rund 40 Codezeilen bestand! Da dieses Beispiel für unsere Ansprüche zu tief gehen würde, wollen wir uns etwas anderes aus Jeremys Schatzkiste ansehen. Es implementiert eine API, die eine interessante Syntax für Telefonnummern zur Verfügung stellt.
84 Kapitel 3: Io Nehmen wir an, Sie wollen Telefonnummern in dieser Form verarbeiten: { "Bob Smith": "5195551212", "Mary Walsh": "4162223434" }
Es gibt viele Ansätze, eine solche Liste anzugehen. Zwei, die mir in den Sinn kommen, sind das Parsing der Liste und ihre Interpretation. Parsing bedeutet, dass Sie ein Programm entwickeln, das die verschiedenen Elemente der Syntax erkennt und den Code in einer Struktur ablegt, die Io versteht. Diesem Problem können wir uns ein andermal widmen. Es macht viel mehr Spaß, diesen Code als Io-Hash zu interpretieren. Zu diesem Zweck müssen Sie Io anpassen. Wenn Sie fertig sind, akzeptiert Io diese Liste als gültige Syntax zum Aufbau von Hashes! Jeremy löste das Problem wie folgt (mit etwas Hilfe von Chris Kappler, der dieses Beispiel für die aktuelle Version von Io aufpeppte): io/phonebook.io
OperatorTable addAssignOperator(":", "atPutNumber") curlyBrackets := method( r := Map clone call message arguments foreach(arg, r doMessage(arg) ) r ) Map atPutNumber := method( self atPut( call evalArgAt(0) asMutable removePrefix("\"") removeSuffix("\""), call evalArgAt(1)) ) s := File with("phonebook.txt") openForReading contents phoneNumbers := doString(s) phoneNumbers keys println phoneNumbers values println
Der Code ist etwas komplexer als alles, was Sie bisher gesehen haben, aber Sie kennen die Grundbausteine. Sehen wir ihn uns genauer an: OperatorTable addAssignOperator(":", "atPutNumber")
Die erste Zeile fügt einen Operator in die Zuweisungsoperator-Tabelle von Io ein. Sobald ein : erkannt wird, verarbeitet Io es als atPutNumber. Io ist klar, dass das erste Argument ein Name (und somit ein String) und das zweite ein Wert ist. schlüssel : wert werden also als atPutNumber("schlüssel", wert) verarbeitet. Weiter geht’s:
Tag 3: Die Parade und andere sonderbare Orte 85 curlyBrackets := method( r := Map clone call message arguments foreach(arg, r doMessage(arg) ) r )
Der Parser ruft die curlyBrackets-Methode auf, sobald er geschweifte Klammern ({}) erkennt. Innerhalb dieser Methode erzeugen wir eine leere Map. Dann rufen wir call message arguments foreach(arg, r doMessage(arg)) für jedes Argument auf. Das ist eine ganz schön dicht gepackte Codezeile! Nehmen wir sie auseinander. Von links nach rechts gehend, nehmen wir call message (Teil des Codes zwischen den geschweiften Klammern) und gehen dann mit forEach jede Nummer der Liste durch. Für jeden Namen und jede Nummer führen wir r doMessage(arg) aus. So wird die erste Telefonnumer beispielsweise als r "Bob Smith": "5195551212" ausgeführt. Weil : in unserer Operatortabelle als atPutNumber steht, führen wir r atPutNumber("Bob Smith", "5195551212") aus. Das bringt uns zu Folgendem: Map atPutNumber := method( self atPut( call evalArgAt(0) asMutable removePrefix("\"") removeSuffix("\""), call evalArgAt(1)) )
Denken Sie daran, dass schlüssel : wert als atPutNumber("schlüssel", wert) verarbeitet wird. In unserem Fall ist der Schlüssel bereits ein String, weshalb wir die beiden Anführungszeichen entfernen. Sie können erkennen, dass atPutNumber einfach atPut für das Ziel (also self) aufruft und dabei die Anführungszeichen im ersten Argument entfernt. Da Nachrichten unveränderlich sind, müssen wir (um die Anführungszeichen entfernen zu können) die Nachricht in einen veränderlichen Wert umwandeln. Sie können den Code so verwenden: s := File with("phonebook.txt") openForReading contents phoneNumbers := doString(s) phoneNumbers keys println phoneNumbers values println
Ios Syntax ist leicht zu verstehen. Sie müssen nur wissen, was in den Bibliotheken passiert. In diesem Fall sehen Sie einige neue Bibliotheken. Die Nachricht doString evaluiert unsere Telefonnummer als Code. File ist ein Prototyp für die Arbeit mit Dateien, with legt einen Dateinamen fest und gibt ein Dateiobjekt zurück, openForReading öffnet die
86 Kapitel 3: Io Datei und gibt das Dateiobjekt zurück und contents liefert den Inhalt dieser Datei zurück. Dieser Code liest also das Telefonbuch ein und evaluiert es als Code. Dann definieren die Klammern eine Map. Jede Zeile in der Map "string1" : "string2" führt zu einem map atPut("string1", "string2"), und wir erhalten einen Hash mit Telefonnummern. Da
man bei Io alles von Operatoren bis zu den Symbolen, die die Sprache bilden, umdefinieren kann, können Sie DSLs ganz nach Bedarf aufbauen. Jetzt erkennen Sie vermutlich langsam, wie Sie Ios Syntax verändern können. Wie wäre es mit der dynamischen Veränderung des Verhaltens der Sprache? Das ist das Thema des nächsten Abschnitts.
Ios method_missing Sehen wir uns den Kontrollfluss noch einmal an. Das, was in einer gegebenen Nachricht passiert, ist fest in Object eingebrannt. Wenn Sie einem Objekt eine Nachricht senden, passiert Folgendes: 1. Die Argumente werden in umgekehrter Reihenfolge berechnet. Es gibt nur Nachrichten. 2. Name, Ziel und Sender der Nachricht werden bestimmt. 3. Es wird versucht, den Slot mit dem Namen der Nachricht im Ziel zu lesen. 4. Wenn der Slot existiert, werden die Daten zurückgeliefert oder die darin enthaltene Methode aufgerufen. 5. Wenn der Slot nicht existiert, wird die Nachricht an den Prototyp weitergegeben. Das sind die grundlegenden Mechanismen der Vererbung in Io. Normalerweise würden Sie damit nicht herumspielen. Aber Sie dürfen. Sie können die forward-Nachricht auf die gleiche Weise nutzen wie Rubys method_missing, doch der Einsatz ist etwas höher: Io kennt keine Klassen, wenn Sie also forward verändern, ändern Sie auch die Art und Weise, auf die Sie an die grundlegenden Verhaltensweisen von object gelangen. Das ist ein bisschen so, als würde man mit Äxten auf dem Hochseil jonglieren: ein cooler Trick, wenn man es beherrscht. Also lassen Sie uns loslegen!
Tag 3: Die Parade und andere sonderbare Orte 87 XML ist eine gute Möglichkeit, Daten mit einer hässlichen Syntax zu strukturieren. Sie möchten etwas entwickeln, dass es Ihnen ermöglicht, die XML-Daten als Io-Code darzustellen. Beispielsweise könnten Sie
Ein einfacher Absatz.
so ausdrücken wollen: body( p("Ein einfacher Absatz.") )
Wir wollen die neue Sprache LispML nennen. Wir werden Ios forward wie missing_method einsetzen. Hier der Code: io/builder.io
Builder := Object clone Builder forward := method( writeln("<", call message name, ">") call message arguments foreach( arg, content := self doMessage(arg); if(content type == "Sequence", writeln(content))) writeln("", call message name, ">")) Builder
ul( li("Io"), li("Lua"), li("JavaScript"))
Sehen wir uns das genauer an. Der Builder -Prototyp ist unser Arbeitspferd. Er überschreibt forward, um alle Methoden abzufangen. Zuerst gibt er einen öffnenden Tag aus. Dann nutzen wir ein wenig Nachrichtenreflexion. Ist die Nachricht ein String, erkennt Io sie als Sequenz, und Builder gibt den String ohne Anführungszeichen aus. Abschließend gibt Builder einen schließenden Tag aus. Die Ausgabe entspricht genau dem, was Sie erwarten:
- Io
- Lua
88 Kapitel 3: Io JavaScript
Ich muss zugeben, dass ich mir nicht sicher bin, ob LispML eine große Verbesserung gegenüber traditionellem XML darstellt, aber das Beispiel ist lehrreich. Sie haben gerade die Art und Weise komplett verändert, auf die die Vererbung bei einem Io-Prototyp funktioniert. Jede Instanz von Builder weist das gleiche Verhalten auf. Auf diese Weise können Sie eine neue Sprache mit Ios Syntax, aber völlig anderen Verhaltensweisen entwickeln. Dazu definieren Sie ein neues Object und lassen alle Prototypen auf ihm basieren. Sie können Object überschreiben, um Ihr neues Objekt zu klonen.
Nebenläufigkeit Io besitzt hervorragende Bibliotheken für Nebenläufigkeit. Die wesentlichen Komponenten sind Koroutinen, Aktoren und Futures.
Koroutinen Die Koroutine bildet die Grundlage für Nebenläufigkeit. Eine Koroutine bietet die Möglichkeit, die Ausführung eines Prozesses freiwillig zu unterbrechen und später wieder aufzunehmen. Stellen Sie sich eine Koroutine als Funktion mit mehreren Ein- und Ausstiegspunkten vor. Jedes yield hält einen Prozess an und übergibt die Kontrolle an einen anderen Prozess. Sie können Nachrichten mithilfe von @ oder @@ asynchron starten. Die erste Variante liefert ein Future zurück (mehr dazu später). Die zweite gibt nil zurück und startet die Nachricht in einem eigenen Thread. Betrachten Sie zum Beispiel folgendes Programm: io/coroutine.io
vizzini := Object clone vizzini talk := method( "Fezzik, are there rocks ahead?" println yield "No more rhymes now, I mean it." println yield) fezzik := Object clone fezzik rhyme := method( yield "If there are, we'll all be dead." println yield "Anybody want a peanut?" println)
Tag 3: Die Parade und andere sonderbare Orte 89 vizzini @@talk; fezzik @@rhyme Coroutine currentCoroutine pause
fezzik und vizzini sind unabhängige Instanzen von Object mit Koroutinen. Wir stoßen die Methoden talk und rhyme asynchron an. Sie wer-
den nebenläufig ausgeführt und geben die Kontrolle an bestimmten Stellen freiwillig per yield-Nachricht ab. Die letzte Unterbrechung wartet, bis alle asynchronen Nachrichten abgearbeitet sind, und endet dann. Koroutinen sind gut geeignet, wenn die Lösung kooperatives Multitasking verlangt. Bei unserem Beispiel können sich zwei Prozesse ganz nach Bedarf koordinieren, beispielsweise um wechselseitig Prosa vorzutragen: batate$ io code/io/coroutine.io Fezzik, are there rocks ahead? If there are, we'll all be dead. No more rhymes now, I mean it. Anybody want a peanut? Scheduler: nothing left to resume so we are exiting
Java und C-basierte Sprachen verwenden eine Nebenläufigkeits-Philosophie, die als präemptives Multitasking bezeichnet wird. Wenn Sie diese Nebenläufigkeits-Strategie mit Objekten kombinieren, die veränderliche Zustände aufweisen, landen Sie bei Programmen, die nur schwer vorherzubestimmen und fast nicht zu debuggen sind (zumindest nicht mit den Teststrategien, die momentan von den meisten Teams verwendet werden). Koroutinen sind anders. Mit Koroutinen können Anwendungen die Kontrolle freiwillig an geeigneten Orten abgeben. Ein verteilter Client könnte die Kontrolle abgeben, wenn er auf den Server wartet. Worker-Prozesse könnten eine Pause einlegen, nachdem Sie Queue-Elemente verarbeitet haben. Koroutinen sind die Grundbausteine für auf höherer Ebene angesiedelte Abstraktionen wie etwa Aktoren. Stellen Sie sich Aktoren als universelle Primitive für Nebenläufigkeit vor, die Nachrichten senden und verarbeiten sowie andere Aktoren erzeugen können. Die von einem Aktor empfangenen Nachrichten sind nebenläufig. Bei Io legt ein Aktor eine eingehende Nachricht in einer Queue ab und verarbeitet den Inhalt der Queue mithilfe von Koroutinen. Als Nächstes wollen wir uns Aktoren ansehen. Sie werden nicht glauben, wie einfach sie zu programmieren sind.
90 Kapitel 3: Io
Aktoren Aktoren haben gegenüber Threads einen riesigen theoretischen Vorteil. Ein Aktor verändert seinen eigenen Status und greift auf andere Aktoren nur über streng kontrollierte Queues zu. Threads können andere Zustände ohne Einschränkung verändern. Threads leiden unter einem Problem von Nebenläufigkeit, das man Race Condition nennt: Zwei Threads greifen gleichzeitig auf eine Ressource zu, was zu unvorhergesehenen Ergebnissen führen kann. Das ist das Schöne an Io: Wenn Sie eine asynchrone Nachricht an ein Objekt senden, wird es zu einem Aktor. Und fertig. Sehen wir uns ein einfaches Beispiel dafür an. Zuerst legen wir zwei Objekte namens faster und slower an: Io> slower := Object clone ==> Object_0x1004ebb18: Io> faster := Object clone ==> Object_0x100340b10:
Nun fügen wir für beide eine start-Methode hinzu: Io> slower start := method(wait(2); writeln("slowly")) ==> method( wait(2); writeln("slowly") ) Io> faster start := method(wait(1); writeln("quickly")) ==> method( wait(1); writeln("quickly") )
Wir können beide Methoden hintereinander in einer Zeile starten: Io> slower start; faster start slowly quickly ==> nil
Sie werden nacheinander ausgeführt, weil die erste Nachricht abgearbeitet sein muss, bevor die zweite loslegen kann. Doch wir können jedes Objekt in einem eigenen Thread starten, indem wir jeder Nachricht ein @@ voranstellen. Dann kehren sie sofort zurück und liefern nil: Io> slower @@start; faster quickly slowly
@@start; wait(3)
Wir haben am Ende ein zusätzliches wait eingefügt, damit alle Threads beendet werden, bevor das Programm zu Ende ist, aber das Ergebnis ist gut. Wir führen zwei Threads aus. Wir haben bei Objekte zu Aktoren gemacht, einfach indem wir ihnen asynchrone Nachrichten geschickt haben!
Tag 3: Die Parade und andere sonderbare Orte 91
Futures Ich möchte unsere Diskussion zur Nebenläufigkeit mit dem Konzept von Futures beschließen. Ein Future ist ein Ergebnisobjekt, das von einem asynchronen Nachrichtenaufruf sofort zurückgeliefert wird. Da die Verarbeitung der Nachricht etwas dauern kann, erhält das Future sein Ergebnis erst, wenn es zur Verfügung steht. Wenn Sie den Wert eines Future anfordern, bevor das Ergebnis verfügbar ist, blockiert der Prozess, bis der Wert verfügbar ist. Nehmen wir an, wir haben eine Methode, deren Ausführung sehr lange dauert: futureResult := URL with("http://google.com/") @fetch
Wir können die Methode ausführen und gleich etwas anderes machen, bis das Ergebnis verfügbar ist: writeln("Wir machen etwas anderes, während fetch im Hintergrund abtaucht ...") // ...
Dann kann ich den Future-Wert verwenden: writeln("Jetzt wird geblockt, bis das Ergebnis da ist.") // diese Zeile wird direkt ausgeführt writeln("Insgesamt ", futureResult size, " Bytes abgerufen") // jetzt wird beblockt, bis die Berechnung abgeschlossen ist // und Io den Wert ausgibt ==> 1955
Das futureResult-Codefragment liefert das Future-Objekt direkt zurück. Bei Io ist ein Future keine Proxy-Implementierung! Das Future blockiert, bis das Ergebnisobjekt verfügbar ist. Der Wert ist ein FutureObjekt, bis das Ergebnis verfügbar ist. Dann zeigen alle Instanzen des Wertes auf das Ergebnisobjekt. Die Konsole gibt den Stringwert der letzten Anweisung zurück. Futures bieten bei Io auch eine automatische Deadlock-Erkennung. Das ist ein netter Zug, und sie sind einfach zu verstehen und zu nutzen. Jetzt, wo Sie eine Vorstellung von Ios Nebenläufigkeit haben, besitzen Sie eine solide Grundlage zur Evaluierung der Sprache. Fassen wir Tag 3 zusammen, damit Sie Ihr Wissen in die Praxis umsetzen können.
Was Sie am dritten Tag gelernt haben In diesem Abschnitt haben Sie erfahren, wie man etwas nicht Triviales mit Io macht. Zuerst haben wir die Syntaxregeln gebeugt und eine neue Hash-Syntax mit geschweiften Klammern entwickelt. Wir haben einen
92 Kapitel 3: Io Operator in die Operatortabelle eingefügt und diesen mit Operationen für eine Hash-Tabelle verknüpft. Als Nächstes haben wir einen XMLGenerator aufgebaut, der method_missing zur Ausgabe von XML-Elementen verwendet. Danach haben wir ein wenig Code geschrieben, der Koroutinen für Nebenläufigkeit verwendet. Koroutinen unterscheiden sich von der Nebenläufigkeit in Sprachen wie Ruby, C und Java, weil die Threads nur ihren eigenen Zustand ändern können, was zu einem besser vorhersehbaren und verständlicheren Modell von Nebenläufigkeit führt. Darüber hinaus gibt es weniger blockierende Zustände, die zu Flaschenhälsen werden können. Wir haben ein paar asynchrone Nachrichten versendet, die unsere Prototypen zu Aktoren machen. Wir mussten dafür nicht mehr tun, als die Syntax unserer Nachrichten zu ändern. Zum Schluss haben wir uns noch kurz Futures angesehen und wie sie in Io funktionieren.
Tag 3: Selbststudium Machen Sie Folgendes:
3.5
앫
Erweitern Sie das XML-Programm um Leerzeichen, um die Einrückungsstruktur zu zeigen.
앫
Entwickeln Sie eine Listensyntax, die eckige Klammern verwendet.
앫
Erweitern Sie das XML-Programm um die Verarbeitung von Attributen: Ist das erste Argument eine Map (verwenden Sie die geschweifte-Klammern-Syntax), fügen Sie dem XML-Programm Attribute hinzu. Beispielsweise würde book({"author": "Tate"}...) als
: ausgegeben.
Io zusammengefasst Io ist eine ausgezeichnete Sprache, um den Umgang mit prototypbasierten Sprachen zu erlernen. Wie bei Lisp ist die Syntax überraschend einfach, doch die Semantik der Sprache verleiht ihr sehr viel Stärke. Prototypsprachen kapseln Daten und Verhaltensweisen wie objektorientierte Programmiersprachen. Die Vererbung ist einfacher. Es gibt keine Klassen oder Module in Io. Ein Objekt erbt Verhalten direkt von seinem Prototyp.
Io zusammengefasst 93
Stärken Prototypsprachen sind generell gut formbar. Man kann jeden Slot bei jedem Objekt ändern. Io treibt diese Flexibilität auf die Spitze und ermöglicht es einem, die gewünschte Syntax schnell aufzubauen. Wie bei Ruby wirken sich einige der Kompromisse, die Io so dynamisch machen, negativ auf die Performance aus, zumindest bei nur einem Thread. Die mächtigen, modernen Bibliotheken zur Nebenläufigkeit machen Io zu einer guten Sprache für die parallele Datenverarbeitung. Sehen wir uns an, wo Io heute brilliert.
Footprint Ios Footprint ist klein. Die meisten Produktionsanwendungen finden sich in eingebetteten Systemen. Das ergibt durchaus Sinn, weil die Sprache klein ist, leistungsfähig und flexibel. Die virtuelle Maschine kann leicht auf verschiedene Betriebsumgebungen portiert werden.
Einfachheit Ios Syntax ist bemerkenswert kompakt. Sie können Io sehr schnell erlernen. Sobald Sie die Grundsyntax verstanden haben, geht es nur noch darum, die Bibliotheksstrukturen kennenzulernen. Ich konnte mich in die Metaprogrammierung innerhalb des ersten Monats einarbeiten, in dem ich die Sprache nutzte. Bei Ruby dauerte es etwas länger, an diesen Punkt zu gelangen. Bei Java dauerte es Monate, bis ich an einen Punkt kam, an dem ich mir auf Metaprogrammierung überhaupt einen Reim machen konnte.
Flexibilität Ios Duck-Typing und Freiheit erlaubt Ihnen, jeden Slot jedes Objekts jederzeit zu ändern. Diese Freizügigkeit bedeutet, dass Sie die grundlegenden Regeln der Sprache an die Bedürfnisse Ihrer Anwendung anpassen können. Es ist recht einfach, Proxies an beliebigen Stellen einzufügen, indem man den forward-Slot ändert. Sie können auch Schlüssel-Sprachkonstrukte überschreiben, indem Sie ihre Slots direkt ändern. Sie können sogar auf die Schnelle eine eigene Syntax aufbauen.
94 Kapitel 3: Io
Nebenläufigkeit Anders als bei Java und Ruby sind die Konstrukte zur Nebenläufigkeit frisch und auf dem neuesten Stand. Aktoren, Futures und Koroutinen machen es sehr viel einfacher, Multithread-Anwendungen zu entwickeln, die sich leicht testen lassen und eine bessere Performance aufweisen. Io macht sich auch ernsthafte Gedanken um veränderliche Daten und darüber, wie man sie vermeidet. Diese Features sind fest in die Kernbibliotheken integriert und machen es einfach, ein robustes Nebenlläufigkeitsmodell zu erlernen. Später, bei anderen Sprachen, werden wir auf diesen Konzepten aufbauen. Sie werden Aktoren noch in Scala, Erlang und Haskell sehen.
Schwächen Es gibt viel, was man an Io mögen kann, aber auch einige suboptimale Aspekte. Freiheit und Flexibilität haben ihren Preis. Und da Io die kleinste Community der in diesem Buch vorgestellten Sprachen besitzt, ist es für einige Projekte vielleicht eine riskante Wahl. Sehen wir uns die Probleme von Io an.
Syntax Io bietet nur sehr wenig syntaktischen Zucker. Einfache Syntax ist ein zweischneidiges Schwert: Einerseits macht die klare Syntax Io als Sprache leicht zu verstehen, was aber andererseits seinen Preis hat. Eine einfache Syntax macht es häufig schwer, komplizierte Konzepte prägnant zu formulieren. Anders ausgedrückt, kann es Ihnen leicht fallen, zu verstehen, wie ein Programm Io nutzt, während Sie gleichzeitig Schwierigkeiten haben könnten, zu verstehen, was Ihr Programm überhaupt macht. Denken Sie als Kontrast an Ruby. Auf den ersten Blick könnten Sie den Ruby-Code array[-1] verwirrend finden, weil Sie den syntaktischen Zucker nicht verstehen: -1 ist ein Kürzel für das letzte Element eines Arrays. Sie müssen auch wissen, dass [] eine Methode ist, um den Wert an einem bestimmten Index des Arrays zu ermitteln. Sobald Sie diese Konzepte verstanden haben, können Sie mit einem Blick mehr Code verarbeiten. Bei Io sieht der Kompromiss genau umgekehrt aus. Sie müssen nicht viel lernen, um loslegen zu können, aber Sie müssen etwas mehr tun, um die Konzepte aufzunehmen, die man anderswo mithilfe syntaktischen Zuckers kommuniziert.
Io zusammengefasst 95 Bei syntaktischem Zucker ein Gleichgewicht zu finden, ist schwierig. Bei zu viel Zucker wird es kompliziert, die Sprache zu lernen und zu nutzen. Bei zu wenig Zucker müssen Sie mehr Zeit aufwenden, um bestimmte Dinge auszudrücken, und potenziell mehr Energie aufwenden, um ihn zu debuggen. Letztlich ist Syntax eine Frage der eigenen Vorliebe. Matz mag gern viel Zucker, Steve nicht.
Community Im Moment ist die Community sehr klein. Sie werden nicht immer Bibliotheken für Io finden, wie Sie es von anderen Sprachen gewohnt sind. Es ist auch schwieriger, Programmierer zu finden. Diese Aspekte werden durch eine gute C-Schnittstelle (die sich in einer Vielzahl von Sprachen ausdrücken kann) etwas abgemildert, sowie durch die so einfach zu merkende Syntax. Doch eine kleine Community ist definitiv eine Schwäche und die Hauptbremse für leistungsfähige neue Sprachen. Entweder findet sich für Io eine Killeranwendung, die die Akzeptanz erhöht, oder es wird ein Nischenprodukt bleiben.
Performance Ein Urteil über die Performance zu fällen, ohne andere Aspekte wie Nebenläufigkeit und Design der Anwendung zu berücksichtigen, ist nicht besonders klug, aber ich möchte hervorheben, dass Io einige Features besitzt, die einfache Single-Thread-Anwendungen langsam machen. Das Problem wird durch Ios Nebenläufigkeits-Konstrukte zwar etwas abgemildert, aber Sie sollten diese Einschränkung im Hinterkopf haben.
Abschließende Gedanken Ganz allgemein hat es mir Spaß gemacht, Io kennenzulernen. Die einfache Syntax und der kleine Footprint haben mich fasziniert. Ich finde auch, dass Io (genau wie Lisp) eine starke übergeordnete Philosophie der Einfachheit und Flexibilität verfolgt. Indem er sich bei der Entwicklung der Sprache konsequent an diese Philosophie gehalten hat, hat Steve Dekorte so etwas wie das Lisp der Prototypsprachen geschaffen. Ich finde, dass die Sprache Wachstumschancen hat. Wie Ferris Bueller hat sie eine rosige, aber gefährliche Zukunft vor sich.
Sally Dibbs, Dibbs, Sally. 461-0192. Raymond
Kapitel 4
Prolog Ah, Prolog. Manchmal beeindruckend clever und manchmal ebenso frustrierend. Verblüffende Antworten erhalten Sie nur, wenn Sie wissen, wie die Frage zu stellen ist. Denken Sie an Rain Man.1 Ich erinnere mich, wie die Hauptfigur Raymond ohne nachzudenken Sally Dibbs’ Telefonnummer herunterrasselte, nachdem er in der Nacht das Telefonbuch gelesen hatte. Bei Raymond und Prolog frage ich mich zu gleichen Teilen: „Wie konnte er das wissen?“ und „Wie konnte er das nicht wissen?“ Er ist eine unglaubliche Wissensquelle, wenn man es denn hinbekommt, die Fragen richtig zu stellen. Prolog stellt eine deutliche Abweichung von den anderen Sprachen dar, die wir bisher betrachtet haben. Sowohl Io als auch Ruby werden als imperative Sprachen bezeichnet. Imperative Sprachen sind wie Rezepte: Sie sagen dem Computer ganz genau, was er tun soll. Imperative Sprachen höherer Ordnung haben vielleicht eine größere Hebelwirkung, doch letztendlich stellen Sie eine Einkaufsliste aller Zutaten zusammen und beschreiben Schritt für Schritt, wie man einen Kuchen backt. Ich habe einige Wochen mit Prolog herumgespielt, bevor ich mich an dieses Kapitel heranwagte. In der Einstiegsphase benutzte ich verschiedene Tutorials, darunter eines von J. R. Fisher2 (um einige Beispiele durchzuarbeiten) und ein weiteres von A. Aaby3 (Hilfe im Bezug auf Struktur und Terminologie), und machte sehr viele Experimente.
1 Rain Man. DVD. By Barry Levinson. 1988; Los Angeles, CA: MGM, 2000. 2 http://www.csupomona.edu/~jrfisher/www/prolog_tutorial/contents.html 3 http://www.lix.polytechnique.fr/~liberti/public/computing/prog/prolog/prologtutorial.html
98 Kapitel 4: Prolog Prolog ist eine deklarative Sprache. Sie geben ein paar Fakten und Schlussfolgerungen und überlassen der Sprache das „Denken“. Es ist so, wie zu einem guten Bäcker zu gehen: Sie beschreiben die Eigenschaften der Torte, die Sie mögen, und überlassen es dem Bäcker, (basierend auf den von Ihnen aufgestellten Regeln) die Zutaten herauszusuchen und die Torte zu backen. Bei Prolog müssen Sie nicht wissen, wie. Der Computer übernimmt das für Sie. Im Internet finden Sie problemlos Beispiele für Programme, die ein Sudoku mit weniger als 20 Zeilen Code lösen, Rubiks Würfel knacken und berühmte Rätsel wie die Türme von Hanoi (etwa ein Dutzend Codezeilen) lösen. Prolog war eine der ersten erfolgreichen logischen Programmiersprachen. Sie treffen Aussagen mit reiner Logik, und Prolog ermittelt, ob sie wahr sind. Ihre Aussagen können Lücken aufweisen, die Prolog so zu ergänzen versucht, dass Ihre unvollständigen Fakten wahr werden.
4.1
Über Prolog Im Jahr 1972 von Alain Colmerauer und Phillipe Roussel entwickelt, war Prolog eine logische Programmiersprache, die sich bei der Verarbeitung natürlicher Sprachen großer Beliebtheit erfreute. Heutzutage bildet die altehrwürdige Sprache die programmtechnische Grundlage zur Lösung unterschiedlichster Probleme von der Disposition bis zu Expertensystemen. Sie können diese regelbasierte Sprache verwenden, um Logik auszudrücken und Fragen zu stellen. Wie SQL arbeitet Prolog mit Datenbanken, doch die Daten bestehen aus logischen Regeln und Beziehungen. Wie SQL besteht Prolog aus zwei Teilen: einem, der die Daten ausdrückt, und einem, der diese Daten abfragt. Bei Prolog haben die Daten die Form logischer Regeln. Hier die Grundbausteine: 앫
Fakten. Ein Faktum ist eine grundlegende Aussage über eine Welt. (Babe ist ein Schwein; Schweine mögen Schlamm.)
앫
Regeln. Eine Regel ist eine Folgerung aus den Fakten einer Welt. (Ein Tier mag Schlamm, wenn es ein Schwein ist.)
앫
Query. Eine Query (Abfrage) ist eine Frage zu einer Welt. (Mag Babe Schlamm?)
Fakten und Regeln wandern in eine Knowledge Base, also eine Wissensdatenbank. Ein Prolog-Compiler übersetzt die Wissensdatenbank in eine Form, die sich für Queries eignet. Wenn wir im Folgenden die Beispiele durchgehen, werden Sie Prolog benutzen, um Ihr Wissen (für
Tag 1: Ein ausgezeichneter Fahrer 99 die Wissensdatenbank) auszudrücken. Dann werden Sie die Daten direkt abrufen. Außerdem werden Sie Prolog verwenden, um Regeln zu verknüpfen und sich Dinge sagen lassen, die Sie noch nicht wussten. Doch genug der Hintergrundinformationen. Legen wir los.
4.2
Tag 1: Ein ausgezeichneter Fahrer In Rain Man erzählt Raymond seinem Bruder, dass er ein ausgezeichneter Fahrer sei, was in seinem Fall bedeutet, dass er die Sache bei 10 km/h auf einem Parkplatz ganz ordentlich erledigt. Er benutzt dabei alle wesentlichen Elemente (das Lenkrad, die Bremsen, und den Gashebel), wenn auch in einem etwas beschränkten Umfeld. Genau das ist heute unser Ziel. Wir werden Prolog nutzen, um einige Fakten auszudrücken, ein paar Regeln aufstellen und einige einfache Queries durchführen. Wie Io ist Prolog syntaktisch eine extrem einfache Sprache, deren Syntaxregeln man sehr schnell erlernen kann. Der Spaß beginnt erst so richtig, wenn Sie die Konzepte auf interessante Art und Weise verknüpfen. Wenn das Ihr erstes Zusammentreffen mit Prolog ist, garantiere ich Ihnen, dass Sie entweder Ihr Denken ändern oder kläglich scheitern werden. Den genaueren Aufbau sparen wir uns für einen anderen Tag auf. Eins nach dem anderen. Sie benötigen eine funktionierende Installation. Ich verwende für dieses Buch GNU-Prolog in der Version 1.3.1. Doch Vorsicht: Die Dialekte sind verschieden. Ich tue mein Bestes, um auf der sicheren Seite zu bleiben, doch wenn Sie sich für eine andere Prolog-Version entscheiden, müssen Sie Ihre Hausaufgaben machen und herausfinden, worin sich der Dialekt unterscheidet, den Sie verwenden. Unabhängig von der verwendeten Version werden Sie hier erfahren, wie man die Sprache grundsätzlich benutzt.
Grundlegende Fakten Bei einigen Sprachen liegt die Groß- und Kleinschreibung völlig im Ermessen des Programmierers. Bei Prolog ist die Schreibweise des ersten Buchstaben von Bedeutung: Beginnt ein Wort mit einem Kleinbuchstaben, ist es ein Atom, also ein fester Wert (wie ein Ruby-Symbol). Beginnt es mit einem Großbuchstaben oder einem Unterstrich, handelt es sich um eine Variable. Die Werte von Variablen können sich ändern, die von Atomen nicht. Lassen Sie uns eine einfache Wissensdatenbank mit ein paar Fakten aufbauen. Geben Sie Folgendes in einem Editor ein:
100 Kapitel 4: Prolog prolog/friends.pl
likes(wallace, cheese). likes(grommit, cheese). likes(wendolene, sheep). friend(X, Y) :- \+(X = Y), likes(X, Z), likes(Y, Z).
Die obige Datei ist eine Wissensdatenbank mit Fakten und Regeln. Die ersten drei Anweisungen sind Fakten, und die letzte Anweisung ist eine Regel. Fakten sind direkte Beobachtungen aus unserer Welt. Wir wollen zuerst nur die ersten drei Zeilen betrachten. Diese drei Zeilen sind Fakten. wallace, grommit und wendolene sind Atome. Sie können Sie wie folgt lesen: wallace mag cheese, grommit mag cheese und wendolene mag sheep. Lassen wir diese Fakten in Aktion treten. Starten Sie den Prolog-Interpreter. Wenn Sie GNU-Prolog benutzen, geben Sie den Befehl gprolog ein. Um die Datei zu laden, geben Sie dann Folgendes ein: | ?- ['friends.pl']. compiling /Users/batate/prag/Book/code/prolog/friends.pl for byte code... /Users/batate/prag/Book/code/prolog/friends.pl compiled, 4 lines read 997 bytes written, 11 ms yes | ?-
Wenn Prolog nicht auf ein Zwischenergebnis wartet, antwortet es mit yes oder no. In unserem Fall wurde die Datei erfolgreich geladen, weshalb es mit yes antwortet. Wir können nun einige Fragen stellen. Die einfachsten Fragen sind Ja/Nein-Fragen zu Fakten. Stellen Sie einige Fragen: |?- likes(wallace, sheep). no | ?- likes(grommit, cheese). yes
Diese Fragen sind intuitiv verständlich. Mag Wallace sheep? Nein. Mag Grommit cheese Ja. Sie sind nicht besonders interessant: Prolog plappert einfach die Fakten nach. Es wird etwas spannender, wenn Sie damit beginnen, etwas Logik einfließen zu lassen. Werfen wir einen Blick auf Schlussfolgerungen.
Tag 1: Ein ausgezeichneter Fahrer 101
Grundlegende Folgerungen und Variablen Probieren wir die friend-Regel aus: | ?- friend(wallace, wallace). no
Prolog arbeitet sich also durch die von uns aufgestellten Regeln und beantwortet Fragen mit yes oder no. Es steckt mehr dahinter, als man denken könnte. Sehen wir uns die friend-Regel noch einmal an: Damit X ein friend von Y sein kann, darf X nicht gleich Y sein. Sehen wir uns den ersten Teil rechts von :- an, den man als Teilziel (subgoal) bezeichnet. \+ steht für die logische Negation; \+(X = Y) bedeutet also X ist nicht gleich Y. Probieren Sie weitere Queries aus: | ?- friend(grommit, wallace). Yes | ?- friend(wallace, grommit). yes
Auf Deutsch formuliert ist X ein Freund von Y, wenn wir beweisen können, dass X irgendein Z mag und Y das gleiche Z mag. Sowohl wallace als auch grommit mögen cheese, weshalb die Queries wahr sind. Tauchen wir in den Code ein. Bei diesen Queries ist X ungleich Y, wodurch das erste Teilziel nachgewiesen ist. Die Query verwendet das zweite und das dritte Teilziel likes(X, Z) und likes(Y, Z). grommit und wallace mögen cheese, wodurch das zweite und dritte Teilziel nachgewiesen wären. Probieren wir eine weitere Query aus: | ?- friend(wendolene, grommit). no
In diesem Fall muss Prolog mehrere mögliche Werte für X, Y und Z durchprobieren: 앫 wendolene, grommit
und cheese
앫 wendolene, grommit
und sheep
Keine Kombination erfüllt beide Ziele, das wendolene Z mag und grommit Z mag. Es existiert keine passende Kombination, weshalb die LogikEngine no zurückgibt, d. h. sie sind keine Freunde.
102 Kapitel 4: Prolog Lassen Sie uns die Terminologie ein wenig formalisieren. Das hier ... friend(X, Y) :- \+(X = Y), likes(X, Z), likes(Y, Z).
... ist eine Prolog-Regel mit den drei Variablen X, Y und Z. Wir nennen diese Regel friend/2, als Abkürzung für friend mit zwei Parametern. Die Regel hat drei Teilziele, getrennt durch Kommata. Alle müssen erfüllt sein, damit die Regel wahr ist. Unsere Regel besagt also, dass X ein Freund von Y ist, wenn X und Y nicht gleich sind und X und Y das gleiche Z mögen.
Die Lücken füllen Wir haben Prolog benutzt, um einige Ja/Nein-Fragen zu beantworten, doch wir können mehr als das. In diesem Abschnitt werden wir die Logik-Engine benutzen, um alle für eine Abfrage möglichen Treffer zu finden. Zu diesem Zweck werden Sie in Ihrer Query eine Variable verwenden. Betrachten wir die folgende Wissensdatenbank: prolog/food.pl
food_type(edamer, cheese). food_type(tuc, cracker). food_type(spam, meat). food_type(sausage, meat). food_type(jolt, soda). food_type(yes, dessert). flavor(sweet, dessert). flavor(savory, meat). flavor(savory, cheese). flavor(sweet, soda). food_flavor(X, Y) :- food_type(X, Z), flavor(Y, Z).
Wir hab einige Fakten. So etwas wie food_type(edamer, cheese) gibt an, dass es sich um Nahrung eines bestimmten Typs handelt. Andere wie flavor(sweet, dessert) beschreiben den charakteristischen Geschmack eines Nahrungstyps. Schließlich gibt es noch eine Regel namens food_flavor, die den Geschmack eines Nahrungsmittels schlussfolgert. Ein Nahrungsmittel X hat einen food_flavor Y, wenn das Nahrungsmittel ein food_type Z ist und Z gleichzeitig den charakteristischen Geschmack aufweist. Wir wollen das kompilieren ... | ?- ['code/prolog/food.pl']. compiling /Users/batate/prag/Book/code/prolog/food.pl for byte code... /Users/batate/prag/Book/code/prolog/food.pl compiled,
Tag 1: Ein ausgezeichneter Fahrer 103 12 lines read - 1557 bytes written, 15 ms (1 ms) yes
... und ein paar Fragen stellen: | ?- food_type(What, meat). What = spam ? ; What = sausage ? ; no
Das ist interessant. Wir haben Prolog gefragt, „welcher Wert für was erfüllt die Query food_type(What, meat)“. Prolog hat einen gefunden: spam. Als wir dann ; eingegeben haben, um von Prolog eine weitere Antwort zu erhalten, erhielten wir sausage. Diese Werte zu finden, war leicht, da die Abfragen auf grundlegenden Fakten basieren. Wir wollten dann eine weitere Antwort, und Prolog antwortete mit no. Dieses Verhalten kann leicht variieren. Wenn Prolog erkennt, dass es keine weiteren Möglichkeiten gibt, wird der Bequemlichkeit halber yes ausgegeben. Kann Prolog ohne weitere Berechnungen nicht sofort ermitteln, ob es weitere Alternativen gibt, fragt es nach der nächsten und gibt no aus. Dieses Feature ist tatsächlich sehr bequem. Kann Prolog Ihnen früher eine Information geben, dann macht es das auch. Probieren wir weitere Queries aus: | ?- food_flavor(sausage, sweet). no | ?- flavor(sweet, What). What = dessert ? ; What = soda yes
Nein, eine Wurst ist nicht süß. Welche Nahrungsmittel sind süß? dessert und soda. Das sind Fakten. Doch Sie können Prolog auch Zusammenhänge herstellen lassen: | ?- food_flavor(What, savory). What = edamer ? ; What = spam ? ; What = sausage ? ; no
104 Kapitel 4: Prolog Denken Sie daran, dass food_flavor(X, Y) eine Regel ist, kein Fakt. Wir fordern von Prolog alle Werte an, die die Anfrage „Welche Nahrungsmittel haben einen herzhaften Geschmack?“ erfüllen. Prolog muss die einfachen Fakten über Nahrungsmittel, Typen und Geschmack verknüpfen, um zu einer Schlussfolgerung zu kommen. Die Logik-Engine muss die möglichen Kombinationen durchgehen, für die alle Ziele zutreffen.
Karten einfärben Als etwas spektakuläreres Prolog-Beispiel wollen wir die gleiche Idee verwenden, um eine Landkarte einzufärben, genauer gesagt eine Karte des Südostens der USA. Wir betrachten die Staaten aus Abbbildung 4.1. Wir wollen nicht, das sich zwei Staaten mit derselben Farbe berühren.
Tennessee
Mississippi
Alabama
Georgia
Florida
Abbildung 4.1: Karte der südöstlichen USA Wir halten die folgenden einfachen Fakten fest: prolog/map.pl
different(red, green). different(red, blue). different(green, red). different(green, blue). different(blue, red). different(blue, green). coloring(Alabama, Mississippi, Georgia, Tennessee, Florida) :different(Mississippi, Tennessee), different(Mississippi, Alabama),
Tag 1: Ein ausgezeichneter Fahrer 105 different(Alabama, different(Alabama, different(Alabama, different(Alabama, different(Georgia, different(Georgia,
Tennessee), Mississippi), Georgia), Florida), Florida), Tennessee).
Wir verwenden drei Farben. Wir teilen Prolog die Gruppen verschiedener Farben mit, die beim Einfärben der Karte verwendet werden sollen. Als Nächstes folgt eine einzelne Regel. Diese teilt Prolog mit, welche Staaten Nachbarn sind, und fertig. Probieren Sie es aus: | ?- coloring(Alabama, Mississippi, Georgia, Tennessee, Florida). Alabama = blue Florida = green Georgia = red Mississippi = red Tennessee = green ?
Offensichtlich gibt es eine Möglichkeit, diese fünf Staaten mit drei Farben einzufärben. Sie erhalten die weiteren möglichen Kombinationen, indem Sie a eingeben. Mit einem Dutzend Zeilen sind wir durch. Die Logik ist völlig simpel, ein Kind könnte sie herausfinden. An dieser Stelle müssen Sie sich selbst fragen ...
Wo ist das Programm? Wir haben keinen Algorithmus! Versuchen Sie, das Problem mit einer prozeduralen Sprache ihrer Wahl zu lösen. Ist ihre Lösung leicht zu verstehen? Überlegen Sie, was Sie tun müssen, um komplexe logische Probleme wie diese mit Ruby oder Io zu lösen. Eine mögliche Lösung könnte wie folgt aussehen: 1. Erfassen und organisieren Sie Ihre Logik. 2. Drücken Sie die Logik in einem Programm aus. 3. Finden Sie alle möglichen Lösungen. 4. Lassen Sie alle möglichen Lösungen durch Ihr Programm laufen. Und Sie müssten das Programm immer und immer wieder schreiben. Prolog erlaubt Ihnen, die Logik über Fakten und Schlussfolgerungen auszudrücken und Fragen zu stellen. Sie sind bei dieser Sprache nicht dafür verantwortlich, ein Schritt-für-Schritt-Rezept aufzubauen. Bei Prolog geht es nicht darum, Algorithmen für logische Probleme zu entwickeln, sondern darum, eine Welt so zu beschreiben, wie Sie ist, und
106 Kapitel 4: Prolog logische Probleme zu präsentieren, die der Computer zu lösen versuchen kann. Lassen Sie den Computer die Arbeit erledigen!
Unifizierung, Teil 1 An diesem Punkt müssen wir einen Schritt zurücktreten und etwas auf die Theorie eingehen. Beschäftigen wir uns etwas mit der Unifizierung. Einige Sprachen verwenden Variablenzuweisungen. Bei Java oder Ruby bedeutet x=10 beispielsweise die Zuweisung von 10 an die Variable x. Die Unifizierung zweier Strukturen versucht, diese beiden Strukturen identisch zu machen. Nehmen wir die folgende Wissensdatenbank: prolog/ohmy.pl
cat(lion). cat(tiger). dorothy(X, Y, Z) :- X = lion, Y = tiger, Z = bear. twin_cats(X, Y) :- cat(X), cat(Y).
In diesem Beispiel steht = für das Unifizieren, oder „mach beide Seiten gleich“. Wir haben zwei Fakten: lions und tigers sind cats. Es gibt auch zwei einfache Regeln. Bei dorothy/3 sind X, Y und Z gleich lion, tiger und bear. Bei twin_cats/2 ist X eine cat und Y auch. Wir können diese Wissensbasis nutzen, um etwas mehr Licht in die Unifizierung zu bringen. Zuerst wollen wir die erste Regel nutzen. Ich kompiliere unser Wissen und führe dann eine einfache Abfrage ohne Parameter durch: | ?- dorothy(lion, tiger, bear). yes
Denken Sie daran, was die Unifizierung bedeutet: „Finde die Werte, bei denen beide Seiten gleich sind“. Auf der rechten Seite bindet Prolog X, Y und Z an lion, tiger und bear. Diese passen zu den entsprechenden Werten auf der linken Seite, die Unifizierung war also erfolgreich. Prolog meldet yes. Dieser Fall ist einfach, doch wir können ihn ein wenig aufpeppen. Die Unifizierung funktioniert auf beiden Seiten der Implikation. Probieren Sie Folgendes: | ?- dorothy(One, Two, Three). One = lion Three = bear
Tag 1: Ein ausgezeichneter Fahrer 107 Two = tiger yes
Dieses Beispiel verwendet eine zusätzliche Dereferenzierungsschicht. In den Zielen unifiziert Prolog X, Y und Z zu lion, tiger und bear. Auf der linken Seite unifiziert Prolog X, Y und Z zu One, Two und Three und gibt dann das Ergebnis zurück. Sehen wir uns nun die letzte Regel an: twin_cats/2. Die Regel besagt, dass twin_cats(X, Y) wahr ist, wenn Sie beweisen können, dass X und Y beides Katzen sind. Probieren Sie es aus: | ?- twin_cats(One, Two). One = lion Two = lion ?
Prolog gibt das erste Beispiel zurück. lion und lion sind beides Katzen. Sehen wir uns an, wie die Sprache darauf kommt: 1. Wir haben die Query twin_cats(One, Two) angestoßen. Prolog bindet One an X und Two an Y. Um das lösen zu können, muss sich Prolog durch die Ziele arbeiten. 2. Das erste Ziel ist cat(X). 3. Wir besitzen zwei passende Fakten: cat(lion) und cat(tiger). Prolog probiert das erste Faktum aus, bindet X an lion und macht dann mit dem nächsten Ziel weiter. 4. Prolog bindet nun Y an cat(Y). Prolog kann dieses Ziel auf die gleiche Weise lösen wie das erste und wählt lion. 5. Wir haben beide Ziele zufriedengestellt, die Regel ist also erfolgreich. Prolog gibt die erfolgreichen Werte für One und Two aus und meldet yes. Wir besitzen nun die erste Lösung, für die unsere Regeln zutreffen. Manchmal reicht eine Lösung aus. Manchmal braucht man mehr als eine. Wir können uns nun eine Lösung nach der anderen ansehen, indem wir ; eingeben, oder wir sehen uns den ganzen Rest an, indem wir a drücken. Two = lion ? a One = lion Two = tiger
108 Kapitel 4: Prolog One = tiger Two = lion One = tiger Two = tiger (1 ms) yes
Beachten Sie, dass Prolog die Liste aller Kombinationen von X und Y durcharbeitet und dabei die in den Zielen und den entsprechenden Fakten angegebenen Informationen nutzt. Wie Sie später sehen werden, ermöglicht die Unifizierung ein anspruchsvolles Matching, das auf der Struktur der Daten basiert. Das ist genug für den ersten Tag. Am zweiten Tag werden wir uns schwierigeren Dingen zuwenden.
Prolog in der Praxis Es war ein bisschen befremdlich, ein solches „Programm“ zu sehen. Bei Prolog gibt es kaum ein detailliertes Schritt-für-Schritt-Rezept, sondern nur eine Beschreibung des Kuchens, der nach dem Backen aus dem Ofen kommt. Beim Lernen von Prolog half es mir enorm, dass ich jemanden interviewen durfte, der die Sprache in der Praxis eingesetzt hat: Ich habe mit Brian Tarbox gesprochen, der diese Logiksprache verwendet hat, um für ein Forschungsprojekt Arbeitspläne für die Arbeit mit Delphinen zu entwickeln.
Ein Interview mit Brian Tarbox, Delfinforscher Bruce: Was können Sie uns über Ihre Erfahrung beim Erlernen von Prolog erzählen? Brian: Ich lernte Prolog in den späten 1980ern, als ich an der University of Hawaii in Manoa studierte. Ich forschte am Kewalo Basin Marine Mammal Laboratory über die kognitiven Fähigkeiten Großer Tümmler. Zu der Zeit bemerkte ich, dass die Leute im Labor größtenteils Theorien darüber diskutierten, wie Delfine denken. Wir arbeiteten hauptsächlich mit einer Delfinin namens Akeakamai, oder kurz Ake. Viele unserer Debatten begannen mit „Hmm, Ake sieht das wahrscheinlich so und so ...“ Ich wollte in meiner Masterarbeit ein ausführbares Modell entwickeln, das unsere Ansichten von Akes Vorstellung von der Welt widerspiegeln sollte (oder zumindest den kleinen Teil davon, an dem wir forschten). Mit unserem ausführbaren Modell Akes tatsächliches Verhalten vorhersagen zu können, sollte unsere Theorien zu ihrem Denken verifizieren.
Tag 1: Ein ausgezeichneter Fahrer 109 Prolog ist eine wundervolle Sprache, aber die Ergebnisse können ziemlich schräg sein. Ich erinnere mich an meine ersten Experimente. Ich schrieb so etwas wie x = x + 1 und Prolog antwortete „no“. Sprachen sagen nicht einfach „no“. Sie können die falsche Antwort zurückliefern oder die Kompilierung kann fehlschlagen, aber ich kannte noch keine Sprache, die mir Widerworte gab. Also rief ich den Prolog-Support an und sagte, dass die Sprache „no“ antwortete, wenn ich versuchte, den Wert einer Variablen zu ändern. Ich wurde gefragt: „Warum sollten Sie den Wert einer Variablen ändern wollen?“ Na ja, welche Sprache lässt einen nicht den Wert einer Variablen ändern? Sobald man Prolog begriffen hat, versteht man, dass Variablen entweder bestimmte Werte haben oder nicht gebunden sind, aber zu dem Zeitpunkt war das verwirrend. Bruce: Wie haben Sie Prolog genutzt? Brian: Ich habe zwei Hauptsysteme entwickelt: einen Delfinsimulator und einen Laborarbeitsplaner. Das Labor sollte jeden Tag vier Experimente mit den vier Delfinen durchführen. Sie müssen wissen, dass Forschungsdelfine eine unglaublich knappe Ressource sind. Jeder Delphin arbeitete an unterschiedlichen Experimenten, und jedes Experiment verlangte unterschiedliches Personal. Einige Rollen, wie etwa die des Delfintrainers, konnten nur von wenigen Leuten übernommen werden. Andere Aufgaben, wie die Datenaufzeichnung, konnten von verschiedenen Leuten erledigt werden, verlangten aber trotzdem ein gewisses Training. Für die meisten Experimente waren sechs bis zu einem Dutzend Leute notwendig. Wir hatten Doktoranden, Studenten und Earthwatch-Freiwillige. Jede Person hatte ihren eigenen Zeitplan und ihre ganz eigenen Fähigkeiten. Einen Arbeitsplan zu finden, der alle auslastet und sicherstellt, dass alle Arbeiten erledigt werden, wurde für einen vom Personal zur Vollzeitbeschäftigung. Ich wollte versuchen, einen Prolog-basierten Arbeitsplaner zu entwickeln. Es stellte sich heraus, dass die Sprache für dieses Problem wie gemacht zu sein schien. Ich entwickelte eine Reihe von Fakten, die die Fähigkeiten und Zeitpläne der einzelnen Personen und die Anforderungen aller Experimente beschrieben. Ich konnte Prolog dann grundsätzlich sagen: „Mach es so und so!“ Für jede in einem Experiment aufgeführte Aufgabe sollte die Sprache eine verfügbare Person mit den geforderten Kenntnissen finden und an diese Aufgabe binden. Das würde so lange weitergehen, bis entweder alle Anforderungen des Experiments erfüllt waren oder eine Lösung unmöglich war. Konnte Prolog keine gültige Bindung finden, löste es frühere Bindungen auf und versuchte es mit einer anderen Kombination. Letztendlich würde es entwe-
110 Kapitel 4: Prolog der einen gültigen Arbeitsplan finden oder das Experiment als zu stark gebunden deklarieren. Bruce: Gibt es Beispiele für Fakten, Regeln oder Aussagen im Bezug auf Delfine, die für unsere Leser von Interesse sein könnten? Brian: Ich erinnere mich an eine bestimmte Situation, in der der simulierte Delfin uns dabei half, Akes tatsächliches Verhalten zu verstehen. Ake reagierte auf eine gestenreiche Zeichensprache mit „Sätzen“ wie „spring durch den Reifen“ oder „berühr den rechten Ball mit der Schwanzflosse“. Wir gaben ihr Anweisungen und sie reagierte. Ein Teil meiner Forschung bestand darin, ihr neue Worte wie „nicht“ beizubringen. In diesem Kontext bedeutete „berühr den Ball nicht“, dass sie alles außer dem Ball berühren durfte. Dieses Problem war für Ake schwer zu lösen, doch eine Zeit lang machte die Forschung gute Fortschritte. An einem Punkt ließ sie sich aber einfach unter Wasser sinken, sobald wir ihr die Anweisung gaben. Wir verstanden das nicht. Das ist eine sehr frustrierende Situation, weil Sie einen Delfin nicht fragen können, warum er etwas macht. Also präsentierten wir die Trainingsaufgabe dem simulierten Delfin und erhielten ein sehr interessantes Ergebnis. Zwar sind Delfine sehr clever, doch versuchen sie generell, die einfachste Antwort auf ein Problem zu finden. Wir hatten unserem simulierten Delfin einige Heuristiken mitgegeben. Es stellte sich heraus, dass Akes Zeichensprache ein „Wort“ für eines der Fenster im Tank enthielt. Die meisten Trainer hatten dieses Wort vergessen, weil es nur selten genutzt wurde. Der simulierte Delfin entdeckte die Regel, dass „Fenster“ eine richtige Antwort auf „nicht Ball“ war. Es war auch die richtige Reaktion auf „nicht Reifen“ „nicht Tunnel“ und „nicht Frisbee“. Wir hatten versucht, dieses Muster zu meiden, indem wir vor jedem Versuch die Objekte im Tank veränderten, aber das Fenster konnten wir natürlich nicht entfernen. Es stellte sich heraus, dass Ake direkt neben das Fenster schwamm, wenn sie sich auf den Boden des Beckens sinken ließ, auch wenn ich das Fenster nicht sehen konnte! Bruce: Was finden Sie an Prolog am besten? Brian: Das deklarative Programmiermodell ist sehr reizvoll. Wenn Sie das Problem beschreiben können, haben Sie es generell gelöst. Bei den meisten Sprachen habe ich an irgendeiner Stelle versucht, mit dem Computer zu diskutieren: „Du weißt, was ich meine, mach es einfach!“ Compilerfehler bei C und C++ wie „fehlendes Semikolon“ sind dafür ein typisches Beispiel. Wenn Du ein Semikolon erwartest, warum fügst du keines ein und siehst, ob es das Problem löst?
Tag 1: Ein ausgezeichneter Fahrer 111 Bei Prolog musste ich beim Arbeitsplan-Problem im Wesentlichen nur sagen, „Ich möchte einen Tag, der wie folgt aussieht, also mach mir einen!“, und das Programm machte mir einen. Bruce: Wo hatten Sie die größten Schwierigkeiten? Brian: Prolog schien für Probleme einen Alles-oder-nichts-Ansatz zu verwenden, zumindest bei den Problemen, an denen ich arbeitete. Beim Laborarbeitsplan lief das System 30 Minuten und gab dann entweder einen wunderschönen Plan oder einfach „no“ aus. „No“ hieß in diesem Fall, dass wir den Tag zu stark verplant hatten und es keine vollständige Lösung gab. Es lieferte uns aber keine Teillösung und keine Informationen darüber, wo wir uns verplant hatten. Was man hier erkennt, ist ein extrem leistungsfähiges Konzept. Sie müssen nicht die Lösung eines Problems beschreiben, sondern nur das Problem. Und die Sprache für die Beschreibung des Problems ist Logik, reine Logik. Beginnen Sie mit Fakten und Folgerungen, und Prolog erledigt den Rest. Prolog-Programme bilden eine höhere Ebene der Abstraktion. Pläne und Verhaltensmuster sind Beispiele für Probleme, die gut zu Prolog passen.
Was wir am ersten Tag gelernt haben Heute haben wir die grundlegenden Bausteine der Sprache Prolog kennengelernt. Statt Schritte zu programmieren, die Prolog zu einer Lösung führen, haben wir Wissen mittels reiner Logik kodiert. Prolog hat dann die schwierige Aufgabe übernommen, dieses Wissen zu verknüpfen, um Lösungen zu finden. Wir haben unsere Logik in Wissensdatenbanken gepackt und diese dann abgefragt. Nach dem Aufbau einiger Wissensdatenbanken haben wir diese kompiliert und abgefragt. Die Abfragen (Queries) wiesen zwei Formen auf. Zum einen konnte die Query einfach ein Faktum angeben, und Prolog sagte uns, ob es stimmte oder nicht. Zum anderen konnten wir eine Query mit einer oder mehreren Variablen angeben, und Prolog berechnete dann alle Möglichkeiten, die es gab, um diese Fakten wahr werden zu lassen. Wir haben gelernt, dass Prolog sich durch Regeln arbeitet, indem es die Klauseln nacheinander durchgeht. Für jede Klausel versucht Prolog, die gewünschten Ziele zu erreichen, indem es alle möglichen Kombinationen von Variablen durchgeht. Alle Prolog-Programme funktionieren auf diese Weise.
112 Kapitel 4: Prolog In den noch kommenden Abschnitten werden wir komplexere Schlussfolgerungen treffen. Wir werden auch sehen, wie man rechnen kann und komplexere Datenstrukturen wie Listen verwendet. Und wir zeigen Strategien, mit denen man über solche Listen iteriert.
Tag 1: Selbststudium Finden Sie 앫
einige freie Einführungen in Prolog,
앫
ein Support-Forum (es gibt verschiedene) und
앫
eine Onlinereferenz für die von Ihnen verwendete Prolog-Version.
Machen Sie Folgendes:
4.3
앫
Bauen Sie eine einfache Wissensdatenbank mit einigen Ihrer Lieblingsbücher und -autoren auf.
앫
Finden Sie alle Bücher in Ihrer Wissensdatenbank, die von einem Autor geschrieben wurden.
앫
Bauen Sie eine Wissensdatenbank mit Musikern und Instrumenten auf. Stellen Sie auch Musiker und ihre Musikrichtung dar.
앫
Finden Sie alle Musiker, die Gitarre spielen.
Tag 2: Fünfzehn Minuten für Wapner Der mürrische Richter Wapner aus „The People’s Court“ ist eine Obsession der Zentralfigur in Rain Man. Wie die meisten Autisten ist Raymond von allem besessen, was ihm vertraut ist. Er klammert sich an Richter Wapner und „The People’s Court“. Nachdem Sie sich durch diese rätselhafte Sprache gekämpft haben, sind Sie jetzt vielleicht bereit für Dinge, bei denen es Klick macht. Vielleicht sind Sie einer jener glücklichen Leser, bei denen es immer ganz von alleine Klick macht, aber wenn nicht, sollten Sie Ihren Mut zusammennehmen: Heute gibt es definitiv „fünfzehn Minuten für Wapner“. Warten Sie geduldig ab. Wir brauchen noch weitere Werkzeuge in unserem Werkzeugkasten. Wir wollen lernen, wie man mit Rekursion, Mathematik und Listen umgeht. Los geht’s.
Tag 2: Fünfzehn Minuten für Wapner 113
Rekursion Ruby und Io sind imperative Sprachen. Sie beschreiben jeden Schritt des Algorithmus. Prolog ist die erste der deklarativen Sprachen, die wir uns ansehen. Wenn Sie mit Collections wie Listen oder Bäumen arbeiten, verwenden Sie oft Rekursion anstelle von Iteration. Wir wollen uns die Rekursion ansehen und zeigen, wie man mit ihr Probleme mit einfachen Schlussfolgerungen lösen kann. Dann werden wir die gleiche Technik auf Listen und die Mathematik anwenden. Sehen Sie sich die folgende Datenbank an. Sie enthält den umfangreichen Stammbaum der Waltons, der Figuren aus einem Film von 1963 und der nachfolgenden Serie. Sie drückt die Vaterbeziehung aus und leitet daraus eine Vorfahrenbeziehung ab. Weil „Vorfahre“ Vater, Großvater oder Urgroßvater bedeuten kann, müssen wir die Regeln verschachteln oder iterieren. Da wir mit einer deklarativen Sprache arbeiten, werden wir sie verschachteln. Eine Klausel der ancestor -Klausel wird ancestor nutzen. In diesem Fall ist ancestor(Z, Y) ein rekursives Teilziel. Hier sehen Sie die Wissensdatenbank: prolog/family.pl
father(zeb, john_boy_sr). father(john_boy_sr, john_boy_jr). ancestor(X, Y) :father(X, Y). ancestor(X, Y) :father(X, Z), ancestor(Z, Y).
father bildet die Kernmenge der Fakten, die unser rekursives Teilziel möglich machen. Die Regel ancestor/2 besitzt zwei Klauseln. Besteht eine Regel aus mehreren Klauseln, muss nur eine Regel zutreffen, damit die Regel wahr wird. Betrachten Sie die Kommata zwischen den Teilzielen als UND- und die Punkte zwischen zwischen den Klauseln als ODER-Bedingungen. Die erste Klausel besagt, dass „X ein Vorfahre (engl.: ancestor) von Y ist, wenn X der Vater von Y ist“. Das ist eine klare Beziehung. Wir können die Regel so ausprobieren: | ?- ancestor(john_boy_sr, john_boy_jr). true ? no
Prolog meldet true, john_boy_sr ist ein Vorfahre von john_boy_jr. Diese erste Klausel ist von einem Faktum abhängig.
114 Kapitel 4: Prolog Die zweite Klausel ist etwas komplexer: ancestor(X, Y) :- father(X, Z), ancestor(Z, Y). Diese Klausel besagt, das X ein Vorfahre von Y ist, wenn wir beweisen können, dass X der Vater von Z ist und Z gleichzeitig ein Vorfahre von Y. Puh. Lassen Sie uns die zweite Klausel verwenden: | ?- ancestor(zeb, john_boy_jr). true ?
Ja, zeb ist ein Vorfahre von john_boy_jr. Wie immer können wir in einer Query Variablen nutzen: | ?- ancestor(zeb, Who). Who = john_boy_sr ? a Who = john_boy_jr no
Und wir sehen, dass zeb ein Vorfahre von john_boy_jr und john_boy_sr ist. Das ancestor -Prädikat funktioniert auch anders herum: | ?- ancestor(Who, john_boy_jr). Who = john_boy_sr ? a Who = zeb (1 ms) no
Das ist ein wunderbare Sache, weil wir diese Regel unserer Wissensdatenbank für zwei Zwecke nutzen können. Wir können damit die Vorfahren ermitteln, aber auch die Nachkommen. Ein Wort der Warnung: Wenn Sie rekursive Teilziele verwenden, müssen Sie sehr vorsichtig sein, weil jedes rekursive Teilziel Platz auf dem Stack benötigt, der irgendwann mal überläuft. Deklarative Sprachen lösen dieses Problem häufig mit einer Technik, die man als Endrekursionsoptimierung bezeichnet. Wenn Sie das rekursive Teilziel am Ende einer rekursiven Regel platzieren können, optimiert Prolog den Aufruf und bereinigt den Aufruf-Stack. Auf diese Weise bleibt die Nutzung des Speichers konstant. Unser Aufruf ist Endrekursiv („tail recursive“), weil das rekursive Teilziel ancestor(Z, Y) das letzte Ziel der rekursiven Regel ist. Wenn ihr Prolog-Programm mit einem Stack-Überlauf abstürzt, wissen Sie, dass es an der Zeit ist, nach einer Möglichkeit zu suchen, die Sache mithilfe von Endrekursion zu optimieren. Nachdem wir diesen letzten organisatorischen Punkt geklärt haben, wollen wir uns Listen und Tupel ansehen.
Tag 2: Fünfzehn Minuten für Wapner 115
Listen und Tupel Listen und Tupel sind wichtige Bestandteile von Prolog. Sie geben Listen mit [1, 2, 3] an und Tupel mit (1, 2, 3). Listen sind Container variabler Länge, während Tupel Container fester Länge sind. Sowohl Listen als auch Tupel werden sehr viel mächtiger, wenn man sie unter dem Aspekt der Unifizierung betrachtet.
Unifizierung, Teil 2 Denken Sie daran, dass Prolog versucht, beide Seiten übereinstimmen zu lassen, wenn es Variablen unifiziert. Zwei Tupel stimmen überein, wenn die Anzahl der Elemente übereinstimmt und alle Elemente gleich sind. Sehen wir uns einige Beispiele an: | ?- (1, 2, 3) = (1, 2, 3). yes | ?- (1, 2, 3) = (1, 2, 3, 4). no | ?- (1, 2, 3) = (3, 2, 1). no
Zwei Tupel sind gleich, wenn alle Elemente gleich sind. Das erste Tupel ist ein Treffer, die Tupel im zweiten Beispiel haben nicht die gleiche Anzahl von Elementen, und im dritten Beispiel liegen die Elemente nicht in der gleichen Reihenfolge vor. Nun mischen wir ein paar Variablen unter: | ?- (A, B, C) = (1, 2, 3). A = 1 B = 2 C = 3 yes | ?- (1, 2, 3) = (A, B, C). A = 1 B = 2 C = 3 yes | ?- (A, 2, C) = (1, B, 3). A = 1 B = 2 C = 3 yes
116 Kapitel 4: Prolog Es spielt tatsächlich keine Rolle, auf welcher Seite die Variablen stehen. Die Unifizierung erfolgt, wenn Prolog sie gleich machen kann. Kommen wir nun zu den Listen. Sie können wie Tupel arbeiten: | ?- [1, 2, 3] = [1, 2, 3]. yes | ?- [1, 2, 3] = [X, Y, Z]. X = 1 Y = 2 Z = 3 yes | ?- [2, 2, 3] = [X, X, Z]. X = 2 Z = 3 yes | ?- [1, 2, 3] = [X, X, Z]. no | ?- []= [].
Die beiden letzten Beispiele sind interessant. [X, X, Z] und [2, 2, 3] werden unifiziert, weil Prolog sie mit X = 2 gleichsetzen kann. [1, 2, 3] = [X, X, Z] werden nicht unifiziert, weil wir X für die erste und die zweite Position verwendet haben und die Werte unterschiedlich sind. Listen besitzen eine Fähigkeit, die Tupel nicht haben. Sie können Listen mit [Head|Tail] zerlegen. Wenn Sie eine Liste mit diesem Konstrukt unifizieren, bindet Head das erste Element der Liste und Tail den Rest: | ?- [a, b, c] = [Head|Tail]. Head = a Tail = [b,c] yes
[Head|Tail] unifiziert keine leere Liste, aber ein Liste mit einem Element funktioniert: | ?- [] = [Head|Tail]. no | ?- [a] = [Head|Tail]. Head = a Tail = [] yes
Tag 2: Fünfzehn Minuten für Wapner 117 Durch verschiedene Kombinationen kann das Ganze recht kompliziert werden: | ?- [a, b, c] = [a|Tail]. Tail = [b,c] (1 ms) yes
Prolog erkennt das a und unifiziert den Rest mit Tail. Man kann dieses Tail auch noch weiter in Head und Tail aufteilen: | ?- [a, b, c] = [a|[Head|Tail]]. Head = b Tail = [c] yes
Oder man kann das dritte Element ermitteln: | ?- [a, b, c, d, e] = [_, _|[Head|_]]. Head = c yes
_ ist ein Platzhalter („Wildcard“) und unfiziert mit allem. Er bedeutet: „Es ist mir egal, was an dieser Position steht.“ Wir haben Prolog angewiesen, die ersten beiden Elemente zu überspringen und den Rest in Head und Tail aufzuteilen. Das Head greift sich das dritte Element; das abschließende _ schnappt sich das Tail, ignoriert also den Rest der Liste.
Das sollte reichen, um loslegen zu können. Unifizierung ist ein mächtiges Werkzeug. Und zusammen mit Listen und Tupeln wird es noch leistungsfähiger. Sie sollten nun ein grundlegendes Verständnis der elementaren PrologDatenstrukturen besitzen und wissen, wie die Unifizierung funktioniert. Wir sind nun so weit, dass wir diese Elemente mit Regeln und Folgerungen kombinieren können, um grundlegende mathematische Operationen mithilfe von Logik anzugehen.
Listen und Mathematik Im nächsten Beispiel werden Sie sehen, wie man Rekursion und Mathematik auf Listen anwendet. Wir werden zählen, summieren und Durchschnittswerte ermitteln. Fünf Regeln erledigen die ganze Arbeit.
118 Kapitel 4: Prolog prolog/list_math.pl
count(0, []). count(Count, [Head|Tail]) :- count(TailCount, Tail), Count is TailCount + 1. sum(0, []). sum(Total, [Head|Tail]) :- sum(Sum, Tail), Total is Head + Sum. average(Average, List) :- sum(Sum, List), count(Count, List),Average is Sum/Count.
Das einfachste Beispiel ist count. Sie benutzen es so: | ?- count(What, [1]). What = 1 ? ; no
Die Regeln sind extrem einfach. Die Anzahl einer leeren Liste ist 0. Die Anzahl einer Liste entspricht der Anzahl von Tail plus eins. Sehen wir uns Schritt für Schritt an, wie das funktioniert: 앫
Wir stoßen die Query count(What, [1]) an. Diese kann mit der ersten Regel nicht unifiziert werden, weil die Liste nicht leer ist. Um unser Ziel zu erreichen, machen wir mit der zweiten Regel weiter: count(Count, [Head|Tail]). Wir unifizieren, indem wir Count an Was binden, Head an 1 und Tail an [ ].
앫
Nach der Unifizierung ist count(TailCount, []) das erste Ziel. Wir versuchen, dieses Teilziel zu beweisen. Diesmal wird über die erste Regel unifiziert. Dadurch wird TailCount an 0 gebunden. Die erste Regel ist nun erfüllt, und wir können uns dem zweiten Ziel zuwenden.
앫
Nun evaluieren wir Count is TailCount + 1. Wir können Variablen unifizieren. TailCount ist an 0 gebunden, und wir binden Count an 0+ 1, also 1.
Und das war’s. Wir haben keinen rekursiven Prozess definiert, sondern logische Regeln. Das nächste Beispiel addiert die Elemente einer Liste auf. Hier noch einmal der Code für diese Regeln: sum(0, []). sum(Total, [Head|Tail]) :- sum(Sum, Tail), Total is Head + Sum.
Dieser Code arbeitet genau wie die count-Regel. Er hat außerdem zwei Klauseln, einen Basisfall und einen rekursiven Fall. Die Verwendung ist ähnlich: | ?- sum(What, [1, 2, 3]).
Tag 2: Fünfzehn Minuten für Wapner 119 What = 6 ? ; no
Sieht man sich das „imperativ“ an, funktioniert sum genau so, wie man es bei einer rekursiven Sprache erwartet. Die Summe einer leeren Liste ist null, und die Summe des Rests ist der Kopfteil (Head) plus der Summe des Fußteils (Tail). Doch es gibt noch eine andere Interpretation. Wir haben Prolog eigentlich noch nicht mitgeteilt, wie man Summen berechnet. Wir haben Summen bloß durch Regeln und Ziele beschrieben. Um bestimmte Ziele erreichen zu können, muss die Logik-Engine bestimmte Teilziele erreichen. Die deklarative Interpretation ist wie folgt: „Die Summe einer leeren Liste ist null und die Summe einer Liste Total, wenn wir beweisen können, dass die Summe von Head plus Tail Total ist“. Wir ersetzen die Rekursion durch die Vorstellung von Zielen und Teilzielen. In gleicher Weise ist die Anzahl bei einer leeren Liste null. Die Anzahl einer Liste entspricht eins für Head plus der Anzahl von Tail. Wie bei der Logik können diese Regeln aufeinander aufbauen. Zum Beispiel können Sie sum und count zusammen nutzen, um den Mittelwert (Average) zu berechnen: average(Average, List) :- sum(Sum, List), count(Count, List), Average
is Sum/Count.
Der Mittelwert von List ist also Average, wenn Sie Folgendes beweisen können: 앫
Die Summe dieser Liste ist Sum,
앫
die Anzahl dieser Liste ist Count und
앫 Average
(also der Durchschnitt) ist Sum/Count.
Und es funktioniert genau so, wie Sie es erwarten: | ?- average(What, [1, 2, 3]). What = 2.0 ? ; no
Regeln in beiden Richtungen verwenden Jetzt sollten Sie ganz gut verstanden haben, wie Rekursion funktioniert. Ich schalte nun einen Gang hoch und spreche über eine kleine
120 Kapitel 4: Prolog Regel namens append. Die Regel append(List1, List2, List3) ist wahr, wenn List3 gleich List1 + List2 ist. Das ist eine sehr mächtige Regel, die Sie vielseitig einsetzen können. Dieses kleine Stück Code hat es in sich. Sie können es auf unterschiedliche Art verwenden. Es ist ein Lügendetektor. | ?- append([oil], [water], [oil, water]). yes | ?- append([oil], [water], [oil, slick]). no
Es baut Listen auf: | ?- append([tiny], [bubbles], What). What = [tiny,bubbles] yes
Es subtrahiert Listen: | ?- append([dessert_topping], Who, [dessert_topping, floor_wax]). Who = [floor_wax] yes
Und es berechnet mögliche Permutationen: | ?- append(One, Two, [apples, oranges, bananas]). One= [] Two = [apples,oranges,bananas] ? a One = [apples] Two = [oranges,bananas] One = [apples,oranges] Two = [bananas] One = [apples,oranges,bananas] Two = [] (1 ms) no
Eine Regel liefert Ihnen also vier Möglichkeiten. Man möchte meinen, dass der Aufbau einer solchen Regel viel Code verlange. Finden wir heraus, wie viel es genau ist. Wir wollen das Prolog-append nachbilden, doch wir nennen es concatenate. Wir gehen das in mehreren Schritten an:
Tag 2: Fünfzehn Minuten für Wapner 121 1. Wir schreiben eine Regel namens concatenate(List1, List2, List3), die eine leere Liste mit List1 verketten kann. 2. Wir fügen eine Regel ein, die ein Element aus List1 mit List2 verkettet. 3. Wir fügen eine Regel ein, die zwei und drei Elemente aus List1 mit List2 verkettet. 4. Wir sehen uns an, was wir verallgemeinern können. Unser erster Schritt besteht darin, eine leere Liste mit List1 zu verketten. Das ist eine recht einfache Regel: prolog/concat_step_1.pl
concatenate([], List, List).
Kein Problem. concatenate ist wahr, wenn der erste Parameter eine Liste und die beiden nächsten Parameter gleich sind. Das funktioniert: | ?- concatenate([], [harry], What). What = [harry] yes
Weiter mit dem nächsten Schritt. Wir fügen eine Regel ein, die das erste Elemente von List1 an den Anfang von List2 setzt: prolog/concat_step_2.pl
concatenate([], List, List). concatenate([Head|[]], List, [Head|List]).
Für concatenate(List1, List2, List3) zerlegen wir List1 in Head und Tail, wobei Tail eine leere Liste ist. Wir zerlegen unser drittes Element in Head und Tail und benutzen den Head von List1 und List2 als Tail. Vergessen Sie nicht, die Wissensdatenbank zu kompilieren. Auch das funktioniert: | ?- concatenate([malfoy], [potter], What). What = [malfoy,potter] yes
Nun können wir eine Reihe weiterer Regeln definieren, die Listen der Länge 2 und 3 verketten. Sie funktionieren auf die gleiche Art und Weise:
122 Kapitel 4: Prolog prolog/concat_step_3.pl
concatenate([], List, List). concatenate([Head|[]], List, [Head|List]). concatenate([Head1|[Head2|[]]], List, [Head1, Head2|List]). concatenate([Head1|[Head2|[Head3|[]]]], List, [Head1, Head2, Head3|List]). | ?- concatenate([malfoy, granger], [potter], What). What = [malfoy,granger,potter] yes
Wir haben also einen Basisfall und eine Strategie, bei der jedes Teilziel die erste Liste verkleinert und die dritte Liste vergrößert. Die zweite bleibt unverändert. Wir besitzen nun genug Informationen, um das Ergebnis zu verallgemeinern. Hier die Verkettung mithilfe verschachtelter Regeln: prolog/concat.pl
concatenate([], List, List). concatenate([Head|Tail1], List, [Head|Tail2]) :concatenate(Tail1, List, Tail2).
Dieser kurze und knappe Codeblock ist unglaublich einfach zu erklären. Die erste Klausel besagt, dass die Verkettung einer leeren Liste mit List genau diese Liste ergibt. Die zweite Klausel besagt, dass die Verkettung von List1 mit List2 genau dann List3 ergibt, wenn die HeadElemente von List1 und List3 gleich sind und Sie beweisen können, dass die Verkettung des Tail-Elements von List1 mit List2 das TailElement von List3 ist. Die Einfachheit und Eleganz dieser Lösung sind ein Beleg für die Leistungsfähigkeit von Prolog. Sehen wir uns an, was es mit der Query concatenate([1, 2], [3], What) macht. Wir gehen für jeden Schritt die Unifizierung durch. Denken Sie daran, dass wir die Regeln schachteln; jedes Mal, wenn wir versuchen, ein Teilziel zu überprüfen, haben wir es also mit anderen Variablen zu tun. Ich werde die wichtigen davon mit einem Buchstaben markieren, damit Sie den Überblick behalten. Ich zeige Ihnen, was passiert, wenn Prolog versucht, das nächste Teilziel zu beweisen. 앫
Wir beginnen mit concatenate([1, 2], [3], What)
앫
Die erste Regel trifft nicht zu, weil [1, 2] keine leere Liste ist. Wir unifizieren das zu concatenate([1|[2]], [3], [1|Tail2-A]) :- concatenate([2], [3], [Tail2-A])
Tag 2: Fünfzehn Minuten für Wapner 123 Alles außer dem zweiten Tail-Element wird unifiziert. Wir machen nun mit den Zielen weiter. Lassen Sie uns die rechte Seite unifizieren. 앫
Wir versuchen, die Regel concatenate<>([2], [3], [Tail2-A]) anzuwenden. Das liefert uns Folgendes: concatenate([2|[]], [3], [2|Tail2-B]) :- concatenate([], [3], Tail2-B) Beachten Sie, dass Tail2-B das Tail-Element von Tail2-A ist. Es ist nicht mit dem original Tail2 identisch. Doch nun müssen wir
die rechte Seite erneut unifizieren. 앫 concatenate([], [3], Tail2-C) :- concatenate([], [3], [3]) 앫
So, wir wissen, dass Tail2-C [3] ist. Nun können wir uns durch die Kette zurückarbeiten. Sehen wir uns den dritten Parameter an und tragen Tail2 bei jedem Schritt ein. Tail2-C ist [3], d. h. [2|Tail2-2] ist [2, 3], und schließlich ist [1|Tail2] [1, 2, 3]. Was ist also [1, 2, 3]?
Prolog erledigt hier eine ganze Menge Arbeit für Sie. Gehen Sie die Liste durch, bis Sie es verstehen. Die Unifizierung verschachtelter Teilziele ist ein Kernkonzept für die komplizierteren Aufgaben in diesem Buch. Nun haben Sie einen tieferen Einblick in eine der vielseitigsten PrologFunktionen gewonnen. Nehmen Sie sich etwas Zeit, um sich die Lösungen anzusehen, und stellen Sie sicher, dass Sie sie verstanden haben.
Was wir am zweiten Tag gelernt haben In diesem Abschnitt haben wir uns den grundlegenden Bausteinen zugewandt, mit deren Hilfe Prolog Daten organisiert: Listen und Tupel. Wir haben außerdem Regeln verschachtelt. Das erlaubt es uns, Probleme auszudrücken, die andere Sprachen mit Iteration lösen würden. Wir haben einen genaueren Blick auf die Prolog-Unifizierung geworfen und darauf, wie Prolog arbeitet, um mit beiden Seiten von :- und = mithalten zu können. Wir haben beim Schreiben von Regeln gesehen, dass wir logische Regeln beschreiben und nicht Algorithmen, und haben es dann Prolog überlassen, sich den Weg zur Lösung zu bahnen. Wir haben auch Mathematik genutzt, und zwar grundlegende Arithmetik und verschachtelte Teilziele, um Summen und Durchschnitte zu berechnen.
124 Kapitel 4: Prolog Schließlich haben Sie gelernt, Listen zu verwenden. Wir haben ein oder mehrere Variablen innerhalb einer Liste mit Variablen verglichen und (noch wichtiger) das Head-Element einer Liste und die restlichen Elemente über das [Head|Tail]-Muster mit Variablen verglichen. Wir haben diese Technik genutzt, um rekursiv über Listen zu iterieren. Diese Grundbausteine dienen uns als Grundlage für die komplexen Probleme, die wir an Tag 3 lösen werden.
Tag 2: Selbststudium Finden Sie Folgendes: 앫
Einige Implementierungen von Fibonacci-Folgen und -Brüchen. Wie funktionieren sie?
앫
Eine reale Community, die Prolog nutzt. Welche Probleme löst man heutzutage mit der Sprache?
Wenn Sie etwas anspruchsvolleres suchen, in das Sie sich verbeißen können, probieren Sie es mit den folgenden Problemen: 앫
Eine Implementierung der Türme von Hanoi. Wie funktioniert sie?
앫
Was sind einige der Probleme beim Umgang mit „Nicht“-Ausdrücken?
앫
Warum muss man bei Prolog mit der Negation so vorsichtig sein?
Machen Sie Folgendes:
4.4
앫
Kehren Sie die Elemente einer Liste um.
앫
Finden Sie das kleinste Element einer Liste.
앫
Sortieren Sie die Elemente einer Liste.
Tag 3: Die Bank sprengen Sie sollten nun besser verstehen, warum ich Rain Man, den Autisten mit Savant-Syndrom, für Prolog gewählt habe. Auch wenn sie manchmal nur schwer zu verstehen ist, ist es verblüffend, sich Programmierung auf diese Weise vorzustellen. Eine meiner Lieblingsstellen in Rain Man ist, als Rays Bruder erkennt, dass Ray Karten zählen kann. Raymond und sein Bruder fahren nach Vegas und sprengen die Bank. In diesem Abschnitt werden Sie eine Seite von Prolog kennenlernen, die Ihnen ein Lächeln ins Gesicht zaubern wird. Die Kodierung der Bei-
Tag 3: Die Bank sprengen 125 spiele in diesem Kapitel hat mich gleichermaßen wahnsinnig und glücklich gemacht. Wir werden zwei berühmte Rätsel lösen, die genau der Kragenweite von Prolog entsprechen, nämlich Probleme mit Randbedingungen zu lösen. Vielleicht wollen Sie sich an einigen dieser Rätsel selbst versuchen. Dann sollten Sie die Regeln beschreiben, die Sie bezüglich der Spiele kennen. Sie sollten nicht versuchen, Prolog eine Schritt-für-SchrittLösung zu zeigen. Wir beginnen mit einem kleinen Sudoku. Sie können dann im Rahmen Ihrer täglichen Übungen größere aufbauen. Danach wenden wir uns dem klassischen Acht-Damen-Problem zu.
Sudokus lösen Das Programmieren des Sudokus hatte für mich etwas Magisches. Ein Sudoku ist ein Raster aus Zeilen, Spalten und Kästchen. Ein typisches Rätsel verwendet ein 9x9-Raster, bei dem einige Kästchen gefüllt sind und einige nicht. Jedes Kästchen des Rasters besitzt eine Nummer, bei einem 9x9-Quadrat von 1 bis 9. Ihre Aufgabe besteht darin, die Kästchen so mit Ziffern aufzufüllen, dass jede Zeile, jede Spalte und das Quadrat alle Ziffern enthält. Wir wollen mit einem 4x4-Sudoku beginnen. Die Konzepte sind gleich, nur die Lösung ist kürzer. Wir wollen damit beginnen, die Welt so zu beschreiben, wie wir sie kennen. Abstrakt betrachtet, haben wir ein Brett mit vier Spalten, vier Zeilen und vier Quadraten. Die Tabelle zeigt die Quadrate 1 bis 4: 1 1
2
2
1 1 3 3 3 3
2 4 4
2 4 4
Die erste Aufgabe besteht darin, zu entscheiden, wie die Query aussehen soll. Das ist einfach. Wir haben ein Rätsel und eine Lösung der Form sodoku(Puzzle, Solution). Der Benutzer kann ein Rätsel in Form einer Liste eingeben, wobei er unbekannte Zahlen durch Unterstriche ersetzt: sodoku([_, _, 2, 3, _, _, _, _, _, _, _, _, 3, 4, _, _], Solution).
126 Kapitel 4: Prolog Wenn eine Lösung existiert, liefert Prolog sie zurück. Als ich dieses Rätsel mit Ruby löste, musste ich mir Gedanken um die Algorithmen zur Lösung dieses Problems machen. Bei Prolog ist das nicht der Fall. Ich muss nur die Regeln des Spiels angeben. Hier sind sie: 앫
Für ein gelöstes Rätsel müssen die Zahlen im Rätsel und in der Lösung gleich sein.
앫
Ein Sudoku-Brett ist ein Raster aus 16 Zellen mit Werten von 1 bis 4.
앫
Das Spielbrettt besteht aus vier Zeilen, vier Spalten und vier Quadraten.
앫
Ein Rätsel ist gültig, wenn sich die Elemente jeder Zeile, jeder Spalte und jedes Quadrats nicht wiederholen.
Wir wollen am Anfang beginnen. Die Zahlen in der Lösung und im Rätsel müssen übereinstimmen: prolog/sudoku4_step_1.pl
sudoku(Puzzle, Solution) :Solution = Puzzle.
Wir haben tatsächlich Fortschritte gemacht. Unser „Sudoku-Löser“ funktioniert für den Fall, dass es keine leeren Stellen gibt: | ?- sudoku([4, 2, 1, 3,
1, 3, 2, 4,
2, 4, 3, 1,
3, 1, 4, 2], Solution).
Solution = [4,1,2,3,2,3,4,1,1,2,3,4,3,4,1,2] yes
Das Format ist nicht schön, doch der Zweck ist klar. Wir erhalten 16 Zahlen (Zeile für Zeile) zurück. Doch wir sind ein wenig zu gierig: | ?- sudoku([1, 2, 3], Solution). Solution = [1,2,3] yes
Unser Spielbrett ist ungültig, oder unser Lösungsprogramm meldet eine gültige Lösung. Natürlich müssen wir das Spielbrett auf 16 Elemente beschränken. Es gibt noch ein weiteres Problem. Die Werte in den Zellen können beliebig sein:
Tag 3: Die Bank sprengen 127 | ?- sudoku([1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6], Solution). Solution = [1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6] yes
Damit die Lösung gültig ist, muss sie Zahlen zwischen 1 und 4 verwenden. Dieses Problem wirkt sich für uns auf zweierlei Weise aus: Zum einen können wir einige ungültige Lösungen erlauben, zum anderen besitzt Prolog nicht genügend Informationen, um mögliche Werte für jede Zelle zu testen. Mit anderen Worten ist die Ergebnismenge nicht geerdet, d. h. wir haben keine Regeln definiert, die mögliche Werte für jede Zelle einschränken, weshalb Prolog die Werte nicht ermitteln kann. Wir wollen diese Probleme lösen, indem wir die nächste Regel des Spiels implementieren. Regel 2 besagt, dass das Spielbrett 16 Felder mit Werten zwischen 1 und 4 besitzt. GNU-Prolog besitzt ein fest eingebautes Prädikat namens fd_domain(Liste, Untergrenze, Obergrenze), um mögliche Werte auszudrücken. Dieses Prädikat gibt „wahr“ zurück, wenn alle Werte der Liste zwischen Unter- und Obergrenze (einschließlich) liegen. Wir müssen nur sicherstellen, dass alle Werte des Sudokus im Bereich von 1 bis 4 liegen. prolog/sudoku4_step_2.pl
sudoku(Puzzle, Solution) :Solution = Puzzle, Puzzle = [S11, S12, S13, S21, S22, S23, S31, S32, S33, S41, S42, S43, fd_domain(Puzzle, 1, 4).
S14, S24, S34, S44],
Wir haben Puzzle mit einer Liste von 16 Variablen unifiziert und die Domäne der Zellen auf Werte zwischen 1 und 4 beschränkt. Nun scheitern wir, wenn das Rätsel nicht gültig ist: | ?- sudoku([1, 2, 3], Solution). no | ?- sudoku([1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6], Solution). no
Nun gelangen wir zum Kernstück der Lösung. Regel 3 besagt, dass unser Spielbrett aus Zeilen, Spalten und Quadraten besteht. Sie können erkennen, warum wir die Zellen so benannt haben. Die Zeilen zu beschreiben, ist ein einfacher Prozess:
128 Kapitel 4: Prolog Row1 Row2 Row3 Row4
= = = =
[S11, [S21, [S31, [S41,
S12, S22, S32, S42,
S13, S23, S33, S43,
S14], S24], S34], S44],
Das gilt auch für die Spalten: Col1 Col2 Col3 Col4
= = = =
[S11, [S12, [S13, [S14,
S21, S22, S23, S24,
S31, S32, S33, S34,
S41], S42], S43], S44],
Und für die Quadrate: Square1 Square2 Square3 Square4
= = = =
[S11, [S13, [S31, [S33,
S12, S14, S32, S34,
S21, S23, S41, S43,
S22], S24], S42], S44].
Wenn wir unser Spielbrett in Teile zerlegt haben, können wir mit der nächsten Regel weitermachen. Das Spielbrett ist nur gültig, wenn alle Zeilen, Spalten und Quadrate keine sich wiederholenden Elemente enthalten. Wir werden ein Prädikat von GNU-Prolog verwenden, um auf sich wiederholende Elemente zu prüfen. fd_all_different(List) gibt „wahr“ zurück, wenn alle Elemente der Liste unterschiedlich sind. Wir müssen eine Regel aufbauen, die überprüft, ob alle Zeilen, Spalten und Quadrate gültig sind. Wir verwenden dafür eine einfache Regel: valid([]). valid([Head|Tail]) :fd_all_different(Head), valid(Tail).
Dieses Prädikat ist gültig, wenn alle Listen verschieden sind. Die erste Klausel besagt, dass eine leere Liste gültig ist. Die zweite Klausel besagt, dass eine Liste gültig ist, wenn die Einträge des ersten Elements alle verschieden sind und der Rest der Liste gültig ist. Bleibt uns nur noch, die valid(Liste)-Regel aufzurufen: valid([Row1, Row2, Row3, Row4, Col1, Col2, Col3, Col4, Square1, Square2, Square3, Square4]).
Ob Sie es glauben oder nicht, wir sind fertig. Unser Programm kann ein 4x4-Sudoku lösen: | ?- sudoku([_, _, 2, 3, _, _, _, _, _, _, _, _, 3, 4, _, _], Solution).
Tag 3: Die Bank sprengen 129 Solution = [4,1,2,3,2,3,4,1,1,2,3,4,3,4,1,2] yes
Bringen wir das in eine etwas freundlichere Form, haben wir die Lösung: 4 1
2
3
2 3 1 2 3 4
4 3 1
1 4 2
Hier noch einmal das vollständige Programm: prolog/sudoku4.pl
valid([]). valid([Head|Tail]) :fd_all_different(Head), valid(Tail). sudoku(Puzzle, Solution) :Solution = Puzzle, Puzzle = [S11, S21, S31, S41,
S12, S22, S32, S42,
S13, S23, S33, S43,
S14, S24, S34, S44],
fd_domain(Solution, 1, 4), Row1 Row2 Row3 Row4
= = = =
[S11, [S21, [S31, [S41,
S12, S22, S32, S42,
S13, S23, S33, S43,
S14], S24], S34], S44],
Col1 Col2 Col3 Col4
= = = =
[S11, [S12, [S13, [S14,
S21, S22, S23, S24,
S31, S32, S33, S34,
S41], S42], S43], S44],
Square1 Square2 Square3 Square4
= = = =
[S11, [S13, [S31, [S33,
S12, S14, S32, S34,
S21, S23, S41, S43,
S22], S24], S42], S44],
valid([Row1, Row2, Row3, Row4, Col1, Col2, Col3, Col4, Square1, Square2, Square3, Square4]).
Wenn Sie Ihre Prolog-Erleuchtung noch nicht hatten, sollte Ihnen dieses Beispiel einen Schubs in die richtige Richtung geben. Wo ist das Programm? Tja, wir haben kein Programm geschrieben. Wir haben die
130 Kapitel 4: Prolog Regeln des Spiels beschrieben: Das Spielbrett besteht aus 16 Zellen mit Zahlen zwischen 1 und 4, und in keiner der Zeilen, Spalten und Quadrate dürfen sich Werte wiederholen. Das Rätsel benötigt zur Lösung ein paar Dutzend Zeilen Code und keinerlei Wissen über irgendwelche Sudoku-Lösungsstrategien. In den täglichen Übungen erhalten Sie die Chance, ein neunzeiliges Sudoku zu lösen. Das sollte nicht allzu schwer sein. Dieses Rätsel ist ein großartiges Beispiel für die Art von Problemen, die Prolog gut lösen kann. Wir haben eine Reihe von Einschränkungen, die sich einfach ausdrücken, aber nur schwer lösen lassen. Sehen wir uns ein weiteres Rätsel an, bei dem es um stark eingeschränkte Ressourcen geht: das Acht-Damen-Problem.
Acht Damen Beim Acht-Damen-Problem werden acht Damen auf einem Schachbrett plaziert. Keine der Damen darf die gleiche Zeile, Spalte oder Diagonale nutzen. Auf den ersten Blick mag das ein triviales Problem sein. Nur ein Kinderspiel. Auf einer anderen Ebene kann man die Zeilen, Spalten und Diagonalen als beschränkte Ressourcen betrachten. Die Industrie ist voller Probleme, die eine Lösung derart beschränkter Systeme verlangen. Schauen wir uns an, wie wir dieses Problem mit Prolog lösen können. Sehen wir uns zuerst an, wie die Query aussehen muss. Wir können jede Dame als (Row, Col) beschreiben, ein Tupel mit Zeile und Spalte. Ein Brett (Board) ist eine Liste von Tupeln. eight_queens(Board) erreicht sein Ziel, wenn wir über ein gültiges Brett verfügen. Unsere Query wird wie folgt aussehen: eight_queens([(1, 1), (3, 2), ...]).
Sehen wir uns die Ziele an, die wir erfüllen müssen, um das Rätsel zu lösen. Wenn Sie sich an diesem Spiel versuchen wollen, ohne sich die Lösung anzusehen, sehen Sie sich nur diese Ziele an. Die vollständige Lösung behandle ich erst später in diesem Kapitel. 앫
Auf einem Brett sind acht Damen.
앫
Jede Dame hat eine Zeile von 1 bis 8 sowie eine Spalte von 1 bis 8.
앫
Zwei Damen dürfen nicht in der gleichen Zeile stehen.
앫
Zwei Damen dürfen nicht in der gleichen Spalte stehen.
Tag 3: Die Bank sprengen 131 앫
Zwei Damen dürfen nicht in derselben Diagonalen stehen (Südwest nach Nordost).
앫
Zwei Damen dürfen nicht in derselben Diagonalen stehen (Nordwest nach Südost).
1
Row
2 al on ag Di
al
on
ag
Di
Column
Zeilen und Spalten müssen einmalig sein, doch bei den Diagonalen müssen wir etwas vorsichtiger sein. Jede Dame liegt auf zwei Diagonalen. Die eine verläuft von unten links (Nordwest) nach oben rechts (Südost) und die andere von oben links nach unten rechts (siehe Abbildung 4.2). Doch diese Regeln sollten sich recht einfach programmieren lassen.
Row
Abbildung 4.2: Regeln für die acht Damen Wir wollen erneut mit dem ersten Punkt der Liste beginnen. Ein Spielbrett besitzt acht Damen. Das bedeutet, dass unsere Liste die Größe 8 haben muss. Das ist einfach. Wir können das von uns an früherer Stelle entwickelte count-Prädikat oder einfach das fest in Prolog eingebaute Prädikat length verwenden. length(List, N) gibt „wahr“ zurück, wenn die Liste N Elemente besitzt. Diesmal zeige ich Ihnen nicht jedes Ziel in Aktion, sondern gehe mit Ihnen die Ziele durch, die wir zur Lösung des gesamten Problems erreichen müssen. Hier also das erste Ziel: eight_queens(List) :- length(List, 8).
Als Nächstes müssen wir sicherstellen, dass jede Dame aus unserer Liste gültig ist. Wir entwickeln eine Regel, die überprüft, ob eine Dame gültig ist:
132 Kapitel 4: Prolog valid_queen((Row, Col)) :Range = [1,2,3,4,5,6,7,8], member(Row, Range), member(Col, Range).
Das Prädikat member macht genau, was Sie erwarten: Es überprüft die Zugehörigkeit. Eine Dame ist gültig, wenn sowohl die Zeile als auch die Spalte Integer-Werte zwischen 1 und 8 sind. Als Nächstes entwickeln wir eine Regel, die überprüft, ob das gesamte Spielbrett aus gültigen Damen besteht: valid_board([]). valid_board([Head|Tail]) :- valid_queen(Head), valid_board(Tail).
Ein Spielbrett ist gültig, wenn es leer ist, und auch wenn das erste Element eine gültige Dame und der Rest des Spielbretts gültig ist. Weiter geht’s. Die nächste Regel lautet, dass zwei Damen nicht dieselbe Zeile verwenden dürfen. Um die nächsten Einschränkungen lösen zu können, benötigen wir ein wenig Hilfe. Wir zerlegen das Programm in kleinere Teile, die uns dabei helfen, das Problem zu beschreiben: Was sind Zeilen, Spalten und Diagonalen? Zuerst kommen die Zeilen dran. Wir entwickeln eine Funktion namens rows(Queens, Rows). Diese Funktion liefert „wahr“ zurück, wenn Rows die Liste der Row-Elemente aller Damen ist. rows([], []). rows([(Row, _)|QueensTail], [Row|RowsTail]) :rows(QueensTail, RowsTail).
Hier brauchen wir ein wenig Fantasie, wenn auch nicht allzu viel. rows für eine leere Liste ist die leere Liste und rows(Queens, Rows) ist Rows, wenn die Zeile der ersten Dame in der Liste dem ersten Element von Rows entspricht, und wenn rows des Tail-Elements von Queens mit dem Tail-Element von Rows übereinstimmt. Falls Sie das verwirrt, gehen Sie sie mit ein paar Testlisten durch. Glücklicherweise funktionieren die Spalten genauso, nur dass wir hier die Spalten anstelle der Zeilen verwenden: cols([], []). cols([(_, Col)|QueensTail], [Col|ColsTail]) :cols(QueensTail, ColsTail).
Die Logik funktioniert exakt wie bei den Zeilen, nur dass wir diesmal anstelle des ersten das zweite Element des Damen-Tupels prüfen. Nun gilt es, die Diagonalen zu nummerieren. Die einfachste Lösung bilden einige einfache Additionen und Subtraktionen. Wenn Nord und West 1 sind, weisen wir den von Nordwest nach Südost verlaufenden
Tag 3: Die Bank sprengen 133 Diagonalen den Wert Col - Row zu. Hier das Prädikat, das diese Diagonalen festhält: diags1([], []). diags1([(Row, Col)|QueensTail], [Diagonal|DiagonalsTail]) :Diagonal is Col - Row, diags1(QueensTail, DiagonalsTail).
Diese Regel funktioniert genau wie rows und cols, besitzt aber eine weitere Einschränkung: Diagonal is Col -- Row. Beachten Sie, dass das keine Unifizierung ist! Es handelt sich um ein Prädikat und stellt sicher, dass wir eine fundierte Lösung abliefern. Abschließend verarbeiten wir Südost nach Nordwest wie folgt: diags2([], []). diags2([(Row, Col)|QueensTail], [Diagonal|DiagonalsTail]) :Diagonal is Col + Row, diags2(QueensTail, DiagonalsTail).
Diese Formel ist etwas komplizierter, also probieren Sie ruhig einige Werte aus, bis Sie sich sicher sind, dass Damen mit der gleichen Summe von Zeile und Spalte tatsächlich auf derselben Diagonalen liegen. Nachdem wir nun über die Regeln verfügen, mit deren Hilfe wir Zeilen, Spalten und Diagonalen beschreiben können, müssen wir nur noch sicherstellen, dass die Zeilen, Spalten und Diagonalen alle unterschiedlich sind. Damit Sie noch mal den ganzen Kontext sehen, folgt die vollständige Lösung. Die letzten acht Klauseln bilden die Tests für Zeilen und Spalten. prolog/queens.pl
valid_queen((Row, Col)) :Range = [1,2,3,4,5,6,7,8], member(Row, Range), member(Col, Range). valid_board([]). valid_board([Head|Tail]) :- valid_queen(Head), valid_board(Tail). rows([], []). rows([(Row, _)|QueensTail], [Row|RowsTail]) :rows(QueensTail, RowsTail). cols([], []). cols([(_, Col)|QueensTail], [Col|ColsTail]) :cols(QueensTail, ColsTail). diags1([], []). diags1([(Row, Col)|QueensTail], [Diagonal|DiagonalsTail]) :Diagonal is Col - Row, diags1(QueensTail, DiagonalsTail).
134 Kapitel 4: Prolog diags2([], []). diags2([(Row, Col)|QueensTail], [Diagonal|DiagonalsTail]) :Diagonal is Col + Row, diags2(QueensTail, DiagonalsTail). eight_queens(Board) :length(Board, 8), valid_board(Board), rows(Board, Rows), cols(Board, Cols), diags1(Board, Diags1), diags2(Board, Diags2), fd_all_different(Rows), fd_all_different(Cols), fd_all_different(Diags1), fd_all_different(Diags2).
Jetzt würde das Programm laufen, wenn Sie es ausführen ... und laufen ... und laufen. Es gibt einfach zu viele Kombinationen, um sie effektiv durchgehen zu können. Wenn wir mal scharf nachdenken, wissen wir aber, dass es nur eine Dame pro Zeile geben kann. Man kann der Lösung näher kommen, indem man folgendes Spielbrett vorgibt: | ?- eight_queens([(1, A), (2, B), (3, C), (4, D), (5, E), (6, F), (7, G), (8, H)]). A= B= C= D= E= F= G= H=
1 5 8 6 3 7 2 4?
Das funktioniert, aber das Programm arbeitet immer noch zu lang. Wir können die Auswahlmöglichkeiten für die Zeilen leicht eliminieren und die API vereinfachen, wo wir gerade dabei sind. Hier eine leicht optimierte Fassung: prolog/optimized_queens.pl
valid_queen((Row, Col)) :- member(Col, [1,2,3,4,5,6,7,8]). valid_board([]). valid_board([Head|Tail]) :- valid_queen(Head), valid_board(Tail). cols([], []). cols([(_, Col)|QueensTail], [Col|ColsTail]) :cols(QueensTail, ColsTail). diags1([], []). diags1([(Row, Col)|QueensTail], [Diagonal|DiagonalsTail]) :-
Tag 3: Die Bank sprengen 135 Diagonal is Col - Row, diags1(QueensTail, DiagonalsTail). diags2([], []). diags2([(Row, Col)|QueensTail], [Diagonal|DiagonalsTail]) :Diagonal is Col + Row, diags2(QueensTail, DiagonalsTail). eight_queens(Board) :Board = [(1, _), (2, _), (3, _), (4, _), (5, _), (6, _), (7, _), (8, _)], valid_board(Board), cols(Board, Cols), diags1(Board, Diags1), diags2(Board, Diags2), fd_all_different(Cols), fd_all_different(Diags1), fd_all_different(Diags2).
Philosophisch betrachtet, haben wir eine wesentliche Änderung vorgenommen. Wir haben das Spielbrett mit (1, _), (2, _), (3, _), (4, _), (5, _), (6, _), (7, _), (8, _) verglichen, um die Gesamtzahl der Permutationen deutlich zu reduzieren. Wir haben auch alle Regeln bezüglich der Zeilen entfernt. Auf meinem alten MacBook werden alle Lösungen innerhalb von drei Minuten berechnet. Erneut ist das Endergebnis recht ansprechend. Wir haben kaum Wissen über die Lösungsmenge eingebracht. Wir haben nur die Regeln des Spiels beschrieben und ein wenig Logik angewandt, um das Ganze ein wenig zu beschleunigen. Bei den richtigen Problemen kann ich mich für Prolog tatsächlich erwärmen.
Was wir an Tag 3 gelernt haben Heute haben wir einige Ideen zusammengefasst, die man verwenden kann, um mit Prolog klassische Denkaufgaben zu lösen. Die restriktionsbasierten Probleme weisen viele Charakteristika industrieller Anwendungen auf. Führen Sie die Restriktionen auf und zaubern Sie eine Lösung hervor. Wir würden bei der imperativen Programmierung einen SQL-Join über neun Tabellen nicht einmal in Erwägung ziehen, während wir gleichzeitig nicht zögern, logische Probleme auf diese Weise zu lösen. Wir haben mit einem Sudoku begonnen. Prologs Lösung war beeindruckend einfach. Wir haben 16 Variablen auf Zeilen, Spalten und Quadrate abgebildet. Dann haben wir die Regeln des Spiels beschrieben und jede Zeile, Spalte und jedes Quadrat gezwungen, „einmalig“ zu
136 Kapitel 4: Prolog sein. Prolog hat sich dann methodisch durch die Möglichkeiten gearbeitet und schnell eine Lösung gefunden. Wir haben Platzhalter und Variablen verwendet, um eine intuitive API aufzubauen, doch wir haben keinerlei Hilfestellungen für die Lösungstechniken gegeben. Als Nächstes haben wir das Rätsel der acht Damen gelöst. Wieder haben wir die Regeln des Spiels kodiert und Prolog eine Lösung ausarbeiten lassen. Dieses klassische Problem ist mit 92 Lösungen ziemlich rechenintensiv, doch selbst mit unserem einfachen Ansatz war es innerhalb weniger Minuten zu lösen. Ich kenne noch lange nicht alle Tricks und Techniken, um anspruchsvolle Sudokus zu lösen, aber mit Prolog muss ich sie auch gar nicht wissen. Ich muss nur die Regeln des Spiels kennen.
Tag 3: Selbststudium Finden Sie Folgendes: 앫
Prolog besitzt einige Features zur Ein- und Ausgabe. Finden Sie print-Prädikate, die Variablen ausgeben.
앫
Finden Sie eine Möglichkeit, die print-Prädikate so einzusetzen, dass nur erfolgreiche Lösungen ausgegeben werden. Wie funktionieren diese Lösungen?
Machen Sie Folgendes: 앫
Modifizieren Sie den Sudoku-Löser so, das er 6x6- (die Quadrate sind 3x2) und 9x9-Rätsel lösen kann.
앫
Lassen Sie den Sudoku-Löser schönere Lösungen ausgeben. Wer Rätsel mag, kann sich leicht in Prolog verlieren. Wenn Sie tiefer in die von mir vorgestellten Rätsel einsteigen wollen, sind die acht Damen ein guter Ausgangspunkt.
앫
Lösen Sie das Acht-Damen-Problem mithilfe einer Liste von Damen. Anstelle eines Tupels repräsentieren Sie jede Dame durch einen Integer-Wert zwischen 1 und 8. Bestimmen Sie die Zeile einer Dame anhand ihrer Position und die Spalte über den Wert in der Liste.
Prolog zusammengefasst 137
4.5
Prolog zusammengefasst Prolog ist eine der ältesten Sprachen in diesem Buch, doch die Ideen sind auch heute noch interessant und relevant. Prolog bedeutet Programmieren mit Logik. Wir haben Prolog verwendet, um Regeln zu verarbeiten, die aus Klauseln bestehen, die wiederum aus einer Reihe von Zielen bestehen. Prolog-Programmierung besteht aus zwei wesentlichen Schritten. Sie beginnen mit dem Aufbau einer Wissensdatenbank, die aus logischen Fakten und Schlussfolgerungen über die Problemdomäne besteht. Als Nächstes kompilieren Sie die Wissensdatenbank und stellen Fragen zu dieser Domäne. Einige der Fragen können Annahmen sein, auf die Prolog mit yes oder no antwortet. Andere Fragen verwenden Variablen. Prolog füllt diese Lücken so auf, dass diese (Ab-)Fragen wahr werden. Anstelle einfacher Zuweisungen verwendet Prolog einen als Unifizierung bezeichneten Prozess, der dafür sorgt, dass Variablen auf beiden Seiten des Systems übereinstimmen. Manchmal muss Prolog viele verschiedene mögliche Kombinationen durchgehen, um die Variablen für eine Schlussfolgerung unifizieren zu können.
Stärken Prolog ist für eine Vielzahl von Problemen geeignet, die von Flugplänen bis zu Finanzderivaten reichen. Prolog (und andere Sprachen seiner Art) zu lernen, ist nicht leicht, aber angesichts der anspruchsvollen Probleme, die es zu lösen vermag, ist es die Mühe wert. Denken Sie an Brian Tarbox’ Arbeit mit den Delfinen: Er konnte einfache Schlussfolgerungen über die Welt ziehen und mit einer komplexen Schlussfolgerung über das Verhalten von Delfinen einen Durchbruch erzielen. Er war auch in der Lage, stark eingeschränkte Ressourcen zu nehmen und mit Prolog einen Zeitplan zu finden, in den sie reinpassten. Es gibt so einige Bereiche, in denen Prolog heute noch aktiv eingesetzt wird.
Natürliche Sprachverarbeitung Prolog wurde zuerst zur Sprachverarbeitung genutzt. Tatsächlich können Prolog-Sprachmodelle natürliche Sprache nehmen, eine Wissensbasis aus Fakten und Schlussfolgerungen anwenden und die komplexe, ungenaue Sprache in konkrete Regeln umwandeln, die für Computer geeignet sind.
138 Kapitel 4: Prolog
Spiele Spiele werden immer komplexer, insbesondere die Modellierung von Konkurrenten oder Feinden. Prolog-Modelle können das Verhalten anderer Figuren im System recht einfach ausdrücken. Prolog kann auch unterschiedliche Verhaltensweisen für verschiedene Arten von Feinden definieren, was für eine realistischere und unterhaltsamere Spielerfahrung sorgt.
Semantisches Web Das semantische Web ist der Versuch, Dienste und Informationen mit einer Bedeutung anzureichern. Dadurch soll es einfacher werden, Anfragen zu beantworten. Ein RDF (Resource Description Framework) ermöglicht die grundlegende Beschreibung von Ressourcen. Ein Server kann diese Ressourcen in eine Wissensdatenbank kompilieren. Dieses Wissen zusammen mit Prologs natürlicher Sprachverarbeitung kann für den Endanwender eine ergiebige Erfahrung sein. Es existieren viele Prolog-Pakete, die die Art der Funktionalität im Kontext eines Webservers bereitstellen.
Künstliche Intelligenz Künstliche Intelligenz (KI) konzentriert sich darauf, Maschinen intelligentes Verhalten beizubringen. Diese Intelligenz kann verschiedene Formen annehmen, doch in allen Fällen verändert ein „Agent“ auf komplexen Regeln basierend sein Verhalten. Prolog tut sich auf diesem Gebiet hervor, insbesondere wenn die Regeln konkret sind und auf formaler Logik basieren. Aus diesem Grund wird Prolog manchmal auch als logische Programmiersprache bezeichnet.
Planung Prolog glänzt bei der Arbeit mit beschränkten Ressourcen. Prolog ist häufig zur Entwicklung von Betriebssystem-Schedulern und anderen anspruchsvollen Schedulern eingesetzt worden.
Schwächen Prolog ist eine Sprache, die mit der Zeit mithalten konnte. Dennoch ist die Sprache auf vielerlei Arten veraltet und weist signifikante Beschränkungen auf.
Prolog zusammengefasst 139
Nützlichkeit Prolog glänzt in seiner Kerndomäne, doch die Logikprogrammierung ist eine recht enge Nische. Prolog ist keine Allzweck-Programmiersprache und weist im Bezug auf das Sprachdesign einige Einschränkungen auf.
Sehr große Datenmengen Prolog verwendet eine tiefenbasierte Suche („depth-first search“) im Entscheidungsbaum, um alle möglichen Kombinationen mit den Regeln zu vergleichen. Verschiedene Sprachen und Compiler können diese Aufgabe gut optimieren. Dennoch ist diese Strat egie konzeptbedingt ziemlich rechenintensiv, insbesondere bei sehr großen Datenmengen. Deshalb sind Prolog-Nutzer außerdem gezwungen, sich Kenntnisse über die Funktionsweise der Sprache anzueignen, damit die Datenmenge handhabbar bleibt.
Mischen imperativer und deklarativer Modelle Wie bei vielen Sprachen aus der funktionalen Familie (insbesondere denjenigen, die stark auf Rekursion setzen), müssen Sie durchschauen, wie Prolog rekursive Regeln auflöst. Häufig müssen endrekursive Regeln verwendet werden, um selbst mittelschwere Probleme lösen zu können. Es ist ganz einfach, Prolog-Anwendungen zu entwickeln, die ab einer bestimmten Datenmenge nicht mehr gut skalieren. Sie müssen häufig ein tieferes Verständnis der Funktionsweise von Prolog mitbringen, um effektive Regeln entwickeln zu können, die akzeptabel skalieren.
Abschließende Gedanken Während ich die Sprachen in diesem Buch durcharbeitete, hätte ich mir regelmäßig selbst in den Hintern treten können. Ich musste nämlich bemerken, dass ich jahrelang Schrauben mit dem Hammer in die Wand gehauen hatte. Prolog war ein besonders schmerzliches Beispiel dafür. Wenn Sie ein Problem finden, das für Prolog besonders gut geeignet ist, sollten Sie den Vorteil wahrnehmen. In einem solchen Fall können Sie diese regelbasierte Sprache am besten in Kombination mit einer anderen Allzwecksprache einsetzen, genau wie Sie SQL in Ruby oder Java verwenden. Wenn Sie sie sorgfältig miteinander verknüpfen, werden Sie auf lange Sicht gut fahren.
We are not sheep. Edward Scissorhands
Kapitel 5
Scala Bisher habe ich drei Sprachen und drei unterschiedliche Programmierparadigmen vorgestellt. Bei Scala wird es, mehr oder weniger, zum vierten Mal so sein. Es handelt sich um eine Hybridsprache, die also bewusst versucht, eine Brücke zwischen den Programmierparadigmen zu schlagen. In diesem Fall ist das die Brücke zwischen objektorientierten Sprachen wie Java und funktionalen Sprachen wie Haskell. In diesem Sinn ist Scala eine Art Frankenstein, aber kein Monster. Denken Sie an Edward mit den Scherenhänden.1 In diesem surrealen Film von Tim Burton ist Edward halb Junge, halb Maschine, mit Scheren statt Händen. Er ist eine meiner Lieblingsfiguren, ein faszinierender Charakter in einem wunderschönen Film. Er war oft ungeschickt, manchmal staunenswert, doch immer mit einem einmaligen Ausdruck. Manchmal konnte er mit seinen Scherenhänden Unglaubliches vollbringen. Manchmal war er ungeschickt und wurde gedemütigt. Wie es bei allem Neuen oder Andersartigen so ist, wurde er oft falsch verstanden und bezichtigt, „zu weit von der Rechtschaffenheit abzuweichen“. Doch in einem seiner stärkeren Momente offenbart der schüchterne Junge: „Wir sind keine Schafe“. Allerdings.
5.1
Über Scala Während die Anforderungen an Computerprogramme immer komplexer werden, müssen sich auch die Sprachen weiterentwickeln. Alle 20 Jahre (oder so) reichen die alten Paradigmen nicht mehr aus, um die neuen Anforderungen an die Organisation und den Ausdruck neuer 1 Edward Scissorhands. DVD. Directed by Tim Burton. 1990; Beverly Hills, CA: 20th Century Fox, 2002.
142 Kapitel 5: Scala Ideen zu erfüllen. Neue Paradigmen müssen entstehen, doch dieser Prozess ist nicht so leicht. Jedes neue Programmierparadigma kommt mit einer Welle von Programmiersprachen daher, nicht nur mit einer. Die erste Sprache ist dabei häufig auffallend produktiv und wahnsinnig unpraktisch. Denken Sie an Smalltalk für Objekte oder Lisp für funktionale Sprachen. Dann bauen Sprachen mit anderen Paradigmen Features ein, die es erlauben, die neuen Konzepte zu übernehmen, während die Benutzer gleichzeitig innerhalb des alten Paradigmas weiterleben können. Ada machte es beispielsweise möglich, dass einige der Kernideen der objektorientierten Programmierung, etwa die Kapselung, innerhalb einer prozeduralen Programmiersprache existieren konnten. An irgendeinem Punkt bieten dann hybride Sprachen genau die richtige, praktische Brücke wischen dem alten und dem neuen Paradigma an, wie etwa C++. Als Nächstes kommt dann eine kommerziell akzeptable Sprache wie Java oder C#. Und zum Schluss folgen ausgereifte, reine Implementierungen des neuen Paradigmas.
Affinität zu Java ... Scala ist zumindest eine Brücke, und vielleicht noch mehr. Es bietet eine enge Integration in Java, was es den Leuten ermöglicht, ihre Investitionen auf vielerlei Arten zu schützen: 앫
Scala läuft auf der Java Virtual Machine, kann also Seite an Seite mit existierenden Anwendungen laufen.
앫
Scala kann Java-Bibliotheken direkt nutzen, Entwickler können also existierende Frameworks und alten Code nutzen.
앫
Wie Java ist auch Scala statisch typisiert, es gibt also ein philosopisches Band zwischen den Sprachen.
앫
Scalas Syntax ist relativ nah an Java dran, Entwickler können also die Grundlagen schnell erlernen.
앫
Scala unterstützt sowohl das objektorienterte als auch das funktionale Programmierparadigma, Programmierer können also schrittweise lernen, funktionale Programmierideen auf ihren Code anzuwenden.
Über Scala 143
Ohne sklavische Hingabe Einige Sprachen, die sich an ihren Vorfahren orientieren, gehen zu weit. Sie erweitern die beschränkenden Konzepte, die in der Basis unzulänglich sind. Obwohl die Ähnlichkeiten mit Java auffällig sind, weist Scalas Design einige signifikante Abweichungen auf, die seiner Community gute Dienste leisten. Diese Verbesserungen stellen wichtige Abweichungen von Java dar: 앫
Typinferenz. Bei Java müssen Sie den Typ jeder Variablen, jedes Arguments und jedes Parameters deklarieren. Scala leitet Variablentypen ab, wenn möglich.
앫
Funktionale Konzepte. Scala führt wichtige funktionale Konzepte in Java ein. Insbeslondere erlaubt es existierenden Funktionen, auf viele unterschiedliche Arten neue zu bilden. In diesem Kapitel vorgestellte Konzepte sind Codeblöcke, Funktionen höherer Ordnung und eine ausgeklügelte Collection-Bibliothek. Scala geht weit über grundlegenden syntaktischen Zucker hinaus.
앫
Unveränderliche Variablen. Java erlaubt unveränderliche Variablen, allerdings mit einem selten genutzten Modifikator. In diesem Kapitel werden Sie sehen, dass Scala explizit eine Entscheidung darüber erzwingt, ob eine Variable veränderlich ist oder nicht. Diese Entscheidungen haben tiefgehenden Einfluss darauf, wie sich Anwendungen in einem nebenläufigen Kontext verhalten.
앫
Fortgeschrittene Programmierkonstrukte. Scala nutzt die zugrunde liegende Sprache gut und baut auf nützlichen Konzepten auf. In diesem Kapitel werde ich Aktoren für die Nebenläufigkeit, Collections mit Funktionen höherer Ordnung im Ruby-Stil und eine erstklassige XML-Verarbeitung vorstellen.
Bevor wir eintauchen, sollten wir etwas über die Motivation hinter Scala erfahren. Wir werden etwas Zeit mit seinem Schöpfer verbringen. Wir konzentrieren uns dabei darauf, wie er zwei Programmierparadigmen miteinander verbunden hat.
Ein Interview mit Scalas Schöpfer Martin Odersky Martin Odersky, der Schöpfer von Scala, ist Professor an der École Polytechnique Fédérale de Lausanne (EPFL), einer der zwei staatlichen Technischen Universitäten der Schweiz. Er hat an der Spezifikation der Java Generics mitgearbeitet und ist Entwickler des javac-Referenz-
144 Kapitel 5: Scala Compilers. Er ist auch der Autor von „Programming in Scala: A Comprehensive Step-by-Step Guide“ [OSV08], einem der besten verfügbaren Scala-Bücher. Bruce: Warum haben Sie Scala geschrieben? Dr. Odersky: Ich war überzeugt davon, dass die Vereinigung funktionaler und objektorientierter Programmierung einen großen praktischen Nutzen haben würde. Ich war außerdem frustriert über die abschätzige Haltung der Verfechter funktionaler Programmierung und den Glauben objektorientierter Programmierer, dass die funktionale Programmierung nur eine akademische Übung sei. Also wollte ich zeigen, dass man diese beiden Paradigmen vereinen kann und etwas Neues und Leistungsfähiges bei dieser Kombination herauskommt. Ich wollte außerdem eine Sprache entwickeln, bei der ich mich beim Schreiben von Programmen persönlich wohl fühlen würde. Bruce: Was mögen Sie an ihr am meisten? Dr. Odersky: Ich mag, dass sich Programmierer frei ausdrücken können und dass sie sich so leichtgewichtig anfühlt, einen aber gleichzeitig durch das Typsystem stark unterstützt. Bruce: Welche Arten von Problemen löst sie am besten? Dr. Odersky: Sie ist wirklich für alle Aufgaben geeignet. Es gibt kein Problem, das ich nicht mit ihr angehen würde. Abgesehen davon liegt die besondere Stärke von Scala (im Vergleich zu anderen etablierten Sprachen) in der Unterstützung funktionaler Programmierung. Überall da, wo ein funktionaler Ansatz wichtig ist, glänzt Scala, sei es Nebenläufigkeit, Parallelität, mit XML arbeitende Webanwendungen oder DSLs. Bruce: Welches Feature würden Sie gerne ändern, wenn Sie noch einmal anfangen könnten? Dr. Odersky: Scalas lokale Typinferenz funktioniert generell ganz gut, hat aber ihre Grenzen. Wenn ich noch einmal anfangen könnte, würde ich versuchen, einen leistungsfähigeren Constraint-Solver zu nutzen. Vielleicht ist das immer noch möglich, aber die Tatsache, dass wir es mit einer großen installierten Basis zu tun haben, macht s etwas schwerer. Die Begeisterung über Scala wächst, seit Twitter seinen Nachrichtenverarbeitungskern von Ruby auf Scala umgestellt hat. Die objektorientierten Features erlauben einen recht reibungslosen Wechsel von Java,
Über Scala 145 doch die Ideen, die die Aufmerksamkeit auf Scala lenken, sind die Features zur funktionalen Programmierung. Rein funktionale Sprachen erlauben einen Programmierstil auf mathematischer Basis. Eine funktionale Sprache weist die folgenen Charakteristika auf: 앫
Funktionale Programme bestehen aus Funktionen.
앫
Eine Funktion liefert immer einen Wert zurück.
앫
Eine Funktion liefert bei der gleichen Eingabe immer dieselben Werte zurück.
앫
Funktionale Programme vermeiden Zustands- und Datenänderungen. Sobald ein Wert einmal gesetzt ist, bleibt er unverändert.
Genaugenommen ist Scala keine rein funktionale Programmiersprache, genau wie C++ keine rein objektorientierte Sprache ist. Sie erlaubt veränderliche Werte, was dazu führen kann, dass Funktionen bei gleichen Eingaben unterschiedliche Ergebnisse liefern. (Bei den meisten objektorientierten Sprachen bricht die Verwendung von Gettern und Settern diese Regel.) Doch sie bietet Werkzeuge an, die es Entwicklern erlauben, funktionale Abstraktionen zu verwenden, wenn diese einen Sinn ergeben.
Funktionale Programmierung und Nebenläufigkeit Das größte Problem, dem Programmierer objektorientierter Sprachen im Bezug auf Nebenläufigkeit gegenüberstehen, ist der veränderliche Zustand, also dass sich Daten ändern können. Jede Variable, die nach der Initialisierung mehr als einen Wert annehmen kann, ist veränderlich. Nebenläufigkeit ist der Dr. Evil für den veränderlichen Zustand Austin Powers. Wenn zwei Threads dieselben Daten zur selben Zeit ändern können, kann man nur schwer garantieren, dass die Ausführung die Daten in einem gültigen Zustand zurücklässt, und Testen ist nahezu unmöglich. Datenbanken begegnen diesem Problem mit Transaktionen und Locking. Objektorientierte Programmiersprachen begegnen diesem Problem, indem sie Programmierern Werkzeuge für die Zugriffskontrolle auf gemeinsam genutzte Daten zur Verfügung stellen. Und die Programmierer nutzen diese Werkzeuge im Allgemeinen nicht besonders gut, auch wenn sie wissen, wie es geht. Funktionale Programmiersprachen können diese Probleme lösen, indem sie veränderliche Zustände aus der Gleichung streichen. Scala zwingt Sie nicht dazu, veränderliche Zustände vollständig zu eliminieren, doch es gibt Ihnen die Werkzeuge an die Hand, um etwas im rein funktionalen Stil zu kodieren.
146 Kapitel 5: Scala Mit Scala müssen Sie sich nicht zwischen ein wenig Smalltalk und ein bisschen Lisp entscheiden. Lassen Sie uns die objektorientierten und funktionalen Welten durch Scala-Code miteinander vermischen.
5.2
Tag 1: Die Burg auf der Anhöhe Bei Edward mit den Scherenhänden gibt es eine Burg auf einer Anhöhe, die, nun, ein wenig anders ist. In früheren Zeiten war die Burg ein mysteriöser und bezaubernder Ort, doch nun zeigt er Anzeichen von Altern und Verfall. Kaputte Fenster schützen sie nicht mehr vor dem Wetter, und die Räume sind auch nicht mehr das, was sie einmal waren. Das Haus, das für seine Bewohner einmal so behaglich war, ist nun kalt und wenig einladend. Das objektorientierte Paradigma zeigt auch einige Alterserscheinungen, insbesondere die frühen objektorientierten Implementierungen. Java mit seiner veralteten Implementierung von statischer Typisierung und Nebenläufigkeit benötigt einen Facelift. In diesem Abschnitt werden wir über Scala hauptsächlich im Kontext der Burg auf dem Hügel reden, also des objektorientierten Programmierparadigmas. Scala läuft auf der Java Virtual Machine (JVM). Ich werde hier keinen umfassenden Überblick zu Java geben. Diese Informationen sind an anderer Stelle frei verfügbar. Sie werden einige Java-Ideen in Scala durchscheinen sehen, doch ich werde versuchen, deren Einfluss zu minimieren, damit Sie nicht zwei Sprachen auf einmal erlernen müssen. Fürs Erste sollten Sie Scala installieren. Ich verwende in diesem Buch die Version 2.7.7.final.
Scala-Typen Sobald Sie Scala am Laufen haben, öffnen Sie eine Konsole mit dem Befehl scala. Wenn alles gut geht, erhalten Sie keine Fehlermeldungen und es erscheint ein scala>-Prompt. Sie können dann etwas Code eingeben. typisierungsmodell:Scala scala> println("Hallo surreale Welt") Hallo surreale Welt scala> 1 + 1 res8: Int = 2 scala> (1).+(1) res9: Int = 2 scala> 5 + 4 * 3
Tag 1: Die Burg auf der Anhöhe 147 res10: Int = 17 scala> 5.+(4.*(3)) res11: Double = 17.0 scala> (5).+((4).*(3)) res12: Int = 17
Integer-Werte sind also Objekte. Bei Java habe ich mir so manches Bein ausgerissen, um zwischen Int (Primitiven) und Integer (Objekten) hin und her zu konvertieren. Tatsächlich ist bei Scala (mit einigen kleinen Ausnahmen) alles ein Objekt. Das ist eine deutliche Abkehr von den meisten statisch typisierten objektorientierten Sprachen. Sehen wir uns an, wie Scala mit Strings umgeht: scala> "abc".size res13: Int = 3
Auch ein String ist ein Objekt erster Güte, angereichert mit ein wenig syntaktischem Zucker. Lassen Sie uns eine Typkollision herbeiführen: scala> "abc" + 4 res14: java.lang.String = abc4 scala> 4 + "abc" res15: java.lang.String = 4abc scala> 4 + "1.0" res16: java.lang.String = 41.0
Hm ... das ist nicht ganz das, was wir erwartet haben. Scala macht aus diesen Integer-Werten Strings. Wir wollen etwas weiter gehen, um einen Fehler zu erzwingen: scala> 4 * "abc" :5: error: overloaded method value * with alternatives (Double)Double (Float)Float (Long)Long (Int)Int (Char)Int (Short)Int (Byte)Int cannot be applied to (java.lang.String) 4 * "abc" ^
Ah, das ist es. Scala ist tatsächlich stark typisiert. Scala nutzt die Typinferenz, es erkennt also meistens den Typ von Variablen über syntaktische Hinweise. Im Gegenlsatz zu Ruby kann Scala diese Typprüfung aber während der Kompilierung durchführen. Tatsächlich kompiliert Scalas Konsole die Codezeilen und führt sie Stück für Stück aus. Nebenbei bemerkt, erhalten Sie Java-Strings zurück. Die meisten Scala-Artikel und -Bücher gehen detaillierter auf dieses Thema ein, aber wir beschränken uns darauf, in die Programmierkonstrukte einzutauchen, die für Sie aus meiner Sicht am interessantesten sein dürften.
148 Kapitel 5: Scala Ich werde auf ein paar Bücher hinweisen, die die Java-Integration im Detail ansprechen. Im Augenblick sage ich Ihnen nur, dass Scala an vielen Stellen eine Strategie besitzt, um die Typen über zwei Sprachen hinweg zu verwalten. Ein Teil dieser Strategie besteht darin, einfache Java-Typen (wie java.lang.String) zu verwenden, wenn es sinnvoll ist. Bitte vertrauen Sie mir und akzeptieren Sie diese sehr starke Vereinfachung.
Ausdrücke und Bedingungen Nun wollen wir anhand einiger Beispiele schnell die grundlegende Syntax durchgehen. Hier einige „wahr/falsch“-Ausdrücke in Scala: scala> 5 < 6 res27: Boolean = true scala> 5 <= 6 res28: Boolean = true scala> 5 <= 2 res29: Boolean = false scala> 5 >= 2 res30: Boolean = true scala> 5 != 2 res31: Boolean = true
Daran ist nichts besonders Interessantes. Wir haben es mit einer Syntax im Stil von C zu tun, die Ihnen von den Sprachen her vertraut sein müsste, über die wir bisher gesprochen haben. Lassen Sie uns einen Ausdruck in einer if-Anweisung benutzen: scala> val a = 1 a: Int = 1 scala> val b = 2 b: Int = 2 scala> if ( b < a) { | println("true") | } else { | println("false") |} false
Wir weisen einigen Variablen Werte zu und vergleichen sie dann mit einer if/else-Anweisung. Werfen Sie einen genaueren Blick auf die Variablenzuweisung. Zuerst sollten Sie bemerken, dass Sie keinen Typ angeben. Im Gegensatz zu Ruby bindet Scala die Typen während der
Tag 1: Die Burg auf der Anhöhe 149 Kompilierung. Doch im Gegensatz zu Java kann Scala den Typ ableiten, weshalb Sie nicht val a : Int = 1 eingeben müssen (auch wenn Sie könnten, wenn Sie wollten). Beachten Sie als Nächstes, dass diese Scala-Variablendeklarationen mit dem Schlüsselwort val beginnen. Sie können auch das Schlüsselwort var verwenden. Während var veränderliche Variablen deklariert, sind mit val deklarierte unveränderlich. Darauf gehen wir später noch genauer ein. Bei Ruby evaluiert 0 zu true. Bei C war 0 false. Bei beiden Sprachen evaluierte nil zu false. Sehen wir uns an, wie Scala damit umgeht: scala> Nil res3: Nil.type = List() scala> if(0) {println("true")} :5: error: type mismatch; found : Int(0) required: Boolean if(0) {println("true")} ^ scala> if(Nil) {println("true")} :5: error: type mismatch; found : Nil.type (with underlying type object Nil) required: Boolean if(Nil) {println("true")} ^
Nil ist also eine leere Liste und man kann weder Nil noch 0 prüfen.
Dieses Verhalten steht im Einklang mit Scalas Philosophie der starken, statischen Typisierung. Nils und Zahlen sind nicht vom Typ boolean, also behandelt man sie nicht wie booleans. Nachdem wir einfache Ausdrücke und die grundlegendsten Entscheidungskonstrukte hinter uns haben, wollen wir mit Schleifen weitermachen.
Schleifen Da die nächsten Programme etwas komplexer werden, wollen wir sie als Skripten ausführen, nicht über die Konsole. Ähnlich wie bei Ruby und Io führen Sie sie mit scala pfad/zum/programm.scala aus. Sie werden eine Reihe von Möglichkeiten kennenlernen, über eine Ergebnismenge zu iterieren, wenn wir uns an Tag 2 den Codeblöcken zuwenden. Jetzt wollen wir uns auf den imperativen Programmierstil für Schleifen konzentrieren. Sie werden sehen, dass diese stark an Schleifenstrukturen von Java erinnern.
150 Kapitel 5: Scala
Mein innerer Kampf mit der statischen Typisierung Manch enthusiastischer Programmierneuling verwechselt schon mal die Konzepte der starken und der statischen Typisierung. Einfach ausgedrückt, bedeutet starke Typisierung, dass die Sprache erkennt, wenn zwei Typen kompatibel sind. Ist das nicht der Fall, gibt sie entweder einen Fehler aus oder erzwingt einen Typ. Oberflächlich betrachtet, sind Java und Ruby beide stark typisiert. (Mir ist bewusst, dass das eine starke Vereinfachung darstellt.) Assembler und C-Compiler sind hingegen schwach typisiert. Den Compiler interessiert es nicht unbedingt, ob es sich bei den Daten an einer Speicherstelle um einen Integer-Wert, einen String oder einfach um irgendwelche Daten handelt. Scala:Typisierungsmodelll. Der Unterschied zwischen statischer und dynamischer Typisierung ist ein anderes Thema. Statisch typisierte Sprachen erzwingen Polymorphismus basierend auf der Struktur des Typs. Es handelt sich aufgrund der genetischen Blaupause um eine Ente (statisch), oder es handelt sich um eine Ente, weil sie so schnattert oder so läuft. Statisch typisierte Sprachen haben einen Vorteil, weil Compiler und Tools mehr über ihren Code wissen und so Fehler abfangen, Code hervorheben und Refactoring ermöglichen können. Der Preis dafür ist, dass Sie mehr Arbeit haben und mit einigen Einschränkungen leben müssen. Ihre Entwicklerkarriere wird oft bestimmen, wie Sie sich mit den Nachteilen statischer Typisierung fühlen. Meine erste OO-Entwicklung erfolgte in Java. Ich sah ein Framework nach dem anderen bei dem Versuch, die Fesseln von Javas statischer Typisierung zu sprengen. Die Industrie investierte Hunderte von Millionen von Dollar in drei Versionen von Enterprise Java Beans, Spring, Hibernate, JBoss und aspektorientierte Programmierung, um bestimmte Nutzungsmodelle formbarer zu gestalten. Wir machten Javas Typisierungsmodell dynamischer, und die Schlachten waren bei jedem Schritt sehr heftig. Es fühlte sich mehr nach rivalisierenden Sekten denn nach Programmierumgebungen an. Meine Bücher hatten den gleichen Weg vor sich: von dynamischen Frameworks hin zu dynamischen Sprachen. Meine Vorbehalte gegenüber der statischen Typisierung wurden also durch die Java-Kriege geprägt. Haskell und sein großartiges statisches Typsystem haben das ein wenig abgemildert, aber nur langsam. Mein Gewissen ist rein. Sie haben einen heimlichen Politiker zu einem zwanglosen Abendessen eingeladen, und ich versuche mein Bestes, um die Konversation locker und unvoreingenommen zu führen.
Tag 1: Die Burg auf der Anhöhe 151 Als Erstes kommt die grundlegende while-Schleife: scala/while.scala
def
whileLoop { var i= 1 while(i <= 3) { println(i) i +=1 }
}
whileLoop Wir definieren eine Funktion. Java-Entwickler werden bemerken, dass kein public angegeben werden muss. Bei Scala ist public die Standardsichtbarkeit, diese Funktion ist also für alle sichtbar. Innerhalb der Methode deklarieren wir eine einfache while-Schleife, die bis drei zählt. i ändert sich, weshalb wir sie mit var deklarieren. Dann erkennen Sie die Deklaration einer while-Anweisung im Java-Stil. Wie Sie sehen können, wird der Code innerhalb der geschweiften Klammern ausgeführt, bis die Bedingung nicht mehr erfüllt wird (false). Sie können diesen Code wie folgt ausführen: batate$ scala code/scala/while.scala 1 2 3
Die for -Schleife arbeitet wie bei Java und C, verwendet aber eine etwas andere Syntax: scala/for_loop.scala
def
forLoop { println( "for loop using Java-style iteration" ) for(i <- 0 until args.length) { println(args(i)) }
}
forLoop Das Argument ist ein Variable, gefolgt vom Operator <-, gefolgt von einem Wertebereich für die Schleife in der Form startWert until endWert. In unserem Beispiel gehen wir alle Kommandozeilenargumente durch:
152 Kapitel 5: Scala batate$ scala code/scala/forLoop.scala its all in the grind for loop using Java-style iteration its all in the grind
Wie bei Ruby können Sie Schleifen auch benutzen, um Collections zu verarbeiten. Im Gedenken an Rubys each wollen wir mit foreach beginnen: scala/ruby_for_loop.scala
def
rubyStyleForLoop { println( "for loop using Ruby-style iteration" ) args.foreach { arg => println(arg) }
}
rubyStyleForLoop args ist eine Liste mit den übergebenen Kommandozeilenargumenten.
Scala übergibt jedes Element nacheinander an diesen Block. In unserem Beispiel ist arg ein Argument aus der args-Liste. Bei Ruby lautet der gleiche Code args.each {|arg| println(arg) }. Die Syntax zur Angabe der Argumente ist etwas unterschiedlich, doch das Konzept ist das gleiche. Hier der Code in Aktion: batate$ scala code/scala/ruby_for_loop.scala freeze those knees chickadees for loop using Ruby-style iteration freeze those knees chickadees
Sie werden feststellen, dass Sie diese Methode der Iteration später viel häufiger verwenden werden als imperative Schleifen. Doch da wir uns auf das Haus auf dem Hügel konzentrieren, verschieben wir diese Unterhaltung noch ein wenig.
Bereiche (Ranges) und Tupel Wie Ruby unterstützt auch Scala Wertebereiche (Ranges). Starten Sie die Konsole und geben Sie folgende Codefragmente ein: scala> val range = 0 until 10 range: Range = Range(0, 1, 2, 3, 4, 5, 6, 7, 8, 9)
Tag 1: Die Burg auf der Anhöhe 153 scala> range.start res2: Int = 0 scala> range.end res3: Int = 10
Das ergibt alles einen Sinn. Es funktioniert wie die Ranges bei Ruby. Sie können auch Inkremente festlegen: scala> range.step res4: Int = 1 scala> (0 to 10) by 5 res6: Range = Range(0, 5, 10) scala> (0 to 10) by 6 res7: Range = Range(0, 6)
Das Gegenstück zu Rubys Range 1..10 ist 1 to 10, und das Gegenstück zu Rubys 1...10 ist 1 until 10. to ist inklusiv: scala> (0 until 10 by 5) res0: Range = Range(0, 5)
Sie können auch eine Richtung angeben: scala> val range = (10 until 0) by -1 range: Range = Range(10, 9, 8, 7, 6, 5, 4, 3, 2, 1)
Doch die Richtung wird aus der Definition nicht automatisch erschlossen: scala> val range = (10 range: Range = Range()
until 0)
scala> val range = (0 to 10) range: Range.Inclusive = Range(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
1 ist die Standard-Schrittweite, unabhängig davon, welche Endpunkte Sie in Ihrem Wertebereich angeben. Sie sind nicht auf Integer-Werte beschränkt: scala> val range = 'a' to 'e' range: RandomAccessSeq.Projection[Char] = RandomAccessSeq.Projection(a, b, c, d, e)
Scala erledigt bestimmte implizite Typumwandlungen für Sie. Als Sie die for -Anweisung spezifizierten, haben Sie in Wirklichkeit einen Wertebereich spezifiziert. Wie Prolog bietet auch Scala Tupel an. Ein Tupel ist eine Menge von Objekten mit einer festen Länge. Sie finden dieses Muster auch in vielen anderen funktionalen Sprachen. Die Objekte in einem Tupel können alle einen anderen Typ haben. Bei rein funktionalen Sprachen drü-
154 Kapitel 5: Scala cken Programmierer Objekte und deren Attribute häufig über Tupel aus. Probieren Sie das folgende Beispiel: scala> val person = ("Elvis", "Presley") person: (java.lang.String, java.lang.String) = (Elvis,Presley) scala> person._1 res9: java.lang.String = Elvis scala> person._2 res10: java.lang.String = Presley scala> person._3 :6: error: value _3 is not a member of (java.lang.String, java.lang.String) person._3 ^
Scala verwendet Tupel statt Listen für Mehrfachzuweisungen: scala> val (x, y) = x: Int = 1 y: Int = 2
(1, 2)
Da Tupel eine feste Länge haben, kann Scala basierend auf den jeweiligen Tupel-Werten eine statische Typprüfung durchführen: scala> val (a, b) = (1, 2, 3) :15: error: constructor cannot be instantiated to expected type; found : (T1, T2) required: (Int, Int, Int) val (a, b) = (1, 2, 3) ^ :15: error: recursive value x$1 needs type val (a, b) = (1, 2, 3) ^
Nachdem wir die Grundlagen geklärt haben, wollen wir alles zusammenfügen. Wir werden einige objektorientierte Klassendefinitionen erzeugen.
Klassen in Scala Die einfachsten Klassen, d.h. die ohne Methoden oder Konstruktoren, sind bei Scala einfache, einzeilige Definitionen: class Person(vorName: String, nachName: String)
Für eine einfache Werteklasse müssen wir keinen Body angeben. Person-Klasse ist public und besitzt die Attribute vorName und nachName. Und Sie können diese Klasse in der Konsole nutzen:
Tag 1: Die Burg auf der Anhöhe 155 scala> class Person(vorName: String, defined class Person
nachName: String)
scala> val gump = new Person("Forrest", "Gump") gump: Person = Person@7c6d75b6
Doch Sie wollen etwas mehr. Objektorientierte Klassen mischen Daten und Verhalten. Wir wollen eine vollständige objektorientierte Klasse in Scala aufbauen. Wir nennen diese Klasse Kompass. Der Kompass ist zunächst nach Norden ausgerichtet. Wir weisen ihn an, sich um 90 Grad nach links oder rechts zu drehen, und aktualisieren die Richtung entsprechend. Hier der gesamte Scala-Code: scala/compass.scala
class Compass { val directions = List("north", "east", "south", "west") var bearing = 0 print("Initial bearing: ") println(direction) def
direction() = directions(bearing)
def inform(turnDirection: String) { println("Turning " + turnDirection + ". Now bearing " + direction) } def turnRight() { bearing = (bearing + 1) % directions.size inform("right") } def turnLeft() { bearing = (bearing + (directions.size - 1)) % directions.size inform("left") } } val myCompass = new Compass myCompass.turnRight myCompass.turnRight myCompass.turnLeft myCompass.turnLeft myCompass.turnLeft
Die Syntax ist recht geradlinig und hat eine Reihe bemerkenswerte Eigenheiten. Der Konstruktur ist verantwortlich für die Definition von Instanzvariablen (zumindest derjenigen, die Sie nicht an den Konstruktor übergeben) und Methoden. Im Gegensatz zu Ruby besitzen alle Methodendefinitionen Parametertypen und Namen. Und der erste
156 Kapitel 5: Scala Codeblock liegt in gar keiner Methodendefinition. Sehen wir uns das genauer an: class Compass { val directions = List("north", "east", "south", "west") var bearing = 0 print("Initial bearing: ") println(direction)
Der gesamte Codeblock, der auf die Klassendefinition folgt, ist tatsächlich der Konstruktor. Unser Konstruktor besitzt eine Liste der Richtungen und einen Kurs (der einfach ein Index für die Richtungen ist). Später wird der Richtungswechsel den Kurs verändern. Als Nächstes folgt eine Reihe von Methoden, die der Bequemlichkeit dienen und dem Benutzer die aktuelle Richtung anzeigen: def direction() = directions(bearing) def inform(turnDirection: String) { println("Turning " + turnDirection + ". Now bearing " + direction) }
Der Konstruktor macht mit Methodendefinitionen weiter. Die Methode direction gibt einfach das Element von directions zurück, auf das der Index in bearing verweist. Scala erlaubt praktischerweise eine alternative Syntax für einzeilige Methoden, bei denen die geschweiften Klammern um den Body der Methode weggelassen werden können. Die inform-Methode gibt eine freundliche Meldung aus, sobald der Benutzer die Richtung wechselt. Sie verlangt einen einfachen Parameter, die Richtung des Wechsels. Diese Methode gibt keinen Wert zurück. Sehen wir uns die Methoden an, die den Richtungswechsel verarbeiten. def turnRight() { bearing = (bearing + 1) % directions.size inform("right") } def turnLeft() { bearing = (bearing + (directions.size - 1)) % directions.size inform("left") }
Die turns-Methode ändert den Kurs je nach Richtung des Wechsels. Der %-Operator ist die modulare Division. (Dieser Operator führt eine Division durch, ignoriert den Quotienten und gibt nur den Rest zurück.) right addiert also eins zum Kurs, während left eins abzieht, wobei das Ergebnis korrekt umspringt.
Tag 1: Die Burg auf der Anhöhe 157
Hilfskonstruktoren Sie haben gesehen, wie der grundlegende Konstruktor funktioniert. Es handelt sich um einen Codeblock, der Klassen und Methoden initialisiert. Sie können auch alternative Konstruktoren verwenden. Betrachten Sie die folgende Person-Klasse mit zwei Konstruktoren: scala/constructor.scala
class Person(first_name: String) { println("Outer constructor") def this(first_name: String, last_name: String) { this(first_name) println("Inner constructor") } def
talk() = println("Hi")
} val bob = new Person("Bob") val bobTate = new Person("Bob", "Tate")
Die Klasse besitzt einen Konstruktor mit einem Parameter namens firstName und eine Methode namens talk. Beachten Sie die thisMethode. Das ist der zweite Konstruktor. Er verlangt die zwei Parameter firstName und lastName. Zuerst ruft die Methode this mit dem primären Konstrukor auf, wobei nur der Parameter firstName übergeben wird. Der Code nach der Klassendefinition instanziiert eine Person auf zwei Arten. Zuerst mit dem primären Konstruktor und dann mit dem Hilfskonstruktor: batate$ scala code/scala/constructor.scala Outer constructor Outer constructor Inner constructor
Und das war es auch schon. Hilfskonstruktoren sind wichtig, weil sie eine Vielzahl von Nutzungsmustern erlauben. Sehen wir uns an, wie man Klassenmethoden aufbaut.
Klassen erweitern Bisher waren Klassen ziemlich 08/15. Wir haben einige grundlegende Klassen mit nichts weiter als Attributen und Methoden erzeugt. In diesem Abschnitt wollen wir uns einige der Möglichkeiten ansehen, über die Klassen interagieren können.
158 Kapitel 5: Scala
Companion-Objekte und Klassenmethoden Bei Java und Ruby erzeugen Sie sowohl Klassen- als auch Instanzenmethoden im selben Code-Block, dem Körper der Klassendefinition. Bei Java verwenden Klassenmethoden das Schlüsselwort static. Ruby verwendet def self.class_method. Scala nutzt keine dieser beiden Strategien. Stattdessen deklarieren Sie Instanzmethoden in den class-Definitionen. Wenn es etwas gibt, von dem es nur eine Instanz geben kann, definieren Sie es mit dem Schlüsselwort object anstelle von class. Hier ein Beispiel: scala/ring.scala
object TrueRing { def rule = println("To rule them all") } TrueRing.rule
Die Definition von TrueRing funktioniert genau wie jede andere classDefinition auch, erzeugt aber ein Singleton-Objekt. Bei Scala können Sie eine object- und eine class-Definition mit dem gleichen Namen anlegen. In diesem Szenario können Sie Klassenmethoden in Deklarationen von Singleton-Objekten erzeugen und Instanzmethoden innerhalb der Klassendeklaration. In unserem Beispiel ist die Methode rule eine Klassenmethode. Das ist die Technik der Companion-Objekte.
Vererbung Vererbung ist bei Scala recht einfach, doch die Syntax muss genau eingehalten werden. Es folgt ein Beispiel für die Erweiterung einer PersonKlasse um Employee. Beachten Sie, dass der Employee eine zusätzliche Mitarbeiternummer im id-Feld besitzt. Hier der Code: scala/employee.scala
class def def }
Person(val name: String) { talk(message: String) = println(name + " says " + message) id(): String = name
class Employee(override val name: String, val number: Int) extends Person(name) { override def talk(message: String) { println(name + " with number " + number + " says " + message) } override def id():String = number.toString }
Tag 1: Die Burg auf der Anhöhe 159 val employee = new Employee("Yoda", 4) employee.talk("Extend or extend not. There is no try.")
In diesem Beispiel erweitern wir die Basisklasse Person um Employee. Wir fügen eine neue Instanzvariable namens nummer in Employee ein und überschreiben außerdem die talk-Methode, um zusätzliches Verhalten einzufügen. Ein Großteil der kniffligen Syntax dreht sich um die Definition des Klassenkonstruktors. Beachten Sie, dass Sie die komplette Parameterliste für Person angeben müssen, auch wenn Sie die Typen weglassen können. Das Schlüsselwort override ist sowohl im Konstruktor als auch bei allen zu erweiternden Methoden der Basisklasse Pflicht. Es verhindert, dass Sie versehentlich neue (falsch geschriebene) Methoden einführen. Insgesamt gibt es hier keine großen Überraschungen, aber ich fühle mich die ganze Zeit ein wenig so wie Edward, der versucht, ein verletzliches Babyhäschen zu streicheln. Weiter geht’s ...
Traits Jede objektorientierte Sprache muss das Problem lösen, dass ein Objekt unterschiedliche Rollen haben kann. Jedes Objekt kann ein persistentes, serialisierbares Gestrüpp sein. Sie wollen nicht, dass Ihr Strauchwerk wissen muss, wie man binäre Daten an MySQL übergibt. C++ verwendet Mehrfachvererbung, Java Interfaces, Ruby Mixins und Scala Traits. Ein Scala-Trait ist wie ein Ruby-Mixin, implementiert mit Modulen. Oder, falls Sie das bevorzugen, ist ein Trait wie ein Java-Interface samt Implementierung. Betrachten Sie einen Trait als partielle Implementierung einer Klasse. Idealerweise sollte er einen kritischen Aspekt implementieren. Hier ein Beispiel, das den Trait Nett in Person einbindet: scala/nice.scala
class Person(val name:String) trait Nice { def greet() = println("Howdily doodily.") } class Character(override val name:String) extends val flanders = new Character("Ned") flanders.greet
Person(name) with
Nice
160 Kapitel 5: Scala Als erstes Element sehen Sie Person. Dabei handelt es sich um eine einfache Klasse mit einem einzelnen Attribut namens name. Das zweite Element ist der Trait namens Nice. Das ist das Mixin. Es besitzt eine einzelne Methode namens greet. Das letzte Element ist eine Klasse namens Character, die den Nicetrait einbindet. Wer will, kann nun die gruss-Methode für jede Instanz von Character benutzen. Die Ausgabe entspricht dem, was Sie erwarten würden: batate$ scala code/scala/nice.scala Howdily doodily.
Das ist nicht allzu kompliziert. Wir können den Trait namens Nice mit einer Methode namens greet nehmen und in jede Scala-Klasse einfügen, um das greet-Verhalten einzuführen.
Was wir am ersten Tag gelernt haben Wir haben am ersten Tag ein riesiges Feld abgearbeitet, da wir zwei völlig verschiedene Programmierparadigmen in einer Sprache entwickeln müssen. Der erste Tag hat gezeigt, dass Scala objektorientierte Konzepte aufgreift und in einer JVM Seite an Seite mit existierenden JavaBibliotheken läuft. Scalas Syntax ähnelt der von Java und ist darüber hinaus stark und statisch typisiert. Doch Martin Odersky entwickelte Scala, um zwei Paradigmen miteinander zu verbinden, nämlich das der objektorientierten und das der funktionalen Programmierung. Die funktionalen Programmierkonzepte, die wir an Tag 2 einführen werden, erleichtern die Entwicklung nebenläufiger Anwendungen. Scalas statische Typisierung wird abgeleitet. Die Benutzer müssen nicht in jeder Situation immer die Typen für alle Variablen angeben, weil Scala diese Typen häufig aus syntaktischen Hinweisen ableiten kann. Der Compiler kann außerdem bestimmte Typen erzwingen, etwa Integer zu String, was eine implizite Typkonvertierung erlaubt, wenn diese denn sinnvoll ist. Scalas Ausdrücke funktionieren mehr oder weniger so wie in anderen Sprachen, sind aber etwas strikter. Die meisten Bedingungen verlangen einen booleschen Typ, und 0 oder Nil funktionieren gar nicht, sind also kein Ersatz für true oder false. Doch es gibt keine dramatischen Unterschiede bei Scalas Schleifen- und Kontrollstrukturen. Scala unterstützt einige fortgeschrittene Typen wie Tupel (Listen fester Länge mit heterogenen Typen) und Ranges (oder Wertebereiche, also feste, geordnete Folgen von Zahlen).
Tag 2: Gesträuch beschneiden und andere neue Tricks 161 Scala-Klassen funktionieren fast so wie bei Java, unterstützen aber keine Klassenmethoden. Stattdessen verwendet Scala ein als Companion-Objekt bezeichnetes Konzept, um Klassen- und Instanzmethoden in der gleichen Klasse zu mischen. Wo Ruby Mixins und Java Interfaces verwendet, nutzt Scala eine Mixin-artige Struktur namens Trait. Am zweiten Tag werden wir uns alle funktionalen Features von Scala ansehen. Wir behandeln Codeblöcke, Collections, unveränderliche Variablen und einige fortgeschrittene, fest eingebaute Methoden wie foldLeft.
Tag 1: Selbststudium Der erste Scala-Tag hat sehr viele Grundlagen abgedeckt, doch Sie sollten sich größtenteils auf vertrautem Terrain bewegt haben. Diese objektorientierten Konzepte sollten Ihnen vertraut sein. Die Übungen sind verglichen mit den bisherigen Übungen in diesem Buch etwas anspruchsvoller, aber das sollten Sie schaffen. Finden Sie 앫
die Scala-API,
앫
einen Vergleich zwischen Java und Scala und
앫
eine Besprechung von val im Vergleich mit var.
Machen Sie Folgendes:
5.3
앫
Entwickeln Sie ein Spiel, das ein Tic-Tac-Toe-Spielbrett mit X, O und Leerzeichen füllt und den Gewinner ermittelt oder ob ein Unentschieden vorliegt. Verwenden Sie Klassen an passenden Stellen.
앫
Zusatzaufgabe: Lassen Sie zwei Spieler Tic-Tac-Toe spielen.
Tag 2: Gesträuch beschneiden und andere neue Tricks Bei Edward mit den Scherenhänden gibt es einen magischen Moment, als Edward erkennt, dass er sich weit vom Haus auf dem Hügel entfernt hat und seine einzigartigen Fähigkeiten ihm einen besonderen Platz in der Gesellschaft bieten.
162 Kapitel 5: Scala Jeder mit einem Auge für die Geschichte der Programmiersprachen hat dieses Märchen schon mal gesehen. Als das objektorientierte Paradigma aufkam, konnte die Masse Smalltalk nicht akzeptieren, weil das Paradigma zu neu war. Wir brauchten eine Sprache, die es uns erlaubte, mit der prozeduralen Programmierung weiterzumachen und mit objektorientierten Ideen zu experimentieren. Mit C++ konnten die neuen objektorientierten Tricks sicher innerhalb existierender prozeduraler Features von C existieren. Das Ergebnis war, dass die Leute damit anfingen, die neuen Tricks in einem alten Kontext zu verwenden. Nun ist es an der Zeit, Scala als funktionale Sprache auf Herz und Nieren zu prüfen. Einiges wird etwas plump wirken, doch die Ideen sind leistungsfähig und wichtig. Sie bilden die Grundlage für die Nebenläufigkeitskonstrukte, die Sie am dritten Tag kennenlernen werden. Wir wollen am Anfang beginnen, mit einer einfachen Funktion: scala> def double(x:Int):Int = double: (Int)Int
x *
2
scala> double(4) res0: Int = 8
Diese Defintion einer Funktion erinnert stark an Ruby. Das Schlüsselwort def definiert sowohl eine Funktion als auch eine Methode. Darauf folgen die Parameter und ihre Typen. Danach können Sie optional einen Rückgabetyp angeben. Scala kann den Rückgabetyp häufig ableiten. Um die Funktion aufzurufen, verwenden Sie ihren Namen und die Argumentliste. Beachten Sie, dass die Klammern im Gegensatz zu Ruby in diesem Kontext nicht optional sind. Das war eine einzeilige Methodendefinition. Sie können eine Methodendefinition auch in Blockform angeben: scala> def double(x:Int):Int = | x * 2 | } double: (Int)Int
{
scala> double(6) res3: Int = 12
Das = hinter dem Int-Rückgabetyp ist Pflicht. Es zu vergessen, bringt Sie in Schwierigkeiten. Das sind die Hauptformen der Funktionsdeklaration. Sie werden kleinere Varianten entdecken, etwa das Weglassen von Parametern, doch die Formen oben werden Sie am häufigsten sehen.
Tag 2: Gesträuch beschneiden und andere neue Tricks 163 Machen wir mit den Variablen weiter, die Sie innerhalb einer Funktion verwenden werden. Sie müssen besonders auf den Lebenszyklus einer Variablen achten, wenn Sie das rein funktionale Programmiermodell erlernen wollen.
var versus val Scala basiert auf der Java Virtual Machine und hat eine enge Beziehung zu Java. In gewisser Weise schränken diese Designziele die Sprache ein. Andererseits kann Scala die Fortschritte bei der Entwicklung der letzten 15 bis 20 Jahre nutzen. Sie werden feststellen, dass es verstärkte Bestrebungen gibt, Scala besser für die nebenläufige Programmierung zu wappnen. Doch alle Features zur Nebenläufigkeit helfen Ihnen nicht, wenn Sie grundlegende Designprinzipien nicht befolgen. Veränderliche Zustände sind schlecht. Wenn Sie Variablen deklarieren, sollten diese möglichst immer unveränderlich sein, um Zustandskonflikte zu vermeiden. Bei Java bedeutet das die Verwendung des Schlüsselworts final. Bei Scala bedeutet „unveränderlich“ die Verwendung von val anstelle von var: scala> var mutable = "I am mutable" mutable: java.lang.String = I am mutable scala> mutable = "Touch me, change me..." mutable: java.lang.String = Touch me, change me... scala> val immutable = "I am not mutable" immutable: java.lang.String = I am not mutable scala> immutable = "Can't touch this" :5: error: reassignment to val immutable = "Can't touch this" ^
var -Werte sind also veränderlich, val-Werte nicht. In der Konsole können Sie der Bequemlichkeit halber Variablen wiederholt neu definieren, selbst wenn Sie val verwenden. Sobald Sie die Konsole verlassen, erzeugt die Neudefinition eines val einen Fehler.
In gewisser Weise hat Scala Variablen im var -Stil eingeführt, um den traditionellen imperativen Programmierstil zu unterstützen. Doch während Sie Scala erlernen, sollten Sie var für eine bessere Nebenläufigkeit so oft wie möglich vermeiden. Diese grundlegende Designphilosophie ist das Schlüsselelement, das die funktionale von der objektorientierten Programmierung unterscheidet: Veränderliche Zustände schränken die Nebenläufigkeit ein.
164 Kapitel 5: Scala Sehen wir uns einen meiner Lieblingsbereiche innerhalb funktionaler Sprachen an: den Umgang mit Collections.
Collections Funktionale Sprachen haben eine lange Tradition spektakulär nützlicher Features für Collections. Eine der frühesten funktionalen Sprachen, Lisp, basierte auf dem Konzept, Listen verarbeiten zu können. Das drückt schon der Name aus: list processing. Funktionale Sprachen machen es einfach, komplexe Strukturen aufzubauen, die Daten und Code enthalten. Scalas primäre Collections sind Listen, Sets und Maps.
Listen Wie bei den meisten funktionalen Sprachen ist die Liste die Hauptdatenstruktur. Scala-Listen vom Typ List sind geordnete Collections mit wahlfreiem Zugriff. Geben Sie die folgenden Listen auf der Konsole ein: scala> List(1, 2, 3) res4: List[Int] = List(1, 2, 3)
Beachten Sie den ersten Rückgabewert: List[Int] = List(1, 2, 3). Dieser Wert gibt nicht nur den Typ der Gesamtliste an, sondern auch den Typ der Datenstrukturen innerhalb der Liste. Eine Liste mit Strings sieht so aus: scala> List("one", "two", "three") res5: List[java.lang.String] = List(one, two, three)
Wenn Sie hier etwas Java-Einfluss zu erkennen glauben, liegen Sie richtig. Java besitzt ein Feature namens „Generics“, das es erlaubt, den Typ der Elemente innerhalb einer Datenstruktur wie einer Liste oder einem Array anzugeben. Sehen wir uns an, was passiert, wenn wir in einer Liste Strings und Ints kombinieren: scala> List("one", "two", 3) res6: List[Any] = List(one, two, 3)
Sie erhalten den Datentyp Any zurück, der bei Scala als Sammeldatentyp fungiert. Der Zugriff auf ein Element einer Liste geht so: scala> List("one", "two", 3)(2) res7: Any = 3 scala> List("one", "two", 3)(4) java.util.NoSuchElementException: head of empty list at scala.Nil$.head(List.scala:1365) at scala.Nil$.head(List.scala:1362) at scala.List.apply(List.scala:800)
Tag 2: Gesträuch beschneiden und andere neue Tricks 165 at at at at at at
.(:5) .() RequestResult$.(:3) RequestResult$.() RequestResult$result() sun.reflect.NativeMethodAccessorImpl.invoke0(Native Met...
Sie verwenden den ()-Operator. Der Listenzugriff ist eine Funktion, weshalb Sie () anstelle von [ ] verwenden können. Scalas Listenindex beginnt mit 0, genau wie bei Java und Ruby. Im Gegensatz zu Ruby löst der Zugriff auf ein Element außerhalb des Wertebereichs eine Ausnahme aus. Sie können auch eine Indexierung mit negativen Werten versuchen. Frühere Versionen lieferten das erste Element zurück: scala> List("one", res9: Any = one
"two", 3)(-1)
scala> List("one", res10: Any = one
"two", 3)(-2)
scala> List("one", res11: Any = one
"two", 3)(-3)
Da dieses Verhalten nicht ganz zur NoSuchElement-Ausnahme für zu große Indizes passt, korrigiert Version 2.8.0 es und führt zu einer java.lang.IndexOutOfBoundsException. Noch eine letzte Anmerkung: Nil ist bei Scala eine leere Liste. scala> Nil res33: Nil.type = List()
Wir werden diese Liste als grundlegenden Baustein nutzen, wenn wir Codeblöcke behandeln, doch im Moment sollten Sie etwas Nachsicht mit mir haben. Ich möchte zuerst noch eine Reihe weiterer CollectionTypen vorstellen.
Sets Eine Set (d. h. eine Menge) ähnelt einer Liste, besitzt aber keine explizite Reihenfolge. Sie legen Sets mit dem Schlüsselwort Set an: scala> val animals = Set("lions", "tigers", "bears") animals: scala.collection.immutable.Set[java.lang.String] = Set(lions, tigers, bears)
166 Kapitel 5: Scala Etwas in das Set einzufügen oder aus ihm zu entfernen, ist einfach: scala> animals + "armadillos" res25: scala.collection.immutable.Set[java.lang.String] = Set(lions, tigers, bears, armadillos) scala> animals - "tigers" res26: scala.collection.immutable.Set[java.lang.String] = Set(lions, bears) scala> animals + Set("armadillos", "raccoons") :6: error: type mismatch; foun d : scala.collection.immutable.Set[java.lang.String] required: java.lang.String animals + Set("armadillos", "raccoons") ^
Denken Sie daran, dass Set-Operationen nicht destruktiv sind. Jede SetOperation baut eine neue Menge auf, statt die alte zu verändern. Standardmäßig sind Mengen unveränderlich. Wie Sie sehen, ist das Einfügen oder Löschen einzelner Elemente ein Klacks, doch Sie können + und nicht (wie bei Ruby) verwenden, um Sets zu kombinieren. Bei Scala verwenden Sie ++ und -- für Vereinigungs- und Differenzmengen: scala> animals ++ Set("armadillos", "raccoons") res28: scala.collection.immutable.Set[java.lang.String] = Set(bears, tigers, armadillos, raccoons, lions) scala> animals -- Set("lions", "bears") res29: scala.collection.immutable.Set[java.lang.String] = Set(tigers)
Sie können auch die Schnittmenge (Elemente, die in zwei Sets vorkommen) bestimmen2: scala> animals ** Set("armadillos", "raccoons", "lions", "tigers") res1: scala.collection.immutable.Set[java.lang.String] = Set(lions, tigers)
Im Gegensatz zu Listen sind Sets von der Reihenfolge unabhängig. Diese Regel bedeutet, dass Gleichheit bei Sets und Listen verschieden ist: scala> Set(1, 2, 3) == res36: Boolean = true scala> List(1, 2, 3) == res37: Boolean = false
Set(3, 2, 1)
List(3,
2, 1)
Das soll es mit Sets erst einmal gewesen sein. Sehen wir uns Maps an.
2
Verwenden Sie ab Scala 2.8.0 &, da ** veraltet ist.
Tag 2: Gesträuch beschneiden und andere neue Tricks 167
Maps Eine Map ist ein Schlüssel/Wert-Paar (wie ein Ruby-Hash). Die Syntax sollte Ihnen vertraut sein: scala> val ordinals = Map(0 -> "zero", 1 -> "one", 2 -> "two") ordinals: scala.collection.immutable.Map[Int,java.lang.String] = Map(0 -> zero, 1 -> one, 2 -> two) scala> ordinals(2) res41: java.lang.String = two
Wie bei Scala-Listen und -Sets legen Sie Maps mit dem Schlüsselwort Map an. Sie trennen die Elemente der Map mit dem Operator ->. Sie nutzen nur ein wenig syntaktischen Zucker, der den Aufbau einer ScalaMap vereinfacht. Sehen wir uns eine andere Form der Hashmap an, die die Typen von Schlüssel und Wert festlegt: scala> import scala.collection.mutable.HashMap import scala.collection.mutable.HashMap scala> val map = new HashMap[Int, String] map: scala.collection.mutable.HashMap[Int,String] = Map() scala> map +=
4 ->
"four"
scala> map +=
8 ->
"eight"
scala> map res2: scala.collection.mutable.HashMap[Int,String] = Map(4 -> four, 8 -> eight)
Zuerst importieren wir Scala-Bibliotheken für veränderliche HashMaps. Das bedeutet, dass die Werte innerhalb der Hashmap verändert werden können. Als Nächstes deklarieren wir eine unveränderliche Variable namens map. Das bedeutet, dass die Referenz auf die Map nicht verändert werden kann. Beachten Sie, dass wir auch die Typen der Schlüssel/Wert-Paare festlegen. Zum Schluss fügen wir einige Schlüssel/ Wert-Paare ein und geben das Ergebnis zurück. Hier sehen Sie ein Beispiel dafür, was passiert, wenn Sie den falschen Typ angeben: scala> map += "null" -> 0 :7: error: overloaded method value += with alternatives (Int)map.MapTo ((Int, String))Unit cannot be applied to ((java.lang.String, Int)) map += "null" -> 0 ^
168 Kapitel 5: Scala Wie zu erwarten, erhalten Sie einen Typfehler. Die Typbeschränkungen werden wann immer möglich während der Kompilierung durchgesetzt, aber auch zur Laufzeit. Nachdem Sie nun die Grundlagen der Collections kennen, wollen wir tiefer in die Details eintauchen.
Alles und Nichts Bevor wir uns anonymen Funktionen zuwenden, wollen wir ein wenig über die Scala-Klassenhierarchie reden. Wenn Sie Scala zusammen mit Java nutzen, werden Sie sich häufig mehr Gedanken um die Java-Klassenhierarchie machen. Dennoch sollten Sie etwas über die Scala-Typen wissen. Any („Alles“) bildet in der Scala-Klassenhierarchie die Stammklasse. Es ist häufig verwirrend, doch Sie sollten wissen, dass alle Scala-Typen von Any erben. Ebenso ist Nothing („Nichts“) ein Subtyp jedes Typs. Auf diese Weise kann eine Funktion, etwa für eine Collection, Nothing zurückgeben und dem Rückgabetyp für die gegebene Funktion genügen. Zu sehen ist das alles in Abbildung 5.1. Alles erbt von Any, und Nothing erbt von allem.
Any
AnyVal
Float
AnyRef
Int
etc...
ScalaObject
List
Map
Nothing
Null
Abbildung 5.1: Any und Nothing
etc...
Tag 2: Gesträuch beschneiden und andere neue Tricks 169 Es gibt ein paar unterschiedliche Nuancen, wenn Sie mit nil-Konzepten arbeiten. Null ist ein Trait und null ist davon eine Instanz, die wie Javas null funktioniert, also als „leerer“ Wert. Eine leere Collection ist Nil. Im Gegensaz dazu ist Nothing ein Trait, der ein Subtyp von allem ist. Nothing hat keine Instanz, Sie können es also nicht wie Null dereferenzieren. Zum Beispiel hat eine Methode, die eine Ausnahme auslöst, den Rückgabetyp Nothing, also gar keinen Wert. Behalten Sie diese Regeln im Hinterkopf, und Sie sind auf der sicheren Seite. Nun können Sie mit Funktionen höherer Ordnung etwas mehr mit Collections anfangen.
Collections und Funktionen Da wir gerade mit Sprachen beginnen, die eine stärkere funktionale Grundlage haben, möchte ich einige Konzepte formal aufarbeiten, mit denen wir schon die ganze Zeit arbeiten. Das erste dieser Konzepte sind Funktionen höherer Ordnung. Wie bei Ruby und Io werden Scala-Collections wesentlich interessanter, wenn man Funktionen höherer Ordnung nutzt. Genau wie Ruby each und Io foreach verwendet, können Sie bei Scala Funktionen an foreach übergeben. Das zugrunde liegende Konzept, das Sie die ganze Zeit genutzt haben, ist die Funktion höherer Ordnung. Allgemeinverständlich formuliert, ist eine Funktion höherer Ordnung eine Funktion, die Funktionen produziert oder konsumiert. Etwas genauer ausgedrückt, erwartet eine Funktion höherer Ordnung andere Funktionen als Eingabeparameter oder gibt Funktionen als Ergebnis zurück. Der Aufbau von Funktionen, die andere Funktionen auf diese Weise nutzen, ist ein wesentliches Konzept der funktionalen Sprachfamilie, bestimmt aber auch die Art und Weise, wie man in anderen Sprachen kodiert. Scalas Unterstützung für Funktionen höherer Ordnung ist sehr leistungsfähig. Wir haben nicht die Zeit, uns fortgeschrittenere Themen wie partiell angewandte Funktionen oder Currying anzusehen, aber Sie werden erfahren, wie man einfache Funktionen, oft Codeblöcke genannt, als Parameter an Collections übergibt. Sie können eine Funktion nehmen und jeder Variable und jedem Parameter zuweisen. Sie können sie an Funktionen übergeben und von Funktionen zurückgeben. Wir werden uns darauf konzentrieren, anonyme Funktionen als Eingabeparameter an einige der interessanteren Collection-Methoden zu übergeben.
170 Kapitel 5: Scala
foreach Die erste Funktion, die wir uns ansehen wollen, ist foreach, das Iterations-Arbeitspferd bei Scala. Wie bei Io verlangt die foreach-Methode bei einer Collection einen Codeblock als Parameter. Bei Scala drücken Sie diesen Codeblock in der Form variableName => ihrCode wie folgt aus: scala> val list = List("frodo", "samwise", "pippin") list: List[java.lang.String] = List(frodo, samwise, pippin) scala> list.foreach(hobbit => frodo samwise pippin
println(hobbit))
hobbit => println(hobbit) ist eine anonyme Funktion, also eine Funktion ohne Namen. Die Deklaration enthält die Argumente links vom => und den Code rechts davon. foreach ruft die anonyme Funktion auf und übergibt jedes Element der Liste als Eingabeparameter. Wie Sie es sich vielleicht schon gedacht haben, können Sie dieselbe Technik auch für Sets und Maps verwenden, auch wenn die Reihenfolge dabei nicht garantiert werden kann: val hobbits = Set("frodo", "samwise", "pippin") hobbits: scala.collection.immutable.Set[java.lang.String] = Set(frodo, samwise, pippin) scala> hobbits.foreach(hobbit => frodo samwise pippin
println(hobbit))
scala> val hobbits = Map("frodo" -> "hobbit", "samwise" -> "hobbit", "pippin" -> "hobbit") hobbits: scala.collection.immutable.Map[java.lang.String,java.lang.String] = Map(frodo -> hobbit, samwise -> hobbit, pippin -> hobbit) scala> hobbits.foreach(hobbit => (frodo,hobbit) (samwise,hobbit) (pippin,hobbit)
println(hobbit))
Natürlich liefern Maps Tupel anstelle einzelner Elemente zurück. Denken Sie daran, dass Sie beide Enden eines Tupels so erreichen: scala> hobbits.foreach(hobbit => frodo samwise pippin
println(hobbit._1))
Tag 2: Gesträuch beschneiden und andere neue Tricks 171 scala> hobbits.foreach(hobbit => hobbit hobbit hobbit
println(hobbit._2))
Mit diesen anonymen Funktionen können Sie weit mehr anstellen, als einfach nur alle Elemente zu durchlaufen. Ich gehe mit Ihnen einige Grundlagen durch und dann einige der interessanten Fälle, in denen Scala Funktionen im Zusammenhang mit Collections nutzt.
Weitere Listenmethoden Ich schweife kurz ein wenig ab, um einige weitere Methoden für Listen einzuführen. Diese grundlegenden Methoden bieten die Features, die Sie für eine manuelle Interaktion oder Rekursion über Listen benötigen. Zuerst die Methoden, die Sie brauchen, um eine leere Liste zu ermitteln, oder die Größe einer Liste: scala> list res23: List[java.lang.String] = List(frodo, samwise, pippin) scala> list.isEmpty res24: Boolean = false scala> Nil.isEmpty res25: Boolean = true scala> list.length res27: Int = 3 scala> list.size res28: Int = 3
Beachten Sie, dass Sie die Größe einer Liste sowohl mit length als auch mit size ermitteln können. Denken Sie auch daran, dass die Implementierung von Nil die leere Liste ist. Wie bei Prolog ist es für die Rekursion nützlich, den Kopf und den Fuß einer Liste abrufen zu können. scala> list.head res34: java.lang.String = frodo scala> list.tail res35: List[java.lang.String] = List(samwise, pippin) scala> list.last res36: java.lang.String = pippin scala> list.init res37: List[java.lang.String] = List(frodo, samwise)
172 Kapitel 5: Scala Hier wartet eine Überraschung auf uns. Sie können head und tail benutzen, um die Rekursion vom Kopf ausgehend auszuführen, oder last und init, um zuerst das Ende zu verarbeiten. Runden wir die Grundlagen mit ein paar interessanten, der Bequemlichkeit dienenden Methoden ab: scala> list.reverse res29: List[java.lang.String] = List(pippin, samwise, frodo) scala> list.drop(1) res30: List[java.lang.String] = List(samwise, pippin) scala> list res31: List[java.lang.String] = List(frodo, samwise, pippin) scala> list.drop(2) res32: List[java.lang.String] = List(pippin)
Die bewirken genau, was man erwarten würde: reverse gibt die Liste in umgekehrter Reihenfolge zurück und drop(n) gibt eine Liste zurück, bei der die ersten n Elemente entfernt wurden (ohne die Originalliste zu verändern).
count, map, filter und mehr Wie Ruby besitzt auch Scala viele weitere Funktionen zur Manipulation von Listen. Sie können die Liste nach bestimmten Kriterien filtern, nach beliebigen Kriterien sortieren, andere Listen aus allen Elementen aufbauen oder Aggregatwerte erzeugen: scala> val words = List("peg", "al", "bud", "kelly") words: List[java.lang.String] = List(peg, al, bud, kelly) scala> words.count(word => res43: Int = 3
word.size >
2)
scala> words.filter(word => word.size > 2) res44: List[java.lang.String] = List(peg, bud, kelly) scala> words.map(word => word.size) res45: List[Int] = List(3, 2, 3, 5) scala> words.forall(word => res46: Boolean = true
word.size >
1)
scala> words.exists(word => res47: Boolean = true
word.size >
4)
scala> words.exists(word => res48: Boolean = false
word.size >
5)
Tag 2: Gesträuch beschneiden und andere neue Tricks 173 Wir beginnen mit einer Scala-Liste. Dann zählen wir alle Wörter, die größer sind als 2. count ruft den Codeblock word => word.size > 2 auf und evaluiert den Ausdruck word.size > 2 für jedes Element der Liste. Die count-Methode zählt alle true-Ausdrücke. Genauso liefert words.filter(word => word.size > 2) eine Liste aller Wörter zurück, die größer als 2 sind (ähnlich Rubys select). Nach dem gleichen Muster baut map eine Liste der Größen aller Wörter der Liste auf. forall gibt true zurück, wenn der Codeblock für alle Elemente im Set true zurückgibt, und exists gibt true zurück, wenn der Codeblock true für ein beliebiges Element im Set zurückgibt. Manchmal können Sie ein Feature mithilfe von Codeblöcken verallgemeinern, um etwas Leistungsfähigeres aufzubauen. Beispielsweise könnten Sie ganz traditionell sortieren wollen: scala> words.sort((s, t) => s.charAt(0).toLowerCase < t.charAt(0).toLowerCase) res49: List[java.lang.String] = List(al, bud, kelly, peg)
Dieser Code nimmt einen Codeblock, der die zwei Parameter s und t verlangt. Über sort3 können Sie die beiden Argumente in jeder gewünschten Form miteinander vergleichen. Im obigen Code wandeln wir die Zeichen in Kleinbuchstaben4 um und vergleichen sie. Das ergibt eine Sortierung, die die Schreibweise ignoriert. Wir können diese Methode auch benutzen, um die Liste nach der Wortlänge zu sortieren: scala> words.sort((s, t) => s.size < t.size) res50: List[java.lang.String] = List(al, bud, peg, kelly)
Durch die Verwendung eines Codeblocks können wir nach jeder gewünschten Regel sortieren.5 Sehen wir uns ein etwas komplexeres Beispiel an: foldLeft.
foldLeft Die foldLeft-Methode bei Scala ähnelt stark der inject-Methode bei Ruby. Sie übergeben einen Startwert und einen Codeblock. foldLeft übergibt dem Codeblock jedes Element des Arrays und einen weiteren Wert. Der zweite Wert ist entweder der Startwert (beim ersten Aufruf) oder das Ergebnis des Codeblocks (bei nachfolgenden Aufrufen). Es gibt zwei Versionen dieser Methode. Die erste Variante, /:, ist ein Operator mit startWert /: codeBlock.
3 4 5
Seit Version 2.8.0 ist sort veraltet. Verwenden Sie stattdessen sortWith. Seit Version 2.8.0 ist toLowerCase veraltet. Verwenden Sie stattdessen toLower. In Version 2.8.0 ist sort veraltet. Verwenden Sie stattdessen sortWith.
174 Kapitel 5: Scala Hier sehen Sie die Methode in Aktion: scala> val list = List(1, 2, 3) list: List[Int] = List(1, 2, 3) scala> val sum = sum: Int = 6
(0
/:
list) {(sum, i) =>
sum +
i}
Wir hatten das schon bei Ruby, aber es hilft vielleicht, es sich noch einmal anzusehen. Es funktioniert so: 앫
Wir rufen den Operator mit einem Wert und einem Codeblock auf. Der Codeblock verlangt die beiden Argumente sum und i.
앫
Zu Beginn nimmt /: den Startwert 0 und das erste Element der Liste, 1, und übergibt sie an den Codeblock. sum ist 0, i ist 1, und das Ergebnis von 0+1 ist 1.
앫
Als Nächstes nimmt /: die 1 (das Ergebnis des Codeblocks) und übergibt sie als sum erneut an die Berechnung. sum ist jetzt also 1; i ist das nächste Element der Liste (also 2), und das Ergebnis des Codeblocks lautet 3.
앫
Zum Schluss nimmt sich /: die 3 (das Ergebnis des Codeblocks) und übergibt sie wieder als sum an die Berechnung. sum ist also 3, i ist das nächste Element der Liste (3), und sum + i ist 6.
Die Syntax der anderen Version von foldLeft wird Ihnen etwas seltsam vorkommen. Sie verwendet ein als Currying bezeichnetes Konzept. Funktionale Sprachen verwenden Currying, um eine Funktion mit mehreren Parametern in verschiedene Funktionen mit einer eigenen Parameterliste umzuwandeln. Sie werden in Kapitel 8, Haskell, auf Seite 287 noch mehr Currying sehen. Sie müssen nur begreifen, dass hinter den Kulissen eine Reihe von Funktionen abläuft, nicht nur eine einzelne. Mechanik und Syntax sind zwar anders, aber das Ergebnis ist genau das gleiche: scala> val list = List(1, 2, 3) list: List[Int] = List(1, 2, 3) scala> list.foldLeft(0)((sum, res54: Int = 6
value) =>
sum +
value)
Beachten Sie, dass der Funktionsaufruf list.foldLeft(0)((sum, value) => sum + value) zwei Parameterlisten aufweist. Das ist das vorhin erwähnte Currying-Konzept. Sie werden Varianten dieser Methode bei allen anderen Sprachen in diesem Buch finden.
Tag 2: Gesträuch beschneiden und andere neue Tricks 175
Was wir am zweiten Tag gelernt haben Der erste Tag war dem Durcharbeiten der Ihnen bereits vertrauten objektorientierten Features gewidmet. Am zweiten Tag wurde Scalas primärer Daseinsgrund eingeführt: funktionale Programmierung. Wir haben mit einer grundlegenden Funktion begonnen. Scala besitzt eine flexible Syntax mit Funktionsdefinitionen. Der Compiler kann den Rückgabetyp häufig ableiten. Der Funktionsrumpf kennt einzeilige und Codeblock-Varianten, und die Parameterliste kann variieren. Als Nächstes haben wir uns verschiedene Collections angesehen. Scala unterstützt drei Arten: Listen, Maps und Sets. Ein Set ist eine Sammlung von Objekten. Eine Liste ist eine geordnete Collection. Maps sind Schlüssel/Wert-Paare. Wie bei Ruby gibt es leistungsfähige Kombinationen aus Codeblöcken und unterschiedlichen Collections. Wir haben uns einige Collection-APIs angesehen, die die Paradigmen funktionaler Programmierung demonstrieren. Bei Listen konnten wir (genau wie bei Prolog) auch Lisp-artige Methoden verwenden, die das erste Element der Liste oder den Rest zurückgeben. Wir haben außerdem die Methoden count, empty und first (zum jeweiligen offensichtlichen Zweck) genutzt. Die leistungsfähigsten Methoden nutzten Funktionsblöcke. Wir haben foreach zur Iteration eingesetzt und mit filter verschiedene Elemente von Listen selektiert. Sie haben auch erfahren, wie man foldLeft benutzt, um Ergebnisse zu akkumulieren, während man die Elemente einer Collection verarbeitet. Auf diese Weise wurde zum Beispiel eine fortlaufende Gesamtsumme berechnet. Bei der funktionalen Programmierung geht es zu einem großen Teil darum, zu lernen, wie man Collections mit Konstrukten höherer Ebene manipuliert, statt eine Iteration im Java-Stil durchzuführen. Wir werden diese Fähigkeiten an Tag 3 auf Herz und Nieren prüfen, wenn wir Nebenläufigkeit benutzen, ein wenig XML verwenden und ein einfaches praktisches Beispiel durcharbeiten. Bleiben Sie dran!
176 Kapitel 5: Scala
Tag 2: Selbststudium Nachdem wir jetzt etwas tiefer in Scala eingestiegen sind, werden Sie einige der funktionalen Aspekte kennenlernen. Wann immer es um den Umgang mit Funktionen geht, bilden Collections einen guten Ausgangspunkt. Die Übungen ermöglichen es Ihnen, Collections, aber auch einige Funktionen zu nutzen. Finden Sie Folgendes: 앫
eine Erörterung über den Einsatz von Scala-Dateien und
앫
was ein Closure von einem Codeblock unterscheidet.
Machen Sie Folgendes:
5.4
앫
Verwenden Sie foldLeft, um die Gesamtgröße einer Liste von Strings zu berechnen.
앫
Schreiben Sie einen Zensur -Trait mit einer Methode, die die Schimpfwörter Mist und Verdammt durch Blupp und Piep ersetzt. Verwenden Sie eine Map, um die Schimpfwörter und ihre Alternativen zu speichern.
앫
Laden Sie die Schimpfwörter und die Alternativen aus einer Datei.
Tag 3: Sich durch die Fusseln schneiden Kurz vor dem Höhepunkt von „Edward mit den Scherenhänden“ lernt Edward, seine Scheren im täglichen Leben als Künstler zu gebrauchen. Er formt Hecken zu Dinosauriern um, gestaltet spektakuläre Frisuren mit der Leichtigkeit eines Vidal Sassoon und tranchiert sogar den Festtagsbraten. Wir hatten mit Scala einige peinliche Momente, doch wenn sich die Sprache gut anfühlt, ist sie nur mäßig spektakulär. Schwierige Dinge wie XML und Nebenläufigkeit werden fast zur Routine. Sehen wir uns das an.
XML Moderne Programmierprobleme treffen mit zunehmender Regelmäßigkeit auf die Extensible Markup Language (XML). Scala unterimmt den drastischen Schritt, XML zu einem Programmierkonstrukt der Sprache selbst zu machen. Sie können XML so einfach ausdrücken wie einen String:
Tag 3: Sich durch die Fusseln schneiden 177 scala> val movies = | <movies> | <movie genre="action">Pirates of the Caribbean | <movie genre="fairytale">Edward Scissorhands | movies: scala.xml.Elem = <movies> <movie genre="action">Pirates of the Caribbean <movie genre="fairytale">Edward Scissorhands
Nachdem Sie die Variable filme mit XML definiert haben, können Sie auf die verschiedenen Elemente direkt zugreifen. Zum Beispiel können Sie sich den gesamten inneren Text ansehen, indem Sie einfach Folgendes eingeben: scala> movies.text res1: String = Pirates of the Caribbean Edward Scissorhands
Sie sehen den gesamten inneren Text aus dem obigen Beispiel. Doch Sie sind nicht gezwungen, den gesamten Block auf einmal zu verarbeiten. Sie können etwas wählerischer sein. Scala integriert eine Abfragesprache, die stark an XPath (eine XML-Suchsprache) erinnert. Doch da // bei Scala ein Kommentar ist, verwendet es \ und \\. Um nach den Top-Level-Knoten zu suchen, verwenden Sie einen Backslash: scala> val movieNodes = movies \ "movie" movieNodes: scala.xml.NodeSeq = <movie genre="action">Pirates of the Caribbean <movie genre="fairytale">Edward Scissorhands
Hier haben wir nach XML-film-Elementen gesucht. Sie können per Index nach einzelnen Knoten suchen: scala> movieNodes(0) res3: scala.xml.Node = <movie genre="action">Pirates of the Caribbean
Wir haben nur das Element Nummer null gefunden, Fluch der Karibik. Sie können mithilfe des @-Symbols auch nach Attributen einzelner XML-Knoten suchen. Um beispielsweise das genre-Attribut des ersten Elements im Dokument zu ermitteln, benutzt man folgende Suche: scala> movieNodes(0) \ "@genre" res4: scala.xml.NodeSeq = action
Dieses Beispiel kratzt nur an der Oberfläche dessen, was möglich ist, aber jetzt haben Sie zumindest eine Vorstellung davon. Wenn wir die Mustererkennung im Prolog-Stil dazunehmen, wird das Ganze etwas
178 Kapitel 5: Scala aufregender. Als Nächstes wollen wir ein Beispiel für die Mustererkennung mit einfachen Strings durchgehen.
Mustererkennung Die Mustererkennung (Pattern Matching) ermöglicht die bedingte Ausführung von Code auf der Basis eines bestimmten Datenfragments. Scala nutzt die Mustererkennung häufig, etwa beim Parsing von XML und bei der Übergabe von Nachrichten zwischen Threads. Hier die einfachste Form der Mustererkennung: scala/chores.scala
def
doChore(chore: String): String = chore match { case "clean dishes" => "scrub, dry" case "cook dinner" => "chop, sizzle" case _ => "whine, complain"
} println(doChore("clean dishes")) println(doChore("mow lawn"))
Wir definieren zwei Hausarbeiten, clean dishes und cook dinner. Neben jeder Hausarbeit steht ein Codeblock. In diesem Fall geben die Codeblöcke einfach Strings zurück. Die letzte definierte Arbeit ist der Platzhalter _. Scala führt den Codeblock mit der ersten Hausarbeit aus und gibt „whine, complain“ zurück, wenn es keinen Treffer gibt: >> scala chores.scala scrub, dry whine, complain
Guards Die Mustererkennung verfügt über einige Ausschmückungen. Bei Prolog war die Mustererkennung häufig mit Bedingungen verknüpft. Um die Fähigkeit in Scala zu implementieren, geben wir in einem Guard für jede match-Anweisung eine Bedingung an: scala/factorial.scala
def
factorial(n: Int): Int = n match { case 0 => 1 case x if x>0 => factorial(n - 1) * n
} println(factorial(3)) println(factorial(0))
Tag 3: Sich durch die Fusseln schneiden 179 Die erste Mustererkennung betrifft die 0, doch der zweite Guard hat die Form case x if x > 0. Er erkennt jedes x für x > 0. Sie können auf diese Weise eine Vielzahl von Bedingungen festlegen. Die Mustererkennung kann auch reguläre Ausdrücke und Typen erkennen. Sie werden später ein Beispiel sehen, in dem eine leere Klasse definiert und als Nachrichten in unseren Nebenläufigkeits-Beispielen verwendet wird.
Reguläre Ausrücke Scala besitzt ausgezeichnete reguläre Ausdrücke. Die Methode .r kann jeden String in einen regulären Ausdruck umwandeln. Auf der nächste Seite sehen Sie ein Beispiel für einen regulären Ausdruck, der das große und das kleine F bzw. f am Anfang von Strings erkennt. scala> val reg = """^(F|f)\w*""".r reg: scala.util.matching.Regex = ^(F|f)\w* scala> println(reg.findFirstIn("Fantastic")) Some(Fantastic) scala> println(reg.findFirstIn("not Fantastic")) None
Wir beginnen mit einem einfachen String. Wir verwenden die durch """ getrennte Form, die mehrzeilige Strings erlaubt und die Evaluierung unterdrückt. Die Methode .r wandelt den String in einen regulären Ausdruck um. Wir verwenden dann die Methode findFirstIn, um das erste Vorkommen zu finden. scala> val reg = "the".r reg: scala.util.matching.Regex = the scala> reg.findAllIn("the way the scissors trim the hair and the res9: scala.util.matching.Regex.MatchIterator = non-empty iterator
shrubs")
In diesem Beispiel bauen wir einen regulären Ausdruck auf und verwenden die Methode findAllIn, um alle Vorkommen des Wortes die im String "the way the scissors trim the hair and the shrubs" zu finden. Wir könnten bei Bedarf die ganze Trefferliste mit foreach durchgehen. Und mehr gibt es tatsächlich nicht zu sagen. Sie können mit regulären Ausdrücken vergleichen, wie Sie es mit Strings machen würden.
180 Kapitel 5: Scala
XML mit Matching Eine interessante Kombination bei Scala ist die XML-Syntax in Verbindung mit der Mustererkennung. Sie können eine XML-Datei durchgehen und basierend auf den zurückgelieferten XML-Elementen Code ausführen. Betrachten Sie beispielsweise die folgende XMLFilmdatei: scala/movies.scala
val movies = <movies> <movie>The Incredibles <movie>WALL E <short>Jack Jack Attack <short>Geri's Game (movies \ "_").foreach { movie => movie match { case <movie>{movieName} => println(movieName) case <short>{shortName} => println(shortName + " (short)") } }
Alle Knoten des Baums werden abgefragt. Dann werden Muster verwendet, die shorts und movies erkennen. Ich mag die Art und Weise, wie Scala die gängigsten Arbeiten erleichtert, indem man mit XML-Syntax, Mustererkennung und der XQuery-artigen Sprache arbeitet. Das Ergebnis kommt geradezu mühelos. Das waren also einige Grundlagen zur Mustererkennung. Sie werden sie im nächsten Abschnitt zur Nebenläufigkeit in Aktion sehen.
Nebenläufigkeit Einer der wichtigsten Aspekte von Scala ist der Umgang mit Nebenläufigkeit. Die primären Konstrukte sind Aktoren und das Message Passing. Aktoren verwenden Pools von Threads und Queues. Wenn Sie eine Nachricht an einen Aktor senden (über den !-Operator), platzieren Sie ein Objekt in dessen Queue. Der Aktor liest die Nachricht und führt eine Aktion aus. Häufig verwendet der Aktor eine Mustererkennung, um die Nachricht zu erkennen und entsprechend zu verarbeiten. Sehen wir uns das kids-Programm an:
Tag 3: Sich durch die Fusseln schneiden 181 scala/kids.scala
import import case case
scala.actors._ scala.actors.Actor._ object Poke object Feed
class Kid() extends Actor { def act() { loop { react { case Poke => { println("Ow...") println("Quit it...") } case Feed => { println("Gurgle...") println("Burp...") } } } } } val bart = new Kid().start val lisa = new Kid().start println("Ready to poke and feed...") bart ! Poke lisa ! Poke bart ! Feed lisa ! Feed
Bei diesem Programm erzeugen wir zwei leere, triviale Singletons namens Poke und Feed. Diese Objekte machen nichts. Sie dienen einfach als Nachrichten. Das Herz des Programms bildet die Kid-Klasse. Kid ist ein Aktor, d. h. er wird über einen Pool von Threads ausgeführt und erhält Nachrichten in einer Queue. Er verarbeitet eine Nachricht und macht mit der nächsten weiter. Wir starten eine einfache Schleife. Darin finden Sie ein react-Konstrukt. react empfängt die Nachrichten eines Aktors. Die Mustererkennung lässt uns die entsprechende Nachricht erkennen, die immer nur Poke oder Feed enthält. Der Rest des Skripts erzeugt einige Kids und sendet ihnen Poke- oder Feed-Nachrichten. Sie können es so ausführen: batate$ scala code/scala/kids.scala Ready to poke and feed... Ow... Quit it... Ow... Quit it... Gurgle...
182 Kapitel 5: Scala Burp... Gurgle... Burp... batate$ scala code/scala/kids.scala Ready to poke and feed... Ow... Quit it... Gurgle... Burp... Ow... Quit it... Gurgle... Burp...
Ich führe die Anwendung mehrmals aus, um zu zeigen, dass sie tatsächlich nebenläufig ist. Beachten Sie, das die Reihenfolge unterschiedlich ist. Bei Aktoren können Sie auch einen Timeout festlegen. reactWithin reagiert mit einem Timeout, wenn nicht innerhalb der festgelegten Zeitspanne eine Nachricht empfangen wird. Außerdem können Sie receive (das einen Thread blockiert) und receiveWithin (blockiert den Thread mit Timeout) verwenden.
Nebenläufigkeit in Aktion Da es für simulierte Simpsons nur einen kleinen Markt gibt, wollen wir etwas Anspruchsvolleres entwickeln. In der folgenden Anwendung namens sizer berechnen wir die Größen von Webseiten. Wir besuchen ein paar Seiten und berechnen deren Größe. Da es dabei zu langen Wartezeiten kommt, wollen wir alle Seiten gleichzeitig über Aktoren abrufen. Nehmen Sie sich etwas Zeit, sich das ganze Programm anzusehen. Dann sehen wir uns zusammen einzelne Abschnitte an:. scala/sizer.scala
import import import
scala.io._ scala.actors._ Actor._
object PageLoader { def getPageSize(url : String) = Source.fromURL(url).mkString.length } val urls = List("http://www.amazon.com/", "http://www.twitter.com/", "http://www.google.com/", "http://www.cnn.com/" ) def timeMethod(method: () => Unit) = { val start = System.nanoTime method()
Tag 3: Sich durch die Fusseln schneiden 183 val end = System.nanoTime println("Method took " + (end - start)/1000000000.0 + " seconds.") } def getPageSizeSequentially() = { for(url <- urls) { println("Size for " + url + ": " + PageLoader.getPageSize(url)) } } def getPageSizeConcurrently() = { val caller = self for(url <- urls) { actor { caller ! (url, PageLoader.getPageSize(url)) } } for(i <- 1 to urls.size) { receive { case (url, size) => println("Size for " + url + ": " + size) } } } println("Sequential run:") timeMethod { getPageSizeSequentially } println("Concurrent run") timeMethod { getPageSizeConcurrently }
Fangen wir also vorne an. Zuerst importieren wir einige Bibliotheken für Aktoren und die Ein-/Ausgabe, damit wir nebenläufig arbeiten und HTTP-Anfragen stellen können. Als Nächstes berechnen wir die Größe einer Seite für eine gegebene URL: object PageLoader { def getPageSize(url : String) = Source.fromURL(url).mkString.length }
Dann erzeugen wir einen val mit ein paar URLs. Dann folgt eine Methode, die die Laufzeit der einzelnen Web-Requests ermittelt: def timeMethod(method: () => Unit) = { val start = System.nanoTime method() val end = System.nanoTime println("Method took " + (end - start)/1000000000.0 + " seconds.") }
Jetzt führen wir die Web-Requests mit zwei unterschiedlichen Methoden aus. Die erste läuft sequenziell, wir durchlaufen also jeden Request in einer forEach-Schleife.
184 Kapitel 5: Scala def getPageSizeSequentially() = { for(url <- urls) { println("Size for " + url + ": " + PageLoader.getPageSize(url)) } }
Hier die Methode, die das asynchron erledigt: def getPageSizeConcurrently() = { val caller = self for(url <- urls) { actor { caller ! (url, PageLoader.getPageSize(url)) } } for(i <- 1 to urls.size) { receive { case (url, size) => println("Size for " + url + ": " + size) } } }
Bei diesem Aktor wissen wir, dass wir eine feste Zahl von Nachrichten empfangen. Innerhalb der for -Schleife senden wir vier asynchrone Requests. Das passiert mehr oder weniger sofort. Als Nächstes empfangen wir einfach vier Nachrichten mit receive. Diese Methode erledigt die eigentliche Arbeit. Zum Schluss können wir das Skript ausführen, das den Test aufruft: println("Sequential run:") timeMethod { getPageSizeSequentially } println("Concurrent run") timeMethod { getPageSizeConcurrently }
Und hier die Ausgabe: >> scala sizer.scala Sequential run: Size for http://www.amazon.com/: 81002 Size for http://www.twitter.com/: 43640 Size for http://www.google.com/: 8076 Size for http://www.cnn.com/: 100739 Method took 6.707612 seconds. Concurrent run Size for http://www.google.com/: 8076 Size for http://www.cnn.com/: 100739 Size for http://www.amazon.com/: 84600 Size for http://www.twitter.com/: 44158 Method took 3.969936 seconds.
Wie erwartet, ist die nebenläufige Schleife schneller. Das ist ein Überblick zu einem interessanten Problem in Scala. Fassen wir zusammen.
Tag 3: Sich durch die Fusseln schneiden 185
Was wir an Tag 3 gelernt haben Was Tag 3 an Größe fehlt, macht er durch Intensität wett. Wir haben eine Reihe nebenläufiger Programme entwickelt und XML verarbeitet, verteiltes Message Passing mit Aktoren vorgenommen sowie Mustererkennung und reguläre Ausdrücke genutzt. Im Verlauf des Kapitels haben Sie vier fundamentale Konstrukte kennengelernt, die aufeinander aufbauen. Zuerst haben Sie gelernt, XML direkt in Scala zu nutzen. Wir haben einzelne Elemente und Attribute über eine XQuery-artige Sprache abgefragt. Dann haben wir Scalas Version der Mustererkennung vorgestellt. Zuerst sah das nach einer einfachen case-Anweisung aus, doch als Guards, Typen und reguläre Ausdrücke dazukamen, wurde die Leistungsfähigkeit schnell offensichtlich. Als Nächstes haben wir uns der Nebenläufigkeit zugewandt. Wir haben das Aktor-Konzept verwendet. Aktoren sind für die Nebenläufigkeit enwickelte Objekte. Sie verwenden üblicherweise eine Schleifenanweisung um eine react- oder receive-Methode, die die eigentliche Arbeit erledigt, nämlich die in der Queue liegenden Nachrichten für das Objekt zu empfangen. Abschließend folgte im Inneren eine Mustererkennung. Wir haben einfache Klassen als Nachrichten verwendet. Diese sind klein, leicht, robust und einfach zu manipulieren. Wenn wir Parameter in der Nachricht benötigen, können wir einfach Attribute in unsere Klassendefinition aufnehmen, so wie wir es für die URL innerhalb der sizer -Anwendung getan haben. Wie alle Sprachen in diesem Buch ist Scala wesentlich robuster, als Sie es hier gesehen haben. Die Interaktion mit Java-Klassen geht deutlich tiefer, als ich es gezeigt habe. Und bei komplexen Konzepten wie Currying habe ich bloß an der Oberfläche gekratzt. Aber Sie sollten eine gute Grundlage besitzen, falls Sie tiefer einsteigen wollen.
Tag 3: Selbststudium Nun haben Sie also einige der fortschrittlicheren Features gesehen, die Scala zu bieten hat. Sie können Scala jetzt selbst auf Herz und Nieren prüfen. Mal wieder sind die Übungen nun etwas anspruchsvoller. Finden Sie Folgendes heraus: 앫
Was würde passieren, wenn Sie im sizer -Programm keinen neuen Aktor für jeden Link erzeugen würden? Wie würde sich das auf die Performance der Anwendung auswirken?
186 Kapitel 5: Scala Tun Sie Folgendes:
5.5
앫
Erweitern Sie die sizer -Anwendung um eine Nachricht, die die Anzahl der Links auf der Seite zählt.
앫
Zusatzaufgabe: Lassen Sie sizer die Links auf einer Seite verfolgen und diese ebenfalls laden. Beispielsweise würde sizer für „google.com“ die Größe für Google berechnen und für alle Seiten, mit denen es verlinkt ist.
Scala zusammengefasst Wir haben Scala umfassender betrachtet als die bisherigen Sprachen, weil Scala zwei Programmierparadigmen unterstützt. Die objektorientierten Features positionieren Scala nachdrücklich als Java-Alternative. Im Gegensatz zu Ruby und Io verwendet Scala die statische Typisierung. Syntaktisch leiht sich Scala viele Elemente von Java aus, einschließlich geschweifter Klammern und der Nutzung von Konstruktoren. Scala bietet auch eine starke Unterstützung funktionaler Konzepte und unveränderlicher Variablen. Die Sprache konzentriert sich stark auf Nebenläufigkeit und XML, was eine Vielzahl von Unternehmensanwendungen abdeckt, die momentan in Java implementiert sind. Scalas funktionale Fähigkeiten gehen über das hinaus, was ich in diesem Kapitel behandelt habe. Konstrukte wie Currying, vollständige Closures, Multiparameter-Listen oder die Ausnahmebehandlung wurden nicht betrachtet, sind aber alle wertvolle Konzepte, die die Leistungsfähigkeit und Flexibilität von Scala erhöhen. Sehen wir uns einige wesentliche Stärken und Schwächen von Scala an.
Kernstärken Ein Großteil von Scalas Stärken baut sich rund um ein fortgeschrittenes Programmierparadigma auf, das sich gut in die Java-Umgebung integriert, sowie um einige gut entworfene Kernfeatures. Insbesondere Aktoren, die Mustererkennung und die XML-Integration sind wichtig und sehr gut entworfen. Lassen Sie uns gleich in die Liste einsteigen.
Scala zusammengefasst 187
Nebenläufigkeit Scalas Handhabung der Nebenläufigkeit ist ein signifikanter Fortschritt für die nebenläufige Programmierung. Das Aktorenmodell und der Thread-Pool sind willkommene Verbesserungen, und die Fähigkeit, Anwendungen ohne veränderliche Zustände zu entwickeln, ist absolut großartig. Das Aktorparadigma, das Sie bei Io und jetzt bei Scala kennengelernt haben, ist für Entwickler einfach zu verstehen und in der akademischen Gemeinschaft ausreichend untersucht worden. Sowohl Java als auch Ruby könnten in dieser Hinsicht Verbesserungen vertragen. Das Nebenläufigkeitsmodell ist aber nur ein Teil der Geschichte. Wenn sich Objekte Zustände teilen, müssen Sie unveränderliche Werte anstreben. Io und Scala bekommen das zumindest teilweise hin, d. h. es gibt veränderliche Zustände, aber auch Bibliotheken und Schlüsselwörter, die Unveränderlichkeit unterstützen. Unveränderlichkeit ist die wichtigste Sache, wenn es darum geht, das Codedesign für Nebenläufigkeit zu verbessern. Schließlich ähnelt die Message-Passing-Syntax, die Sie bei Scala gesehen haben, stark dem, was Sie im nächsten Kapitel über Erlang sehen werden. Sie bildet eine deutliche Verbesserung gegenüber den Standardbibliotheken für Java-Threading.
Evolution des Java-Erbes Scala startet mit einer starken, fest integrierten Nutzerbasis: der JavaCommunity. Scala-Anwendungen können Java-Bibliotheken direkt verwenden. Bei Bedarf können durch Codegenerierung Proxy-Objekte geschaffen werden, weshalb die Interoperabilität hervorragend ist. Die abgeleitete Typisierung ist ein dringend benötigter Fortschritt gegenüber dem archaischen Java-Typisierungssystem. Die beste Möglichkeit, eine neue Programmiercommunity zu schaffen, besteht darin, eine vorhandene zu vereinnahmen. Scala macht seine Aufgabe gut und bietet ein prägnanteres Java. Und die Idee hat einen Nutzen. Scala bietet der Java-Community auch neue Features. Codeblöcke sind Sprachkonstrukte erster Güte und arbeiten gut mit den CollectionBibliotheken zusammen. Scala bietet auch erstklassige Mixins in Form von Traits. Die Mustererkennung ist ebenfalls eine wesentliche Verbesserung. Mit diesen und weiteren Features besitzen Java-Entwickler eine fortschrittliche Programmiersprache, ohne die fortschrittlicheren funktionalen Paradigmen überhaupt anrühren zu müssen.
188 Kapitel 5: Scala Mischen Sie funktionale Konstrukte ein, und Sie besitzen deutlich verbesserte Anwendungen. Scala-Anwendungen weisen üblicherweise nur einen Bruchteil der Codezeilen einer gleichwertigen Java-Anwendung auf, und das ist extrem wichtig. Eine bessere Programmiersprache sollte es ermöglichen, komplexere Ideen mit minimalem Overhead in weniger Codezeilen auszudrücken. Scala löst dieses Versprechen ein.
Domänenspezifische Sprachen Scalas flexible Syntax und die Überladung von Operatoren machen es zu einer idealen Sprache zur Entwicklung domänenspezifischer Sprachen im Stil von Ruby. Denken Sie daran, dass Operatoren (genau wie bei Ruby) einfach nur Methodendeklarationen sind, die Sie in den meisten Fällen überschreiben können. Darüber hinaus lassen zusätzliche Leerzeichen, Punkte und Semikola die Syntax viele verschiedene Formen annehmen. Zusammen mit Mixins sind das genau die Werkzeuge, die DSL-Entwickler suchen.
XML Scala unterstützt von Haus aus XML. Die Mustererkennung macht das Parsing von Blöcken verschiedenartiger XML-Strukturen einfach. Die Integration der XPath-Syntax, um tief in komplexes XML einzutauchen, führt zu einfachem, gut lesbarem Code. Dieser Vorteil ist einfach und wichtig, insbesondere für die XML-lastige Java-Community.
Brückenbildung Das Entstehen eines neuen Programmierparadigmas verlangt nach einer Brücke. Scala ist gut positioniert, um als diese Brücke fungieren zu können. Das funktionale Programmiermodell ist wichtig, weil es Nebenläufigkeit gut abdeckt, und kommende Prozessordesigns setzen verstärkt auf den Parallelbetrieb. Scala bietet hier den Entwicklern einen iterativen Ansatz.
Schwächen Auch wenn ich viele Ideen von Scala vom Konzept her mag, empfinde ich die Syntax als anstrengend und akademisch. Zwar ist die Syntax eine Frage des Geschmacks, aber Scala bringt da eine größere Bürde mit sich als die meisten anderen Sprachen (zumindest in meinen Augen). Ich finde auch, dass einige der Kompromisse, die Scala zu so einer effektiven Brücke machen, seinen Wert schmälern. Ich sehe nur drei Schwächen, aber die sind groß.
Scala zusammengefasst 189
Statische Typisierung Statische Typisierung ist die natürliche Wahl für funktionale Programmiersprachen, doch Java-artige statische Typisierung für objektorientierte Systeme ist ein Pakt mit dem Teufel. Manchmal muss man die Anforderungen des Compilers erfüllen, imdem man den Entwicklern etwas mehr Arbeit aufbürdet. Mit der statischen Typisierung ist diese Bürde viel größer, als Sie es erwarten würden. Die Auswirkungen auf Code, Syntax und Programmdesign reichen weit. Während ich die Sprache erlernte, befand ich mich im ständigen Kampf mit der Sprachsyntax und dem Programmdesign. Traits haben diese Last ein wenig verringert, doch ich empfand diesen Kompromiss zwischen Programmierflexibilität und Überprüfung während der Kompilierung als unbefriedigend. Später in diesem Buch werden Sie sehen, wie ein rein funktionales, starkes, statisch typisiertes Typsystem bei Haskell aussieht. Ohne die Bürde zweier Programmierparadigmen wird das Typsystem wesentlich flüssiger und produktiver, unterstützt Polymorphismus besser und verlangt vom Programmierer weniger Strenge für das gleiche Ergebnis.
Syntax Ich empfinde Scalas Syntax als etwas zu akademisch und mühsam. Ich habe mit mir gehadert, ob ich das hier schreiben sollte, da die Syntax etwas Subjektives ist, aber einige Elemente sind etwas verwirrend. Manchmal hält Scala Java-Konventionen ein, etwa bei Konstruktoren. Man verwendet new Person anstelle von Person.new. Bei anderen Gelegenheiten führt Scala neue Konventionen ein, etwa bei Argumenttypen. Bei Java würden Sie setName(String name) statt Scalas setName(name: String) verwenden. Rückgabetypen verlagern sich vom Anfang an das Ende der Methodendeklaration. Die kleinen Unterschiede lassen mich über die Syntax nachdenken statt über den Code. Das Problem ist, dass der Wechsel zwischen Scala und Java aufwendiger ist, als er sein sollte.
Veränderlichkeit Wenn Sie eine Sprachbrücke bauen, müssen Sie Kompromisse eingehen. Ein signifikanter Kompromiss bei Scala ist die Einführung von Veränderlichkeit. Mit var öffnet Scala in gewisser Weise die Büchse der Pandora, weil veränderliche Zustände bei der Nebenläufigkeit eine Vielzahl von Fehlern heraufbeschwören. Doch solche Kompromisse sind unvermeidlich, wenn Sie das besondere Kind nach Hause bringen wollen, das im Haus auf dem Hügel wohnt.
190 Kapitel 5: Scala
Abschließende Gedanken Alles in allem waren meine Erfahrungen mit Scala gemischt. Die statische Typisierung war nicht meins, gleichzeitig begrüßte der Java-Programmierer in mir die verbesserten Modelle zur Nebenläufigkeit, die abgeleitete Typsierung und XML. Scala stellt einen signifikanten Schritt in Richtung Stand der Technik dar. Ich würde Scala nutzen, um meine Produktivität zu erhöhen, wenn ich signifikant in Java-Programme oder -Programmierer investiert hätte. Ich würde Scala auch für eine Anwendung in Erwägung ziehen, die beträchtliche Anforderungen an die Skalierbarkeit stellt und Nebenläufigkeit verlangt. Kommerziell hat dieser Frankenstein eine gute Chance, weil er ein Brücke darstellt und ein wichtige Programmiercommunity anspricht.
Do you hear that, Mr. Anderson? That is the sound of inevitability Agent Smith
Kapitel 6
Erlang Nur wenige Sprachen haben einen so geheimnisvollen Nimbus wie Erlang, die Sprache zur Parallelprogrammierung, die Schwieriges leicht macht und Leichtes schwierig. Die virtuelle Maschine namens BEAM rivalisiert beim stabilen Unternehmenseinsatz nur mit der Java Virtual Machine. Man kann sie als brutal effektiv bezeichnen, doch Erlangs Syntax fehlt die Schönheit und Einfachheit von beispielsweise Ruby. Denken Sie an den Agenten Smith aus „Matrix“.1 „Matrix“ ist ein Science-Fiction-Klassiker aus dem Jahr 1999, der unsere Welt als virtuelle Welt darstellt, die (als Illusion) von Computern aufgebaut und gepflegt wird. Agent Smith ist eine künstliche Intelligenz in der Matrix, der jede Form annehmen und die Regeln der Realität beugen kann, um an vielen Stellen gleichzeitig zu sein. Man kann ihm nicht entkommen.
6.1
Einführung in Erlang Der Name klingt seltsam. Er ist ein Akronym für „Ericsson Language“. Die Sprache teilt sich den Namen mit einem dänischen Mathematiker, was ja irgendwie passt. Agner Karup Erlang war ein großer Name in der Mathematik, der hinter der Analyse von Telefonnetzwerken steckt. Im Jahr 1986 entwickelte Joe Armstrong die erste Version von Erlang, die er ein halbes Jahrzehnt lang weiterentwickelte und aufpolierte. Während der 1990er wuchs es sporadisch und konnte in den 2000ern noch weiter Fuß fassen. Es ist die Sprache hinter CouchDB und SimpleDB, zwei populären Datenbanken für die Cloud. Erlang steckt auch hinter Facebooks Chat. Der Hype um Erlang nimmt stetig zu, weil 1 The Matrix. DVD. Directed by Andy Wachowski, Lana Wachowski. 1999; Burbank, CA: Warner Home Video, 2007.
192 Kapitel 6: Erlang es etwas bietet, was viele andere Sprachen nicht können: skalierbare Nebenläufigkeit und Zuverlässigkeit.
Entwickelt für Nebenläufigkeit Erlang ist ein Produkt jahrelanger Forschungsarbeit von Ericsson. Ziel war die Entwicklung nahezu echtzeitfähiger, fehlertoleranter, verteilter Programme für Telekom-Anwendungen. Die Systeme konnten für Wartungsarbeiten häufig nicht abgeschaltet werden und die Softwareentwicklung war unglaublich teuer. Ericsson untersuchte Programmiersprachen während der 1980er und fand heraus, dass die existierenden Sprachen aus dem einen oder anderen Grund für die Anforderungen ungeeignet waren. Diese Anforderungen führten schließlich zur Entwicklung einer ganz neuen Sprache. Erlang ist eine funktionale Sprache, eine, in die viele der Zuverlässigkeit dienende Features integriert sind. Erlang kann irrsinnig zuverlässige Systeme unterstützen. Sie können einen Telefon-Switch für Wartungsarbeiten nicht einfach abschalten und müssen Erlang nicht herunterfahren, um ganze Module zu ersetzen. Einige mit ihm entwickelte Anwendungen laufen jahrelang, ohne für die Wartung heruntergefahren zu werden. Doch Erlangs Schlüsselfähigkeit ist die Nebenläufigkeit. Experten in Sachen Nebenläufigkeit sind sich über den besten Ansatz nicht immer einig. Eine typische Diskussion dreht sich um die Frage, ob Threads oder Prozesse eine bessere Nebenläufigkeit ergeben. Viele Threads bilden einen Prozess. Prozesse besitzen eigene Ressourcen. Threads haben ihren eigenen Ausführungspfad, teilen sich aber Ressourcen mit anderen Threads im selben Prozess. Üblicherweise ist ein Thread leichtgewichtiger als ein Prozess, auch wenn sich die Implementierungen unterscheiden.
Kein Threading Viele Sprachen, wie Java und C, verwenden einen Threading-Ansatz für Nebenläufigkeit. Threads benötigen weniger Ressourcen, sollten also theoretisch eine bessere Performance ergeben. Der Nachteil von Threads besteht darin, dass gemeinsam genutzte Ressourcen zu komplexen, fehlerhaften Implementierungen führen können. Außerdem sind Locks nötig, die zu einem Flaschenhals werden können. Um die Kontrolle von zwei Anwendungen koordinieren zu können, die sich Ressourcen teilen, benötigen Thread-Systeme Semaphoren oder ein Locking auf Betriebssystemebene. Erlang verfolgt einen anderen Ansatz: Es versucht, die Prozesse so leichtgewichtig wie möglich zu machen.
Einführung in Erlang 193
Leichtgewichtige Prozesse Statt sich durch einen Sumpf aus gemeinsam genutzten Ressourcen und den Ressourcenflaschenhälsen zu kämpfen, greift Erlang die Philosophie leichtgewichtiger Prozesse auf. Erlangs Schöpfer haben sich sehr bemüht, die Erzeugung, Verwaltung und Kommunikation von Prozessen in Anwendungen zu vereinfachen. Verteiltes Message-Passing ist ein grundlegendes Sprachkonstrukt, durch das die Notwendigkeit des Locking vermieden und die Nebenläufigkeit verbessert wird. Wie Io nutzt Armstrongs Schöpfung Aktoren für die Nebenläufigkeit, weshalb das Message-Passing ein kritisches Konzept darstellt. Sie werden Scalas Message-Passing-Syntax wiedererkennen, die Erlangs Message-Passing ähnelt. Bei Scala repräsentiert ein Aktor ein Objekt, hinter dem ein Thread-Pool liegt. Bei Erlang repräsentiert ein Aktor einen leichtgewichtigen Prozess. Der Aktor liest eingehende Nachrichten aus der Queue und verwendet Mustererkennung, um zu entscheiden, wie sie verarbeitet werden sollen.
Zuverlässigkeit Erlang besitzt traditionelle Fehlerprüfungen, doch bei traditionellen Anwendungen werden Sie weit weniger Fehlerhandlung sehen als in einer traditionellen fehlertoleranten Anwendung. Das Erlang-Mantra lautet „Lass es abstürzen“. Da Erlang es einfach macht, den Tod eines Prozesses zu überwachen, ist das Beenden dazugehöriger und das Starten neuer Prozesse eine triviale Angelegenheit. Sogar das „Hot-Swapping“ von Code ist möglich, Sie können also Teile der Anwendung ersetzen, ohne sie anhalten zu müssen. Diese Fähigkeit erlaubt wesentlich einfachere Wartungsstrategien als bei ähnlich gelagerten verteilten Anwendungen. Erlang kombiniert die robuste „Lass es abstürzen“-Strategie mit Hot-Swapping und leichtgewichtigen Prozessen, die sich mit minimalem Overhead starten lassen. Man kann leicht nachvollziehen, dass manche Anwendungen auf diese Weise über Jahre ohne Unterbrechung laufen. Erlangs Nebenläufigkeit ist also verlockend. Die wichtigen Primitive (Message-Passing, Starten von Prozessen, Überwachung von Prozessen) sind alle da. Die gestarteten Prozesse sind leichtgewichtig, weshalb Sie sich in diesem Bereich um beschränkte Ressourcen keine Gedanken machen müssen. Die Sprache wurde stark darauf ausgerichtet,
194 Kapitel 6: Erlang Nebenwirkungen und Veränderlichkeit zu vermeiden, und die Überwachung des Endes eines Prozesses ist einfach, ja trivial. Dieses kombinierte Paket ist sehr verführerisch.
Interview mit Dr. Joe Armstrong Beim Schreiben dieses Buches hatte ich die Gelegenheit, einige Leute kennenzulernen (zumindest per E-Mail), vor denen ich Hochachtung habe. Dr. Joe Armstrong, Schöpfer von Erlang und Autor von „Programming Erlang: Software for a Concurrent World“ [Arm07], steht für mich auf dieser Liste ganz weit oben. Ich führte mehrere Unterhaltungen mit ihm. Bruce: Warum haben Sie Erlang geschrieben? Dr. Armstrong: Durch Zufall. Ich bin nicht losgegangen, um eine neue Programmiersprache zu erfinden. Zu der Zeit wollten wir eine bessere Möglichkeit finden, Steuersoftware für eine Telefonvermittlung zu schreiben. Ich begann, mit Prolog zu experimentieren. Prolog war fantastisch, machte aber nicht genau das, was ich wollte. Also begann ich damit, an Prolog herumzubasteln. Ich dachte: „Was passiert wohl, wenn ich die Art ändere, wie Prolog Dinge tut?“ Also schrieb ich einen Prolog-Metainterpreter, der Prolog um parallele Prozesse erweiterte. Dann fügte ich einen Mechanismus zur Fehlerbehandlung hinzu, und so weiter. Nach einer Weile erhielt dieser Satz von Änderungen an Prolog einen Namen, Erlang, und eine neue Sprache war geboren. Dann schlossen sich weitere Leute dem Projekt an, die Sprache wuchs, wir fanden heraus, wie man sie kompilieren konnte, fügten weitere Dinge hinzu, weitere Nutzer kamen hinzu, und so weiter ... Bruce: Was mögen Sie an Prolog am meisten? Dr. Armstrong: Die Fehlerbehandlung, den Mechanismus zur Codeaktualisierung und die Mustererkennung auf Bit-Ebene. Die Fehlerbehandlung ist einer der am wenigsten verstandenen Teile der Sprache und der Teil, der sich am meisten von anderen Sprachen unterscheidet. Die gesamte Idee der „nichtdefensiven“ Programmierung und des „Lass es abstürzen“, des Mantra der Erlang-Programmierung, ist das genaue Gegenteil der üblichen Praxis, führt aber zu wirklich kurzen und schönen Programmen. Bruce: Welches Feature würden Sie am liebsten ändern, wenn Sie noch mal neu anfangen könnten? (Alternativ könnten Sie antworten, welches die größten Einschränkungen von Erlang sind.)
Einführung in Erlang 195 Dr. Armstrong: Das ist eine schwierige Frage. Wahrscheinlich gebe ich an verschiedenen Tagen unterschiedliche Antworten. Es wäre schön, die Sprache um Mobilität zu erweitern, so dass wir Berechnungen über das Netz schicken könnten. Das ist über Bibliotheken möglich, wird aber nicht direkt durch die Sprache unterstützt. Gerade jetzt denke ich, dass es wirklich nett wäre, an die Wurzeln von Erlang zurückzukehren und eine Prolog-artige Prädikatenlogik in die Sprache zu integrieren. Eine Art neuer Mischung aus Prädikatenlogik und Message-Passing. Dann gibt es eine Reihe kleinerer wünschenswerter Änderungen, etwa Hash-Maps, Module höherer Ordnung, und so weiter. Wenn ich alles noch einmal machen müsste, würde ich wohl wesentlich mehr darauf achten, wie alles zusammenpasst, etwa wie wir große Projekte mit sehr viel Code handhaben, also wie wir Codeversionen verwalten, Dinge finden und wie sich alles Mögliche entwickelt. Wenn sehr viel Code geschrieben wurde, ändert sich die Aufgabe des Programmierers weg vom Schreiben neuen Codes hin zum Finden und Integrieren existierenden Codes. Das Auffinden und Zusammenfügen von Sachen wird also immer wichtiger. Es wäre nett, Ideen von beispielsweise GIT und Mercurial und Typsystemen in die Sprache selbst integrieren zu können, so dass wir auf kontrollierte Weise verstehen könnten, wie sich Code entwickelt. Bruce: An welchem Ort hat Sie der produktive Einsatz von Erlang am meisten überrascht? Dr. Armstrong: Nun, genaugenommen war ich nicht besonders überrascht, da ich wusste, dass es passieren würde: Als ich meine UbuntuVersion auf Karmic Koala aktualisierte, fand ich ein ziemlich gut verstecktes Erlang im Hintergrund werkeln. Es diente der Unterstützung von CouchDB, das ebenfalls auf meiner Maschine lief. Auf diese Weise konnte sich Erlang auf mehr als 10 Millionen Rechnern einschleichen. In diesem Kapitel wollen wir einige Erlang-Grundlagen behandeln. Danach wollen wir Erlang als funktionale Sprache auf Herz und Nieren prüfen. Abschließend wollen wir ein wenig Zeit mit Nebenläufigkeit und einigen coolen, der Zuverlässigkeit dienenden Features verbringen. Ja, Freunde, Zuverlässigkeit kann cool sein.
196 Kapitel 6: Erlang
6.2
Tag 1: Menschlich erscheinen Agent Smith ist ein Programm, das andere Programme oder simulierte Menschen tötet, die die als Matrix bekannte simulierte Realität stören. Die wesentliche Eigenschaft, die ihn so gefährlich macht, ist seine Fähigkeit, menschlich zu erscheinen. In diesem Abschnitt sehen wir uns Erlangs Fähigkeit an, Allzweckanwendungen zu entwickeln. Ich werde mein Bestes tun, um „Normalität“ zu vermitteln, aber das wird nicht leicht. Wenn Sie als objektorientierter Entwickler mit diesem Buch begonnen haben, werden Sie vielleicht ein wenig zu kämpfen haben, doch Sie sollten sich nicht wehren. Sie haben Codeblöcke bereits bei Ruby kennengelernt, Aktoren in Io, Mustererkennung bei Prolog und verteiltes Message-Passing bei Scala. Das sind bei Erlang die grundlegenden Prinzipien. Dieses Kapitel beginnt mit einem weiteren wichtigen Konzept. Erlang ist die erste unserer funktionalen Sprachen. (Scala ist ein Hybrid aus funktionaler und objektorientierter Sprache.) Für Sie bedeutet das Folgendes: 앫
Ihre Programme bestehen vollständig aus Funktionen. Es gibt keine Objekte.
앫
Diese Funktionen geben üblicherweise die gleichen Werte zurück, wenn sie mit den gleichen Eingabewerten aufgerufen werden.
앫
Diese Funktionen haben üblicherweise keine Nebenwirkungen, sie verändern also den Zustand des Programms nicht.
앫
Sie können einer Variablen nur einmal etwas zuweisen.
Mit der ersten Regel leben zu müssen, ist eine mittelgroße Herausforderung. Die nächsten drei können einen aber umhauen, zumindest für eine Weile. Sie sollten wissen, dass man lernen kann, auf diese Weise zu programmieren, und dass das Ergebnis Programme sind, die bis durch und durch für Nebenläufigkeit geeignet sind. Wenn man veränderliche Variablen aus der Gleichung entfernt, wird Nebenläufigkeit deutlich einfacher. Wenn Sie genau aufgepasst haben, wird ihnen das Wort üblicherweise bei der zweiten und dritten Regel aufgefallen sein. Erlang ist keine rein funktionale Sprache. Es erlaubt einige Ausnahmen. Haskell ist in diesem Buch die einzige rein funktionale Sprache. Doch Sie werden einen intensiven Vorgeschmack auf die funktionale Programmierung bekommen und hauptsächlich nach deren Regeln kodieren.
Tag 1: Menschlich erscheinen 197
Erste Schritte Ich habe mit der Erlang-Version R13B02 gearbeitet, aber der grundlegende Stoff in diesem Kapitel sollte mit jeder akzeptablen Version laufen. Sie gelangen in die Erlang-Shell, indem Sie erl (bei einigen Windows-System werl) auf der Kommandozeile eingeben: batate$ erl Erlang (BEAM) emulator version 5.4.13 [source] Eshell V5.4.13 (abort with ^G) 1>
Wie in anderen Kapiteln erledigen Sie hier zu Beginn einen Großteil der Arbeit. Wie Java ist auch Erlang eine kompilierte Sprache. Sie kompilieren eine Datei mit c(dateiname). (Der Punkt am Ende ist notwendig.) Sie können die Konsole oder eine Schleife mit Control+C beenden. Los geht’s.
Kommentare, Variablen und Ausdrücke Zuerst wollen wir ein wenig grundlegende Syntax aus dem Weg räumen. Öffnen Sie die Konsole und geben Sie Folgendes ein: 1> % Dies ist ein Kommentar
Das war simpel. Kommentare beginnen mit einem % und erstrecken sich bis zum Zeilenende. Der Erlang-Parser macht aus Kommentaren einzelne Leerzeichen. 1> 2 + 2. 4 2> 2 + 2.0. 4.0 3> "string". "string"
Jede Anweisung endet mit einem Punkt. Einige elementare Typen sind Strings, Integer und Floats. Nun folgt eine Liste: 4> [1, 2, 3]. [1,2,3]
Wie bei der Prolog-Sprachfamilie stehen Listen zwischen eckigen Klammern. Hier eine kleine Überraschung: 4> [72, 97, 32, 72, 97, 32, 72, 97]. "Ha Ha Ha"
198 Kapitel 6: Erlang Ein String ist also eigentlich eine Liste, und Agent Smith hat Sie gerade ausgelacht. Oh, welch soziale Kompetenz. 2 + 2.0 zeigt uns, das Erlang einige grundlegende Typumwandlungen vornimmt. Wir wollen versuchen, mit einem falschen Typ einen Fehler zu erzwingen: 5> 4 + "string". ** exception error: bad argument in an arithmetic expression in operator +/2 called as 4 + "string"
Im Gegensatz zu Scala gibt es keine Umwandlung zwischen String- und Integer-Werten. Nun wollen wir einer Variablen etwas zuweisen: 6> variable = 4. ** exception error: no match of right hand side value 4
Ah. Hier sehen Sie die hässliche Seite des Vergleichs zwischen Agenten und Erlang. Manchmal hat diese nervige Sprache mehr Hirn als Seele. Diese Fehlermeldung ist eigentlich ein Verweis auf Erlangs Mustererkennung. Der Fehler tritt auf, weil variable ein Atom ist. Variablen müssen mit einem Großbuchstaben beginnen. 7> Var = 1. 1 8> Var = 2. =ERROR REPORT==== 8-Jan-2010::11:47:46 === Error in process <0.39.0> with exit value: {{badmatch,2},[{erl_eval,expr,3}]} ** exited: {{badmatch,2},[{erl_eval,expr,3}]} ** 8> Var. 1
Wie Sie sehen, beginnen Variablen mit einem Großbuchstaben und sind unveränderlich. Sie können einen Wert nur einmal zuweisen. Dieses Konzept stellt Neulinge funktionaler Sprachen vor Probleme. Sehen wir uns etwas komplexere Datentypen an.
Atome, Listen und Tupel Bei funktionalen Sprachen gewinnen Symbole an Bedeutung. Sie bilden das primitivste Datenelement und können alles repräsentieren, dem Sie einen Namen geben wollen. Sie sind Symbolen bei allen anderen Programmiersprachen in diesem Buch begegnet. Bei Erlang wird ein Symbol als Atom bezeichnet und beginnt mit einem Kleinbuchstaben. Es handelt sich um atomische Werte, mit denen Sie etwas repräsentieren können. Sie verwenden sie wie folgt:
Tag 1: Menschlich erscheinen 199 9> red. red 10> Pill = blue. blue 11> Pill. blue
red und blue sind Atome, beliebige Namen, die Sie verwenden können, um Sachen aus der realen Welt zu smybolisieren. Zuerst geben wir ein einfaches Atom namens red zurück. Als Nächstes weisen wir das Atom blue der Variablen namens Pill zu. Atome werden interessanter, sobald man sie mit den robusteren Datenstrukturen verknüpft, die Sie gleich sehen werden. Im Moment wollen wir aber mit den Primitiven weitermachen und uns die Liste ansehen. Listen werden zwischen eckigen Klammern angegeben: 13> [1, 2, 3]. [1,2,3] 14> [1, 2, "three"]. [1,2,"three"] 15> List = [1, 2, 3]. [1,2,3]
Die Listensyntax ist uns vertraut. Listen sind heterogen und können beliebig lang sein. Sie können sie Variablen genau wie Primitive zuweisen. Tupel sind heterogene Listen fester Länge: 18> {one, two, three}. {one,two,three} 19> Origin = {0, 0}. {0,0}
Hier gibt es keine Überraschungen. Man erkennt den starken Einfluss von Prolog. Später, wenn wir die Mustererkennung behandeln, werden Sie bemerken, dass beim Matching eines Tupels die Länge eine Rolle spielt. Sie können ein Dreiertupel nicht mit einem Zweiertupel vergleichen. Beim Matching einer Liste kann die Länge variieren, genau wie bei Prolog. Bei Ruby verwenden Sie Hashmaps, um Namen mit Werten zu verknüpfen. Bei Erlang werden Sie häufig Tupel sehen, die wie Maps oder Hashes verwendet werden: 20> {name, "Spaceman Spiff"}. {name,"Spaceman Spiff"} 21> {comic_strip, {name, "Calvin and Hobbes"}, {character, "Spaceman Spiff"}}. {comic_strip,{name,"Calvin and Hobbes"}, {character,"Spaceman Spiff"}}
200 Kapitel 6: Erlang Wir stellen ein Hash für einen Comicstrip dar. Wir verwenden Atome als Hash-Schlüssel und Strings für die Werte. Sie können auch Listen und Tupel mischen, etwa eine Liste von Comics, die durch Tupel dargestellt werden. Wie greift man nun auf die einzelnen Elemente zu? Wenn Ihnen Prolog noch in frischer Erinnerung ist, dann denken Sie bereits in die richtige Richtung: Sie nutzen die Mustererkennung.
Mustererkennung Wenn Sie das Prolog-Kapitel durchgearbeitet haben, beherrschen Sie die Grundlagen der Mustererkennung ganz gut. Ich möchte aber einen wichtigen Unterschied hervorheben: Wenn Sie eine Regel in Prolog definieren, werden alle Werte in der Datenbank verglichen und Prolog arbeitet sich durch alle Kombinationen. Erlang arbeitet wie Scala: Das Matching erfolgt über einen einzelnen Wert. Wir wollen die Mustererkennung nutzen, um die Werte aus einem Tupel zu extrahieren. Nehmen wir an, wir haben es mit einer Person zu tun: 24> Person = {person, {name, "Agent Smith"}, {profession, "Killing programs"}}. {person,{name,"Agent Smith"}, {profession,"Killing programs"}}
Nehmen wir weiter an, dass wir name zu Name und profession zu Profession zuweisen wollen. Dieses Matching sorgt dafür: 25> {person, {name, Name}, {profession, Profession}} = Person. {person,{name,"Agent Smith"}, {profession,"Killing programs"}} 26> Name. "Agent Smith" 27> Profession. "Killing programs"
Erlang vergleicht die Datenstrukturen und weist den Werten in den Tupeln Variablen zu. Ein Atom steht für sich selbst, es muss also nur noch die Variable Name mit "Agent Smith" und die Variable Profession mit "Killing programs" verglichen werden. Das funktioniert genau wie bei Prolog und wird das grundlegende Konstrukt sein, um Entscheidungen zu treffen. Wenn Sie an Hashes bei Ruby oder Java gewöhnt sind, wird ihnen das Ausgangsatom person seltsam vorkommen. Bei Erlang gibt es häufig mehrere Matching-Anweisungen und mehrere Arten von Tupeln. Indem Sie Ihre Datenstrukturen auf diese Weise aufbauen, können Sie schnell alle person-Tupel bestimmen und die anderen ignorieren.
Tag 1: Menschlich erscheinen 201 Die Listen-Mustererkennung ähnelt der von Prolog: 28> [Head | Tail] = [1, 2, 3]. [1,2,3] 29> Head. 1 30> Tail. [2,3]
So einfach wie „eins, zwei, drei“. Sie können auch mehr als eine Variable an den Kopf der Liste binden: 32> [Eins, Zwei|Rest] = [1, 2, 3]. [1,2,3] 33> One. 1 34> Two. 2 35> Rest. [3]
Gibt es nicht genügend Elemente in der Liste, wird das Muster nicht erkannt: 36> [X|Rest] = []. ** exception error: no match of right hand side value []
Nun, einige der anderen Fehlermeldungen ergeben etwas mehr Sinn. Nehmen wir an, Sie haben vergessen, eine Variable mit einem Großbuchstaben beginnen zu lassen. Sie erhalten die folgende Fehlermeldung: 31> one = 1. ** exception error: no match of right hand side value 1
Wie gerade gesehen, ist die =-Anweisung nicht einfach eine Zuweisung. Tatsächlich handelt es sich um eine Mustererkennung. Sie verlangen von Erlang, den Integer-Wert 1 mit dem Atom eins zu vergleichen, und das kann es nicht.
Bit-Matching Manchmal müssen Sie auf Bit-Ebene auf Daten zugreifen. Wenn Sie mehr Daten auf weniger Raum unterbringen oder mit vordefinierten Formaten wie JPEG oder MPEG arbeiten, ist die Lage jedes einzelnen Bits von Bedeutung. Erlang erlaubt Ihnen, mehrere Datensegmente einfach in ein Byte zu packen. Um so etwas tun zu können, benötigen Sie zwei Operationen: pack (zum Packen) und unpack (zum Entpacken). Bei Erlang funktionieren Bitmaps wie alle anderen Arten von Collections. Um eine Datenstruktur zu packen, müssen Sie Erlang mitteilen, aus wie vielen Bits jedes Element besteht:
202 Kapitel 6: Erlang 1> W = 1. 1 2> X = 2. 2 3> 3> Y = 3. 3 4> Z = 4. 4 5> All = <<W:3, X:3, Y:5, Z:5>>. <<"(d">>
<< und >> packen bei diesem Konstruktor binäre Muster zusammen. In
diesem Fall steht es für 3 Bits für die Variablen W und X und 5 Bits für Y und Z. Als Nächstes müssen wir das auch wieder entpacken können. Wahrscheinlich können Sie sich die Syntax denken: 6> <> = All. <<"(d">> 7> A 7> . 1 8> D. 4
Wir verwenden einfach die gleiche Syntax wie bei Tupeln und Listen und überlassen der Mustererkennung den Rest. Mit diesen bitorientierten Operationen ist Erlang bei Low-Level-Aufgaben überraschend leistungsfähig. Wir haben viele Grundlagen recht schnell abgehandelt, weil Ihnen alle wichtigen Konzepte dieses Kapitels bereits bekannt sein sollten. Ob Sie es glauben oder nicht, wir sind mit dem ersten Tag des Erlang-Kapitels fast durch. Doch vorher müssen wir noch das wichtigste Konzept einführen: die Funktion.
Funktionen Im Gegensatz zu Scala ist Erlang dynamisch typisiert. Sie müssen sich keine allzu großen Gedanken um die Zuweisung von Typen an Datenelemente machen. Wie Ruby ist auch Erlang dynamisch typisiert. Erlang bindet die Typen zur Laufzeit auf der Grundlage syntaktischer Hinweise wie Anführungszeichen oder Dezimalpunkten. An dieser Stelle wollen wir die Konsole starten und einige Begriffe einführen. Wir werden Funktionen in einer Datei mit der Endung .erl schreiben. Die Datei enthält Code für ein Modul, das wir kompilieren müssen, um es ausführen zu können. Bei der Kompilierung der Datei wird ein .beamExecutable erzeugt. Das kompilierte .beam-Modul wird in einer virtuellen Maschine namens beam ausgeführt.
Tag 1: Menschlich erscheinen 203 Nachdem wir unsere Hausaufgaben erledigt haben, wird es Zeit, ein paar einfache Funktionen aufzubauen. Ich trage Folgendes in eine Datei ein: erlang/basic.erl
-module(basic). -export([mirror/1]). mirror(Anything) -> Anything.
Die erste Zeile definiert den Namen des Moduls. Die zweite Zeile definiert eine Funktion, die Sie außerhalb des Moduls nutzen wollen. Die Funktion heisst mirror, und die /1 bedeutet, dass sie einen Parameter besitzt. Zum Schluss kommt die Funktion selbst. Sie erkennen den Einfluss von Prologs Regeln: Die Funktionsdefinition benennt die Funktion und bestimmt die Argumente. Dahinter sehen Sie das Symbol ->, das einfach das erste Argument zurückgibt. Nachdem ich die Funktionsdefinition abgeschlossen habe, starte ich die Konsole aus dem Verzeichnis, in dem diese Datei liegt. Ich kann sie dann wie folgt kompilieren: 4> c(basic). {ok,basic}
Die Datei basic.erl wurde kompiliert, und Sie finden die Datei basic.beam im selben Verzeichnis. Ausgeführt wird sie so: 5> mirror(smiling_mug). ** exception error: undefined shell command mirror/1 6> basic:mirror(smiling_mug). smiling_mug 6> basic:mirror(1). 1
Beachten Sie, dass es nicht ausreicht, nur den Funktionsnamen zu verwenden. Sie müssen auch den Modulnamen (gefolgt von einem Doppelpunkt) angeben. Die Funktion selbst ist simpel. Beachten Sie: Sie waren in der Lage, Anything an zwei verschiedene Typen zu bilden. Erlang ist dynamisch typsiert, und für mich fühlt sich das gut an. Nach Scalas starker Typisierung kehren wir hier in vertrautere Gefilde zurück. Sehen wir uns eine etwas kompliziertere Funktion an, die mehrere alternative Matches definiert.
204 Kapitel 6: Erlang Die Datei matching_function.erl sieht so aus: erlang/matching_function.erl
-module(matching_function). -export([number/1]). number(one) -> 1; number(two) -> 2; number(three) -> 3.
Sie führen sie wie folgt aus: 8> c(matching_function). {ok,matching_function} 9> matching_function:number(one). 1 10> matching_function:number(two). 2 11> matching_function:number(three). 3 12> matching_function:number(four). ** exception error: no function clause matching matching_function:number(four)
Das ist die erste von mir vorgestellte Funktion, bei der mehrere Matches möglich sind. Jeder mögliche Treffer besteht aus dem Funktionsnamen, dem zu vergleichenden Argument sowie dem auszuführenden Code nach dem Symbol ->. In allen Fällen gibt Erlang einfach einen Integer-Wert zurück. Schließen Sie die letzte Anweisung mit . und alle anderen mit ; ab. Genau wie bei Io, Scala und Prolog spielt die Rekursion eine wichtige Rolle. Wie Prolog ist auch Erlang für Endrekursion optimiert. Hier die obligatorische Fakultätsberechnung: erlang/yet_again.erl
-module(yet_again). -export([another_factorial/1]). -export([another_fib/1]). another_factorial(0) -> 1; another_factorial(N) -> N * another_factorial(N-1). another_fib(0) -> 1; another_fib(1) -> 1; another_fib(N) -> another_fib(N-1) + another_fib(N-2).
Wir haben hier also eine weitere Fakultätsberechnung, und wie alle anderen ist sie rekursiv definiert. Wo ich gerade dabei bin, kann ich auch eine Fibonacci-Folge berechnen.
Tag 1: Menschlich erscheinen 205 Doch diesmal scheint es das wert zu sein: 18> c(yet_again). {ok,yet_again} 19> yet_again:another_factorial(3). 6 20> yet_again:another_factorial(20). 2432902008176640000 21> yet_again:another_factorial(200). 788657867364790503552363213932185062295135977687173263294742533244359 449963403342920304284011984623904177212138919638830257642790242637105 061926624952829931113462857270763317237396988943922445621451664240254 033291864131227428294853277524242407573903240321257405579568660226031 904170324062351700858796178922222789623703897374720000000000000000000 000000000000000000000000000000 22> yet_again:another_factorial(2000). 3316275092450633241175393380576324038281117208105780394571935437060380 7790560082240027323085973259225540235294122583410925808481741529379613 1386633526343688905634058556163940605117252571870647856393544045405243 9574670376741087229704346841583437524315808775336451274879954368592474 ... and on and on... 0000000000000000000000000000000000000000000000000000000000000000000000
Oooohkaaay. Das sieht dann doch etwas anders aus. Hier sehen Sie die überraschende Seite des Agent Smith/Erlang-Vergleichs. Wenn Sie es nicht selbst ausprobieren, darf ich Ihnen versichern, dass das Ergebnis sofort erscheint. Ich kenne die maximale Integer-Größe nicht, aber ich wage zu behaupten, dass sie groß genug für mich ist. Das ist ein ganz guter Anfang. Wir haben einige einfache Funktionen entwickelt und sie in Aktion gesehen. Ein guter Zeitpunkt, um Tag 1 zusammenzufassen.
Was wir an Tag 1 gelernt haben Erlang ist eine funktionale Sprache. Sie ist stark und dynamisch typisiert. Es gibt nicht viel Syntax, doch was es gibt, ist in keiner Weise so wie bei typischen objektorientierten Sprachen. Wie Prolog hat auch Erlang keine Vorstellung von einem Objekt. Erlang hat eine enge Verbindung zu Prolog. Die Konstrukte zur Mustererkennung und multiple Funktionseinstiegspunkte sollten Ihnen vertraut sein. Einige Probleme werden mit Rekursion auch auf die gleiche Weise behandelt. Die funktionale Sprache kennt keine veränderlichen Zustände oder gar Nebenwirkungen. Die Pflege des Programmzustands ist schwierig, doch Sie werden einige neue Tricks kennenlernen. Sie werden gleich die andere Seite der Medaille zu sehen bekommen. Die Eliminierung von Zuständen und Nebenwirkungen wirkt sich dramatisch auf den Umgang mit Nebenläufigkeit aus.
206 Kapitel 6: Erlang Am ersten Tag haben Sie sowohl in der Konsole als auch mit dem Compiler gearbeitet. Sie haben sich primär auf die Grundlagen konzentriert. Sie haben grundlegende Ausdrücke entwickelt und einfache Funktionen aufgebaut. Wie Prolog erlaubt Erlang Funktionen mit mehreren Einstiegspunkten. Sie haben eine einfache Mustererkennung genutzt. Sie haben auch einfache Tupel und Listen verwendet. Tupel haben die Rolle von Ruby-Hashes übernommen und bilden die Grundlage für Datenstrukturen. Sie haben gelernt, die Mustererkennung auf Listen und Tupel anzuwenden. Diese Ideen erlauben Ihnen, in späteren Kapiteln Verhaltensweisen auf Tupel oder Interprozess-Nachrichten auzuwenden. An Tag 2 werden wir die grundlegenden funktionalen Konzepte erweitern. Wir werden lernen, wie man Code entwickelt, der in einer nebenläufigen Welt funktioniert, auch wenn wir noch nicht in diese Welt vorstoßen. Nehmen Sie sich ein wenig Zeit für das Selbststudium, um das bisher Gelernte praktisch umzusetzen.
Tag 1: Selbststudium Die Erlang-Online-Community wächst rapide. Eine Konferenz in San Francisco nimmt Fahrt auf. Und im Gegensatz zu Io und C sollten Sie Google benutzen können, um zu finden, was Sie brauchen. Finden Sie 앫
die offizielle Site der Sprache Erlang,
앫
die offizielle Dokumentation zu Erlangs Funktionsbibliothek und
앫
die Dokumentation zu Erlangs OTP-Bibliothek.
Machen Sie Folgendes: 앫
Entwickeln Sie eine rekursive Funktion, die die Anzahl der Wörter in einem String zurückgibt.
앫
Entwickeln Sie eine Funktion, die Rekursiv bis zehn zählt.
앫
Entwickeln Sie eine Funktion, die per Matching selektiv „erfolg“ oder „Fehler: Nachricht“ ausgibt, wenn sie eine Eingabe der Form {fehler, Nachricht} oder erfolg erhält.
Tag 2: Die Form ändern 207
6.3
Tag 2: Die Form ändern In diesem Abschnitt werden wir die Macht von Agent Smith zu schätzen lernen. Die Agenten in „Matrix“ haben übermenschliche Kräfte. Sie können Kugeln ausweichen und durch Wände gehen. Funktionale Sprachen bilden eine höhere Abstraktionsebene als objektorientierte Sprachen. Sie sind zwar schwieriger zu verstehen, aber mit ihnen kann man große Ideen mit weniger Code ausdrücken. Agent Smith kann außerdem die Form jeder anderen Person in der Matrix annehmen. Das ist eine wichtige Fähigkeit einer funktionalen Sprache. Sie werden lernen, Funktionen auf Listen anzuwenden, die diese Listen schnell zu dem formen, was Sie benötigen. Sie wollen aus einer Einkaufsliste eine Preisliste machen? Wie wäre es, eine Liste von URLs in Tupel umzuwandeln, die die Inhalte und URLs enthalten? Das sind die Probleme, die funktionale Sprachen gierig aufsaugen.
Kontrollstrukturen Wir beginnen mit einem profanen Stück Erlang: grundlegenden Kontrollstukturen. Sie werden bemerken, dass dieser Abschnitt wesentlich kürzer ist als bei Scala. Häufig werden Sie Programme mit sehr vielen case-Anweisungen sehen, da diese bei der Entwicklung nebenläufiger Anwendungen interpretieren, welche Nachricht verarbeitet werden soll. ifs sind weniger verbreitet.
Case Wir wollen mit case beginnen. An die Mustererkennung denken Sie meistens im Kontext eines Funktionsaufrufs. Stellen Sie sich diese Kontrollstruktur als Mustererkennung vor, die Sie überall einsetzen können. Nehmen wir beispielsweise an, Sie verwenden eine Variable namens Tier. Sie möchten auf dem Wert dieser Variablen basierend Code ausführen: 1> Animal = "dog". 2> case Animal of 2> "dog" -> underdog; 2> "cat" -> thundercat 2> end. underdog
In diesem Beispiel passte der String bei der ersten Klausel, und es wurde das Atom underdog zurückgegeben. Wie bei Prolog können Sie
208 Kapitel 6: Erlang den Unterstrich (_) als Platzhalter verwenden (Anmerkung: Animal ist immer noch "dog"): 3> case Animal of 3> "elephant" -> dumbo; 3> _ -> something_else 3> end. something_else
Das Tier war kein "elephant", also trifft die letzte Klausel zu. Sie können Unterstriche auch bei allen anderen Erlang-Matches verwenden. Ich möchte hier einen syntaktischen Fallstrick hervorheben: Beachten Sie, dass alle case-Klauseln außer der letzten mit einem Semikolon enden. Wenn Sie also die Anweisungen bearbeiten und Klauseln neu anordnen, müssen Sie auch die Syntax entsprechend anpassen, auch wenn es ein Leichtes gewesen wäre, ein optionales Semikolon nach der letzten Klausel zu erlauben. Sicher, die Syntax ist logisch: Das Semikolon dient als Trennzeichen für die case-Klauseln. Sie ist halt nur nicht sonderlich bequem. Agent Smith hat mir gerade Sand ins Gesicht gestreut und ich glaube, ich habe ihn lachen gehört. Er muss an solchen Dingen noch arbeiten, wenn er Agent des Monats werden will. Machen wir mit dem grundlegenden if weiter.
If Die case-Anweisung verwendet die Mustererkennung, und die ifAnweisung nutzt Guards. Bei Erlang ist ein Guard eine Bedingung, die erfüllt werden muss, damit ein Matching erfolgreich ist. Später werden wir noch Guards für die Mustererkennung einführen, doch die einfachste Form eines Guard findet sich in der if-Anweisung. Sie beginnen mit dem Schlüsselwort if und lassen mehrere ->-Ausdrücke folgen. Hier sehen Sie ein Beispiel: if ProgramsTerminated > 0 -> success; ProgramsTerminated < 0 -> error end.
Was passiert, wenn es keinen Treffer gibt? 8> 0 9> 9> 9> 9> **
X = 0. if X > 0 -> positive; X < 0 -> negative end. exception error: no true branch found when evaluating an if expression
Tag 2: Die Form ändern 209 Im Gegensatz zu Ruby oder Io muss eine der Anweisungen wahr sein, da es sich um eine Funktion handelt. Jeder Fall muss einen Wert zurückgeben. Wenn Sie wirklich ein else wünschen, verwenden Sie als letzten Guard true: 9> if 9> X > 0 -> positive; 9> X < 0 -> negative; 9> true -> zero 9> end.
Und das war es auch wirklich schon mit den Kontrollstrukturen. Aber mit Funktionen höherer Ordnung und der Mustererkennung können Sie Ihre Ziele viel besser erreichen, weshalb wir uns von diesen Kontrollstukturen abwenden und tiefer in die funktionale Programmierung eintauchen wollen. Wir werden mit Funktionen höherer Ordnung arbeiten und sie zur Verarbeitung von Listen nutzen. Sie werden lernen, immer komplexere Probleme mit Funktionen zu lösen.
Anonyme Funktionen Wie Sie wissen, liefern Funktionen höherer Ordnung entweder Funktionen zurück oder erwarten Funktionen als Argumente. Ruby nutzt Codeblöcke für Funktionen höherer Ordnung. Besonderes Augenmerk liegt dabei auf der Übergabe von Codeblöcken bei der Iteration über Listen. Bei Erlang können Sie Variablen beliebige Funktionen zuweisen und diese dann wie jeden anderen Datentyp übergeben. Einige dieser Konzepte kennen Sie bereits, aber wir wollen hier den Grundstein legen und dann einige höher angesiedelte Abstraktionen aufbauen. Es beginnt alles mit anonymen Funktionen. Sie weisen einer Variablen eine Funktion wie folgt zu: 16> Negate = fun(I) -> -I end. #Fun<erl_eval.6.13229925> 17> Negate(1). -1 18> Negate(-1). 1
Zeile 16 verwendet ein neues Schlüsselwort namens fun. Dieses Schlüsselwort definiert eine anonyme Funktion. In unserem Fall erwartet die Funktion ein einzelnes Argument namens I und gibt die Negation -I zurück. Wir weisen Negate diese anonyme Funktion zu. Um es klarzustellen: Negate ist nicht der von der Funktion zurückgegebene Wert. Tatsächlich ist es die Funktion.
210 Kapitel 6: Erlang Zwei wichtige Ideen kommen hier zusammen. Zuerst weisen wir eine Funktion einer Variablen zu. Dieses Konzept ermöglicht uns, Verhaltenweisen wie alle anderen Daten herumzureichen. Zweitens können wir die zugrunde liegende Funktion einfach aufrufen, indem wir eine Argumentenliste angeben. Beachten Sie die dynamische Typisierung. Wir müssen uns nicht selbst um den Rückgabetyp der Funktion kümmern, weshalb uns die invasive Syntax von beispielsweise Scala erspart bleibt. Der Nachteil ist, dass diese Funktionen fehlschlagen können. Ich zeige Ihnen, wie Erlang diese Einschränkungen kompensiert. Lassen Sie uns diese neu gewonnene Macht nutzen. Wir wollen anonyme Funktionen verwenden, um die each-, map- und inject-Konzepte zu nutzen, die Sie ursprünglich bei Ruby kennengelernt haben.
Listen und Funktionen höherer Ordnung Wie Sie gesehen haben, bilden Listen und Tupel das Herz und die Seele der funktionalen Programmierung. Es ist kein Zufall, dass die erste funktionale Sprache mit Listen begann und alles auf dieser Grundlage aufbaute. In diesem Abschnitt beginnen wir damit, Funktionen höherer Ordnung auf Listen anzuwenden.
Funktionen auf Listen anwenden Mittlerweile sollte Ihnen das Konzept verständlicher sein: Funktionen werden uns dabei helfen, Listen zu verwalten. Einige, wie ForEach, iterieren über Listen. Andere, wie filter oder map, geben Listen zurück, die entweder gefiltert oder auf andere Funktionen abgebildet („gemappt“) wurden. Wieder andere wie foldl oder foldr verarbeiten Listen und liefern dabei Ergebnisse (wie Rubys inject oder Scalas FoldLeft). Öffnen Sie die Konsole, definieren Sie ein oder zwei Listen und legen Sie los. Zuerst widmen wir uns der einfachen Iteration. Die Methode lists:foreach erwartet eine Funktion und eine Liste. Die Funktion kann dabei anonym sein: 1> Numbers = [1, 2, 3, 4]. [1,2,3,4] 2> lists:foreach(fun(Number) -> io:format("~p~n", [Number]) end, Numbers). 1 2 3 4 ok
Tag 2: Die Form ändern 211 Die Syntax in Zeile 2 ist nicht ganz einfach, weshalb wir die Sache durchgehen wollen. Wir beginnen mit dem Aufruf einer Funktion namens lists:foreach. Das erste Argument ist die anonyme Funktion fun(Number) -> io:format("~p~n", [Number]) end. Die Funktion verlangt ein Argument und gibt den übergebenen Wert mithilfe der Funktion io:format aus.2 Das zweite an foreach übergebene Argument ist Numbers, also die Liste, die wir in Zeile 1 definiert haben. Wir könnten das vereinfachen, indem wir die Funktion in einer separaten Zeile definieren: 3> Print = fun(X) -> io:format("~p~n", [X]) end.
Nun ist Print an die Funktion io:format gebunden. Wir können den Code vereinfachen: 8> lists:foreach(Print, Numbers). 1 2 3 4 ok
Das war die einfache Iteration. Sehen wir uns eine Map-Funktion an. Die map-Funktion arbeitet wie Rubys collect, d. h. sie übergibt jeden Wert einer Liste an eine Funktion und baut dabei eine Liste von Ergebnissen auf. Wie lists:foreach erwartet lists:map eine Funktion und eine Liste. Wir wollen map mit unserer Liste von Zahl verwenden und jeden Wert um eins erhöhen: 10> lists:map(fun(X) -> X + 1 end, Numbers). [2,3,4,5]
Das war einfach. Diesmal war fun(X) -> X + 1 end unsere anonyme Funktion. Sie erhöht jeden Wert um eins, und lists:map baut eine Liste mit den Ergebnissen auf. Die Definition der Map ist wirklich einfach: map(F, [H|T]) -> [F(H) | map(F, T)]; map(F, []) -> [].
Ganz einfach. Die Abbildung (Map) von F über eine Liste ist F(head) plus map(F, tail). Sie werden eine kompaktere Version sehen, wenn wir uns Listenkomprehensionen ansehen.
2 ~p gibt ein Argument „schön“ aus (Pretty Print), ~n ist der Zeilenvorschub (Newline) und [Zahl] ist die Liste der auszugebenden Argumente.
212 Kapitel 6: Erlang Wir können Listen auch über Boolesche Operationen filtern. Lassen Sie uns eine anonyme Funktion definieren und Small zuweisen: 11> Small = fun(X) -> X < 3 end. #Fun<erl_eval.6.13229925> 12> Small(4). false 13> Small(1). true
Nun benutzen wir diese Funktion, um unsere Liste zu filtern. Die Funktion lists:filter baut eine Liste aller Elemente auf, die Small erfüllen (also kleiner sind als drei): 14> lists:filter(Small, Numbers). [1,2]
Wie Sie sehen, macht es Erlang sehr einfach, auf diese Weise zu kodieren. Alternativ können Sie die Small-Funktion nutzen, um Listen mit all und any zu prüfen. lists:all gibt nur dann „wahr“ zurück, wenn alle Elemente der Liste den Filter erfüllen: 15> lists:all(Small, [0, 1, 2]). true 16> lists:all(Small, [0, 1, 2, 3]). false
Alternativ gibt lists:any „wahr“ zurück, wenn eines der Elemente in der Liste die Filterbedingung erfüllt: 17> lists:any(Small, [0, 1, 2, 3]). true 18> lists:any(Small, [3, 4, 5]). false
Sehen wir uns an, was bei leeren Listen passiert: 19> lists:any(Small, []). false 20> lists:all(Small, []). true
Wie Sie es erwarten, gibt all „wahr“ zurück (alle Elemente der Liste erfüllen also die Filterbedingung, auch wenn es keine Elemente in der Liste gibt), und any gibt „falsch“ zurück (was bedeutet, dass keine Elemente der leeren Liste die Filterbedingung erfüllen). In diesen Fällen spielt der Filter selbst keine Rolle. Sie können auch Listen erzeugen, die aus allen Elementen bestehen, die vom Listenanfang ausgehend den Filter erfüllen. Alternativ können Sie auch alle Elemente vom Anfang einer Liste entfernen, die den Filter erfüllen:
Tag 2: Die Form ändern 213 22> lists:takewhile(Small, [1,2] 23> lists:dropwhile(Small, [3,4] 24> lists:takewhile(Small, [1,2,1] 25> lists:dropwhile(Small, [4,1]
Numbers). Numbers). [1, 2, 1, 4, 1]). [1, 2, 1, 4, 1]).
Solche Tests sind nützlich, um etwa die Header von Nachrichten zu verarbeiten oder zu verwerfen. Wir wollen diesen Abschnitt mit foldl und foldr abschließen.
foldl Mir ist klar, dass Sie diese Konzepte bereits kennen. Wenn Sie Neo sind und diesen Teil der Matrix gemeistert haben, sehen Sie sich das grundlegende Beispiel an und kämpfen weiter. Einige brauchen etwas länger, um foldl zu meistern, weshalb ich es auf verschiedene Weise erläutern möchte. Denken Sie daran, dass diese Funktionen nützlich sind, um die Ergebnisse einer Funktion über eine Liste hinweg aufzurollen. Eines der Argumente dient als Akkumulator und das andere steht für die Listenelemente. lists:foldl erwartet eine Funktion, den Anfangswert des Akkumulators und eine Liste: 28> Numbers. [1,2,3,4] 29> lists:foldl(fun(X, Sum) -> X + Sum end, 0, Numbers). 10
Um die Sache ein wenig zu vereinfachen, legen wir die anonyme Funktion in einer Variablen ab und machen unsere Absichten durch bessere Variablennamen deutlich: 32> Adder = fun(ListItem, SumSoFar) -> ListItem + SumSoFar end. #Fun<erl_eval.12.113037538> 33> InitialSum = 0. 0 34> lists:foldl(Adder, InitialSum, Numbers). 10
Ah, das ist besser. Wir halten also eine fortlaufende Summe fest. Wir übergeben (eine nach der anderen) SumSoFar und jede Zahl aus Numbers an eine Funktion namens Adder. Jedesmal wird die Summe größer, und lists:foldl merkt sich die fortlaufende Summe und gibt sie wieder an Adder zurück. Zum Schluss gibt die Funktion die letzte Summe zurück.
214 Kapitel 6: Erlang Bisher haben Sie nur Funktionen kennengelernt, die mit existierenden Listen arbeiten. Ich habe Ihnen noch nicht gezeigt, wie man Listen aufbaut. Legen wir also einen Zahn zu und wenden uns dem Aufbau von Listen zu.
Fortgeschrittene Listenkonzepte Alle von mir eingeführten Listenkonzepte sind Erweiterungen der Ideen, die Sie schon in anderen Sprachen gesehen haben. Doch es geht auch etwas anspruchsvoller. Wir haben noch nicht über den Aufbau von Listen gesprochen und nur einfache Abstraktionen mit schlichten Codeblöcken verwendet.
Konstruktion von Listen Oberflächlich betrachtet, erscheint es schwierig, Listen ohne veränderliche Zustände aufzubauen. Bei Ruby oder Io würden Sie kontinuierlich Elemente in eine Liste einfügen. Es gibt eine andere Möglichkeit: Sie können eine neue Liste zurückliefern, in die das neue Element eingefügt wurde. Häufig fügen Sie neue Elemente an den Anfang der Liste ein. Wir werden das [H|T]-Konstrukt nutzen, allerdings auf der rechten Seite eines Match. Das folgende Programm nutzt diese Technik der Konstruktion von Listen, um jedes Element einer Liste zu verdoppeln: erlang/double.erl
-module(double). -export([double_all/1]). double_all([]) -> []; double_all([First|Rest]) -> [First + First|double_all(Rest)].
Das Modul exportiert eine Funktion namens double_all. Die Funktion verwendet zwei Klauseln. Die erste besagt, dass double_all für eine leere Liste eine leere Liste zurückgibt. Diese Regel stoppt die Rekursion. Die zweite Regel nutzt das [H|T]-Konstrukt, und zwar sowohl im Prädikat als auch in der Funktionsdefinition. So etwas wie [First|Rest] haben Sie bereits auf der linken Seite eines Match gesehen. Auf diese Weise können Sie eine Liste in ihr erstes Element und den Rest aufteilen. Nutzt man es auf der rechten Seite, wird eine Liste aufgebaut (und nicht zerlegt). In diesem Fall bedeutet [First + First| double_all(Rest)], dass eine Liste erzeugt werden soll, die First + First als erstes Element enthält und double_all(Rest) als Rest.
Tag 2: Die Form ändern 215 Sie können das Programm wie üblich kompilieren und ausführen: 8> c(double). {ok,double} 9> double:double_all([1, 2, 3]). [2,4,6]
Sehen wir uns in der Konsole die Listenkonstruktion mit | an: 14> [1| [2, 3]]. [1,2,3] 15> [[2, 3] | 1]. [[2,3]|1] 16> [[] | [2, 3]]. [[],2,3] 17> [1 | []]. [1]
Hier sollte es keine Überraschungen geben. Das zweite Argument muss eine Liste sein. Was auf der linken Seite steht, wird als erstes Element in die neue Liste eingefügt. Wir wollen uns nun ein fortgeschrittenes Erlang-Konzept ansehen, die sogenannte Listenkomprehension („list comprehension“). Sie vereint einige der Konzepte in sich, über die wir bisher gesprochen haben.
Listenkomprehension Eine der wichtigsten Funktion nahezu jeder funktionalen Sprache ist map. Mit ihrer Hilfe können Listen mutieren, genau wie die Gegner in „Matrix“. Weil dieses Feature so wichtig ist, bietet Erlang eine leistungsfähigere Variante an, die sich sehr kurz fasst und mehrere Transformationen auf einmal erlaubt. Wir starten die Konsole und führen ein Mapping auf althergebrachte Weise durch: 1> Fibs = [1, 1, 2, 3, 5]. [1,1,2,3,5] 2> Double = fun(X) -> X * 2 end. #Fun<erl_eval.6.13229925> 3> lists:map(Double, Fibs). [2,2,4,6,10]
Wir haben eine Liste mit Zahlen namens Fibs und eine anonyme Funktion namens Double, die den ihr übergebenen Wert dupliziert. Dann rufen wir lists:map auf, um Double für jedes Element aufzurufen und eine Liste aus dem Ergebnis aufzubauen. Das ist ein großartiges Werkzeug und wird so häufig verwendet, dass Erlang dafür eine kompaktere Syntax bereitstellt.
216 Kapitel 6: Erlang Dieses Konstrukt wird Listenkomprehension genannt. Hier das Äquivalent zu unserem obigen Beispiel: 4> [Double(X) || X <- Fibs]. [2,2,4,6,10]
Auf gut Deutsch sagen wir, dass wir Double von X für jedes X aus der Liste Fibs berechnen wollen. Wenn Sie wollen, können Sie den Mittelsmann auch ausschalten: 5> [X * 2 || X <- [1, 1, 2, 3, 5]]. [2,2,4,6,10]
Das Konzept ist das gleiche. Wir berechnen X*2 für jedes X aus der Liste [1, 1, 2, 3, 5]. Dieses Feature ist weit mehr als nur syntaktischer Zucker. Lassen Sie uns etwas anspruchsvollere Listenkomprehensionen aufbauen. Wir starten mit einer kompakten Definition von map: map(F, L) -> [ F(X) || X <- L].
Auf gut Deutsch ist die map einer Funktion F über eine Liste L der Menge von F(X) für jedes X, das Element von L ist. Nun wollen wir Listenkomprehension benutzen, um mit einem Katalog zu arbeiten, der Produkte, Mengen und Preise enthält: 7> Cart = [{pencil, 4, 0.25}, {pen, 1, 1.20}, {paper, 2, 0.20}]. [{pencil,4,0.25},{pen,1,1.2},{paper,2,0.2}]
Nehmen wir an, wir müssen eine Steuer berechnen, die für jeden Euro acht Cent beträgt. Wir können eine einfache Listenkomprehension einfügen, die uns einen neuen Katalog mitsamt dieser Steuer berechnet: 8> WithTax = [{Product, Quantity, Price, Price * Quantity * 0.08} || 8> {Product, Quantity, Price} <- Cart]. [{pencil,4,0.25,0.08},{pen,1,1.2,0.096},{paper,2,0.2,0.032}]
Alle früheren Erlang-Konzepte gelten natürlich noch: Sie haben es hier mit einer Mustererkennung zu tun! Wir liefern also eine Liste von Tupeln zurück, die ein Produkt, den Preis, die Menge und die Steuer (Price * Quantity * 0.08) enthalten. Die Ergebnismenge berechnet sich dabei aus jedem Tupel {Product, Quantity, Price} aus der Liste Cart. Dieser Code ist für mich absolut wundervoll. Die Syntax erlaubt es mir, die Form meiner Liste ganz nach Bedarf zu verändern. Als weiteres Beispiel möchte ich den Katalog meinen bevorzugten Kunden mit einem Preisnachlass von 50 Prozent anbieten. Der Katalog soll aussehen wie vorhin, nur die Menge soll ignoriert werden: 10> Cat = [{Product, Price} || {Product, _, Price} <- Cart]. [{pencil,0.25},{pen,1.2},{paper,0.2}]
Tag 2: Die Form ändern 217 Anders ausgedrückt, wollen wir Tupel mit Produkten und Preisen für jedes Tupel aus Produkten und Preisen (das zweite Argument wird ignoriert) aus der Cart-Liste. So kann ich meinen Preisnachlass berechnen: 11> DiscountedCat = [{Product, Price / 2} || {Product, Price} <- Cat]. [{pencil,0.125},{pen,0.6},{paper,0.1}]
Das ist kurz und bündig, lesbar und leistungsfähig. Eine wundervolle Abstraktion. In Wahrheit habe ich Ihnen nur einen Teil der Leistungsfähigkeit der Listenkomprehension gezeigt. Die vollständige Form kann noch leistungsfähiger sein: 앫
Eine Listenkomprehension hat die Form [Ausdruck || Klausel1, Klausel2, ..., KlauselN].
앫
Listenkomprehensionen können eine beliebige Anzahl von Klauseln aufweisen.
앫
Die Klauseln können Generatoren oder Filter sein.
앫
Ein Filter kann ein Boolescher Ausdruck oder eine Funktion sein, die einen Booleschen Wert zurückgibt.
앫
Ein Generator der Form Match <-List vergleicht ein Muster auf der linken mit den Elementen der Liste auf der rechten Seite.
Wirklich gar nicht so schwer. Generatoren fügen ein, Filter löschen. Hier ist ein starker Prolog-Einfluss spürbar. Generatoren definieren die möglichen Werte, und Filter kürzen die Liste entsprechend den angegebenen Bedingungen. Hier ein paar Beispiele: [X || X <- [1, 2, 3, 4], X < 4, X > 1]. [2,3]
Anders ausgedrückt, geben wir X zurück, wobei X aus [1, 2, 3, 4] kommt, wenn X kleiner als 4 und größer als 1 ist. Sie können auch mehrere Generatoren verwenden: 23> [{X, Y} || X <- [1, 2, 3, 4], X < 3, Y <- [5, 6]]. [{1,5},{1,6},{2,5},{2,6}] 24>
Hier erzeugen wir ein Tupel {X, Y}, indem wir die X-Werte aus [1, 2, 3, 4], die kleiner sind als 3, mit den Y-Werten aus [5, 6] verknüpfen. Übrig bleiben zwei X- und zwei Y-Werte, und Erlang berechnet ein kartesisches Produkt.
218 Kapitel 6: Erlang Und das war es auch schon. Sie haben erfahren, wie man mit Erlang sequenziell programmiert. Wir wollen eine Pause einlegen, um alles zusammenzufassen und das Gelernte praktisch anzuwenden.
Was wir an Tag 2 gelernt haben Zugegebenermaßen sind wir nicht detailliert auf Erlang-Ausdrücke oder die Bibliothek eingegangen, doch Sie verfügen nun über genügend Informationen, um funktionale Programme schreiben zu können. Sie haben den Tag mit einigen banalen Kontrollstrukturen begonnen, doch wir haben das Tempo schnell erhöht. Als Nächstes haben wir Funktionen höherer Ordnung behandelt. Wir haben diese Funktionen verwendet, um Listen durchzugehen, zu filtern und zu verändern. Sie haben auch gelernt, wie man mit foldl Ergebnisse aufrollt, genau wie bei Scala. Zum Schluss haben wir uns fortgeschrittenere Listenkonzepte angesehen. Wir haben [H|T] auf der linken Seite eines Match verwendet, um die Liste in das erste Element und den Rest aufzuteilen. Wir haben [H|T] auf der rechten Seite eines Match (oder allein) eingesetzt, um Listen (vom Anfang ausgehend) aufzubauen. Wir haben dann Listenkomprehensionen kennengelernt, eine elegante und leistungsfähige Abstraktion, mit deren Hilfe wir Listen über Generatoren und Filter schnell transformieren können. Die Syntax ist durchwachsen. Höher angesiedelte Konzepte lassen sich dank Erlangs dynamischer Typisierung mit wenig Tippen erledigen. Dennoch gab es einige unbeholfene Momente, insbesondere mit den Semikola bei case- und if-Klauseln. Im nächsten Abschnitt werden wir lernen, was der ganze Wirbel eigentlich soll. Wir wenden uns der Nebenläufigkeit zu.
Tag 2: Selbststudium Machen Sie Folgendes: 앫
Nehmen Sie eine Liste mit Schlüssel/Wert-Tupeln wie etwa [{erlang, "eine funktionale Sprache"}, {ruby, "eine OO-Sprache"}]. Schreiben Sie eine Funktion, die eine Liste oder ein Schlüsselwort nimmt und den mit dem Schlüsselwort verknüpften Wert zurückliefert.
Tag 3: Die rote Pille 219 앫
Sie haben eine Einkaufsliste der Form [{produkt menge preis}, ...]. Schreiben Sie eine Listenkomprehension, die eine Liste aller Produkte der Form [{produkt gesamtpreis}, ...] zurückgibt. Der Gesamtpreis ist dabei die Menge mal dem Preis.
Zusatzaufgabe: 앫
6.4
Entwickeln Sie ein Programm, das ein Tic-Tac-Toe-Feld einliest, das durch eine Liste oder ein Tupel der Größe 9 repräsentiert wird. Geben Sie den Gewinner (x oder o) aus, wenn es einen Sieger gibt, patt, wenn kein Zug mehr möglich ist und kein_sieger, wenn kein Spieler gewonnen hat.
Tag 3: Die rote Pille Die meisten werden es schon gehört haben. In der Matrix kann man die blaue Pille nehmen und ein Leben in seliger Ignoranz führen. Nimmt man die rote Pille, öffnet sich der Blick für die Realität. Manchmal tut die Realität weh. Wir haben eine ganze Industrie, die blaue Pillen in sich reinwirft, als gäbe es kein Morgen. Nebenläufigkeit ist schwer, also reichen wir den Kelch weiter. Wir fügen veränderliche Zustände hinzu, so dass unsere Programme kollidieren, wenn wir sie nebenläufig ausführen. Unsere Funktionen und Methoden haben Nebenwirkungen, weshalb wir ihre Korrektheit nicht prüfen und ihre Ergebnisse nicht vorhersagen können. Wir verwenden aus Performancegründen Threads mit gemeinsam genutzten Zuständen, weshalb wir zusätzliche Arbeit darauf verwenden müssen, jedes Stück Code zu schützen. Das Ergebnis ist Chaos. Nebenläufigkeit tut weh, aber nicht weil sie besonders schwierig wäre, sondern weil wir das falsche Programmiermodell verwenden! Zu Beginn dieses Kapitels habe ich erwähnt, dass Erlang es uns bei einigen einfachen Dingen schwer macht. Ohne Nebenwirkungen und veränderliche Zustände müssen Sie Ihren Kodierungsansatz generell ändern. Sie müssen mit der Prolog-basierten Syntax klarkommen, die vielen völlig fremd ist. Doch nun kommt der Lohn: Die rote Pille, Nebenläufigkeit und Zuverlässigkeit, wird Ihnen wie Zucker vorkommen. Sehen wir uns das an.
220 Kapitel 6: Erlang
Grundlegende Primitive zur Nebenläufigkeit Die drei grundlegenden Primitive zur Nebenläufigkeit sind das Senden einer Nachricht (mit !), das Starten eines Prozesses (mit spawn) und das Empfangen einer Nachricht (mit receive). In diesem Abschnitt zeige ich Ihnen, wie man diese Primitive zum Senden und Empfangen von Nachrichten verwendet und wie man sie in ein einfaches Client/ServerIdiom packt.
Eine einfache Empfangsschleife Wir wollen mit einem Übersetzungsprozess anfangen. Wenn wir dem Prozess einen String auf Spanisch senden, antwortet er mit einer englischen Übersetzung. Generell besteht Ihre Strategie darin, einen Prozess zu starten, der die Nachricht in einer Schleife empfängt und verarbeitet. Die grundlegende Empfangsschleife sieht so aus: erlang/translate.erl
-module(translate). -export([loop/0]). loop() -> receive "casa" -> io:format("house~n"), loop(); "blanca" -> io:format("white~n"), loop(); _ -> io:format("I don't understand.~n"), loop() end.
Das ist länger als unsere bisherigen Beispiele, weshalb wir es einzeln durchgehen wollen. Die ersten beiden Zeilen definieren einfach ein Modul namens Translate und exportieren eine Funktion namens loop. Der nächste Codeblock ist die Funktion namens loop(): loop() -> ... end.
Tag 3: Die rote Pille 221 Beachten Sie, dass der Code innerhalb der Funktion dreimal loop() aufruft, ohne je zurückzukehren. Das ist in Ordnung. Erlang ist für die Endrekursion optimiert, es gibt also nur wenig Overhead, solange die letzte Anweisung jeder receive-Klausel ein loop() ist. Grundsätzlich definieren wir eine leere Funktion mit einer Endlosschleife. Sehen wir uns dem Empfang an: receive -> ...
Diese Funktion empfängt eine Nachricht von einem anderen Prozess. receive funktioniert wie andere Konstrukte zur Mustererkennung, case und die Funktionsdefinitionen. Dem receive folgen mehrere Pattern-Matching-Konstrukte. Sehen wir uns die einzelnen Matches an: "casa" -> io:format("house~n"), loop();
Das ist eine Matching-Klausel. Die Syntax stimmt mit der von caseAnweisungen überein. Wenn die eingehende Nachricht dem String "casa" entspricht, führt Erlang den nachfolgenden Code aus. Einzelne Zeilen werden durch Kommata getrennt und die Klausel wird mit einem Semikolon abgeschlossen. Dieser Code gibt das Wort house aus und ruft dann loop auf. (Denken Sie daran, dass es keinen Overhead auf dem Stack gibt, weil loop die letzte aufgerufene Funktion ist.) Alle anderen Matching-Klauseln sehen genauso aus. Nun besitzen wir ein Modul mit einer receive-Schleife. Es wird Zeit, sie zu nutzen.
Einen Prozess starten Zuerst kompilieren wir das Modul: 1> c(translate). {ok,translate}
Um einen Prozess zu starten, verwenden Sie die Funktion spawn, die wiederum eine Funktion verlangt. Diese Funktion wird in einem neuen, leichtgewichtigen Prozess gestartet. spawn gibt eine Prozess-ID (PID) zurück. Wir übergeben die Funktion aus unserem translate-Modul wie folgt: 2> Pid = spawn(fun translate:loop/0). <0.38.0>
222 Kapitel 6: Erlang Wie Sie sehen, gibt Erlang die Prozess-ID <0.38.0> zurück. In der Konsole sehen Sie die Prozess-ID zwischen spitzen Klammern. Wir werden nur die einfache Version des Prozess-Starts betrachten, doch Sie sollten wissen, dass es auch einige andere gibt. Sie können Prozesse auch über den Namen registrieren, so dass andere Prozesse beispielsweise bestimmte Dienste über den Namen statt über eine Prozess-ID ansprechen können. Wieder eine andere Version von spawn können Sie für Code verwenden, der sich jederzeit „on the fly“ ändern kann (Hot-Swapping). Wenn Sie einen entfernten Prozess starten, können Sie spawn(Node, function) verwenden. Diese Themen sprengen allerdings den Rahmen dieses Buches. Wir haben nun also ein Modul mit einem Codeblock kodiert und als leichtgewichtigen Prozess gestartet. Der letzte Schritt besteht darin, ihm Nachrichten zu senden. Das ist die dritte Erlang-Primitive.
Nachrichten senden Wie bei Scala gesehen, senden Sie verteilte Nachrichten bei Erlang mit dem Operator !. Die Form lautet Pid ! nachricht. Die Pid ist ein beliebiger Prozessbezeichner. Die Nachricht kann einen beliebigen Wert enthalten, einschließlich Primitive, Listen oder Tupel. Lassen Sie uns ein paar Nachrichten senden: 3> Pid ! "house" "casa" 4> Pid ! "white" "blanca" 5> Pid ! "I don't "loco"
"casa".
"blanca".
"loco". understand."
Jede Zeile sendet eine Nachricht. Die io:format-Anweisungen in den receive-Klauseln geben eine Meldung aus, und die Konsole gibt dann den Rückgabewert des Ausdrucks zurück, also die von uns gesendete Nachricht. Wenn Sie eine verteilte Nachricht an eine benannte Ressource senden, verwenden Sie stattdessen die Syntax node@server! nachricht. Das Einrichten eines entfernten Servers würde den Rahmen dieses Buches sprengen, doch mit ein wenig Selbststudium ist ein verteilter Server recht einfach aufzubauen.
Tag 3: Die rote Pille 223 Dieses Beispiel veranschaulicht die grundlegenden Primitive und zeigt, wie man sie verknüpft, um einen einfachen asynchronen Dienst aufzubauen. Sie werden bemerkt haben, dass es einen Rückgabewert gibt. Im nächsten Abschnitt sehen wir uns an, wie man synchrone Nachrichten sendet.
Synchrone Nachrichten Einige nebenläufige Systeme (z. B. Telefongespräche) arbeiten asynchron: Der Sender überträgt eine Nachricht und macht weiter, ohne auf eine Antwort zu warten. Andere (wie das Web) arbeiten synchron: Wir fordern eine Seite an, und der Webserver sendet sie uns, während wir auf die Antwort warten. Wir wollen unseren Übersetzungsdienst, der die Werte einfach nur ausgibt, zu einem Dienst ausbauen, der dem Benutzer den übersetzten String zurückgibt. Um unser Nachrichtenmodell von asynchron auf synchron umzustellen, verwenden wir eine dreiteilige Strategie: 앫
Jede receive-Klausel unseres Messaging-Dienstes muss ein Tupel erkennen, das die ID des Prozesses enthält, der die Übersetzung anfordert, sowie das zu übersetzende Wort. Das Hinzufügen dieser ID erlaubt uns, eine Antwort zu senden.
앫
Jede receive-Klausel muss dem Sender eine Antwort schicken, anstatt das Ergebnis auszugeben.
앫
Anstelle der einfachen Primitive ! schreiben wir eine einfache Funktion, die den Request sendet und auf eine Antwort wartet.
Nachdem Sie den Hintergrund kennen, wollen wir uns die einzelnen Teile der Implementierung ansehen.
Synchron empfangen Die erste Aufgabe besteht darin, unsere receive-Klauseln um zusätzliche Parameter zu erweitern. Das bedeutet, dass wir mit Tupeln arbeiten müssen. Das Pattern-Matching macht das einfach. Jede receiveKlausel sieht wie folgt aus: receive {Pid, "casa"} -> Pid ! "house", loop(); ...
224 Kapitel 6: Erlang Wir erkennen ein beliebiges Element (das immer die Prozess-ID sein muss), gefolgt vom Wort casa. Wir senden dann das Wort house an den Empfänger und fangen wieder von vorne an. Beachten Sie das Pattern-Matching. Das ist die übliche Form für ein receive. Die ID des sendenden Prozesses bildet dabei das erste Ele-
ment des Tupels. Ansonsten besteht der Hauptunterschied darin, dass wir das Ergebnis nicht ausgeben, sondern senden. Das Senden einer Nachricht ist allerdings etwas komplizierter.
Synchrones Senden Auf der anderen Seite der Gleichung müssen wir eine Nachricht senden und dann auf eine Antwort warten. Steht die Prozess-ID in Receiver, sieht das Senden einer synchronen Nachricht etwa so aus: Receiver ! "zu_übersetzende_nachricht", receive Nachricht -> mach_etwas_mit(Nachricht) end
Da wir Nachrichten so oft senden, vereinfachen wir den Dienst, indem wir den Request an den Server kapseln. In unserem Beispiel sieht der einfache entfernte Prozeduraufruf (RPC, remote procedure call) so aus: translate(To, Word) -> To ! {self(), Word}, receive Translation -> Translation end.
Fügt man alles zusammen, erhält man ein nebenläufiges Programm, das kaum komplizierter ist. erlang/translate_service.erl
-module(translate_service). -export([loop/0, translate/2]). loop() -> receive {From, "casa"} -> From ! "house", loop(); {From, "blanca"} -> From ! "white", loop(); {From, _} ->
Tag 3: Die rote Pille 225 From ! "I don't understand.", loop() end. translate(To, Word) -> To ! {self(), Word}, receive Translation -> Translation end.
Wir benutzen es so: 1> c(translate_service). {ok,translate_service} 2> Translator = spawn(fun translate_service:loop/0). <0.38.0> 3> translate_service:translate(Translator, "blanca"). "white" 4> translate_service:translate(Translator, "casa"). "house"
Der Code wird einfach kompiliert, die Schleife wird gestartet und dann wird ein synchroner Dienst von der von uns entwickelten Hilfsfunktion angefordert. Wie Sie sehen können, gibt der Prozess Translator den übersetzten Wert für das Wort zurück. Und nun verfügen wir über synchrone Nachrichten. Nun erkennen Sie die Struktur einer einfachen Empfangsschleife. Jeder Prozess besitzt ein „Postfach“. Das receive-Konstrukt entnimmt Nachrichten aus der Queue und vergleicht sie mit einer auszuführenden Funktion. Prozesse kommunizieren miteinander, indem sie Nachrichten austauschen. Es kommt nicht von ungefähr, dass Dr. Armstrong Erlang als eine wahrhaftig objektorientierte Sprache bezeichnet! Sie erlaubt das Message-Passing und die Kapselung von Verhalten. Es fehlen nur veränderliche Zustände und die Vererbung, auch wenn man Vererbung (und mehr) über Funktionen höherer Ordnung simulieren kann.
Client (console)
Server (roulette)
Abbildung 6.1: Einfaches Client/Server-Design Bisher haben wir unter einfachen, sterilen Bedingungen gearbeitet, ohne die Fähigkeit zur Fehlerbehebung. Erlang bietet eine Ausnahmebehandlung, doch ich möchte mit Ihnen eine andere Möglichkeit durchgehen, Fehler zu verarbeiten.
226 Kapitel 6: Erlang
Prozesse der Zuverlässigkeit halber verknüpfen In diesem Abschnitt sehen wir uns an, wie man Prozesse verknüpft, um eine höhere Zuverlässigkeit zu erreichen. Bei Erlang können Sie zwei Prozesse miteinander verknüpfen. Sobald ein Prozess stirbt, sendet er dem verknüpften Zwilling ein Exit-Signal. Ein Prozess kann dieses Signal empfangen und entsprechend reagieren.
Einen verknüpften Prozess starten Um zu zeigen, wie das Verknüpfen von Prozessen funktioniert, wollen wir zuerst einen Prozess erzeugen, der sich leicht verabschieden kann. Ich habe eine Art Russisches Roulette entwickelt. Es gibt eine „Waffe“ (Gun) mit sechs Kammern. Um eine Kammer abzufeuern, senden Sie eine Zahl von 1 bis 6 an den gun-Prozess. Geben Sie die richtige (oder aus Sicht des Spielers genau die falsche) Zahl ein, beendet sich der Prozess selbst. Hier der Code: erlang/roulette.erl
-module(roulette). -export([loop/0]). % send a number, 1-6 loop() -> receive 3 -> io:format("bang.~n"), exit({roulette,die,at,erlang:time()}); _ -> io:format("click~n"), loop() end.
Die Implementierung ist ganz einfach. Wird innerhalb der Schleife eine 3 erkannt, wird der Code io:format("bang~n"), exit({roulette,die, at,erlang:time()}; ausgeführt und der Prozess beendet. Bei jedem anderen Wert wird nur eine Nachricht ausgegeben, und die Schleife geht wieder von vorne los. Tatsächlich haben wir hier ein einfaches Client/Server-Programm. Der Client ist die Konsole und der Server ist der roulette-Prozess (wie in Abbildung 6.1 zu sehen ist). Wenn wir es ausführen, sieht das wie folgt aus: 1> c(roulette). {ok,roulette} 2> Gun = spawn(fun roulette:loop/0). <0.38.0> 3> Gun ! 1.
Tag 3: Die rote Pille 227 "click" 1 4> Gun ! 3. "bang" 3 5> Gun ! 4. 4 6> Gun ! 1. 1
Das Problem tritt nach der 3 auf: Der gun-Prozess ist tot und die nachfolgenden Nachrichten machen nichts. Wir können aber herausfinden, ob ein Prozess noch lebt: 7> erlang:is_process_alive(Gun). false
Der Prozess ist definitiv tot. Jetzt geht’s ans Eingemachte. Wir können das besser. Lassen Sie uns einen Monitor entwickeln, der uns sagt, ob der Prozess tot ist. Ich denke, das hat eher etwas von einem Leichenbeschauer („coroner“) denn von einem Monitor. Wir sind nur am Tod interessiert. Hier der Code für unseren Leichenbeschauer: erlang/coroner.erl
-module(coroner). -export([loop/0]). loop() -> process_flag(trap_exit, true), receive {monitor, Process} -> link(Process), io:format("Monitoring process.~n"), loop(); {'EXIT', From, Reason} -> io:format("The shooter ~p died with reason ~p.", [From, Reason]), io:format("Start another one.~n"), loop() end.
Wie üblich bauen wir eine receive-Schleife auf. Bevor wir irgendetwas unternehmen können, muss das Programm den Prozess als einen Prozess registrieren, der Exits abfängt. Ohne würden Sie keine EXIT -Nachrichten empfangen. Dann verarbeiten wir ein receive, das zwei Arten von Tupeln verarbeitet: Diejenigen, die mit dem Atom monitor beginnen, und diejenigen, die mit 'EXIT' anfangen. Sehen wir uns beide genauer an.
228 Kapitel 6: Erlang {monitor, Process} -> link(Process), io:format("Monitoring process.~n"), loop();
Dieser Code verknüpft den coroner -Prozess mit jedem Prozess mit der PID von Process. Sie können mit spawn_link auch einen Prozess starten, bei dem diese Verknüpfung bereits vorhanden ist. Stirbt nun der überwachte Prozess, sendet er eine exit-Nachricht an diesen coroner Prozess. Hier wird der Fehler abgefangen: {'EXIT', From, Reason} -> io:format("The shooter died. Start another one.~n"), loop() end.
Das ist der Code, der die Exit-Nachricht erkennt. Verarbeitet wird ein Dreiertupel aus 'EXIT', gefolgt von der PID des sterbenden Prozesses (From) und dem Grund des Fehlers. Wir geben die PID des sterbenden Prozesses und den Grund aus. Hier der gesamte Ablauf: 1> c(roulette). {ok,roulette} 2> c(coroner). {ok,coroner} 3> Revolver=spawn(fun roulette:loop/0). <0.43.0> 4> Coroner=spawn(fun coroner:loop/0). <0.45.0> 5> Coroner ! {monitor, Revolver}. Monitoring process. {monitor,<0.43.0>} 6> Revolver ! 1. click 1 7> Revolver ! 3. bang. 3 The shooter <0.43.0> died with reason {roulette,die,at,{8,48,1}}. Start another one.
Nun haben wir etwas Eleganteres als Client/Server. Wir haben einen Überwachungsprozess eingefügt (siehe Abbildung 6.2), so dass wir feststellen können, wenn ein Prozess stirbt.
Tag 3: Die rote Pille 229
Monitor (coroner)
Client (console)
Server (roulette)
Abbildung 6.2: Monitoring einfügen
Vom Leichenbeschauer zum Doktor Das können wir noch besser. Wenn wir gun registrieren, müssen Spieler die PID nicht mehr kennen. Dann können wir die Erzeugung von gun dem coroner übertragen. Zum Schluss kann der coroner den Prozess wieder neu starten, sobald er stirbt. Wir erreichen eine wesentlich höhere Zuverlässigkeit, ohne uns großartig um die Fehlerbehandlung kümmern zu müssen. An diesem Punkt ist der Coroner nicht mehr nur Leichenbeschauer, sondern auch Doktor, der Tote wieder zum Leben erwecken kann. Hier ist unser neuer Doktor: erlang/doctor.erl
-module(doctor). -export([loop/0]). loop() -> process_flag(trap_exit, true), receive new -> io:format("Creating and monitoring process.~n"), register(revolver, spawn_link(fun roulette:loop/0)), loop(); {'EXIT', From, Reason} -> io:format("The shooter ~p died with reason ~p.", [From, Reason]), io:format(" Restarting. ~n"), self() ! new, loop() end.
230 Kapitel 6: Erlang Der receive-Block erkennt nun zwei Nachrichten: new und wieder das Beide unterscheiden sich ein wenig vom alten coroner. Hier die magische Codezeile im neuen Block: 'EXIT'-Tupel.
register(revolver, spawn_link(fun roulette:loop/0)),
Wir starten also einen Prozess mit spawn_link. Diese Version von spawn verknüpft die Prozesse mit doctor, so dass dieser immer informiert wird, sobald der roulette-Prozess stirbt. Dazu registrieren wir die PID, die wir mit dem revolver -Atom verknüpfen. Nun können Nutzer Nachrichten über die revolver !-Nachricht an diesen Prozess senden. Wir benötigen die PID nicht mehr. Der EXIT-Block ist ebenfalls etwas cleverer. Hier die neue Codezeile: self() ! new,
Wir senden uns selbst eine Nachricht, die einen neuen gun-Prozess startet und registriert. Das Spiel ist nun auch wesentlich leichter zu spielen: 2> c(doctor). {ok,doctor} 3> Doc = spawn(fun doctor:loop/0). <0.43.0> 4> revolver ! 1. ** exception error: bad argument in operator !/2 called as revolver ! 1
Wie erwartet, erhalten wir eine Fehlermeldung, da wir den Prozess noch nicht registriert haben. Nun wollen wir einen erzeugen und registrieren: 5> Doc ! new. Creating and monitoring process. new 6> revolver ! 1. click 1 7> revolver ! 3. bang. 3 The shooter <0.47.0> died with reason {roulette,die,at,{8,53,40}}. Restarting. Creating and monitoring process. 8> revolver ! 4. click 4
Wir gehen nun den ethisch eher fragwürdigen Weg, die Waffe vom Doktor erzeugen zu lassen. Wir interagieren mit der Waffe, indem wir ihr Nachrichten über das revolver -Atom senden, nicht mehr über die Waf-
Tag 3: Die rote Pille 231 fen-PID. Wir können auch erkennen, dass tatsächlich ein neuer revolver erzeugt und registriert wird. Die gesamte Topologie entspricht generell der aus Abbildung 6.2 auf Seite 229, wobei der Doktor eine aktivere Rolle einnimmt als der Leichenbeschauer. Wir haben nur an der Oberfläche gekratzt, aber ich hoffe, dass Sie erkennen konnten, wie leicht Erlang es einem macht, wesentlich robustere nebenläufige Anwendungen zu entwickeln. Über Fehlerbehandlung haben wir nicht viel gesprochen. Wenn etwas abstürzt, starten Sie es einfach neu. Es ist relativ einfach, Monitore zu entwickeln, die sich gegenseitig überwachen. Tatsächlich enthält die Basisbibliothek viele Tools zum Aufbau von Monitoring-Diensten und Lebenserhaltungssystemen, die Erlang bei jedwedem Fehler automatisch neu starten.
Was wir an Tag 3 gelernt haben Am dritten Tag haben Sie ein Gefühl dafür entwickelt, was man mit Erlang machen kann. Wir haben mit den Primitiven für die Nebenläufigkeit begonnen: send, receive und spawn. Wir haben die natürliche asynchrone Version eines Übersetzers entwickelt, um deutlich zu machen, wie das grundlegende Message-Passing funktioniert. Wir haben dann eine einfache Hilfsfunktion aufgebaut, die das Senden und Empfangen kapselt. Auf diese Weise haben wir einen entfernten Funktionsaufruf mit einemsend und receive simuliert. Als Nächstes haben wir Prozesse miteinander verknüpft, um zu zeigen, wie ein Prozess einen anderen über sein Sterben informiert. Wir haben auch gelernt, wie man der besseren Zuverlässigkeit halber einen Prozess von einem anderen überwachen lässt. Unser System war nicht fehlertolerant, auch wenn man die von uns verwendeten Konzepte anwenden kann, um fehlertolerante Systeme zu entwickeln. Erlangs verteilte Kommunikation funktioniert genau wie die Interprozess-Kommunikation: Wir haben zwei Prozesse auf verschiedenen Computern miteinander verknüpft, so dass ein Standby-System den Master überwachen und im Notfall die Kontrolle übernehmen kann. Nun wollen wir einen Teil des Gelernten anwenden.
Tag 3: Selbststudium Die Übungen sind relativ leicht, aber ich habe einige Zusatzaufgaben dazugepackt, damit Sie sich etwas strecken müssen.
232 Kapitel 6: Erlang Open Telecom Platform (OTP) ist ein mächtiges Paket, das einen Großteil dessen abdeckt, was man zum Aufbau verteilter, nebenläufiger Dienste benötigt. Finden Sie Folgendes: 앫
einen OTP-Dienst, der sich neu startet, wenn er stirbt, und
앫
eine Dokumentation zum Bau eines einfachen OTP-Servers.
Machen Sie Folgendes: 앫
Überwachen Sie translate_service und startet Sie es neu, wenn es stirbt.
앫
Lassen Sie den Doktor-Prozess sich selbst neu starten, wenn er stirbt.
앫
Entwickeln Sie einen Monitor für den Doktor-Monitor. Stirbt einer der Monitore, starten Sie ihn neu.
Die folgenden Zusatzaufgaben verlangen etwas zusätzliche Recherche:
6.5
앫
Entwickeln Sie einen einfachen OTP-Server, der Nachrichten in einer Datei protokolliert.
앫
Ändern Sie translate_service so ab, dass es im Netzwerk funktioniert.
Erlang zusammengefasst Am Anfang dieses Kapitels habe ich behauptet, dass Erlang Schwieriges leicht macht und Leichtes schwierig. Die Prolog-artige Syntax ist allen fremd, denen die große Familie C-artiger Sprachen vertraut ist, und das funktionale Programmierparadigma hat seine ganz eigenen Herausforderungen. Doch Erlang besitzt einige Kernfähigkeiten, die es für die nächsten Hardwaredesigns, bei denen Nebenläufigkeit immer wichtiger wird, besonders interessant macht. Einige dieser Fähigkeiten sind eher philosophischer Natur. Leichtgewichtige Prozesse ähneln Javas Threadund Prozessmodellen. Die „Lass es abstürzen“-Philosophie vereinfacht den Code deutlich, verlangt aber eine entsprechende Unterstützung auf der Ebene der virtuellen Maschine, die bei anderen Systemen einfach nicht vorhanden ist. Sehen wir uns die Kernvorteile und -nachteile an.
Erlang zusammengefasst 233
Kernstärken Bei Erlang dreht sich alles um Nebenläufigkeit und Fehlertoleranz. Während sich die Prozessordesigns hin zu mehr Kernen entwickeln, muss sich der Stand der Technik auch in der Programmierung weiterentwickeln. Erlangs Stärken decken die wichtigsten Bereiche ab, denen die neue Generation von Programmierern begegnen wird.
Dynamisch und zuverlässig Zuallererst wurde Erlang im Hinblick auf Zuverlässigkeit entwickelt. Die Kernbibliotheken wurden getestet, und Erlang-Anwendungen zählen zu den zuverlässigsten und (hoch-)verfügbarsten der Welt. Besonders beeindruckend ist, dass die Sprachdesigner diese Zuverlässigkeit erreicht haben, ohne die Strategien der dynamischen Typisierung zu opfern, die Erlang so produktiv machen. Statt davon abhängig zu sein, dass der Compiler ein künstliches Sicherheitsnetz schafft, verlässt sich Erlang auf die Fähigkeit, nebenläufige Prozesse auf einfache und zuverlässige Weise miteinander zu verknüpfen. Ich war erstaunt, wie einfach es war, zuverlässige Monitore zu entwickeln, ohne von Betriebssystemtricks abhängig zu sein. Ich finde, dass die Kompromisse, die man bei Erlang findet, sehr verlockend und einzigartig sind. Java und seine virtuelle Maschine bieten nicht den richtigen Satz an Primitiven, um Erlangs Performance oder Zuverlässigkeit kopieren zu können. Die auf BEAM aufbauenden Bibliotheken spiegeln diese Philosophie ebenfalls wider, weshalb es relativ einfach ist, zuverlässige verteilte Systeme zu entwickeln.
Leichtgewichtige, nichts teilende Prozesse Eine andere Stelle, an der Erlang glänzen kann, ist das zugrunde liegende Prozessmodell. Erlang-Prozesse sind leichtgewichtig, weshalb Erlang-Programmierer sie häufig benutzen. Erlang baut auf einer Philosophie auf, die Unveränderlichkeit fördert, weshalb Programmierer Systeme entwickeln, bei denen es sehr viel unwahrscheinlicher ist, dass es zu Fehlern kommt, nur weil man sich gegenseitig behindert. Das Message-Passing-Paradigma und die dazugehörigen Primitive machen es leicht, Anwendungen zu entwickeln, die einen Grad der Trennung erlauben, den man bei objektorientierten Sprachen selten findet.
234 Kapitel 6: Erlang
OTP, die Enterprise-Bibliotheken Da Erlang in einem Telekommunikationsunternehmen entstanden ist, wo die Anforderungen an Verfügbarkeit und Zuverlässigkeit sehr hoch sind, besitzt es über 20 Jahre gewachsene Bibliotheken, die diese Art der Entwicklung unterstützen. Die primäre Bibliothek ist die Open Telecom Platform (OTP). Sie finden Bibliotheken, mit deren Hilfe Sie überwachte, hochverfügbare Prozesse entwickeln können, die mit Datenbanken verlinkt sind, oder verteilte Anwendungen programmieren können.. OTP besitzt einen vollständigen Webserver und viele Tools zur Bindung an Telekommunikationsanwendungen. Das Schöne an diesen Bibliotheken ist, dass Fehlertoleranz, Skalierbarkeit, transaktionelle Integrität und Hotswapping integriert sind. Sie müssen sich darüber keine Gedanken machen. Sie können eigene Serverprozesse entwickeln, die die Vorteile dieser Features nutzen.
Lass es abstürzen Wenn Sie mit parallelen Prozessen und ohne Nebenwirkungen arbeiten, funktioniert „Lass es abstürzen“: Es interessiert Sie nicht besonders, warum einzelne Prozesse abstürzen, wenn Sie sie neu starten können. Die funktionale Programmierung verstärkt Erlangs Verteilungsstrategie. Wie alle anderen Sprachen in diesem Buch ist auch Erlang „unsauber“. Nur die Art der Probleme ändert sich. Das sind die Stellen, an denen Agent Smith nicht immer sauber spielt.
Kernschwächen Erlangs grundlegende Probleme mit seiner Verbreitung haben ihre Wurzeln in Dingen, die bewusst in eine Nischensprache implantiert wurden. Die Syntax ist den meisten Programmierern fremd. Auch das funktionale Programmierparadigma ist so andersartig, dass es der weiten Verbreitung entgegensteht. Schließlich läuft die mit Abstand beste Implementierung auf BEAM, nicht auf der Java Virtual Machine. Graben wir ein wenig tiefer.
Syntax Wie ein Film ist auch Syntax etwas Subjektives. Doch abgesehen davon hat Erlang einige Probleme, die auch einem Unparteiischen auffallen. Sehen wir uns zwei davon an.
Erlang zusammengefasst 235 Interessanterweise haben einige von Erlangs Kernstärken ihre Wurzeln bei Prolog, was ebenso für einige seiner Schwächen gilt. Für die meisten Programmierer ist Prolog undurchsichtig. Die Syntax wird als schwierig und fremdartig empfunden. Ein wenig syntaktischer Zucker könnte die Verbreitung deutlich vorantreiben. In diesem Kapitel habe ich die Probleme mit if- und case-Konstrukten erwähnt. Die syntaktischen Regeln sind logisch („Verwende ein Trennzeichen zwischen Anweisungen!“), aber nicht besonders praktisch, weil man die Reihenfolge von case-, if- und receive-Blöcken nicht ändern kann, ohne auch die Interpunktion zu ändern. Solche Einschränkungen sind unnötig. Und es gibt andere Seltsamkeiten wie etwa die bedingte Präsentation eines Arrays von Zahlen als Strings. Da auszumisten würde Erlang unglaublich helfen.
Integration Wie das Prolog-Erbe ist auch der Verzicht auf die JVM ein zweischneidiges Schwert. In jüngster Zeit hat eine JVM-basierte VM namens Erjang einige Fortschritte gemacht, ist aber noch weit weg von den besten JVM-Alternativen. Die JVM hat so ihre Altlasten, wie etwa das Prozessund Threading-Modell, das für Erlangs Bedürfnisse völlig ungeeignet ist. Doch die JVM zu nutzen, hat auch seine Vorteile, etwa die Fülle an Java-Bibliotheken und Hundertausende potentieller Server, die man nutzen könnte.
Abschließende Gedanken Der Erfolg einer Programmiersprache ist oft eine wackelige Sache. Erlang steht vor einigen ernsthaften Hürden auf der Marketingseite. Und Java-Programmierer hin zu einem Lisp-artigen Programmierparadigma und einer Prolog-artigen Syntax zu locken, wird nicht einfach. Erlang scheint dennoch Fahrt aufzunehmen, weil es die richtigen Probleme auf die richtige Weise zur richtigen Zeit löst. Bei diesem Kampf zwischen Anderson und Agent Smith gebe ich Agent Smith durchaus gute Erfolgschancen.
Do or do not...there is no try. Yoda
Kapitel 7
Clojure Clojure ist Lisp auf der JVM. Lisp ist verwirrend und mächtig. Lisp ist eine der ersten Programmiersprachen und doch auch eine der neuesten. Dutzende von Dialekten haben versucht, Lisp in den Mainstream zu hieven, und sind gescheitert. Die Syntax und das Programmiermodell waren zu viel, um von einem typischen Entwickler aufgenommen werden zu können. Und doch ist an Lisp etwas Besonderes, das einen zweiten Blick lohnenswert macht, so dass neue Dialekte auch weiterhin entstehen. Einige der besten Universitäten setzen auf Lisp, um junge Köpfe zu formen, solange sie noch offen sind. In vielerlei Hinsicht ist Clojure der weise Kung-Fu-Meister, das Orakel vom Berg oder der mysteriöse Jedi-Meister. Denken Sie an Yoda. In „Star Wars Episode V: Das Imperium schlägt zurück“1 wurde Yoda als nette, aber unbedeutende Figur vorgestellt. Sein Kommunikationsstil ist häufig verdreht und schwer zu verstehen, so wie die Präfixnotation von Lisp (verstehen mich später du wirst). Er scheint zu klein zu sein, um etwas bewirken zu können, genau wie die syntaktischen Regeln von Lisp, mit nur wenig mehr als Klammern und Symbolen. Doch es wird schnell deutlich, dass an Yoda mehr dran ist, als man mit bloßem Auge sieht. Wie Lisp ist er alt und weise (wie das obige Zitat zeigt) und hat mit der Zeit seinen Feinschliff erhalten und sich in der Krise bewährt. Er besitzt eine innere Kraft, die andere nicht meistern können, so wie Lisp Makros und Konstrukte höherer Ordnung besitzt. In vielerlei Hinsicht hat alles mit Lisp begonnen. Bevor wir tiefer eintauchen, wollen wir ein wenig über Lisp reden und uns dann ansehen, was an Clojure so besonders ist. 1 Star Wars Episode V: The Empire Strikes Back. Regie: George Lucas. 1980; Beverly Hills, CA: 20th Century Fox, 2004.
238 Kapitel 7: Clojure
7.1
Einführung in Clojure Schließlich und endlich ist Clojure nur ein weiterer Lisp-Dialekt. Es besitzt die gleichen Sprachbeschränkungen und viele der gleichen Stärken. Clojure verstehen zu lernen, beginnt damit, Lisp zu verstehen.
Alles Lisp Nach Fortran ist Lisp die älteste kommerziell aktive Sprache. Es handelt sich um eine funktionale, aber nicht rein funktionale Sprache. Das Akronym steht für LISt Processing, und Sie werden gleich sehen warum. Lisp besitzt einige interessante Eigenschaften: 앫
Lisp ist eine Sprache für Listen. Ein Funktionsaufruf verwendet das erste Element der Liste als Funktion und den Rest als Argumente.
앫
Lisp benutzt seine eigenen Datenstrukturen, um Code auszudrücken. Lisp-Anhänger nennen diese Strategie Daten als Code (code as data).
Wenn Sie diese beiden Ideen miteinander kombinieren, erhalten Sie eine Sprache, die sich hervorragend für Metaprogrammierung eignet. Sie können Ihren Code als benannte Methoden in einer Klasse anordnen. Sie können diese Objekte in einem Baum anordnen und verfügen über ein grundlegendes Objektmodell. Sie können auch eine prototypbasierte Codeorganisation mit Slots für Daten und Verhalten aufbauen. Sie können eine rein funktionale Implementierung nutzen. Es ist diese Flexibilität, die es Lisp erlaubt, nahezu jedes gewünschte Programmierparadigma zu übernehmen. In „Hackers and Painters“ [Gra04] erzählt Paul Graham, wie ein kleines Entwicklerteam Lisp und sein mächtiges Programmiermodell einsetzte, um wesentlich größere Unternehmen zu schlagen. Die Teammitglieder glaubten, dass Lisp einen signifikanten Vorteil bei der Programmierung biete. Sie achteten auf Stellenangebote von Startups, die Lisp und andere höhere Programmiersprachen verlangten. Die primären Lisp-Dialekte sind Common Lisp und Scheme. Scheme und Clojure stammen aus der Familie der „lisp-1“ genannten Lisp-Dialekte. Common Lisp ist ein „lisp-2“-Dialekt. Der Hauptunterschied zwischen den Dialektfamilien liegt in der Funktionsweise von Namensräumen. Common Lisp verwendet separate Namensräume für Funktionen und Variablen, während Scheme für beide denselben Namensraum benutzt. Nachdem wir uns die Lisp-Seite der Gleichung angesehen haben, wollen wir uns nun der Java-Seite zuwenden.
Tag 1: Luke trainieren 239
Auf der JVM Jeder Lisp-Dialekt umsorgt sein Publikum. Eine der wichtigsten Eigenschaften von Clojure ist die Verwendung der JVM. Bei Scala haben Sie gesehen, dass eine kommerziell erfolgreiche Deployment-Plattform den Unterschied ausmachen kann. Sie müssen den Leuten, die das System einsetzen, keinen Clojure-Server verkaufen. Obwohl die Sprache relativ neu ist, können Sie auf Zehntausende von Java-Bibliotheken zugreifen und alles tun, was Sie tun müssen. Während des gesamten Kapitels werden Sie Hinweise auf die JVM finden: die Art des Aufrufs, die verwendeten Bibliotheken und die von uns entwickelten Artefakte. Doch Sie werden auch unabhängig von ihr sein. Clojure ist funktional, Sie werden also fortgeschrittene Konzepte auf Ihren Code anwenden können. Clojure ist dynamisch typisiert. Dadurch wird Ihr Code kompakter und einfacher zu lesen, und das Entwickeln macht mehr Spaß. Und Clojure verfügt über die Ausdrucksfähigkeit von Lisp. Clojure und Java brauchen einander dringend. Lisp braucht den Marktplatz, den die Java Virtual Machine zu bieten hat, und die Java-Community braucht eine ernsthafte Modernisierung und ein wenig Spaß.
Modernisiert für eine parallele Welt Der letzte Teil der Gleichung für diese Sprache ist die Menge der Bibliotheken. Clojure ist eine funktionale Sprache, die sich auf Funktionen ohne Nebenwirkungen konzentriert. Doch wenn Sie mit veränderlichen Zuständen arbeiten, unterstützt die Sprache eine Reihe von Konzepten, um Ihnen dabei zu helfen. Transactional Memory funktioniert wie Transaktionsdatenbanken und bietet einen sicheren, parallelen Zugriff auf den Speicher. Agenten erlauben den gekapselten Zugriff auf veränderliche Ressourcen. Einige dieser Konzepte werden wir an Tag 3 behandeln. Ungeduldig Sie sind? Beginnen mit Clojure wir wollen.
7.2
Tag 1: Luke trainieren Bei „Star Wars“ begann der Schüler Luke bei Yoda eine weiterführende Ausbildung nach Art der Jedi. Er begann seine Ausbildung unter jemand anderem. Wie Luke haben auch Sie bereits mit dem Training für funktionale Sprachen angefangen. Sie haben Closures in Ruby verwendet und sich dann zu Funktionen höherer Ordnung bei Scala und
240 Kapitel 7: Clojure Erlang vorgearbeitet. In diesem Kapitel werden Sie einige dieser Konzepte mit Clojure anwenden. Besuchen Sie die Clojure-Website unter http://www.assembla.com/ wiki/show/clojure/ Getting_Started. Folgen Sie den Anweisungen zur Installation von Clojure auf Ihrer Plattform und der von Ihnen bevorzugten Entwicklungsumgebung. Ich verwende die Prerelease-Version von Clojure 1.2, das definitiv fertig sein sollte, wenn Sie dieses Buch in Händen halten. Möglicherweise müssen Sie zuerst Java installieren, auch wenn heutzutage bei den meisten Betriebssystemen Java bereits vorinstalliert ist. Ich verwende das leiningen-Tool2 zur Verwaltung meiner Clojure-Projekte und der Java-Konfiguration. Dieses Tool ermöglicht mir, Projekte abzuwickeln, und erspart mir Java-Details wie Klassenpfade. Wenn Sie es installiert haben, können Sie ein neues Projekt anlegen: batate$ lein new seven-languages Created new project in: seven-languages batate$ cd seven-languages/ seven-languages batate$
Dann können Sie die Clojure-Konsole repl starten: seven-languages batate$ lein repl Copying 2 files to /Users/batate/lein/seven-languages/lib user=>
... und schon sind Sie bereit. Hinter den Kulissen installiert leiningen einige Abhängigkeiten und ruft Java mit einigen Clojure-Java-Archiven (jars) und Optionen auf. Bei Ihrer Installation müssen Sie repl möglicherweise anders starten. Von nun an werde ich einfach nur davon reden, „repl zu starten“. Nach all der Arbeit verfügen Sie nur über eine primitive Konsole. Wenn Sie Code evaluieren sollen, können Sie repl oder jede IDE und jeden Editor mit Closure-Unterstützung nutzen. Geben wir etwas Code ein: user=> (println "Give me some Clojure!") Give me some Clojure! nil
Okay, die Konsole funktioniert. Bei Closure schließen Sie jeden Funktionsaufruf zwischen runden Klammern ein. Das erste Element ist der Name der Funktion, und die restlichen Elemente sind die Argumente. Auch eine Schachtelung ist möglich. Sehen wir uns das Konzept mit ein wenig Mathematik an. 2
http://github.com/technomancy/leiningen
Tag 1: Luke trainieren 241
Grundlegende Funktionsaufrufe user=> (- 1) -1 user=> (+ 1 1) 2 user=> (* 10 10) 100
Das ist einfache Mathematik. Die Division ist etwas interessanter: user=> (/ 1 3) 1/3 user=> (/ 2 4) 1/2 user=> (/ 2.0 4) 0.5 user=> (class (/ 1 3)) clojure.lang.Ratio
Clojure besitzt einen elementaren Datentyp namens ratio. Das ist ein nettes Feature, das es erlaubt, die Berechnung zu verzögern, um einen Verlust der Genauigkeit zu verhindern. Wenn Sie es vorziehen, können Sie aber ebenso gut mit Fließkommazahlen arbeiten. Der Divisionsrest lässt sich einfach ermitteln: user=> (mod 5 4) 1
Das ist der Modulo-Operator. Diese Notation wird Präfixnotation genannt. Bisher haben die hier vorgestellten Sprachen die Infixnotation verwendet, bei der der Operator zwischen den Operanden steht (z. B. 4 + 1 - 2). Viele Leute ziehen die Infixnotation vor, weil sie daran gewöhnt sind. Es ist uns vertraut, Mathematik auf diese Weise darzustellen. Nach einer kurzen Aufwärmphase werden Sie sich aber an die Präfixnotation gewöhnen. Mathematik ist in dieser Form etwas lästig, aber sie funktioniert. Präfixnotation mit Klammern hat aber auch ihre Vorteile. Nehmen Sie den folgenden Ausdruck: user=> (/ (/ 12 2) (/ 6 2)) 2
Es gibt keine Mehrdeutigkeiten. Clojure evaluiert diese Anweisung den Klammern entsprechend . Und sehen Sie sich diesen Ausdruck an: user=> (+ 2 2 2 2) 8
242 Kapitel 7: Clojure Wenn Sie wollen, können Sie einfach zusätzliche Elemente in die Berechnung aufnehmen. Sie können diesen Stil auch nutzen, wenn Sie mit Subtraktion oder Division arbeiten: user=> (- 8 1 2) 5 user=> (/ 8 2 2) 2
Nach konventioneller (Infix-)Notation haben wir (8 - 1) - 2 und (8 / 2) / 2 evaluiert. Wenn ihnen das Clojure-Gegenstück mit nur jeweils zwei Operanden lieber ist, schreiben Sie (- (- 8 1) 2) und (/ (/ 8 2) 2). Die Evaluierung einfacher Operatoren fördert aber auch überraschend leistungsfähige Ergebnisse zutage: user=> (< 1 2 3) true user=> (< 1 3 2 4) false
Nett. Mit einem einzigen Operator können Sie prüfen, ob eine beliebig lange Argumentliste sortiert ist. Abgesehen von der Präfixnotation und mehreren Parameterlisten ist Clojures Syntax sehr einfach. Lassen Sie uns das Typisierungssystem ein wenig auf die Probe stellen und auf starke Typisierung und Typumwandlungen achten: user=> (+ 3.0 5) 8.0 user=> (+ 3 5.0) 8.0
Clojure wandelt Typen für uns um. Sie werden bemerken, dass Clojure die starke, dynamische Typisierung unterstützt. Steigen wir etwas tiefer ein und sehen uns einen von Clojures Grundbausteinen an, die sogenannten Forms. Stellen Sie sich Forms als ein Stück Syntax vor. Wenn Clojure Code verarbeitet, teilt es das Programm zuerst in Teile auf, die als Forms bezeichnet werden. Dann kompiliert oder interpretiert Clojure den Code. Ich werde nicht zwischen Code und Daten unterscheiden, weil sie in Lisp ein und dasselbe sind. Boolesche Werte, Zeichen, Strings, Sets, Maps und Vektoren sind alles Beispiele für Forms, die Sie in diesem Kapitel kennenlernen werden.
Tag 1: Luke trainieren 243
Strings und Chars Sie kennen Strings bereits, doch wir können noch etwas tiefer gehen. Sie schließen Strings in doppelte Anführungszeichen ein und können Escape-Zeichen im Stil von C benutzen (wie bei Ruby): user=> (println "master yoda\nluke skywalker\ndarth vader") master yoda luke skywalker darth vader nil
Keine Überraschungen. Nebenbei bemerkt, haben wir bisher nur ein einziges Argument mit println verwendet, aber es funktioniert auch mit null oder mehr Argumenten, Sie können also eine Leerzeile ausgeben oder mehrere Werte verketten. Bei Clojure können Sie etwas mit der str -Funktion in einen String umwandeln: user=> (str 1) "1"
Liegt dem Ziel eine Java-Klasse zugrunde, ruft str die zugrunde liegende toString-Funktion auf. Diese Funktion kann auch mehr als ein Argument verarbeiten: user=> (str "yoda, " "luke, " "darth") "yoda, luke, darth"
Clojure-Entwickler benutzen str, um Strings zu verketten. Praktischerweise kann man auch Elemente verketten, bei denen es sich nicht um Strings handelt: user=> (str "eins: " 1 "zwei: " 2) "eins: 1 zwei: 2"
Sie können auch verschiedene Typen miteinander verketten. Um ein Zeichen ohne doppelte Anführungszeichen einzugeben, stellen sie ihm einen Backslash (\) voran: user=> \a \a
Und wie üblich können Sie sie mit str verketten: user=> (str \f \o \r \c \e) "force"
Führen wir einige Vergleiche durch: user=> (= "a" \a) false
244 Kapitel 7: Clojure Zeichen sind also keine Strings der Länge 1. user=> (= (str \a) "a") true
Doch Sie können Zeichen ganz einfach in Strings umwandeln. Das soll es mit der Stringmanipulation gewesen sein. Wenden wir uns ein paar Booleschen Ausdrücken zu.
Boolesche Werte und Ausdrücke Clojure verwendet die starke, dynamische Typisierung. Rufen Sie sich ins Gedächtnis, dass dynamische Typisierung bedeutet, dass die Typen zur Laufzeit evaluiert werden. Sie haben einige dieser Typen bereits in Aktion gesehen, doch wir wollen unsere Betrachtung ein wenig fokussieren. Ein Boolescher Wert ist das Ergebnis eines Ausdrucks: user=> (= 1 1.0) true user=> (= 1 2) false user=> (< 1 2) true
Wie bei den meisten anderen Sprachen in diesem Buch ist true ein Symbol. Doch es ist auch noch etwas anderes: Clojures Typen sind mit dem zugrunde liegenden Java-Typsystem abgeglichen. Sie können die zugrunde liegende Klasse mithilfe der class-Funktion bestimmen. Die Klasse eines Booleschen Werts sieht so aus: user=> (class true) java.lang.Boolean user=> (class (= 1 1)) java.lang.Boolean
Sie sehen hier die JVM durchschimmern. Diese Typstrategie wird Ihnen im weiteren Verlauf die Arbeit sehr erleichtern. Sie können Boolesche Werte in vielen Ausdrücken verwenden. Hier ein einfaches if: user=> (if true (println "True it is.")) True it is. nil user=> (if (> 1 2) (println "True it is.")) nil
Wie bei Io haben wir im zweiten Argument Code an if übergeben. Praktischerweise behandelt Lisp Code wie Daten. Wir können das Ganze netter gestalten, indem wir den Code in mehrere Zeilen aufteilen: user=> (if (< 1 2) (println "False it is not."))
Tag 1: Luke trainieren 245 False it is not. nil
Wir können ein else als drittes Argument angeben: user=> (if false (println "true") (println "false")) false nil
Nun wollen wir sehen, was sonst noch als Boolescher Wert durchgeht. Zuerst versuchen wir es mit nil: user=> (first ()) nil
Ah. Das ist einfach. Das Symbol namens nil. user=> (if 0 (println "true")) true nil user=> (if nil (println "true")) nil user=> (if "" (println "true")) true nil
0 und "" sind wahr, nicht aber nil. Wir werden weitere Boolesche Ausdrücke einführen, sobald wir sie benötigen. Nun wollen wir uns einige komplexere Datenstrukturen ansehen.
Listen, Maps, Sets und Vektoren Wie bei allen funktionalen Sprachen übernehmen Kerndatenstrukturen wie Listen und Tupel die Schwerstarbeit. Bei Clojure sind Listen, Maps und Vektoren drei wichtige Typen. Wir beginnen mit den Collections, mit denen Sie bisher die mit Abstand meiste Zeit verbracht haben.
Listen Eine Liste ist eine geordnete Folge von Elementen. Bei diesen Elementen kann es sich um beliebige Dinge handeln, aber im Clojure-üblichen Sprachgebrauch werden Listen für Code verwendet und Vektoren für Daten. Ich werde mit Ihnen aber Listen von Daten durchgehen, um Verwirrung zu vermeiden. Da Listen als Funktionen evaluiert werden, ist Folgendes nicht möglich: user=> (1 2 3) java.lang.ClassCastException: java.lang.Integer cannot be cast to clojure.lang.IFn (NO_SOURCE_FILE:0)
246 Kapitel 7: Clojure Wenn Sie wirklich eine aus den Elementen 1, 2 und 3 bestehende Liste wünschen, müssen Sie stattdessen Folgendes eingeben: user=> (list 1 2 3) (1 2 3) user=> '(1 2 3) (1 2 3)
Nun können Sie die Listen wie üblich verarbeiten. Die zweite Form im obigen Beispiel wird Quoting genannt. Die vier Hauptoperationen sind first (der Kopf), rest (die Liste ohne den Kopf), last (das letzte Element) und cons (konstruiert aus Kopf und Rest eine neue Liste): user=> (first '(:r2d2 :c3po)) :r2d2 user=> (last '(:r2d2 :c3po)) :c3po user=> (rest '(:r2d2 :c3po)) (:c3po) user=> (cons :battle-droid '(:r2d2 :c3po)) (:battle-droid :r2d2 :c3po)
Natürlich können Sie das mit Funktionen höherer Ordnung kombinieren, aber das machen wir erst, wenn wir Sequenzen behandeln. Nun wollen wir uns einen nahen Verwandten der Liste ansehen, den Vektor.
Vektoren Wie die Liste ist ein Vektor eine geordnete Folge von Elementen. Vektoren sind für den wahlfreien Zugriff optimiert. Vektoren umschließt man mit eckigen Klammern: user=> [:hutt :wookie :ewok] [:hutt :wookie :ewok]
Verwenden Sie Listen für Code und Vektoren für Daten. Die verschiedenen Elemente rufen Sie so ab: user=> :hutt user=> :ewok user=> :hutt user=> :ewok user=> :ewok
(first [:hutt :wookie :ewok]) (nth [:hutt :wookie :ewok] 2) (nth [:hutt :wookie :ewok] 0) (last [:hutt :wookie :ewok]) ([:hutt :wookie :ewok] 2)
Beachten Sie, dass Vektoren auch Funktionen sind, die einen Index als Argument erwarten. Sie können zwei Vektoren wie folgt kombinieren:
Tag 1: Luke trainieren 247 user=> (concat [:darth-vader] [:darth-maul]) (:darth-vader :darth-maul)
Ihnen wird aufgefallen sein, dass repl eine Liste anstelle eines Vektors ausgibt. Viele Funktionen, die Collections zurückgeben, verwenden eine als Sequenz bezeichnete Clojure-Abstraktion. Sie werden am zweiten Tag mehr darüber erfahren. Für den Augenblick müssen Sie nur verstehen, dass Clojure eine Sequenz zurückgibt und diese im repl als Liste ausgegeben wird. Clojure kennt für Vektoren natürlich auch die übliche Kopf/Rest-Operation: user=> (first [:hutt :wookie :ewok]) :hutt user=> (rest [:hutt :wookie :ewok]) (:wookie :ewok)
Wir werden beide Features bei der Mustererkennung nutzen. Listen und Vektoren sind geordnet. Sehen wir uns einige ungeordnete Collections an: Sets und Maps.
Sets Ein Set ist eine ungeordnete Folge von Elementen. Die Collection hat eine stabile Ordnung, aber die ist implementierungsabhängig, weshalb Sie sich nicht auf sie verlassen sollten. Sets schließt man in #{} ein: user=> #{:x-wing :y-wing :tie-fighter} #{:x-wing :y-wing :tie-fighter}
Wir können sie einer Variablen namens spacecraft zuweisen und dann manipulieren: user=> (def spacecraft #{:x-wing :y-wing :tie-fighter}) #'user/spacecraft user=> spacecraft #{:x-wing :y-wing :tie-fighter} user=> (count spacecraft) 3 user=> (sort spacecraft) (:tie-fighter :x-wing :y-wing)
Wir können auch ein sortiertes Set aufbauen, das die Elemente in beliebiger Reihenfolge annimmt und sortiert zurückgibt: user=> (sorted-set 2 3 1) #{1 2 3}
Sie können zwei Sets so mischen: user=> (clojure.set/union #{:skywalker} #{:vader}) #{:skywalker :vader}
248 Kapitel 7: Clojure Oder Sie können die Differenzmenge berechnen: (clojure.set/difference #{1 2 3} #{2})
Bevor wir weitermachen, will ich Ihnen noch eine letzte (praktische) Eigentümlichkeit von Sets vorstellen. Das Set #{:jar-jar, :chewbacca} ist ein Element, gleichzeitig aber auch eine Funktion. Sets prüfen die Zugehörigkeit so: user=> (#{:jar-jar :chewbacca} :chewbacca) :chewbacca user=> (#{:jar-jar :chewbacca} :luke) nil
Wenn Sie ein Set als Funktion verwenden, gibt die Funktion das erste Argument zurück, wenn es im Set enthalten ist. Das waren die SetGrundlagen. Sehen wir uns nun Maps an.
Maps Wie Sie wissen, ist eine Map ein Schlüssel/Wert-Paar. Bei Clojure geben Sie Maps mit geschweiften Klammern an: user=> {:chewie :wookie :leia :human} {:chewie :wookie, :leia :human}
Das ist ein Beispiel für eine Map, ein Schlüssel/Wert-Paar, doch es ist schwer zu lesen. Eine ungleiche Anzahl von Schlüsseln und Werten lässt sich nur schwer erkennen und führt zu einem Fehler: user=> {:jabba :hut :han} java.lang.ArrayIndexOutOfBoundsException: 3
Clojure löst dieses Problem, indem es Kommata als Trennzeichen erlaubt: user=> {:darth-vader "obi wan", :luke "yoda"} {:darth-vader "obi wan", :luke "yoda"}
Ein Wort, vor dem ein : steht, ist ein Schlüsselwort (wie Symbole bei Ruby oder Atome bei Prolog und Erlang). Clojure kennt zwei Arten von Forms, die man nutzt, um Dinge auf diese Art zu benennen: Schlüsselwörter („keywords“) und Symbole. Symbole verweisen auf etwas, während Schlüsselwörter auf sich selbst verweisen. true und map sind Symbole. Verwenden Sie Schlüsselwörter, um Entitäten einer Domäne (etwa eine Eigenschaft in einer Map) zu benennen, so wie Sie ein Atom in Erlang verwenden.
Tag 1: Luke trainieren 249 Definieren wir eine Map namens mentors: user=> (def mentors {:darth-vader "obi wan", :luke "yoda"}) #'user/mentors user=> mentors {:darth-vader "obi wan", :luke "yoda"}
Nun können Sie einen Wert abrufen, indem Sie einen Schlüssel als ersten Wert übergeben: user=> (mentors :luke) "yoda"
Maps sind auch Funktionen. Schlüsselwörter sind ebenfalls Funktionen: user=> (:luke mentors) "yoda"
:luke, die Funktion, schaut sich selbst in der Map nach. Das ist merk-
würdig, aber nützlich. Wie bei Ruby können Sie jedweden Datentyp als Schlüssel oder Wert verwenden. Und Sie können zwei Maps mit merge mischen: user=> (merge {:y-wing 2, :x-wing 4} {:tie-fighter 2}) {:tie-fighter 2, :y-wing 2, :x-wing 4}
Sie können auch angeben, welcher Operator verwendet werden soll, wenn ein Hash in beiden Maps existiert: user=> (merge-with + {:y-wing 2, :x-wing 4} {:tie-fighter 2 :x-wing 3}) {:tie-fighter 2, :y-wing 2, :x-wing 7}
In diesem Beispiel haben wir die Werte 4 und 3, die mit den x-wing kSchlüssel verknüpft sind, mit + verarbeitet. Aus einer bestehenden Assozation können Sie wie folgt eine neue Assoziation mit einem neuen Schlüssel/Wert-Paar erzeugen: user=>(assoc {:one 1} :two 2) {:two 2, :one 1}
Sie können auch eine sortierte Map erzeugen, die Elemente in beliebiger Reihenfolge annimmt und sortiert zurückgibt: user=> (sorted-map 1 :one, 3 :three, 2 :two) {1 :one, 2 :two, 3 :three}
Wir erweitern die Daten schrittweise um Struktur. Nun können wir mit der Form weitermachen, die sich um das Verhalten kümmert: der Funktion.
250 Kapitel 7: Clojure
Funktionen definieren Funktionen bilden das Herzstück aller Lisp-Varianten. Verwenden Sie defn zur Definition einer Funktion. user=> (defn force-it [] (str "Use the force," "Luke.")) #'user/force-it
Die einfachste Form ist (defn [parameter] rumpf). Wir haben eine Funktion namens force-it ohne Parameter definiert. Diese Funktion verkettet einfach zwei Strings. Sie rufen die Funktion auf wie jede andere auch: user=> (force-it) "Use the force,Luke."
Wenn Sie wollen, können Sie einen zusätzlichen String angeben, der die Funktion beschreibt: user=> (defn force-it "The first function a young Jedi needs" [] (str "Use the force," "Luke"))
Die Dokumentation der Funktion können Sie dann mit doc abrufen: user=> (doc force-it) ------------------------- user/force-it ([]) The first function a young Jedi needs nil
Nun wollen wir einen Parameter hinzufügen: user=> (defn force-it "The first function a young Jedi needs" [jedi] (str "Use the force," jedi)) #'user/force-it user=> (force-it "Luke") "Use the force,Luke"
Dieses doc-Feature können Sie übrigens bei jeder Funktion nutzen, die eine Dokumentationszeile angibt: user=> (doc str) ------------------------clojure.core/str ([] [x] [x & ys]) With no args, returns the empty string. With one arg x, returns x.toString(). (str nil) returns the empty string. With more than one arg, returns the concatenation of the str values of the args. nil
Jetzt, da Sie einfache Funktionen definieren können, wollen wir uns Parameterlisten zuwenden.
Tag 1: Luke trainieren 251
Bindungen Wie bei den meisten anderen Sprachen, die wir bisher betrachtet haben, wird der Prozess der Zuweisung von Parametern auf Grundlage der eingehenden Argumente als Bindung bezeichnet. Eine schöne Sache an Clojure ist seine Fähigkeit, auf jeden Teil des Arguments als Parameter zuzugreifen. Nehmen wir zum Beispiel an, Sie arbeiten mit einer Linie, die durch einen Vektor von Punkten dargestellt wird: user=> (def line [[0 0] [10 20]]) #'user/line user=> line [[0 0] [10 20]]
Sie können eine Funktion entwickeln, die auf das Ende der Linie zugreift: user=> (defn line-end [ln] (last ln)) #'user/line-end user=> (line-end line) [10 20]
Doch wir brauchen die ganze Linie gar nicht. Es wäre viel schöner, wenn wir unseren Parameter an das zweite Elemente der Linie binden könnten. Mit Clojure ist das ganz einfach: (defn line-end [[_ second]] second) #'user/line-end user=> (line-end line) [10 20]
Dieses Konzept nennt man Destrukturierung. Wir nehmen eine Datenstruktur und picken uns nur die Teile heraus, die für uns wichtig sind. Sehen wir uns die Bindungen genauer an. Wir haben [[_ second]]. Die äußeren eckigen Klammern definieren den Parameter-Vektor. Die inneren eckigen Klammern zeigen an, dass wir individuelle Elemente einer Liste oder eines Vektors binden wollen. _ und second sind einzelne Parameter, aber _ wird idiomatisch für Parameter verwendet, die man ignorieren möchte. Auf gut Deutsch sagen wir: „Die Parameter für diese Funktion sind _ für das erste Element des ersten Arguments und second für das zweite Elemente des ersten Arguments.“ Wir können diese Bindungen auch verschachteln. Nehmen wir an, wir haben ein Tic-Tac-Toe-Spielfeld und möchten den Wert des mittleren Quadrats zurückgeben. Wir stellen das Spielfeld wie folgt mit drei Zeilen von jeweils drei Elementen dar: user=> (def board [[:x :o :x] [:o :x :o] [:o :x :o]]) #'user/board
252 Kapitel 7: Clojure Nun wollen wir das zweite Element der zweiten Zeile herauspicken: user=> (defn center [[_ [_ c _] _]] c) #'user/center
Wundervoll! Wir schachteln einfach das Konzept. Sehen wir uns das genauer an. Die Bindungen sind [[_ [_ c _] _]]. Wir binden einen Parameter an das eingehende Argument: [_ [_c _] _]. Dieser Parameter besagt, dass wir das erste und das dritte Element ignorieren, also die obere und untere Zeile unseres Tic-Tac-Toe-Spielfelds. Wir konzentrieren uns auf die mittlere Zeile, die [_ c _] lautet. Wir erwarten eine weitere Liste und picken uns das mittlere Element heraus: user=> (center board) :x
Wir können diese Funktion auf unterschiedliche Art und Weise vereinfachen. Erstens müssen wir keine Platzhalterargumente angeben, die hinter den Zielargumenten stehen: (defn center [[_ [_ c]]] c)
Außerdem kann die Destrukturierung in der Argumentenliste oder auch in einer let-Anweisung erfolgen. Bei jeder Lisp-Variante verwenden Sie let, um eine Variable an einen Wert zu binden. Wir können let nutzen, um die Destrukturierung vor den Nutzern der center -Funktion zu verstecken: (defn center [board] (let [[_ [_ c]] board] c))
let erwartet zwei Argumente. Zuerst kommt ein Vektor mit dem zu bindenden Symbol ([[_ [_c]]]), gefolgt vom zu bindenden Wert (board). Nun folgt irgendein Ausdruck, der diesen Wert (wir haben nur c
zurückgegeben) verwendet. Beide Varianten führen zum gleichen Ergebnis. Es hängt nur davon ab, wo Sie die Destrukturierung vornehmen wollen. Ich werde einige kurze Beispiele mit let vorstellen, doch Ihnen sollte klar sein, dass man sie auch in einer Argumentenliste verwenden kann. Sie können eine Map destrukturieren: user=> (def person {:name "Jabba" :profession "Gangster"}) #'user/person user=> (let [{name :name} person] (str "The person's name is " name)) "The person's name is Jabba"
Tag 1: Luke trainieren 253 Sie können Maps und Vektoren auch kombinieren: user=> (def villains [{:name "Godzilla" :size "big"} {:name "Ebola" :size "small"}]) #'user/villains user=> (let [[_ {name :name}] villains] (str "Name of the second villain: " name)) "Name of the second villain: Ebola"
Die Bindung erfolgt an einen Vektor, wobei wir die erste überspringen und uns den Namen der zweiten Map herauspicken. Sie erkennen den Einfluss von Lisp auf Prolog und Erlang. Destrukturierung ist eine einfache Form der Mustererkennung.
Anonyme Funktionen Bei Lisp sind Funktionen einfach Daten. Funktionen höherer Ordnung sind von Grund auf in die Sprache integriert, weil Code nur eine andere Art von Daten ist. Anonyme Funktionen erlauben Ihnen, unbenannte Funktion zu erzeugen. Das ist eine fundamentale Fähigkeit jeder Sprache in diesem Buch. Bei Clojure definieren Sie Funktionen höherer Ordnung mit der fn-Funktion. Üblicherweise lassen Sie den Namen weg, die Form sieht also so aus: (fn [parameter*] rumpf). Sehen wir uns ein Beispiel an. Wir wollen eine Funktion höherer Ordnung verwenden, um für eine Wortliste eine Liste mit Wortlängen aufzubauen. Nehmen wir an, wir haben eine Liste mit Namen: user=> (def people ["Leia", "Han Solo"]) #'user/people
Wir können die Länge eine Wortes wie folgt berechnen: user=> (count "Leia") 3
Eine Liste der Längen dieser Namen können wir so erzeugen: user=> (map count people) (3 8)
Sie haben diese Konzepte bereits kennengelernt. count ist in diesem Kontext eine Funktion höherer Ordnung. Bei Clojure ist dieses Konzept einfach, weil eine Funktion eine Liste ist, genau wie jedes andere Listenelement auch. Sie können die gleichen Bausteine nutzen, um eine Liste zu erzeugen, die die doppelte Länge der Personennamen enthält: user=> (defn twice-count [w] (* 2 (count w))) #'user/twice-count user=> (twice-count "Lando") 10 user=> (map twice-count people) (6 16)
254 Kapitel 7: Clojure Da diese Funktion so einfach ist, können wir sie als anonyme Funktion formulieren: user=> (map (fn [w] (* 2 (count w))) people) (6 16)
Wir können auch eine kürzere Form wählen: user=> (map #(* 2 (count %)) people) (6 16)
Bei der kurzen Form definiert # eine anonyme Funktion, wobei % jedes Element der Sequenz bindet. # wird als Reader-Makro bezeichnet. Anonyme Funktionen bieten Ihnen die Bequemlichkeit und Freiheit, eine Funktion aufzubauen, die im Augenblick keinen Namen braucht. Sie kennen das von anderen Sprachen. Es folgen einige der CollectionFunktionen, die Funktionen höherer Ordnung nutzen. Für all diese Funktionen wollen wir einen gemeinsamen Vektor namens v verwenden: user=> (def v [3 1 2]) #'user/v
Wir werden diese Liste in den folgenden Beispielen mit verschiedenen anonymen Funktionen nutzen.
apply apply wendet eine Funktion auf eine Argumentliste an. (apply f '(x y)) funktioniert wie (f x y): user=> (apply + v) 6 user=> (apply max v) 3
filter Die filter -Funktion funktioniert wie find_all bei Ruby. Sie verlangt eine als Test fungierende Funktion und gibt die Sequenz von Elementen zurück, die den Test bestehen. Um zum Beispiel alle ungeraden Elemente zu ermitteln oder alle Elemente, die kleiner sind als 3, verwenden Sie das hier: user=> (filter odd? v) (3 1) user=> (filter #(< % 3) v) (1 2)
Tag 1: Luke trainieren 255 Wir werden uns einige der anonymen Funktionen genauer ansehen, wenn wir uns eingehender mit Clojure-Sequenzen befassen. Nun machen wir erst mal eine kleine Pause und sehen uns an, was Rich Hickey, der Schöpfer von Clojure, zu sagen hat.
Interview mit Rich Hickey, Schöpfer von Clojure Rich Hickey hat einige Fragen für die Leser dieses Buches beantwortet. Er legte besonderen Wert darauf, warum diese Lisp-Version erfolgreicher sein könnte als andere Lisp-Versionen, weshalb dieses Interview etwas länger ist als sonst. Ich hoffe, Sie finden seine Antworten so faszinierend wie ich. Bruce Tate: Warum haben Sie Clojure geschrieben? Rich Hickey: Ich bin nur ein Praktiker, der sich eine vorwiegend funktionale, erweiterbare, dynamische Sprache mit einem soliden parallelen Unterbau für die standardisierten Plattformen JVM und CLR wünschte, aber keine fand. Bruce Tate: Was mögen Sie an ihr am meisten? Rich Hickey: Ich mag die Betonung der Abstraktion bei Datenstrukturen und Bibliotheken und die Einfachheit. Das sind zwar zwei Dinge, aber sie gehören zusammen. Bruce Tate: Welches Feature würden Sie ändern, wenn Sie noch einmal von vorne beginnen könnten? Rich Hickey: Ich würde einen anderen Ansatz für Zahlen untersuchen. Boxed numbers sind sicher ein wunder Punkt der JVM. Das ist ein Bereich, an dem ich aktiv arbeite. Bruce Tate: Was war für Sie das interessanteste Problem, das mit Clojure gelöst wurde? Rich Hickey: Ich finde, Flightcaster3 (ein Dienst, der Flugverspätungen in Echtzeit vorhersagt) nutzt viele Aspekte von Clojure, von der syntaktischen Abstraktion von Makros über den Aufbau einer DSL für das maschinelle Lernen hin zu statistischer Inferenz und Java-Interoparabilität mit Infrastruktur wie Hadoop und Cascading. Bruce Tate: Aber wie kann Clojure erfolgreicher sein, wenn so viele andere Lisp-Dialekte gescheitert sind? 3
http://www.infoq.com/articles/flightcaster-clojure-rails
256 Kapitel 7: Clojure Rich Hickey: Das ist eine wichtige Frage! Ich würde nicht sagen, dass die Hauptdialekte von Lisp (Scheme und Common Lisp) bei ihrem Einsatzzweck versagt haben. Scheme war der Versuch einer sehr kleinen Sprache, die die Grundlagen der Informatik abdeckte, während Common Lisp bestrebt war, die vielen in der Forschung verwendeten LispDialekte zu standardisieren. Sie sind als praktische Werkzeuge für die allgemeine, produktive Programmierung durch Entwickler in der Industrie gescheitert, das ist aber etwas, wofür sie nie gedacht waren. Clojure wurde hingegen als praktisches Tool für die allgemeine, produktive Programmierung durch Entwickler in der Industrie entworfen. Als solche erweitert die Sprache die alten Lisps um diese Ziele. Wir arbeiten besser in Teams, wir harmonieren gut mit anderen Sprachen und wir lösen einige traditionelle Lisp-Probleme. Bruce Tate: Wie arbeitet Clojure besser in Teamumgebungen? Rich Hickey: Man hat das Gefühl, dass es bei einigen Lisps nur darum geht, den einzelnen Entwickler maximal zu fördern, doch Clojure weiß, dass Entwicklung Teamarbeit ist. Zum Beispiel unterstützt es keine benutzerdefinierten Reader-Makros, die dazu führen könnten, dass Code in vielen kleinen, inkompatiblen Mikrodialekten geschrieben wird. Bruce Tate: Warum haben Sie sich dazu entschieden, eine vorhandene virtuelle Maschine zu verwenden? Rich Hickey: Die Existenz großer, nützlicher, in anderen Sprachen geschriebener Codebasen ist eine Tatsache des heutigen Lebens. Das war noch nicht der Fall, als die alten Lisps erfunden wurden. Die Fähigkeit, andere Sprachen aufzurufen und von diesen aufgerufen zu werden, ist entscheidend, insbesondere bei JVM und CLR.4 Die ganze Idee mehrsprachiger Standardplattformen, die das HostBetriebssystem wegabstrahieren, gab es kaum, als die alten Lisps erfunden wurden. Die Industrie ist mittlerweile um einiges größer, und De-facto-Standards sind entstanden. Technisch ist die Stratifikation, die die Wiederverwendung von Kerntechniken wie fortschrittlicher Garbage Collection und dynamische Compiler wie HotSpot unterstützt, eine gute Sache. Also konzentriert sich Clojure auf Sprache-auf-Plattform und nicht auf Sprache-ist-Plattform.
4 Microsofts Common Language Runtime, eine virtuelle Maschine für die .NET-Plattform
Tag 1: Luke trainieren 257 Bruce Tate: Schön und gut, aber wie kann dieses Lisp zugänglicher sein? Rich Hickey: Dafür gibt es viele Gründe. Zum Beispiel wollten wir das „Klammerproblem“ lösen. Lisp-Programmierer kennen den Wert von „Code ist Daten“, doch es ist falsch, einfach diejenigen aufzugeben, die von den Klammern abgeschreckt werden. Ich glaube nicht, dass der Wechsel von foo(bar, baz) zu (foo bar baz) für Entwickler besonders schwierig ist. Aber ich habe mir die Verwendung von Klammern bei älteren Lisps genau angesehen, um herauszufinden, ob es besser geht, und es geht besser. Ältere Lisps verwenden Klammern für alles. Wir nicht. Und bei älteren Lisps gibt es schlicht zu viele Klammern. Clojure nutzt den anderen Ansatz und entfernt sich von den gruppierenden Klammern. Das macht es Makroentwicklern etwas schwerer, den Benutzern aber einfacher. Die Kombination aus weniger Klammern und nahezu keiner Überladung von Klammern sorgt dafür, dass Clojure wesentlich einfacher zu lesen, visuell zu verarbeiten und zu verstehen ist als ältere Lisps. Führende doppelte Klammern sind bei Java-Code deutlich weiter verbreitet als bei Clojure. Denken Sie an das schreckliche ((EinTyp)einding). einemethode().
Was wir am ersten Tag gelernt haben Clojure ist eine funktionale Sprache für die JVM. Wie Scala und Erlang ist dieser Lisp-Dialekt funktional, aber nicht rein funktional. Er erlaubt eingeschränkte Nebenwirkungen. Im Gegensatz zu anderen Lisp-Dialekten erweitert Clojure die Syntax ein wenig und verwendet geschweifte Klammern für Maps und eckige Klammern für Vektoren. Sie können Kommata als Leerzeichen benutzen und an einigen Stellen Klammern weglassen. Sie haben erfahren, wie man einfache Clojure-Forms verwendet. Zu den einfacheren Forms gehörten Boolesche Werte, Zeichen, Zahlen, Schlüsselwörter und Strings. Wir haben uns auch verschiedene Collections angesehen. Listen und Vektoren sind geordnete Container. Vektoren sind für den wahlfreien Zugriff optimiert, Listen für die sortierte Verarbeitung. Wir haben auch Sets (ungeordnete Collections) und Maps (Schlüssel/Wert-Paare) genutzt.
258 Kapitel 7: Clojure Wir haben einige benannte Funktionen mit Parameterliste, Funktionsrumpf und einem optionalen Dokumentations-String übergeben. Dann haben wir die Dekonstruktion von Bindungen verwendet, um einen beliebigen Parameter an jeden beliebigen Teil des eingehenden Arguments zu binden. Dieses Feature erinnert an Prolog und Erlang. Schließlich haben wir einige anonyme Funktionen definiert und diese dann mithilfe der map-Funktion zur Iteration über eine Liste genutzt. Am zweiten Tag werden wir uns die Rekursion bei Clojure ansehen, die bei den meisten funktionalen Sprachen einen wichtigen Grundbaustein bildet. Wir werden uns auch Sequenzen und „Lazy Evaluation“ ansehen. Das sind Eckpfeiler des Clojure-Modells, die es ermöglichen, eine gemeinsame, mächtige Abstraktionsebene über den Collections aufzubauen. Nun wollen wir eine Pause einlegen und das bisher Gelernte in der Praxis anwenden.
Tag 1: Selbststudium Obwohl Clojure eine neue Sprache ist, werden Sie eine überraschend aktive wachsende Community vorfinden. Es war mit die beste, die ich finden konnte, während ich für dieses Buch recherchierte. Finden Sie Folgendes: 앫
Beispiele für den Einsatz von Clojure-Sequenzen,
앫
die formale Definition einer Clojure-Funktion und
앫
ein Skript zum schnellen Aufruf von repl in Ihrer Umgebung.
Tun Sie Folgendes:
7.3
앫
Implementieren Sie eine Funktion namens (big st n), die true zurückgibt, wenn ein String st länger als n Zeichen ist.
앫
Schreiben Sie eine Funktion namens (collection-type col), die basierend auf dem Typ der Collection col, :list, :map oder :vector zurückgibt.
Tag 2: Yoda und die Macht Als Jedi-Meister bringt Yoda den Anwärtern bei, die Macht (die vereinigende Kraft zwischen allen lebenden Dingen) zu nutzen. In diesem Abschnitt kommen wir zu den fundamentalen Konzepten von Clojure.
Tag 2: Yoda und die Macht 259 Wir werden über Sequenzen sprechen, die Abstraktionsschicht, die alle Clojure-Collections vereint und an Java-Collections bindet. Wir werden uns auch die Lazy Evaluation ansehen, die Sequenzelemente nur dann berechnet, wenn man sie wirklich braucht. Und dann wollen wir uns das mystische Sprachfeature ansehen, das für alle Lisps die Macht darstellt: das Makro.
Rekursion mit loop und recur Wie Sie von den anderen Sprachen in diesem Buch wissen, stützen sich funktionale Sprachen auf Rekursion, und nicht auf Iteration. Hier sehen Sie ein rekursives Programm zur Bestimmung der Größe eines Vektors: (defn size [v] (if (empty? v) 0 (inc (size (rest v))))) (size [1 2 3])
Das ist nicht schwer zu verstehen. Die Größe einer leeren Liste ist null. Die Größe jeder anderen Liste ist eins plus der Größe des Rests der Liste. Vergleichbare Lösungen in anderen Sprachen haben Sie in diesem Buch schon mehrfach gesehen. Sie haben auch erfahren, dass Stacks wachsen, weshalb rekursive Algorithmen so lange immer mehr Speicher konsumieren, bis er aufgebraucht ist. Funktionale Sprachen umgehen diese Beschränkung mithilfe von Endrekursion. Clojure kann aufgrund von Einschränkungen der JVM keine implizite Endrekursion unterstützen. Sie muss explizit über loop und recur erfolgen. Stellen Sie sich loop als eine Art let-Anweisung vor. (loop [x x-anfangs-wert, y y-anfangs-wert] (mach-etwas-mit x y))
Für einen gegebenen Vektor bindet loop die Variablen an den geraden Stellen an die Werte an den ungeraden Stellen. Tatsächlich funktioniert loop genau wie ein let, wenn Sie kein recur angeben: user=> (loop [x 1] x) 1
Die Funktion recur ruft loop erneut auf, übergibt aber neue Werte. Lassen Sie uns die size-Funktion mit recur refaktorieren: (defn size [v] (loop [l v, c 0] (if (empty? l) c (recur (rest l) (inc c)))))
260 Kapitel 7: Clojure Bei dieser zweiten Version von size verwenden wir endrekursiv optimiertes loop und recur. Da wir keinen Wert zurückgeben, halten wir das Ergebnis in einer Variablen fest, die als Akkumulator bezeichnet wird. In unserem Beispiel hält c die Größe fest. Diese Version arbeitet wie ein endrekursiv optimierter Aufruf, doch der Code ist etwas unhandlicher. Manchmal ist die JVM ein zweischneidiges Schwert. Wenn Sie die Community wollen, müssen Sie mit den Problemen leben. Doch da diese Funktion in einige elementare Collection-APIs integriert ist, werden Sie recur nicht oft benötigen. Darüber hinaus bietet Clojure ausgezeichnete Alternativen zur Rekursion, einschließlich „Lazy Sequences“, die wir später in diesem Kapitel behandeln werden. Nachdem wir die schlechte Nachricht des zweiten Tages hinter uns haben, können wir uns angenehmeren Dingen zuwenden. Sequenzen gehören zu den Features, die Clojure zu etwas Besonderem machen.
Sequenzen Eine Sequenz ist eine implementierungsabhängige Abstraktion für die verschiedenen Container des Clojure-Ökosystems. Sequenzen schließen alle Clojure-Collections (Sets, Maps, Vektoren und Ähnliches), Strings und sogar Dateisystemstrukturen (Streams, Verzeichnisse) ein. Sie bieten auch eine gemeinsame Abstraktion für Java-Container, inklusive Java-Collections, -Arrays und -Strings. Generell gilt, dass man etwas in eine Sequenz packen kann, wenn die Funktionen first, rest und cons unterstützt werden. Als wir vorhin mit Vektoren gearbeitet haben, hat Clojure in der Konsole manchmal mit einer Liste geantwortet: user=> [1 2 3] [1 2 3] user=> (rest [1 2 3]) (2 3)
Beachten Sie, dass wir einen Vektor verwendet haben. Das Ergebnis ist keine Liste. Tatsächlich hat repl mit einer Sequenz gearbeitet. Das bedeutet, dass wir alle Collections gleich behandeln können. Sehen wir uns die Sequenzbibliothek an. Sie ist viel zu umfangreich und mächtig, um in einem Abschnitt behandelt zu werden, aber ich will Ihnen einen Vorgeschmack von dem geben, was vorhanden ist. Ich werde Sequenzfunktionen vorstellen, die Sequenzen ändern, testen und erzeugen, werde diese aber nur kurz behandeln.
Tag 2: Yoda und die Macht 261
Tests Wenn Sie eine Sequenz testen wollen, verwenden Sie eine Funktion, die als Prädikat bezeichnet wird. Diese erwartet eine Sequenz, und eine Testfunktion und gibt einen Booleschen Wert zurück. every? gibt beispielsweise true zurück, wenn die Testfunktion für alle Elemente der Sequenz „wahr“ zurückgibt: user=> (every? number? [1 2 3 :four]) false
Eines der obigen Elemente ist also keine Zahl. some ist wahr, wenn der Test für ein beliebiges Element der Sequenz wahr ist:5 (some nil? [1 2 nil]) true
Eines der Elemente ist nil. not-every? und not-any? sind die entsprechenden Umkehrfunktionen: user=> (not-every? odd? [1 3 5]) false user=> (not-any? number? [:one :two :three]) true
Sie verhalten sich genau, wie Sie es erwarten würden. Sehen wir uns Funktionen an, die Sequenzen verändern.
Eine Sequenz verändern Die Sequenzbibliothek besitzt eine Reihe von Funktionen, die Sequenzen auf unterschiedliche Art und Weise transformieren. Sie haben filter bereits kennengelernt. Um sich nur die Wörter herauszupicken, die länger sind als vier Zeichen, verwenden Sie das hier: user=> (def words ["luke" "chewie" "han" "lando"]) #'user/words user=> (filter (fn [word] (> (count word) 4)) words) ("chewie" "lando")
Auch map haben Sie schon kennengelernt, das eine Funktion für alle Elemente einer Collection aufruft und die Ergebnisse zurückgibt. Sie können etwa die Quadrate für alle Elemente eines Vektors berechnen: user=> (map (fn [x] (* x x)) [1 1 2 3 5]) (1149 25)
5 Genauer gesagt, gibt some den ersten Wert zurück, der nicht nil oder false ist. So gibt (some first [[ ] [1]]) beispielsweise 1 zurück.
262 Kapitel 7: Clojure Listenkomprehension (die Sie bereits bei Erlang und Scala kennengelernt haben), kombiniert Maps und Filter. Wie Sie wissen, kombiniert eine Listenkomprehension mehrere Listen und Filter, erzeugt alle möglichen Kombinationen der Listen und wendet den Filter darauf an. Sehen wir uns zuerst einen einfachen Fall an. Wir verwenden zwei Listen namens colors und toys: user=> (def colors ["red" "blue"]) #'user/colors user=> (def toys ["block" "car"]) #'user/toys
Wir können eine Funktion per Listenkomprehension auf alle Farben anwenden. Das funktioniert ähnlich wie map: user=> (for [x colors] (str "I like " x)) ("I like red" "I like blue")
[x colors] bindet x an ein Element aus der colors-Liste. (str "I like " x) ist eine beliebige Funktion, die auf jedes x von colors angewandt
wird. Interessanter wird es, wenn Sie mehr als eine Liste binden: user=> (for [x colors, y toys] (str "I like " x " " y "s")) ("I like red blocks" "I like red cars" "I like blue blocks" "I like blue cars")
Die Listenkomprehension erzeugt jede mögliche Kombination der Werte aus den beiden Listen. Sie können mithilfe des Schlüsselwortes :when auch direkt während der Bindung einen Filter anwenden: user=> (defn small-word? [w] (< (count w) 4)) #'user/small-word? user=> (for [x colors, y toys, :when (small-word? y)] (str "I like " x " " y "s")) ("I like red cars" "I like blue cars")
Wir haben einen Filter namens small-word? geschrieben. Jedes Wort mit weniger als vier Zeichen ist klein. Wir haben den small-word?-Filter mit :when (small- word? y) auf y angewandt. Wir haben alle möglichen Kombinationen von (x, y) erhalten, wobei x ein Element aus colors, y ein Element aus toys und y weniger als vier Zeichen lang ist. Der Code ist kompakt, aber aussagekräftig. Das ist eine ideale Kombination. Sie kennen foldl, foldleft und inject aus Erlang, Scala und Ruby. Bei Lisp heißt das Gegenstück reduce. Um schnell eine Summe oder die Fakultät zu berechnen, verwenden Sie das hier: user=> (reduce + [123 4]) 10 user=> (reduce * [1234 5]) 120
Tag 2: Yoda und die Macht 263 Sie können eine Liste sortieren user=> (sort [312 4]) (123 4)
... und auch das Ergebnis einer Funktion sortieren: user=> (defn abs [x] (if (< x 0) (- x) x)) #'user/abs user=> (sort-by abs [-1 -4 3 2]) (-1 2 3 -4)
Wir definieren eine Funktion namens abs, um den Absolutwert zu berechnen, und benutzen diese Funktion dann für die Sortierung. Das sind einige der wichtigsten Clojure-Funktionen zur Transformation von Sequenzen. Als Nächstes wollen wir uns Funktionen zuwenden, die Sequenzen erzeugen, doch dazu müssen Sie zuerst etwas „fauler“ werden.
Lazy Evaluation In der Mathematik sind unendliche Zahlenfolgen häufig einfacher zu beschreiben. Bei funktionalen Sprachen würden wir diesen Vorteil auch gerne nutzen, können eine unendliche Folge aber nicht berechnen. Die Antwort heißt Lazy („faule“) Evaluation. Bei dieser Strategie berechnet Clojures Sequenzbibliothek Werte nur dann, wenn sie wirklich verwendet werden. Tatsächlich sind die meisten Sequenzen „lazy“. Wir gehen zuerst den Aufbau endlicher Sequenzen durch und wenden uns dann der Lazy-Variante zu.
Endliche Sequenzen mit range Im Gegensatz zu Ruby unterstützt Clojure Ranges (also Wertebereiche) als Funktionen. range erzeugt eine Sequenz: user=> (range 1 10) ( 1 2 3 4 5 6 7 8 9)
Beachten Sie, dass die Obergrenze nicht inklusiv ist (die Sequenz enthält die 10 nicht). Sie können ein beliebiges Inkrement angeben: user=> (range 1 10 3) (1 4 7)
Sie müssen keine Untergrenze angeben, wenn es kein Inkrement gibt: user=> (range 10) (0 1 2 3 4 5 6 7 8 9)
264 Kapitel 7: Clojure Null ist die Standard-Untergrenze. Die mit range erzeugten Sequenzen sind endlich. Doch was tun, wenn die Sequenz keine Obergrenze haben, also eine unendliche Sequenz sein soll? Sehen wir uns an, wie das geht.
Unendliche Sequenzen und take Wir wollen mit der einfachsten unendlichen Sequenz starten, einer Sequenz mit einem sich unendlich oft wiederholenden Element. Wir können (repeat 1) verwenden. Wenn Sie das im repl eingeben, werden Einsen ausgegeben, bis Sie den Prozess beenden. Ganz offensichtlich benötigen wir eine Möglichkeit, nur eine endliche Teilmenge abzugreifen. Diese Möglichkeit bietet die Funktion take: user=> (take 3 (repeat "Use the Force, Luke")) ("Use the Force, Luke" "Use the Force, Luke" "Use the Force, Luke")
Hier haben wir die unendliche Sequenz des Strings "Use the Force, Luke" erzeugt und dann die ersten drei verwendet. Sie können mit cycle auch die Elemente einer Liste wiederholen: user=> (take 5 (cycle [:lather :rinse :repeat])) (:lather :rinse :repeat :lather :rinse)
Wir greifen uns die ersten fünf Elemente der mit cycle erzeugten Sequenz des Vektors [:lather :rinse :repeat]. Nachvollziehbar. Wir können auch die ersten Elemente einer Sequenz verwerfen: user=> (take 5 (drop 2 (cycle [:lather :rinse :repeat]))) (:repeat :lather :rinse :repeat :lather)
Arbeiten wir uns von innen nach außen: Wir bauen erneut einen cycle auf, ignorieren die ersten beiden Elemente per drop und greifen uns dann die fünf nachfolgenden Elemente. Aber wir müssen uns nicht von innen nach außen vorarbeiten. Wir können den neuen Rechts-nachlinks-Operator (->>) verwenden, um jede Funktion auf ein Ergebnis anzuwenden: user=> (->> [:lather :rinse :repeat] (cycle) (drop 2) (take 5)) (:repeat :lather :rinse :repeat :lather)
Wir haben also einen Vektor, bauen daraus mit cycle eine Sequenz auf, verwerfen die ersten beiden Elemente per drop und greifen dann fünf Elemente mit take ab. Manchmal ist von links nach rechts laufender Code einfacher zu lesen. Was tun, wenn Sie irgendein Trennzeichen zwischen den Wörtern einfügen wollen? Sie benutzen interpose: user=> (take 5 (interpose :and (cycle [:lather :rinse :repeat]))) (:lather :and :rinse :and :repeat)
Tag 2: Yoda und die Macht 265 Wir verwenden das Schlüsselwort :and und platzieren es zwischen allen Elementen der unendlichen Sequenz. Stellen Sie sich diese Funktion als verallgemeinerte Variante von Rubys join vor. Sie wollen Elemente aus einer Sequenz zwischenschalten? Verwenden Sie interleave: user=> (take 20 (interleave (cycle (range 2)) (cycle (range 3)))) (0 0 1 1 0 2 1 0 0 1 1 2 0 0 1 1 0 2 1 0)
Wir „verzahnen“ (interleave) die zwei unendlichen Sequenzen (cycle (range 2)) und (cycle (range 3)) und nutzen die ersten 20 Elemente. Die iterate-Funktion stellt eine weitere Möglichkeit dar, Sequenzen zu erzeugen. Sehen Sie sich die folgenden Beispiele an: user=> (take 5 (iterate inc 1)) ( 1 2 3 4 5) user=> (take 5 (iterate dec 0)) (0 -1 -2 -3 -4)
iterate verlangt eine Funktion und einen Startwert. Dann wendet es
die Funktion immer wieder auf den Startwert an. In den beiden obigen Beispielen rufen wir inc und dec auf. Hier sehen Sie ein Beispiel, das aufeinanderfolgende Paare der Fibonacci-Folge berechnet. Wie Sie wissen, ist dabei jede Zahl der Folge die Summe der beiden vorherigen Zahlen. Aus dem Paar [a b] können wir also das nächste mit [b, a + b] berechnen. Wir können eine anonyme Funktion zur Generierung eines solchen Paares verwenden: user=> (defn fib-pair [[a b]] [b (+ a b)]) #'user/fib-pair user=> (fib-pair [3 5]) [5 8]
Als Nächstes verwenden wir iterate, um eine unendliche Sequenz zu erzeugen. Führen Sie Folgendes aber noch nicht aus: (iterate fib-pair [1 1])
Wir wollen map benutzen, um das erste Element aus allen Paaren abzugreifen: (map first (iterate fib-pair [1 1]))
Das ist eine unendliche Sequenz. Nun können wir die ersten fünf verarbeiten: user=> (take 5 (map first (iterate fib-pair [1 1]))) (1 1 2 3 5)
266 Kapitel 7: Clojure Oder wir können die Zahl an Index 500 abfangen: (nth (map first (iterate fib-pair [1 1])) 500) (225... weitere Zahlen ...626)
Die Performance ist ausgezeichnet. Mithilfe dieser Lazy Sequences können häufig rekursive Probleme wie Fibonaccis beschrieben werden. Die Fakultät ist ein weiteres Beispiel: user=> (defn factorial [n] (apply * (take n (iterate inc 1)))) #'user/factorial user=> (factorial 5) 120
Wir verarbeiten n Elemente aus der unendlichen Sequenz (iterate inc 1). Dann nehmen wir n Elemente und multiplizieren Sie mit apply *. Die Lösung ist wirklich einfach. Nachdem wir nun etwas Zeit mit Lazy Sequences verbracht haben, ist es an der Zeit, sich die neuen ClojureFunktionen defrecord und protocol anszusehen.
defrecord und protocol Bisher haben wir die Java-Integration nur auf höherer Ebene diskutiert, doch allzu viel haben wir von der JVM bei Clojure noch nicht durchscheinen sehen. Schließlich und endlich dreht sich bei der JVM alles um Typen und Interfaces. (Für Nicht-Java-Programmierer: Stellen Sie sich Typen als Java-Klassen vor und Interfaces als Java-Klassen ohne Implementierung.) Damit sich Clojure gut in die JVM integrieren kann, enthält die Originalimplementierung ganz schön viel Java. Als Clojure dann Fahrt aufnahm und sich in der Praxis als effektive JVM-Sprache bewährte, gab es verstärkte Bemühungen, mehr von Clojure in Clojure selbst zu implementieren. Um das tun zu können, benötigten die Clojure-Entwickler eine Möglichkeit, schnelle (plattformunabhängige), offene Erweiterungen entwickeln zu können. Es wurde hierbei für eine Abstraktion programmiert und nicht für eine Implementierung. Das Ergebnis ist defrecord für Typen und protocol, das Funktionen um einen Typ herum gruppiert. Aus Closure-Sicht sind die besten Teile der OO Typen und Protokolle (wie Interfaces), und das Schlimmste ist die Implementierungsvererbung. Clojures defrecord und protocol erhalten die guten Teile und lassen den Rest weg. Während dieses Buch geschrieben wird, sind die Sprachfeatures wichtig, aber in der Entwicklung begriffen. Ich stütze mich stark auf Stuart Halloway, Mitgründer von Relevance und Autor von „Programming Clojure“ [Hal09], um eine praktische Implementierung durchzugehen. Wir
Tag 2: Yoda und die Macht 267 kehren noch einmal zu einer anderen funktionalen Sprache auf der JVM zurück: Scala. Wir wollen das Compass-Programm in Clojure neu schreiben. Los geht’s. Zuerst definieren wir ein Protokoll. Ein Clojure-protocol ist wie ein Vertrag. Typen dieses Protokolls unterstützen einen bestimmten Satz von Funktionen, Feldern und Argumenten. Hier ein Protokoll, das unseren Kompass beschreibt: clojure/compass.clj
(defprotocol Compass (direction [c]) (left [c]) (right [c]))
Dieses Protokoll definiert eine Abstraktion namens Compass und führt die Funktionen auf, die Compass unterstützen muss: direction, left und right mit der angegebenen Zahl von Argumenten. Wir können das Protokoll nun mit defrecord implementieren. Zuerst benötigen wir die vier Richtungen: (def directions [:north :east :south :west])
Wir brauchen eine Funktion, die den Richtungswechsel verarbeitet. Wie Sie sich erinnern werden, repräsentieren die Integer-Werte 0, 1, 2 und 3 in base die Grundrichtungen :north, :east, :south und :west. Jede 1, die Sie zu base hinzuaddieren, bewegt den Kompass um 90 Grad nach rechts. Wir verwenden den Rest von base/4 (genauer base/anzahl-derrichtungen), um einen korrekten Richtungswechsel von :west nach :north durchzuführen: (defn turn [base amount] (rem (+ base amount) (count directions)))
Das funktioniert genau, wie Sie es erwarten. Ich lade die compass-Datei und nutze dann die turn-Funktionen: user=> (turn 1 1) 2 user=> (turn 3 1) 0 user=> (turn 2 3) 1
Anders ausgedrückt, führt ein Richtungswechsel von :east zu :south, ein Wechsel von :west nach rechts ergibt :north, und drei Wechsel von :south nach rechts ergeben :east.
268 Kapitel 7: Clojure Es ist an der Zeit, das Protokoll zu implementieren. Das machen wir mit defrecord. Wir wollen das Stück für Stück durchgehen. Zuerst deklarieren wir mit defrecord, dass wir ein Protokoll implementieren: (defrecord SimpleCompass [bearing] Compass
Wir definieren einen neuen Record namens SimpleCompass. Er besitzt ein Feld namens bearing. Als Nächstes implementieren wir das Compass-Protokoll und beginnen mit der direction-Funktion: (direction [_] (directions bearing))
Die Funktion direction sucht das Element in directions am Index bearing heraus. So gibt (directions 3) beispielsweise :west zurück. Jede Argumentenliste besitzt eine Referenz auf die Instanz (also das self bei Ruby oder this bei Java), doch das nutzen wir nicht, sondern fügen _ in unsere Argumentenliste ein. Nun sind left und right an der Reihe: (left [_] (SimpleCompass. (turn bearing 3))) (right [_] (SimpleCompass. (turn bearing 1)))
Denken Sie daran, dass wir bei Clojure mit unveränderlichen Werten arbeiten. Das bedeutet, dass ein Richtungswechsel einen neuen, modifizierten Kompass zurückgibt, statt den alten zu verändern. Sowohl left als auch right verwenden eine Syntax, die Sie noch nicht gesehen haben. (SimpleCompass. arg) bedeutet, dass der Konstruktur für SimpleCompass aufgerufen und arg an den ersten Parameter gebunden werden soll. Sie können das selbst überprüfen: Die Eingabe von (String. "neuer string") unter repl gibt den neuen String "neuer String" zurück. Okay, die Funktionen left und right sind einfach. Jede gibt einen neuen Kompass mit der passsenden Richtung zurück. Dazu nutzen wir die vorhin definierte turn-Funktion. right dreht um 90 Grad nach rechts und left dreht dreimal 90 Grad nach rechts. Bisher besitzen wir den Typ SimpleCompass, der das Compass-Protokoll implementiert. Wir brauchen nur eine Funktion, die uns die entsprechende Textdarstellung zurückliefert, doch toString ist eine Methode in java.lang.Object. Das können wir unserem Typ ganz einfach hinzufügen. Object (toString [this] (str "[" (direction this) "]")))
Wir implementieren dann einen Teil des Object-Protokolls mit der toString-Methode und geben einen String der Form SimpleCompass [:north] aus.
Tag 2: Yoda und die Macht 269 Der Typ ist nun vollständig. Erzeugen wir einen neuen Kompass: user=> (def c (SimpleCompass. 0)) #'user/c
Richtungswechsel geben einen neuen Kompass zurück: user=> (left c) ; gibt einen neuen Kompass zurück #:SimpleCompass{:bearing 3} user=> c ; Originalkompass ist unverändert #:SimpleCompass{:bearing 0}
Beachten Sie, dass der alte Kompass unverändert bleibt. Da wir einen JVM-Typ definieren, sind alle Felder als Java-Felder zugänglich. Doch Sie können auf die Felder auch über Clojure-Map-Schlüsselwörter zugreifen: user=> (:bearing c) 0
Weil diese Typen wie Maps funktionieren, können Sie neue Typen schnell als Maps implementieren und sie dann schrittweise in Typen umwandeln, sobald sich das Design stabilisiert. Sie können Typen auch als Ersatz für Maps in ihren Tests als Stubs oder Mocks verwenden. Es gibt noch weitere Vorteile: 앫
Typen harmonieren gut mit Clojures Konstrukten zur Nebenläufigkeit. An Tag 3 werden Sie erfahren, wie man veränderliche Referenzen auf Clojure-Objekte erzeugt, die die transaktionale Integrität wahren (ähnlich wie relationale Datenbanken).
앫
Wir implementieren ein Protokoll, sind aber nicht darauf beschränkt, etwas auf diese neue Art zu erledigen. Da wir JVMTypen aufbauen, können wir mit Java-Klassen und -Interfaces zusammenarbeiten.
Mit defrecord und protocol bietet Clojure die Möglichkeit, nativen Code für die JVM ohne Java zu entwickeln. Dieser Code kann vollständig mit anderen Typen der JVM zusammenarbeiten, einschließlich Java-Klassen und -Interfaces. Sie können das ausnutzen, um Subklassen von Java-Typen aufzubauen oder Interfaces zu implementieren. Java-Klassen können auch auf Closure-Typen aufsetzen. Natürlich ist das nicht die ganze Geschichte in Sachen Java-Interoperabilität, aber doch ein wichtiger Teil. Nachdem Sie gelernt haben, Java zu erweitern, sollen Sie erfahren, wie man Clojure selbst mit Makros erweitert. y
270 Kapitel 7: Clojure
Makros In diesem Abschnitt kehren wir noch einmal zum Io-Kapitel zurück. Wir haben das Ruby-unless in Abschnitt , Nachrichten, auf Seite 78 implementiert. Die Form ist (unless test form1). Die Funktion führt form1 aus, wenn der Test false zurückgibt. Wir können nicht einfach eine Funktion entwerfen, weil jeder Parameter ausgeführt wird: user=> ; Fehlerhaftes unless user=> (defn unless [test body] (if (not test) body)) #'user/unless user=> (unless true (println "Danger, danger Will Robinson")) Danger, danger Will Robinson nil
Wir haben dieses Problem schon bei Io angesprochen. Die meisten Sprachen führen zuerst Parameter aus und legen die Ergebnisse dann auf dem Aufruf-Stack ab. In unserem Fall wollen wir den Block nicht evaluieren, wenn die Bedingung nicht „false“ ist. Bei Io wurde das Problem umgangen, indem die Ausführung der unless-Nachricht verzögert wurde. Bei Lisp können wir Makros benutzen. Wenn wir (unless test body) eingeben, soll Lisp das in (if (not test) body) übersetzen. Makros sind die Rettung. Ein Clojure-Programm wird in zwei Stufen ausgeführt. Die Makroauflösung übersetzt alle Makros in ihre erweiterte Form. Was da passiert, können Sie sich mit dem Befehl macroexpand ansehen. Wir haben bereits mit einer Reihe von Makros gearbeitet, sogenannten ReaderMakros. Ein Semikolon (;) ist ein Kommentar, ein einzelnes Anführungszeichen (') steht für quote und die Raute (#) für eine anonyme Funktion. Um die Ausführung zu unterdrücken, stellen wir dem Ausdruck, den wir erweitern wollen, ein Anführungszeichen (quote) voran: user=> (macroexpand ''something-we-do-not-want-interpreted) (quote something-we-do-not-want-interpreted) user=> (macroexpand '#(count %)) (fn* [p1 97] (count p1 97))
Die Auflösung von Makros erlaubt Ihnen, Code wie Listen zu behandeln. Soll eine Funktion nicht gleich ausgeführt werden, benutzen Sie Quoting. Clojure ersetzt die Argumente korrekt. Unser unless sieht so aus: user=> (defmacro unless [test body] (list 'if (list 'not test) body)) #'user/unless
Tag 2: Yoda und die Macht 271 Beachten Sie, dass Clojure test und body einsetzt, ohne sie zu evaluieren, aber if und not verlangen ein Quoting. Wir müssen das Ganze auch in eine Liste packen. Wir bauen eine Liste von Code in der Form auf, in der Clojure ihn ausführt. Wir können uns das mit macroexpand ansehen: user=> (macroexpand '(unless condition body)) (if (not condition) body)
Und wir können es natürlich ausführen: user=> (unless true (println "No more danger, Will.")) nil user=> (unless false (println "Now, THIS is The FORCE.")) Now, THIS is The FORCE. nil
Tatsächlich haben wir die Grunddefinition der Sprache verändert. Wir haben eine eigene Kontrollstruktur eingefügt, ohne dass die Sprachschöpfer neue Schlüsselwörter einfügen mussten. Die Auflösung von Makros ist das vielleicht mächtigste Werkzeug von Lisp, und nur wenige Sprachen können so etwas. Das Geheimnis ist der Ausdruck von „Daten als Code“ und nicht bloß als String. Der Code liegt bereits in einer höher angesiedelten Datenstruktur vor. Fassen wir Tag 2 zusammen. Da ist einiges zusammengekommen. Legen wir eine kleine Pause ein, um das Gelernte anzuwenden.
Was wir an Tag 2 gelernt haben Das war ein weiterer vollgepackter Tag. Wir haben riesige Mengen an Abstraktionen in unsere stetig wachsende Trickkiste gepackt. Fassen wir zusammen. Zuerst haben wir gelernt, Rekursion zu verwenden. Da die JVM keine Endrekursion unterstützt, müssen wir loop und recur benutzen. Diese Schleifenkonstrukte erlauben uns die Implementierung vieler Algorithmen, die Sie üblicherweise mit rekursiven Funktionsaufrufen lösen würden (auch wenn die Syntax etwas gewöhnungsbedürftig ist). Wir haben auch Sequenzen eingesetzt. Über sie kapselt Clojure den Zugriff auf alle Collections. Mit einer für alle gleichen Bibliothek können Sie auch immer gleiche Strategien für den Umgang mit Collections nutzen. Wir verwenden verschiedene Funktionen zur Mutation, Transformation und Suche in Sequenzen. Funktionen höherer Ordnung erhöhen die Leistungsfähigkeit und Einfachheit der Sequenzbibliotheken.
272 Kapitel 7: Clojure Mit Lazy Sequences erweitern wir Sequenzen um eine zusätzliche leistungsfähige Schicht. Lazy Sequences vereinfachen Algorithmen. Sie bieten auch eine verzögerte Ausführung, was die Performance (theoretisch) signifikant steigert und die Bindung lockert. Als Nächstes haben wir etwas Zeit damit verbracht, Typen zu implementieren. Mit defrecord und protocol haben wir Typen implementiert, die vollwertige Mitglieder der JVM sind. Abschließend haben wir Makros verwendet, um die Sprache um Features zu erweitern. Sie haben erfahren, dass es einen Schritt gibt, die sogenannte Makroauflösung, der ausgeführt wird, bevor Clojure Code implementiert oder interpretiert. Wir haben unless implementiert, indem wir die if-Funktion in der Makroauflösung benutzt haben. Es gibt viel zu verdauen. Nehmen Sie sich etwas Zeit, um das Gelernte anzuwenden.
Tag 2: Selbststudium Dieser Tag war vollgepackt mit einigen der anspruchsvollsten und mächtigsten Elemente von Clojure. Nehmen Sie sich etwas Zeit, um sich diese Features genau anzusehen und zu verstehen. Finden Sie Folgendes: 앫
die Implementierung für einige bei Clojure gängige Makros,
앫
ein Beispiel für die Definition einer eigenen Lazy Sequence und
앫
den aktuellen Status der defrecord- und protocol-Features (die sich noch in der Entwicklung befanden, als dieses Buch geschrieben wurde).
Tun Sie Folgendes:
7.4
앫
Implementieren Sie unless mit einer else-Bedingung als Makro.
앫
Schreiben Sie mit defrecord einen Typ, der ein Protokoll implementiert.
Tag 3: Ein Auge für Böses Bei „Star Wars“ war Yoda der erste, der das Böse in Darth Vader erkannte. Mit Clojure hat Rich Hickey die Kernprobleme identifiziert, die die Entwicklung paralleler objektorientierter Systeme plagen. Wir haben
Tag 3: Ein Auge für Böses 273 oft erwähnt, dass veränderliche Zustände das Böse darstellen, das in den Herzen objektorientier Programme lauert. Wir haben verschiedene Ansätze gezeigt, wie man mit veränderlichen Zuständen umgehen kann. Io und Scala verwenden ein aktorbasiertes Modell und bieten unveränderliche Konstrukte an, die es dem Programmierer ermöglichen, Probleme ohne veränderliche Zustände zu lösen. Erlang bietet Aktoren mit leichtgewichtigen Prozessen und eine virtuelle Maschine, die die effektive Überwachung und Kommunikation erlaubt, was für eine beispiellose Zuverlässigkeit sorgt. Clojures Ansatz für Nebenläufigkeit ist anders: Es verwendet sogenanntes Software Transactional Memory (STM). In diesem Abschnitt wollen wir uns STM ansehen sowie verschiedene Tools, um Zustände über verteilte Anwendungen hinweg zu nutzen.
Referenzen und Transactional Memory Datenbanken verwenden Transaktionen, um die Integrität von Daten sicherzustellen. Moderne Datenbanken verwenden mindestens zwei Arten der parallelen Zugriffskontrolle. Locks verhindern, das zwei konkurrierende Transaktionen gleichzeitig auf dieselbe Zeile zugreifen. Versionierung nutzt mehrere Versionen, um jeder Transaktion eine private Kopie der Daten zur Verfügung zu stellen. Kommen sich Transaktionen in die Quere, führt die Datenbank die Transaktion einfach erneut aus. Sprachen wie Java nutzen Locking, um die Ressourcen eines Threads vor konkurrierenden Threads (die die Daten beschädigen könnten) zu schützen. Locking bürdet die parallele Zugriffskontrolle grundsätzlich dem Entwickler auf. Wir mussten aber erfahren, dass diese Bürde zu viel für uns ist. Sprachen wie Clojure verwenden Software Transactional Memory (STM). Dieser Ansatz verwendet mehrere Versionen, um die Konsistzenz und Integrität zu wahren. Wenn Sie bei Clojure den Zustand einer Referenz verändern wollen, müssen Sie das (anders als mit Scala, Ruby oder Io) innerhalb einer Transaktion machen. Sehen wir uns an, wie es funktioniert.
Referenzen Bei Clojure ist eine ref (für Referenz) eine verpacktes Stück Daten. Der gesamte Zugriff muss bestimmten Regeln entsprechen. In diesem Fall dienen die Regeln der Unterstützung von STM. Sie können eine Referenz nicht außerhalb einer Transaktion verändern.
274 Kapitel 7: Clojure Um zu sehen, wie das funktioniert, erzeugen wir eine Referenz: user=> (ref "Attack of the Clones") #
Das ist nichts Besonderes. Wir sollten die Referenz einem Wert zuweisen: user=> (def movie (ref "Star Wars")) #'user/movie
Die Referenz erhalten Sie so zurück: user=> movie #
Doch eigentlich interessiert uns der Wert innerhalb der Referenz. Dazu benutzen wir deref: user=> (deref movie) "Star Wars"
Oder die Kurzform von deref: user=> @movie "Star Wars"
Das ist schon besser. Wir können nun ganz einfach auf den Wert innerhalb unserer Referenz zugreifen. Bisher haben wir noch nicht versucht, den Zustand der Referenz zu verändern. Das wollen wir nun tun. Bei Clojure senden wir eine Funktion, die den Wert verändert. Die dereferenzierte ref wird dabei als erstes Argument der Funktion übergeben: user=> (alter movie str ": The Empire Strikes Back") java.lang.IllegalStateException: No transaction running (NO_SOURCE_FILE:0)
Wie Sie sehen, kann der Zustand nur innerhalb einer Transaktion verändert werden. Das geschieht mit der Funktion dosync. Die Modifikation einer Referenz erfolgt vorzugsweise mithilfe einer Transformationsfunktion: user=> (dosync (alter movie str ": The Empire Strikes Back")) "Star Wars: The Empire Strikes Back"
Wir hätten auch einen Anfangswert mit ref-set setzen können: user=> (dosync (ref-set movie "Star Wars: The Revenge of the Sith")) "Star Wars: The Revenge of the Sith"
Sie sehen, dass sich die Referenz geändert hat: user=> @movie "Star Wars: The Revenge of the Sith"
Tag 3: Ein Auge für Böses 275 Das entspricht dem, was wir erwarten. Die Referenz wurde geändert. Es mag etwas umständlich sein, veränderliche Variablen auf diese Weise zu ändern, doch Clojure erzwingt gewisse Regeln, um Ihnen später viel Ärger zu ersparen. Wir wissen, dass Programme, die sich so verhalten, absolut korrekt ausgeführt werden (im Bezug auf Race Conditions und Deadlocks). Ein Großteil unseres Codes folgt funktionalen Paradigmen; wir heben uns STM für die Probleme auf, die am stärksten von Veränderlichkeit profitieren.
Mit Atomen arbeiten Wennn Sie sich Threadsicherheit für eine einzelne Referenzen wünschen (ohne Koordination mit anderen Aktivitäten), können Sie Atome benutzen. Diese Datenelemente erlauben eine Änderung außerhalb des Kontexts einer Transaktion. Wie eine Referenz ist auch ein ClojureAtom ein gekapseltes Datenelement. Probieren wir es aus und erzeugen ein Atom: user=> (atom "Split at your own risk.") #
Nun binden wir das Atom: user=> (def danger (atom "Split at your own risk.")) #'user/danger user=> danger # user=> @danger "Split at your own risk."
Sie können danger mit reset! an ein neues Atom binden: user=> (reset! danger "Split with impunity") "Split with impunity" user=> danger # user=> @danger "Split with impunity"
reset! ersetzt das gesamte Atom, doch setzt bevorzugt eine Funktion
ein, um das Atom zu transformieren. Wenn Sie einen großen Vektor verändern, können Sie ein Atom direkt mit swap! modifizieren: user=> (def top-sellers (atom [])) #'user/top-sellers user=> (swap! top-sellers conj {:title "Seven Languages", :author "Tate"}) [{:title "Seven Languages in Seven Weeks", :author "Tate"}] user=> (swap! top-sellers conj {:title "Programming Clojure" :author "Halloway"})
276 Kapitel 7: Clojure [{:title "Seven Languages in Seven Weeks", :author "Tate"} {:title "Programming Clojure", :author "Halloway"}]
Wie bei einer Referenz wollen Sie einen Wert einmal erzeugen und ändern ihn dann mit swap!. Sehen wir uns ein praktisches Beispiel dafür an.
Einen Atom-Cache entwickeln Nun haben Sie sowohl Referenzen als auch Atome kennengelernt. Die gleiche Philosophie werden Sie bei der Arbeit mit Haskell antreffen. Sie packen Zustände in ein Paket und können diese dann mit Funktionen verändern. Während Referenzen Transaktionen verlangen, ist das bei Atomen nicht der Fall. Wir wollen einen einfachen Atom-Cache aufbauen. Das ist für ein Atom die perfekte Aufgabe. Wir werden Hashes nutzen, um Namen mit Werten zu verknüpfen. Dieses Beispiel wird mit freundlicher Genehmigung von Stuart Halloway von Relevance6 (einem ConsultingUnternehmen, das Clojure-Training und -Consulting anbietet) verwendet. Wir müssen den Cache erzeugen und benötigen dann Funktionen, um Elemente in den Cache einfügen und wieder löschen zu können. Zuerst erzeugen wir den Cache: clojure/atomcache.clj
(defn create [] (atom {}))
Wir haben einfach ein Atom erzeugt und lassen es vom Nutzer dieser Klasse binden. Nun benötigen wir einen Cache-Schlüssel: (defn get [cache key] (@cache key))
Wir verwenden den Cache und den Schlüssel als Argumente. Der Cache ist ein Atom, wir deferenzieren ihn also und liefern den mit dem Schlüssel verknüpften Wert zurück. Abschließend müssen wir noch einen Wert in den Cache einfügen können: (defn put ([cache value-map] (swap! cache merge value-map)) ([cache key value] (swap! cache assoc key value)))
6
http://www.thinkrelevance.com
Tag 3: Ein Auge für Böses 277 Wir haben zwei verschiedene put definiert. Die erste Variante nutzt merge, damit wir alle Assoziationen einer Map in den Cache einfügen können. Die zweite Version verwendet assoc, um einen Schlüssel und einen Wert einzufügen. Nachfolgend sehen Sie den Cache in Aktion. Wir fügen ein Element in den Cache ein und rufen ihn dann ab: (def ac (create)) (put ac :quote "I'm your father, Luke.") (println (str "Cached item: " (get ac :quote)))
Das Ergebnis sieht so aus: Cached item: I'm your father, Luke.
Atome und refs sind einfache und sichere Möglichkeiten, veränderliche Zustände synchron zu verarbeiten. In den nächsten Abschnitten wollen wir uns einige asychrone Beispiele ansehen.
Mit Agenten arbeiten Wie ein Atom ist auch ein Agent ein gekapseltes Stück Daten. Wie ein Io-Future blockt der Status eines dereferenzierten Agenten, bis der Wert verfügbar ist. Nutzer können die Daten über Funktionen asynchron verändern, und die Aktualisierung erfolgt in einem separaten Thread. Nur jeweils eine Funktion kann den Zustand eines Agenten verändern. Probieren wir es aus. Wir definieren eine Funktion namens twice, die den ihr übergebenen Wert quadriert: user=> (defn twice [x] (* 2 x)) #'user/twice
Als Nächstes definieren wir einen Agenten namens tribbles mit dem Anfangswert 1: user=> (def tribbles (agent 1)) #'user/tribbles
Jetzt können wir tribbles verändern, indem wir dem Agenten einen Wert senden: user=> (send tribbles twice) #
Diese Funktion wird in einem anderen Thread ausgeführt. Rufen wir den Wert des Agenten ab: user=> @tribbles 2
278 Kapitel 7: Clojure Das Lesen eines Wertes aus einer Referenz, einem Agenten oder einem Atom sperrt und blockiert niemals. Leseoperationen sollen schnell sein, und mit den richtigen Abstraktionen können sie das auch sein. Mit der folgenden Funktion können Sie die Unterschiede in den Werten sehen, die Sie von einem Agenten abrufen können: user=> (defn slow-twice [x] (do (Thread/sleep 5000) (* 2 x))) #'user/slow-twice user=> @tribbles 2 user=> (send tribbles slow-twice) # user=> @tribbles 2 user=> ; und fünf Sekunden später user=> @tribbles 4
Halten Sie sich nicht bei der Syntax auf. (Thread/sleep 5000) ruft einfach Javas sleep-Method für Thread auf. Konzentrieren Sie sich nur auf den Wert des Agenten. Wir haben eine langsame Version von twice geschrieben, die fünf Sekunden braucht. Die Zeit reicht, um im repl die Unterschiede in @tribbles über die Zeit zu zeigen. Sie erhalten also einen Wert von tribbles. Sie erhalten aber möglicherweise nicht die letzte Änderung ihres eigenen Threads. Wenn Sie sicherstellen wollen, dass Sie den aktuellen Wert im Bezug auf Ihren eigenen Thread erhalten, können Sie (await tribbles) oder (awaitfor timeout tribbles) aufrufen (timeout ist dabei ein Timeout in Millisekunden). Denken Sie daran, dass await und awaitfor nur blockieren, solange Aktionen durch Ihren Thread angestoßen werden. Das sagt nichts darüber aus, was andere Threads von diesem Thread angefordert haben. Wenn Sie glauben, den neuesten Wert von etwas zu haben, liegen Sie wahrscheinlich schon falsch. Clojures Tools verlangen die Arbeit mit einem Schnappschuss, dessen Wert sofort verfügbar und möglicherweise auch direkt veraltet ist. Genau so arbeiten schnelle, parallele, versionierende Datenbanken.
Futures Bei Java würden Sie Threads direkt starten, um eine bestimmte Aufgabe zu erledigen. Natürlich können Sie die Java-Integration verwenden, um Threads auf diese Weise zu nutzen, aber häufig gibt es einen
Tag 3: Ein Auge für Böses 279 besseren Weg. Nehmen wir an, Sie wollen einen Thread erzeugen, um eine komplexe Berechnung im Kontext eines gekapselten Zustands durchzuführen. Sie könnten einen Agenten verwenden. Oder nehmen wir an, dass Sie einen Wert berechnen, aber nicht auf das Ergebnis der Berechnung warten wollen. Wie bei Io können Sie ein Future verwenden. Sehen wir uns das an. Zuerst wollen wir ein Future erzeugen. Es gibt sofort eine Referenz zurück: user=> (def finer-things (future (Thread/sleep 5000) "take time")) #'user/finer-things user=> @finer-things "take time"
Je nachdem, wie schnell Sie tippen, müssen Sie vielleicht auf das Ergebnis warten. Ein Future erwartet einen Rumpf von einem oder mehr Ausdrücken und gibt den Wert des letzten Ausdrucks zurück. Das Future startet in einem anderen Thread. Wenn Sie es dereferenzieren, blockiert das Future, bis der Wert verfügbar ist. Ein Future ist also ein paralleles Konstrukt, das eine asynchrone Rückkehr erlaubt, bevor die Berechnung abschlossen ist. Wir können Futures benutzen, um lange laufende Funktionen parallel auszuführen.
Was wir weggelassen haben Clojure ist ein Lisp, das für sich genommen schon eine extrem umfangreiche Sprache ist. Es basiert auf der JVM, die mehr als ein Jahrzehnt Entwicklung hinter sich hat. Die Sprache mischt auch einige neue und leistungsfähige Konzepte ein. Es ist unmöglich, Clojure in nur einem Kapitel eines Buches abzuhandeln. Es gibt einiges, worüber Sie Bescheid wissen sollten.
Metadaten Manchmal ist es hilfreich, wenn man Metadaten mit einem Typ assoziieren kann. Clojure erlaubt die Bindung und den Zugriff auf Metadaten für Symbole und Collections. (with-meta wert metadaten) assoziiert einen neuen Wert mit den Metadaten, die üblicherweise als Map implementiert sind.
Java-Integration Clojure verfügt über eine hervorragende Java-Integration. Wir haben die Java-Integration nur am Rande angesprochen und auch einen Typ für die
280 Kapitel 7: Clojure JVM entwickelt. Die vorhandenen Java-Bibliotheken haben wir hingegen gar nicht genutzt. Wir sind auch nicht besonders auf Forms eingegangen, die der Java-Kompatibilität dienen. Zum Beispiel ruft (.toUpperCase "Fred") die Member-Funktion .toUpperCase für den String "Fred" auf.
Multimethoden Objektorientierte Sprachen erlauben einen Organisationsstil für Verhalten und Daten. Clojure erlaubt es, mit Multimethoden eine eigene Organisation des Codes zu entwickeln. Sie können eine Funktionsbibliothek mit einem Typ verknüpfen. Sie können mit Multimethoden auch Polymorphismus implementieren, wobei der Dispatch über den Typ, die Metadaten, die Argumente und sogar über Attribute erfolgen kann. Das Konzept ist leistungsfähig und extrem flexibel. Sie können beispielsweise eine Java-artige Vererbung oder eine Prototypvererbung implementieren, oder auch etwas völlig anderes.
Threadzustand Clojure bietet für verschiedene Nebenläufigkeitsmodelle Atome, Refs und Agenten an. Manchmal will man Daten aber für jede Threadinstanz speichern. Bei Clojure ist das mit vars recht einfach möglich. Zum Beispiel würde (binding [name "wert"] ...) nur für den aktuellen Thread name an "wert" binden.
Was wir an Tag 3 gelernt haben Heute sind wir die Strukturen für die Nebenläufigkeit durchgegangen. Dabei haben wir verschiedene interessante Konstrukte kennengelernt. Refs erlauben uns die Implementierung veränderlicher Zustände bei gleichzeitiger Wahrung der Konsistenz über mehrere Threads. Wir haben STM genutzt, Software Transactional Memory. Wir haben alle Veränderungen an Refs in Transaktionen gepackt, die wir mit einer dosync-Funktion ausgedrückt haben. Als Nächstes haben wir Atome benutzt, leichtgewichtige parallele Konstrukte mit weniger Schutz, aber einem einfacheren Nutzungsmodell. Wir haben ein Atom außerhalb einer Transaktion verändert. Abschließend haben wir Agenten verwendet, um einen Pool zu implementieren, der für lange laufende Berechnungen genutzt werden kann. Agenten unterscheiden sich von Io-Aktoren, weil wir den Wert des
Tag 3: Ein Auge für Böses 281 Agenten mit einer beliebigen Funktion verändern können. Agenten geben außerdem einen zeitbasierten Schnappschuss zurück. Der Wert selbst kann sich jederzeit ändern.
Tag 3: Selbststudium Am zweiten Tag haben wir uns auf fortgeschrittene Programmierabstraktionen konzentriert. Der dritte Tag brachte uns Clojures Konstrukte zur Nebenläufigkeit. Bei den folgenden Übungen sollen Sie einen Teil des Gelernten zur Anwendung bringen. Finden Sie Folgendes: 앫
eine Queue-Implementierung, die blockiert, wenn die Queue leer ist und auf ein neues Element wartet
Machen Sie Folgendes: 앫
Benutzen Sie refs, um einen Vektor mit Konten im Speicher aufzubauen. Entwickeln Sie debit- und credit-Funktionen, um den Kontostand zu ändern.
In diesem Abschnitt werde ich ein einzelnes Problem beschreiben, das Sleeping Barber („schlafender Friseur“) genannt wird. Es ist aus dem Jahr 1965 und stammt von Edsger Dijkstra. Es lässt sich so beschreiben: 앫
Ein Friseursalon nimmt Kunden an.
앫
Die Kunden tauchen in zufälligen Intervallen zwischen 10 und 30 Millisekunden auf.
앫
Der Friseursalon hat drei Stühle im Wartebereich.
앫
Der Friseursalon hat einen Friseur und einen Frisierstuhl.
앫
Wenn der Frisierstuhl leer ist, setzt sich ein Kunde auf den Stuhl, weckt den Friseur auf und erhält einen Haarschnitt.
앫
Sind alle Stühle besetzt, kehren neue Kunden um.
앫
Haarschnitte dauern 20 Millisekunden.
앫
Nachdem ein Kunde seinen Haarschnitt erhalten hat, steht er auf und geht.
Entwickeln Sie ein Multithread-Programm, um zu ermitteln, wie viele Haarschnitte ein Friseur in zehn Sekunden durchführen kann.
282 Kapitel 7: Clojure
7.5
Clojure zusammengefasst Clojure kombiniert die Leistungsfähigkeit eines Lisp-Dialekts mit der Bequemheit der JVM. Von der JVM profitiert Clojure durch die existierende Community, die Deployment-Plattform und die Codebibliotheken. Als Lisp-Dialekt kommt Clojure mit den entsprechenden Stärken und Schwächen daher.
Das Lisp-Paradox Clojure ist die vielleicht mächtigste und flexibleste Sprache in diesem Buch. Multimethoden erlauben Multiparadigmencode, und Makros ermöglichen es, die Sprache einfach umzudefinieren. Keine andere Sprache in diesem Buch bietet diese mächtige Kombination. Diese Flexibilität hat sich als unglaubliche Stärke erwiesen. In „Hackers and Painters“ erzählt Graham die Geschichte eines Startups, das die Produktivität mit Lisp auf eine Ebene hob, mit der kein anderer Anbieter mithalten konnte. Einige Dienstleister gehen den gleichen Weg und setzen darauf, dass Clojure einen Produktivitäts- und Qualitätsvorsprung bietet, mit dem andere Sprachen einfach nicht mithalten können. Lisps Flexibilität kann auch eine Schwäche sein. Die Auflösung von Makros ist in den Händen eines Experten ein mächtiges Feature, kann ohne die richtigen Überlegungen und die nötige Sorgfalt aber auch zur Katastrophe führen. Die Fähigkeit, viele mächtige Abstraktionen in wenige Zeilen Code zu packen, macht Lisp zu einer extrem anspruchsvollen Sprache. Um Clojure erfolgreich evaluieren zu können, müssen Sie sich Lisp ansehen, aber auch die einzigartigen Aspekte des Java-Ökosystems und die neuen, einmaligen Features. Sehen wir uns die grundlegenden Stärken von Clojure genauer an.
Kernstärken Clojure ist eine der paar Sprachen, die um die Position der nächsten großen, populären Sprache auf der Java Virtual Machine kämpfen. Es gibt viele Gründe dafür, dass Clojure ein aussichtsreicher Kandidat ist.
Clojure zusammengefasst 283
Ein gutes Lisp Tim Bray, Programmiersprachenexperte und Superblogger, hat Clojure in einem Posting namens „Eleven Theses on Clojure“7 als gutes Lisp bezeichnet, genaugenommen als „das beste Lisp aller Zeiten“. Ich stimme zu, dass Clojure ein sehr gutes Lisp ist. In diesem Kapitel haben Sie erfahren, was Clojure nach Rich Hickeys Meinung zu einem so guten Lisp macht: 앫
Reduzierte Klammern. Clojure verbessert die Lesbarkeit, indem es die Syntax ein wenig öffnet, etwa eckige Klammern für Vektoren, geschweifte Klammern für Maps und eine Kombination von Zeichen für Sets.
앫
Das Ökosystem. Lisps viele Dialekte verwässern den Support und die Menge an Bibliotheken, die ein einzelner Dialekt haben kann. Ironischerweise kann ein zusätzlicher Dialekt dieses Problem lösen. Da es unter der JVM läuft, kann sich Clojure Java-Programmierer (die mehr wollen) und die unglaubliche Menge an Bibliotheken zunutze machen.
앫
Beschränkt. Indem er sich beschränkt und in Clojures Syntax Reader-Makros vermieden hat, hat Hickey Clojures Leistungsfähigkeit tatsächlich beschnitten, gleichzeitig aber auch die Wahrscheinlichkeit neuer Dialekte minimiert.
Sie könnten Lisp an sich als Sprache zu schätzen wissen. So gesehen könnten Sie Clojure als ein neues Lisp sehen. Auf dieser Ebene ist es erfolgreich.
Nebenläufigkeit Clojures Ansatz der Nebenläufigkeit hat das Potenzial, den Entwurf nebenläufiger Anwendungen komplett zu verändern. STM bürdet (weil es so neu ist) dem Entwickler zusätzliche Last auf, aber erstmalig schützt es Entwickler, weil es erkennt, ob Zustandsänderungen mit geeigneten Funktionen vorgenommen werden. Befindet man sich nicht in einer Transaktion, kann der Zustand nicht verändert werden.
7
http://www.tbray.org/ongoing/When/200x/2009/12/01/Clojure- Theses
284 Kapitel 7: Clojure
Java-Integration Clojure verfügt über eine sehr gute Java-Integration. Es nutzt einige native Typen wie Strings und Zahlen transparent und bietet für die Performance Typhinweise an. Doch Clojure brilliert, indem es eine enge JVM-Integration erlaubt: Clojure-Typen können also in Java-Anwendungen vollständig genutzt werden. Sehr bald werden Sie sehen, dass immer mehr von Clojure selbst in der JVM implementiert sein wird.
Lazy Evaluation Clojure kennt mächtige Lazy Evaluation-Features. Lazy Evaluation kann Probleme vereinfachen. Sie haben nur einen Vorgeschmack darauf bekommen, wie Lazy Sequences die Art und Weise formen, in der Sie ein Problem angehen. Lazy Sequences können den Berechnungsoverhead deutlich reduzieren, indem sie die Ausführung so lange verzögern, bis sie tatsächlich gebraucht wird. Darüber hinaus bietet die „faule“ Problemlösung ein weiteres Werkzeug an, um schwierige Probleme zu lösen. Sie können Lazy Sequences häufig nutzen, um Rekursion, Iteration oder bereits berechnete Collections zu ersetzen.
Daten als Code Programme sind Listen. Wie bei jedem anderen Lisp können Sie Daten als Code darstellen. Die Arbeit mit Ruby half mir dabei, den Wert von Programmen zu erkennen, die Programme schreiben. Ich denke, dass das die wichtigste Fähigkeit einer jeden Programmiersprache ist. Funktionale Programme erlauben die Metaprogrammierung über Funktionen höherer Ordnung. Lisp erweitert diese Idee, indem es Daten als Code evaluiert.
Kernschwächen Clojure sieht sich zurecht als Allzweck-Programmiersprache. Ob ihr auf der JVM ein breiter Erfolg beschieden sein wird, muss sich aber erst noch zeigen. Clojure besitzt wunderbare Abstraktionen, und zwar viele. Damit der Programmierer diese Features effektiv und sicher übernehmen und nutzen kann, muss er sehr gut ausgebildet und ganz schön talentiert sein. Es folgen einige meiner Bedenken.
Clojure zusammengefasst 285
Präfixnotation Die Darstellung von Code in Listenform ist eines der mächtigsten Features aller Lisp-Versionen, aber sie hat ihren Preis: Präfixnotation.8 Typische objektorientierte Sprachen verwenden eine ganz andere Syntax. Die Gewöhnung an die Präfixnotation ist nicht einfach. Sie verlangt ein besseres Gedächtnis, und der Entwickler muss den Code von innen nach außen begreifen statt von außen nach innen. Manchmal habe ich das Gefühl, dass man beim Lesen von Clojure-Code zu früh zu viele Details verstehen muss. Im besten Fall trainiert Lisp das Kurzzeitgedächtnis. Man hat mir gesagt, dass sich das mit etwas Übung verbessere. Bei mir konnte ich das bisher aber noch nicht feststellen.
Lesbarkeit Ein weiterer Preis für Daten-als-Code ist die erdrückende Zahl von Klammern. Optimierung für Mensch und Computer ist nicht immer gleich. Die Position und Anzahl der Klammern ist dennoch ein Problem. Lisp-Entwickler verlassen sich stark auf ihre Editoren, um die richtige Zahl von Klammern im Griff zu behalten, aber Tools können Probleme mit der Lesbarkeit niemals ganz überdecken. Hut ab vor Rich, dass er das Problem angegangen ist. Aber es ist immer noch ein Problem.
Lernkurve Clojure ist umfangreich und die Lernkurve erdrückend. Sie benötigen extrem talentierte und erfahrene Programmierer, damit Lisp funktioniert. Lazy Sequences, funktionale Programmierung, Makroauflösung und STM sind leistungsfähige Konzepte, deren Raffinesse zu verstehen seine Zeit dauert.
Beschränktes Lisp Alle Kompromisse haben ihren Preis. Die Nutzung der JVM schränkt Clojure im Bezug auf die Optimierung der Endrekursion ein. ClojureEntwickler müssen die lästige recur -Syntax verwenden. Versuchen Sie einmal, (size x) zu implementieren, das die Größe einer Sequenz x mithilfe von Rekursion und loop/recur löst.
8 Clojure kennt auch links-nach-rechts-Makros, ->> und ->, die diese Probleme ein wenig abmildern.
286 Kapitel 7: Clojure Die Eliminierung benutzerdefinierter Reader-Makros ist ebenfalls bedeutsam. Der Vorteil ist klar. Reader-Makros können, wenn sie missbraucht werden, dazu führen, dass verschiedene Dialekte der Sprache entstehen. Der Preis für ihre Abschaffung ist auch klar. Sie verlieren ein Werkzeug für die Metaprogrammierung.
Zugänglichkeit Einer der schönsten Aspekte an Ruby und am frühen Java war die Zugänglichkeit als Programmiersprache. Beide Sprachen konnten relativ leicht aufgenommen werden. Clojure stellt hohe Anforderungen an den Entwickler. Es kennt so viele Abstraktionswerkzeuge und Konzepte, dass es einen überwältigen kann.
Abschließende Gedanken Ein Großteil der Stärken und Schwächen von Clojure hat etwas mit Leistungsfähigkeit und Flexibilität zu tun. Sicher, Sie müssen hart arbeiten, um Clojure zu erlernen. Und Sie arbeiten ja schon hart, wenn Sie Java-Entwickler sind. Sie verbringen Ihre Zeit nur mit Abstraktionen auf Java-Anwendungsebene. Sie suchen beispielsweise eine weniger feste Bindung durch Spring oder aspektorientierte Programmierung. Sie können also nicht die Vorteile zusätzlicher Flexibilität auf Sprachebene nutzen. Bei vielen hat dieser Nachteil funktioniert. Dennoch wage ich vorauszusagen, dass die steigenden Anforderungen an Nebenläufigkeit und Komplexität die Java-Plattform als immer weniger geeignet erscheinen lassen werden. Wenn Sie ein extremes Programmiermodell brauchen und bereit sind, den Preis für das Erlernen der Sprache zu zahlen, dann ist Clojure eine gute Wahl. Ich finde, dass es eine großartige Sprache für disziplinierte, gut ausgebildete Teams ist, die einen Vorteil suchen: Sie können mit Clojure schneller bessere Software entwickeln.
Logic is little tweeting bird chirping in meadow. Spok
Kapitel 8
Haskell Haskell repräsentiert für viele Puristen funktionaler Programmierung Reinheit und Freiheit. Die Sprache ist umfangreich und mächtig, doch das hat seinen Preis. Sie können nicht einfach nur ein paar Häppchen davon nehmen. Haskell zwingt Ihnen den ganzen Burger der funktionalen Programmierung auf. Denken Sie an Spock aus „Star Trek“. Das obige Zitat1 ist typisch und vereint Logik und Wahrheit. Die Figur Spock hat mit ihrer Aufrichtigkeit und Reinheit Generationen von Zuschauern fasziniert. Während Scala, Erlang und Clojure die Nutzung imperativer Konzepte in kleinen Dosen erlauben, lässt Haskell dafür keinen Platz. Diese rein funktionale Programmiersprache wird Sie fordern, wenn es um die Ein-/Ausgabe oder die Akkumulation von Zuständen geht.
8.1
Einführung in Haskell Wenn man verstehen will, warum eine Sprache bestimmte Kompromisse eingeht, sollte man sich (wie immer) ihre Geschichte ansehen. Anfang bis Mitte der 1980er kam die rein funktionale Programmierung in mehreren Sprachen auf. Die Schlüsselkonzepte, die die Forschung vorantrieben, waren „Lazy Processing“, wie wir es in Clojure kennengelernt haben, und die rein funktionale Programmierung. 1987 fanden sich Teilnehmer der „Functional Programming Languages and Computer Architecture“-Konferenz zusammen und entschieden, einen offenen Standard für eine rein funktionale Sprache zu schaffen. Aus dieser Gruppe heraus entstand 1990 Haskell, das 1998 noch einmal überar1 Star Trek: The Original Series, Episode 41 und 42: „I, Mudd“/„The Trouble with Tribbles.“ Directed by Marc Daniels. 1967; Burbank, CA: 20th CBS Paramount International Television, 2001.
288 Kapitel 8: Haskell beitet wurde. Der aktuelle Standard, Haskell 98, wurde mehrfach überarbeitet. Es gibt auch die Definition einer neuen Version namens Haskell Prime. Haskell wurde also von Grund auf als rein funktionale Sprache konzipiert. Es vereint die Ideen der besten funktionalen Sprachen mit einem besonderen Schwerpunkt auf Lazy Processing. Haskell verwendet wie Scala eine starke statische Typisierung. Das Typmodell ist größtenteils abgeleitet und wird allgemein als eines der effektivsten in den funktionalen Sprachen betrachtet. Sie werden sehen, dass das Typsystem Polymorphismus und sehr klare Designs erlaubt. Haskell unterstützt auch andere Konzepte, die Sie in diesem Buch schon kennengelernt haben: Mustererkennung und Guards im Stil von Erlang, Lazy Evaluation im Stil von Clojure und Listenkomprehensionen à la Clojure und Erlang. Bei Hasekell als rein funktionale Sprache gibt es keine Nebenwirkungen. Stattdessen kann eine Funktion eine Nebenwirkung zurückgeben, die dann später ausgeführt wird. Ein entsprechendes Beispiel werden Sie an Tag 3 kennenlernen, ebenso wie ein Beispiel für die Erhaltung von Zuständen mithilfe eines als Monaden bezeichneten Konzepts. Die ersten Tage werden Sie mit ein paar typischen funktionalen Programmierkonzepten verbringen: Ausdrücke, Funktionsdefinitionen, Funktionen höherer Ordnung und Ähnliches. Wir sehen uns auch Haskells Typisierungsmodell an, das einige neue Konzepte bereithält. Der dritte Tag wird Sie fordern. Wir sehen uns das parametrisierte Typsystem und Monaden an, die als Konzept recht schwer zu verstehen sind. Legen wir los.
8.2
Tag 1: Logisch Wie Spock sind auch Haskells Kernkonzepte leicht zu verstehen. Sie arbeiten nur mit der Definition von Funktionen. Für die gleichen Eingabeparameter erhalten Sie immer die gleichen Ausgabeparameter. Ich verwende GHC, den Glasgow Haskell Compiler, in der Version 6.12.1. Er ist weit verbreitet und für viele Plattformen verfügbar, doch es gibt auch andere Implementierungen. Wie immer starte ich zuerst die Konsole. Geben Sie ghci ein:
Tag 1: Logisch 289 GHCi, version 6.12.1: http://www.haskell.org/ghc/ :? for help Loading package ghc-prim ... linking ... done. Loading package integer-gmp ... linking ... done. Loading package base ... linking ... done. Loading package ffi-1.0 ... linking ... done.
Haskell lädt einige Pakete, dann können Sie Befehle eingeben.
Ausdrücke und primitive Typen Wir werden Haskells Typsystem ein bisschen später betrachten. In diesem Abschnitt konzentrieren wir uns auf die Verwendung primitiver Typen. Wie bei anderen Sprachen beginnen wir mit Zahlen und einigen einfachen Ausdrücken, wenden uns dann aber schnell fortgeschrittenen Typen wie Funktionen zu.
Zahlen Mittlerweile kennen Sie das ja schon: Geben Sie ein paar Ausdrücke ein. Prelude> 4 Prelude> 5 Prelude> 5.0 Prelude> 14.0
4 4 + 1 4 + 1.0 4 + 2.0 * 5
Die Rangfolge der Operatoren ist so, wie Sie es erwarten würden: Prelude> 4 * 5 + 1 21 Prelude> 4 * (5 + 1) 24
Beachten Sie, dass Sie Operationen mit Klammern gruppieren können. Sie haben eine Reihe von Zahlentypen gesehen. Sehen wir uns einige Character-Typen an.
Character-Daten Strings werden zwischen doppelte Anführungszeichen gestellt: Prelude> "hello" "hello" Prelude> "hello" + " world" :1:0: No instance for (Num [Char]) arising from a use of `+' at :1:0-17
290 Kapitel 8: Haskell Possible fix: add an instance declaration for (Num [Char]) In the expression: "hello" + " world" In the definition of `it': it = "hello" + " world" Prelude> "hello" ++ " world" "hello world"
Beachten Sie, dass die Verkettung mit ++ erfolgt und nicht mit +. Einzelne Zeichen können Sie so angeben: Prelude> 'a' 'a' Prelude> ['a', 'b'] "ab"
Wie Sie sehen, ist ein String bloß eine Liste von Zeichen. Sehen wir uns kurz einige Boolesche Werte an.
Boolesche Werte Ein Boolescher Wert ist ein primitiver Typ, der wie bei den meisten anderen Infixsprachen funktioniert. Hier die Gleich- und Nicht-gleichAusdrücke, die Boolesche Werte zurückgeben: Prelude> (4 + 5) == 9 True Prelude> (5 + 5) /= 10 False
Probieren Sie eine if/then -Anweisung aus: Prelude> if (5 == 5) then "true" :1:23: parse error (possibly incorrect indentation)
Das ist er erste wichtige Unterschied zu den anderen Sprachen in diesem Buch: Bei Haskell ist die Einrückung von Bedeutung. Haskell glaubt, dass es eine Folgezeile gibt, die nicht korrekt eingerückt wurde. Wir werden später einige eingerückte Strukturen sehen. Wir werden nicht über Layouts reden, die diese Einrückungsmuster steuern. Verwenden Sie vorhersehbare Einrückungsstrategien (die dem entsprechen, was Sie hier sehen), und Sie sind auf der sicheren Seite. Lassen Sie uns eine vollständige if/then/else-Anweisung eingeben: Prelude> if (5 == 5) then "true" else "false" "true"
Bei Haskell ist if eine Funktion, keine Kontrollstruktur. Das bedeutet, dass sie wie jede andere Funktion einfach einen Wert zurückgibt. Probieren wir einige wahr/falsch-Werte aus:
Tag 1: Logisch 291 Prelude> if 1 then "true" else "false" :1:3: No instance for (Num Bool) arising from the literal `1' at :1:3 ...
Haskell ist stark typisiert, also verlangt if rein Boolesche Typen. Lassen Sie uns eine weitere Typkollision provozieren: Prelude> "one" + 1 :1:0: No instance for (Num [Char]) arising from a use of `+' at :1:0-8 ...
Diese Fehlermeldung gibt uns einen ersten Einblick in Haskells Typsystem. Sie besagt, dass es keine Funktion namens + gibt, die ein Num mit [Char] (einer Liste von Zeichen) dahinter verarbeitet. Beachten Sie, dass wir Haskell nicht gesagt haben, von welchem Typ diese Dinge sind. Die Sprache leitet die Typen aus Hinweisen ab. Sie können an jedem Punkt sehen, was Haskells Typinferenz macht. Sie können :t so verwenden (oder die Option :t aktivieren, die Ähnliches bewirkt): Prelude> :set +t Prelude> 5 5 it :: Integer Prelude> 5.0 5.0 it :: Double Prelude> "hello" "hello" it :: [Char] Prelude> (5 == (2 + 3)) True it :: Bool
Nun können Sie nach jedem Ausdruck sehen, welchen Typ er zurückgibt. Ich möchte Sie aber warnen: Die Verwendung von :t mit Zahlen ist verwirrend. Das hat mit dem Zusammenspiel von Zahlen und der Konsole zu tun. Probieren Sie die Funktion :t aus: Prelude> :t 5 5 :: (Num t) => t
Das ist nicht derselbe Typ wie vorhin: it :: Integer. Die Konsole versucht, Zahlen so allgemein wie möglich zu halten, solange Sie kein :set t verwenden. Anstelle eines reinen Typs erhalten Sie eine Klasse, die eine Gruppe ähnlicher Typen beschreibt. Mehr erfahren Sie im Abschnitt , Klassen und Typen auf Seite 316.
292 Kapitel 8: Haskell
Funktionen Das Herzstück des gesamten Haskell-Programmierparadigmas ist die Funktion. Da Haskell eine starke, statische Typisierung nutzt, geben Sie jede Funktion in zwei Teilen an: einer optionalen Typspezifikation und der Implementierung. Wir wollen die Konzepte, die Sie schon von anderen Sprachen her kennen, schnell durchgehen, also bleiben Sie dran.
Einfache Funktionen definieren Eine Haskell-Funktion besteht traditionell aus zwei Teilen: der Typund der Funktionsdeklaration. Zu Beginn wollen wir Funktionen innerhalb der Konsole definieren. Wir werden die let-Funktion verwenden, um Werte an Implementierungen zu binden. Probieren Sie let aus, bevor Sie eine Funktion definieren. Wie bei Lisp bindet let bei Haskell eine Variable im lokalen Geltungsbereich an eine Funktion. Prelude> let x = 10 Prelude> x 10
Wenn Sie ein Haskell-Modul entwickeln, deklarieren Sie Funktionen wie folgt: double x = x * 2
In der Konsole verwenden wir let hingegen, um eine Funktion im lokalen Geltungsbereich zuzuweisen, damit wir sie verwenden können. Hier als Beispiel eine einfache double-Funktion: Prelude> let double x = x * 2 Prelude> double 2 4
An diesem Punkt gehen wir dazu über, unsere Programme in Dateien zu speichern. Wir können mit mehrzeiligen Definitionen arbeiten. Bei GHC würde die vollständige double-Definition so aussehen: haskell/double.hs
module Main where double x = x + x
Tag 1: Logisch 293 Beachten Sie, dass wir ein Modul namens Main eingefügt haben. Bei Haskell fassen Module zusammengehörigen Code im selben Geltungsbereich zusammen. Das Modul Main ist etwas Besonderes: Es ist das Top-LevelModul. Für den Augenblick wollen wir uns auf die double-Funktion konzentrieren. Laden Sie Main in die Konsole und benutzen Sie es so: Prelude> :load double.hs [1 of 1] Compiling Main Ok, modules loaded: Main. *Main> double 5 10
( double.hs, interpreted )
Bisher haben Sie keinen Typ erzwungen. Haskell ist nachsichtig und leitet den Typ für uns ab. Es gibt aber definitiv eine zugrunde liegende Typdefinition für jede Funktion. Hier sehen Sie ein Beispiel für eine Funktion mit einer Typdefinition: haskell/double_with_type.hs
module Main where double :: Integer -> Integer double x = x + x
Und wir können sie wie vorhin laden und ausführen: [1 of 1] Compiling Main Ok, modules loaded: Main. *Main> double 5 10
( double_with_type.hs, interpreted )
Sie können sich den mit der neuen Funktion assoziierten Typ ansehen: *Main> :t double double :: Integer -> Integer
Diese Definition besagt, dass die Funktion double ein Integer -Argument verlangt (den ersten Integer) und einen Integer-Wert zurückgibt. Diese Typdefinition ist beschränkt. Wenn Sie sich die frühere, typfreie Version von double ansehen, erhalten Sie etwas ganz anderes: *Main> :t double double :: (Num a) => a -> a
Ja, das ist anders! In diesem Fall ist a eine Typvariable. Die Definition besagt: „Die Funktion double verlangt ein einzelnes Argument vom Typ a und gibt einen Wert vom selben Typ a zurück“. Mit dieser erweiterten Definition können wir die Funktion mit jedem Typ nutzen, der die Funktion + unterstützt. Lassen Sie uns einen Zahn zulegen und etwas Interessanteres Implementieren: die Fakultät.
294 Kapitel 8: Haskell
Rekursion Wir wollen mit einer kleinen Rekursion beginnen. Hier ein rekursiver Einzeiler, der die Fakultät in der Konsole implementiert: Prelude> let fact x = if x == 0 then 1 else fact (x - 1) * x Prelude> fact 3 6
Das ist ein Anfang. Die Fakultät von x ist 1, wenn x 0 ist, und andernfalls fact (x - 1) * x. Es geht etwas besser, wenn wir die Mustererkennung einführen. Tatsächlich erinnert die Syntax stark an Erlangs Pattern-Matching und verhält sich auch so: haskell/factorial.hs
module Main where factorial :: Integer -> Integer factorial 0 = 1 factorial x = x * factorial (x - 1)
Die Definition besteht aus drei Zeilen. Die erste deklariert den Typ des Arguments und den Rückgabewert. Die nächsten beiden sind unterschiedliche funktionale Definitionen, die von der Mustererkennung des eingehenden Arguments abhängen. Die Fakultät von 0 ist 1, und die Fakultät von n ist factorial x = x * factorial (x - 1). Diese Definition sieht genau wie die mathematische Definition aus. In diesem Fall ist die Reihenfolge der Muster wichtig. Haskell verwendet den ersten Treffer. Wollen Sie die Reihenfolge umkehren, müssen Sie mit einem Guard arbeiten. Bei Haskell sind Guards Bedingungen, die die Werte von Argumenten beschränken: haskell/fact_with_guard.hs
module Main where factorial :: Integer -> Integer factorial x |x > 1=x * factorial (x - 1) | otherwise = 1
In diesem Fall verwendet der Guard Boolesche Werte auf der linken und die anzuwendende Funktion auf der rechten Seite. Wird die Bedingung eines Guard erfüllt, ruft Haskell die entsprechende Funktion auf. Guards ersetzen häufig die Mustererkennung. Wir benutzen sie, um die Grundbedingung für unsere Rekursion festzulegen.
Tag 1: Logisch 295
Tupel und Listen Wie einige der anderen Sprachen, die wir uns angesehen haben, ist Haskell von der Endrekursion abhängig, um effektiv mit der Rekursion umgehen zu können. Sehen wir uns verschiedene Versionen einer Fibonacci-Folge mit Haskell an. Zuerst der einfache Fall: haskell/fib.hs
module Main where fib :: Integer -> Integer fib 0 = 1 fib 1 = 1 fib x = fib (x - 1) + fib (x - 2)
Das ist wirklich simpel. fib 0 oder fib 1 ist 1 und fib x ist fib (x - 1) + fib (x - 2). Doch diese Lösung ist ineffizient. Lassen Sie uns eine effizientere Lösung entwickeln.
Programmieren mit Tupeln Wir können Tupel benutzen, um eine effizientere Implementierung zu entwickeln. Ein Tupel ist eine Collection mit einer festen Anzahl von Elementen. Bei Haskell sind Tupel in runden Klammern stehende, durch Kommata getrennte Elemente. Die Implementierung erzeugt ein Tupel aufeinanderfolgender Fibonacci-Zahlen und verwendet einen Zähler, der Rekursion unterstützt. Hier die Grundlösung: fibTuple :: (Integer, Integer, Integer) -> (Integer, Integer, Integer) fibTuple (x, y, 0) = (x, y, 0) fibTuple (x, y, index) = fibTuple (y, x + y, index - 1)
fibTuple verlangt ein Dreiertupel und gibt ein Dreiertupel zurück. Vor-
sicht: Ein einzelner Dreiertupel-Parameter ist nicht das Gleiche wie drei Parameter. Um die Funktion zu nutzen, beginnen wir unsere Rekursion mit den Zahlen 0 und 1. Wir übergeben außerdem einen Zähler. Während der Zähler heruntergezählt wird, werden die ersten beiden Zahlen in der Folge sukzessive größer. Aufeinanderfolgende Aufrufe von fibTuple (0, 1, 4) würden so aussehen: 앫 fibTuple (0, 1, 4) 앫 fibTuple (1, 1, 3) 앫 fibTuple (1, 2, 2) 앫 fibTuple (2, 3, 1) 앫 fibTuple (3, 5, 0)
296 Kapitel 8: Haskell Sie können das Programm so ausführen: Prelude> :load fib_tuple.hs [1 of 1] Compiling Main Ok, modules loaded: Main. *Main> fibTuple(0, 1, 4) (3, 5, 0)
( fib_tuple.hs, interpreted )
Die Antwort steht an erster Stelle. Sie können die Antwort so abrufen: fibResult :: (Integer, Integer, Integer) -> Integer fibResult (x, y, z) = x
Wir nutzen einfach das Pattern-Matching, um die erste Position abzurufen. Wir können die Verwendung auch vereinfachen: fib :: Integer -> Integer fib x = fibResult (fibTuple (0, 1, x))
Diese Funktion benutzt die beiden Hilfsfunktionen, um einen recht schnellen Fibonacci-Generator zu entwickeln. Hier noch mal das gesamte Programm: haskell/fib_tuple.hs
module Main where fibTuple :: (Integer, Integer, Integer) -> (Integer, Integer, Integer) fibTuple (x, y, 0) = (x, y, 0) fibTuple (x, y, index) = fibTuple (y, x + y, index - 1) fibResult :: (Integer, Integer, Integer) -> Integer fibResult (x, y, z) = x fib :: Integer -> Integer fib x = fibResult (fibTuple (0, 1, x))
Und hier das (sofort erscheinende) Ergebnis: *Main> fib 100 354224848179261915075 *Main> fib 1000 43466557686937456435688527675040625802564660517371780 40248172908953655541794905189040387984007925516929592 25930803226347752096896232398733224711616429964409065 33187938298969649928516003704476137795166849228875
Probieren wir einen anderen Ansatz aus, der die Funktionskomposition („function composition“) nutzt.
Tupel und Komposition Manchmal müssen Sie Funktionen kombinieren, indem Sie sie verketten und das Ergebnis einer Funktion an die nächste weitergeben. Hier ein Beispiel, das das zweite Element einer Liste bestimmt, indem es den Head des Tail der Liste abgreift:
Tag 1: Logisch 297 *Main> let second = head . tail *Main> second [1, 2] 2 *Main> second [3, 4, 5] 4
Wir haben nur eine Funktion in der Konsole definiert. second = head . tail entspricht lst = head (tail lst). Wir übergeben das Ergebnis einer Funktion an eine andere. Lassen Sie uns dieses Feature mit einer weiteren Fibonacci-Folge verwenden. Wir berechnen wie vorhin ein einzelnes Paar, doch diesmal ohne Zähler: fibNextPair :: (Integer, Integer) -> (Integer, Integer) fibNextPair (x, y) = (y,x+ y)
Mit zwei Zahlen der Folge kann man immer die nächste berechnen. Die nächste Aufgabe besteht darin, rekursiv das nächste Element der Folge zu berechnen: fibNthPair :: Integer -> (Integer, Integer) fibNthPair 1 = (1, 1) fibNthPair n = fibNextPair (fibNthPair (n - 1))
Der Basisfall ist der Wert (1, 1) für ein n von 1. Der Rest ist einfach: Wir berechnen das nächste Element der Folge auf der Grundlage des vorigen. Wir können jedes Paar der Folge bestimmen: *Main> fibNthPair(8) (21,34) *Main> fibNthPair(9) (34,55) *Main> fibNthPair(10) (55,89)
Nun bleibt uns nur noch, das erste Element jedes Paars abzugreifen und in einer Folge zusammenzufassen. Wir nutzen dazu eine Funktionskomposition namens fst, um das erste Element abzurufen, und fibNthPair, um ein Paar zu erzeugen: haskell/fib_pair.hs
module Main where fibNextPair :: (Integer, Integer) -> (Integer, Integer) fibNextPair (x, y) = (y, x + y) fibNthPair :: Integer -> (Integer, Integer) fibNthPair 1 = (1, 1) fibNthPair n = fibNextPair (fibNthPair (n - 1)) fib :: Integer -> Integer fib = fst . fibNthPair
298 Kapitel 8: Haskell Anders ausgedrückt, ermitteln wir das erste Element des n-ten Tupels und sind fertig. Nachdem wir jetzt ein wenig mit Tupeln gearbeitet haben, wollen wir einige Probleme mit Listen lösen.
Listen durchgehen Sie haben Listen in vielen verschiedenen Sprachen kennengelernt. Ich will das ganze Thema nicht wieder aufrollen, gehe aber ein einfaches Beispiel für die Rekursion durch und stelle dann einige neue Funktionen vor, die Sie noch nicht gesehen haben. Das Zerlegen einer Liste in Kopf und Rest funktioniert bei jeder Bindung, etwa einer let-Anweisung oder einer Mustererkennung: let (h:t) = [1, 2, 3, 4] *Main> h 1 *Main> t [2,3,4]
Wir binden die Liste [1, 2, 3, 4] an (h:t). Stellen Sie sich dieses Konstrukt wie die verschiedenen head|tail-Konstrukte von Prolog, Erlang und Scala vor. Mit diesem Tool können wir einige einfache rekursive Definitionen durchführen. Hier die Funktionen size und prod für eine Liste: haskell/lists.hs
module Main where size [] = 0 size (h:t) = 1 + size t prod [] = 1 prod (h:t) = h * prod t
Ich nutze Haskells Typinferenz, um die Typen dieser Funktionen zu behandeln, aber die Absicht ist klar. Die Größe (size) einer Liste ist 1 + der Größe des Rests der Liste. Prelude> :load lists.hs [1 of 1] Compiling Main ( lists.hs, interpreted ) Ok, modules loaded: Main. *Main> size "Fascinating." 12
zip ist eine leistungsfähige Möglichkeit, Listen zu kombinieren. Hier
die Funktion in Aktion: *Main> zip "kirk" "spock" [('kirk','spock')]
Tag 1: Logisch 299 Wir haben also ein Tupel mit zwei Elementen erzeugt. Sie können auch Listen kombinieren: Prelude> zip ["kirk", "spock"] ["enterprise", "reliant"] [("kirk","enterprise"),("spock","reliant")]
Dieses Kombinieren zweier Listen ist sehr effektiv. Die Features, die Sie bei Haskell bisher kennengelernt haben, sind denen der anderen Sprachen bemerkenswert ähnlich. Nun wollen wir etwas fortgeschrittenere Konstrukte verwenden. Wir sehen uns fortgeschrittene Listen an, inklusive Wertebereichen (Ranges) und Listenkomprehensionen.
Listen generieren Wir haben bereits einige Möglichkeiten gesehen, Listen mit Rekursion zu verarbeiten. In diesem Abschnitt wollen wir uns einige Möglichkeiten ansehen, neue Listen zu generieren. Namentlich sehen wir uns Rekursion, Wertebereiche (Ranges) und Listenkomprehensionen an.
Rekursion Der elementarste Baustein für die Listenkonstruktion ist der Operator :, der Kopf und Rest zu einer Liste kombiniert. Wir haben diesen Operator schon beim Pattern-Matching gesehen, als wir eine rekursive Funktion aufriefen. Hier steht : auf der linken Seite eines let: Prelude> let h:t = [1, 2, 3] Prelude> h 1 Prelude> t [2,3]
Wir können : auch zu Konstruktion statt zur Dekonstruktion nutzen. Das kann etwa so aussehen: Prelude> 1:[2, 3] [1,2,3]
Denken Sie daran, dass Listen homogen sind. Sie können beispielsweise eine Liste nicht in eine Liste von Integer-Werten einfügen: Prelude> [1]:[2, 3] :1:8: No instance for (Num [t]) arising from the literal `3' at :1:8
300 Kapitel 8: Haskell Sie können aber eine Liste zu einer Liste von Listen (selbst einer leeren Liste) hinzufügen: Prelude> [1]:[[2], [3, 4]] [[1],[2],[3,4]] Prelude> [1]:[] [[1]]
Hier sehen Sie die Listenkonstruktion in Aktion. Nehmen wir an, Sie wollen eine Funktion entwickeln, die die geraden Zahlen in einer Liste zurückgibt. Eine Möglichkeit, eine solche Funktion zu entwickeln, ist die Listenkonstruktion: haskell/all_even.hs
module Main allEven allEven allEven
where :: [Integer] -> [Integer] [] = [] (h:t) = if even h then h:allEven t else allEven t
Unsere Funktion nimmt eine Liste von Integer-Werten und gibt eine Liste gerader Integer-Werte zurück. allEven für eine leere Liste ergibt die leere Liste. Sind eine Liste vorhanden und deren Kopf gerade, fügen wir den Kopf zu allEven hinzu, was auf den Rest angewandt wird. Ist der Kopf ungerade, verwerfen wir ihn, indem wir allEven für den Rest aufrufen. Kein Problem. Sehen wir uns andere Möglichkeiten an, Listen zu erzeugen.
Ranges und Composition Wie Ruby und Scala kennt Haskell Wertebereiche (Ranges) und etwas syntaktischen Zucker zu deren Unterstützung. Haskell kennt eine einfache Form, die aus Anfangs- und Endpunkt des Bereichs besteht: Prelude> [1..2] [1,2] Prelude> [1..4] [1,2,3,4]
Sie geben den Anfangs- und Endpunkt an, und Haskell berechnet den Wertebereich. Das Standardinkrement ist 1. Was passiert, wenn Haskell den Endpunkt mit dem Standardinkrement nicht erreichen kann? Prelude> [10..4] []
Sie erhalten eine leere Liste. Sie können ein Inkrement festlegen, indem Sie das nächste Element der Liste angeben: Prelude> [10, 8 .. 4] [10,8,6,4]
Tag 1: Logisch 301 Sie können auch mit Brüchen arbeiten: Prelude> [10, 9.5 .. 4] [10.0,9.5,9.0,8.5,8.0,7.5,7.0,6.5,6.0,5.5,5.0,4.5,4.0]
Ranges sind syntaktischer Zucker für die Erzeugung von Sequenzen. Die Sequenzen müssen nicht gebunden sein. Wie bei Clojure können Sie sich einzelne Elemente der Sequenz herauspicken: Prelude> take 5 [ 1 ..] [1,2,3,4,5] Prelude> take 5 [0, 2 ..] [0,2,4,6,8]
Wir werden uns an Tag 2 über Lazy Sequences unterhalten. Jetzt wollen wir uns noch eine andere Möglichkeit ansehen, Listen automatisch zu generieren: Listenkomprehension.
Listenkomprehension Wir haben uns Listenkomprehensions erstmals im Erlang-Kapitel angesehen. Bei Haskell funktioniert sie auf die gleiche Weise. Auf der rechten Seite sehen Sie (genau wie bei Erlang) Generatoren und Filter. Sehen wir uns einige Beispiele an. Um alle Elemente einer Liste zu quadrieren, machen wir Folgendes: Prelude> [x * 2 | x <- [1, 2, 3]] [2,4,6]
Auf gut Deutsch bedeutet die Listenkomprehension: „Sammle x*2, wobei x aus der Liste [1, 2, 3] stammt.“ Wie bei Erlang können wir innerhalb unserer Listenkomprehensionen auch Mustererkennung nutzen. Nehmen wir an, wir besitzen eine Liste von Punkten, die ein Polygon repräsentieren. Wir wollen dieses Polygon nun diagonal kippen. Wir können x und y einfach austauschen: Prelude> [ (y, x) | (x, y) <- [(1, 2), (2, 3), (3, 1)]] [(2,1),(3,2),(1,3)]
Oder wir können, um das Polygon horizontal zu kippen, x von 4 abziehen: Prelude> [ (4 - x, y) | (x, y) <- [(1, 2), (2, 3), (3, 1)]] [(3,2),(2,3),(1,1)]
Wir können auch Kombinationen berechnen. Nehmen wir an, Sie wollen alle möglichen Zweier-Außenteams für Kirk, Spock und McCoy ermitteln:
302 Kapitel 8: Haskell Prelude> let crew = ["Kirk", "Spock", "McCoy"] Prelude> [(a, b) | a <- crew, b <- crew] [("Kirk","Kirk"),("Kirk","Spock"),("Kirk","McCoy"), ("Spock","Kirk"),("Spock","Spock"),("Spock","McCoy"), ("McCoy","Kirk"),("McCoy","Spock"),("McCoy","McCoy")]
Diese Komposition funktioniert, entfernt aber keine Duplikate. Wir können Bedingungen einfügen, um die Listenkomprehensionen zu filtern: Prelude> [(a, b) | a <- crew, b <- crew, a /= b] [("Kirk","Spock"),("Kirk","McCoy"),("Spock","Kirk"), ("Spock","McCoy"),("McCoy","Kirk"),("McCoy","Spock")]
Das ist etwas besser, aber noch etwas unordentlich. Wir können es noch besser machen, indem wir nur die sortierten Optionen aufnehmen und den Rest ignorieren: Prelude> [(a, b) | a <- crew, b <- crew, a < b] [("Kirk","Spock"),("Kirk","McCoy"),("McCoy","Spock")]
Mit einer kurzen, einfachen Listenkomprehension haben wir die Antwort. Listenkomprehensionen sind ein großartiges Werkzeug zur schnellen Erzeugung und Transformation von Listen.
Ein Interview mit Philip Wadler Nachdem Sie einige Grundfeatures von Haskell kennengelernt haben, wollen wir sehen, was ein Mitglied des Komitees zu sagen hat, das Haskell entworfen hat. Philip Wadler, Professor für theoretische Informatik an der University of Edinburgh, wirkt nicht nur an Haskell aktiv mit, sondern auch an Java und XQuery. Zuvor hat er bei den Avaya Labs, den Bell Labs, in Glasgow, in Chalmers, in Oxford, an der Carnegie Mellon University, bei Xerox Parc und in Stanford gearbeitet bzw. geforscht. Bruce Tate: Warum hat Ihr Team Haskell entwickelt? Philip Wadler: In den späten 1980ern gab es eine große Zahl von Gruppen, die an Designs und Implementierungen funktionaler Sprachen arbeiteten. Wir erkannten, dass wir stärker sein würden, wenn wir zusammenarbeiteten statt unabhängig voneinander. Die ursprünglichen Ziele waren nicht gerade bescheiden: Die Sprache sollte die Grundlage der Forschung bilden und für den Unterricht sowie die Industrie geeignet sein. Die gesamte Geschichte wird detailliert in einem Aufsatz beschrieben, den wir für die „History of Programming Languages“-Konferenz geschrieben haben. Sie finden ihn im Web.2
2
http:// www.haskell.org/ haskellwiki/ History_of_Haskell
Tag 1: Logisch 303 Bruce Tate: Was mögen Sie an Haskell am liebsten? Philip Wadler: Ich genieße die Programmierung mit Listenkomprehensionen. Es ist schön, dass sie letztlich den Weg in andere Sprachen wie Python gefunden haben. Typklassen bieten eine einfache Form der generischen Programmierung. Sie definieren einen Datentyp und bekommen mit nur einem zusätzlichen Schlüsselwort, derived, Routinen zum Vergleich von Werten, zur Konvertierung von Werten in und aus Strings und so weiter. Ich finde das sehr bequem und vermisse es, wenn ich andere Sprachen benutze. Jede gute Programmiersprache hat die Fähigkeit, sich selbst zu erweitern, um andere Programmiersprachen einzubetten, die auf eine bestimmte Aufgabe spezialisiert sind. Haskell ist besonders gut als Tool zur Einbettung anderer Sprachen geeignet. Laziness, Lambda-Ausdrücke, Monaden und Pfeilnotation, Typklassen, das ausdrucksstarke Typsystem und Template-Haskell unterstützen die Erweiterung der Sprache auf verschiedene Art und Weise. Bruce Tate: Was würden Sie ändern, wenn Sie noch einmal von vorne beginnen müssten? Philip Wadler: Da verteiltes Rechnen immer wichtiger wird, müssen wir uns auf Programme konzentrieren, die auf mehreren Maschinen laufen und sich untereinander Werte senden. Wenn Sie einen Wert senden, wollen Sie üblicherweise den Wert selbst („eager evaluation“) und kein Programm (samt der Werte aller freien Variablen des Programms), das evaluiert werden kann, um den Wert zu berechnen. Ich denke daher, dass es in der verteilten Welt besser wäre, standardmäßig Eager Evaluation zu benutzen. Doch es sollte auch einfach sein, Lazy Evaluation zu nutzen, wenn man möchte. Bruce Tate: Was war das interessanteste Problem, dessen Lösung Sie in Haskell gesehen haben? Philip Wadler: Ich bin immer hin und weg, wenn ich sehe, welche Verwendungsmöglichkeiten Leute für Haskell finden. Vor Jahren war ich einmal äußerst beeindruckt darüber, wie Haskell für die Verarbeitung natürlicher Sprache genutzt wurde, und Jahre später noch einmal beim Protein-Folding in einer Anwendung im Kampf gegen AIDS. Ich habe mir gerade die Seite der Haskell-Community angesehen, die vierzig industrielle Anwendungen von Haskell aufführt. Es gibt jetzt viele Anwender in der Finanzwelt: ABN Amro, Credit Suisse, Deutsche Bank und Standard Chartered. Facebook nutzt Haskell für ein hausinternes
304 Kapitel 8: Haskell Tool zur Aktualisierung von PHP-Code. Einer meiner Favoriten ist die Verwendung von Haskell bei der Garbage Collection, nicht der Garbage Collection, die wir von Software kennen, sondern echte „Garbage Collection“: Programmiergeräte, die in Müllwagen eingesetzt werden!
Was wir an Tag 1 gelernt haben Haskell ist eine funktionale Programmiersprache. Ihr erstes besonderes Merkmal ist, dass es sich um eine rein funktionale Sprache handelt. Eine Funktion mit den gleichen Argumenten erzeugt immer das gleiche Ergebnis. Es gibt keine Nebenwirkungen. Wir haben den größten Teil des ersten Tages mit Features verbracht, die Sie auch bei anderen Sprachen dieses Buches gesehen haben. Wir haben zuerst elementare Ausdrücke und einfache Datentypen betrachtet. Da es keine veränderlichen Variablenzuweisungen gibt, haben wir Rekursion eingesetzt, um einfache mathematische Funktionen zu definieren und Listen zu verarbeiten. Wir haben mit grundlegenden Haskell-Ausdrücken gearbeitet und uns zu Funktionen vorgearbeitet. Wir haben eine Mustererkennung und Guards gesehen, die wir von Erlang und Scala her kennen. Wir haben (wie bei Erlang) Listen und Tupel als elementare Collections verwendet. Abschließend haben wir uns die Generierung von Listen angesehen, was uns zu Listenkomprehensionen, Ranges und sogar Lazy Sequences brachte. Lassen Sie uns einige dieser Ideen in die Praxis umsetzen.
Tag 1: Selbststudium Wenn Sie die anderen funktionalen Sprachen durchgearbeitet haben, sollte Ihnen mittlerweile das Schreiben funktionaler Programme leichter fallen. In diesem Abschnitt werde ich Sie etwas mehr fordern. Finden Sie Folgendes: 앫
das Haskell-Wiki und
앫
eine Haskell-Onlinegruppe, die den Compiler Ihrer Wahl unterstützt.
Tun Sie Folgendes: 앫
Finden Sie möglichst viele verschiedene Möglichkeiten, allEven zu implementieren.
Tag 2: Spocks große Stärke 305
8.3
앫
Schreiben Sie eine Funktion, die eine Liste nimmt und in umgekehrter Reihenfolge zurückgibt.
앫
Schreiben Sie eine Funktion, die ein Zweiertupel aller möglichen Kombinationen der Farben schwarz, weiß, blau, gelb und rot zurückgibt. Sie sollten dabei jeweils nur eine Kombination von zwei Farben berücksichtigen, also zum Beispiel (schwarz, blau) oder (blau, schwarz).
앫
Schreiben Sie eine Listenkomprehension, die das kleine Einmaleins erzeugt. Die Tabelle soll eine Liste aus Dreiertupeln sein, bei denen die ersten beiden Werte ganze Zahlen von 1 bis 12 sind und der dritte das Produkt der ersten beiden.
앫
Lösen Sie das „Karteneinfärbeproblem“ (siehe Abschnitt , Karten einfärben auf Seite 104) in Haskell.
Tag 2: Spocks große Stärke Bei einigen Rollen bemerkt man die besten Eigenschaften erst nach einiger Zeit. Bei Spock ist es leicht, seine großen Stärken zu erkennen: Er ist brilliant, immer logisch und immer vorhersehbar. Haskells große Stärken sind ebenfalls diese Vorhersehbarkeit und die Einfachheit der Logik. Viele Universitäten lehren Haskell im Kontext der Beschäftigung mit Programmiertheorie. Haskell macht den Nachweis der Korrektheit wesentlich einfacher als die „imperativen“ Vertreter. In diesem Abschnitt tauchen wir in die Konzepte ein, die zu einer besseren Vorhersehbarkeit führen. Wir werden mit Funktionen höherer Ordnung beginnen. Dann werden wir über Haskells Strategie reden, diese zu kombinieren. Das bringt uns zu „Partially Applied Functions“ und zum Currying. Abschließend sehen wir uns „Lazy Computation“ an. Es wird ein langer Tag, also fangen wir an.
Funktionen höherer Ordnung Jede Sprache in diesem Buch setzt sich mit dem Konzept der Programmierung höherer Ordnung auseinander. Haskell ist davon besonders abhängig. Wir werden uns rasch durch anonyme Funktionen arbeiten und diese dann auf die vielen eingebauten Funktionen anwenden, die mit Listen arbeiten. Ich werde das sehr viel schneller durchgehen als bei den anderen Sprachen, weil Sie diese Konzepte jetzt schon kennen und wir so viele Dinge zu behandeln haben. Wir beginnen mit anonymen Funktionen.
306 Kapitel 8: Haskell
Anonyme Funktionen Wie Sie es wohl erwarten, verwenden anonyme Funktionen bei Haskell eine lächerlich einfache Syntax. Die Form ist (\param1 .. paramn -> funktiona_rumpf). Probieren Sie es aus: Prelude> (\x -> x) "Logical." "Logical." Prelude> (\x -> x ++ " captain.") "Logical," "Logical, captain."
Für sich genommen, bringen sie nicht viel. Doch in Kombination mit anderen Funktionen werden Sie extrem mächtig.
map und where Zuerst haben wir eine anonyme Funktion geschrieben, die einfach nur den ersten Parameter zurückgab. Danach haben wir einen String angehängt. Wie Sie bei den anderen Sprachen bereits gesehen haben, sind anonyme Funktionen ein wichtiges Feature für Listenbibliotheken. Haskell kennt ein map: map (\x -> x * x) [1, 2, 3]
Wir wenden die map-Funktion auf eine anonyme Funktion und eine Liste an. map wendet die anonyme Funktion auf jedes Element der Liste an und sammelt die Ergebnisse ein. Es gibt hier keine Überraschungen, aber die Form zu verdauen, könnte etwas zu viel sein. Wir können das alles in eine Funktion packen und die anonyme Funktion als Funktion mit lokalem Geltungsbereich realisieren: haskell/map.hs
module Main where squareAll list = map square list where square x = x * x
Wir haben eine Funktion namens squareAll definiert, die einen Parameter namens list verlangt. Als Nächstes haben wir map verwendet, um eine Funktion namens square auf alle Elemente in list anzuwenden. Dann nutzen wir ein neues Feature namens where, um eine lokale Version von square zu deklarieren. Sie müssen keine Funktionen mit where binden. Sie können auch jede Variable binden. Im weiteren Verlauf des Kapitels werden Sie immer wieder Beispiele für where finden.
Tag 2: Spocks große Stärke 307 Hier das Ergebnis: *Main> :load map.hs [1 of 1] Compiling Main Ok, modules loaded: Main. *Main> squareAll [1, 2, 3] [1,4,9]
( map.hs, interpreted )
Sie können map auch mit einem Teil einer Funktion verwenden, einer sogenannten Section: Prelude> map (+ 1) [1, 2, 3] [2,3,4]
Tatsächlich ist (+ 1) eine sogenannte „partiell angewandte Funktion“ (partially applied function). Die Funktion + verlangt zwei Argumente, wir haben aber nur eines übergeben. Das Ergebnis ist eine Funktion wie (x + 1) mit einem einzelnen Parameter x.
filter, foldl, foldr Die nächste gängige Funktion ist filter, die einen Test auf die Elemente einer Liste anwendet: Prelude> odd 5 True Prelude> filter odd [1, 2, 3, 4, 5] [1,3,5]
Wie bei Clojure und Scala gibt es auch ein foldl und ein foldr. Die Funktionen, die Sie verwenden werden, sind Varianten von foldl und foldr: Prelude> foldl (\x carryOver -> carryOver + x) 0 [1 .. 10] 55
Wir haben für Carry-over zu Beginn den Wert 0 verwendet und die Funktion dann auf jedes Element der Liste angewandt. Das Ergebnis der Funktion wurde als carryOver -Argument für jedes weitere Element der Liste verwendet. Eine andere Form von fold ist praktisch, wenn das Folding mit einem anderen Operator erfolgt: Prelude> foldl1 (+) [1 .. 3] 6
Hier verwenden Sie den Operator + als reine Funktion mit zwei Parametern, die einen Integer-Wert zurückgibt. Das Ergebnis entspricht der Evaluierung von Prelude> 1 + 2 + 3 6
308 Kapitel 8: Haskell Das Folding ist mit foldr1 auch von rechts nach links möglich. Wie Sie sich vorstellen können, bietet Haskell in seiner Listenbibliothek viele weitere Funktionen an, und viele davon sind Funktionen höherer Ordnung. Statt ein ganzes Kapitel dem Umgang mit diesen Funktionen zu widmen, überlasse ich das lieber Ihrer eigenen Entdeckungslust. Jetzt werde ich Ihnen zeigen, wie Haskell Funktionen zur Zusammenarbeit bewegt.
Partiell angewandte Funktionen und Currying Wir haben kurz über die Funktionskomposition und „Partially Applied Functions“ gesprochen. Diese Konzepte sind für Haskell wichtig und elementar genug, um ihnen etwas mehr Zeit zu widmen. Jede Funktion hat bei Haskell einen Parameter. Sie könnten sich fragen: „Wenn das stimmt, wie kann man dann eine Funktion wie + schreiben, die zwei Zahlen addiert?“ Es stimmt tatsächlich: Jede Funktion hat einen Parameter. Um die Typsyntax zu vereinfachen, wollen wir eine Funktion namens prod entwickeln: Prelude> let prod x y = x * y Prelude> prod 3 4 12
Wir haben eine Funktion angelegt und sie funktioniert auch, wie Sie sehen. Lassen Sie uns den Typ der Funktion bestimmen: Prelude> :t prod prod :: (Num a) => a -> a -> a
Das Num a => bedeutet „in der folgenden Typdefinition ist a vom Typ Num“. Den Rest haben Sie schon mal gesehen, und ich habe bei der Erläuterung seiner Bedeutung ein wenig gemogelt, um das Ganze zu vereinfachen. Jetzt ist es an der Zeit, einiges zurechtzurücken. Haskell verwendet ein Konzept, das eine Funktion mit mehreren Argumenten in mehrere Funktionen mit jeweils einem Argument zerlegt. Haskell erledigt diese Aufgabe über eine partielle Anwendung (partial application). Lassen Sie sich durch diesen Begriff nicht verwirren. Die partielle Anwendung bindet einige Argumente, aber nicht alle. Zum Beispiel können wir prod partiell anwenden, um andere Funktionen aufzubauen: Prelude> let double = prod 2 Prelude> let triple = prod 3
Tag 2: Spocks große Stärke 309 Sehen Sie sich zuerst die linke Seite dieser Funktionen an. Wir haben prod mit zwei Parametern definiert, doch nur den ersten angewandt. Die Berechnung von prod 2 ist einfach. Nehmen Sie einfach die Ursprungsfunktion prod x y = x * y, setzen Sie 2 für x ein, und schon haben Sie prod y = 2 * y. Die Funktionen funktionieren wie erwartet: Prelude> double 3 6 Prelude> triple 4 12
Das Mysterium ist also gelöst. Wenn Haskell prod 2 4 berechnet, berechnet es in Wirklichkeit (prod 2) 4: 앫
Zuerst wird prod 2 angewandt. Das gibt die Funktion (\y -> 2 * y) zurück.
앫
Als Nächstes wird (\y -> 2 * y) 4, also 2* 4, angewandt, was 8 ergibt.
Dieser Prozess wird Currying genannt; fast jede Multiargument-Funktion in Haskell nutzt ihn. Das führt zu größerer Flexibilität und einfacherer Syntax. Meistens müssen Sie darüber nicht weiter nachdenken, da das Ergebnis der Funktion in beiden Fällen gleich ist.
Lazy Evaluation Wie Clojures Sequenzbibliothek macht auch Haskell reichlich Gebrauch von der Lazy Evaluation. Mit ihr können Sie Funktionen entwickeln, die unendliche Listen zurückgeben. Häufig werden Sie die Listenkonstruktion benutzen, um eine unendliche Liste zu erzeugen. Betrachten Sie das folgende Beispiel, das einen unendlichen Wertebereich erzeugt, der bei x beginnt und in Schritten von y wächst: haskell/my_range.hs
module Main where myRange start step = start:(myRange (start + step) step)
Die Syntax ist etwas befremdlich, doch der Effekt ist sehr schön. Wir haben eine Funktion namens myRange entwickelt, die einen Startpunkt und die Schrittweite verlangt. Wir nutzen die Listenkomposition, um eine Liste zu erzeugen, die beim Kopf beginnt und (myRange (start + step) step) als Rest verwendet. Hier die aufeinanderfolgende Evaluierung von myRange 1 1: 앫 1:myRange (2 1)
310 Kapitel 8: Haskell 앫 1:2:myRange (3 1) 앫 1:2:3:myRange (4 1)
... und so weiter. Diese Rekursion läuft unendlich weiter, weshalb wir sie üblicherweise zusammen mit einer anderen Funktion verwenden, die die Rekursion einschränkt. Stellen Sie sicher, dass my_range.hs zuerst geladen wird: *Main> take 10 (myRange 10 1) [10,11,12,13,14,15,16,17,18,19] *Main> take 5 (myRange 0 5) [0,5,10,15,20]
Einige rekursive Funktionen sind bei der Listenkonstruktion effizienter. Hier als Beispiel die Fibonacci-Folge mit Lazy Evaluation und Listenkomposition: haskell/lazy_fib.hs
module Main where lazyFib x y = x:(lazyFib y (x + y)) fib = lazyFib 1 1 fibNth x = head (drop (x - 1) (take (x) fib))
Die erste Funktion baut eine Folge auf, bei der jede Zahl die Summe der beiden vorherigen Zahlen ist. Wir haben effektiv eine Sequenz, aber wir können die API verbessern. Um eine korrekte Fibonacci-Folge zu sein, muss sie mit 1 und 1 beginnen, weshalb fib lazyFib sie mit den ersten beiden Werten füttert. Schließlich gibt es noch eine weitere Hilfsfunktion, die es dem Benutzer erlaubt, eine einzelne Zahl aus der Folge abzurufen, wozu sie drop und take nutzt. Hier die Funktionen in Aktion: *Main> take 5 (lazyFib 0 1) [1,1,2,3,5] *Main> take 5 (fib) [1,1,2,3,5] *Main> take 5 (drop 20 (lazyFib 0 1)) [10946,17711,28657,46368,75025] *Main> fibNth 3 2 *Main> fibNth 6 8
Die drei Funktionen sind schön und kompakt. Wir definieren eine unendliche Sequenz, und Haskell berechnet nur den Teil, der notwendig ist, um die Aufgabe zu erledigen. Sie werden Ihren Spaß haben, wenn Sie damit beginnen, unendliche Sequenzen miteinander zu kom-
Tag 2: Spocks große Stärke 311 binieren. Zuerst wollen wir zwei Fibonacci-Folgen miteinander verknüpfen, die um ein Element verschoben sind: *Main> take 5 (zipWith (+) fib (drop 1 fib)) [2,3,5,8,13]
Überraschung! Wir erhalten eine Fibonacci-Folge. Diese Funktionen höherer Ordnung arbeiten gut zusammen. Wir haben zipWith aufgerufen, das jedes Element der unendlichen Liste nach Index paart. Wir haben ihm die Funktion + übergeben. Wir könnten den Bereich auch verdoppeln: *Main> take 5 (map (*2) [1 ..]) [2,4,6,8,10]
Wir haben map verwendet, um die partiell angewandte Funktion *2 auf den unendlichen Wertebereich [1 ..] anzuwenden. Dann verwenden wir den unendlichen Wertebereich beginnend mit 1. Das Schöne an funktionalen Sprachen ist, dass man Dinge auf unerwartete Art und Weise kombinieren kann. Zum Beispiel können wir mühelos Funktionskomposition zusammen mit partiell angewandten Funktionen und Lazy Sequences verwenden: *Main> take 5 (map ((* 2). (* 5)) fib) [10,10,20,30,50]
Dieser Code packt einiges rein, weshalb wir ihn uns genauer ansehen wollen. Wir arbeiten uns von innen nach außen und sehen zuerst (* 5). Das ist eine partiell angewandte Funktion. Was auch immer wir der Funktion übergeben, wird mit 5 multipliziert. Wir übergeben das Ergebnis an eine weitere partielle Funktion, (* 2). Diese zusammengesetzte Funktion übergeben wir an map und wenden sie auf jedes Element der unendlichen fib-Sequenz an. Wir übergeben das unendliche Ergebnis an take 5 und erzeugen die ersten fünf Elemente einer Fibonacci-Folge, multipliziert mit fünf und dann noch mal multipliziert mit zwei. Sie können leicht erkennen, wie man Lösungen für Probleme zusammenstellt. Sie übergeben einfach eine Funktion an die nächste. Bei Haskell ist f . g x die Abkürzung für f(g x). Wenn Sie Funktionen auf diese Weise aufbauen, sollten Sie sie vom Anfang bis zum Ende anwenden. Sie machen das mit dem .-Operator. Soll ein Image beispielsweise invertiert, vertikal und dann horizontal gekippt werden, würde die Bildverarbeitung so etwas wie (flipHorizontally . flipVertically . invert) image ausführen.
312 Kapitel 8: Haskell
Ein Interview mit Simon Peyton-Jones Wir wollen eine kurze Pause machen, um eine weitere Person des Komitees zu Wort kommen zu lassen, das Haskell entwickelt hat. Simon Peyton Jones verbrachte sieben Jahre als Dozent am University College London und neun Jahre als Professor an der Glasgow University, bevor er 1998 zu Microsoft Research nach Cambridge wechselte. Seine Forschungsarbeit konzentriert sich auf die Implementierung und Anwendung funktionaler Programmiersprachen für Ein- und Multiprozessorsysteme. Er ist der Chefdesigner des in diesem Buch verwendeten Compilers. Bruce Tate: Erzählen Sie uns etwas über die Entwicklung von Haskell. Simon Peyton-Jones: Eine sehr ungewöhnliche Sache an Haskell ist, dass es sich um eine erfolgreiche Komiteesprache handelt. Denken Sie an andere erfolgreiche Sprachen, und es ist recht wahrscheinlich, dass sie von einer einzelnen Person oder einem kleinen Team entwickelt wurde. Haskell ist anders: Es wurde ursprünglich von einer internationalen Gruppe aus etwa 20 Forschern entworfen. Wir hatten genug Einigkeit über die Grundsätze der Sprache erzielt, und Haskell ist eine Sprache mit hohen Grundsätzen, um das Design kohärent halten zu können. Darüber hinaus erlebt Haskell 20 Jahre, nachdem es entworfen wurde, einen deutlichen Anstieg der Popularität. Sprachen sind üblicherweise in den ersten Jahren nach ihrem Entwurf erfolgreich (oder eben nicht), was passiert hier also? Ich glaube, dass Haskells grundsätzliches Festhalten an Reinheit, dem Fehlen von Nebenwirkungen, ein unbekannter Wissenszweig ist, der verhindert hat, dass Haskell zu einer Mainstream-Sprache wurde. Diese langfristigen Vorteile werden schrittweise offensichtlich. Ob nun die Mainstream-Sprachen der Zukunft wie Haskell aussehen werden oder nicht, ich glaube, sie werden starke Mechanismen zur Kontrolle der Nebenwirkungen besitzen. Bruce Tate: Was mögen Sie am liebsten? Simon Peyton-Jones: Abgesehen von der Reinheit ist das ungewöhnlichste und interessanteste Feature an Haskell vielleicht sein Typsystem. Statische Typen sind heutzutage die mit Abstand häufigste Technik zur Programmverifikation. Millionen von Programmierern geben jeden Tag Typen an (die bloß partielle Spezifikationen sind), und Compiler überprüfen sie jedes Mal, wenn sie ein Programm kompilieren. Typen sind die UML funktionaler Programmierung: eine Entwurfssprache, die einen inneren, permanenten Teil des Programms bildet.
Tag 2: Spocks große Stärke 313 Vom ersten Tag an war Haskells Typsystem ungewöhnlich ausdrucksstark, hauptsächlich aufgrund der Typklassen und Typvariablen höherer Art. Seither war Haskell eine Art Labor, in dem neue Typsystemkonzepte untersucht wurden. Das war etwas, das ich sehr genossen habe. Multiparameter-Typklassen, Typen höheren Ranges, Polymorphismus erster Klasse, implizite Parameter, GADTs und Typfamilien ... wir hatten unseren Spaß! Und was noch wichtiger ist: Wir erweitern den Bereich der Eigenschaften, die vom Typsystem statisch geprüft werden können. Bruce Tate: Was würden Sie ändern, wenn Sie noch einmal von vorne beginnen könnten? Simon Peyton-Jones: Ich hätte gerne ein besseres Record-System. Es gibt Gründe dafür, dass Haskells Record-System so schlicht ist, aber es ist dennoch ein Schwachpunkt. Ich hätte gerne ein besseres Modulsystem. Im Besonderen wäre ich gern in der Lage, ein Haskell-Paket P an jemanden auszuliefern und zu sagen: „P muss von irgendwo die Interfaces I und J importieren. Sie stellen sie zur Verfügung und es bietet Ihnen das Interface K.“ Haskell besitzt keine formale Möglichkeit, das auszudrücken. Bruce Tate: Was war das interessanteste Problem, dessen Lösung Sie in Haskell gesehen haben? Simon Peyton-Jones: Haskell ist eine echte Allzwecksprache. Das ist eine Stärke, aber auch eine Schwäche, weil es keine einzelne „Killer App“ gibt. Gleichwohl scheint Haskell ein Medium zu sein, mit dem man besonders elegante und ungewöhnliche Wege zur Lösung von Problemen finden kann. Sehen Sie sich zum Beispiel Conal Elliots Arbeit zur funktionalen reaktiven Animation an, die mich über einen „zeitvariierenden Wert“ nachdenken ließ. Das ist ein einzelner Wert, der durch ein funktionales Programm manipuliert werden kann. Auf einer etwas banaleren (aber sehr nützlichen) Ebene gibt es viele Bibliotheken mit Parser- und Pretty-Printing-Kombinatoren, die komplexe intelligente Überlegungen hinter einfachen Interfaces verstecken. In einer dritten Domäne zeigte mir Jean-Marc Eber, wie man eine kombinatorische Bibliothek zur Beschreibung von Finanzderivaten entwirft. Das ist etwas, worauf ich selbst nie gekommen wäre. In allen Fällen hat das Medium (Haskell) eine neue Ebene des Ausdrucks erlaubt, die mit einer Mainstream-Sprache nur schwer hätte erreicht werden können.
314 Kapitel 8: Haskell Bis hierher wissen wir genug, um einige schwierige Probleme mit Haskell angehen zu können, aber mit einfachen Dingen wie Ein-/Ausgabe, Zuständen und Fehlerbehandlung können wir noch nicht umgehen. Diese Dinge bringen uns zu etwas fortgeschrittener Theorie: Am dritten Tag werden wir uns Monaden ansehen.
Was wir an Tag 2 gelernt haben Am zweiten Tag haben wir uns Funktionen höherer Ordnung angesehen. Wir haben mit den gleichen Listenbibliotheken begonnen, die wir bei fast allen Sprachen in diesem Buch gesehen haben. Sie haben map, verschiedene Versionen von fold und einige zusätzliche Funktionen wie zip und zipWith kennengelernt. Nachdem wir sie mit einigen festen Listen verwendet haben, benutzten wir einige „Lazy“-Techniken, die wir schon bei Clojure kennengelernt hatten. Als wir ein paar fortgeschrittene Funktionen durchgegangen sind, haben wir gesehen, wie man eine Funktion nimmt und einige der Parameter anwendet. Diese Technik wird partiell angewandte Funktionen (partially applied functions) genannt. Wir haben dann partiell angewandte Funktionen benutzt, um eine Funktion mit mehreren Argumenten (f (x, y)) in eine Funktion umzuwandeln, die jeweils ein Argument verarbeitet (f(x)(y)). Wir haben gelernt, dass bei Haskell alle Funktionen Currying nutzen, was die Typsignatur von Haskell-Funktionen erklärt, die mehrere Argumente verlangen. So lautet beispielsweise die Typsignatur der Funktion fxy = x+ y f :: (Num a) =>a -> a -> a. Wir haben auch die Funktionskomposition kennengelernt. Dieser Prozess nutzt den Rückgabewert einer Funktion als Eingabe für eine andere Funktion. Wir konnten auf diese Weise Funktionen effektiv miteinander verknüpfen. Abschließend haben wir die Lazy Evaluation benutzt. Wir konnten Funktionen definieren, die unendliche Listen erzeugten, die dann nach Bedarf verarbeitet wurden. Wir haben auf diese Weise eine FibonacciFolge definiert und die Komposition zusammen mit Lazy Sequences verwendet, um völlig mühelos neue Lazy Sequences zu erzeugen.
Tag 2: Spocks große Stärke 315
Tag 2: Selbststudium Finden Sie Folgendes: 앫
Funktionen, die Sie auf Listen, String oder Tupel anwenden können, und
앫
eine Möglichkeit, Listen zu sortieren.
Tun Sie Folgendes: 앫
Schreiben Sie ein Sortierprogramm, das eine Liste nimmt und sortiert zurückgibt.
앫
Schreiben Sie ein Sortierprogramm, das eine Liste nimmt sowie eine Funktion, die ihre beiden Argumente vergleicht.
앫
Schreiben Sie eine Haskell-Funktion, die einen String in eine Zahl umwandelt. Der String soll die Form $2,345,678.99 haben und darf führende Nullen enthalten.
앫
Schreiben Sie eine Funktion, die ein Argument x verlangt und eine Lazy Sequence zurückgibt, die jede dritte Zahl enthält, beginnend mit x. Schreiben Sie dann eine Funktion, die jede fünfte Zahl einfügt, beginnend mit y. Kombinieren Sie diese Funktionen über Funktionskomposition, um jede achte Zahl zurückzugeben, beginnend mit x+ y.
앫
Nutzen Sie eine partiell angewandte Funktion, um eine Funktion zu definieren, die den Wert einer Zahl durch zwei zurückgibt. Schreiben Sie eine weitere Funktion dieser Art, die \n ans Ende jedes Strings anhängt.
Hier noch einige weitere anspruchsvollere Probleme (falls Sie nach etwas noch Interessanterem Ausschau halten): 앫
Schreiben Sie eine Funktion, die den größten gemeinsamen Nenner zweier Integer-Werte bestimmt.
앫
Entwickeln Sie eine Lazy Sequence für Primzahlen.
앫
Zerlegen Sie einen langen String an den korrekten Wortgrenzen in einzelne Wörter (jeweils in einer separaten Zeile).
앫
Fügen Sie in die obige Übung Zeilennummern ein.
앫
Fügen Sie noch Funktionen zur links- und rechtsbündigen und zentrierten Ausrichtung des Textes mit Leerzeichen ein.
316 Kapitel 8: Haskell
8.4
Tag 3: Gedankenverschmelzung Bei „Star Trek“ besaß Spock die besondere Fähigkeit zur Gedankenverschmelzung, mit der er die Verbindung zu einer anderen Person herstellen konnte. Haskell-Fans beanspruchen für sich gerne eine solche Verbindung zu ihrer Sprache. Für viele ist das Typsystem das Sprachfeature, das den größten Respekt verdient hat. Nachdem ich so viel Zeit mit der Sprache verbracht habe, kann ich erkennen, warum das stimmt. Das Typsystem ist flexibel und mächtig genug, um den Großteil meiner Absichten ableiten zu können. Es steht mir nicht im Weg, solange ich es nicht brauche. Ich bekomme auch eine Plausibilitätsprüfung bei der Entwicklung meiner Funktionen. Das gilt besonders für die Abstrakten, die Funktionen zusammensetzen.
Klassen und Typen Haskells Typsystem ist eine seiner größten Stärken. Es erlaubt die Typinferenz, so dass die Programmierer keine größere Verantwortung haben. Es ist außerdem robust genug, um selbst subtile Programmierfehler abzufangen. Es ist polymorph, Sie können also verschiedene Formen des gleichen Typs gleich behandeln. In diesem Abschnitt sehen wir uns einige Beispiele für Typen an und bauen dann eigene Typen auf.
Grundlegende Typen Fassen wir kurz zusammen, was wir bislang mit einigen grundlegenden Typen gelernt haben. Zuerst aktivieren wir die Typ-Option in der Shell: Prelude> :set +t
Nun sehen wir die Typen, die jede Anweisung zurückgibt. Probieren Sie einige Zeichen und Strings aus: Prelude> 'c' 'c' it :: Char Prelude> "abc" "abc" it :: [Char] Prelude> ['a', 'b', 'c'] "abc" it :: [Char]
it gibt immer den Wert des letzten „Dings“ zurück, das Sie eingegeben haben. Das :: steht für ist vom Typ. Für Haskell ist ein Zeichen ein pri-
mitiver Typ. Ein String ist ein Array von Zeichen. Es spielt keine Rolle, ob Sie das Array von Zeichen als Array oder in doppelten Anführungszeichen angeben. Für Haskell sind die Werte gleich:
Tag 3: Gedankenverschmelzung 317 Prelude> "abc" == ['a', 'b', 'c'] True
Es gibt einige andere Primitive: Prelude> True True it :: Bool Prelude> False False it :: Bool
Während wir tiefer in die Typisierung eintauchen, helfen uns die Konzepte dabei, zu verstehen, was wirklich vor sich geht. Lassen Sie uns unsere eigenen Typen definieren.
Benutzerdefinierte Typen Wir können unsere eigenen Typen mit dem Schlüsselwort data definieren. Die einfachsten Typdeklarationen verwenden eine endliche Liste von Werten. „Boolean“ würde man beispielsweise so definieren: data Boolean = True | False
Das bedeutet, dass der Typ Boolean einen einzelnen Wert besitzt, der True oder False ist. Wir können unsere eigenen Typen genauso definieren. Nehmen wir zum Beispiel ein vereinfachtes Kartenspiel mit zwei Farben und fünf Werten: haskell/cards.hs
module Main where data Suit = Spades | Hearts data Rank = Ten | Jack | Queen | King | Ace
Bei diesem Beispiel sind Suit und Rank Typkonstruktoren. Wir haben data benutzt, um einen neuen benutzerdefinierten Typ zu definieren. Sie können das Modul wie folgt laden: *Main> :load cards.hs [1 of 1] Compiling Main ( cards.hs, interpreted ) Ok, modules loaded: Main. *Main> Hearts :1:0: No instance for (Show Suit) arising from a use of `print' at :1:0-5
Argh! Was ist passiert? Haskell sagt uns, dass die Konsole versucht, diese Werte auszugeben, aber nicht weiß, wie sie das machen soll. Es gibt für benutzerdefinierte Datentypen eine Kurzform zur Ableitung der show-Funktion. Sie funktioniert so:
318 Kapitel 8: Haskell haskell/cards-with-show.hs
module Main where data Suit = Spades | Hearts deriving (Show) data Rank = Ten | Jack | Queen | King | Ace deriving (Show) type Card = (Rank, Suit) type Hand = [Card]
Beachten Sie, dass wir einige Alias-Typen in unser System eingefügt haben. Eine Karte (Card) ist einfach ein Tupel aus Wert (Rank) und Farbe (Suit), und eine Hand ist einfach eine Liste von Karten. Wir können diese Typen nutzen, um neue Funktionen zu entwickeln: value value value value value value
:: Rank -> Integer Ten = 1 Jack = 2 Queen = 3 King = 4 Ace = 5
cardValue :: Card -> Integer cardValue (rank, suit) = value rank
Bei jedem Kartenspiel müssen Sie in der Lage sein, den Karten eine Rangfolge zuzuordnen. Das ist nicht schwer. suit spielt dabei keine Rolle. Wir definieren einfach eine Funktion, die den Wert (value) des Rangs berechnet, und dann eine weitere, die den Kartenwert (cardValue) bestimmt. Hier die Funktion in Aktion: *Main> :load cards-with-show.hs [1 of 1] Compiling Main Ok, modules loaded: Main. *Main> cardValue (Ten, Hearts) 1
( cards-with-show.hs, interpreted )
Wir arbeiten mit einem komplexen Tupel benutzerdefinierter Typen. Das Typsystem macht unsere Absichten deutlich, weshalb man relativ einfach erkennen kann, was passiert.
Funktionen und Polymorphismus Sie haben bereits einige Funktionstypen gesehen. Schauen wir uns eine einfache Funktion an: backwards [] = [] backwards (h:t) = backwards t ++ [h]
Tag 3: Gedankenverschmelzung 319 Wir könnten dieser Funktion einen Typ hinzufügen: backwards :: Hand -> Hand ...
Das würde die backwards-Funktion darauf beschränken, mit nur einer Art von Liste zu arbeiten, nämlich einer Liste von Karten. Was wir wirklich möchten, ist Folgendes: backwards :: [a] -> [a] backwards [] = [] backwards (h:t) = backwards t ++ [h]
Nun ist die Funktion polymorph. [a] bedeutet, dass wir eine Liste jedes Typs verwenden können. Das bedeutet, dass wir eine Funktion definieren können, die eine Liste irgendeines Typs a nimmt und eine Liste genau dieses Typs a zurückgibt. Mit [a] -> [a] haben wir eine Schablone (Template) von Typen geschaffen, die mit unserer Funktion arbeiten werden. Darüber hinaus haben wir dem Compiler gesagt, dass die Funktion auch eine Liste von Integern zurückgeben soll, wenn wir mit einer Liste von Integern beginnen. Haskell hat nun genügend Informationen, um Sie auf der sicheren Seite zu wissen. Lassen Sie uns einen polymorphen Datentyp entwickeln. Hier ist einer, der ein Dreiertupel mit drei Punkten desselben Typs aufbaut: haskell/triplet.hs
module Main where data Triplet a = Trioaaa deriving (Show)
Auf der linken Seite haben wir data Triplet a angegeben. In diesem Fall ist a eine Typvariable. Jetzt ist also jedes Dreiertupel mit Elementen des gleichen Typs vom Typ Triplet a. Sehen Sie es sich an: *Main> :load triplet.hs [1 of 1] Compiling Main Ok, modules loaded: Main. *Main> :t Trio 'a' 'b' 'c' Trio 'a' 'b' 'c' :: Triplet Char
( triplet.hs, interpreted )
Ich habe den Datenkonstruktor Trio zum Aufbau eines Dreiertupels benutzt. Wir gehen im nächsten Abschnitt genauer auf Datenkonstruktoren ein. Basierend auf unserer Typdeklaration war das Ergebnis ein Triplet a, oder genauer gesagt ein Triplet char, und genügt jeder Funktion, die ein Triplet a benötigt. Wir haben eine ganze Schablone von Typen entwickelt, die alle drei Elemente beschreibt, deren Typen gleich sind.
320 Kapitel 8: Haskell
Rekursive Typen Auch rekursive Typen sind möglich. Denken Sie zum Beispiel an einen Baum. Man kann das auf verschiedene Weise realisieren, doch bei unserem Baum liegen die Werte an den Blattknoten. Ein Knoten ist daher entweder ein Blattknoten oder eine Liste von Bäumen. Wir können den Baum wie folgt beschreiben: haskell/tree.hs
module Main where data Tree a = Children [Tree a] | Leaf a deriving (Show)
Wir haben also ein Typkonstruktor Tree. Wir haben noch zwei weitere Typkonstruktoren: Children und Leaf. Zusammen können wir sie wie folgt verwenden, um Bäume zu repräsentieren: Prelude> :load tree.hs [1 of 1] Compiling Main Ok, modules loaded: Main. *Main> let leaf = Leaf 1 *Main> leaf Leaf 1
( tree.hs, interpreted )
Zuerst bauen wir einen Baum mit einem einzelnen Blatt auf. Wir weisen dieses neue Blatt einer Variablen zu. Die einzige Aufgabe des Datenkonstruktors Leaf besteht darin, die Werte zusammen mit dem Typ festzuhalten. Wir können auf jeden Teil mit Pattern-Matching zugreifen: *Main> let (Leaf value) = leaf *Main> value 1
Bauen wir einen etwas komplexeren Baum auf: *Main> Children[Leaf 1, Leaf 2] Children [Leaf 1,Leaf 2] *Main> let tree = Children[Leaf 1, Children [Leaf 2, Leaf 3]] *Main> tree Children [Leaf 1,Children [Leaf 2,Leaf 3]]
Wir bauen einen Baum mit zwei Child-Elementen auf, die jeweils ein Blatt darstellen. Als Nächstes bauen wir einen Baum mit zwei Knoten auf, einem Blatt und einem rechten Baum. Wieder können wir PatternMatching benutzen, um uns die Teile herauszupicken. Nun kann es komplexer werden. Die Definition ist rekursiv, wir können also mit let und Pattern-Matching so tief gehen wie nötig. *Main> let (Children ch) = tree *Main> ch [Leaf 1,Children [Leaf 2,Leaf 3]]
Tag 3: Gedankenverschmelzung 321 *Main> let (fst:tail) = ch *Main> fst Leaf 1
Wir können deutlich die Absichten des Designers des Typsystems erkennen und uns die Teile herauspicken, die wir brauchen, um die Aufgabe zu erledigen. Diese Designstrategie hat natürlich ihren Preis, doch wenn man sich auf höhere Abstraktionsstufen begibt, zeigt sich, dass sie ihn wert ist. In unserem Fall erlaubt uns das Typsystem, Funktionen zu jedem Typkonstruktor hinzuzufügen. Sehen wir uns eine Funktion an, die die Tiefe eines Baums ermittelt: depth (Leaf _) = 1 depth (Children c) = 1 + maximum (map depth c)
Das erste Muster unserer Funktion ist einfach. Handelt es sich um ein Blatt, ist die Tiefe des Baums 1, unabhängig vom Inhalt des Blatts. Das nächste Muster ist etwas komplizierter. Wenn wir depth für Children aufrufen, addieren wir 1 zu maximum (map depth c) hinzu. Die Funktion maximum berechnet das Maximum in einem Array, und wie Sie gesehen haben, berechnet map depth c eine Liste der Tiefen aller ChildElemente. Hier können Sie sehen, wie wir Datenkonstruktoren benutzen, um genau die Datenstrukturen abzubilden, die wir für unsere Aufgabe benötigen.
Klassen Bisher haben wir uns nur das Typsystem angesehen und wie es in einer Reihe von Bereichen funktioniert. Wir haben benutzerdefinierte Typkonstruktoren aufgebaut und Templates erhalten, die es uns erlauben, Datentypen zu definieren und Funktionen zu deklarieren, die mit ihnen arbeiten. Haskell besitzt im Bezug auf Typen noch ein weiteres wichtiges Konzept. Dieses Konzept wird Klasse genannt, aber Vorsicht: Es handelt sich nicht um eine objektorientierte Klasse, da dabei keine Daten im Spiel sind. Bei Haskell erlauben Klassen die genaue Steuerung des Polymorphismus und der Überladung. Beispielsweise können Sie zwei Boolesche Werte nicht addieren, zwei Zahlen hingegen schon. Haskell nutzt zu diesem Zweck Klassen. Genauer gesagt, definiert eine Klasse, welche Operationen mit welchen Eingabewerten funktionieren. Stellen Sie sich das als eine Art ClojureProtokoll vor. Und so funktioniert es: Eine Klasse stellt einige Funktionssignaturen bereit. Ein Typ ist eine Instanz einer Klasse, wenn er all diese Funktio-
322 Kapitel 8: Haskell nen unterstützt. Zum Beispiel gibt es in der Haskell-Biblothek eine Klasse namens Eq.
Abbildung 8.1: Wichtige Haskell-Klassen Das sieht so aus: class Eq a where (==), (/=) :: a -> a -> Bool -- Minimal complete definition: -(==) or (/=) x /= y = not (x == y) x == y = not (x /= y)
Ein Typ ist also eine Instanz von Eq, wenn er == und /= unterstützt. Sie können auch Standardimplementierungen spezifizieren. Und wenn eine Instanz eine dieser Funktionen definiert, gibt es die andere umsonst dazu. Klassen unterstützen Vererbung, und die verhält sich so, wie Sie es erwarten würden. Zum Beispiel besitzt die Num-Klasse die Subklassen Fractional und Real. Die Hierarchie der wichtigsten Haskell-Klassen von Haskell 98 ist in Abbildung 8.1 zu sehen. Denken Sie daran, dass die Instanzen dieser Klassen Typen sind, keine Datenobjekte!
Tag 3: Gedankenverschmelzung 323
Monaden Seit ich mich dazu entschieden hatte, dieses Buch zu machen, fürchtete ich mich davor, einen Abschnitt über Monaden schreiben zu müssen. Als ich etwas recherchierte, musste ich aber feststellen, dass die Konzepte gar nicht ganz so schwierig sind. Hier möchte ich eine intuitive Erläuterung dazu liefern, warum wir Monaden brauchen. Dann werden wir uns auf hoher Ebene ansehen, wie Monaden aufgebaut sind. Abschließend werde ich noch ein wenig syntaktischen Zucker vorstellen, der wirklich zeigen sollte, wie sie funktionieren. Ich lehne mich an eine Reihe von Tutorials an, die mir dabei geholfen haben, die Sache zu verstehen. Das Haskell-Wiki3 bietet verschiedene gute Beispiele, und auch Understanding Monads4 bietet einige praktische Beispiele. Aber sehr wahrscheinlich müssen Sie verschiedene Beispiele aus unterschiedlichen Quellen durchgehen, um zu verstehen, was Monaden für Sie tun können.
Das Problem: Der betrunkene Pirat Nehmen wir an, wir haben einen Piraten, der eine Schatzkarte macht. Er ist betrunken und sucht sich daher einen bekannten Punkt und eine bekannte Richtung und legt den Weg zum Schatz schwankend und kriechend zurück. Jedes Wanken (stagger) zählt zwei Schritte und jedes Kriechen (crawl) einen Schritt. In einer imperativen Sprache würden Sie Anweisungen sequenziell zusammenfassen, wobei v der Wert ist, der die Distanz vom Ausgangspunkt enthält: def treasure_map(v) v = stagger(v) v = stagger(v) v = crawl(v) return( v ) end
Wir
verschiedene Funktionen, die wir innerhalb von treasure_map aufrufen. Diese Funktionen transformieren sequenziell unseren Zustand, die zurückgelegte Distanz (distance). Das Problem ist, dass es einen veränderlichen Zustand gibt. Wir könnten das Problem auf funktionale Weise angehen:
3 4
haben
http://www.haskell.org/tutorial/monads.html http://en.wikibooks.org/wiki/Haskell/Understanding_monads
324 Kapitel 8: Haskell haskell/drunken-pirate.hs
module Main where stagger :: (Num t) => t -> t stagger d = d + 2 crawl d = d + 1 treasureMap d = crawl ( stagger ( stagger d))
Sie sehen, dass die funktionale Definition recht schwer zu lesen ist. Anstelle von stagger, stagger und crawl müssen wir crawl, stagger und stagger lesen, und die Platzierung der Argumente ist furchtbar. Wir verfolgen lieber die Strategie, mehrere Funktionen hintereinander zu verketten. Wir können stattdessen einen let-Ausdruck verwenden: letTreasureMap (v, d) = let d1 = stagger d d2 = stagger d1 d3 = crawl d2 in d3
Haskell erlaubt es uns, let-Ausdrücke miteinander zu verketten und den letzten Ausdruck in einer in-Anweisung festzuhalten. Sie können erkennen, dass diese Version fast genauso unbefriedigend ist wie die erste. Die Ein- und Ausgaben sind gleich, weshalb es einfacher sein sollte, diese Art von Funktion zu bilden. Wir möchten stagger(crawl(x)) in stagger(x) · crawl(x) umwandeln, wobei · für „Funktionskomposition“ steht. Das ist eine Monade. Kurz gesagt, erlaubt eine Monade die Komposition von Funktionen in einer Form, die bestimmte Eigenschaften aufweist. Bei Haskell werden wir Monaden für verschiedene Zwecke nutzen. Erstens ist der Umgang mit Dingen wie der Ein-/Ausgabe schwierig, weil eine Funktion in einer rein funktionalen Programmiersprache immer das gleiche Ergebnis für die gleiche Eingabe liefern soll. Bei der Ein-/Ausgabe sollen sich die Funktionen ändern, beispielsweise auf der Grundlage des Inhalts einer Datei. Außerdem funktioniert Code wie der betrunkene Pirat, weil er den Zustand enthält. Monaden erlauben die Simulation von Programmzuständen. Haskell stellt eine spezielle Syntax bereit, die sogenannte doSyntax, um Programme im imperativen Stil zu ermöglichen. Um funktionieren zu können, ist die do-Syntax von Monaden abhängig.
Tag 3: Gedankenverschmelzung 325 Zu guter Letzt ist auch so etwas Einfaches wie eine Fehlerbedingung schwierig, da der zurückgegebene Typ davon abhängig ist, ob die Funktion erfolgreich war. Haskell stellt für diesen Zweck die Maybe-Monade zur Verfügung. Graben wir etwas tiefer.
Komponenten einer Monade Auf der einfachsten Ebene besitzt eine Monade drei grundlegende Dinge: 앫
Einen Typkonstruktor, der auf irgendeinem Containertyp basiert. Der Container kann eine einfache Variable, eine Liste, oder alles andere sein, was einen Wert aufnehmen kann. Wir werden den Container benutzen, um eine Funktion festzuhalten. Welchen Container Sie verwenden, hängt davon ab, was die Monade tun soll.
앫
Eine Funktion namens return, die eine Funktion verpackt und im Container ablegt. Der Name wird später einen Sinn ergeben, wenn wir uns die do-Notation ansehen. Merken Sie sich einfach, dass return eine Funktion in eine Monade packt.
앫
Eine Bindungsfunktion (bind) namens >>=, die eine Funktion wieder auspackt. Wir werden bind verwenden, um Funktionen miteinander zu verketten.
Alle Monaden müssen drei Regeln erfüllen. Für eine Monaden m, eine Funktion f und einen Wert x gilt: 앫
Sie müssen einen Typkonstruktor nutzen können, um eine Monade zu erzeugen, die mit einem Typ arbeiten kann, der einen Wert aufnimmt.
앫
Sie müssen in der Lage sein, Werte zu verpacken und wieder zu entpacken, ohne Informationen zu verlieren (monad >>= return = monad).
앫
Die Verschachtelung von Bindungsfunktionen muss deren sequenziellem Aufruf entsprechen ((m >>= f) >>= g = m >>= (\x -> f x >>= g)).
Wir wollen nicht näher auf diese Regeln eingehen, doch die Gründe für sie sind recht einfach. Sie erlauben viele nützliche Transformationen ohne Informationsverlust. Für den Fall, dass Sie wirklich tiefer einsteigen wollen, gebe ich Ihnen einige Quellen an die Hand.
326 Kapitel 8: Haskell Genug der Theorie. Lassen Sie uns von Grund auf eine einfache Monade entwickeln. Ich schließe das Kapitel mit einigen nützlichen Monaden ab.
Eine Monade von Grund auf entwickeln Das Erste, was wir brauchen, ist ein Typkonstruktor. Unsere Monade besitzt eine Funktion und einen Wert: haskell/drunken-monad.hs
module Main where data Position t = Position t deriving (Show) stagger (Position d) = Position (d + 2) crawl (Position d) = Position (d + 1) rtn x = x x >>== f = f x
Die drei Hauptelemente einer Monade sind ein Typcontainer, ein return und eine Bindung. Unsere Monade ist die einfachste, die überhaupt möglich ist. Der Typcontainer ist ein einfacher Typkonstruktor: data Position t = Position t. Er definiert nur einen grundlegenden Typ auf der Grundlage eines beliebigen Typ-Templates. Als Nächstes benötigen wir ein return, das eine Funktion als Wert verpackt. Da unsere Monade so einfach ist, müssen wir nur den Wert der Monade selbst zurückgeben, was wir entsprechend in (rtn x = x) verpacken. Abschließend benötigen wir eine Bindung, die uns die Komposition von Funktionen erlaubt. Unsere heißt >>==; wir definieren sie so, dass sie einfach die verknüpfte Funktion mit dem Wert der Monade aufruft (x >>== f = f x). Wir verwenden >>== und rtn anstelle von >>= und return, um Konflikte mit den in Haskell fest eingebauten Monadenfunktionen zu vermeiden. Beachten Sie, dass wir auch stagger und crawl so umgeschrieben haben, dass sie unsere selbstgemachte Monade anstelle nackter Integer-Werte verwenden. Wir sind so weit, unsere Monade einem Test zu unterziehen. Denken Sie daran, dass wir eine Syntax suchen, die Verschachtelung in Komposition umwandelt. Das überarbeitete Schatzkartenprogramm sieht so aus: treasureMap pos = pos >>== stagger >>== stagger >>== crawl >>== rtn
Tag 3: Gedankenverschmelzung 327 Und es funktioniert wie erwartet: *Main> treasureMap (Position 0) Position 5
Monaden und do-Notation Die Syntax ist viel besser, aber man kann sich durchaus etwas syntaktischen Zucker vorstellen, um sie noch etwas zu verbessern. Haskells do-Syntax macht genau das. Die do-Syntax kommt einem bei Problemen wie der Ein-/Ausgabe besonders gelegen. Im folgenden Code lesen wir eine Zeile von der Konsole ein und geben diese invertiert wieder aus. Wir nutzen dazu die do-Notation: haskell/io.hs
module Main where tryIo = do putStr "Enter your name: " ; line <- getLine ; let { backwards = reverse line } ; return ("Hello. Your name backwards is " ++ backwards)
Beachten Sie, dass der Anfang des Programms eine Funktionsdeklaration ist. Dann verwenden wir die einfache do-Notation, um Monaden mit etwas syntaktischem Zucker anzureichern. Unser Programm fühlt sich imperativ an und als gäbe es Zustände, aber in Wirklichkeit verwenden wir Monaden. Sie müssen auf einige syntaktische Regeln achten. Die Zuweisung verwendet <-. In GHCI müssen Sie Zeilen durch Semikola trennen und den Body von do- und let-Ausdrücken mit geschweiften Klammern umschließen. Bei mehreren Zeilen müssen Sie Ihren Code mit :{ und }: umschließen, jeweils in einer eigenen Zeile. Und jetzt können Sie auch erkennen, warum wir das Wrapping-Konstrukt unserer Monaden return genannt haben: Es verpackt einen Rückgabewert in einer hübschen Form, die die do-Syntax absorbieren kann. Der Code verhält sich wie bei einer zustandsorientierten imperativen Sprache, nutzt aber Monaden zur Verwaltung der zustandsorientierten Interaktionen. Die gesamte Ein-/Ausgabe ist fest gekapselt und muss mit einer der I/O-Monaden in einem do-Block in Beschlag genommen werden.
Verschiedene rechnerische Strategien Mit jeder Monade ist eine rechnerische Strategie verknüpft. Die Identity-Monade, die wir beim betrunkenen Piraten verwendet haben, plap-
328 Kapitel 8: Haskell pert einfach das nach, was wir eingeben. Wir haben sie benutzt, um eine verschachtelte Programmstruktur in eine sequenzielle umzuwandeln. Sehen wir uns ein anderes Beispiel an. So seltsam es sein mag, auch eine Liste ist eine Monade, bei der return und bind (>>=) wie folgt definiert sind: instance Monad [] where m >>= f = concatMap f m return x = [x]
Denken Sie daran, dass eine Monade irgendeinen Container und einen Typkonstruktor braucht, eine return-Methode, die eine Funktion verpackt, sowie eine bind-Methode, die sie wieder entpackt. Eine Monade ist eine Klasse und [] instanziiert sie, was den Typkonstruktor ergibt. Als Nächstes benötigen wir eine Funktion, die ein Ergebnis als return verpackt. Für die Liste packen wir die Funktion in die Liste. Zum Entpacken ruft unser bind die Funktion für jedes Element der Liste mit map auf und verkettet die Ergebnisse miteinander. concat und map werden so oft nacheinander benutzt, dass es eine Funktion gibt, die der Bequemlichkeit halber beides macht. Aber wir hätten auch einfach concat (map f m) verwenden können. Um ein Gefühl für die Listenmonade in Aktion zu bekommen, sehen Sie sich folgendes Skript in do-Notation an: Main> let cartesian (xs,ys) = do x <- xs; y <- ys; return (x,y) Main> cartesian ([1..2], [3..4]) [(1,3),(1,4),(2,3),(2,4)]
Wir haben eine einfache Funktion mit do-Notation und Monade geschrieben. Wir nehmen x aus der Liste xs und y aus der Liste ys. Dann haben wir jede mögliche Kombination von x und y zurückgegeben. Von diesem Punkt an ist ein Passwortknacker ganz einfach. haskell/password.hs
module Main where crack = do x <- ['a'..'c'] ; y <- ['a'..'c']; z <- ['a'..'c']; let { password = [x, y, z] } ; if attempt password then return (password, True) else return (password, False) attempt pw = if pw == "cab" then True else False
Tag 3: Gedankenverschmelzung 329 Hier verwenden wir die Listenmonade zur Berechnung aller möglichen Kombinationen. Beachten Sie, dass in diesem Kontext x <- [lst] für „für jedes x aus [lst]“ steht. Wir überlassen Haskell die Schwerstarbeit. An dieser Stelle müssen Sie einfach nur jedes Passwort ausprobieren. Unser Passwort ist in der attempt-Funktion fest vorgegeben. Es gibt viele rechnerische Strategien wie Listenkomprehensionen, mit denen wir dieses Problem hätten lösen können, doch auf diese Weise ist die rechnerische Strategie hinter Listenmonaden zu erkennen.
Maybe-Monade Bisher haben Sie die Identity- und die List-Monade kennengelernt. Mit Letzterer haben wir gelernt, dass Monaden eine zentrale rechnerische Strategie verwenden. In diesem Abschnitt sehen wir uns die Maybe-Monade an. Wir werden sie benutzen, um ein typisches Programmierproblem zu behanden: Funktionen können fehlschlagen. Sie könnten an die Bereiche Datenbanken und Kommunikation denken, aber auch weitaus einfachere AOIS müssen das Konzept „Fehler“ unterstützen. Denken Sie an eine Stringsuche, die den Index des Strings zurückgibt. Ist der String vorhanden, ist der Rückgabetyp ein Integer, anderenfalls vom Typ Nothing. Solche Berechnungen aneinanderzureihen, ist lästig. Nehmen wir an, Sie besitzen eine Funktion, die eine Webseite analysiert. Sie wollen die HTML-Seite, den Body innerhalb der Seite und den ersten Absatz innerhalb des Body. Sie wollen Funktionen entwickeln, deren Signaturen etwa so aussehen: paragraph XmlDoc -> XmlDoc ... body XmlDoc -> XmlDoc ... html XmlDoc -> XmlDoc ...
Diese unterstützen eine Funktion, die etwa so aussieht: paragraph body (html doc)
Das Problem ist, dass die Funktionen paragraph, body und html fehlschlagen können, weshalb wir einen Typ erlauben müssen, der „nichts“ (Nothing) sein kann. Haskell besitzt einen solchen Typ: Just. Just x kann Nothing oder irgendeinen Typ einpacken:
330 Kapitel 8: Haskell Prelude> Just "some string" Just "some string" Prelude> Just Nothing Just Nothing
Sie können das Just mithilfe von Mustererkennung zerlegen. Wenn wir auf unser Beispiel zurückkommen, können die paragraph-, body- und html d-Dokumente Just Xml-Doc zurückgeben. Dann können Sie Haskells case-Anweisung (die wie Erlangs case-Anweisung funktioniert) und Pattern-Matching nutzen und erhalten so etwas wie das hier: case (html doc) of Nothing -> Nothing Just x -> case body x of Nothing -> Nothing Just y -> paragraph 2 y
Das Ergebnis ist höchst unbefriedigend, wenn man bedenkt, dass wir paragraph 2 body (html doc) entwickeln wollten. Was wir eigentlich brauchen, ist die Maybe-Monade. Hier die Definition: data Maybe a = Nothing | Just a instance Monad Maybe return = Nothing >>= f = (Just x) >>= f =
where Just Nothing f x
...
Der Typ, den wir verpacken, ist der Typkonstruktor Maybe a. Dieser Typ kann Nothing oder Just a verpacken. return ist einfach: Es verpackt das Ergebnis einfach in Just. Die Bindung ist ebenfalls einfach. Nothing gibt eine Funktion zurück, die Nothing zurückgibt. Für Just x gibt sie eine Funktion zurück, die x zurückgibt. Beide werden durch das return verpackt. Nun lassen sich diese Operationen einfach miteinander verketten: Just someWebPage >>= html >>= body >>= paragraph >>= return
Wir können die Elemente also fehlerfrei kombinieren. Das funktioniert, weil die Monade die Entscheidungsfindung durch die von uns abgefassten Funktionen steuert.
Was wir an Tag 3 gelernt haben In diesem Abschnitt haben wir uns drei anspruchsvolle Konzepte angesehen: Haskell-Typen, -Klassen und -Monaden. Wir haben mit Typen begonnen und uns abgeleitete Typen existierender Funktionen, Zahlen,
Tag 3: Gedankenverschmelzung 331 Boolescher Werte und Zeichen angesehen. Wir haben dann mit einigen benutzerdefinierten Typen weitergemacht. In einem einfachen Beispiel haben wir Typen benutzt, um Spielkarten zu definieren, die Farbe und Rangordnung berücksichtigten. Sie haben erfahren, wie man Typen parametrisiert, und sogar rekursive Typdefinitionen verwendet. Dann haben wir unsere Betrachtung mit Monaden abgeschlossen. Weil Haskell eine rein funktionale Sprache ist, kann es schwierig sein, Probleme auf imperative Weise auszudrücken oder Zustände während der Programmausführung zu erhalten. Haskells Designer stützen sich auf Monaden, um beide Probleme zu lösen. Eine Monade ist ein Typkonstruktor mit ein paar Funktionen, die Funktionen verpacken und verketten. Sie können Monaden mit verschiedenen Typcontainern kombinieren, um verschiedene Arten rechnerischer Strategien zu unterstützen. Wir haben Monaden benutzt, um unser Programm mit einem etwas natürlicheren, imperativen Stil zu versehen und mehrere Möglichkeiten zu verarbeiten.
Tag 3: Selbststudium Finden Sie Folgendes: 앫
einige Monaden-Tutorials und
앫
eine Liste der Monaden in Haskell.
Tun Sie Folgendes: 앫
Schreiben Sie eine Funktion, die die Maybe-Monade nutzt und den Wert einer Hash-Tabelle abruft. Entwickeln Sie einen Hash, der andere Hashes speichert, und zwar mehrere Ebenen tief. Verwenden Sie die Maybe-Monade, um ein Element für einen HashSchlüssel abzurufen, der mehrere Ebenen tief vergraben ist.
앫
Stellen Sie in Haskell ein Labyrinth dar. Sie benötigen einen MazeTyp und einen Node-Typ sowie eine Funktion, die den Knoten für ein gegebenes Koordinatenpaar zurückgibt. Der Knoten muss eine Liste von Übergängen zu anderen Knoten enthalten.
앫
Verwenden Sie eine List-Monade zur Lösung des Labyrinths.
앫
Implementieren Sie eine Monade in einer nichtfunktionalen Sprache (siehe die Artikelserie zu Monaden bei Ruby5).
5 http://moonbase.rydia.net/mental/writings/programming/monads- in- ruby/ 00introduction.html
332 Kapitel 8: Haskell
8.5
Haskell zusammengefasst Von allen Sprachen in diesem Buch ist Haskell die einzige, die von einem Komitee entworfen wurde. Nach der Ausbreitung rein funktionaler Sprachen mit lockerer Semantik wurde ein Komitee gegründet, das einen offenen Standard entwickeln sollte. Es sollte die vorhandenen Fähigkeiten und die zukünftige Forschung konsolidieren. Haskell wurde geboren und 1990 die Version 1.0 definiert. Die Sprache und die Community sind seitdem gewachsen. Haskell unterstützt eine Vielzahl funktionaler Fähigkeiten wie Listenkomprehensionen, Lazy Computing, Partially Applied Functions und Currying. Tatsächlich verarbeiten Haskell-Funktionen nur jeweils einen Parameter und nutzen Currying, um mehrere Argumente zu unterstützen. Das Haskell-Typsystem bietet ein exzellentes Gleichgewicht zwischen Typsicherheit und Flexibilität. Das vollständig polymorphe TemplateSystem erlaubt eine umfassende Unterstützung von benutzerdefinierten Typen und sogar Typklassen, die eine vollständige Vererbung des Interface unterstützen. Üblicherweise muss sich der Haskell-Programmierer nicht um Typdetails kümmern, außer bei der Funktionsdeklaration. Das Typsystem schützt den Benutzer vor jeglichen Typfehlern. Wie bei jeder rein funktionalen Sprache müssen Haskell-Entwickler kreativ sein, wenn es um Programme im imperativen Stil oder um akkumulierte Zustände geht. Die Ein-/Ausgabe kann ebenfalls eine Herauforderung sein. Glücklicherweise können Haskell-Entwickler zu diesem Zweck auf Monaden zurückgreifen. Eine Monade ist ein Typkonstruktor und ein Container, der Basisfunktionen zum Packen und Entpacken von Funktionen als Werte unterstützt. Diese Funktionen erlauben es Programmierern, Monaden über die do-Syntax auf interessante Weise zu verknüpfen. Dieser syntaktische Zucker erlaubt (mit gewissen Einschränkungen) Programme im imperativen Stil.
Kernstärken Da Haskell kompromisslos den absoluten Ansatz reiner Funktionen verfolgt, können die Vor- und Nachteile extrem sein. Sehen wir sie uns an.
Typsystem Wenn Sie starke Typisierung mögen (und vielleicht auch wenn nicht), werden Sie Haskells Typsystem toll finden: Es ist da, wenn Sie es brau-
Haskell zusammengefasst 333 chen, aber auch nur dann. Das Typsystem kann einen hilfreichen Schutz vor typischen Fehlern bieten, und diese können während der Kompilierung abgefangen werden, nicht erst zur Laufzeit. Doch diese zusätzliche Sicherheit ist nur ein Teil der Geschichte. Der vielleicht interessanteste Aspekt an einem Haskell-Typ ist die einfache Assoziation neuer Typen mit neuen Verhaltensweisen. Sie können anspruchsvolle Typen von Grund auf entwickeln. Mit Typkonstruktoren und -klassen können Sie selbst extrem komplexe Typen und Klassen wie Monaden problemlos anpassen. Mit Klassen können Ihre neuen Typen die Vorteile bestehender Haskell-Bibliotheken nutzen.
Ausdrucksfähigkeit Die Sprache Haskell besitzt eine phantastische Leistungsfähigkeit. Auf abstrakter Ebene besitzt sie alles, was Sie brauchen, um mächtige Ideen knapp ausdrücken zu können. Diese Ideen umfassen das Verhalten durch eine umfangreiche funktionale Bibliothek und eine mächtige Syntax. Die Konzepte erstrecken sich auf Datentypen, wenn Sie neue (sogar rekursive) Typen entwickeln, die ohne komplexe Syntax die richtigen Funktionen an die richtigen Daten binden. Aus akademischer Sicht findet man keine bessere Sprache für das Lehren funktionaler Programmierung als Haskell. Alles, was man braucht, ist vorhanden.
Reinheit des Programmiermodells Reine Programmiermodelle können radikal die Art und Weise verändern, in der man Probleme angeht. Sie zwingen einen, alte Programmierparadigmen hinter sich zu lassen und andere Wege zu finden, um Aufgaben anzugehen. Rein funktionale Sprachen geben Ihnen etwas, worauf Sie sich verlassen können. Für die gleiche Eingabe liefert eine Funktion immer das gleiche Ergebnis zurück. Diese Eigenschaft macht es einfacher, Schlussfolgerungen über Programme anzustellen. Sie können manchmal beweisen, dass ein Programm korrekt ist (oder eben nicht). Sie können sich auch von vielen Problemen befreien, die mit der Abhängigkeit von Nebenwirkungen einhergehen: Komplexität und Instabilität oder Langsamkeit bei Nebenläufigkeit.
„Lazy“ Semantik Irgendwann einmal bedeutete die Arbeit mit funktionalen Sprachen den Umgang mit Rekursion. Strategien des „Lazy Computing“ bieten einen ganzen Satz neuer Strategien für den Umgang mit Daten. Sie können
334 Kapitel 8: Haskell häufig Programme entwickeln, die eine bessere Performance zeigen und nur einen Bruchteil der Codezeilen anderer Strategien benötigen.
Akademische Unterstützung Einige der wichtigsten und einflussreichsten Sprachen wie Pascal entstanden in der akademischen Welt. Sie profitierten stark von der Forschung und der Verwendung in diesem Umfeld. Als primäre Lehrsprache funktionaler Techniken wächst und verbessert sich Haskell ständig. Auch wenn es keine echte Mainstream-Sprache ist, finden Sie immer ausreichend Entwickler, um wichtige Aufgaben erledigen zu können.
Kernschwächen Mittlerweile wissen Sie, dass nicht jede Programmiersprache für jeden Zweck gleich gut geeignet ist. Haskells Stärken haben üblicherweise auch eine Kehrseite.
Unflexibles Programmiermodell Eine rein funktionale Sprache hat ihre Vorteile, bereitet einem aber auch einige Kopfschmerzen. Sie werden bemerkt haben, dass die Programmierung mit Monaden den letzten Abschnitt im letzten Kapitel darstellte, und das zurecht: Die Konzepte sind anspruchsvoll. Aber wir haben Monaden für einiges verwendet, das in anderen Sprachen trivial ist, etwa das Schreiben von Programmen im imperativen Stil, die Verarbeitung der Ein-/Ausgabe und sogar das Handling von Listenfunktionen, die vielleicht einen Wert finden, vielleicht aber auch nicht. Ich habe es schon bei anderen Sprachen erwähnt, sage es hier aber noch einmal: Zwar macht Haskell einige schwierige Dinge einfach, aber es macht auch einige einfache Dinge schwierig. Bestimmte Stile führen selbst zu bestimmten Programmierparadigmen. Wenn Sie einen Schritt-für-Schritt-Algorithmus entwickeln, sind imperative Sprachen gut geeignet. Bei viel I/O und Scripting geht man nicht in Richtung funktionaler Sprachen. Die Reinheit des einen ist die fehlende Kompromissfähigkeit des anderen.
Community Da wir gerade von Kompromissen sprechen: Hier kann man die unterschiedlichen Ansätze von Scala und Haskell deutlich erkennen. Obwohl beide stark typisiert sind, verfolgen sie radikal unterschiedliche Philo-
Haskell zusammengefasst 335 sophien. Bei Scala dreht sich alles um Kompromisse, bei Haskell hingegen alles um Reinheit. Durch das Eingehen von Kompromissen konnte Scala anfangs eine viel größere Community gewinnen als Haskell. Zwar kann man den Erfolg nicht an der Größe der Programmiercommunity messen, aber man muss eine ausreichend große Menge anziehen, um erfolreich sein zu können. Und mehr Benutzer führen zu mehr Möglichkeiten und mehr Community-Ressourcen.
Lernkurve Monaden sind nicht das einzige geistig anspruchsvolle Konzept bei Haskell. Currying wird bei jeder Funktion mit mehr als einem Argument verwendet. Die meisten Grundfunktionen verwenden parametrisierte Typen, und Funktionen für Zahlen verwenden häufig eine Typklasse. Auch wenn es den Aufwand am Ende durchaus wert sein kann, müssen Sie ein guter Programmierer mit solidem theoretischem Grundwissen sein, um eine Chance zu haben, Haskell erfolgreich zu meistern.
Abschließende Gedanken Von den funktionalen Sprachen in diesem Buch ist Haskell diejenige, die am schwersten zu erlernen ist. Der Schwerpunkt auf Monaden und das Typsystem machen die Lernkurve steil. Sobald man einige Schlüsselkonzepte gemeistert hat, wird das Ganze einfacher. Für mich war es die bereicherndste Sprache, die ich gelernt habe. Basierend auf dem Typsystem und der Eleganz der Anwendung von Monaden werden wir eines Tages auf diese Sprache als eine der wichtigsten in diesem Buch zurückblicken. Haskell spielt noch eine weitere Rolle. Die Reinheit des Ansatzes und der akademische Fokus verbessern unser Verständnis für das Programmieren. Der Beste funktionale Programmierer der nächsten Generation wird seine ersten Erfahrungen mit Haskell gesammelt haben.
Kapitel 9
Zusammenfassung Meinen Glückwunsch! Sie haben sich durch sieben Programmiersprachen gearbeitet. Vielleicht erwarten Sie jetzt, dass ich in diesem Kapitel die Gewinner und Verlierer küre. Doch in diesem Buch geht es nicht um Gewinner und Verlierer, sondern darum, neue Ideen vorzustellen. Ihnen könnte es gehen wie mir zu Beginn meiner Karriere: Sie stecken tief in kommerziellen Projekten, mit großen Teams und wenig Phantasie. Das sind die Softwarefabriken unserer Generation. In einer solchen Welt war mein Blick auf andere Programmiersprachen äußerst beschränkt. Ich war wie ein Filmliebhaber in den 1970ern in einer kleinen Stadt mit einem einzigen Kino, der sich nur die großen Blockbuster ansehen konnte. Seit ich damit begonnen habe, selbständig Software zu entwickeln, komme ich mir vor, als hätte ich gerade den Independent-Film entdeckt. Ich kann meinen Lebensunterhalt damit verdienen, in Ruby zu programmieren, aber ich bin nicht so naiv, zu glauben, dass Ruby die Antwort auf alle Fragen wäre. Genau wie Independent-Filme das Filmemachen voranbringen, ändern neu aufkommende Programmiersprachen die Art und Weise, wie wir über die Organisation und Konstruktion von Programmen denken. Lassen Sie uns zusammenfassen, was wir in diesem Buch gesehen haben.
9.1
Programmiermodelle Programmiermodelle verändern sich extrem langsam. Bisher sind etwa alle 20 Jahre neue Modelle aufgekommen. Meine Ausbildung begann mit prozeduralen Sprachen, Basic und Fortran. Am College lernte ich einen etwas strukturierteren Ansatz mit Pascal kennen. Bei IBM
338 Kapitel 9: Zusammenfassung begann ich, kommerziell in C und C++ zu programmieren und lernte erstmals Java kennen. Ich begann auch damit, objektorientierten Code zu schreiben. Meine Programmiererfahrung erstreckt sich über 30 Jahre, und ich habe nur zwei bedeutende Programmierparadigmen gesehen. Sie könnten sich fragen, warum ich so begeistert darüber bin, einige andere Programmierparadigmen vorstellen zu dürfen. Das ist eine berechtigte Frage. Programmierparadigmen ändern sich zwar langsam, doch sie ändern sich. Wie ein Tornado können sie eine Spur der Verwüstung hinterlassen, Karrieren zerstören und Unternehmen auslöschen, die falsch investiert haben. Wenn Sie selbst mit einem Programmierparadigma kämpfen, sollten Sie gut aufpassen. Nebenläufigkeit und Zuverlässigkeit schubsen uns unaufhörlich in Richtung einer auf höherer Ebene angesiedelten Programmiersprache. Ich glaube, dass wir zumindest immer mehr spezialisierte Sprachen zur Lösung spezieller Probleme sehen werden. Es folgt ein Überblick über die Programmiermodelle, die wir kennengelernt haben.
Objektorientiert (Ruby, Scala) Der aktuelle Platzhirsch ist die Objektorientierung, typischerweise in Java. Dieses Programmierparadigma kreist um drei wesentliche Konzepte: Kapselung, Vererbung und Polymorphismus. Bei Ruby haben wir das dynamische Duck Typing kennengelernt. Statt einen Vertrag auf der Grundlage der Definition einer Klasse oder eines Objekts zu erzwingen, nutzt Ruby eine Typisierung, die auf den Methoden basiert, die ein Objekt unterstützt. Wir haben erfahren, dass Ruby verschiedene funktionale Konzepte wie Codeblöcke kennt. Auch Scala bietet objektorientierte Programmierung. Da es statische Typisierung unterstützt, ist es nicht so langatmig wie Java und bietet Features wie Typinferenz und einfache Syntax. Mit diesem Feature leitet Scala automatisch den Typ von Variablen ab, indem es Hinweise in der Syntax und in der Verwendung nutzt. Scala geht bei funktionalen Konzepten weit über Ruby hinaus. Beide Sprachen sind heute bei produktiven Anwendungen weit verbreitet, und beide stellen im Vergleich zu Mainstream-Sprachen wie Java deutliche Fortschritte im Sprachdesign dar. Es gibt viele Varianten objektorientierter Sprachen; eine davon ist das nächste Programmierparadigma: Prototypsprachen.
Programmiermodelle 339
Prototyp-Programmierung (Io) Tatsächlich kann man Prototypsprachen als Untermenge der objektorientierten Sprachen bezeichnen, aber sie sind in der Praxis unterschiedlich genug, um sie als eigenes Programmiermodell zu betrachten. Statt sich durch eine Klasse zu arbeiten, sind alle Prototypen Instanzen von Objekten. Einige speziell festgelegte Instanzen dienen als Prototypen für andere Objektinstanzen. Diese Sprachfamilie umfasst JavaScript und Io. Einfach und ausdrucksstark sind Prototypsprachen üblicherweise dynamisch typisiert und eignen sich gut zum Scripting und zur Anwendungsentwicklung, insbesondere für Benutzerschnittstellen. Wie Sie bei Io gelernt haben, kann ein einfaches Programmiermodell mit einer kleinen, konsistenten Syntax eine mächtige Kombination sein. Wir haben Io in den unterschiedlichsten Bereichen eingesetzt, die vom Scripting nebenläufiger Programme bis hin zur Entwicklung eigener DSLs reichten. Doch Prototypprogrammierung ist nicht das am stärksten spezialisierte Paradigma, dem wir begegnet sind.
Logikprogrammierung (Prolog) Prolog stammt aus einer Familie von Progammiersprachen, die für die Logikprogrammierung entwickelt wurden. Die verschiedenen Anwendungen, die wir mit Prolog entwickelt haben, lösen einen recht begrenzten Typ von Problemen, aber die Ergebnisse waren häufig spektakulär. Wir haben logische Einschränkungen definiert, die wir zu unserem Problem („Universum“) kannten, und es Prolog überlassen, eine Lösung zu finden. Wenn das Programmiermodell zu diesem Paradigma passte, konnten wir Ergebnisse mit einem Bruchteil der Codezeilen erzielen, die bei anderen Programmiersprachen notwendig wären. Diese Sprachfamilie unterstützt viele der kritischsten Anwendungen der Welt in Bereichen wie der Flugsicherung oder dem Bauingenieurwesen. Einfache Logik-Engines finden Sie auch für andere Sprachen wie C und Java. Prolog diente als Inspiration für Erlang, das zu einer anderen Sprachfamilie gehört.
Funktionale Programmierung (Scala, Erlang, Clojure, Haskell) Das vielleicht am sehnsüchtigsten erwartete Programmierparadigma in diesem Buch ist die funktionale Programmierung. Der Reinheitsgrad funktionaler Programmiersprachen ist unterschiedlich, doch die Konzepte sind immer gleich. Funktionale Programme bestehen aus mathematischen Funktionen. Ruft man die gleiche Funktion mehrfach auf,
340 Kapitel 9: Zusammenfassung erhält man immer das gleiche Ergebnis zurück. Nebenwirkungen sind entweder verpönt oder verboten. Sie können diese Funktionen auf unterschiedliche Art und Weise erzeugen. Sie haben gesehen, dass funktionale Programmiersprachen üblicherweise ausdrucksstärker sind als objektorientierte Sprachen. Die Beispiele waren häufig kürzer und einfacher als ihre objektorientierten Gegenstücke, weil mehr Tools zum Aufbau der Programme zur Verfügung standen als beim objektorientierten Paradigma. Zwei Beispiele dafür, die man in objektorientierten Sprachen nicht findet, sind Funktionen höherer Ordnung und Currying. Wie Sie bei Haskell gesehen haben, führen verschiedene Reinheitsgrade zu unterschiedlichen Vorund Nachteilen. Ein klarer Vorteil funktionaler Sprachen ist das Fehlen von Nebenwirkungen, was die Entwicklung paralleler Programme vereinfacht. Wenn es keine veränderlichen Zustände gibt, verschwinden auch viele traditionelle Probleme nebenläufiger Programmierung.
Paradigmen wechseln Wenn Sie entscheiden, mehr auf funktionale Programmierung zu setzen, gibt es verschiedene Wege: Sie können ganz mit der OOP brechen oder etwas evolutionärer vorgehen. Mit den vorgestellten sieben Sprachen haben Sie Sprachen kennengelernt, die vier Jahrzehnte und ebenso viele Programmierparadigmen überdauert haben. Ich hoffe, Sie wissen auch die Evolution der Programmiersprachen zu würdigen. Sie haben drei verschiedene Ansätze bei den aufkommenden Paradigmen gesehen. Bei Scala ist der Ansatz Koexistenz. Scala-Programmierer können objektorientierte Programme mit stark funktionaler Prägung entwickeln. Es liegt in der Natur der Sprache, beide Paradigmen als gleichwertig zu betrachten. Clojure wählt den Ansatz der Kompatibilität. Die Sprache setzt auf der JVM auf und erlaubt es ihren Anwendungen, Java-Objekte direkt zu verwenden. Doch die Clojure-Philosophie betrachtet bestimmte Elemente der OOP grundsätzlich als fehlerbehaftet. Im Gegensatz zu Scala dient die Clojure/ Java-Interoperabilität dazu, Frameworks für die Java Virtual Machine zu entlasten. Sie dient nicht dazu, die Programmiersprache zu erweitern. Haskell und Erlang sind grundsätzlich eigenständige Sprachen. Philosophisch unterstützen sie die objektorientierte Programmierung in keiner Weise. Sie können sich also für beide Paradigmen entscheiden, einen klaren Schnitt machen oder objektorientierte Bibliotheken nutzen und das OOP-Paradigma hinter sich lassen. Die Entscheidung liegt bei Ihnen.
Nebenläufigkeit 341 Ob Sie nun eine der Sprachen in diesem Buch einführen oder nicht, Sie werden besser sein, weil Sie wissen, was es in der Welt so gibt. Als JavaEntwickler musste ich ein Jahrzehnt auf Closures warten, hauptsächlich weil Leute wie ich unwissend waren und nicht laut genug nach ihnen geschrien haben. In der Zwischenzeit wurden Mainstream-Frameworks wie Spring mit anonymen inneren Klassen vollgestopft, um Probleme zu lösen, die Closures wunderbar hätten lösen können. Meine Finger sind von der ganzen Tipperei wund und meine Augen tränen, weil ich das ganze Zeug lesen muss. Der moderne Java-Entwickler weiß viel mehr, nicht zuletzt weil Leute wie Martin Odersky und Rich Hickey uns Alternativen gegeben haben, die den Stand der Technik vorantreiben und Java zwingen, sich weiterzuentwickeln oder den Weg frei zu machen.
9.2
Nebenläufigkeit Ein wiederkehrendes Thema in diesem Buch ist die Notwendigkeit besserer Sprachkonstrukte und Programmiermodelle zur Handhabung der Parallelität. Zwischen den Sprachen waren die Ansätze oft radikal verschieden, aber extrem effektiv. Gehen wir die Ansätze durch, die wir gesehen haben.
Kontrolle veränderlicher Zustände Der mit Abstand häufigste Aspekt der Betrachtung der Nebenläufigkeit war das Programmiermodell. Die objektorientierte Programmierung erlaubt Nebenwirkungen und veränderliche Zustände. Zusammengenommen werden die Programme dadurch wesentlich komplizierter. Kommen mehrere Threads und Prozesse dazu, wächst uns die Komplexität über den Kopf. Funktionale Programmiersprachen geben der Sache durch eine wichtige Regel mehr Struktur: Wiederholte Aufrufe der gleichen Funktion führen immer zum gleichen Ergebnis. Variablen können nur einmal zugewiesen werden. Wenn Nebenwirkungen verschwinden, verschwinden auch Race Conditions und alle dazugehörigen Verwicklungen. Dennoch haben wir handfeste Techniken kennengelernt, die über das bloße Programmiermodell hinausgehen. Sehen wir sie uns genauer an.
342 Kapitel 9: Zusammenfassung
Aktoren bei Io, Erlang und Scala Ob man nun ein Objekt oder einen Prozess verwendet, der Aktoransatz ist derselbe. Er nimmt die unstrukturierte Interprozesskommunikation über Objektgrenzen hinweg und macht daraus ein strukturiertes Message-Passing zwischen Konstrukten, die eine Message-Queue unterstützen. Erlang und Scala verwenden Pattern-Matching, um eingehende Nachrichten zu erkennen und entsprechenden Code auszuführen. In Kapitel 6, Erlang, auf Seite 191, haben wir um ein Russisches Roulette herum ein Beispiel aufgebaut, um einen sterbenden Prozess zu demonstrieren. Erinnern Sie sich daran, wie wir die Kugel in die dritte Kammer geschoben haben: erlang/roulette.erl
-module(roulette). -export([loop/0]). % send a number, 1-6 loop() -> receive 3 -> io:format("bang.~n"), exit({roulette,die,at,erlang:time()}); _ -> io:format("click~n"), loop() end.
Wir starteten einen Prozess und wiesen ihm die ID Gun zu. Wir konnten den Prozess mit Gun ! 3 abschießen. Erlangs virtuelle Maschine und die Sprache unterstützen ein mächtiges Monitoring, das uns bei ersten Anzeichen von Problemen benachrichtigt oder sogar Prozesse neu startet.
Futures Das Aktorenmodell hat Io um zwei zusätzliche parallele Konstrukte erweitert: Coroutinen und Futures. Coroutinen erlauben zwei Objekten kooperatives Multitasking, bei dem jedes die Kontrolle zur geeigneten Zeit abgibt. Futures sind Platzhalter für lang laufende, parallele Berechnungen. Wir haben die Anweisung futureResult := URL with("http://google. com/") @fetch ausgeführt. Zwar war das Ergebnis nicht sofort verfügbar, aber die Kontrolle wurde sofort wieder an das Programm übergeben und es wurde erst geblockt, wenn wir auf das Future zugreifen wollten. Tatsächlich verwandelt sich ein Io-Future in ein Ergebnis, sobald dieses zur Verfügung steht.
Programmierkonstrukte 343
Transactional Memory Bei Clojure haben wir eine Reihe interessanter Ansätze für Parallelität gesehen. Software Transactional Memory (STM) schließt jeden verteilten Zugriff auf eine gemeinsam genutzte Ressource in eine Transaktion ein. Der gleiche Ansatz, aber für Datenbankobjekt, sorgt bei parallelen Aufrufen für die Integrität von Datenbanken. Wir haben jeden Zugriff in eine dosync-Funktion gepackt. Mit diesem Ansatz können sich Clojure-Entwickler vom rein funktionalen Design lösen, wenn es angebracht ist, und dennoch die Integrität über mehrere Threads und Prozesse erhalten. STM ist eine relativ neue Idee, die sich gerade erst bei beliebteren Sprachen verbreitet. Als Lisp-Derivat ist Clojure eine ideale Sprache für einen solchen Ansatz, da Lisp eine Multiparadigmensprache ist: Nutzer können verschiedene Programmierparadigmen verwenden, wenn es sinnvoll ist, und sicher sein, dass die Anwendung ihre Integrität und Performance beibehält, selbst bei hochgradig parallelen Zugriffen. Die nächste Generation von Programmierern wird von einer Sprache weit mehr verlangen. Der einfache Ansatz, einen Thread zu starten und auf eine Semaphore zu warten, ist nicht mehr gut genug. Eine neuere Sprache muss eine schlüssige Philosophie verfolgen, Parallelität unterstützen und die entsprechenden Tools zur Verfügung stellen. Mag sein, dass der Bedarf an Parallelität ganze Programmierparadigmen hinfällig werden lassen wird. Aber vielleicht passen sich ältere Sprachen auch an und führen strengere Kontrollen für veränderliche Variablen ein und clevere parallele Konstrukte wie Aktoren und Futures.
9.3
Programmierkonstrukte Eines der aufregendsten Dinge beim Schreiben dieses Buches war die Untersuchung der grundlegenden Bausteine der verschiedenen Sprachen. Mit jeder neuen Sprache habe ich bedeutende neue Konzepte eingeführt. Es folgt ein Überblick über einige der Programmierkonstrukte, denen Sie wahrscheinlich bei neuen Sprachen begegnen werden. Sie zählen zu meinen Lieblingsentdeckungen.
Listenkomprehensionen Wie wir bei Erlang, Clojure und Haskell1 gesehen haben, ist die Listenkomprehension eine kompakte Struktur, die verschiedene Ideen in 1
Scala unterstützt ebenfalls List Comprehensions, doch wir haben sie nicht verwendet.
344 Kapitel 9: Zusammenfassung einem einzelnen, mächtigen Konstrukt vereint. Eine Listenkomprehension besitzt einen Filter, eine Map und ein kartesisches Produkt. Wir haben Listenkomprehensionen zuerst bei Erlang gesehen. Wir begannen mit einer einfachen Einkaufsliste wie Cart = [{pencil, 4, 0.25}, {pen, 1, 1.20}, {paper, 2, 0.20}]. Um die Steuer zu berechnen, haben wir eine einzelne Listenkomprehension entwickelt, die das Problem auf einmal lösen konnte: 8> WithTax = [{Product, Quantity, Price, Price * Quantity * 0.08} || 8> {Product, Quantity, Price} <- Cart]. [{pencil,4,0.25,0.08},{pen,1,1.2,0.096},{paper,2,0.2,0.032}]
Mehrere Sprachschöpfer nennen Listenkomprehensionen als eines ihrer Lieblingsfeatures. Ich stimme dem zu.
Monaden Die vielleicht größte intellektuelle Entwicklung habe ich wohl im Bereich der Monaden durchgemacht. Bei rein funktionalen Sprachen konnten wir keine Programme mit veränderlichen Zuständen entwickeln. Stattdessen entwickelten wir Monaden, die uns Funktionen so aufbauen ließen, dass wir Probleme so strukturieren konnten, als gäbe es veränderliche Zustände. Haskell kennt die do-Notation, die von Monaden unterstützt wird, um dieses Problem zu lösen. Wir haben auch herausgefunden, dass Monaden es uns ermöglichen, komplexe Berechnungen zu vereinfachen. Jede unserer Monaden unterstützte eine rechnerische Strategie. Wir haben die Maybe-Monade genutzt, um Fehlerbedingungen zu verarbeiten, etwa eine Listensuche, die theoretisch nichts (Nothing) zurückgeben kann. Wir haben die ListMonade verwendet, um ein kartesisches Produkt zu berechnen und ein Passwort zu knacken.
Matching Eines der weiter verbreiteten Programmierfeatures ist die Mustererkennung. Wir begegneten Pattern-Matching erstmals bei Prolog, aber auch noch bei Scala, Erlang, Clojure und Haskell. Jede dieser Sprachen setzt auf Pattern-Matching, um den Code deutlich zu vereinfachen. Zu den Problemdomänen gehören das Parsing, verteiltes Message-Passing, Destrukturierung, Unifikation, XML-Verarbeitung und Vieles mehr.
Programmierkonstrukte 345 Eine typische Erlang-Mustererkennung zeigte der Übersetzungsdienst: erlang/translate_service.erl
-module(translate_service). -export([loop/0, translate/2]). loop() -> receive {From, "casa"} -> From ! "house", loop(); {From, "blanca"} -> From ! "white", loop(); {From, _} -> From ! "I don't understand.", loop() end. translate(To, Word) -> To ! {self(), Word}, receive Translation -> Translation end.
Die loop-Funktion erkannte die Prozess-ID (From), gefolgt von einem Wort (casa oder blanca) oder einem Platzhalter. Die Mustererkennung erlaubt es dem Programmierer, sich schnell die wichtigen Teile aus der Nachricht herauszupicken, ohne dass ein Parsing von seiner Seite notwendig wäre.
Unifizierung Prolog nutzt die Unifizierung, einen nahen Verwandten der Mustererkennung. Sie haben erfahren, dass Prolog mögliche Werte in einer Regel ersetzt, damit die linke und rechte Seite übereinstimmen. Prolog probiert die verschiedenen Werte so lange durch, bis alle Möglichkeiten ausgeschöpft sind. Wir haben uns ein einfaches Prolog-Programm namens concatenate als Beispiel für die Unifizierung angesehen: prolog/concat.pl
concatenate([], List, List). concatenate([Head|Tail1], List, [Head|Tail2]) :concatenate(Tail1, List, Tail2).
346 Kapitel 9: Zusammenfassung Wir haben gelernt, dass die Unifizierung dieses Programm so mächtig macht, weil es auf drei Arten arbeiten kann: Test auf Wahrheit, Matching der linken Seite und Matching der rechten Seite.
9.4
Ihre Sprache finden Wir haben im gesamten Buch über Filme und Figuren gesprochen. Der Spaß am Filmemachen besteht darin, seine Erfahrungen mit den Schauspielern, Szenen und Schauplätzen zu kombinieren, die die Geschichte erzählen, die man erzählen will. All das tut man, um das Publikum zu erfreuen. Je mehr man weiß, desto besser werden die Filme. Wir müssen die Programmierung auf die gleiche Weise betrachten. Auch wir haben ein Publikum. Aber ich rede nicht von den Anwendern Ihrer Programme. Ich rede von den Leuten, die Ihren Code lesen müssen. Als guter Programmierer müssen Sie für Ihr Publikum schreiben und eine Sprache finden, die ihm gefällt. Sie haben mehr Entscheidungsfreiheit beim Finden dieser Sprache, wenn Sie wissen, was andere Sprachen zu bieten haben. Ihre Sprache ist Ihre einzige Möglichkeit, sich im Code auszudrücken. Und das Ergebnis wird nur so gut sein wie die Summe Ihrer Erfahrungen. Ich hoffe, dass dieses Buch Ihnen dabei geholfen hat, Ihre Sprache zu finden. Und am meisten hoffe ich, dass Sie etwas Spaß hatten.
Anhang A
Bibliografie [Arm07] Joe Armstrong. Programming Erlang: Software for a Concurrent World. The Pragmatic Programmers, LLC, Raleigh, NC und Dallas, TX, 2007. [Gra04] Paul Graham. Hackers und Painters: Big Ideas from the Computer Age. O’Reilly & Associates, Inc, Sebastopol, CA, 2004. [Hal09] Stuart Halloway. Programming Clojure. The Pragmatic Programmers, LLC, Raleigh, NC und Dallas, TX, 2009. [OSV08] Martin Odersky, Lex Spoon und Bill Venners. Programming in Scala. Artima, Inc., Mountain View, CA, 2008. [TFH08] David Thomas, Chad Fowler und Andrew Hunt. Programming Ruby: The Pragmatic Programmers’ Guide. The Pragmatic Programmers, LLC, Raleigh, NY und Dallas, TX, Third Edition, 2008.
Index A Aktoren Erlang 193 Io 90 Scala 180–184 anonyme Funktionen Clojure 253, 255 Erlang 209–210 Haskell 306 Armstrong, Joe 192–195
B Bedingungen Io 69, 71 Scala 148, 150
C Clojure 15, 237–286 Agenten 277–278 anonyme Funktionen 253, 255 Atome 275, 277 Ausdrücke 244–245 Bindungen 251, 253 Boolesche Werte 244–245 Destrukturierung 251 Forms 242 Funktionen 250–255 Futures 279, 342
Installieren 240 Java-Integration 239, 266, 280, 284 Konsole 240 Lazy Evaluation 263–266, 284 leiningen 240 Lesbarkeit 285 Lisp und 238, 282–283, 285 Listen 245–246, 344 Makros 270–271 Maps 248–249 mathematische Operationen 241– 242 Metadaten 279 Multimethoden 280 Mustererkennung (Pattern Matching) 344 Nebenläufigkeit 273–280, 283 Präfix-Notation 285 Programmiermodell 340 Protokolle 266 Referenzen 273, 275 Rekursion 259–260 repl 240 Schöpfer 255–258 Schwächen 284, 286 Sequenzen 260, 266–269 Sets 247–248 Stärken 282, 284 Strings 243–244 Thread-Zustand 280 Transactional Memory 273, 275, 343
350 Codeblöcke, Ruby 38 Typisierungsmodell 242 Unendliche Sequenzen 264–266 Vektoren 246–247 Codeblöcke, Ruby 38 Currying Haskell 308–309 Scala 174
D Datenstrukturen 13 deklarative Sprache, Prolog als 98 Dekorte, Steve 59 domänenspezifische Sprache mit Scala 188 domänenspezifische Sprachen mit Io 86 dynamische Typisierung Clojure 242 Erlang 202, 205 Ruby 30
E Entscheidungskonstrukte 13 Erlang 15, 191–235 Aktoren 193, 342 anonyme Funktionen 209–210 Atome 198, 200 Ausdrücke 197–198 Bibliotheken 234 Funktionen 202–205, 209–214 gekoppelte Prozesse 226–231 Iteration 210–211 Kontrollstrukturen 207, 209 leichtgewichtige Prozesse 193, 233 Listen 198, 200, 210–218, 344 Mustererkennung (Pattern Matching) 200–202, 344 Nachrichten 220–225 Nebenläufigkeit 192, 194, 220–225 Programmiermodell 196, 340 Schöpfer 191, 194–195 Schwächen 234–235 Stärken 233–234 Tupel 198, 200 Typisierungsmodell 202
Variablen 197–198 Zuverlässigkeit 192–193, 226–233
F foldLeft-Methode, Scala 174 Forms, Clojure 242 funktionale Programmiersprache Erlang als 196 Haskell als 288, 333–334 Nebenläufigkeit und 145–146 Scala als 162, 164 Funktionen Clojure 250, 255 Erlang 202–205, 209–214 Haskell 292–293, 318–319 Ruby 32 Scala 169–174 Funktionen höherer Ordnung Haskell 305–308 Scala 141–143 Futures 342 Clojure 279 Io 91
H Haskell 15–335 anonyme Funktionen 306 Ausdrücke 289–291 Currying 308 Filter 307–308 Folding 307–308 Function Composition 296, 298 Funktionen 292–293, 318–319 Funktionen höherer Ordnung 305 Klassen 321–322 Lazy Evaluation 309, 311 List Comprehensions 301–302 Listen 298–302, 344 Mapping 306–307 Monaden 344 Mustererkennung (Pattern Matching) 344 partiell angewandte Funktionen 307–309 Polymorphismus 318–319
logic Programmiersprachen, Prolog als 98, 339 351 Programmiermodell 288, 333–334, 340 Rekursion 294, 299–300 rekursive Typen 320–321 Schöpfer 302, 304, 312 Stärken 332–334 starten 288 Tupel 295–298 Typen 289–291, 316–322 Typisierungsmodell 288, 333 Wertebereiche (Ranges) 300–301 HaskellFunktionen höherer Ordnung 308 Hilfskonstruktoren 157
I Interaktionsmodell 12 interpretierte Sprache Io als 61 Ruby als 25 Io 14 Aktoren 90, 342 Bedingungen 69, 71 Bedingungsanweisungen 74, 76 clone, Nachricht 61 domänenspezifische Sprache mit 83, 86 forward, Nachricht 86, 88 Futures 91, 342 installieren 61 interpretiertes Modell 61 Iteration 74, 76 Listen 67, 69 Maps 68 Methoden 65, 67 Nachrichten 60, 67, 78, 81 Nebenläufigkeit 88, 91, 94 Objekte 60–61, 65, 67 Operatoren 76, 78 Performance 95 Programmiermodell 339 Prototypen 60, 67 Reflexion 81 Schleifen 75–76 Schöpfer 59, 71–72 Schwächen 94–95
slots in Objekte 61, 67 Stärken 93–94 Typisierungsmodell 64 Vererbung 63, 65 Zuweisung 77 Iteration Erlang 210–211 Io 74, 76 Scala 149–152, 170–172
J Java Clojure und 239, 266, 280, 284 Scala integriert in 143 Scala und 142, 187
K Klassen Haskell 321–322 Ruby 38, 41 Scala 154–160 kompilierte Sprache Erlang als 197 Prolog als 99 Scala als 147
L Lazy Evaluation Clojure 284 Haskell 309–311 leichtgewichtige Prozesse, Erlang 233 Lisp, Clojure und 238, 282–283, 285 Listen 344 Clojure 245–246 Erlang 198, 200, 210–218 Haskell 298–302 Io 67, 69 Prolog 115, 119 Scala 164–165, 170–174 logic Programmiersprachen, Prolog als 98, 339
352 Maps
M Maps Clojure 248–249 Haskell 306–307 Io 68 Scala 167–168 Metadaten, Clojure 279 model Erlang 202 Monaden, Haskell 323–330, 344 Mustererkennung (Pattern Matching) 344 Erlang 200–202 Scala 141–190
N Nachrichten, Io 60, 67 natürliche Sprachverarbeitung, Prolog 137 Nebenläufigkeit 341–343 Clojure 273–279, 283 Erlang 192, 194, 220–225 Io 88, 91, 94 Ruby 56 Scala 145–146, 163–164, 180–184, 187
O Objekte Io 66–67 Objektorientierte Sprachen Io als 60 Ruby als 26, 338 Scala als 338
P partiell angewandte Funktionen, Haskell 307–308 Performance Io 95 Ruby 56 Programmiermodell 12 Programmiersprachen installieren 18 lernen 12–13, 16–17
Prolog 14 Abfragen (Queries) 102, 104 Acht Damen, Beispiel 130, 135 append, Regel 120, 123 Großschreibung bei 99 Inferenz 101–102 Landkarte einfärben, Beispiel 104, 106 Listen 115, 119 Mathematik 117, 119 Mustererkennung (Pattern Matching) 344 Programmiermodell 337–340 Rekursion 113–114, 117, 119 Scheduling, Beispiel 111 Schöpfer 98 Schwächen 138 Stärken 137–138 Sudoku, Beispiel 125, 130 Tupel 115, 117 Unifikation 106, 108, 115, 117, 345 Wissensdatenbank 99 PrologUnifikation 117 Protokolle, Clojure 266–269 Prototypen, Io 67 Prototyp-Programmiersprache, Io als 60
R Reflexion, Io 81 Rekursion Clojure 259–260 Haskell 294, 299–300 Prolog 113–114, 117, 119 Scala 171 Ruby 14, 21, 57 Arrays 33–34 aus Datei ausführen 38 Codeblöcke 36 Entscheidungskonstrukte 26, 29 Entwickler von 22–23 Funktionen 32 Geschichte 22 Hashes 34, 36 installieren 24
Transactional Memory, Clojure 343 353 interaktive Console 25 interpretiertes Modell 25 Klassen 38, 41 Metaprogrammierung 45 method_missing-Verhalten 48 Mixins 41, 44 Module 41, 44, 49, 52 Nebenläufigkeit 56 offen 47 offene Klassen 46–47 Performance 56 Produkteinführungszeit (time to market) 55 Programmiermodell 26, 338 Raumschiff- (Spaceship) Operator 42 Schwächen 56–57 Skripting 54 Stärken 54–55 Typisierungsmodell 29–30 Typsicherheit 57 Verarbeitung von Strings 25 Web-Entwicklung 54
S Scala 14, 141–190 Aktoren 180–184, 342 Any, Klasse 168 Ausdrücke 148–150 Bedingungen 148–150 Collections 164–174 Currying 174 domänenspezifische Sprachen mit 188 foldLeft, Methode 174 Funktionen 169–174 Funktionen höherer Ordnung 169 Integration mitJava 143 Iteration 149–152, 170–172 Java und 142, 187 Klassen 154, 156–157, 160 Listen 164–165, 170–174 Maps 167–168 Mustererkennung (Pattern Matching) 178–180, 344
Nebenläufigkeit 145–146, 163–164, 180–184, 187 Nothing (Typ) 168 Programmiermodell 338, 340 Rekursion 171 Schöpfer 145 Schwächen 188 Sets 165–166 Stärken 186, 188 Traits 159 Tupel 154 Typen 146–147 Typisierungsmodell 150, 189 unveränderliche Variablen 163–164 Vererbung 158 Wertebereiche (Ranges) 152–154 XML und 176, 180, 188 semantisches Web, Prolog für 138 Sequenzen, clojure 260–266 Sets Clojure 247–248 Scala 165–166 Skripting, Ruby 54 Slots in Objekten, Io 61, 67 Spiele, Prolog für 138 stark typisierte Sprachen Clojure 242 Erlang 205 Haskell 288, 333 Ruby 29 Scala 147 statische Typisierung Haskell 288 Scala 150, 189 Strings Clojure 243–244 Rubys Verarbeitung von 25 syntaktischer Zucker 22
T Tarbox, Brian 111 Thread-Zustand, Clojure 280 Traits, Scala 159 Transactional Memory, Clojure 343
354 Tupel Tupel Erlang 198, 200 Haskell 295, 298 Prolog 115, 117 Typisierungsmodell 12 Clojure 242 Haskell 333 Io 64 Ruby 29, 31 Scala 146–147 Typsicherheit, Ruby 57
V
U
X
Unifikation, Prolog 345
XML Scala mit 178 XML, Scala und 180 XML, Scala und XPath 188
veränderliche Zustände, Kontrolle 341 Vererbung Io 63, 65 Scala 158
W Web-Entwicklung, Ruby 54 Wissensdatenbank, Prolog 99