¿Qué ventaja obtuvo al implementar LINQ de una manera que no almacena en caché los resultados?

20

Esta es una trampa conocida para las personas que se están mojando los pies con 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;
        }
    }
}

Esto imprimirá "Falso", porque para cada nombre proporcionado para crear la colección original, la función de selección se vuelve a evaluar y el Recordobjeto resultante se crea de nuevo. Para solucionar esto, ToListse puede agregar una simple llamada a al final de GenerateRecords.

¿Qué ventaja esperaba obtener Microsoft al implementarlo de esta manera?

¿Por qué la implementación simplemente no almacena en caché los resultados en una matriz interna? Una parte específica de lo que está sucediendo puede ser la ejecución diferida, pero eso podría implementarse sin este comportamiento.

Una vez que se ha evaluado un miembro dado de una colección devuelta por LINQ, ¿qué ventaja se proporciona al no mantener una referencia / copia interna, sino que se vuelve a calcular el mismo resultado, como un comportamiento predeterminado?

En situaciones donde hay una necesidad particular en la lógica para el mismo miembro de una colección recalculada una y otra vez, parece que eso podría especificarse a través de un parámetro opcional y que el comportamiento predeterminado podría hacerlo de otra manera. Además, la ventaja de velocidad que se obtiene con la ejecución diferida se reduce en última instancia por el tiempo que lleva recalcular continuamente los mismos resultados. Finalmente, este es un bloque confuso para aquellos que son nuevos en LINQ, y podría conducir a errores sutiles en el programa de cualquier persona.

¿Qué ventaja tiene esto y por qué Microsoft tomó esta decisión aparentemente muy deliberada?

Panzercrisis
fuente
1
Simplemente llame a ToList () en su método GenerateRecords (). return listOfNames.Select(x => new Record(Guid.NewGuid(), x)).ToList(); Eso te da tu "copia en caché". Problema resuelto.
Robert Harvey
1
Lo sé, pero me preguntaba por qué habrían hecho esto necesario en primer lugar.
Panzercrisis
11
Debido a que la evaluación diferida tiene beneficios significativos, entre los cuales se encuentra "oh, por cierto, este registro cambió desde la última vez que lo solicitó; aquí está la nueva versión", que es precisamente lo que ilustra su ejemplo de código.
Robert Harvey
Podría jurar que había leído una pregunta redactada de manera casi idéntica aquí en los últimos 6 meses, pero ahora no la encuentro. Lo más cercano que puedo encontrar fue de 2016 en stackoverflow: stackoverflow.com/q/37437893/391656
Mr.Mindor el
29
Tenemos un nombre para un caché sin una política de caducidad: "pérdida de memoria". Tenemos un nombre para un caché sin una política de invalidación: "granja de errores". Si no va a proponer una política de vencimiento e invalidación siempre correcta que funcione para todas las consultas LINQ posibles , su pregunta se responderá a sí misma.
Eric Lippert

Respuestas:

51

¿Qué ventaja obtuvo al implementar LINQ de una manera que no almacena en caché los resultados?

El almacenamiento en caché de los resultados simplemente no funcionaría para todos. Mientras tengas pequeñas cantidades de datos, genial. Bien por usted. Pero, ¿qué pasa si sus datos son más grandes que su RAM?

No tiene nada que ver con LINQ, sino con la IEnumerable<T>interfaz en general.

Es la diferencia entre File.ReadAllLines y File.ReadLines . Uno leerá el archivo completo en la RAM y el otro se lo dará línea por línea, para que pueda trabajar con archivos grandes (siempre que tengan saltos de línea).

Puede almacenar en caché fácilmente todo lo que desea almacenar en la memoria materializando su secuencia llamando .ToList()o .ToArray()en él. Pero aquellos de nosotros que no queremos almacenarlo en caché, tenemos la oportunidad de no hacerlo.

Y en una nota relacionada: ¿cómo almacena en caché lo siguiente?

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

No puedes. Por eso IEnumerable<T>existe como existe.

nvoigt
fuente
2
Su último ejemplo sería más convincente si fuera una serie infinita real (como Fibonnaci), y no simplemente una cadena interminable de ceros, lo que no es particularmente interesante.
Robert Harvey
23
@RobertHarvey Eso es cierto, solo pensé que es más fácil detectar que es un flujo interminable de ceros cuando no hay lógica para entender.
nvoigt
2
int i=1; while(true) { i++; yield fib(i); }
Robert Harvey
2
El ejemplo en el que estaba pensando era Enumerable.Range(1,int.MaxValue): es muy fácil calcular un límite inferior para la cantidad de memoria que se va a usar.
Chris
44
La otra cosa que he visto en la línea de while (true) return ...era while (true) return _random.Next();generar una corriente infinita de números aleatorios.
Chris
24

