Поделиться через


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

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

Эта проблема иногда называется "Проблема Хэллоуина".

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

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

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

a -> b -> c

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

a -> a' -> 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)