Das Clojure-Alphabet – Teil 2

B wie BigInteger und BigDecimal

B wie BigInteger und BigDecimal

Das Clojure-Alphabet – Teil 2

B wie BigInteger und BigDecimal


Moment mal – Java-Datentypen in einer Clojure-Serie? Jein! Clojure ist nicht nur in der Lage, mit Java-Klassen zu interagieren, sondern benutzt auch einige Klassen aus der Java-Standardbibliothek in grundlegenden Sprachkonzepten. Wieso sollte man gute und erprobte Datentypen nicht weiterverwenden, gerade wenn sie passenderweise auch noch selbst immutable sind? Im Folgenden zeigen wir euch, wie Clojure BigDecimal nutzt, wie es BigInteger etwas besser macht und die Arbeit mit beiden in gewohnte Konzepte einbettet.

In Java wird auf BigInteger und BigDecimal zurückgegriffen, um entweder beliebig große Zahlen zu verarbeiten oder, im Falle von BigDecimal, Berechnungen mit hoher Präzision anzustellen. Diese Eigenschaften können wir in Clojure ebenfalls gut brauchen. Naheliegend wäre es, die beiden Datentypen mit Java-Interop-Code zu erstellen. In Listing 1 sehen wir, wie sich das gestalten könnte: Ein Java-Klassenname mit einem Punkt am Ende ist die Kurzschreibweise für den Konstruktoraufruf einer Klasse. In beiden Fällen wird dieser Konstruktor mit einem Stringparameter aufgerufen, um das Objekt mit einem Wert zu instanziieren. Im Falle vom BigDecimal sticht hierbei sofort ins Auge, dass der resultierende Wert als 2.4M ausgedrückt wird. Eine Zahl mit angehängtem M stellt in Clojure ein Literal für ein BigDecimal dar. Anstelle von (java.math.BigDecimal. "2.4") kann man die Zahl also einfach als 2.4M ausdrücken. Gleichzeitig fällt auf, dass keine ähnliche Notation für den BigInteger zurückgegeben wird. Tatsächlich wird Javas BigInteger nicht im selben Maß in Clojure genutzt wie BigDecimal. Clojure verwendet hier den eigenen Typ clojure.lang.BigInt, er wird mit dem Postfix N an einer Zahl ausgedrückt. Wieso wird hier ein Unterschied gemacht?

Listing 1

(java.math.BigDecimal. "2.4")
;=> 2.4M
 
(java.math.BigInteger. "99999999999")
;=> 99999999999  

Besonderheiten bei BigInt

Hauptsächlich ist der Grund für den eigenen Datentyp Effizienz. Ein Blick in die Klasse [1] zeigt, dass BigInt in der internen Repräsentation sowohl einen Wert vom Typ Long, als auch einen Wert vom Typ java.math.BigInteger hält und diese nach außen hin kapselt. Mit diesem Vorgehen speichert ein BigInt Zahlenwerte bis hin zum Maximalwert von Long intern als ein Long Value, darüber hinaus erst als BigInt. Durch dieses Vorgehen soll bei Rechenoperationen mit verschiedenen Datentypen unnötiges Autoboxing vermieden werden.

Eine weitere Besonderheit zu BigInt findet sich bei den Literalen. Das oben genannte Postfix N ist nämlich nur die halbe Wahrheit. Wird von Clojure ein „gewöhnliches“ numerisches Literal interpretiert, das sich nicht mehr in einem Long speichern lässt, wird der Typ automatisch als ein BigInt gesetzt (Listing 2). Zu beachten ist hierbei, dass der Standardtyp für numerische Literale ein java.lang.Long ist. Hier mag sich die Frage stellen, ob eine automatische Typauswahl nicht Gefahren mit sich bringt – wissen wir doch aus der Java-Welt, dass sich die Operationen mit BigInteger und BigDecimal grundlegend von denen primitiver numerischer Datentypen unterscheiden.

Listing 2

(type 999999999)
;=> java.lang.Long
 
(type 999999999N)
;=> clojure.lang.BigInt
 
(type 9999999999999999999) 
;=> clojure.lang.BigInt

Funktionen für Rechenoperationen

