第二部分 集合与Linq

《编写高质量C#的建议》学习笔记 第二部分 集合与Linq

第二部分 集合与Linq

建议16 元素数量可变的情况下不应该使用数组
建议17 多数情况下使用foreach进行循环遍历
建议18 foreach不能代替for
建议19 使用更有效的对象和集合初始化
建议20 使用泛型集合代替非泛型集合
建议21 选择正确的集合
建议22 确保集合的线程安全
建议23 避免将ListT作为自定义集合类的基类
建议24 迭代器应该是只读的
建议25 谨慎集合属性的可写操作
建议26 使用匿名类型存储Linq查询结果
建议27 在查询中使用Lambda表达式
建议28 理解延迟求值和主动求值之间的区别
建议29 区别Linq查询中的IEnumerableT和IQueryableT
建议30 使用Linq取代集合中的比较器和迭代器
建议31 在Linq查询中避免不必要的迭代

第二部分  集合与Linq

建议16、元素数量可变的情况下不应该使用数组

在C#中,数组一旦被创建,长度就不能改变。如果需要一个动态且可变长度的集合,就应该使用ArrayList或List来创建。而数组本身,尤其是一维数组,在遇到要求高效率的算法时,则会专门被优化以提升其效率。一维数组也称为向量,其性能是最佳的,在IL中使用了专门的指令来处理它们(如newarr、ldlem、ldelema、ldlen和stelem)。

从内存使用的角度来说,数组在创建时被分配了一段固定长度的内存。如果数组的元素是值类型,则每个元素的长度等于相应的值类型的长度;如果数组的元素是引用类型,则每个元素的长度为该引用类型的IntPtr.Size。数组的存储结构一旦被分配,就不能再变化。而ArrayList是链表结构,可以动态地增减内存空间,如果ArrayList存储的是值类型,则会为每个元素增加12字节的空间,其中4字节用于对象引用,8字节是元素装箱时引入的对象头。List是ArrayList的泛型实现,它省去了拆箱和装箱带来的开销。

注意

由于数组本身在内存上的特点,因此在使用数组的过程中还应该注意大对象的问题。所谓“大对象”,是指那些占用内存超过85000字节的对象,它们被分配在大对象堆里。大对象的分配和回收与小对象相比,都不太一样,尤其是回收,大对象在回收过程中会带来效率很低的问题。所以不能肆意对数组指定过大的长度,这会让数组成为一个大对象。

如果一定要动态改变数组的长度,一种方法是将数组转换为ArrayList或List,如下所示:

    int[] iArr = { 0, 1, 2, 3, 4, 5, 6 };
    ArrayList arrayListInt = ArrayList.Adapter(iArr); // 将数组转变为ArrayList
    arrayListInt.Add(7);
    List<int> listInt = iArr.ToList<int>(); //将数组转变为List<T>
    listInt.Add(7);

还有一种方法是用数组的复制功能。数组继承自System.Array,抽象类System.Array提供了一些有用的实现方法,如Copy方法,负责将一个数组的内容复制到另一个数组中。无论哪种方法,改变数组长度就相当于重新创建一个数组对象。

为了让数组看上去本身就具有动态改变长度的功能,可以创建一个名为ReSize的扩展方法,如下:

    public static class ClassForExtensions
    {
        public static Array ReSize(this Array array, int newSize)
        {
            Type t = array.GetType().GetElementType();
            Array newArray = Array.CreateInstance(t, newSize);
            Array.Copy(array, 0, newArray, 0, Math.Min(array.Length, newSize));
            return newArray;
        }
    }

调用方代码如下:

    int[] iArr = { 0, 1, 2, 3, 4, 5, 6 };
    iArr = (int[])iArr.ReSize(10);

下面对改变数组长度和改变List长度的耗时做一个比较,以便强调————在元素数量可变的情况下不应该使用数组。

    static void Main(string[] args)
    {
        ReSizeArray();
        ReSizeList();
    }

    private static void ReSizeArray()
    {
        int[] iArr = { 0, 1, 2, 3, 4, 5, 6 };
        Stopwatch watch = new Stopwatch();
        watch.Start();
        iArr = (int[])iArr.ReSize(10);
        watch.Stop();
        Console.WriteLine("ResizeArray: " + watch.Elapsed);
    }

    private static void ReSizeList()
    {
        List<int> iArr = new List<int>(new int[] { 0, 1, 2, 3, 4, 5, 6 });
        Stopwatch watch = new Stopwatch();
        watch.Start();
        iArr.Add(0);
        iArr.Add(0);
        iArr.Add(0);
        watch.Stop();
        Console.WriteLine("ReSizeList: " + watch.Elapsed);
    }

输出为:

1
2
ResizeArray: 00:00:00.0004441
ReSizeList: 00:00:00.0000036

严格意义上来讲,List不存在改变长度的说法,只是为了比较。但在时间效率上,ResizeList比ResizeArray要高100倍以上。

建议17、多数情况下使用foreach进行循环遍历

假设存在一个数组,其遍历模式可能采用依据索引来进行遍历的方法; 假设存在一个HashTable,其遍历模式可能是按照键值来进行遍历。

