Смешанные декларативные и императивные ошибки кода (LINQ to XML)

LINQ to XML содержит различные методы, позволяющие напрямую изменять дерево XML. Можно добавить элементы, удалить элементы, изменить содержимое элемента, добавить атрибуты и т. п. Этот интерфейс программирования описан в разделе "Изменение деревьев XML". Если вы выполняете итерацию по одной из осей, например Elements, и вы изменяете XML-дерево при итерации по оси, вы можете в конечном итоге получить некоторые странные ошибки.

Этот вид ошибки иногда называется Halloween Problem.

При написании кода с помощью LINQ, который выполняет итерацию по коллекции, вы пишете код в декларативном стиле. Это больше похоже на описание того, что вы хотите, а именно то, как вы хотите сделать это. Если написать код, при котором 1) извлекается первый элемент, 2) выполняется его проверка согласно определенному условию, 3) выполняется его изменение и 4) выполняется его помещение назад в список элементов, то это означает, что это был бы императивный код. Вы говорите компьютеру , как сделать то, что вы хотите сделать.

Смешение этих стилей кода в одной операции является источником неполадок. Рассмотрим следующий пример.

Предположим, дан список из трех элементов (a, b и c):

a -> b -> c

Теперь предположим, что необходимо пройти по связанному списку, добавив три новых пункта (a', b' и c'). При этом необходимо, чтобы результирующий список выглядел так:

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

Пишется код, который последовательно обращается к элементам списка и для каждого пункта добавляет новый пункт после него. При этом код распознает элемент a и вставит элемент a' после него. Теперь код перейдет к следующему узлу в списке, который теперь a', поэтому он добавляет новый элемент между a и b в список!

Как вы решите это? Можно сделать копию оригинального списка, после чего создать полностью новый список. Или если вы пишете чисто императивный код, вы можете найти первый элемент, добавить новый элемент, а затем дважды перейти в связанный список, продвигаясь по элементу, который вы только что добавили.

Пример. Добавление при итерации

Например, предположим, что вы хотите написать код для создания дубликата каждого элемента в дереве:

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

Этот код представляет собой бесконечный цикл. Инструкция foreach последовательно применяется ко всей оси Elements(), при этом добавляются новые элементы к элементу doc. После этого она переходит на только что добавленные элементы. И поскольку она выделяет память для новых объектов на каждом шаге, она захватит всю доступную память.

Эту неполадку можно устранить за счет переноса массива в память, используя стандартный оператор запросов ToList следующим образом.

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)

Теперь код работает, как и положено. В итоге получается следующее XML-дерево:

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

Пример. Удаление при итерации

Если требуется удалить все узлы, размещенные на определенном уровне, может возникнуть искушение попробовать реализовать это так:

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)

Однако это не делает то, что вы хотите. В этой ситуации после удаления первого элемента A он удаляется из дерева XML, содержащегося в корне, и код в методе Elements, который выполняет итерацию, не может найти следующий элемент.

В примере получается следующий вывод.

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

Решение снова заключается в вызове ToList, чтобы материализовать коллекцию следующим образом.

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)

В примере получается следующий вывод.

<Root />

Кроме того, можно совсем исключить итерацию за счет вызова RemoveAll на родительском элементе.

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)

Пример. Почему LINQ не может автоматически обрабатывать эти проблемы

Одним из подходов может быть вызов всех элементов в память, вместо того чтобы проводить неспешное вычисление каждого элемента. Однако это может негативно отразиться на производительности и загрузить память. На самом деле, если бы LINQ и LINQ to XML, приняли этот подход, это приведет к сбою в реальных ситуациях.

Другой возможный подход заключается в том, чтобы поместить какой-то синтаксис транзакций в LINQ и попытаться проанализировать код, чтобы определить, требуется ли материализовать какую-либо конкретную коллекцию. Однако попытка определения всего кода с побочными эффектами чрезвычайно сложна. Рассмотрим следующий код:

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)

Такому коду для проведения анализа потребуется анализировать методы TestSomeCondition и DoMyProjection, а также все методы, которые вызывают их, чтобы определить, есть ли у кода какие-либо побочные эффекты. Однако коду анализа не удавалось просто искать код, в котором есть побочные эффекты. Для этого потребовалось бы выбрать только тот код, у которого есть побочные эффекты на дочерних элементах root.

LINQ to XML не пытается выполнить такой анализ. Это до вас, чтобы избежать этих проблем.

Пример. Используйте декларативный код для создания нового XML-дерева, а не изменения существующего дерева

Чтобы избежать таких проблем, не смешивайте декларативный и императивный код, даже если вы точно знаете семантику коллекций и семантику методов, изменяющих xml-дерево. Если вы пишете код, который избегает проблем, ваш код должен поддерживаться другими разработчиками в будущем, и они могут быть не столь ясными в отношении проблем. Если смешать декларативный и императивный стили, то код значительно усложнится. Если удастся написать код, который материализует коллекцию таким образом, что эти проблемы удастся избежать, отметьте это в комментариях кода, чтобы другие программисты понимали, как это сделано.

Если производительность и другие рекомендации позволяют, используйте только декларативный код. Не следует изменять существующее XML-дерево. Вместо этого создайте новую, как показано в следующем примере:

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)