说到c#里面foreach应该是尽人皆知的了,不过,各位是不是了解foreach是怎么工作的哪?
大多数情况下,即使不了解foreach是如何工作的,照样可以把代码写的很正确。不过,前两天我在写一段代码时,却不得不把foreach大卸八块,原因就是遇到了yield和上下文切换,详细情况听我慢慢道来。
情景介绍
首先说说,整个应用程序的场景。这个应用程序是用来对账的,因此涉及很多数据的读取和比对,同时由于业务的快速发展,减少对账相对于各应用的发布的滞后时间,采用了Xml定义对账,并支持sql和内嵌c#等代码片断的方式,以及添加了独立的脚本支持。
同时为了最大限度的增强Xml的表现能力,添加了一个Xql的书写方式(抄袭Linq的思想,只不过是xml表达的),例如:
<value xsi:type="Xql"> <from as="x"> <value xsi:type="Eval" expression="call Split ($a)"/> </from> <where> <value xsi:type="Eval" expression="$x.Length==1"/> </where> <join as="y" on="$x" equals="$y" method="LeftJoin"> <value xsi:type="Eval" expression="call Split ($b)"/> </join> <let as="y" on="$y==null"> <value xsi:type="Eval" expression="$x+'1'"/> </let> <select> <prop as="X">$x</prop> <prop as="Y">$y</prop> <prop as="Length">$y.Length</prop> </select> </value>
其中$a,$b为上下文中的变量,而$x和$y为Xql中查询的当前值。
其中Xql的执行被转换成下面的代码:
private IEnumerable<ScriptObject> SelectResult(IEnumerable<XqlItem> items) { foreach (var item in items) { ScriptObject so = new ScriptObject(); foreach (var prop in this.select) so[prop.@as] = Eval(item, prop.Value); yield return so; } }
在最初的测试中,程序非常完美的做出了正确的结果。但是,这里有一个非常隐蔽的问题,过了好久才被发现。
发现问题
发现问题的时候总是充满着以外,Xql虽然可以提供相对较清晰的逻辑,设计得非常灵活,所以Xql允许嵌套,例如:
<value xsi:type="Xql"> <from as="xy"> <value xsi:type="Xql"> <from as="x"> <value xsi:type="Eval" expression="call GetSource1()"/> </from> <join as="y" on="$x.Key" equals="y.Key" method="InnerJoin"> <value xsi:type="Eval" expression="call GetSource2()"/> </join> <select> <prop as="Key">$x.Key</prop> <prop as="Prop1">$x.Prop1</prop> <prop as="Prop2">$y.Prop2</prop> </select> </value> </from> <join as="z" on="$xy.Key" equals="$z.Key" method="FullJoin"> <value xsi:type="Eval" expression="call GetSource3()"/> </join> <select> <prop as="Key">$xy.Key</prop> <prop as="Prop1">$xy.Prop1</prop> <prop as="Prop2">$xy.Prop2</prop> <prop as="Prop3">$z.Prop3</prop> </select> </value>
这里,先把x和y两个数据源Join成一个xy的数据源,再把xy这个数据源与z做Join,这个查询非常合理,而且也可以充分体现Xql在处理复杂情况时的表达能力,但是一个不幸的bug就发生了。
Xql在设计的时候,定义为总是延迟执行,因此,在外层Xql真正执行前,内层的Xql是不会执行的,表面上看很合理,但是,如果内层的Xql依赖一个函数的参数,而且,在这个函数内,并未对外层Xql做任何查询,仅仅是将Xql的结果返回(类似一个c#方法返回一个IQueryable对象),那么在外层Xql开始迭代时,才会创建出内层Xql,但此时,内层Xql已经无法知道当初的函数的参数的值。
此时,杯具就产生了,一个完全符合Xql语义的Xql实例,却无法做出一个正确的结果。
思考方案
首先,方案应该是积极的,而不是消极的。也就是应该去支持嵌套,而不是想尽办法去阻止嵌套。
其次,参考c#里面的Linq。可以发现c#用的是闭包来解决这个问题,使Linq在执行时的上下文和创建时的一样。
那么,我们也可以创建出一个上下文对象,并且在执行时,让延迟的那部分代码总是在这个被抓去下来的上下文中执行。
执行上下文
因此,很容易想到的ExecutionContext,其实也很容易实现,在轻松搞定后,这时的SelectResult方法就需要修改方法签名了,因为它需要传递当时的上下文:
private IEnumerable<ScriptObject> SelectResult(IEnumerable<XqlItem> items, ExecutionContext context)
当然,上下文提供一个基本的方法:
void Execute(Action action)
这个方法保证action的执行一定是在之前被抓去下来的上下文中执行的,而不是当前的上下文。
那么,这个SelectResult怎么实现哪?
foreach+yield+上下文切换
首先,你可能会想到的是这么写:
context.Execute(() => { foreach (var item in items) { ScriptObject so = new ScriptObject(); foreach (var prop in this.select) so[prop.@as] = Eval(item, prop.Value); yield return so; } });
然后,很遗憾的发现vs会很“聪明”的提示你:不能在匿名方法或 lambda 表达式内使用 yield 语句。
其次,你可能会想到:
return items.Select(item => { ScriptObject so = new ScriptObject(); context.Execute(() => { foreach (var s in this.select) so[s.@as] = Eval(item, s.EvalValue); }); return so; });
很好,你掌握了linq,但是搞错了方向。要切换上下文的不是Select的过程,而是迭代的时候。
当外层Xql执行时,内层Xql是作为一个数据源存在的,也就是在执行MoveNext操作时,内层Xql才会被执行,换句话说,就是只有MoveNext时,才需要切换掉上下文。
正解
经过上面的分析就浮出水面了,需要把foreach大卸八块,也就是还原到可以被抓取到真正执行MoveNext方法的时候:
IEnumerator<XqlItem> x = null; try { x = items.GetEnumerator(); bool moved = false; context.Execute(() => moved = x.MoveNext()); while (moved) { ScriptObject so = new ScriptObject(); foreach (var prop in this.select) so[prop.@as] = Eval(x.Current, prop.Value); yield return so; context.Execute(() => moved = x.MoveNext()); } } finally { if (x != null) x.Dispose(); }
这样,Xql也就真正支持了嵌套,使实现和语义达成了一致。