Das prägende Merkmal von lazy
ist, dass das val
nicht sofort initialisiert wird sondern einmalig, bei Bedarf. Wenn der erste Zugriff erfolgt wird der Ausdruck ausgewertet und das Ergebnis dem Namen des val
zugewiesen. Nachfolgende Zugriffe führen nicht zu wiederholter Auswertung: es wird stattdessen sofort das gespeicherte Ergebnis zurückgegeben.
Mit der oben stehenden Beschreibung, scheint es, als ob „Auswertung bei Bedarf“ harmlos ist und keine negativen Effekte hat, also warum fügt man lazy
nicht immer hinzu, sozusagen als eine spekulative Optimierung? In Kürze werden wir sehen warum eben das meist keine gute Idee ist, aber bevor wir dieses Thema vertiefen, sollten wir erst einen genaueren Blick auf die Semantik von lazy val
werfen.
Wenn wir wie folgt einen Ausdruck einem lazy val
zuweisen:
lazy val two: Int = 1 + 1
erwarten wir, dass der Ausdruck 1 + 1
an den Namen two
gebunden wird, jedoch noch keine Auswertung stattfindet. Beim ersten, und nur beim ersten Zugriff auf two
wird der gespeicherte Ausdruck 1 + 1
ausgewertet und das Ergebnis zurückgegeben. Nachfolgende Zugriffe auf two
führen nicht zu weiteren Auswertungen: stattdessen wurde das Ergebnis der Auswertung gespeichert und wird nun jedes Mal zurückgegeben.
Die Eigenschaft, das Auswertung nur einmal stattfindet ist dabei sehr wichtig. Insbesondere wird dies deutlich, wenn man an Szenarios denkt, bei denen nebenläufige Ausführung stattfindet. Wie, zum Beispiel, soll das Verhalten aussehen, wenn zwei oder mehr Threads gleichzeitig auf ein lazy val
zugreifen wollen? Die oben erwähnte Eigenschaft, dass Auswertung nur einmal stattfindet, bedingt eine Form der Synchronisierung um mehrfache Auswertungen zu vermeiden. In der Praxis wird also ein Thread die Auswertung durchführen, während alle anderen Threads warten müssen. Erst wenn die Auswertung des zugewiesenen Ausdrucks abgeschlossen ist, können die wartenden Threads das Ergebnis lesen.
Und wie ist das in scalac
, dem aktuellen Standard Scala Compiler implementiert? Dazu können wir ein Blick auf SIP-20 werfen. Im Beispiel wird dort eine Klasse LazyCell
mit einem lazy val
„value“ definiert:
final class LazyCell {
lazy val value: Int = 42
}
Eine handgeschriebene Version des Codes, den der Compiler für LazyCell
generiert sieht in etwa so aus:
final class LazyCell {
@volatile var bitmap_0: Boolean = false // (1)
var value_0: Int = _ // (2)
private def value_lzycompute(): Int = {
this.synchronized { // (3)
if (!bitmap_0) { // (4)
value_0 = 42 // (5)
bitmap_0 = true
}
}
value_0
}
def value = if (bitmap_0) value_0 else value_lzycompute() // (6)
}
In (3)
sehen wir die Verwendung eines Monitors via this.synchronized {...}
um zu garantieren, dass die Auswertung auch bei der Verwendung durch mehrere Threads nur einmal stattfindet. Der Compiler verwendetet eine Booleschen Variable ((1)
) um den Initialisierungsstatus ((4)
& (6)
) der Variablen value_0
((2)
) zu verwalten. Diese speichert das finale Ergebnis der Auswertung und wird nach der initialen Auswertung gesetzt ((5)
).
In der obigen Implementierung wird außerdem deutlich, dass ein lazy val
– anders als ein normales val
– bei jedem Zugriff zuerst den Status des Booleschen Flags überprüft ((6)
). Das sollte man auf jeden Fall im Hinterkopf behalten, bevor man (versucht) lazy val
als kostenlose Optimierung zu verwenden.
Nun da wir ein besseres Verständnis der zugrunde liegenden Mechanismen des lazy
Keywords haben, können wir einen Blick auf eine Handvoll Szenarios werfen, bei denen es interessant wird.