无论是哪个集合,如果遍历没有一个公共的接口,那么客户端在进行调用的时候,都相当于是对具体类型进行了编码。这样一来,当需求发生变化时,就必须修改代码。并且由于客户端代码过多的关注了集合内部的实现,代码的可移植性就会变得很差,这直接违反了面向对象中的开闭原则。我们先来实现一个自己的迭代器模式:

    static void Main(string[] args)
    {
        // 使用接口IMyEnumerable代替MyList
        IMyEnumerable list = new MyList();
        // 得到迭代器,在循环中针对迭代器编码,而不是集合MyList
        IMyEnumerator enumerator = list.GetEnumerator();
        for(int i = 0; i < list.Count; i++)
        {
            object current = enumerator.Current;
            enumerator.MoveNext();
        }
        while(enumerator.MoveNext())
        {
            object current = enumerator.Current;
        }
    }

    /// <summary>
    /// 要求所有的迭代器全部实现该接口
    /// </summary>
    interface IMyEnumerator
    {
        bool MoveNext();
        object Current { get; }
    }

    /// <summary>
    /// 要求所有的集合实现该接口
    /// 这样一来,客户端就可以针对该接口编码
    /// 而无需关注具体的实现
    /// </summary>
    interface IMyEnumerable
    {
        IMyEnumerator GetEnumerator();
        int Count { get; }
    }
    
    class MyList : IMyEnumerable
    {
        object[] items = new object[10];
        IMyEnumerator myEnumerator;

        public object this[int i]
        {
            get { return items[i]; }
            set { this.items[i] = value; }
        }

        public int Count
        {
            get { return items.Length; }
        }

        public IMyEnumerator GetEnumerator()
        {
            if(myEnumerator == null)
            {
                myEnumerator = new myEnumerator(this);
            }
            return myEnumerator;
        }
    }

    class myEnumerator : IMyEnumerator
    {
        int index = 0;
        MyList myList;
        publi myEnumerator(MyList myList)
        {
            this.myList = myList;
        }

        public bool MoveNext()
        {
            if(index + 1 > myList.Count)
            {
                index = 1;
                return false;
            }
            else
            {
                idnex++;
                return true;
            }
        }

        public object Current
        {
            get { return myList[index - 1]; }
        }
    }

MyList 模拟了一个集合类,它继承了接口IMyEnumerable,这样,在客户端进行调用的时候,我们就可以直接使用IMyEnumerable来声明变量,如代码中的以下语句:

    IMyEnumerable list = new MyList();

如果未来我们新增了其他的集合类,那么针对list的编码即使不做修改也能运行良好。在IMyEnumerable中声明的GetEnumerator方法返回一个继承了IMyEnumerator的对象。在MyList的内部,默认返回MyEnumerator。MyEnumerator就是迭代器的一个实现,如果对于迭代的需求有变化,可以重新开发一个迭代器(如下所示),然后在客户端迭代的时候使用该迭代器。

    myEnumerator enumerator2 = new myEnumerator(list);
    while(enumerator2.MoveNext())
    {
        object current = enumerator2.Current;
    }

注意,在客户端的代码中,我们在迭代的过程分别演示了for循环和while循环;但是因为使用了迭代器,两个循环都没有针对MyList编码,而是实现了对迭代器的编码。

以上是自己实现的迭代器模式,虽然都加了“My”字样,其实FCL中有与之对应的接口和类型,使用FCL中相应的类型进行客户端的代码编码,大致如下:

    ICollection<object> list = new List<object>();
    IEnumerator enumerator = list.GetEnumerator();
    for(int i = 0; i < list.Count; i++)
    {
        object current = enumerator.Current;
        enumerator.MoveNext();
    }
    while(enumerator.MoveNext())
    {
        object current = enumerator.Current;
    }

但是无论是for循环还是while循环,都有些啰嗦,于是,foreach就出现了:

    foreach(var current in list)
    {
        object current = enumerator.Current;
    }

foreach循环除了可以提供简化的语法外,还有另外两个优势: 自动将代码置入try-finally块。 若类型实现了IDispose接口,它会在循环结束后自动调用Dispose方法。

补充知识:

using是try-finally的语法糖。在对象使用结束后,会自动调用Dispose方法。

1
2
3
4
using(List<object> XXXX = new List<object>())
{

}

等同于:

1
2
3
4
5
6
7
8
9
List<object> XXXX = new List<object>();
try
{

}
finally
{
    XXXX.Dispose();
}

建议18、foreach不能代替for

1
虽然foreach具有语法更简化和默认调用Dispose方法,但不能适用于全部场景。

foreach存在一个问题:不支持循环时对集合进行增删操作

比如运行下面的代码会直接抛出异常InvalidOperationException:

    List<int> list = new List<int>() {0, 1, 2, 3 };
    foreach(int item in list)
    {
        list.Remove(item);
        Console.WriteLine(item.ToString());
    }

取而代之的方式是使用for循环:

    List<int> list = new List<int>() {0, 1, 2, 3 };
    for(int i = 0; i < list.Count; i++)
    {
        list.Remove(list[i]);
        Console.WriteLine(list[i].ToString());
    }

foreach循环使用了迭代器进行集合的遍历,它在FCL提供的迭代器内部维护了一个对集合版本的控制。

集合版本其实就是一个整型变量,任何对集合的增删操作都会是版本号加1。

foreach循环会调用MoveNext方法来遍历元素,在MoveNext方法内部会进行版本号的检测,一旦检测到版本号有变动,就会抛出InvalidOperationException异常。

for循环直接使用索引器,不对集合版本号进行判断,所以不存在因为集合的变动而带来异常(超出索引长度除外)。

关于性能上,两者都承认在时间和内存上存在损耗,尤其是针对泛型集合时,两者的损耗是在同一个数量级别上的。两者内部都是对集合的访问,迭代器仅仅是多进行了一次版本检测。

建议19、使用更有效的对象和集合初始化

对象初始化设定项支持在大括号中对自动实现的属性进行赋值。集合初始化也同样进行了简化。如下:

    Person person = new Person()
    {
        Name = "Mike",
        Age = 20
    }

    List<Person> persons = new List<Person>()
    {
        new Person() { Name = "Rose", Age = 19 },
        mike,
        null
    }

