Effective C# 原则11:选择foreach循环

JerryXia 发表于 , 阅读 (1,037)

C#的foreach语句是从do,while,或者for循环语句变化而来的,它相对要好一些,它可以为你的任何集合产生最好的迭代代码。它的定义依懒于.Net框架里的集合接口,并且编译器会为实际的集合生成最好的代码。当你在集合上做迭代时,可用使用foreach来取代其它的循环结构。检查下面的三个循环:

int [] foo = new int[100];

// Loop 1:
foreach ( int i in foo)
    Console.WriteLine( i.ToString( ));

// Loop 2:
for ( int index = 0;  index < foo.Length;  index++ )
    Console.WriteLine( foo[index].ToString( ));

// Loop 3:
int len = foo.Length;
for ( int index = 0;  index < len;  index++ )
    Console.WriteLine( foo[index].ToString( ));

对于当前的C#编译器(版本1.1或者更高)而言,循环1是最好的。起码它的输入要少些,这会使你的个人开发效率提提升。(1.0的C#编译器对循环1而言要慢很多,所以对于那个版本循环2是最好的。)
循环3,大多数C或者C++程序员会认为它是最有效的,但它是最糟糕的。因为在循环外部取出了变量Length的值,从而阻碍了JIT编译器将边界检测从循环中移出。

C#代码是安全的托管代码里运行的。环境里的每一块内存,包括数据的索引,都是被监视的。稍微展开一下,循环3的代码实际很像这样的:

// Loop 3, as generated by compiler:
int len = foo.Length;
for ( int index = 0;  index < len;  index++ )
{
    if ( index < foo.Length )
        Console.WriteLine( foo[index].ToString( ));
    else
        throw new IndexOutOfRangeException( );
}

C#的JIT编译器跟你不一样,它试图帮你这样做了。你本想把Length属性提出到循环外面,却使得编译做了更多的事情,从而也降低了速度。CLR要保证的内容之一就是:你不能写出让变量访问不属于它自己内存的代码。在访问每一个实际的集合时,运行时确保对每个集合的边界(不是len变量)做了检测。你把一个边界检测分成了两个。

你还是要为循环的每一次迭代做数组做索引检测,而且是两次。循环1和循环2要快一些的原因是因为,C#的JIT编译器可以验证数组的边界来确保安全。任何循环变量不是数据的长度时,边界检测就会在每一次迭代中发生。(译注:这里几次说到JIT编译器,它是指将IL代码编译成本地代码时的编译器,而不是指将C#代码或者其它代码编译成IL代码时的编译器。其实我们可以用不安全选项来迫使JIT不做这样的检测,从而使运行速度提高。)

原始的C#编译器之所以对foreach以及数组产生很慢的代码,是因为涉及到了装箱。装箱会在原则17中展开讨论。数组是安全的类型,现在的foreach可以为数组生成与其它集合不同的IL代码。对于数组的这个版本,它不再使用IEnumerator接口,就是这个接口须要装箱与拆箱。

IEnumerator it = foo.GetEnumerator( );
while( it.MoveNext( ))
{
    int i = (int) it.Current; // box and unbox here.
    Console.WriteLine( i.ToString( ) );
}

取而代之的是,foreach语句为数组生成了这样的结构:

for ( int index = 0;  index < foo.Length;  index++ )
    Console.WriteLine( foo[index].ToString( ));

