Closure

Lesezeit: 11 Minuten

Eine Closure ist eine „Funktion erster Klasse“ (first-class function) inklusive der lokalen Variablen ihres Entstehungskontextes. Die Funktion hat auf diese Variablen Zugriff, auch außerhalb des Gültigkeitsbereiches der übergeordneten Funktion bzw. auch, nachdem die übergeordnete Funktion bereits durchlaufen ist. Darum wird Closure im Deutschen auch als Funktionsabschluss übersetzt.

Das Konzept der Closure ist in den 1960ern im Rahmen des Lambda-Kalküls entwickelt und kurz darauf erstmalig vollständig in einer Programmiersprache umgesetzt worden.

Eine nähere Betrachtung

Das folgende Beispiel ist in JavaScript geschrieben, da diese Sprache von Closures intensiv Gebrauch macht und diese vollständig unterstützt.

let zahl = (function() {
  let wert = 0;
  function set(neu) { 
    wert = neu; 
    console.log(wert); }
  return {
    create: function(x) { set(x) },
    add: function(x) { set(wert + x) },
    sub: function(x) { set(wert - x) },
    square: function() { set(wert * wert) }
  }
})();

zahl.create(10);
zahl.add(2);
zahl.sub(7);
zahl.square();

Auf den ersten Blick könnte man meinen, dass es sich bei zahl um ein Objekt handelt, denn es können diverse mathematische Operationen an dieser Variablen durchgeführt werden, die ihren Wert verändern. Wie aber in der allerersten Zeile deutlich wird, handelt es sich tatsächlich um eine Funktion, die als Rückgabewert selbst mehrere Funktionen hat, welche in einer Struktur gebündelt zur Verfügung gestellt werden.

Das alleine macht zahl aber noch nicht zu einer Closure. Schaut man sich die zurückgegebenen Funktionen genauer an, die sich hinter den Feldern create, add, sub und square verbergen, stellt man fest, dass diese auf die Variable wert in der übergeordneten namenlosen Funktion zugreifen können. Bemerkenswert ist hierbei, dass dieser State (Zustand) erhalten bleibt, auch nachdem die namenlose Funktion durchlaufen worden ist. Als lokale Variable kann wert nach Verlassen der namenlosen Funktion nicht mehr referenziert werden und wird bei einer Sprache mit automatischer Speicherbereinigung im Anschluss zum Löschen freigegeben. Über die Closure kann jedoch weiter auf wert bzw. eine Kopie davon zugegriffen werden, solange die Closure selbst weiter referenziert werden kann. Bei weiteren Aufrufen der zurückgegebenen Funktionen bleibt der vorherige Inhalt von wert erhalten. Dabei teilen sich die vier Funktionen den State.

Lexikalische vs. dynamische Bindung

Sprechen wir von Closure, dann meinen wir genau genommen lexikalische Closure. Der Begriff bezieht sich auf den Sichtbarkeitsbereich der Variablen, die als State über die Closure gebunden werden können. Die meisten bekannten Programiersprachen unterstützen ausschließlich lexikalisch gebundene Variablen. Sehr wenige Sprachen verwenden nur dynamisch gebundene Variablen und einige wenige überlassen dem Entwickler bei der Deklaration die Wahl, ob die Variable lexikalisch oder dynamisch gebunden werden soll. Ein bekannter Vertreter der letzten Variante ist die Sprache Perl.

Lexikalische Bindung bedeutet Bindung an den Quellcode. Dynamische Bindung bedeutet Bindung an die Ausführungsschicht zur Laufzeit. Die für Closures relevante lexikalische Bindung unterstützt je nach Sprache ganz verschiedene Sichtbarkeitsbereiche. So gibt es in Java mehrere Varianten von lokalen Variablen: Eine lokale Variable kann innerhalb einer Methode gelten, sie kann aber auch nur innerhalb eines Blockes gültig sein, so dass in einem nachfolgenden Block in derselben Methode eine zweite Variable mit dem gleichen Namen deklariert werden kann. Diese beiden Variablen teilen sich dann den Namen, haben aber ansonsten keinen Bezug zueinander. In anderen Sprachen gibt es globale Variablen, die überall verfügbar sind und Variablen, die nur innerhalb eines Moduls global sind.

