Lazy und die Probleme mit Closures

Ich habe mich die letzten Tage ausgiebig mit der Klasse Lazy beschäftigt. Was diese alles kann will ich an der Stelle aber nicht ausgiebig erläutern, denn das habe ich hier schon einmal getan. Grundsätzlich sei nur gesagt, dass sie das verzögerte Instanziieren oder Initialisieren einer Klasse ermöglicht. Dabei bietet sie unter anderem die Möglichkeit eine Factory-Methode zu verwenden und genau diese kann uns so manches Problem bereiten.

Das Beispielszenario

Stellen wir uns einmal vor wir wollen unterschiedliche Parser verwenden um eine bestimmte Datenstruktur aus diversen Dateiformaten auszulesen. Diese Parser werden bei einer Factory angefordert in dem der entsprechenden Methode eine Enumeration mit dem Type der zu lesenden Datei übergeben wird.

public class ParserFactory : IParserFactory
{
    // Factory Method
    public IParser Create(FileType type)
    {
        switch (type)
        {
            case ParserType.CSV:
                return new CsvParser();
                break;
            case ParserType.XLS:
                return new XlsParser();
                break;
            default:
                throw new ArgumentOutOfRangeException("type");
        }
    }
}

Nun entscheiden wir, um ressourcenschonender zu arbeiten, die Instanzen nicht immer erneut von der Factory erstellen zu lassen, sondern nur beim ersten Zugriff. Danach sollen sie intern weiter vorgehalten werden um sie ohne erneute Instanziierung immer wieder zu verwenden. Die Factory wird also folgendermaßen erweitert:

public class ParserFactory : IParserFactory
{
    private Dictionary<FileType, Lazy<IParser>> parser = new Dictionary<FileType, Lazy<IParser>>();

    public ParserFactory()
    {
        foreach (FileType type in Enum.GetValues(typeof(FileType)))
        {
            parser.Add(type, new Lazy<IParser>(() => this.Create(type)));
        }        
    }

    public IParser GetParser(FileType type)
    {
        return parser[type].Value;
    }

     // Factory Method
    private IParser Create(FileType type)
    {
        switch (type)
        {
            case FileType.CSV:
                return new CsvParser();
                break;
            case FileType.XLS:
                return new XlsParser();
                break;
            default:
                throw new ArgumentOutOfRangeException("type");
        }
    }
} 

Die Factory-Methode ist noch immer die gleiche, jedoch wird sie jetzt von Lazy aufgerufen. Lazy selbst wird nur ein Verweis auf eben jene Factory-Methode gegeben. Angefordert werden kann der Parser nun über den Indexer des Dictionaries, wobei dies in einer Get-Methode gekapselt wird weil das Dictionary verändert werden könnte wenn wir es per Property zugänglich machen.

Der Vorteil des neuen Verfahrens ist, dass wir nur den Switch-Case-Block erweitern müssen wenn ein neuer Parser unterstützt werden soll. In jedem Fall sind die Zugriffe threadsicher und durch das Lazy-Pattern verschenken wir keinen Speicher falls die Parser überhaupt nicht gebraucht werden.

An dieser Stelle muss gesagt werden, dass ich mir bewusst bin, dass wir bei Verwendung von zum Beispiel MEF, den ganzen Aufwand sparen könnten. Wo bliebe dann aber der Spaß 😉

Das Problem

Der Code ist so weit in Ordnung hat aber ein Problem: Er funktioniert nicht! Führt man ihn aus wird nämlich jedes Mal ein Xls-Parser erstellt. Woran kann das liegen? Für die Beantwortung der Frage müssen wir uns zunächst die Enumeration zu Gemüte führen:

public enum FileType
{
    CSV,
    XLS
}

XLS steht an letzter Stelle. Somit ergibt sich, dass die Factory in beiden Fällen den letzten Wert genutzt hat der der Variable type in der foreach-Schleife zugewiesen wurde. Dies ist verwirrend weil man gewöhnt ist, dass Wertetypen kopiert werden wenn man sie einer Methode übergibt.

In unserem Fall haben wir aber keine Variable übergeben sondern im Grunde den Speicherbereich der Variable und dieser wird bei jedem Durchlauf der Schleife mit einem neuen Wert belegt. Aus diesem Grund steht nach der Schleife auch nur die letzte Belegung zur Verfügung.

Abhilfe schafft hier eine Helfer-Variable. Für diese wird jeweils ein neuer Speicherbereich angelegt und damit erhält jede Closure ihren eigenen Wert.

foreach (FileType type in Enum.GetValues(typeof(FileType)))
{
   var helper = type;
   parser.Add(type, new Lazy<IParser>(() => this.Create(helper)));
} 

Closures

Wikipedia sagt: „Als Closure oder Funktionsabschluss bezeichnet man eine Programmfunktion, die beim Aufruf einen Teil ihres Erstellungskontexts reproduziert, selbst wenn dieser Kontext außerhalb der Funktion schon nicht mehr existiert.“ Genau das ist es was hier passiert.

Wir übergeben der Factory immerhin einen Parameter. Dieser Parameter (die Variable type) existiert nach dem Verlassen der Schleife aber nicht mehr. Aus diesem Grund muss er für die Factory konserviert werden und dabei wird nicht etwa der Wert von der Closure gekapert, sondern der Speicherbereich in dem der Wert steht. Genau diesen Speicherbereich haben wir aber in jedem Durchlauf mit einem anderen Wert belegt.

Oder zusammengefasst gesagt: Bei einem Methodenaufruf mit Wertetypen würde normalerweise eine Kopie des Werts erstellt und auf dem Stack abgelegt, nach dem Verlassen der Methode wird auch der Stack wieder freigeben und die Kopie verschwindet. Bei Closures ist dies aber nicht möglich, denn sie sind Funktionszeiger und die Funktion auf die sie zeigen wird nicht sofort aufgerufen sondern erst zu einem späteren Zeitpunkt. Ein Zeitpunkt an dem die Parametervariable als solches nicht mehr existiert, ihr Speicher aber schon denn immerhin gibt es noch eine Referenz auf ihn, nämlich die die von der Closure gehalten wird.

Abschluss

Nun könnte ich euch etwas von Bisspuren in der Tischkante erzählen die sich aufgrund eines richtig bösen Fehlers ergeben haben. Dank Resharper muss ich aber gestehen, hatte ich keine Probleme. Dieser hat mir sofort gesagt, dass ich im Begriff bin eine Dummheit zu begehen.

Dennoch oder gerade deshalb finde ich das Post wichtig. Denn wie man aus dem Beispiel erkennt, kann man einen sehr tollen Fehler produzieren (nicht nur mit Lazy) ohne auf den eigentlichen Grund dafür zu stoßen.