(译注:注意数组与集合的区别。数组是一次性分配的连续内存,集合是可以动态添加与修改的,一般用链表来实现。而对于C#里所支持的锯齿数组,则是一种折衷的处理。)

foreach总能保证最好的代码。你不用操心哪种结构的循环有更高的效率:foreach和编译器为你代劳了。

如果你并不满足于高效,例如还要有语言的交互。这个世界上有些人(是的,正是他们在使用其它的编程语言)坚定不移的认为数组的索引是从1开始的,而不是0。不管我们如何努力,我们也无法破除他们的这种习惯。.Net开发组已经尝试过。为此你不得不在C#这样写初始化代码,那就是数组从某个非0数值开始的。

// Create a single dimension array.
// Its range is [ 1 .. 5 ]
Array test = Array.CreateInstance( typeof( int ),
new int[ ]{ 5 }, new int[ ]{ 1 });

这段代码应该足够让所有人感到畏惧了(译注:对我而言,确实有一点)。但有些人就是很顽固,无认你如何努力,他们会从1开始计数。很幸运,这是那些问题当中的一个,而你可以让编译器来“欺骗”。用foreach来对test数组进行迭代:

foreach( int j in test )
    Console.WriteLine ( j );

foreach语句知道如何检测数组的上下限,所以你应该这样做,而且这和for循环的速度是一样的,也不用管某人是采用那个做为下界。

对于多维数组,foreach给了你同样的好处。假设你正在创建一个棋盘。你将会这样写两段代码:

private Square[,] _theBoard = new Square[ 8, 8 ];

// elsewhere in code:
for ( int i = 0; i < _theBoard.GetLength( 0 ); i++ )
    for( int j = 0; j < _theBoard.GetLength( 1 ); j++ )
        _theBoard[ i, j ].PaintSquare( );

取而代之的是,你可以这样简单的画这个棋盘:

foreach( Square sq in _theBoard )
    sq.PaintSquare( );

(译注:本人不赞成这样的方法。它隐藏了数组的行与列的逻辑关系。循环是以行优先的,如果你要的不是这个顺序,那么这种循环并不好。)

foreach语句生成恰当的代码来迭代数组里所有维数的数据。如果将来你要创建一个3D的棋盘,foreach循环还是一样的工作,而另一个循环则要做这样的修改:

for ( int i = 0; i < _theBoard.GetLength( 0 ); i++ )
  for( int j = 0; j < _theBoard.GetLength( 1 ); j++ )
    for( int k = 0; k < _theBoard.GetLength( 2 ); k++ )
      _theBoard[ i, j, k ].PaintSquare( );

(译注:这样看上去虽然代码很多,但我觉得,只要是程序员都可以一眼看出这是个三维数组的循环,但是对于foreach,我看没人一眼可以看出来它在做什么!
个人理解。当然,这要看你怎样认识,这当然可以说是foreach的一个优点。)

事实上,foreach循环还可以在每个维的下限不同的多维数组上工作(译注:也就是锯齿数组)。
我不想写这样的代码,即使是为了做例示。但当某人在某时写了这样的集合时,foreach可以胜任。

foreach也给了你很大的伸缩性,当某时你发现须要修改数组里底层的数据结构时,它可以尽可能多的保证代码不做修改。我们从一个简单的数组来讨论这个问题:int
[] foo = new int[100];假设后来某些时候,你发现它不具备数组类(array
class)的一些功能,而你又正好要这些功能。你可能简单把一个数组修改为ArrayList:

// Set the initial size:
ArrayList foo = new ArrayList( 100 );

任何用for循环的代码被破坏:

int sum = 0;
for ( int index = 0;
    // won't compile: ArrayList uses Count, not Length
    index < foo.Length;
    index++ )
    // won't compile: foo[ index ] is object, not int.
    sum += foo[ index ];

然而,foreach循环可以根据所操作的对象不同,而自动编译成不同的代码来转化恰当的类型。什么也不用改。还不只是对标准的数组可以这样,对于其它任何的集合类型也同样可以用foreach.

如果你的集合支持.Net环境下的规则,你的用户就可以用foreach来迭代你的数据类型。为了让foreach语句认为它是一个集合类型,一个类应该有多数属性中的一个:公开方法GetEnumerator()的实现可以构成一个集合类。明确的实现IEnumerable接口可以产生一个集合类。实现IEnumerator接口也可以实现一个集合类。foreach可以在任何一个上工作。

foreach有一个好处就是关于资源管理。IEnumerable接口包含一个方法:GetEnumerator()。foreach语句是一个在可枚举的类型上生成下面的代码,优化过的:

IEnumerator it = foo.GetEnumerator( ) as IEnumerator;
using ( IDisposable disp = it as IDisposable )
{
    while ( it.MoveNext( ))
    {
        int elem = ( int ) it.Current;
        sum += elem;
    }
}

如果断定枚举器实现了IDisposable接口,编译器可以自动优化代码为finally块。但对你而言,明白这一点很重要,无论如何,foreach生成了正确的代码。

foreach是一个应用广泛的语句。它为数组的上下限自成正确的代码,迭代多维数组,强制转化为恰当的类型(使用最有效的结构),还有,这是最重要的,生成最有效的循环结构。这是迭代集合最有效的方法。这样,你写出的代码更持久(译注:就是不会因为错误而改动太多的代码),第一次写代码的时候更简洁。这对生产力是一个小的进步,随着时间的推移会累加起来。

添加新评论