自己动手重新实现LINQ to Objects: 8 - Concat

上文讲的CountLongCount返回的是数值类型,本文我们讲的Concat返回的是一个序列。
 

Concat是什么?


Concat
只有一种签名形式,这让它使用起来很简单:


public
 static IEnumerable<TSource> Concat<TSource>( 

    this IEnumerable<TSource> first, 

    IEnumerable<TSource> second)


Concat的返回值依次包含了两个序列中的元素,也就是说把两个序列串联起来了。


我有时会觉得.NET没有提供Prepend/Append这样的方法是个遗憾,这两个方法应该可以和Concat做类似的事情,只不过它们把一个序列和一个单个的元素串联起来。如果要做一个填充着国家名和一个“None”值的下拉列表的话,这两个方法是很有用的。当然,向Concat中传入一个单元素的数组也可以达到同样的目的,但是我个人认为用特定的方法名做特定的事会让代码的可读性更高。
MoreLINQ中的Concat方法可以做这件事,不过Edulinq的目的只是要实现LINQ to Objects中已有的方法。

和往常一样,我们列出Concat的行为:


参数校验需要立即执行:两个参数都不允许为null

返回值是延迟执行的:当Concat被调用时,两个参数不会立即被迭代

输入序列只有在需要的时候才会被迭代:如果你停止迭代输出序列时第一个输入序列还没有被耗尽的话,那么第二个序列根本就不会被迭代


这几点描述基本就涵盖了Concat的所有行为。


我们需要测试什么呢?


Concat的串联行为很容易被测试,只需要一个用例就够了。我们或许也可以测试输入空序列会如何,但是那种测试基本没有不通过的可能。

参数校验的测试方式和往常一样:调用方法时传入非法的参数,然后不去迭代方法的返回值。

最后,还有一个单元测试用来测试两个输入序列被迭代的时机。这个测试中用到了我们在测试Where时用过的ThrowingEnumerable


[Test] 

public void FirstSequenceIsntAccessedBeforeFirstUse() 

    IEnumerable<int> first = new ThrowingEnumerable(); 

    IEnumerable<int> second = new int[] { 5 }; 

    // No exception yet... 

    var query = first.Concat(second); 

    // Still no exception... 

    using (var iterator = query.GetEnumerator()) 

    { 

        // Now it will go bang 

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

    } 

[Test] 

public void SecondSequenceIsntAccessedBeforeFirstUse() 

    IEnumerable<int> first = new int[] { 5 }; 

    IEnumerable<int> second = new ThrowingEnumerable(); 

    // No exception yet... 

    var query = first.Concat(second); 

    // Still no exception... 

    using (var iterator = query.GetEnumerator()) 

    { 

        // First element is fine... 

        Assert.IsTrue(iterator.MoveNext()); 

        Assert.AreEqual(5, iterator.Current); 

        // Now it will go bang, as we move into the second sequence 

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

    } 

}


我们写测试来检查迭代器是否被Dispose掉了。但是我们可以预测到输入序列的迭代器应该会被合理的Dispose掉。实际上,第一个序列的迭代器会在第二个序列开始被迭代之前就被Dispose掉。


开始动手实现吧!


Concat的实现虽然比较简单,但是我写完之后还是觉得F#更值得拥有...实现分为参数校验和迭代器代码块两部分,每一部分都不复杂:


public
 static IEnumerable<TSource> Concat<TSource>( 

    this IEnumerable<TSource> first, 

    IEnumerable<TSource> second) 

    if (first == null) 

    { 

        throw new ArgumentNullException("first"); 

    } 

    if (second == null) 

    { 

        throw new ArgumentNullException("second"); 

    } 

    return ConcatImpl(first, second); 

private static IEnumerable<TSource> ConcatImpl<TSource>( 

    IEnumerable<TSource> first, 

    IEnumerable<TSource> second) 

    foreach (TSource item in first) 

    { 

        yield return item; 

    } 

    foreach (TSource item in second) 

    { 

        yield return item; 

    } 

}


如果不能利用迭代器代码块的话,这个实现会变得很麻烦。虽然不会特别难,但是我们需要记住当前正在迭代的是哪个序列。

如果是在用F#的话,我们可以使用yield!表达式来把它实现的更简单,yield!表达式作用于一整个序列而不是单个的元素。必需得承认在这种场景下使用yield!并不会带来什么性能上的提升(如果是在递归的场景下就很可能会有性能提升),但是能够用一个语句来yield返回整个序列确实是一种更优雅的风格。(Spec#中也有一个类似的结构叫做嵌套迭代器,用yield foreach来表示。)我对F#Spec#了解的都不够深入,所以就不做更深入的比较了。不过我们在以后实现Edulinq的过程中还会遇到好几次“yield返回一个序列中的每个元素”的模式。请记住,我们不能把yield返回的代码抽取到一个单独的方法中去,因为“yield”表达式需要C#编译器的特殊处理。


结论


虽然我用的实现方式还是蛮简单的,但是我还是吐槽一下:) 如果C#里面也有嵌套迭代器那多好啊,虽然说没有它也没有令我太苦恼。

Concat是一个很有用的操作符,不过它也不过是SelectMany的一个特例罢了。Concat只能把两个序列连接成一个序列,而SelectMany则可以把很多个序列连接成一个序列,而且SelectMany在有时还更有普遍性。下次我们会实现SelectMany,而且会展示一些基于SelectMany来实现其他操作符的例子。(等实现Aggregate的时候,我们会再次见到操作符只返回一个值的例子。)


附录:避免不必要的保持引用


有一条留言建议说要在遍历完第一个序列后把它设为null。这样,在遍历完第一个序列后,它就可以被垃圾回收了。如果采取这个建议,那么实现起来会是这样的:

private static IEnumerable<TSource> ConcatImpl<TSource>( 

    IEnumerable<TSource> first, 

    IEnumerable<TSource> second) 

    foreach (TSource item in first) 

    { 

        yield return item; 

    } 

    // Avoid hanging onto a reference we don't really need 

    first = null; 

    foreach (TSource item in second) 

    { 

        yield return item; 

    } 

}

在普通情况下,把一个不再使用的局部变量设为null这种做法是没用的。因为当CLR在执行优化过的代码,并且没有挂上调试器时,垃圾收集器只关心在方法内部可能还会被访问的变量。

但是在我们这个特例中,这么做还是有用的。因为第一个参数并不是一个简单的局部变量,在C#编译器生成的隐藏类型中,它是一个实例字段,而CLR无法判断实例字段是否会被再次使用。

或许我们可以在调用GetEnumerator之前清空掉我们对“first”这个参数的唯一引用。我们可以写一个这样的方法:

public static T ReturnAndSetToNull<T>(ref T value) where T : class 

    T tmp = value; 

    value = null; 

    return tmp; 

}

然后这样调用它:

foreach (TSource item in ReturnAndSetToNull(ref first))

我认为这样做绝对是有点过了,因为迭代器有可能还会持有对集合的引用。不过在遍历之后把“first”这个参数设为null在我看来是说得通的。

需要提醒你一下,我觉得.NETLINQ to Objects的实现里面是不会这样做的。(以后我可能会用一个有finalizer的集合类来测试一下。)

posted on 2011-09-14 22:39  崔鹏飞  阅读(1426)  评论(0编辑  收藏  举报

导航