Wie bereits in der letzten Ausgabe dieser Serie beschrieben, kennt Clojure keine Operatoren wie sie in Java verwendet werden, sondern ausschließlich Funktionen. Beispielsweise kann die Funktion + verwendet werden, um zwei numerische Werte zu addieren. Der Quellcode der Funktion (ohne Docstring und Metadaten) findet sich in Listing 3.

Listing 3

(defn +
  ([] 0)
  ([x] (cast Number x))
  ([x y] (. clojure.lang.Numbers (add x y)))
  ([x y & more]
    (reduce1 + (+ x y) more))) 

Zunächst lässt sich an diesem Listing erkennen, dass die Funktion überladen ist – sie akzeptiert 0, 1, 2 oder mehr Parameter. Beim parameterlosen Aufruf gibt sie einfach 0 zurück, das neutrale Element der Addition, beim Aufruf mit einem Parameter wird der Parameter zurückgegeben. (+) und (+ 1) sind also valide Aufrufe. Werden exakt zwei Parameter übergeben, wird die Methode add der Klasse clojure.lang.Numbers aufgerufen. Diese kapselt die eigentlich interessante Logik: die Behandlung verschiedener Datentypen vom Typ java.lang.Number. Diese lassen sich nämlich mit der Funktion + beliebig kombinieren (Listing 4). Werden mehr als zwei Parameter übergeben, wird so lange über sie mit der Funktion + reduziert, bis die Parameterliste abgearbeitet ist.

Listing 4

(+ 1 2)
;=> 3
 
(+ 1.2 3)
;=> 4.2
 
(+ 1.2M 3)
;=> 4.2M
 
(+ 1.2M 3N)
;=> 4.2N 

Ganz ähnlich verhalten sich die Funktionen - für Subtraktion und * für Multiplikation. Grundsätzlich gilt das auch für die Division mit der Funktion /, diese leistet jedoch darüber hinaus noch ein wenig mehr, da sie sich um das Konzept der Präzision kümmern muss. Wird mit ganzzahligen Typen dividiert und ist das Ergebnis nicht in einer ganzen Zahl repräsentierbar, gibt die Funktion einen Wert vom Typ Ratio zurück. Dieser stellt einen Bruch dar und vermeidet eine unnötige Konvertierung in eine Gleitkommazahl, die mit einem Verlust an Präzision einherginge. Divisionen mit Double-Werten liefern hingegen Doubles zurück, inklusive sämtlicher Ungenauigkeiten, die diese mit sich bringen.

Die Division mit BigDecimal ist nochmals spezieller. Nicht etwa, weil ähnlich wie in Java spezielle Operationen für BigDecimal genutzt werden müssen, sondern weil bei Rechenoperationen mit BigDecimal generell eine ArithmeticException geworfen wird, wenn kein exaktes Ergebnis ausgedrückt werden kann und keine Präzision für die Operation angegeben wird. Die Funktion / selbst akzeptiert keine Parameter für Präzision oder Rundungsmodi. Stattdessen bietet Clojure uns das Makro with-precision an, um eine bessere Kontrolle über unsere Rechenoperationen ausüben zu können. Beispiele für die beschriebenen Szenarien in der Division sind in Listing 5 aufgeführt. with-precision ist dabei nicht für jeden einzelnen Rechenschritt anzugeben, sondern gilt für alle Rechenoperationen, die es umschließt. Für einzelne Teilrechnungen lässt sich auf diese Weise die jeweils erforderliche Genauigkeit definieren.

Listing 5

(/ 3 2)
;=> 3/2
 
(/ 3N 2N)
;=> 3/2
 
(/ 1.2 2.3)
;=> 0.5217391304347826
 
(/ 2.2M 1.1M)
;=> 1.1M
 
(/ 2M 3M)
;=> java.lang.ArithmeticException
 
(with-precision 5 :rounding HALF_DOWN (/ 2M 3M))
;=> 0.66667M 

Auto-Promotion

Im bisherigen Text wurde bereits erörtert, dass, je nach der Größe der beschriebenen Zahl, die Interpretation numerischer Literale entweder in einem Long oder einem BigInteger resultiert. Des Weiteren haben wir gezeigt, dass dieser Mechanismus Entwickler im Alltag bei ihrer Verwendung nicht vor Probleme stellt, da die Funktionen +, -, * und / mit sämtlichen Datentypen vom Typ java.lang.Number funktionieren.