使用集合的初始化设定项,编译器会在集合对象创建完毕后对集合调用Add方法。上面初始化语句中就创建了一个新对象和一个现有对象,以及一个null值。

初始化设定项绝不仅仅是为了对象和集合初始化的方便,更重要的作用是为Linq查询中的匿名类型进行属性的初始化。由于Linq查询返回的集合中匿名类型的属性都是只读的,如果需要为匿名类型属性赋值,或者增加属性,只能通过初始化设定项来进行。初始化设定项还能为属性使用表达式。

下面的代码为Linq查询中创建了一个新的匿名类型,该类型含有属性Name和AgeScope,而AgeScope需要通过计算Person的Age属性得到。

    List<Person> persons2 = new List<Person>()
    {
        new Person() { Name = "Rose", Age = 19 },
        new Person() { Name = "Steve", Age = 45 },
        new Person() { Name = "Jessica", Age = 20 }
    };

    var pTemp = from p in persons2 select new { p.Name, AgeScope = p.Age > 20 ? "Old" : "Young" };
    foreach(var item in pTemp)
    {
        Console.WriteLine(string.Format("{0}:{1}", item.Name, item.AgeScope));
    }

建议20、使用泛型集合代替非泛型集合

建议1中提到,如果要让代码高效运行,应该尽量避免拆箱和装箱,以及尽量减少转型。

下面看ArrayList这个类的使用情况:

    ArrayList arrs = new ArrayList();
    arrs.Add(0);
    arrs.Add(1);
    arrs.Add("mike");
    foreach(var item in arrs)
    {
        Console.WriteLine(item);
    }

首先,ArrayList的Add方法接收的是一个object参数,所以arrs.Add(1)首先会完成一次装箱;其次,在foreach循环中,待遍历到它的时候,又将完成一次拆箱。在这段代码中,整型和字符串作为值类型和引用类型,都会先被隐式地强制转型为object,然后在foreach循环中又被转型回来。同时,这段代码也是非类型安全的:我们让ArrayList同时存储了整型和字符串,但缺少编译时的类型检查。虽然有时候需要有意这样实现,但是更多的时候,应该尽量避免。缺少类型检查,在运行时会带来隐含的bug。集合类ArrayList 如果进行如下所示运算,就会抛出一个InvalidCastException:

    ArrayList arrs = new ArrayList()
    arrs.Add(0);
    arrs.Add(1);
    arrs.Add("mike");
    int t = 0;
    foreach(int item in arrs)
    {
        t += item;
    }

ArrayList同时还提供了一个带ICollection参数的构造方法 ,可以直接接收数组,如下所示:

    var intArr = new int[] { 0, 1, 2, 3 };
    ArrayList arrs = new ArrayList(intArr);

该方法内部实现一样糟糕,如下所示(构造方法内部最终调用了下面的InsertRange方法):

    public virtual void InsertRange(int index, ICollection c)
    {
        // 省略
        if(index < this.size)
        {
            Array.Copy(this._items, index, this._items, index + count, this._size - index);
        }
        object[] array = new object[count];
        c.CopyTo(array, 0);
        array.CopyTo(this._items, index);
        // 省略
    }

概括来说,如果对大型集合进行循环访问、转型或拆箱和装箱操作,使用ArrayList这样的传统集合对效率的影响会非常大。鉴于此,微软提供了对泛型的支持。泛型使用一对<>括号将实际的类型括起来,然后编译器和运行时会完成剩余的工作,如List

注意

非泛型集合在System.Collections命名空间下,对应的泛型集合则在System.Collections.Generic命名空间下。

开始的那段代码泛型实现为:

    List<int> intList = new List<int>();
    intList.Add(1);
    intList.Add(2);
    // intList.Add("mike");
    foreach(var item in intList)
    {
        Console.WriteLine(item.ToString());
    }

代码中被注释掉的那一行不会被编译通过,因为“mike”不是整型,这里就体现了类型安全的特点。

下面的示例比较了非泛型集合和泛型集合在运行中的效率:

    static void Main(string[] args)
    {
        Console.WriteLine("开始测试ArrayList:");
        TestBegin();
        TestArrayList();
        TestEnd()
        Console.WriteLine("开始测试List<T>:");
        TestBegin();
        TestGenericList();
        TestEnd();
    }
    static int collectionCount = 0;
    static Stopwatch watch = null;
    static int testCount = 10000000;
    static void TestBegin()
    {
        GC.Collect(); // 强制对所有代码进行即时垃圾回收
        GC.WaitForPendingFinalizers(); //挂起线程,执行终结器队列中的终结器(即析构方法)
        GC.Collect(); //再次对所有代码进行垃圾回收,主要包括从终结器队列中出来的对象
        collectionCount = GC.CollectionCount(0); //返回在0代中执行的垃圾回收次数
        watch = new Stopwatch();
        watch.Start();
    }
    static void TestEnd()
    {
        watch.Stop();
        Console.WriteLine("耗时:" + watch.Elapsedmilliseconds.ToString());
        Console.WriteLine("垃圾回收次数:" + (GC.CollectionCount(0) - collectionCount));
    }
    static void TestArrayList()
    {
        ArrayList a1 = new ArrayList();
        int temp = 0;
        for(int i = 0; i < testCount; i++)
        {
            a1.Add(i);
            temp = (int)a1[i];
        }
        a1 = null;
    }
    static void TestGenericList()
    {
        List<int> listT = new List<int>();
        int temp = 0;
        for(int i = 0; i < testCount; i++)
        {
            listT.Add(i);
            temp = listT[i];
        }
        listT = null;
    }