¿Qué ventaja esperaba obtener Microsoft al implementarlo de esta manera?

¿Exactitud? Quiero decir, el núcleo enumerable puede cambiar entre llamadas. El almacenamiento en caché produciría resultados incorrectos y abriría todo el “cuándo / cómo invalidar ese caché”. Lata de gusanos.

Y si se tiene en cuenta LINQ fue diseñado originalmente como un medio para hacer LINQ a las fuentes de datos (como el marco de la entidad, o SQL directamente), la enumerables se va a cambiar ya que eso es lo que las bases de datos hacen .

Además de eso, hay preocupaciones sobre el Principio de Responsabilidad Única. Es mucho más fácil crear algún código de consulta que funcione y generar caché encima que generar código que consulte y almacene en caché, pero luego elimine el almacenamiento en caché.

Telastyn
fuente
3
Vale la pena mencionar que ICollectionexiste, y probablemente se comporta de la manera en que OP espera IEnumerablecomportarse
Caleth
Si está utilizando IEnumerable <T> para leer un cursor de base de datos abierto, sus resultados no deberían cambiar si está utilizando una base de datos con transacciones ACID.
Doug
4

Debido a que LINQ es, y fue pensado desde el principio, una implementación genérica del patrón Monad popular en lenguajes de programación funcionales , y un Monad no está obligado a producir siempre los mismos valores dada la misma secuencia de llamadas (de hecho, su uso en programación funcional es popular precisamente por esta propiedad, que permite escapar del comportamiento determinista de las funciones puras).

Jules
fuente
4

Otra razón que no se ha mencionado es la posibilidad de concatenar diferentes filtros y transformaciones sin crear resultados intermedios basura.

Toma esto por ejemplo:

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

Si los métodos LINQ calcularan los resultados inmediatamente, tendríamos 3 colecciones:

  • Donde resultado
  • Seleccionar resultado
  • Grupo Por resultado

De los cuales solo nos importa el último. No tiene sentido guardar los resultados intermedios porque no tenemos acceso a ellos, y solo queremos saber acerca de los autos que ya están filtrados y agrupados por año.

Si era necesario guardar alguno de estos resultados, la solución es simple: separe las llamadas y recurra .ToList()a ellas y guárdelas en una variable.


Como nota al margen, en JavaScript, los métodos de matriz en realidad devuelven los resultados de inmediato, lo que puede conducir a un mayor consumo de memoria si no se tiene cuidado.

Arturo Torres Sánchez
fuente
3

Básicamente, este código, poner Guid.NewGuid ()una Selectdeclaración dentro , es muy sospechoso. ¡Este es seguramente un código de olor de algún tipo!

En teoría, no necesariamente esperaríamos que una Selectdeclaración creara datos nuevos sino que recuperara datos existentes. Si bien es razonable que Select combine datos de múltiples fuentes para producir contenido unido de diferente forma o incluso calcular columnas adicionales, aún podríamos esperar que sea funcional y puro. Poner el NewGuid ()interior lo hace no funcional y no puro.

La creación de los datos se puede separar de la selección y ponerla en una operación de creación de algún tipo, para que la selección pueda permanecer pura y reutilizable, o de lo contrario, la selección debe hacerse solo una vez y envolverse / protegerse, esto Es la .ToList ()sugerencia.

Sin embargo, para ser claros, el problema me parece la mezcla de la creación dentro de la selección en lugar de la falta de almacenamiento en caché. Poner el NewGuid()interior de la selección me parece una mezcla inapropiada de modelos de programación.

Erik Eidt
fuente
0

La ejecución diferida permite a aquellos que escriben código LINQ (para ser precisos, usar IEnumerable<T>) elegir explícitamente si el resultado se calcula de inmediato y se almacena en la memoria, o no. En otras palabras, permite a los programadores elegir el tiempo de cálculo versus el intercambio de espacio de almacenamiento que sea más apropiado para su aplicación.

Se podría argumentar que la mayoría de las aplicaciones desean los resultados de inmediato, por lo que debería haber sido el comportamiento predeterminado de LINQ. Pero hay muchas otras API (por ejemplo List<T>.ConvertAll) que ofrecen este comportamiento y lo han hecho desde que se creó el Framework, mientras que hasta que se introdujo LINQ, no había forma de tener una ejecución diferida. Lo cual, como han demostrado otras respuestas, es un requisito previo para habilitar ciertos tipos de cálculos que de otra manera serían imposibles (agotando todo el almacenamiento disponible) cuando se utiliza la ejecución inmediata.

Ian Kemp
fuente