自己动手重新实现LINQ to Objects: 2 - Where

提示:本篇文章较长。虽然我选择了一个比较简单的操作符来在本文中实现,不过我们还是会遇到一些特例以及一些与LINQ相关的原则。因为我还在试着找出表现本文内容的最佳方式,所以本文的排版方式暂时是实验性的。

我们将要实现“Where”子句(也可以说是方法或操作符)。Where在总体上来说比较容易理解,但是涉及到延迟执行和流式处理的部分会有些麻烦。Where方法是泛型的,不过只有一个类型参数(在我看来这很重要,因为我觉得一个方法的泛型参数越多就越令人难以理解)。哦,对了,我们将在本文开始涉及查询表达式,这算是本文的一点额外猛料。
 

Where是什么?
 

Where有两个重载:
 

public static IEnumerable<TSource> Where( 

    this IEnumerable<TSource> source, 

    Func<TSource, bool> predicate) 

public static IEnumerable<TSource> Where( 

    this IEnumerable<TSource> source, 

    Func<TSource, intbool> predicate)


在开始讲述Where方法到底做什么之前,我先指出几点LINQ操作符的常识,这些常识适用于几乎所有的LINQ操作符:
 

LINQ操作符都是扩展方法 - 扩展方法要定义在顶层的,非嵌套的静态类型中而且其第一个参数要带有“this”修饰符。简单来说,扩展方法可以被其第一个参数的实例调用,就好像它是该参数类型的实例方法一样。

LINQ操作符是泛型方法 - 我们要讲的Where操作符只有一个叫做TSource的类型参数,该类型参数指明了要处理的序列的类型。比如说,如果要处理一个string的序列,TSource就是string

LINQ操作符接受Func<...>这一族的泛型委托作为参数,通常以lamdba表达式的方式提供,不过委托的其他表现形式也都可以作为其参数。

LINQ操作符处理序列。序列以IEnumerable<T>的形式出现,IEnumerable<T>中含有一个类型为IEnumerator<T>的迭代器。


我希望本文的读者对以上提及的概念有所了解,所以我就不再深入解释了。如果您对上述内容不够熟悉的话,请在继续读下去之前先去做些功课,否则接下来的内容将让您很难理解。

Where的目的是去过滤一个序列。它接受一个输入序列及一个谓词作为参数,返回一个结果序列。输出序列和输入序列的元素类型相同(也就是说如果输入是一个string的序列,输出也会是个string的序列),输出序列中只会包含输入序列中符合谓词条件的元素。(输入序列中的元素会依次被谓词检验。只有谓词返回true时,一个元素才会被包含在输出序列中。)

下面是关于Where的几个重要的细节:
 

Where不会对输入序列做任何修改:它和List<T>.RemoveAll之类的方法不一样。

Where是延迟执行的 在你开始读取输出序列中的元素之前,Where不会去输入序列中取元素。

不过也有一点不是延迟执行的,它会立即检查参数是否为null

它以流式处理结果:它每次只处理一个结果元素,它把结果元素yield返回而且不会保留其引用。这意味着你可以把Where应用在一个无限长的序列上(比如说一个随机数的序列)。

你每在输出序列上迭代一次,Where方法就会在输入序列上迭代一次。

如果输出序列的迭代器被Dispose掉的话,对应的输入序列的迭代器也会被Dispose掉。(C#中的foreach语句会用try/finally来保障迭代器总是会被Dispose调,无论循环是因何种原因结束的。)


以上几点之中的有些对其它的操作符也适用。

Where的一个重载形式会接受一个Func<TSource, int, bool>作为参数,此重载让谓词中不仅可以访问元素值,还可以访问元素的indexIndex总是从0开始并且每次递增1,无论之前谓词的结果如何。
 

我们要测试些什么?
 

理想情况下,我们要测试上述所有的东西。但是不幸的是,流式处理和序列被迭代多少次的细节测试起来很是麻烦。考虑到我们还要实现那么多的东西,我们以后再去测试那些。

我们来看看一些测试。首先,看一个“正向”测试 有一个整数数组,我们用一个lambda表达式来使得输出结果中仅包含小于4的元素。(“过滤”这个词无处不在,这真是很不幸。“过滤掉”这个说法比“包含”要好理解得多,但是实际上谓词就是以“正向”的方式来处理的。)
 

[Test] 

public void SimpleFiltering() 

    int[] source = { 134281 }; 

    var result = source.Where(x => x < 4); 

    result.AssertSequenceEqual(1321); 

}
 

虽然NUnit中已经有了CollectionAssert,我还是在用MoreLINQ中的TestExtension。有三个原因让我觉得MoreLINQ的扩展方法更好用:
 