输出为:

1
2
3
4
5
6
开始测试ArrayList:
耗时:2375
垃圾回收次数:26
开始测试List<T>:
耗时:220
垃圾回收次数:5

建议21、选择正确的集合

先看一下集合的分类:

  • 集合
    • 线性
      • 直接存取
        • 数组,List,字符串,结构
      • 顺序存取
        • 栈:Stack
        • 队列:Queue
        • 索引群集
          • 散列
          • 字典:Dictionary<TKey,TValue>
          • 链表:LinkedList
    • 非线性
      • 层次群集:树
      • 组群集
        • 集:HashSet

集合总体上分为线性集合和非线性集合。线性集合是指元素具有唯一的前驱和后驱的数据结构类型;非线性集合是指具有多个前驱和后驱的数据结构类型,如:树和图。

线性结合按存储方式又分为直接存储和顺序存储。

所谓直接存储,是指该类型的集合数据元素可以直接通过下标来访问,在C#中直接存储的数据结构有三类:Array(包括数组和List)、string、struct。直接存储结构的优点是:向数据结构中添加元素是很高效的,直接放在数据末尾的第一个空位上就可以了。它的缺点是:向集合插入元素将会变得低效,需要给插入的元素腾出位置并顺序移动后面的元素。

string和struct虽然是直接存储结构,但与一般的集合定义有很大区别,故不在此次讨论范围。在直接存储的数据结构中,需要区分的是数组和List的选择,建议16中已经提过: 如果集合的数目固定并且不涉及转型,使用数组效率高,否则就是用List

顺序存储结构,即线性表。线性表可动态地扩大和缩小,在一片连续的区域中存储数据元素。线性表不能按照索引进行查找,是通过对地址的引用来搜索元素的,为了找到某个元素,它必须遍历所有元素,直到找到对应的元素为止。

所以,线性表的优点是插入和删除数据效率高,缺点是查找的效率相对来说低一些。

线性表又可以分为队列、栈及索引群集,在C#中分别表现为:Queue,Stack,索引群集又进一步泛化为字典类型Dictionary<TKey,TValue>和双向链表LinkedList

队列Queue 先入先出,集合末尾添加元素,集合起始位置删除元素

可以用队列处理并发命令等场景:先让所有客户端的命令入队,然后由专门的工作线程来执行队列的命令。在分布式中的消息队列就是一个典型的队列应用实例。

栈Stack 后入先出 集合末尾添加元素 末尾删除元素

字典Dictionary<TKey,TValue>存储的是键值对,值在基于键的散列码的基础上进行存储。字典类对象由包含集合元素的存储桶组成,每一个存储桶与基于该元素的键的哈希值关联。如果需要根据键进行值的查找,使用Dictionary<TKey,TValue>将会使搜索和检索更快捷。

双向链表LinkedList是一个类型为LinkedListNode的元素对象的集合。当我们觉得在集合中插入和删除数据很慢时,就可以考虑使用链表。如果使用LinkedList,我们会发现此类型并没有其他集合普遍具有的Add方法,取而代之的是AddAfter、AddBefore、AddFirst、AddLast等方法。双向链表中的每个节点都向前指向Previous节点,向后指向Next节点。

以上讨论了线性集合,非线性集合实现的不多。非线性集合分为层次集合和组集合。层次集合(如树)在FCL中没有实现。组集合又分为集和图,集在FCL中实现为HashSet,而图在FCL中也没有对应的实现。集的概念本意是指存放在集合中的元素是无序的且不能重复的。

除了上面提到的集合类型外,还有其他几个要掌握的集合类型,他们是在实际应用中发展而来的对以上基础类型的扩展:SortedList、SortedDictionary<TKey,TValue>、SortedSet。它们所扩展的对应类分别为List、Dictionary<TKey,TValue>、HashSet,作用是将原来无序排列的元素变为有序排列。

除了排序上的需求增加了上面3个集合类外,在命名空间System.Collections.Concurrent下,还涉及几个多线程集合类。主要是:

1
2
3
4
ConcurrentBag<T>对应List<T>
ConcurrentDictionary<TKey,TValue>对应Dictionary<TKey,TValue>
ConcurrentQueue<T>对应Queue<T>
ConcurrentStack<T>对应Stack<T>

建议22、确保集合的线程安全

之前提到,foreach循环不能代替for循环的一个原因是在迭代过程中对集合本身进行了增删操作。将此场景移植到多线程场景中,就是本建议的重点:

确保集合的线程安全。集合线程安全是指在多个线程上添加或删除元素时,线程之间必须保持同步。

下面的代码模拟了一个线程在迭代过程中,另外一个线程对集合的元素进行了删除。

    class Program
    {
        static List<Person> list = new list<Person>()
        {
            new Person() { Name = "Rose", Age = 19 },
            new Person() { Name = "Steve", Age = 45 },
            new Person() { Name = "Jessica", Age = 20 }
        };

        static AutoResetEvent autoSet = new AutoResetEvent(fasle);

        static void Main(string[] args)
        {
            Thread t1 = new Thread( () =>
            {
                // 确保等待t2开始之后才运行下面的代码
                autoSet.WaitOne();
                foreach(var item in list)
                {
                    Console.WriteLine("t1:" + item.Name);
                    Thread.Sleep(1000);
                }
            });
            t1.Start();
            Thread t2 = new Thread(() =>
            {
                // 通知t1可以执行代码
                autoSet.Set();
                // 沉睡1秒是为了确保删除操作在t1的迭代过程中
                Thread.Sleep(1000);
                list.RemoveAt(2);
            });
            t2.Start();
        }
    }
    class Person
    {
        public string Name { get; set; }
        public int Age { get; set; }
    }

