当foreach遇到yield和上下文切换时

    说到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也就真正支持了嵌套,使实现和语义达成了一致。

原文地址:https://www.cnblogs.com/vwxyzh/p/1895023.html