Maison  >  Article  >  développement back-end  >  Pièges C# que vous ne connaissez peut-être pas, explication détaillée de l'exemple de code de l'interface IEnumerable

Pièges C# que vous ne connaissez peut-être pas, explication détaillée de l'exemple de code de l'interface IEnumerable

黄舟
黄舟original
2017-03-09 15:05:305140parcourir

Les pièges C# que vous ne connaissez peut-être pas, explication détaillée de l'exemple de code de l'interface IEnumerable :

L'importance de l'interface de l'énumérateur IEnumerable, parlons-en. Les mots ne suffisent pas. Presque toutes les collections implémentent cette interface, et le cœur de Linq s'appuie également sur cette interface universelle. La boucle for en langage C est fastidieuse à écrire, mais foreach est beaucoup plus fluide.

J'aime beaucoup cette interface, mais je rencontre aussi beaucoup de questions lors de son utilisation. Avez-vous la même confusion que moi :

(1) Quelle est la différence entre IEnumerable et. IEnumerator ?

(2) Les énumérations peuvent-elles être accessibles hors limites ? Quelles sont les conséquences d'un accès hors limites ? Pourquoi la valeur d'une collection ne peut-elle pas être modifiée lors d'une énumération ?

(3) Quelle est l'implémentation spécifique de Linq ? Par exemple, Skip ignore certains éléments accessibles  ?

(4) Quelle est l’essence d’IEnumérable ?

(5) Une fermeture sera-t-elle formée dans l'énumération IEnumerable ? Plusieurs processus de dénombrement vont-ils interférer les uns avec les autres ? Puis-je modifier dynamiquement les éléments d’une énumération au sein d’une énumération ?

….

Si vous êtes intéressé, nous continuerons avec le contenu ci-dessous.

Avant de commencer, notre article stipule qu'une énumération est un IEnumerable, une itération est un IEnumerable et celle qui a été instanciée (comme ToList()) est une collection.

1. IEnumerable et IEnumerable

IEnumerable n'a qu'une seule méthode abstraite : GetEnumerator(), et IEnumerable est un itérateur, qui réalise véritablement la fonction d'accès à une collection. IEnumerator n'a qu'une seule propriété Current et deux méthodes : MoveNext et Reset.

Il y a un petit problème. Ne suffit-il pas de simplement créer une interface d'accès ? Pourquoi deux interfaces qui semblent déroutantes ? L’un est appelé énumérateur et l’autre est appelé itérateur. Parce que

(1) implémenter IEnumerator est un sale boulot, ajouter deux méthodes et un attribut en vain, et ces deux méthodes ne sont en fait pas faciles à implémenter (seront mentionnées plus tard).

(2) Il doit maintenir l'état initial, savoir comment MoveNext, comment terminer, et en même temps renvoie l'état précédent de l'itération , ce qui n'est pas facile.

(3) L'itération n'est évidemment pas thread-safe. Chaque IEnumerable générera un nouveau IEnumerator, formant ainsi plusieurs processus d'itération qui ne s'affectent pas les uns les autres. Pendant le processus d'itération, la collection d'itérations ne peut pas être modifiée, sinon elle est dangereuse.

Donc, tant que vous implémentez IEnumerable, le compilateur nous aidera à implémenter IEnumerable. De plus, dans la plupart des cas, elles sont héritées de collections existantes et il n'est généralement pas nécessaire de remplacer les méthodes MoveNext et Reset. Bien entendu, IEnumerable a également une implémentation générique, ce qui n'affecte pas la discussion sur le problème.

IEnumerable nous rappelle une liste chaînée à sens unique. En C, un champ de pointeur est nécessaire pour enregistrer les informations du nœud suivant. Donc dans IEnumerable, qui aide à enregistrer ces informations ? Ce processus prend-il de la mémoire ? Occupe-t-il la zone du programme ou la zone du tas ?

