混合声明性/命令性代码 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)