以上代码在运行过程中会抛出异常InvalidOperationException:

1
“集合已修改,可能无法执行枚举。”

泛型集合出现之前,非泛型集合一般会提供一个SyncRoot属性,要保证非泛型集合的线程安全,可以通过锁定该属性来实现。

如果上面的集合用ArrayList代替,保证其线程安全则要在迭代和删除的时候加上lock,如下:

    static ArrayList list = new ArrayList()
    {
        new Person() { Name = "Rose", Age = 19 },
        new Person() { Name = "Steve", Age = 45 },
        new Person() { Name = "Jessica", Age = 20 }
    };
    static AutoResetEvent autoSet = new AutoResetEvent(fasle);

    static void Main(string[] args)
    {
        Thread t1 = new Thread(() => 
        {
            // 确保等待t2开始之后才运行下面的代码
            autoSet.WaitOne();
            lock(list.SyncRoot)
            {
                foreach(Person item in list)
                {
                    Console.WriteLine("t1:" + item.Name);
                    Thread.Sleep(1000);
                }
            }
        });
        t1.Start();
        Thread t2 = new Thread(() =>
        {
            // 通知t1可以执行代码
            autoSet.Set();
            //  沉睡1秒是为了确保删除操作在t1的迭代过程中
            Thread.Sleep(1000);
            lock(list.SyncRoot)
            {
                list.RemoveAt(2);
                Console.WriteLine("删除成功");
            }
        });
        t2.Start();
    }

以上代码不会抛出异常,因为锁通过互斥的机制保证了同一时刻只能有一个线程操作集合元素。

泛型集合没有这样的属性,必须要自己创建一个锁定对象来完成同步任务。代码如下:

    static List<Person> list = new List<Person>()
    {
        new Person() { Name = "Rose", Age = 19 },
        new Person() { Name = "Steve", Age = 45 },
        new Person() { Name = "Jessica", Age = 20 }
    };
    static AutoResetEvent autoSet = new AutoResetEvent(fasle);
    static object sycObj = new object();

    static void Main(string[] args)
    {
        // object sycObj = new object();
        Thread t1 = new Thread(() => 
        {
            // 确保等待t2开始之后才运行下面的代码
            autoSet.WaitOne();
            lock(sycObj)
            {
                foreach(Person item in list)
                {
                    Console.WriteLine("t1:" + item.Name);
                    Thread.Sleep(1000);
                }
            }
        });
        t1.Start();
        Thread t2 = new Thread(() =>
        {
            // 通知t1可以执行代码
            autoSet.Set();
            // 沉睡1秒是为了确保删除操作在t1的迭代过程中
            Thread.Sleep(1000);
            lock(sycObj)
            {
                list.RemoveAt(2);
                Console.WriteLine("删除成功");
            }
        });
        t2.Start();
    }

在“建议21:选择正确的集合”中,还指出了在命名空间System.Collections.Concurrent下有若干实现了线程安全的集合类,在多线程应用环境下,我们可以根据实际需求选择这些集合类型。

建议23、避免将ListT作为自定义集合类的基类

如果要实现一个自定义的集合类,不应该以一个FCL集合类为基类,而应该扩展相应的泛型接口。FCL集合类应该以组合的形式包含至自定义的集合类,需扩展的泛型接口通常是IEnumerable和ICollection(或ICollection的子接口,如IList),前者规范了集合类的迭代功能,后者则规范了一个集合通 常会有的操作。

一般情况下,下面两个实现的集合类都能完成默认的需求:

    class Employees1 : List<Employee>
    class Employees2 : IEnumerable<Employee>, ICollection<Employee>

List基本上没有提供可供子类使用的protected成员(从object中继承来的Finalize方法和MemberwiseClone方法除外),也就是说,实际上,继承List并没有带来任何继承上的优势,反而丧失了面向接口编程带来的灵活性。而且稍加不注意,隐含的bug就会接踵而至。

以Employees1为例,如果要在Add方法中加入某些需求方面的变化,比如,这个变化是为名字添加一个后缀“Changed”,但是客户端的开发人员也许习惯了面向接口的编程方式,在为集合添加元素的时候使用了如下语法:

    static void Main(string[] args)
    {
        Employees1 employees1 = new Employees1()
        {
            new Employee() { Name = "Mike" },
            new Employee() { Name = "Rose" }
        };
        IList<Employee> employees = employees1;
        employees.Add(new Employee() { Name = "Steve" });
        foreach(var item in employees1)
        {
            Console.WriteLine(item.Name);
        }
    }
    class Employee
    {
        public string Name { get; set; }
    }
    class Employees1 : List<Employee>
    {
        public new void Add(Employee item)
        {
            item.Name += " Changed!";
            base.Add(item);
        }
    }

于是代码的实际输出会偏离集合类的设计者的设想,以上代码的输出为:

1
2
3
Mike Changed!
Rose Changed!
Steve

要纠正这类非预期的行为,我们应该采用Employees2的方式:

    static void Main(string[] args)
    {
        Employees2 employees2 = new Employees2()
        {
            new Employee() { Name = "Mike" },
            new Employee() { Name = "Rose" } 
        };
        ICollection<Employee> employees = employees2;
        employees.Add(new Employee() { Name = "Steve" });
        foreach(var item in employees2)
        {
            Console.WriteLine(item.Name);
        }
    }
    class Employees2 : IEnumerable<Employee>, ICollection<Employee>
    {
        List<Employee> items = new List<Employee>();

        #region IEnumerable<Employee> 成员

        public IEnumerable<Employee> GetEnumerator()
        {
            return items.GetEnumerator();
        }

        #endregion

        #region ICollection<Employee> 成员

        public void Add(Employee item)
        {
            item.Name += " Changed!";
            items.Add(item);
        }

        // 省略

        #endregion
    }

