Quel avantage a été obtenu en implémentant LINQ d'une manière qui ne met pas en cache les résultats?

20

Il s'agit d'un piège connu pour les personnes qui se mouillent les pieds avec LINQ:

public class Program
{
    public static void Main()
    {
        IEnumerable<Record> originalCollection = GenerateRecords(new[] {"Jesse"});
        var newCollection = new List<Record>(originalCollection);

        Console.WriteLine(ContainTheSameSingleObject(originalCollection, newCollection));
    }

    private static IEnumerable<Record> GenerateRecords(string[] listOfNames)
    {
        return listOfNames.Select(x => new Record(Guid.NewGuid(), x));
    }

    private static bool ContainTheSameSingleObject(IEnumerable<Record>
            originalCollection, List<Record> newCollection)
    {
        return originalCollection.Count() == 1 && newCollection.Count() == 1 &&
                originalCollection.Single().Id == newCollection.Single().Id;
    }

    private class Record
    {
        public Guid Id { get; }
        public string SomeValue { get; }

        public Record(Guid id, string someValue)
        {
            Id = id;
            SomeValue = someValue;
        }
    }
}

Cela affichera "False", car pour chaque nom fourni pour créer la collection d'origine, la fonction de sélection continue d'être réévaluée et l' Recordobjet résultant est à nouveau créé. Pour résoudre ce problème, un simple appel à ToListpourrait être ajouté à la fin de GenerateRecords.

Quel avantage Microsoft espérait-il gagner en l'implémentant de cette façon?

Pourquoi l'implémentation ne mettrait-elle pas simplement en cache les résultats d'un tableau interne? Une partie spécifique de ce qui se passe peut être l'exécution différée, mais cela pourrait toujours être implémenté sans ce comportement.

Une fois qu'un membre donné d'une collection retourné par LINQ a été évalué, quel avantage offre de ne pas conserver une référence / copie interne, mais de recalculer le même résultat, comme comportement par défaut?

Dans les situations où il existe un besoin particulier dans la logique pour le même membre d'une collection recalculé encore et encore, il semble que cela pourrait être spécifié via un paramètre facultatif et que le comportement par défaut pourrait faire autrement. De plus, l'avantage de vitesse qui est gagné par l'exécution différée est finalement réduit par le temps qu'il faut pour recalculer continuellement les mêmes résultats. Enfin, c'est un bloc déroutant pour ceux qui sont nouveaux dans LINQ, et cela pourrait conduire à des bugs subtils dans le programme de n'importe qui.

Quel avantage y a-t-il et pourquoi Microsoft a-t-il pris cette décision apparemment très délibérée?

Panzercrisis
la source
1
Appelez simplement ToList () dans votre méthode GenerateRecords (). return listOfNames.Select(x => new Record(Guid.NewGuid(), x)).ToList(); Cela vous donne votre "copie en cache". Problème résolu.
Robert Harvey
1
Je sais, mais je me demandais pourquoi ils auraient rendu cela nécessaire en premier lieu.
Panzercrisis
11
Parce que l'évaluation paresseuse présente des avantages importants, dont le moindre n'est pas «oh, au fait, cet enregistrement a changé depuis la dernière fois que vous l'avez demandé; voici la nouvelle version», ce qui est précisément ce que votre exemple de code illustre.
Robert Harvey
Je pourrais jurer que j'avais lu une question presque identique ici au cours des 6 derniers mois, mais je ne la trouve pas maintenant. Le plus proche que je peux trouver date de 2016 sur stackoverflow: stackoverflow.com/q/37437893/391656
Mr.Mindor
29
Nous avons un nom pour un cache sans politique d'expiration: "fuite de mémoire". Nous avons un nom pour un cache sans politique d'invalidation: "bug farm". Si vous ne proposez pas une politique d'expiration et d'invalidation toujours correcte qui fonctionne pour chaque requête LINQ possible, votre question se répond un peu d'elle-même.
Eric Lippert

Réponses:

51

Quel avantage a été obtenu en implémentant LINQ d'une manière qui ne met pas en cache les résultats?

La mise en cache des résultats ne fonctionnerait tout simplement pas pour tout le monde. Tant que vous avez de petites quantités de données, tant mieux. Bien pour vous. Mais que se passe-t-il si vos données sont plus grandes que votre RAM?

Cela n'a rien à voir avec LINQ, mais avec l' IEnumerable<T>interface en général.

C'est la différence entre File.ReadAllLines et File.ReadLines . L'un lira le fichier entier dans la RAM et l'autre vous le donnera ligne par ligne, afin que vous puissiez travailler avec des fichiers volumineux (tant qu'ils ont des sauts de ligne).

Vous pouvez facilement tout cache que vous voulez cache en matérialisant votre séquence d' appel soit .ToList()ou .ToArray()sur elle. Mais ceux d' entre nous qui ne pas veulent le mettre en cache, nous avons une chance de ne pas le faire.

Et sur une note connexe: comment mettez-vous en cache les éléments suivants?

IEnumerable<int> AllTheZeroes()
{
    while(true) yield return 0;
}

Vous ne pouvez pas. C'est pourquoi IEnumerable<T>existe comme ça.