Closure vs. Objekt

Closures kommen ursprünglich aus der funktionalen Programmierung. Kurioserweise ist die Vermeidung von Seiteneffekten und (mutable) State ein zentrales Element funktionaler Programmierung, während Closures sich ja gerade über ihren State definieren.

Davon ab kann man sagen, dass in einer objektorientieren Programmiersprache alles, was als Closure formuliert werden kann, auch ohne Closure abgebildet werden kann. Wie das eingangs gezeigte JavaScript-Beispiel demonstriert, kann eine Komposition von Closures allerdings auch ganze Objekte abbilden.

Vorteile einer Closure

Grundsätzlich ermöglichen Closures eine Kapselung, die in einigen Sprachen auf anderem Wege nur schwer erreicht werden kann. Möchte man z.B. in JavaScript eine Information global verwalten können, so gelingt dies nur per Definition einer globalen Variable. Die Validität einer Operation an dieser globalen Variable kann in dem Fall aber nicht sichergestellt werden, da diese Variable von überall auf einen beliebigen Wert gesetzt werden kann. Über eine Closure ist es hingegen möglich, den Wert nur über definierte zulässige Operationen zu manipulieren. Das JavaScript-Beispiel oben könnte mit Leichtigkeit dahingehend erweitert werden, dass zu jedem Zeitpunkt gewährleistet ist, dass der Wert der Zahl immer zwischen 0 und 100 verbleibt.

Je stärker Objektorientierung in einer Sprache vorhanden ist, desto geringer wiegt der Kapselungsvorteil einer Closure. Sprachen wie Java erlauben es für Felder und Methoden die Sichtbarkeit in mehreren Stufen festzulegen und damit die gleiche Kapselung zu erreichen wie mit einer Closure. In Python hingegen, einer multiparadigmatischen Sprache mit umfangreicher Unterstützung für Objektorientierung, gibt es keine wirklich privaten Felder und Methoden, so dass mit Closures eine effektivere Kapselung erreicht werden kann.

Je nach Sprache und Stil kann es auch einfach eleganter sein, eine Funktionalität als Closure abzubilden anstatt den State einer Klasse zu erweitern oder eine neue Klasse zu definieren. Zufallsfunktionen beispielsweise verlangen oftmals einen Startwert (Seed), der sich wunderbar in einer Closure kapseln lässt.

Voraussetzungen für die Umsetzung einer Closure

Um Closures in einer Sprache umsetzen zu können, müssen folgende Möglichkeiten gegen sein:

  • Eine Funktion muss Funktionen zurückgegeben können
  • Die innere Funktion muss auf die Variablen der äußeren zugreifen können
  • Der Compiler muss den Wert außerhalb des eigentlichen Sichtbarkeitsbereiches bereitstellen

Stack vs Heap

Von den oben genannten Punkten beschreiben die ersten beiden das, was eine Closure per Definition ausmacht. Aber was hat es mit dem dritten Punkt auf sich? Vereinfacht dargestellt werden Funktionsaufrufe im Speicher über einen Stack (Stapel) abgebildet. Jeder Aufruf einer Funktion aus einer anderen heraus führt dazu, dass ein neuer Datensatz auf dem Stack abgelegt wird, in dem die lokalen Informationen dieser Funktion abgelegt werden. Ist die Funktion durchlaufen, kann der Datensatz wieder vom Stack entfernt werden und ganz oben liegt wieder der Datensatz der aufrufenden Funktion, die nun weiter abgearbeitet wird.

Um Closures abbilden zu können, muss dieses Konstrukt erweitet werden, denn Closures kapseln ja den State der übergeordneten Funktion und stellen ihn noch dann zur Verfügung, wenn diese Funktion bereits durchlaufen ist und der Datensatz vom Stack entfernt werden kann. Eine Möglichkeit damit umzugehen ist Closures im Heap zu verwalten. In diesem Speicherbereich werden üblicherweise Objekte vorgehalten, die nicht per Wert (pass by value), sondern per Referenz (pass by reference) in eine Funktion gegeben werden. Für eine automatische Speicherbereinigung werden diese Objekte regelmäßig auf Referenzen aus anderen Objekten heraus überprüft und erst dann aus dem Speicher entfernt, wenn sie an keiner Stelle mehr referenziert werden.