Was geschieht nun, wenn der Wertebereich eines Datentyps durch eine Rechenoperation überschritten wird? In diesem Fall erhalten wir eine ArithmeticException, es erfolgt keine automatische Erweiterung des Datentyps, eine sogenannte Auto-Promotion. Die Sprache liefert jedoch Werkzeuge, um genau dieses Verhalten zu erreichen: die sogenannten Tick-Varianten der bereits besprochenen Funktionen. Diese werden durch ein Hochkomma am Ende des Funktionsnamens markiert und lassen sich nutzen, um Rechenoperationen mit Auto-Promotion robuster zu gestalten (Listing 6).

Listing 6

(+ java.lang.Long/MAX_VALUE 1)
;=> java.lang.ArithmeticException
 
(+' java.lang.Long/MAX_VALUE 1)
;=> 9223372036854775808N
 
(+ (bigint java.lang.Long/MAX_VALUE) 1)
;=> 9223372036854775808N
 
(*' java.lang.Long/MAX_VALUE java.lang.Long/MAX_VALUE)
;=> 85070591730234615847396907784232501249N  

Die hier beschriebenen Funktionen sind darüber hinaus noch für eine kleine Kuriosität verantwortlich. Clojure weist sich seit Version 1.0 durch eine wirklich vorbildliche Stabilität des API aus. Der einzige API-Bruch, der uns Autoren in Erinnerung geblieben ist, ist die Änderung der Auto-Promotion-Regel bei Clojure 1.3: Die Bedeutung der Tick-Varianten wurde vertauscht. Das geschah 2011, zwei Jahre nach Version 1.0 im Jahr 2009. Andere Breaking Changes sind zumindest nicht störend in Erinnerung geblieben. Seit zehn Jahren herrscht nun angenehme Ruhe, obwohl sich die Sprache konsequent weiterentwickelt. Alle Änderungen über die Versionen ab 1.3 hinweg sind auf GitHub [2] zu finden

Späte Entscheidungen und Quellcodeirritationen

Zum Abschluss unseres kurzen Ausflugs in die Besonderheiten arithmetischer Operationen in Clojure möchten wir erneut die Vorteile des funktionalen Ansatzes der Sprache hervorheben. Dadurch, dass Rechenoperationen nicht auf Operatoren, sondern auf Funktionen zurückgreifen, entsteht eine enorme Flexibilität. Die angebotenen Funktionen können die gängigen JVM-Typen behandeln und miteinander kombinieren, während die Operatoren in Java sich nicht auf gleiche Art verwenden lassen. Stellt man sich beispielsweise vor, dass eine existierende Java-Codebasis von Long oder Double auf BigInteger oder BigDecimal umgestellt werden soll, ist das nicht ohne Breaking Changes und viel Fleißarbeit möglich. Anders in Clojure: Zwar können auch hier durch ungenügende Präzision oder Auto-Promotion kleinere Anpassungen nötig werden, die grundlegenden Funktionen können jedoch in Gänze weiterverwendet werden.

kueper_ingo_sw.tif_fmt1.jpgIngo Küper studierte Angewandte Mathematik und Informatik, ist geschäftsführender Gesellschafter der doctronic GmbH & Co. KG, bildet seit 20 Jahren Fachinformatiker aus und blickt auf über 35 Jahre Erfahrung in der Softwareentwicklung und -architektur zurück.

Mail

zoeller_tim_sw.tif_fmt1.jpgTim Zöller arbeitet seit über 13 Jahren als Softwareentwickler, davon über 10 Jahre mit Java. Mit der von ihm gegründeten Firma lambdaschmiede GmbH in Freiburg unterstützt er Kunden in Softwareprojekten und entwickelt eigene Software mit Clojure.

Mail
Tim Zöller

Tim arbeitet seit 2008 mit der JVM, angefangen bei Desktopapplikationen, über Java EE bis hin zu Spring Boot – am liebsten schreibt er jedoch Applikationen mit Clojure. Er ist Gründer der lambdaschmiede GmbH aus Freiburg.

Ingo Küper

Ingo Küper studierte Angewandte Mathematik und Informatik, ist geschäftsführender Gesellschafter der doctronic GmbH & Co. KG, bildet seit 20 Jahren Fachinformatiker aus und blickt auf über 35 Jahre Erfahrung in der Softwareentwicklung und -architektur zurück. Mail: kueper@doctronic.de


Weitere Artikel zu diesem Thema