以上代码输出为:

1
2
3
Mike Changed!
Rose  Changed!
Steve Changed!

建议24、迭代器应该是只读的

FCL中迭代器只有GetEnumerator方法,没有SetEnumerator方法。所有的集合类也没有一个可写的迭代器属性。原因有二:

其一:违背了设计模式中的开闭原则。被设置到集合中的迭代器可能会直接导致集合的行为发生异常或变动。一旦确实需要新的迭代需求,完全可以创建一个新的迭代器来满足需求,而不为集合设置该迭代器,因为这样做会直接导致使用到该集合对象的其他迭代场景发生不可知的行为。

其二:使用Linq可以不用创建任何新的类型就能满足任何的迭代需求。

通过代码来看一下,若迭代器不是只读的,会存在什么样的危害:

假设存在一个公共集合对象,有两个业务类需要对这个集合对象进行操作。其中,业务类A只负责将元素迭代出来显示到UI上:

    IMyEnumerable list = new MyList();
    IMyEnumerator enumerator = list.GetEnumerator();
    while(enumerator.MoveNext())
    {
        int current = enumerator.Current;
        Console.WritLine(current.ToString());
    }

业务类B出于自己的某种需求,需要实现一个新的针对集合对象的迭代器,于是这样操作:

    MyEnumerator2 enumerator2 = new MyEnumerator2(list as MyList);
    (list as MyList).SetEnumerator(enumerator2);
    while(enumerator2.MoveNext())
    {
        int current = enumerator2.Current;
        Console.WritLine(current.ToString());
    }

问题的关键是,现在再回到业务类A中执行一次迭代显式,结果将会是B所设置的迭代器完成输出。这相当于B在没有通知A的情况下对A的行为进行了干扰,这种情况应该避免。

事实上,上面的代码即使没有下面这行代码也会运行的很好:

    (list as MyList).SetEnumerator(enumerator2);

所以,迭代器模式的原则是: 不要为迭代器设置可写属性。

建议25、谨慎集合属性的可写操作

如果类型的属性中有集合属性,那么应该保证属性对象是由类型本身产生的。如果将属性设置为可写,则会增加抛出异常的几率。一般情况下,如果集合属性没有值,则它返回的Count等于0,而不是集合属性的值为null。下面的代码将产生一个NullReferenceException异常:

    class Program
    {
        static List<Student> list1 = new List<Student>()
        {
            new Student() { Name = "Mike", Age = 1 },
            new Student() { Name = "Rose", Age = 2 }
        };

        static void Main(string[] args)
        {
            StudentTeamA teamA = new StudentTeamA();
            Thread t1 = new Thread(() =>
            {
                teamA.Students = list1;
                Thread.Sleep(3000);
                Console.WriteLine(listStudent.Count); //模拟对集合属性进行一些运算
            });
            t1.Start();
            Thread t2 = new Thread(() =>
            {
                listStudent = null; // 模拟在别的地方对list1而不是属性本身赋值为null
            });
            t2.Start();
        }
    }
    class Student
    {
        public string Name { get; set; }
        public int Age { get; set; }
    }
    class StudentTeamA
    {
        public List<Student> Students { get; set; }
    }

上面的代码中存在的问题是:线程t1模拟将对类型StudentTeamA的Students属性进行赋值,它是一个可读/可写的属性。由于集合属性是一个引用类型,而当前针对该属性对象的引用却有两个,即集合本身和调用者的类型变量listStudent。线程t2也许是另一个程序员写的,但他看到的只有listStudent,结果针对listStudent的修改会直接影响到另一个工作线程中的对象。

下面的StudentTeamA版本是一个改进的版本。首先,将类型的集合属性设置为只读;其次,集合对象由类型自身创建,这保证了集合属性永远只有一个引用:

    class Program
    {
        static List<Student> listStudent = new List<Student>()
        {
            new Student() { Name = "Mike", Age = 1 },
            new Student() { Name = "Rose", Age = 2 }
        };

        static void Main(string[] args)
        {
            StudentTeamA teamA2 = new StudentTeamA();
            teamA2.Students.Add(new Student() { Name = "Steve", Age = 3 });
            teamA2.Students.AddRange(listStudent);
            Console.WriteLine(teamA2.Students.Count);
            // 也可以像下面这样实现
            StudentTeamA teamA3 = new StudentTeamA(listStudent);
            Console.WriteLine(teamA3.Students.Count);
        }

    }
    class Student
    {
        public string Name { get; set; }
        public int Age { get; set; }
    }
    class StudentTeamA
    {
        public List<Student> Students { get; private set; }

        public StudentTeamA()
        {
            Students = new List<Student>();
        }

        public StudentTeamA(IEnumerable<Student> StudentList) : this()
        {
            Students.AddRange(studentList);
        }
    }

在改进的版本的StudentTeamA中尝试对属性Students进行赋值,如下:

    teamA.Students = listStudent;
1
编译将不通过。

建议26、使用匿名类型存储Linq查询结果

匿名类型由var、赋值运算符和一个非空初始值(或以new开头的初始化项)组成。有如下基本特性:

1
2
3
4
5
6
既支持简单类型也支持复杂类型。简单类型必须是一个非空初始值,复杂类型则是一个以new开头的初始化项;
匿名类型的属性是只读的,没有属性设置器,它一旦被初始化就不可更改;
如果两个匿名类型的属性值相同,那么就认为两个匿名类型相等;
匿名类型可以在循环中作用初始化器;
匿名类型支持智能感知;
匿名类型可以拥有方法(不常用)

