Fehler durch Vermischung von deklarativem und imperativem Code (LINQ to XML)

LINQ to XML enthält verschiedene Methoden, mit denen Sie eine XML-Struktur direkt ändern können. Sie können Elemente hinzufügen, Elemente löschen, den Inhalt eines Elements ändern, Attribute hinzufügen usw. Diese Programmierschnittstelle wird in Ändern von XML-Strukturen beschrieben. Wenn Sie eine der Achsen, z. B. Elements, durchlaufen und dabei die XML-Struktur ändern, kann es zu einer Reihe eigenartiger Fehler kommen.

Dieses Problem wird manchmal als "Halloween-Problem" bezeichnet.

Wenn Sie LINQ verwenden, um Code zu schreiben, der eine Auflistung durchläuft, schreiben Sie Code in einem deklarativen Stil. Sie beschreiben also eher, was Sie ausführen möchten, und nicht, wie dies ausgeführt werden soll. Wenn Sie Code schreiben würden, der 1.) das erste Element abruft, 2.) das Element auf bestimmte Bedingungen testet, 3.) das Element modifiziert und 4.) das Element dann wieder in die Liste setzt, wäre dies imperativer Code. Sie teilen dem Computer also mit, wie von Ihnen gestellte Aufgabe ausgeführt werden soll.

Wenn nun diese Formen von Code in ein und derselben Operation miteinander vermischt werden, treten Probleme auf. Nehmen wir einmal die folgende Situation:

Stellen Sie sich vor, Sie haben eine verknüpfte Liste mit drei Elementen darin (a, b und c):

a -> b -> c