First-class functions und higher-order functions

Ermöglicht eine Sprache es, Funktionen als Parameter an andere Funktionen zu übergeben oder Funktionen aus Funktionen zurückzugeben und damit über Variablen referenzierbar zu machen, spricht man von first-class functions. Eine Funktion, die als Argument eine Funktion erwartet oder eine Funktion als Rückgabewert hat, nennt man higher-order function.

Closures in verschiedenen Programmiersprachen

Viele Sprachen unterstützen Closures. Die nachfolgenden Beispiele zeigen, wie Closures in JavaScript, Python, Go, Ruby und Java umgesetzt werden können. Ziel der Implementierungen ist es, zehn Zahlen der Fibonacci-Folge wie folgt auszugeben:

fib 1
fib 2
fib 3
fib 5
fib 8
fib 13
fib 21
fib 34
fib 55
fib 89

Dies soll alleine durch den zehnmaligen Aufruf einer Closure erreicht werden.

JavaScript

Closures sind elementarer Bestandteil der Sprache JavaScript. Da Funktionen in JavaScript immer Zugriff auf den übergeordneten Sichtbarkeitsbereich haben, kann man strenggenommen sogar sagen, dass jede Funktion in JavaScript eine Closure ist.

fib = fibonacci();
for (let i=0; i<10; i++) {
    fib();
}

function fibonacci() {
    let prv = 0;
    let nxt = 1;
    return function nextfib() {
        nxt = prv + nxt;
        prv = nxt - prv;
        console.log("fib: " + nxt);
    }
}

Python

Python bietet seit Python 3 mit dem Keyword nonlocal Unterstützung für Closures. Ohne dieses Schlüsselwort würden im nachfolgenden Beispiel die Variablen prv und nxt innerhalb Funktion nextfib als lokale Variablen dieser Funktion gelten.

def fibonacci():
    prv = 0
    nxt = 1
    def nextfib():
        nonlocal prv
        nonlocal nxt
        nxt = prv + nxt
        prv = nxt - prv
        print("fib", nxt)
    return nextfib

fib = fibonacci()
for i in range(0, 10):
    fib()

Go

Go unterstützt von Anfang an higher-order functions und auch Closures.

package main

import "fmt"

func fibonacci() func() {
	prv := 0
	nxt := 1
	return func() {
		nxt = prv + nxt
		prv = nxt - prv
		fmt.Println("fib " + fmt.Sprintf("%d", nxt))
	}
}

func main() {
	fib := fibonacci()
	for i := 0; i < 10; i++ {
		fib()
	}
}

Ruby

In Ruby lassen sich Closures z.B. mit Lambdas umsetzen.

def fibonacci
    prv = 0
    nxt = 1
    lambda {
      nxt = prv + nxt
      prv = nxt - prv
      puts "fib #{nxt}"
    }
end

fib = fibonacci
(0...10).each { |i| fib.call }

Java

Mit der Einführung von Lambdas in Java 8 lassen sich auch Closures in Java abbilden. Die Umsetzung ist ein wenig umständlicher, da in Lambda-Ausdrücken verwendete Variablen effectively final sein müssen und daher am besten mit einer local inner class gearbeitet wird.

public class Closure {

    interface Fib {
        void exec();
    }

    private Fib fibonacci() {
        class Pair {
            public int prv;
            public int nxt;
        }
        var pair = new Pair();
        pair.nxt = 1;
        return () -> {
            pair.nxt = pair.prv + pair.nxt;
            pair.prv = pair.nxt - pair.prv;
            System.out.println("fib " + pair.nxt);
        };
    }

    public void closure() {
        var fib = fibonacci();
        IntStream.range(0, 10).forEach(i -> fib.exec());
    }

    public static void main(String[] args) {
        new Closure().closure();
    }

}