Fakultät für Informatik Lehrstuhl für Logik und Verifikation Exact Real Numbers in Haskell Julia Martini Seminar Fortgeschrittene Konzepte der funktionalen Programmierung (SS15) Betreuer: Fabian Immler Leitung: Prof. Tobias Nipkow Abgabetermin: 24.06.2015 Inhaltsverzeichnis 1 Vorwort 2 2 Ansätze zur Berechnung reeller Zahlen 2 3 Realisierung exakter reeller Arithmetik durch unendliche Sequenzen 3 4 Lazy Evaluation in Haskell 6 5 Zahlen als Sequenz in Haskell definieren 6 6 Arithmetische Operationen auf Sequenzen 8 7 Zusammenfassung 9 8 Kritik 10 1 1 Vorwort Diese Arbeit beschäftigt sich mit Möglichkeiten, reelle Zahlen zu berechnen und möchte dabei insbesondere auf das Verfahren Exact Real Numbers“ auf” merksam machen und skizzieren, wie man das etwa in Haskell implementieren könnte. Im Wesentlichen geht es darum, dass Zahlen als Berechnungsvorschriften repräsentiert werden und Operatoren dadurch realisiert werden, indem Berechnungsvorschriften von Zahlen zu einer Berechnungsvorschrift für eine neue Zahl zusammengesetzt werden. Solche Berechnungsvorschriften können als Folge von Ziffern in Form von unendlichen Listen implementiert werden, die in Haskell mit Lazy Evaluation soweit wie nötig ausgelesen werden können. Es werden ein paar Beispiele an Haskell-Code gezeigt. 2 Ansätze zur Berechnung reeller Zahlen Meistens werden reelle Zahlen in Computern mit fester Genauigkeit gerechnet. Zum Beispiel mit Fließkommaarithmetik fester Stelligkeit, wie z.B. 64 Bit double-precision (Plu98). Dabei werden die vordersten Stellen einer Zahl berechnet und am Ende muss gerundet werden. Wenn nun zum Beispiel zwei Zahlen addiert werden müssen, lässt sich das Stelle für Stelle von hinten leicht realisieren mit Übertrag, weil alle Zahlen die gleiche Stelligkeit und damit auch Genauigkeit haben. Schwierig wird es, wenn sich Rundungsfehler akkumulieren und dabei größere Ungenauigkeiten entstehen können. Das Verfahren der Rechnung liefert schlichtweg keine Garantie, wie genau das Ergebnis ist, was man als einen großen Nachteil von Fließkommaarithmetik sehen kann (Plu98). Um diesen Rundungsfehlern zu begegnen, gibt es Verfahren zur Fehlerabschätzung mit denen maximale Abweichungen garantiert werden können (Plu98). Dies ermöglicht eine Einschätzung der Genauigkeit des Ergebnisses, falls jedoch ein genaueres Ergebnis benötigt wird, wird das mit diesem Verfahren nicht geliefert. Mit sogenannter Intervallarithmetik können bessere Ergebnisse erzielt werden, indem bei der Berechnung stetes ein Paar von reellen Zahlen angegeben wird, welches ein Intervall beschreibt, in dem die zu berechnende Zahl liegt (Plu98). Auch wenn dieses Verfahren eine größere Sicherheit über die Exaktheit der Ergebnisse liefert, kann es jedoch sein, dass es keine ausreichend hohe Genauigkeit zu Verfügung stellt. Ein weiterer Ansatz ist symbolische Berechnung, die vor allem in Mathematik-Programmen wie Maple, MATLAB oder Mathematica Anwendung findet. Wenn für Zahlen Berechnungsvorschriften in Form von Verknüpfung von Funktionen gegeben sind, kann eine solche Berechnungsvorschrift symbolisch“ ” 2 in andere, äquivalente Berechnungsvorschriften überführt werden, durch Ausnutzung mathematischer Umformungen. Der Nachteil eines solchen Ansatzes ist zunächst, dass er nicht immer einsetzbar ist, wenn es keine entsprechende symbolische Repräsentation oder Umformung gibt, und außerdem oft das Ergebnis einer symbolischen Umformung wieder in nummerischer Form angefragt ist, sodass dennoch weitere nummerische Darstellungen von reellen Zahlen erforderlich sind (Plu98). In dieser Arbeit untersucht ist primär das Verfahren der exakten reellen Arithmetik, mit dem man Berechnungen beliebig genau dynamisch durchführen kann. Mit exakt“ ist hier keineswegs gemeint, dass man jede reelle Zahl wirk” lich exakt, also komplett ausgibt, da es reelle Zahlen gibt, die unendlich viele Stellen haben. Sondern damit ist gemeint, dass man eine reelle Zahl auf so viele Stellen ausgeben kann, wie man sie gerade benötigt. Es gibt reelle Zahlen mit nur endlich vielen Stellen. Ein Verfahren für die exakte Berechnung reeller Zahlen könnte dann verwendet werden, um solche komplett auszugeben, ohne vorher zu wissen, wie viele Stellen sie haben. Es kann aber auch sein, dass bei unendlich-stelligen Zahlen eine flexible Genauigkeit benötigt wird, obwohl die benötigte Genauigkeit des Endergebnisses festgelegt ist. Das passiert, wenn eine Genauigkeit vorgegeben ist, die am Ende der Berechnung erreicht werden muss, aber die Genauigkeit in den Zwischenschritten – die dazu unter Umständen sehr viel größer sein muss – dann automatisch, dynamisch nach den Erfordernissen generiert werden muss, um die Genauigkeit des Endergebnisses zu gewährleisten. Das ist eine wesentliche Anforderung, die bei FließkommaArithmetik nicht gewährleistet ist, weil dort zwar eine feste Zahl an Stellen vorgegeben ist, jedoch keineswegs garantiert wird, dass dort alle Stellen des Endergebnisses richtig sind. Ein Verfahren zur exakten reellen Arithmetik, kann jedoch nicht (wie kein anderes Verfahren) unberechenbare reelle Zahlen berechnen. Insofern geht es hier um ein Verfahren zur dynamischen, beliebig genauen Berechnung berechenbarer reeller Zahlen. Das Ergebnis darf dabei nicht gerundet sein und jede Stelle muss richtig, also exakt“ sein. ” 3 Realisierung exakter reeller Arithmetik durch unendliche Sequenzen Die wesentliche Idee hinter exakter reeller Arithmetik ist das Implementieren von Datenstrukturen, die unendliche Sequenzen wiedergeben können. Damit sind keineswegs unendlich große Datenstrukturen gemeint, sondern Datenstrukturen, die nach Bedarf wachsen. Im mathematischen Sinn wird nichts anderes realisiert als eine unendliche Folge, von der nach Bedarf ein endlicher 3 Teil der Folgenglieder berechnet wird. Es gibt viele Möglichkeiten, reelle Zahlen als Folge darzustellen. So wird zum Beispiel in der Analysis die eulersche Zahl häufig als eine Folge von Partialsummen definiert: ∞ X 1 1 1 1 e= = 1 + + + + ... n! 2! 3! 4! n=0 Man könnte nun eine Datenstruktur realisieren, die sukzessive diese Folge von Partialsummen und damit die eulersche Zahl beliebig genau ausgibt (da die Folge gegen die eulersche Zahl konvergiert). Allerdings ist diese Folge spezifisch für eine ganz bestimmte Zahl. Für einen allgemeineren Ansatz muss man für jede Zahl eine Folge finden, die gegen diese konvergiert. Ein universelles Schema dafür ist die sogenannte Kettenbruchdarstellung einer Zahl. Jean Vuillemin hat einen Artikel darüber geschrieben, wie man exakte reelle Arithmetik mit Kettenbruchdarstellung realisieren kann (Vui89). Wir werden hier eine andere Folge verwenden, die gegen eine reelle Zahl konvergiert: die Dezimaldarstellung. Eine Zahl in unserer reellen Arithmetik soll also eine unendliche Sequenz von Dezimalstellen sein, was nichts anderes ist, als ein Programm, das sukzessive Dezimalstellen berechnet (Plu98). Es wird also für jede Zahl eine Berechnungsvorschrift angegeben - und keine endliche Anzahl an Stellen – und solche Berechnungsvorschriften stellen wir uns als unendliche Folgen von Dezimalstellen vor. Eine solche Dezimaldarstellung einer ∞ P Zahl z entspricht einer Folge a1 , a2 , a3 , . . . wobei a = ak · 10−i . Da eine k=0 Folge auch durch eine Funktion Repräsentiert werden kann entspricht sie der Funktion f : N − > 0, ..., 9 mit f (n) = an . Wir werden später zeigen, wie man solche Berechnungsvorschriften in Haskell realisieren kann. Reelle Arithmetik umfasst jedoch nicht nur das Repräsentieren von einzelnen reellen Zahlen, sondern es soll mit diesen Zahlen auch gerechnet werden können. Wenn also zu jeder Zahl eine Sequenz an Dezimalstellen (in Form einer Berechnungsvorschrift) in einem einheitlichen Format gefunden wurde, müssen zur Schaffung einer exakten reellen Arithmetik noch Rechenoperationen auf diesen Sequenzen definiert werden. Dazu müssen nun - weil die Zahlen in Form von Berechnungsvorschriften, die auch als Funktionen aufgefasst werden können, vorliegen – Funktionen miteinander verknüpft werden. Es muss also eine Funktion definiert werden, die als Eingabe Funktionen erhält. Um das umzusetzen, liegt es nahe, eine funktionale Programmiersprache zu verwenden. Wir werden später erläutern, warum sich insbesondere Haskell mit seiner Lazy Evaluation“ für eine einfache Umsetzung anbietet. ” Man bedenke, dass auch beim Ansatz der Fließkommaarithmetik Zahlen 4 häufig mit irgendwelchen Funktionen definiert sind, es also sehr wohl auch dort so etwas wie die Sequenzen in der exakten reellen Arithmetik geben kann. Der Unterschied ist allerdings, dass dort die Funktionen Zahlen fester Länge berechnen, die dann wiederum an andere Funktionen übergeben werden, während in der exakten Arithmetik die Sequenzen mit anderen Sequenzen zu neuen Sequenzen operiert werden, und dann erst auf Zahlen endlicher Länge heruntergebrochen werden. Um aber eine Operation auf zwei Sequenzen zu realisieren, zum Beispiel das Berechnen der Addition zweier Zahlen, gibt es mehrere Möglichkeiten und man muss sich auf eine festlegen. Während bei der Fließkomma-Arithmetik die Addition an der letzten definierten Ziffer beginnt und der Übertrag von dort mit nach vorne genommen wird, ist es bei einer Operation auf zwei Sequenzen nicht ganz so einfach, eine sinnvolle Definition zu finden. Denn wenn man die Addition zum Beispiel an Stelle x beginnt und dann keinen Übertrag bis an die erste Stelle bekommt, stellt sich die Frage, ob man nicht an einer Stelle rechts von x hätte beginnen sollen, einen Übertrag zu berechnen, der sich dann vielleicht bis an die erste Stelle durchzieht. Das sieht man an folgendem Beispiel: Sei a = 0,264 und b = 0,137. Dann gilt a1 + b 1 = 2 + 1 = 3 = c 1 a2 + b 2 = 6 + 3 = 9 = c 2 a3 + b3 = 4 + 7 = 11 = c3 Wenn nur ab der zweiten Stelle berechnet wird von rechts nach links, ist a + b = 0, 39. Wenn die dritte Stelle hinzugenommen wird ist a + b = 0, 401. Durch das hinzufügen der dritten Stelle hat sich also etwas an der ersten stelle im Ergebnis verändert. Es gibt mehrere Ansätze, um mit dieser Problematik umzugehen (also eine Definition zu wählen). So verwendet zum Beispiel Plume Ziffern, die auch negatives Vorzeichen haben können, was ihm hilft, dieses Problem zu lösen (Plu98). Jerzy Karczmarczuk verwendet in seinem Paper The most Unreliable ” Technique in the World to compute “ normale Dezimalziffern und geht dann, wenn an einer Stelle unklar ist, ob dort ein Übertrag stattfindet, in seiner Berechnung in Form eines Lookaheads so viele Schritte im Voraus nach hinten, bis die Information vorhanden ist und gibt dann erst eine weitere Stelle aus (Kar98). Was hier am besten ist, oder welche Ansätze es mit welchen Vor- und Nachteilen- gibt, soll hier jedoch nicht im Detail diskutiert werden, da hier nur einführend gezeigt werden soll, wie man vorgehen könnte, um exakte reelle Arithmetik in Haskell zu implementieren. 5 4 Lazy Evaluation in Haskell In dieser Arbeit wird skizziert, wie man eine exakte reelle Arithmetik in Haskell umsetzen könnte. Dazu müssen unendliche Sequenzen in Haskell dargestellt werden können und Operationen der Arithmetik realisiert werden. Beides wird in den folgenden Absätzen untersucht anhand von Code-Beispielen, wie man so etwas in Haskell implementieren kann. Der Erfolg für dieses Vorhaben hängt maßgeblich davon ab, wie man das Umsetzen einer beliebig genauen Berech” nung“ implementiert. Dazu gibt es eine Eigenschaft von Haskell, die Haskell für die beabsichtigten Implementierungen prädestiniert macht. Damit wird die Implementierung des beliebig genau“ einfach und effizient. ” Haskell-Programme werden lazy“ ausgewertet. Das bedeutet, dass Berech” nungen verzögert (oder gar nicht ausgeführt) werden, wenn sie nicht für andere Berechnungen benötigt werden (Has15). Das heißt, ein Argument wird nicht dann ausgewertet, wenn es übergeben wird, sondern nur wenn es auch wirklich gebraucht wird. Dieses Konzept werden wir uns im Folgenden zu Nutzen machen, wenn Sequenzen in Haskell als unendliche“ Listen implementieren. Das sind Listen, ” die unendlich lange ausgewertet werden können, aber nur so weit ausgewertet werden, wie sie gebraucht werden. 5 Zahlen als Sequenz in Haskell definieren In Haskell kann man eine Sequenz als eine Funktion implementiert werden, die das erste Element der Sequenz zurückgibt, verknüpft mit einem rekursiven Aufruf von sich selbst. Würde man diesen rekursiven Aufruf komplett auswerten, würde man eine unendliche“ Liste erhalten, das ganze würde nicht ” terminieren. Da Haskell allerdings Lazy Evaluation unterstützt, wird eine Sequenz nur so lange ausgewertet wie benötigt. Das macht es sehr einfach, mit Haskell eine Sequenz zu implementieren. Die Implementierung einer solchen Sequenz hängt allerdings von der Zahl ab, die man implementieren möchte, also muss für jede Zahl eine eigene Sequenz implementiert werden. Im einfachsten Fall ist eine solche Sequenz ohne einen Input-Parameter, weil die Sequenz sozusagen als Konstante für eine Zahl steht, und gibt dann einfach eine Ziffer verknüpft mit einem rekursiven Aufruf von sich selbst aus. Allerdings kann man natürlich auch gleich eine ganze Klasse von Zahlen mit einer parametrisierten Funktion definieren, die dann je nach Parameter die entsprechende Sequenz ausgibt. Wir werden jetzt ein paar Beispiele zeigen, wie das aussehen könnte. 6 Dabei gilt zu beachten ein einheitliches Format festzulegen, um mit den Zahlen rechnen. Beispielsweise das Dezimalsystem. Dort wäre dann zu klären an welcher stelle das Komma gesetzt werden muss. Man könnte die Position des Kommas z.B. mit einer separaten Zahl modellieren. Im Folgenden wird kein genauer Formalismus eingehalten, sonder nur gezeigt wie man Zahlen ungefähr in Haskell modellieren könnte. Beispiel 1 (Plu98): from n = ( n : from ( n+1)) Diese Sequenz bildet die Ziffernfolge n, n+1, n+2, . . . und definiert somit für jedes n eine eigene Sequenz. Hierbei würde es sich nicht um ein Dezimalsystem handeln, weil an jeder Stelle beliebig große natürliche Zahlen stehen dürfen. Beispiel 2 (Kar98): t w o s e v e n t h s = 0 : cycle [ 2 , 8 , 5 , 7 , 1 , 4 ] Diese Funktion implementiert die Sequenz von Ziffern, welche die Zahl 2/7 darstellt, die nämlich periodisch ist, also: 0, 285714. Beispiel 3 (Kar98): s Z e r o = repeat 0 Gibt einfach eine Folge von Nullern als Ziffern zurück. Beispiel 4: z w e i h u n d e r s i e b z e h n = [ 2 , 1 , 7 ] : repeat 0 Jede Zahl mit endlich vielen Ziffern kann wie in diesem Beispiel auch als Sequenz dargestellt werden, indem einfach die endlich vielen Ziffern aufgeführt werden und danach die 0-Sequenz angehängt wird. Die Stelle des Kommas ist Definitionssache. Beispiel 5 (Kar98): f c n n d = l e t ( a , b ) = quotRem n d in a : f c n ( 1 0 ∗ b ) d Die Funktion fcn gibt für jede rationale Zahl nd eine dazugehörige Sequenz in Dezimaldarstellung zurück. Die dabei verwendete Funktion quotRem liefert dabei ein Tupel (a,b) mit a = n ÷ d = n mod d (Ganzzahldivision) und b = n - (a · d) (b ist der Rest von n : d). 7 6 Arithmetische Operationen auf Sequenzen Um nun mit dieser Art der Repräsentation von Zahlen auch rechen zu können, müssen arithmetische Operationen definiert werden. Ebenso wie wir gerade nur für ein paar Zahlen beispielhaft gezeigt haben, wie man sie darstellen kann, kann man höchstens beispielhaft zeigen, wie diverse arithmetische Operationen definiert werden könnten, da es mindestens so viele Operationen gibt wie es Funktionen von den reellen Zahlen in die reellen Zahlen gibt und das sind ganz schön viele. Sicherlich wäre es hilfreich, grundlegende Operationen wie die Addition zu implementieren. Eine sehr einfacher Vorschlag für eine Addition ist folgender: Beispieladdition 1 (Plu98): add ( a : x ) ( b : y ) = ( a + b ) : ( add x y ) Diese Addition würde bei einigen wenigen Sequenzen ganz gut funktionieren. Zum Beispiel wenn man die Zahl nimmt, die nur aus Einsern besteht und zu der Zahl addiert, die nur aus Zweiern besteht, was die Zahl ergeben würde, die nur aus Dreiern besteht, indem alle Ziffern paarweise addiert wurden. Schwieriger wird es schon, wenn ein Überlauf berücksichtigt werden soll und ein Übertrag behandelt werden soll. Wir zeigen noch ein ausgeklügelteres Beispiel für eine Addition: Beispieladdition 2 (Kar98): u <+> v = l e t (w0 : wq) = zipWith (+) u v in c p r w0 wq where c p r u0 ( u1 : uq ) | u1<9 = u0 : c p r u1 uq | u1>9 = ( u0+1) : c p r ( u1 −10) uq | otherwise = l e t v@( v0 : vq)= c p r u1 uq in i f v0<10 then u0 : v e l s e ( u0+1) : ( v0 −10) : vq Hier wird zunächst alles elementweise addiert und der Übertrag wird durch einen Lookahead bestimmt, der entsprechend viele Schritte vorausberechnet, um den Übertrag feststellen zu können, bevor der eigentliche nächste Rekursionsschritt ausgeführt wird. In der Fallunterscheidung beim Lookahead wird an jeder erreichten nachfolgenden Stelle folgende drei Fälle überprüft: Wenn die Ziffer kleiner 9 ist, kann es keinen Übertrag auf alle Ziffern links davon geben, sodass dies als exakt ausgegeben werden können und der aktuelle Lookahead beendet ist. Wenn die Ziffer größer 9 ist, gibt es einen Übertrag nach links und der Lookahead ist beendet, weil nach dem Übertag die Ziffern links davon exakt bestimmt sind. Falls die Ziffer gleich 9 ist, muss der Lookahead weiter nach rechts fortgesetzt werden, weil weiter rechts noch ein Übertrag notwendig 8 werden könnte, der dann weiter gegeben werden müsste. Es könnte Probleme geben, wenn Zahlen addiert werden, sodass der Lookahead für bereits das Prüfen des Übertrags an einer einzigen Ziffer unendlich weit nach hinten gehen müsste. Das bedeutet, bei dieser Implementierung kann es sein, dass der Lookahead nicht terminiert. Egal wie viele Schritte aber für den Lookahead (der nicht-lazy-rekursiv ausgewertet wird) nötig sind, werden bei der eigentlichen Rekursion, die lazy ausgewertet wird, nur Stellen ausgegeben, bei denen die Addition stimmt. Das heißt, es kommt zu keinerlei Abschätzungsfehlern bei den lazy ausgewerteten Stellen der Addition zweier Sequenzen. Das einzig Limitierende ist der zur Verfügung stehende Speicher, aber die Implementierung ermöglicht es zumindest bei genügend Ressourcen, entsprechend genaue Resultate zu liefern. Vom Prinzip her werden zwei Sequenzen zu einer zusammengefasst, welche beliebig genau ausgewertet werden kann, indem die zusammengefassten Sequenzen so weit wie nötig ausgewertet werden. Zwei Sequenzen werden dabei zu einer neuen verknüpft. Manche arithmetische Operationen lassen sich dabei leichter implementieren als andere. Beispielsubtraktion (Kar98): Eine Subtraktion kann durchgeführt werden mithilfe von Komplementbildung und Addition. Dazu kann folgende Funktion für ein 9-Komplement genutzt werden: neg ( u0 : uq ) = ( negate u0 − 1 ) : map ( 9 −) uq 7 Zusammenfassung Wir haben Gründe genannt, warum es sinnvoll sein kann, reelle Zahlen im Rechner ohne Rundung dynamisch auf nötig viele Stellen genau zu berechnen. Dann haben wir beschrieben, wie solch eine exakte reelle Arithmetik über Sequenzen realisiert werden kann und wie solche Sequenzen in der Programmiersprache Haskell mithilfe von Lazy Evaluation implementiert werden können. Dazu haben wir Beispiele genannt von Zahlen, die als Sequenz definiert wurden. Damit mit Zahlen in einer solchen Repräsentation auch gerechnet werden kann, müssen Operationen definiert werden, die zwei Sequenzen lazy zu einer neuen verknüpfen. Wir haben Beispiele genannt, wie eine solche Verknüpfung in Haskell realisierbar ist. 9 8 Kritik Ein Problem bei exakter reeller Arithmetik könnte sein, dass es passieren könnte, dass bei der Definition einer Rechenoperation auf mehrere Sequenzen (man könnte ja beliebig komplizierte Operationen definieren wollen) es Worst-Case-Zahlen geben könnte, welche sich mit dieser Operation schlecht verknüpfen lassen. Mit schlecht“ ist hier die Effizienz gemeint. Es könnte ” sein, dass eine Operation die zu verknüpfenden Sequenzen sehr weit auswerten muss, um nur eine einzige Stelle selbst auswerten zu müssen. Diese Schwierigkeit wurde auch in der Arbeit von Plume beobachtet (Plu98). Ein weiteres Problem ist die Unvorhersagbarkeit, wie viel Zeit und Speicher benötigt ist, um eine Sequenz auf eine gewünschte Genauigkeit hin auszuwerten. Zwar hat man beim Verfahren der Berechnung exakter reeller Zahlen eine Garantie, dass jede Stelle der Ausgabe stimmt, aber man hat eben nicht immer eine Garantie, dass man auch so viele Stellen bekommt, wie man gerne hätte, bzw. die verwendete Rechnung terminiert, weil nicht immer absehbar ist, welcher Rechenaufwand damit verbunden ist. Auch lässt sich die Berechnung dieser Art von Arithmetik nicht durch Hardware-Implementierungen effizienter gestalten, da mehr als nur ein konstanter Speicherbedarf nötig ist. Nachdem allerdings mit der Berechnung exakter reeller Zahlen Berechnungen durchgeführt werden können, die mit gängigen Methoden wie FließkommaArithmetik nicht durchgeführt werden können, kann es je nach Anwendung seine Daseinsberechtigung haben. Wie man exakte reelle Arithmetik im Detail effizient umsetzt, wäre nun über diese Arbeit hinausgehend näher zu untersuchen. 10 Literatur [Has15] Lazy evaluation - HaskellWiki. https://wiki.haskell.org/Lazy evaluation. Version: 10.03.2015 [Kar98] Karczmarczuk, Jerzy: The Most Unreliable Technique in the World to compute pi. (1998) [Plu98] Plume, Dave: A Calculator for Exact Real Number Computation 4th year project Departments of Computer Science and Artificial Intelligence University of Edinburgh. http://www.dcs.ed.ac.uk/home/mhe/plume/. Version: 1998 [Vui89] Vuillemin, Jean: Exact real computer arithmetic with continued fractions. (1989) 11