web-dev-qa-db-fra.com

Pourquoi cette méthode entraîne-t-elle une boucle infinie?

Un de mes collègues est venu me poser une question sur cette méthode qui se traduit par une boucle infinie. Le code réel est un peu trop compliqué à publier ici, mais essentiellement le problème se résume à ceci:

private IEnumerable<int> GoNuts(IEnumerable<int> items)
{
    items = items.Select(item => items.First(i => i == item));
    return items;
}

Cela devrait (on pourrait penser) est juste un moyen très inefficace de créer une copie d'une liste. Je l'ai appelé avec:

var foo = GoNuts(new[]{1,2,3,4,5,6});

Le résultat est une boucle infinie. Étrange.

Je pense que la modification du paramètre est, stylistiquement, une mauvaise chose, j'ai donc légèrement changé le code:

var foo = items.Select(item => items.First(i => i == item));
return foo;

Ça a marché. Autrement dit, le programme est terminé; pas exception.

Plus d'expériences ont montré que cela fonctionne aussi:

items = items.Select(item => items.First(i => i == item)).ToList();
return items;

Tout comme un simple

return items.Select(item => .....);

Curieuse.

Il est clair que le problème est lié à la réaffectation du paramètre, mais uniquement si l'évaluation est différée au-delà de cette déclaration. Si j'ajoute la ToList() ça marche.

J'ai une idée générale et vague de ce qui ne va pas. Il semble que le Select itère sur sa propre sortie. C'est un peu étrange en soi, car généralement un IEnumerable lancera si la collection qu'il itère change.

Ce que je ne comprends pas, parce que je ne connais pas intimement le fonctionnement de ce truc, c'est pourquoi la réaffectation du paramètre provoque cette boucle infinie.

Y a-t-il quelqu'un avec plus de connaissances des internes qui serait disposé à expliquer pourquoi la boucle infinie se produit ici?

64
Jim Mischel

La clé pour y répondre est l'exécution différée . Quand tu fais ça

items = items.Select(item => items.First(i => i == item));

vous ne pas itérer le tableau items passé dans la méthode. Au lieu de cela, vous lui affectez un nouveau IEnumerable<int>, Qui se réfère à lui-même et commence l'itération uniquement lorsque l'appelant commence à énumérer les résultats.

C'est pourquoi tous vos autres correctifs ont résolu le problème: tout ce que vous aviez à faire était d'arrêter de renvoyer IEnumerable<int> À lui-même:

  • L'utilisation de var foo Rompt l'auto-référence en utilisant une variable différente,
  • L'utilisation de return items.Select... Rompt l'auto-référence en n'utilisant pas du tout de variables intermédiaires,
  • L'utilisation de ToList() rompt l'auto-référence en évitant une exécution différée: au moment où items est réaffecté, l'ancien items a été réitéré, vous vous retrouvez donc avec une simple en mémoire List<int>.

Mais s'il se nourrit de lui-même, comment obtient-il quelque chose?

C'est vrai, ça n'obtient rien! Au moment où vous essayez d'itérer items et lui demandez le premier élément, la séquence différée demande à la séquence qui lui est envoyée le premier élément à traiter, ce qui signifie que la séquence se demande le premier élément à traiter. À ce stade, c'est tortues tout le long , car pour renvoyer le premier élément à traiter, la séquence doit d'abord obtenir le premier élément à traiter de lui-même.

64
dasblinkenlight

Il semble que le Select itère sur sa propre sortie

Vous avez raison. Vous retournez un requête qui itère sur lui-même.

La clé est que vous faites référence à itemsdans le lambda. La référence items n'est pas résolue ("fermée") tant que la requête n'est pas répétée, point auquel items fait désormais référence à la requête au lieu de la collection source. C'est où se produit l'auto-référence.

Imaginez un jeu de cartes avec un signe devant lui intitulé items. Imaginez maintenant un homme debout à côté du jeu de cartes dont la mission est d'itérer la collection appelée items. Mais ensuite, vous déplacez le signe du deck vers le man. Lorsque vous demandez à l'homme le premier "article" - il cherche la collection marquée "articles" - qui est maintenant lui! Il se demande donc le premier élément, où se situe la référence circulaire.

Lorsque vous affectez le résultat à une variable nouvelle, vous avez alors une requête qui itère sur une collection différente, et ne donne donc pas lieu à une boucle infinie.

Lorsque vous appelez ToList, vous hydratez la requête dans une nouvelle collection et n'obtenez pas non plus de boucle infinie.

D'autres choses qui briseraient la référence circulaire:

  • Articles hydratants dans le lambda en appelant ToList
  • Assigner items à une autre variable et référencer que dans le lambda.
20
D Stanley

Après avoir étudié les deux réponses données et fouillé un peu, j'ai trouvé un petit programme qui illustre mieux le problème.

    private int GetFirst(IEnumerable<int> items, int foo)
    {
        Console.WriteLine("GetFirst {0}", foo);
        var rslt = items.First(i => i == foo);
        Console.WriteLine("GetFirst returns {0}", rslt);
        return rslt;
    }

    private IEnumerable<int> GoNuts(IEnumerable<int> items)
    {
        items = items.Select(item =>
        {
            Console.WriteLine("Select item = {0}", item);
            return GetFirst(items, item);
        });
        return items;
    }

Si vous appelez cela avec:

var newList = GoNuts(new[]{1, 2, 3, 4, 5, 6});

Vous obtiendrez cette sortie à plusieurs reprises jusqu'à ce que vous obteniez enfin StackOverflowException.

Select item = 1
GetFirst 1
Select item = 1
GetFirst 1
Select item = 1
GetFirst 1
...

Ce que cela montre est exactement ce que dasblinkenlight a précisé dans sa réponse mise à jour: la requête entre dans une boucle infinie en essayant d'obtenir le premier élément.

Écrivons GoNuts d'une manière légèrement différente:

    private IEnumerable<int> GoNuts(IEnumerable<int> items)
    {
        var originalItems = items;
        items = items.Select(item =>
        {
            Console.WriteLine("Select item = {0}", item);
            return GetFirst(originalItems, item);
        });
        return items;
    }

Si vous exécutez cela, cela réussit. Pourquoi? Parce que dans ce cas, il est clair que l'appel à GetFirst transmet une référence aux éléments d'origine qui ont été passés à la méthode. Dans le premier cas, GetFirst transmet une référence à la collection newitems, qui n'a pas encore été réalisée. À son tour, GetFirst dit: "Hé, je dois énumérer cette collection." Et commence ainsi le premier appel récursif qui mène finalement à StackOverflowException.

Fait intéressant, j'avais raison et mal quand j'ai dit qu'il consommait sa propre sortie. Le Select consomme l'entrée d'origine, comme je m'y attendais. First essaie de consommer la sortie.

Beaucoup de leçons à tirer ici. Pour moi, le plus important est "ne modifiez pas la valeur des paramètres d'entrée".

Merci à dasblinkenlight, D Stanley et Lucas Trzesniewski pour leur aide.

5
Jim Mischel