混合声明性/命令性代码 bug (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 之间的新项!

如何解决此问题? 您可以创建原始链接列表的副本,然后创建一个全新的列表。 或者,如果您正在编写纯粹的命令性代码,您可以找到第一项,添加新项,然后在链接列表中前进两步,跳过刚添加的元素。

示例:添加 while 循环访问

例如,假设你要编写代码来创建树中每个元素的副本:

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)