考虑如下场景: 将Person或Person的相关类(如Company)从数据库中取出来之后,需要将Person中的属性Name和根据CompanyID对应起来的Company的属性Name关联起来,形成一个新的类型。我们只需要如下代码即可实现:

    var personWithCompanyList = from person in personList join company in companyList on person.CompanyID equals company.CompanyID
        select new { PersonName = person.Name, CompanyName = company.Name };

建议27、在查询中使用Lambda表达式

Linq实际上是基于扩展方法和Lambda表达式的,任何Linq查询都能通过调用扩展方法的方式来代替,如下代码所示:

    var personWithCompanyList = from person in personList select new 
        { PersonName = person.Name, CompanyName = person.CompanyID == 0 ? "Micro" : "Sun" };
    foreach(var item in personWithCompanyList)
    {
        Console.WriteLine(string.Format("{0}\t:{1}", item.PersonName, item.CompanyName));
    }

直接调用扩展方法Select,并且为Select方法传入一个Lambda表达式:

    foreach(var item in personList.select(person => new { PersonName = person.Name, CompanyName = person.CompanyID == 0 ? "Micro" : "Sun"}))
    {
        Console.WriteLine(string.Format("{0}\t:{1}", item.PersonName, item.CompanyName));
    }

针对Linq设计的扩展方法大多应用了泛型委托。System命名空间定义了泛型委托Action、Func和Predicate。

可以这样理解这三个委托:

Action用于执行一个操作,所以它没有返回值;

Func用于执行一个操作并返回一个值;

Predicate用于定义一组条件并判断参数是否符合条件。

Select扩展方法接收的就是一个Func委托,而Lambda表达式其实就是一个简洁的委托,运算符“=>”左边代表的是方法的参数,右边的是方法体。

建议28、理解延迟求值和主动求值之间的区别

看个例子:

    List<int> = new List<int>() { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
    var temp1 = from c in list where c > 5 select c;
    var temp2 = (from c in list where c > 5 select c).ToList<int>();
    list[0] = 11;
    Console.Write("temp1: ");
    foreach(var item in temp1)
    {
        Console.Write(item.ToString() + " ");
    }
    Console.Write("\ntemp2: ");
    foreach(var item in temp2)
    {
        Console.Write(item.ToString() + " ");
    }

上面代码输出为:

1
2
temp1: 11 6 7 8 9
temp2: 6 7 8 9

在延迟求值的情况下,只是定义了一个查询,而且不是立刻执行。对查询结果的访问每次都会遍历原集合。

如上文中对于temp1的迭代,在迭代之前,我们修改了list[0]的值,直接影响了迭代的输出。对查询调用ToList、ToArray等方法,将会使其立即执行,由于对List[0]的修改是在temp2查询之后进行的,所以针对list[0]的修改不会影响到temp2的结果。

在使用Linq to Sql时,延迟求值能够带来显著的性能提升。如果定义了两个查询,而且采用延迟求值,CLR则会合并两次查询并生成一个最终的查询:

    static void Main(string[] args)
    {
        DataContext ctx = new DataContext("server=.;database=Temp;uid=sa;pwd=123");
        Table<Person> persons = ctx.GetTable<Person>();

        var temp1 = from p in persons where p.Age > 20 select p;
        var temp2 = from p in temp1 where p.Name.IndexOf('e') > 0 select p;
        foreach(var item in temp2)
        {
            Console.WriteLine(string.Format("Name:{0}\tAge:{1}", item.Name, item.Age));
        }
    }

在迭代开始的时候,Linq to Sql引擎会生成一条合并了年龄和姓名的条件查询SQL语句。

如果采用主动求值:

    var temp1 = (from p in persons where p.Age > 20 select p).ToList<Person>();
    var temp2 = from p in temp1 where p.Name.IndexOf('e') > 0 select p;

会生成仅仅包含对年龄条件的查询。执行完temp2才会返回最终结果。

建议29、区别Linq查询中的IEnumerableT和IQueryableT

Linq查询从功能上来讲实际上可分为三类: Linq to Objects、Linq to SQL、Linq to XML(此处不讨论)。

设计两套接口的原因是为了区别对待Linq to Objects、Linq to SQL,两者对于查询的处理在内部使用的是完全不同的机制。针对Linq to Objects时,使用Enumerable中的扩展方法对本地集合进行排序和查询等操作,查询参数接受的是Func<>。Func<>叫做谓语表达式,相当于一个委托。针对Linq to SQL时,则使用Queryable中的扩展方法,它接受的参数是Expression<>。

Expression<>用于包装Func<>。Linq to SQL引擎最终会将表达式树转化成为相应的SQL语句,然后在数据库中执行。

简单说就是: **本地数据源用IEnumerable,远程数据源用IQueryable。**

分析如下代码:

    private static void NewMethod3()
    {
        DataContext ctx = new DataContext("server=.;database=Temp;uid=sa;pwd=123");
        Table<Person> persons = ctx.GetTable<Person>();

        var temp1 = (from p in persons where p.Age > 20 select p).AsEnumerable<Person>();
        var temp2 = from p in temp1 where p.Name.IndexOf('e') > 0 select p;
        foreach(var item in temp2)
        {
            Console.WriteLine(string.Format("Name:{0}\tAge:{1}", item.Name, item.Age));
        }
    }

    private static void NewMethod2()
    {
        DataContext ctx = new DataContext("server=.;database=Temp;uid=sa;pwd=123");
        Table<Person> persons = ctx.GetTable<Person>();

        var temp1 = from p in persons where p.Age > 20 select p;
        var temp2 = from p in temp1 where p.Name.IndexOf('e') > 0 select p;
        foreach(var item in temp2)
        {
            Console.WriteLine(string.Format("Name:{0}\tAge:{1}", item.Name, item.Age));
        }
    }

在Method3中,虽然针对temp1使用的也是延迟求值,但是整个Linq语句最后对结果使用了AsEnumerable方法,相当于将远程数组转成了本地数据。

最终Method3的执行SQL为:

1
    select [t0].[Name],[t0].[Age] from [Person] as [t0] where [t0].[Age] > @p0',N'@p0 int',@p0=20

对于Method2方法,建议28中提到,所生成的SQL语句是组合了Age和Name筛选条件的,所以仅仅返回符合条件的记录。

所以在Linq to SQL的查询中药尽量始终使用IQueryable

IEnumerable查询的逻辑可以直接用我们自己所定义的方法,而IQueryable则不能使用自定义的方法,它必须先生成表达式树,查询由Linq to SQL引擎处理。

在使用IQueryable查询的时候,如果使用自定义的方法,会抛出异常。

    DataContext ctx = new DataContext("server=.;database=Temp;uid=sa;pwd=123");
    Table<Person> persons = ctx.GetTable<Person>();
    var temp1 = from p in persons where OlderThan20(p.Age) select p;
    foreach(var item in temp1)
    {
        Console.WriteLine(string.Format("Name:{0}\tAge:{1}", item.Name, item.Age));
    }

以上代码抛出异常NotSupportedException:

1
方法“Boolean OlderThan20(Int32)”不支持转换为SQL。

但是如果将查询换成一个IEnumerable查询,这种模式是支持的:

    List<int> list = new List<int>() { 19, 20, 21, 22 };
    var temp2 = from c in list where OlderThan20(c) select c;

建议30、使用Linq取代集合中的比较器和迭代器

在建议10中实现的排序至少存在两个问题:

1
2
1)可扩展性太低,如果存在新的排序要求,就必须实现新的比较器。
2)对代码的侵入性太高,为类型继承了接口,增加了新的方法。