扩展方法有助于减轻代码的混乱程度。

可以使用变长数组来表示期望的输出,这样更易于表达测试的意图。

断言失败的提示信息更清楚。
 

AssertSequenceEqual所做的事情看名字就可以猜出来,它检查输出序列(通常就是你调用AssertSequenceEqual方法时所使用的那个变量)和期望的序列(通常就是作为参数传入的变长数组)是否匹配。

目前为止进行的还不错。现在来看看参数校验吧:
 

[Test] 

public void NullSourceThrowsNullArgumentException() 

    IEnumerable<int> source = null; 

    Assert.Throws<ArgumentNullException>(() => source.Where(x => x > 5)); 

[Test] 

public void NullPredicateThrowsNullArgumentException() 

    int[] source = { 137910 }; 

    Func<intbool> predicate = null; 

    Assert.Throws<ArgumentNullException>(()=> source.Where(predicate)); 

}
 

我就不再费劲去检查ArgumentNullException里面的参数名字了,但是我要测试参数是不是立即被校验的,这一点很重要的。我没有迭代输出结果,所以如果参数校验是延迟执行的,这两个测试将不能通过。

最后还有一个有趣的测试也是与延迟执行有关的。我们将用一个叫做ThrowingEnumerablehelper类来做这个测试,这个类是一个序列,你一旦迭代它,它就会抛出一个InvalidOperationException。这个测试是想要测试两点:
 

仅仅调用Where不会开始迭代输入序列。

调用GetEnumerator()来获取迭代器,然后再调用迭代器的MoveNext()的话,就开始迭代了,这就会导致一个异常被抛出。
 

对其它的操作符我们也需要做类似的测试,所以我在ThrowingEnumerable里写了一个helper方法:
 

internal static void AssertDeferred<T>( 

    Func<IEnumerable<int>, IEnumerable<T>> deferredFunction) 

    ThrowingEnumerable source = new ThrowingEnumerable(); 

    var result = deferredFunction(source); 

    using (var iterator = result.GetEnumerator()) 

    { 

        Assert.Throws<InvalidOperationException>(() => iterator.MoveNext()); 

    } 

}
 

现在我们就可以测试Where是不是延迟执行的了:
 

[Test] 

public void ExecutionIsDeferred() 

    ThrowingEnumerable.AssertDeferred(src => src.Where(x => x > 0)); 

}
 

以上所示的都是对Where的简单重载的测试,也就是那个谓词只能访问元素值而不能访问元素index的重载。能够访问index的那个重载的测试与上述测试非常类似。
 

来动手实现吧!
 

原版的LINQ to Objects能够通过所有这些测试,现在来实现我们自己的代码吧。我们将会用到迭代器代码块,它在C# 2中被引入来简化IEnumerable<T>的实现。如果你想了解更多的背景知识的话,我有几篇文章你可以去读一下...或者读C# in Depth(第一或第二版都可以)的第六章也可以。迭代器代码块让我们可以很简单的实现延迟执行...不过它也是一把双刃剑,我们马上就会体会到了。

Where的核心部分是这样的:
 

// Naive implementation 

public static IEnumerable<TSource> Where<TSource>( 

    this IEnumerable<TSource> source, 

    Func<TSource, bool> predicate) 

    foreach (TSource item in source) 

    { 

        if (predicate(item)) 

        { 

            yield return item; 

        } 

    } 

}
 

很简单,是吧?用迭代器代码块写出来的代码就和用自然语言描述起来差不多:迭代输入序列中的每一个元素,如果谓词在一个元素上返回true的话,这个元素就可以被yield(也就是包含)到输出序列中去。

诸位请看,有一些单元测试已经可以通过了。现在我们只需要参数校验了。参数校验很简单的,对吧?我们来试试看:
 

// Naive validation - broken! 

public static IEnumerable<TSource> Where<TSource>( 

    this IEnumerable<TSource> source, 

    Func<TSource, bool> predicate) 

    if (source == null) 

    { 

        throw new ArgumentNullException("source"); 

    } 

    if (predicate == null) 

    { 

        throw new ArgumentNullException("predicate"); 

    } 

    foreach (TSource item in source) 

    { 

        if (predicate(item)) 

        { 

            yield return item; 

        } 

    } 

}
 

呃。测试亮起了红灯,通不过,在“throw”的那一句上设断点也没用...断点根本就执行不到。怎么回事儿?

我之前已经给出过很明显的提示了。导致问题的就是延迟执行。在返回值被迭代之前,我们的代码不会被执行。我们的代码故意的没有去迭代返回值,所以参数校验也不会被执行。