nvoigt
la source
2
Votre dernier exemple serait plus convaincant s'il s'agissait d'une véritable série infinie (comme Fibonnaci), et pas simplement d'une chaîne sans fin de zéros, ce qui n'est pas particulièrement intéressant.
Robert Harvey
23
@RobertHarvey C'est vrai, je pensais simplement qu'il est plus facile de remarquer qu'il s'agit d'un flux infini de zéros quand il n'y a aucune logique à comprendre.
nvoigt
2
int i=1; while(true) { i++; yield fib(i); }
Robert Harvey
2
L'exemple Enumerable.Range(1,int.MaxValue)auquel je pensais était : il est très facile de déterminer une limite inférieure pour la quantité de mémoire qui va être utilisée.
Chris
4
L'autre chose que j'ai vue dans le même sens while (true) return ...était while (true) return _random.Next();de générer un flux infini de nombres aléatoires.
Chris
24

Quel avantage Microsoft espérait-il gagner en l'implémentant de cette façon?

Exactitude? Je veux dire, le noyau énumérable peut changer entre les appels. La mise en cache produirait des résultats incorrects et ouvrirait l'ensemble «quand / comment puis-je invalider ce cache?» Boîte de vers.

Et si l' on considère LINQ a été conçu à l' origine comme un moyen de faire LINQ aux sources de données (comme Entity Framework, SQL ou directement), le dénombrable a été va changer puisque c'est ce que les bases de données font .

En plus de cela, il y a des préoccupations concernant le principe de responsabilité unique. Il est beaucoup plus facile de créer du code de requête qui fonctionne et de créer une mise en cache par-dessus que de créer du code qui interroge et met en cache, puis de supprimer la mise en cache.

Telastyn
la source
3
Il vaut peut-être la peine de mentionner qu'il ICollectionexiste, et se comporte probablement de la façon dont OP s'attend IEnumerableà se comporter
Caleth
Si vous utilisez IEnumerable <T> pour lire un curseur de base de données ouvert, vos résultats ne devraient pas changer si vous utilisez une base de données avec des transactions ACID.
Doug
4

Parce que LINQ est, et était destiné depuis le début à être, une implémentation générique du modèle Monad populaire dans les langages de programmation fonctionnels , et un Monad n'est pas contraint de toujours donner les mêmes valeurs étant donné la même séquence d'appels (en fait, son utilisation en programmation fonctionnelle est populaire précisément à cause de cette propriété, qui permet d'échapper au comportement déterministe des fonctions pures).

Jules
la source
4

Une autre raison qui n'a pas été mentionnée est la possibilité de concaténer différents filtres et transformations sans créer de résultats intermédiaires.

Prenez ceci par exemple:

cars.Where(c => c.Year > 2010)
.Select(c => new { c.Model, c.Year, c.Color })
.GroupBy(c => c.Year);

Si les méthodes LINQ calculaient les résultats immédiatement, nous aurions 3 collections:

  • Où résultat
  • Sélectionnez le résultat
  • Résultat GroupBy

Dont nous ne nous soucions que du dernier. Il ne sert à rien de sauvegarder les résultats intermédiaires parce que nous n'y avons pas accès, et nous voulons seulement connaître les voitures déjà filtrées et regroupées par année.

S'il était nécessaire de sauvegarder l'un de ces résultats, la solution est simple: séparez les appels et appelez- .ToList()les et enregistrez-les dans une variable.


Tout comme une remarque, en JavaScript, les méthodes Array retournent en fait les résultats immédiatement, ce qui peut entraîner une consommation de mémoire plus importante si l'on ne fait pas attention.

Arturo Torres Sánchez
la source
3

Fondamentalement, ce code - mettant Guid.NewGuid ()une Selectdéclaration à l' intérieur - est très suspect. C'est sûrement une sorte d'odeur de code!

En théorie, nous ne nous attendrions pas nécessairement à ce qu'une Selectinstruction crée de nouvelles données mais récupère des données existantes. Bien qu'il soit raisonnable que Select joigne les données de plusieurs sources pour produire un contenu joint de forme différente ou même calculer des colonnes supplémentaires, nous pouvons toujours nous attendre à ce qu'il soit fonctionnel et pur. Mettre l' NewGuid ()intérieur le rend non fonctionnel et non pur.

La création des données pourrait être taquinée en dehors de la sélection et placée dans une opération de création d'une sorte, de sorte que la sélection puisse rester pure et réutilisable, ou bien la sélection ne devrait être effectuée qu'une seule fois et enveloppée / protégée - cela est la .ToList ()suggestion.

Cependant, pour être clair, le problème me semble être le mélange de la création à l'intérieur de la sélection plutôt que le manque de mise en cache. Mettre l' NewGuid()intérieur à l' intérieur de la sélection me semble être un mélange inapproprié de modèles de programmation.

Erik Eidt
la source
0

L'exécution différée permet à ceux qui écrivent du code LINQ (pour être précis, en utilisant IEnumerable<T>) de choisir explicitement si le résultat est immédiatement calculé et stocké en mémoire, ou non. En d'autres termes, il permet aux programmeurs de choisir le temps de calcul ou le compromis d'espace de stockage le plus approprié à leur application.

On pourrait faire valoir que la majorité des applications souhaitent les résultats immédiatement, ce qui aurait dû être le comportement par défaut de LINQ. Mais il existe de nombreuses autres API (par exemple List<T>.ConvertAll) qui offrent ce comportement et l'ont fait depuis la création du Framework, alors que jusqu'à l'introduction de LINQ, il n'y avait aucun moyen d'avoir une exécution différée. Ce qui, comme d'autres réponses l'ont démontré, est une condition préalable à l'activation de certains types de calculs qui seraient autrement impossibles (en épuisant tout le stockage disponible) lors de l'exécution immédiate.

Ian Kemp
la source