Errores de código declarativo/imperativo mixtos (LINQ to XML)

LINQ to XML incluye numerosos métodos que le permiten modificar directamente un árbol XML. Puede agregar elementos, eliminarlos, cambiar sus contenidos, agregar atributos, etc. Esta interfaz de programación se describe en Modificación de árboles XML. Si está llevando a cabo una iteración por uno de los ejes, como puede ser Elements, y está modificando el árbol XML a medida que recorre el eje, es posible que acabe encontrando errores extraños.

En ocasiones, a este problema se le conoce como "El problema de Halloween".

Cuando se escribe cierto código utilizando LINQ para recorrer en iteración una colección, se utiliza un código de estilo declarativo. Es un método que se aproxima más a definir qué es lo que quiere, en vez de especificar cómo quiere hacerlo. Si escribe un código que 1) obtenga el primer elemento, 2) lo compruebe con una cierta condición, 3) lo modifique y 4) lo coloque nuevamente en la lista, entonces se trata de un código imperativo. Le está indicando al equipo cómo hacer lo que quiere.

Si se combinan ambos estilos de código en una misma operación, es cuando aparecen los problemas. Considere el siguiente caso:

Suponga que tiene una lista vinculada que contiene tres elementos (a, b y c):

a -> b -> c

Ahora, suponga que desea recorrer la lista vinculada y añadir tres nuevos elementos (a', b' y c'). Desea que la lista vinculada resultante tenga el siguiente aspecto:

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

De forma que escribe un código que recorre la lista y, que para cada elemento, añade uno nuevo justo después. Lo que ocurrirá es que el código encontrará primero el elemento a e insertará a' justo después. Ahora, el código pasará al siguiente nodo de la lista, que ahora es a', por lo que agrega un nuevo elemento entre a' y b a la lista.

¿Cómo resolvería esto? Una opción es realizar una copia de la lista vinculada original y crear una lista completamente nueva. O bien, si únicamente está escribiendo código imperativo, puede encontrar el primer elemento, agregar el nuevo y, luego, avanzar dos elementos en la lista vinculada, pasando así por encima del elemento recién agregado.

Ejemplo: Adición durante la iteración

Suponga, por ejemplo, que quiere escribir código para crear un duplicado de cada elemento de un árbol:

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

Este código entrará en un bucle infinito. La instrucción foreach lleva a cabo una iteración a lo largo del eje Elements(), agregando nuevos elementos al elemento doc. Al final, acaba realizando dicha iteración por los elementos que se acaban de agregar. Y, dado que coloca los nuevos objetos en cada una de las iteraciones del bucle, llegará un momento en el que consuma toda la memoria disponible.

Puede resolver este problema almacenando en memoria la colección mediante el operador de consulta estándar ToList, de la siguiente forma:

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)

Ahora el código funciona correctamente. El árbol XML resultante es como sigue:

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

Ejemplo: Eliminación durante la iteración

Si desea eliminar todos los nodos que se encuentren en un cierto nivel, podría tener la tentación de escribir un código como el que sigue:

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)

Sin embargo, no se realiza la tarea deseada. En esta situación, después de eliminar el primer elemento, A, se elimina del árbol XML contenido en la raíz, con lo que el código del método Elements encargado de la iteración no podrá encontrar el siguiente elemento.

Este ejemplo produce el siguiente resultado:

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

De nuevo, la solución pasa por llamar a ToList para materializar la colección, de la siguiente forma:

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)

Este ejemplo produce el siguiente resultado:

<Root />

Como alternativa, puede eliminar la iteración entera llamando a RemoveAll en el elemento primario:

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)

Ejemplo: Por qué LINQ no puede administrar automáticamente estos problemas

Una posible aproximación sería trasladar todo a memoria en vez de realizar evaluaciones diferidas. No obstante, esto resultaría muy costoso en términos de rendimiento y de utilización de la memoria. De hecho, si LINQ y LINQ to XML utilizaran este método, no sería viable en situaciones reales.

Otro posible enfoque consiste en utilizar una cierta sintaxis transaccional en LINQ y hacer que el compilador intente analizar el código para determinar si es necesario materializar alguna colección en particular. Sin embargo, puede resultar extremadamente complejo analizar todo el código que pueda tener efectos secundarios. Observe el código siguiente:

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)

Un código de análisis así necesitaría analizar los métodos TestSomeCondition y DoMyProjection, así como todos los métodos a los que llaman, con el fin de determinar si existe código con efectos secundarios. Pero no bastaría con que el código de análisis buscara código que tuviera efectos secundarios. Solo debería seleccionar aquel código que tuviera efectos secundarios sobre los elementos secundarios de root en este caso en particular.

LINQ to XML no realiza ese tipo de análisis. Es responsabilidad del desarrollador evitar este tipo de problemas.

Ejemplo: Uso de código declarativo para generar un nuevo árbol XML en lugar de modificar el árbol existente

Para evitar estos problemas, no mezcle código declarativo con imperativo, incluso si conoce exactamente la semántica de las colecciones y la semántica de los métodos que modifican el árbol XML. Si escribe código para evitar estos problemas, otros desarrolladores deberán mantenerlo en un futuro, y es posible que no tengan tan claro esos problemas. Si mezcla estilos de programación declarativa e imperativa, el código resultará más complicado. Si escribe código que materialice una colección de forma que se eviten todos estos problemas, incluya en él comentarios de forma que los programadores encargados de su mantenimiento puedan comprender la problemática.

Si el rendimiento y otras consideraciones lo permiten, utilice únicamente código declarativo. No modifique el árbol XML existente. En su lugar, genere uno nuevo como se muestra en el ejemplo siguiente:

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)