我们遇到了一个C#设计上的缺陷。C#中的迭代器代码块不能很好的对“立即执行”(通常用来做参数校验)和“延迟执行”作出分离。我们必须得把我们上述的实现分为两个方法:第一个方法做参数校验,第二个方法含有迭代器代码块,用来实现延迟执行,第一个方法会调用第二个方法:
 

public static IEnumerable<TSource> Where<TSource>( 

    this IEnumerable<TSource> source, 

    Func<TSource, bool> predicate) 

    if (source == null) 

    { 

        throw new ArgumentNullException("source"); 

    } 

    if (predicate == null) 

    { 

        throw new ArgumentNullException("predicate"); 

    } 

    return WhereImpl(source, predicate); 

private static IEnumerable<TSource> WhereImpl<TSource>( 

    this IEnumerable<TSource> source, 

    Func<TSource, bool> predicate) 

    foreach (TSource item in source) 

    { 

        if (predicate(item)) 

        { 

            yield return item; 

        } 

    } 

}
 

这样的代码很丑陋,但是能用:所有的针对于Where的简单重载(不含有index)的测试都可以通过了。有了现在的基础,要实现Where的含有index的重载也就很简单了:
 

public static IEnumerable<TSource> Where<TSource>( 

    this IEnumerable<TSource> source, 

    Func<T, intbool> predicate) 

    if (source == null) 

    { 

        throw new ArgumentNullException("source"); 

    } 

    if (predicate == null) 

    { 

        throw new ArgumentNullException("predicate"); 

    } 

    return WhereImpl(source, predicate); 

private static IEnumerable<TSource> WhereImpl<TSource>( 

    this IEnumerable<TSource> source, 

    Func<TSource, intbool> predicate) 

    int index = 0

    foreach (TSource item in source) 

    { 

        if (predicate(item, index)) 

        { 

            yield return item; 

        } 

        index++; 

    } 

}
 

现在所有单元测试都通过了,我们的实现完成了。不过等一下...我们还没有无所不用其极的使用Where呢。
 

查询表达式
 

到目前为止,我们都是在直接的调用Where方法(尽管是以扩展方法的形式出现的),不过LINQ可是还给我们提供了查询表达式的。下面是“SimpleFiltering”那个测试的重写版本,其中用到了查询表达式:
 

[Test] 

public void QueryExpressionSimpleFiltering() 

    int[] source = { 134281 }; 

    var result = from x in source 

                 where x < 4 

                 select x; 

    result.AssertSequenceEqual(1321); 

}
 

(本博文中出现的方法名和下载到的代码中的不同,因为方法名中含有博客服务器的敏感词。呃。)

以上代码会和我们先前的测试产出完全相同IL代码。编译器会把这种查询表达式的形式转译成调用方法的形式,并把用lambda表达式写出来的条件判断(x < 4)转换成一个委托。你可能会感到有点惊讶,因为我们还没有实现Select方法呢...不过我们现在用到的select投影操作实际上是不做任何事情的;我们并没有做任何真正的投影变换。这种情况下,只要查询表达式中含有任意其他的查询(上述代码中,这个查询就是Where)在内,编译器就会把“select”从句忽略掉,这样的话我们没有实现select也就无关紧要了。如果你把“select x”改写成“select x * 2”的话,将无法通过编译,因为我们的LINQ实现中只有Where

查询表达式是基于上述这种模式的,这一强大的特性使得它极具灵活性。举例来说,LINQ to Rx就是基于这一点才能做到仅需实现对其应用场景有意义的操作符的。与此类似,C#编译器在处理查询表达式的时候并不需要知晓任何与IEnumerable<T>有关的东西,也正是如此,像IObservable<T>这样的完全与IEnumerable<T>无关的接口也可以得以应用。
 

我们学到了什么?
 

本文中有不少不太好理解的东西,其中与我们的实现和LINQ核心原则有关的是:
 

LINQ to Objects是基于扩展方法,委托还有IEnumetable<T>的。

条件允许的话,LINQ操作符会尽量利用延迟执行和流式处理。

LINQ操作符不会改变输入序列,而是会返回一个包含符合条件的元素的新序列。

查询表达式基于编译器对一些模式的解释;你要用到的查询表达式和哪些模式相关,你就只需要实现那些模式就可以了,无需多劳。

迭代器代码块很适合用来实现延迟执行...

...但是它也使得需要立即执行的参数校验变得很难搞。
 

代码下载
 

Linq-To-Objects-2.zip

很多人要求给本项目建一个源码管理服务器,这件事正在进行中;大概下一篇博文之前就可以完成。

posted on 2011-08-21 00:08  崔鹏飞  阅读(1034)  评论(0编辑  收藏  举报

导航