Ошибки смешанного декларативного и императивного кода (C#) (LINQ to XML)
Обновлен: November 2007
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' -> c -> c'
Пишется код, который последовательно обращается к элементам списка и для каждого пункта добавляет новый пункт после него. При этом код распознает элемент a и вставит элемент a' после него. Теперь код перейдет к следующему узлу списка, которым является a'! После чего он добавляет новый элемент списка — a''.
Как это следовало бы решить в реальной ситуации? Можно сделать копию оригинального списка, после чего создать полностью новый список. Или, если вы пишете чисто императивный код, можно найти первый пункт, добавить новый пункт и перейти на два шага вперед по списку, чтобы перескочить элемент, который был только что добавлен.
Добавление при последовательном переходе
Например, требуется написать некоторый код, при помощи которого для каждого элемента дерева создается его дубликат:
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<TSource> следующим образом:
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 method), который выполняет последовательный переход, не удастся найти следующий элемент.
Предыдущий код представит следующий вывод:
<Root>
<B>2</B>
<C>3</C>
</Root>
Решение снова заключается в вызове ToList<TSource>, чтобы материализовать коллекцию следующим образом:
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-дерево. Лучше создать новое.
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)