通过Linq可以实现上述的排序要求,同时保持类型仅仅是一个最简单的实体类,代码如下:

    var orderByBaseSalary = from s in companySalary orderby s.BaseSalary select s;
    var orderByBonus = from s in companySalary orderby s.Bonus select s;
    foreach(Salary item in orderByBaseSalary)
    // foreach(Salary item in orderByBonus)
    {
        Console.WriteLine(......)
    }

上面代码隐含的一个功能,foreach实际隐含调用的是集合对象orderByBaseSalary和orderByBonus的迭代器。以往,如果我们要绕开集合的Sort方法,对集合元素按照一定的顺序进行迭代,需要让类型继承IEnumerable接口(泛型集合是IEnumerable接口),实现一个或多个迭代器。现在从Linq查询生 成匿名类型来看,相当于可以无限为集合增加迭代需求。

有了Linq,仍需要比较器和迭代器。我们可以利用Linq的强大功能简化自己的编码,但是Linq功能的实现本身就是借助FCL泛型集合的比较器、迭代器、索引器的。Linq相当于封装了这些功能。

在命名空间System.Linq下存在很多静态类,这些静态类型存在的意义就是为FCL的泛型集合提供扩展方法,如:

    var orderByBaseSalary = from s in companySalary orderby s.BaseSalary select s;

orderby实际就是调用了System.Linq.Enumerable类型的OrderBy方法:

    public static IOrderedEnumerable<TSource> OrderBy<TSource, TKey>(this IEnumerable<TSource> source, Func<TSource, TKey> keySelector)
    {
        // ...
    }

这是个扩展方法,它为继承了IEnumerable接口的集合类型提供排序功能。

建议31、在Linq查询中避免不必要的迭代

无论是SQL查询还是Linq查询,搜索到结果立刻返回总比搜索完所有结果再将结果返回的效率要高。

如(假设list有5条数据):

    from c in list where c.Age == 20 select c;
    (from c in list where c.Age >= 20 select c).First()

通常认为第一种的效率会更高,实际上是第二种情况更高。查看下面的测试语句,第一种查询集合迭代了5次,第二种仅迭代了1次。

    MyList list = new MyList();
    var temp = (from c in list where c.Age == 20 select c).ToList();
    Console.WriteLine(list.IteratedNum.ToString()); // 5
    list.IteratedNum = 0;
    var temp2 = (from c in list where c.Age >= 20 select c).First();
    Console.WriteLine(list.IteratedNum.ToString()); // 1
注意

第二次查询仅仅迭代1次是因为20的元素正好放在list的首位。First方法实际完成的工作是:搜索到满足条件的第一个元素,就从集合中返回。如果没有符合条件的元素,它也会遍历整个集合。

与First方法类似的还有Take方法,Take方法接收一个整型参数,然后返回该参数指定的元素个数。与First一样,它在满足条件以后,会从当前的迭代过程直接返回,而不是等到整个迭代过程完毕再返回。如果一个集合包含了很多元素,那么这种查询会为我们带来可观的时间效率。

如下,使用Take方法仅仅迭代了2次,而使用where查询方式带来的却是整个集合的迭代:

    MyList list = new MyList();
    var temp = (from c in list select c).Take(2).ToList();
    Console.WriteLine(list.IteratedNum.ToString()); // 2
    list.IteratedNum = 0;
    var temp2 = (from c in list where c.Name == "Mike" select c).ToList();
    Console.WriteLine(list.IteratedNum.ToString()); // 5

在实际的编码中,要充分运用First和Take等方法,这样才能带来高效,避免时间浪费在一些无效的迭代中。

许文忠 wechat
欢迎您扫一扫上面的微信公众号,订阅我的博客!