Stellen Sie sich nun vor, Sie möchten die verknüpfte Liste durchgehen und drei neue Elemente (a', b' und c') hinzufügen. Die resultierende verknüpfte Liste soll dann wie folgt aussehen:

a -> a' -> b -> b' -> c -> c'

Sie schreiben also Code, der die Liste durchläuft, und der sofort anschließend für jedes Element ein neues Element hinzufügt. Nun geschieht Folgendes: Ihr Code sieht zuerst das a-Element und fügt dahinter a' ein. Jetzt wechselt Ihr Code zum nächsten Knoten in der Liste, der jetzt a' lautet, sodass der Liste ein neues Element zwischen „a'“ und „b“ hinzugefügt wird!

Wie lässt sich dieses Problem lösen? Nun, Sie könnten eine Kopie der ursprünglichen verknüpften Liste anlegen und eine vollkommen neue Liste erstellen. Sie könnten aber auch rein imperativen Code schreiben. Dieser würde das erste Element finden und dann das neue Element hinzufügen. Danach würde der Code in der verknüpften Liste zweimal vorrücken und damit das Element überspringen, das Sie gerade hinzugefügt haben.

Beispiel: Hinzufügen beim Durchlaufen

In diesem Beispiel wird angenommen, Sie möchten Code schreiben, um ein Duplikat jedes Elements in einer Struktur zu erstellen:

XElement root = new XElement("Root",
    new XElement("A", "1"),
    new XElement("B", "2"),
    new XElement("C", "3")
);
foreach (XElement e in root.Elements())
    root.Add(new XElement(e.Name, (string)e));
Dim root As XElement = _
    <Root>
        <A>1</A>
        <B>2</B>
        <C>3</C>
    </Root>
For Each e As XElement In root.Elements()
    root.Add(New XElement(e.Name, e.Value))
Next

Dieser Code führt zu einer Endlosschleife. Die foreach-Anweisung durchläuft die Elements()-Achse und fügt dabei dem doc-Element neue Elemente hinzu. Das Ergebnis ist, dass die Anweisung auch die gerade hinzugefügten Elemente durchläuft und bei jedem Durchlaufen der Schleife neue Objekte zuweist. Irgendwann wird der gesamte verfügbare Arbeitsspeicher dafür in Beschlag genommen.

Dieses Problem können Sie beheben, indem Sie die Auflistung mit dem ToList-Standardabfrageoperator in den Arbeitsspeicher ziehen. Dies ist im Folgenden dargestellt:

XElement root = new XElement("Root",
    new XElement("A", "1"),
    new XElement("B", "2"),
    new XElement("C", "3")
);
foreach (XElement e in root.Elements().ToList())
    root.Add(new XElement(e.Name, (string)e));
Console.WriteLine(root);
Dim root As XElement = _
    <Root>
        <A>1</A>
        <B>2</B>
        <C>3</C>
    </Root>
For Each e As XElement In root.Elements().ToList()
    root.Add(New XElement(e.Name, e.Value))
Next
Console.WriteLine(root)

Jetzt funktioniert der Code. Die resultierende XML-Struktur sieht wie folgt aus:

<Root>
  <A>1</A>
  <B>2</B>
  <C>3</C>
  <A>1</A>
  <B>2</B>
  <C>3</C>
</Root>

Beispiel: Löschen beim Durchlaufen

Wenn Sie alle Knoten auf einer bestimmten Ebene löschen möchten, könnten Sie versucht sein, Code wie den folgenden zu schreiben:

XElement root = new XElement("Root",
    new XElement("A", "1"),
    new XElement("B", "2"),
    new XElement("C", "3")
);
foreach (XElement e in root.Elements())
    e.Remove();
Console.WriteLine(root);
Dim root As XElement = _
    <Root>
        <A>1</A>
        <B>2</B>
        <C>3</C>
    </Root>
For Each e As XElement In root.Elements()
    e.Remove()
Next
Console.WriteLine(root)

Dieser Code macht jedoch nicht das, was Sie wollen. In dieser Situation passiert Folgendes: Nachdem Sie das erste Element (A) entfernt haben, wird es aus der XML-Struktur im Stamm entfernt, sodass der Code in der Elements-Methode, der die Iteration ausführt, das nächste Element nicht findet.

Dieses Beispiel erzeugt die folgende Ausgabe:

<Root>
  <B>2</B>
  <C>3</C>
</Root>

Auch hier besteht die Lösung darin, ToList aufzurufen, um die Auflistung wie folgt zu materialisieren:

XElement root = new XElement("Root",
    new XElement("A", "1"),
    new XElement("B", "2"),
    new XElement("C", "3")
);
foreach (XElement e in root.Elements().ToList())
    e.Remove();
Console.WriteLine(root);
Dim root As XElement = _
    <Root>
        <A>1</A>
        <B>2</B>
        <C>3</C>
    </Root>
For Each e As XElement In root.Elements().ToList()
    e.Remove()
Next
Console.WriteLine(root)

Dieses Beispiel erzeugt die folgende Ausgabe:

<Root />

Alternativ dazu können Sie die Iteration ganz eliminieren, indem Sie für das übergeordnete Element RemoveAll aufrufen:

XElement root = new XElement("Root",
    new XElement("A", "1"),
    new XElement("B", "2"),
    new XElement("C", "3")
);
root.RemoveAll();
Console.WriteLine(root);
Dim root As XElement = _
    <Root>
        <A>1</A>
        <B>2</B>
        <C>3</C>
    </Root>
root.RemoveAll()
Console.WriteLine(root)

Beispiel: Keine Möglichkeit der automatischen Behandlung dieser Probleme durch LINQ

Eine Herangehensweise bestünde darin, immer alles in den Arbeitsspeicher zu holen, statt mit verzögerter Auswertung zu arbeiten. Dies würde aber massive Leistungseinbußen und einen hohen Arbeitsspeicherbedarf mit sich bringen. Im praktischen Einsatz würden LINQ und LINQ to XML bei dieser Variante scheitern.

Ein anderer möglicher Ansatz bestünde darin, eine Art von Transaktionssyntax in LINQ einzubauen und den Compiler zu veranlassen, den Code zu analysieren, um zu ermitteln, ob eine bestimmte Auflistung materialisiert werden muss. Der Versuch, allen Code zu bestimmen, der Nebenwirkungen hat, ist aber eine unglaublich komplexe Angelegenheit. Betrachten Sie folgenden Code:

var z =
    from e in root.Elements()
    where TestSomeCondition(e)
    select DoMyProjection(e);
Dim z = _
    From e In root.Elements() _
    Where (TestSomeCondition(e)) _
    Select DoMyProjection(e)

Solch ein Analysecode müsste die Methoden TestSomeCondition und DoMyProjection sowie alle von diesen Methoden aufgerufenen Methoden analysieren, um zu bestimmen, ob Code mit Nebenwirkungen vorhanden ist. Der Analysecode kann aber nicht nur einfach nach Code mit Nebenwirkungen suchen. Er dürfte nur den Code auswählen, der in dieser Situation Nebenwirkungen auf die untergeordneten Elemente von root hat.

LINQ to XML versucht nicht, eine solche Analyse durchzuführen. Die Vermeidung dieser Probleme liegt in Ihrer Verantwortung.

Beispiel: Verwenden von deklarativem Code, um eine neue XML-Struktur zu generieren, anstatt die vorhandene Struktur zu ändern

Um solche Probleme zu vermeiden, mischen Sie deklarativen und imperativen Code nicht – auch wenn Sie die Semantik Ihrer Auflistungen und die Semantik der Methoden, die die XML-Struktur ändern, genau kennen. Sie können zwar Code schreiben, um die Probleme zu vermeiden, aber dieser muss in Zukunft von anderen Entwickler*innen verwaltet werden, und diese sind sich über diese Probleme möglicherweise nicht im Klaren. Wenn Sie deklarativen und imperativen Code mischen, ist der Code anfälliger. Wenn Sie Code schreiben, der eine Auflistung so materialisiert, dass diese Probleme vermieden werden, versehen Sie ihn mit entsprechenden Kommentaren, um die für die Wartung zuständigen Programmierer über die Problematik zu informieren.

Wenn die Leistung und andere Überlegungen es zulassen, sollten Sie immer nur deklarativen Code verwenden. Ändern Sie nicht Ihre vorhandene XML-Struktur. Generieren Sie stattdessen eine neue Struktur, wie im folgenden Beispiel gezeigt:

XElement root = new XElement("Root",
    new XElement("A", "1"),
    new XElement("B", "2"),
    new XElement("C", "3")
);
XElement newRoot = new XElement("Root",
    root.Elements(),
    root.Elements()
);
Console.WriteLine(newRoot);
Dim root As XElement = _
    <Root>
        <A>1</A>
        <B>2</B>
        <C>3</C>
    </Root>
Dim newRoot As XElement = New XElement("Root", _
    root.Elements(), root.Elements())
Console.WriteLine(newRoot)