Cependant, IEnumerable a aussi ses défauts. Il ne peut pas revenir en arrière ou sauter (il ne peut sauter qu'un par un), et il n'est pas facile d'implémenter la réinitialisation et l'accès à l'index ne peut pas être obtenu. Pensez-y, s'il s'agit d'un processus d'énumération d'une collection d'instances, revenez simplement directement au 0ème élément, mais si ce IEnumerable est une longue chaîne d'accès, il sera très difficile de retrouver la racine d'origine ! Ainsi, l'auteur de CLR via C# vous dit qu'en fait, de nombreuses implémentations de Reset sont simplement des mensonges. Sachez simplement que cette chose existe et ne vous y fiez pas trop.

2. Y a-t-il une différence entre foreach et MoveNext

La plus grande caractéristique d'IEnumérable est qu'il place le processus d'accès sous le contrôle du visiteur lui-même. En langage C, le contrôle des tableaux est complètement externe. Cette interface encapsule le processus d'accès en interne, améliorant encore l'encapsulation. Par exemple :

public class People  //定义一个简单的实体类
    {
        public string Name { get; set; }
        public int Age { get; set; }
    }

    public class PersonList
    {
        private readonly List<People> peoples;

        public PersonList()  //为了方便,构造过程中插入元素
        {
            peoples = new List<People>();
            for (int i = 0; i < 5; i++)
            {
                peoples.Add(new People {Name = "P" + i, Age = 30 + i});
            }
        }

        public int OldAge = 31;
        public IEnumerable<People> OlderPeoples
        {
            get
            {
                foreach (People people in _people)
                {
                    if (people.Age > OldAge)
                        yield return people;
                }
                yield break;
            }
        }
    }

L'essence de IEnumerable est une machine à états, qui est quelque peu similaire au concept d'événements. Elle jette l'implémentation à l'extérieur et réalise des voyages entre les codes (pensez interstellaire. voyage), c'est la base de Linq. Les itérateurs sympas sont-ils vraiment aussi simples qu’on le pense ?

En langage C, un tableau est un tableau, un véritable espace mémoire Alors que signifie IEnumerable ? S'il est implémenté par une vraie collection (comme List), alors pas de problème, c'est aussi de la vraie mémoire, mais que se passe-t-il si c'est l'exemple ci-dessus ? Le retour de rendement renvoyé par le filtrage ne renvoie que des éléments, mais cette collection réelle peut ne pas exister. Si vous décompilez le retour de rendement d'un simple énumérateur, vous constaterez qu'il s'agit en fait d'un ensemble de cas de commutation et que le compilateur travaille dans le cas. contexte. Il a fait beaucoup de travail pour nous.

Current est en fait vide si MoveNext n'est pas utilisé dans le nouvel itérateur généré. Pourquoi est-ce ? Pourquoi un itérateur ne pointe-t-il pas directement vers l'élément head ?

(Merci pour la réponse : tout comme le pointeur de tête d'une liste chaînée unidirectionnelle en langage C, vous pouvez ainsi spécifier une énumération qui ne contient aucun élément, ce qui rend la programmation plus pratique)

foreach à chaque fois Avancez d'un espace et arrêtez-vous lorsqu'il atteint la fin. Attends, tu es sûr que ça s'arrêtera quand ça se terminera ? Faisons une expérience :

public IEnumerable<People> Peoples1   //直接返回集合
        {
            get { return peoples; }
        }public IEnumerable<People> Peoples2  //包含yield break;
        {
            get
            {
                foreach (var people in peoples)
                {
                    yield return people;
                }
                yield break;  //其实这个用不用都可以
            }
        }

Les deux méthodes ci-dessus sont nos méthodes courantes. Notez que dans la deuxième implémentation, ReSharper marque la rupture de rendement en gris (répété).

我们再写下如下的测试代码,peopleList集合只有五个元素,但尝试去MoveNext 8次。可以把peopleList.Peoples1换成2,3,分别测试。

            var peopleList = new PeopleList();  //内部构造函数插入了五个元素
            IEnumerator<People> e1 = peopleList.Peoples1.GetEnumerator();
            if (e1.Current == null)
            {
                Console.WriteLine("迭代器生成后Current为空");
            }
            int i = 0;
            while (i<8)  //总共只有五个元素,看看一直迭代会发生什么效果
            {
                e1.MoveNext();
                if (e1.Current == null)
                {
                    Console.WriteLine("迭代第{0}次后为空",i);
                }
                else
                {
                    Console.WriteLine("迭代第{0}次后为{1}",i,e1.Current.Name);
                }
                i++;
            }
//PeopleEnumerable1   (直接返回集合)
迭代器生成后Current为空
迭代第0次后为P0
迭代第1次后为P1
迭代第2次后为P2
迭代第3次后为P3
迭代第4次后为P4
迭代第5次后为空
迭代第6次后为空
迭代第7次后为空

//PeopleEnumerable2 (不加yield break)
迭代器生成后Current为空
迭代第0次后为P0
迭代第1次后为P1
迭代第2次后为P2
迭代第3次后为P3
迭代第4次后为P4
迭代第5次后为P4
迭代第6次后为P4
迭代第7次后为P4

//PeopleEnumerable2 (加上yield break)
迭代器生成后Current为空
迭代第0次后为P0
迭代第1次后为P1
迭代第2次后为P2
迭代第3次后为P3
迭代第4次后为P4
迭代第5次后为P4
迭代第6次后为P4
迭代第7次后为P4

越界枚举测试结果

真让人吃惊,返回原始集合,越界之后就返回null了,但如果是MoveNext,不论有没有加yield break, 越界迭代后还是返回最后一个元素! 也许就是我们在第1节里提到的,迭代器只返回上一次的状态,因为无法后移,所以就重复返回,那为什么List集合就不会这样呢?问题留给大家。

(感谢回答:越界枚举到底是null还是最后一个元素的问题,其实没有明确规定,具体看.NET的实现,在.NET Framework中,越界后依然是最后一个元素)。

不过各位看官尽管放心,在foreach的标准枚举过程下,枚举是肯定能枚举完的,这就说明了MoveNext和foreach两种在实现上的不同,显然foreach更安全。同时还注意,不能在yield过程中实现try-catch代码块,为什么呢?因为yield模式组合了来自不同位置的代码和逻辑,怎么可能靠编译给每个引用的代码块加上try-catch?这太复杂了。

枚举的特性在处理大数据的时候很有帮助,就是因为它的状态性,一个超大的文件,我只要每次读一部分,就可以顺次的读取下去,直到文件结束,由于不需要实例化集合,内存占用是很低的。对数据库也是如此,每次读取一部分,就能应对很多难以应付的情况。

3.在枚举中修改枚举器参数?

在枚举过程中,集合是不能被修改的,比如在foreach循环中,如果插入或者删除一个元素,肯定会报运行时异常。有经验的程序员告诉 你,此时用for循环。for和foreach的本质区别是什么呢?

在MoveNext中,我突然改变了枚举的参数,使得它的数据量变多或者变少了,又会发生什么?

           Console.WriteLine("不修改OldAge参数");
            foreach (var olderPeople in peopleList.OlderPeoples)
            {
                Console.WriteLine(olderPeople);

            }

            Console.WriteLine("修改了OldAge参数");
            i = 0;
            foreach (var olderPeople in peopleList.OlderPeoples)
            {
                Console.WriteLine(olderPeople);
                i++;
                if (i ==1)
                    peopleList.OldAge = 33;  //只枚举一次后,修改OldAge 的值
            }

测试结果是:

不修改OldAge参数
ID:2,NameP2,Age32
ID:3,NameP3,Age33
ID:4,NameP4,Age34

修改了OldAge参数
ID:2,NameP2,Age32
ID:4,NameP4,Age34

可以看到,在枚举过程中修改了控制枚举的值,能动态改变枚举的行为。上面是在一个yield结构中改变变量的情况,我们再试试在迭代器和Lambda表达式的情况(代码略), 得到结果是:

在迭代中修改变量值
ID:2,NameP2,Age32
ID:4,NameP4,Age34
在Lambda表达式中修改变量值
ID:2,NameP2,Age32
ID:4,NameP4,Age34

可以看出,外部修改变量能够控制内部的迭代过程,动态改变了“集合的元素”。 这是一个好事,因为它的行为确实是对的;也是坏事:在迭代过程中,修改了变量的值,上下文语境变化,可是如果还按之前的语境进行处理,显然就会酿成大错。 这里和闭包没关系。

因此,如果一个枚举需要在上下文会发生变化的情况下保持原有的行为,就需要手动保存变量的副本。

如果你把两个集合A,B用Concat函数顺次拼接起来,也就是A-B, 而且不实例化,那么在枚举A的阶段中,修改集合B的元素,会报错么? 为什么?

比如如下的测试代码:

       List<People> peoples=new List<People>(){new People(){Name = "PA"}};
            Console.WriteLine("将一个虚拟枚举A连接到集合B,并在枚举A阶段修改集合B的元素");
            var e8 = peopleList.PeopleEnumerable1.Concat(peoples);
            i = 0;
            foreach (var people in e8)
            {
                Console.WriteLine(people);
                i++;
                if (i == 1)   
                  peoples.Add(new People(){Name = "PB"});  //此时还在枚举PeopleEnumerable1阶段
        }

如果你想知道,可以自己做个试验(在我附件里也有这个例子)。留给大家讨论。

4. 更多LINQ的讨论

你可以在yield中插入任何代码,这就是延迟(Lazy)的表现,只是需要执行的时候才执行。 我们不难想象Linq很多函数的实现方式,比较有意思的包括Concat,它将两个集合连在了一起,就像下面这样:

public static IEnumerable<T> Concat<T>(this IEnumerable<T> source, IEnumerable<T> source2)
       {
           foreach (var r in source)
           {
               yield return r;
           }
           foreach (var r in source2)
           {
               yield return r;
           }
       }

还有Select, Where都好实现,就不讨论了。

Skip怎么实现的呢?  它跳过了集合中的一部分元素,我猜是这样的:

public static IEnumerable<T> Skip<T>(this IEnumerable<T> source, int count)
       {
           int t = 0;
           foreach (var r in source)
           {
               t++;
               if(t<=count)
                   continue;
               yield return r;
           }
       }

那么,被跳过的元素,到底被访问过没有?它的代码被执行了么?

 Console.WriteLine("Skip的元素是否会被访问到?");
 IEnumerable<People> e6 = peopleList.PeopleEnumerable1.Select(d =>
       {
              Console.WriteLine(d);
              return d;
       }).Skip(3);
 Console.WriteLine("只枚举,什么都不做:");
 foreach (var  r in e6){}  
 Console.WriteLine("转换为实体集合,再次枚举");
 IEnumerable<People> e7 = e6.ToList();
 foreach (var r in e7){}

测试结果如下:

只枚举,什么都不做:
ID:0,NameP0,Age30
ID:1,NameP1,Age31
ID:2,NameP2,Age32
ID:3,NameP3,Age33
ID:4,NameP4,Age34
转换为实体集合,再次枚举
ID:0,NameP0,Age30
ID:1,NameP1,Age31
ID:2,NameP2,Age32
ID:3,NameP3,Age33
ID:4,NameP4,Age34

可以看出,Skip虽然是跳过,但还是会“访问”元素的,因此会执行额外的操作,比如lambda表达式,这不论是枚举器还是实体集合都是如此。这个角度说,要优化表达式,应当尽可能在linq中早的Skip和Take,以减少额外的副作用。

但对于Linq to SQL的实现中,显然Skip是做过额外优化的。我们是否也能优化Skip的实现,使得上层尽可能提升海量数据下的Skip性能呢?

5. 有关IEnumerable枚举的更多问题

(1) 枚举过程如何暂停?有暂停这一说么? 如何取消?

(2) PLinq的实现原理是什么?它改变的到底是IEnumerable接口的哪种特性?是否产生了乱序枚举?这种乱序枚举到底是怎么实现?

(3) IEnumerable实现了链条结构,这是Linq的基础,但这个链条的本质是什么?

(4) Parce que IEnumerable représente l'état et le délai, il n'est pas difficile de comprendre que l'essence de nombreuses opérations asynchrones est IEnumerable. Au cours d'un de mes entretiens, on m'a posé des questions sur l'essence de l'asynchronisme, selon vous, quelle est l'essence de l'asynchronisme ? Async n'est pas du multithread ! La beauté de l’asynchrone réside essentiellement dans la réorganisation du code, car les opérations asynchrones à long terme sont des machines à états. . . Par exemple, la bibliothèque CCR. Je ne vais pas m’étendre là-dessus ici car cela dépasse temporairement la réserve de connaissances de l’auteur. J’en parlerai la prochaine fois.

(5) Si le même énumérateur est implémenté en langage C, le même Linq cool peut-il être implémenté sans recourir à un compilateur ? Ne parlons pas de l'astuce Lambda, utilisons des pointeurs de fonction.

(6) IEnumerable est écrit en MapReduce ? Linq pour MapReduce ?

(7) Comment trier IEnumerable Peut-il être instancié dans un ensemble puis trié ? S’il s’agit d’une très grande collection virtuelle, comment l’optimiser ?

Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!

Déclaration:
Le contenu de cet article est volontairement contribué par les internautes et les droits d'auteur appartiennent à l'auteur original. Ce site n'assume aucune responsabilité légale correspondante. Si vous trouvez un contenu suspecté de plagiat ou de contrefaçon, veuillez contacter admin@php.cn