domingo, 6 de noviembre de 2016

Extendiendo LinQ




Como hemos mencionado en otros posts y en otras ocasiones, la librería principal de LinQ, System.Linq, está formada por un conjunto de métodos extensores que en la mayoría de los casos expande la funcionalidad del tipo IEnumerable<T>. Por este motivo, y por facilidad de generación de este tipo de métodos, alargar, dilatar, mejorar o incluso moldear muchos de ellos, se vuelve una tarea muy simple y en ocasiones bastante gratificante y útil.

 Os indico como recordatorio los enlaces para métodos extensores y generics, que son básicos para dominar el tema de hoy.






Recuerda que aquí tienes el indice de todos los posts del Curso de LinQ.



Construyendo un nuevo Operador en LinQ

La característica más importante cuando expandimos la librería de LinQ, es crear métodos extensores sobre el tipo IEnumerable<T>. Con esto quiero argumentar que no debemos, aunque en nuestro trabajo diario trabajemos con tipos más experimentados como List<T>, ObservableCollection<T> ó Arrays, extender estas clases, ya que estaríamos limitando mucho el ámbito d trabajo de los mismos.

Podemos agrupar los tipos de operadores que podemos extender en 4 tipos principales:

  • OPERADOR QUE DEVUELVA UN SENCILLO ELEMENTO .-  Este tipo de operador, devolverá un elemento del tipo de nuestra secuencia ‘T’. Ejemplos de este tipo son First, Last, etc.
  • OPERADOR QUE DEVUELVA UNA SECUENCIA DE ELEMENTOS .-   Este tipo de operador, devolverá una secuencia de elementos de tipo ‘T’. Ejemplos de este tipo son Where, Select, Skip o Take.
  • OPERADOR QUE DEVUELVA UN VALOR TOTALIZADOR (Aggregate) .- Este tipo de operador, devolverá un valor como resultado de una totalización o conjunción de registros de una secuencia. Ejemplos de este tipo son Sum, Count o el propio Aggregate.
  • OPERADOR QUE DEVUELVA UNA SECUENCIA DE ELEMENTOS AGRUPADOS .-   Este tipo de operador, devolverá una secuencia de elementos agrupados de tipo ‘T’. Ejemplos de este tipo son GroupBy o GroupJoin.



Vamos a construir uno de cada tipo, para poder observar su fórmula de creación con más detalle.


Clase utilizada para los ejemplos:


public class Persona
{
    public string   DNI             { get; set; }
    public string   Nombre          { get; set; }
    public decimal  Ingresos        { get; set; }
    public DateTime FechaNacimiento { get; set; }
 
 
    public static IEnumerable<Persona> ConstruirPersonas()
    {
        return new List<Persona>()
        {
            new Persona { DNI = "11111111A", Nombre = "Ana",     Ingresos = 25000m, FechaNacimiento = new DateTime(1990, 03,15)  },
            new Persona { DNI = "22222222B", Nombre = "Luis",    Ingresos = 35000m, FechaNacimiento = new DateTime(1980, 08,15)  },
            new Persona { DNI = "33333333C", Nombre = "Marta",   Ingresos = 30000m, FechaNacimiento = new DateTime(1992, 10,10)  },
            new Persona { DNI = "44444444D", Nombre = "Miguel",  Ingresos = 55000m, FechaNacimiento = new DateTime(1978, 06,28)  },
            new Persona { DNI = "55555555E", Nombre = "Enrique", Ingresos = 90000m, FechaNacimiento = new DateTime(1987, 01,15)  },
            new Persona { DNI = "66666666F", Nombre = "Pakkko",  Ingresos = 25000m, FechaNacimiento = new DateTime(1992, 12,01)  },
            new Persona { DNI = "77777777G", Nombre = "Juan",    Ingresos = 1     , FechaNacimiento = new DateTime(1990, 06,20)  }
        };
    }
 
 
}




Tipo Elemento Sencillo

Este tipo de extensión, debe como es común en todos, extender el tipo IEnumerable<T> y devolver un único elemento de este tipo genérico, al igual que lo hacen Last, Single o FirstOrDefault, en general para los Operadores de Elemento.

En este primer tipo, trataremos de hacer un caso amplio, con varias versiones del método, que nos sirvan de ejemplo para las siguientes implementaciones de tipos de extensión.

Vamos a crear un ejemplo muy simple, pero a la vez muy didáctico en el que se pueda conseguir el objetivo de forma clara y concisa. Nuestra extensión elegida para este caso va a ser Second. Este método como su propio nombre indica va a devolver el segundo elemento de una secuencia. Iremos dando pasadas para irlo mejorando.

Vamos con la primera versión de nuestro método extensor Second:


public static T Second<T>(this IEnumerable<T> source)
{
    if (source.Count() < 2) throw new ArgumentException($"El parametro {nameof(source)}, debe de tener al menos 2 elementos", nameof(source));
 
    T resultado = source.Skip(1).Take(1).First();
 
    return resultado;
}

Esta sería la versión inicial, como se puede apreciar tiene una implementación muy sencilla y en ella se emplean otros operadores de la librería de LinQ.
Dentro del uso extensivo de LinQ, cuando lo aplicamos sobre colecciones con un número muy alto de elementos, se estima que este tiene una perdida en torno a 10% sobre el manejo de bucles generales, por este motivo y para intentar generar el método extensor lo más óptimo posible, vamos a sustituir las llamadas a los métodos de la librería por bucles.

Este es el resultado de la modificación del código:


public static T Second<T>(this IEnumerable<T> source)
{
    if (source.Count() < 2) throw new ArgumentException($"El parametro {nameof(source)}, debe de tener al menos 2 elementos", nameof(source));
 
    T resultado = Activator.CreateInstance<T>();

    int contador = 0;
 
    using(var enumerador = source.GetEnumerator())
    {
        while (enumerador.MoveNext() && contador < 2)
        {
            if (contador == 1)
            {
                resultado = enumerador.Current;
            }
 
            contador++;
        }
    }
 
    return resultado;
}

Estamos realizando un bucle a bajo nivel, esto está explicado más detalladamente en la entrada secuencias y enumeradores.

Con esto hemos ganado rendimiento, pero todavía nos quedaría dar un pasito más en pos de construir el método extensor más óptimo de la historia. El paso sucesivo consistiría en comprobar el tipo de secuencia que está realizando la llamada. 

IEnumerable<T>, podríamos afirmar que es el tipo más primitivo, o el que está en la punta más alta de la jerarquía, por eso es el tipo elegido para extender. Este método puede ser consumido por tipos más complejos que implementan IEnumerable<T>, tales como List<T>, T[], etc., que incorporan otro tipo de interfaces que suman más funcionalidad. Una opción a tener en cuenta es la que aporta la interface IList<T>, y no es otra que la capacidad de indexación, que en nuestro ejemplo simple, nos liberaría del trabajo de tener que iterar sobre la secuencia, ya que podríamos devolver directamente el elemento de índice 1 ( [1] ):


public static T Second<T>(this IEnumerable<T> source)
{
    if (source.Count() < 2) throw new ArgumentException($"El parametro {nameof(source)}, debe de tener al menos 2 elementos", nameof(source));
 
    T resultado = Activator.CreateInstance<T>();
 
    /// Es un IList<T> ??
    var list = source as IList<T>;
    if (list != null)
    {
        resultado = list[1];
    }
    else
    {
        int contador = 0;
 
        using (var enumerador = source.GetEnumerator())
        {
            while (enumerador.MoveNext() && contador < 2)
            {
                if (contador == 1)
                {
                    resultado = enumerador.Current;
                }
 
                contador++;
            }
        }
    }
 
    return resultado;
}


Para poner la guinda en el pastel y dejar el método como los chorros del oro, solo nos quedaría finalizar añadiendo un segundo método extensor: SecondOrDefault, en la línea del conjunto de operadores de elemnto y que tendría una dificultad bajísima si reutilizamos el predecesor.


public static T SecondOrDefault<T>(this IEnumerable<T> source)
{
    T resultado = source.Count() >= 2 ? source.Second() : default(T);
 
    return resultado;
}


La parte más reseñable, es la correspondiente a default(T), que se encarga de devolver el valor por defecto según el tipo de datos. Esto ya lo repasamos en la palabra reservada default.




Tipo Secuencia de elementos

Este tipo de extensión se caracteriza porque sus métodos devuelven una secuencia de datos IEnumerable<T>. Ejemplos de este tipo son los elementos de consulta estándar, como Where, Select, etc.
Una de las características que debería de implementar este tipo de operadores, es la capacidad de carga perezosa o diferida, mediante el operador Yield return y Yield Break.


El método extensor que he pensado para este ejemplo, más que un ejemplo es un caso real de trabajo, que implementé hace unos años. Este caso, era la típica situación de una aplicación que generaba un fichero de salida. Este fichero se iba rellenando con datos de otros ficheros y de otras tablas, hasta estar completamente finalizado. Algo así:
































El ejemplo puede complicarse un poco, pero espero que su coste práctico le dé más valor.

Tenemos un ‘FICHERO PRINCIAPAL’ que se carga solo con 2 campos de manera inicial, y necesita ir consultando otros ficheros para ir completando su información .En nuestro ejemplo el ‘SUB-FICHERO 1’ que contiene datos de Salarios y el ‘SUB-FICHERO 2’ que contiene datos sobre fechas de nacimiento.
Ya que aquí no nos daría nada de valor generar ficheros y cargarlos, sustituiremos los éstos, por Secuencias de Datos (Colecciones de objetos).

Nuestro método extensor tiene que tener 2 parámetros de tipo T y K, ya que las colecciones utilizadas, serán de objetos de tipos diferentes.

La estructura del método tendrá 4 parámetros:
  1. Source .- Secuencia principal sobre la que tomará el tipo para realizar el método de Extensión. Esta secuencia será la que completará sus datos.
  2. SubSecuencia .- Secuencia secundaria sobre la que se consultarán los datos para completar la secuencia principal con los valores coincidentes.
  3. FiltroCohesion .- Condición que elegimos mediante la cual cruzamos los datos entre la secuencia principal y la subsecuencia. En otras palabras, este es el criterio que elegimos para comunicarle al método que tienen que campos o características tienen que poseer los registros de una u otra colección para considerarlas iguales.
  4. AccionRelleno .- Acción que debe de ejecutarse cada vez que se encuentre una coincidencia en cada una de las secuencias. Esto viene a ser de forma reducida la manera de indicar que valor(es) del campo de la subsecuencia debe de tomarse para rellenar otro valor(es) de la principal.


public static void CompletarDatos<T, K>(this IEnumerable<T> source, 
                                                IEnumerable<K> SubSecuencia, 
                                                Func<T, K, bool> filtroCohesion, 
                                                Action<T, K> accionRelleno)
{
    foreach (T s in source)
    {
        var coincidencia = SubSecuencia.FirstOrDefault(d => filtroCohesion(s, d));
 
        if (coincidencia != null)
        {
            accionRelleno(s, coincidencia);
        }
    }
 
}


Para realizar su misión, el método recorre todos los elementos de la colección principal,  y aplica el filtroCohesion sobre la subColeccion, con el objetivo de buscar el elemento coincidente, que en caso de encontrarlo le aplica la acción de relleno.

Vamos a verlo en acción.

Construimos los moldes de nuestras 3 colecciones:


public static IEnumerable<Persona> ConstruirColeccionPrincipal()
{
    return new List<Persona>()
        {
            new Persona { DNI = "11111111A", Nombre = "Ana"     },
            new Persona { DNI = "22222222B", Nombre = "Luis"    },
            new Persona { DNI = "33333333C", Nombre = "Marta"   },
            new Persona { DNI = "44444444D", Nombre = "Miguel"  },
            new Persona { DNI = "55555555E", Nombre = "Enrique" },
            new Persona { DNI = "66666666F", Nombre = "Pakkko"  },
            new Persona { DNI = "77777777G", Nombre = "Juan"    }
        };
}
 
public static IEnumerable<Persona> ConstruirSubColeccionIngresos()
{
    return new List<Persona>()
        {
                    
            new Persona { DNI = "22222222B", Ingresos = 35000m },
            new Persona { DNI = "11111111A", Ingresos = 25000m },
            new Persona { DNI = "33333333C", Ingresos = 30000m },
            new Persona { DNI = "66666666F", Ingresos = 25000m },
            new Persona { DNI = "55555555E", Ingresos = 90000m },
            new Persona { DNI = "44444444D", Ingresos = 55000m },
            new Persona { DNI = "77777777G", Ingresos = 1      }
        };
}
 
public static IEnumerable<Persona> ConstruirSubColeccionFechas()
{
    return new List<Persona>()
        {
            new Persona { DNI = "66666666F", FechaNacimiento = new DateTime(1992, 12,01)  },
            new Persona { DNI = "11111111A", FechaNacimiento = new DateTime(1990, 03,15)  },
            new Persona { DNI = "44444444D", FechaNacimiento = new DateTime(1978, 06,28)  },
            new Persona { DNI = "22222222B", FechaNacimiento = new DateTime(1980, 08,15)  },
            new Persona { DNI = "33333333C", FechaNacimiento = new DateTime(1992, 10,10)  },
            new Persona { DNI = "55555555E", FechaNacimiento = new DateTime(1987, 01,15)  },
            new Persona { DNI = "77777777G", FechaNacimiento = new DateTime(1990, 06,20)  }
        };
}


Creamos un método para pintar valores:


public static void PintarValores(IEnumerable<Persona> source)
{
    foreach (var persona in source)
    {
        string strIngresos        = persona.Ingresos        == 0                 ? "(null)" : persona.Ingresos.ToString("c");
        string strFechaNacimiento = persona.FechaNacimiento == DateTime.MinValue ? "(null)" : persona.FechaNacimiento.ToString("d");
 
        Console.WriteLine("{0:-10} - {1,-8} - {2,10} - {3,-10}", persona.DNI, persona.Nombre, strIngresos, strFechaNacimiento);
    }
}


Ponemos en práctica nuestro método extensor:


static void Main(string[] args)
{
    var coleccionPrincipal   = Persona.ConstruirColeccionPrincipal();
    var subColeccionIngresos = Persona.ConstruirSubColeccionIngresos();
    var subColeccionFechas   = Persona.ConstruirSubColeccionFechas();
 
    Console.WriteLine("  ****   Datos Principales ");
    Persona.PintarValores(coleccionPrincipal);
 
    Console.WriteLine("  ****   Fusionamos la SubColecciónIngresos ");
    coleccionPrincipal.CompletarDatos(subColeccionIngresos, 
                                        (principal, subClase) => principal.DNI     == subClase.DNI, 
                                        (principal, subClase) => principal.Ingresos = subClase.Ingresos);
    Persona.PintarValores(coleccionPrincipal);
 
    Console.WriteLine("  ****   Fusionamos la SubColecciónFechas ");
    coleccionPrincipal.CompletarDatos(subColeccionFechas,
                                        (principal, subClase) => principal.DNI            == subClase.DNI,
                                        (principal, subClase) => principal.FechaNacimiento = subClase.FechaNacimiento);
    Persona.PintarValores(coleccionPrincipal);
 
    Console.Read();
}

Cargamos cada una de nuestras colecciones, la principal, la subcolección de Ingresos y de fechas. Para ello hemos utilizado secuencias del mismo tipo de datos, pero estas podrían ser perfectamente de tipos de datos diferentes.

Cargamos la colección principal y la pintamos por pantalla para mostrar los campos que están a null y que iremos rellenando en pasos posteriores. Fusionamos con nuestro método extensor los valores referentes a los ingresos, advirtiendo que el campo por el que cruzaremos los datos será el DNI (siendo este nuestro filtroCohesion) . El campo a completar será la propiedad ingresos de la colección principal, mediante el valor del campo Ingresos de la colección de Ingresos (siendo este nuestra accionRelleno)

Pintamos de nuevo para ver que el campo ingresos de la colección principal ya está relleno. El tercer paso será un calco del segundo para los datos referentes a Fechas.


Y este será el resultado:























Con esto daríamos por acabado nuestro método extensor de tipo secuencia de elementos.


Tipo Valor (aggregate) Totalizador

Para construir un método extensor de este tipo, obligatoriamente tenemos que fijarnos en los operadores de tipo agregación, tales como min, max, sum, etc. Todos estos operadores atesoran una característica común, y es que retornan un dato calculado.

El método que hemos elegido, se va a llamar ContarLetras, y va a consistir en contar cada una de las letras de los valores de los campos. Si alguno de los campos no es de tipo cadena, se llamará a su método ToString y se contarán las letras de este resultado.

Vamos a añadirle una sobrecarga, para dar la posibilidad de poder hacer el recuento de una sola columna.

Estas serían las implementaciones:


public static int ContarLetras<T>(this IEnumerable<T> source)
{
    int resultado = 0;
 
    var propiedades = typeof(T).GetProperties();
 
    foreach (var item in source)
    {
        foreach (var propiedad in propiedades)
        {
            object value = propiedad.GetValue(item);
 
            resultado += value?.ToString()?.Length ?? 0;
        }
    }
 
    return resultado;
}
 
 
 
public static int ContarLetras<T>(this IEnumerable<T> source, Func<T, object> field)
{
    int resultado = 0;
 
    var propiedades = typeof(T).GetProperties();
 
    foreach (var item in source.Select(field))
    {
        foreach (var propiedad in propiedades)
        {
            object value = propiedad.GetValue(item);
 
            resultado += value?.ToString()?.Length ?? 0;
        }
    }
 
    return resultado;
}


De forma generalista, los métodos, recorren los elementos de las secuencias de datos y con la ayuda de reflexión obtienen los valores de cada una de las propiedades que contabilizan el nº de elementos de cada una de sus propiedades pasadas a cadena.


static void Main(string[] args)
{
    var secuenciaDeDatos = Persona.ConstruirPersonas();
 
    int numeroLetrasTotales = secuenciaDeDatos.ContarLetras();
    Console.WriteLine($"El nº de letras totales es {numeroLetrasTotales}");
 
    int numeroLetrasTotalesCampoNombre = secuenciaDeDatos.ContarLetras(a => a.Nombre);
    Console.WriteLine($"El nº de letras totales del campo Nombre es {numeroLetrasTotalesCampoNombre}");
 
    Console.Read();
}


Con el siguiente resultado:














Tipo Secuencia de elementos Agrupados

La característica principal de este caso de extensión, se centra en que el patrón de elementos devueltos es de tipo agrupado, exactamente el correspondiente a la interface IGrouping<TKey, TElement>, que comparte con los operadores de agrupación, tales como GroupBy, GroupJoin o ToLookUp.

Vamos a añadir un ejemplo no demasiado práctico, pero sí bastante pedagógico, ya que nos revela el procedimiento de definición de sus argumentos y la salida.


El método extensor es algo tan simple, como devolver los elementos redundantes y agrupados de una secuencia:


public static IEnumerable<IGrouping<TKey, TElement>> RedundantesDuplicados<TKey, TElement>(this IEnumerable<TElement> source, Func<TElement, TKey> agrupacion)
{
    var resultado = source.GroupBy(agrupacion).Where(a => a.Count() > 1);
 
    return resultado;
}



En el día a día del desarrollador, este va a ser el tipo de extensión que menos se utiliza, ya que el tipo IGrouping<TKey, TElement>   es muy poco flexible, no permite generar instancias compatibles del mismo con facilidad y la agrupación tiene que generarse por uno o varios campos del tipo de datos principal. Normalmente cuando realizamos algún método extensor que devuelve datos agrupados, se opta por crearlo en base a una clase POCO, con un elemento de TKey, de tipo conocido y una lista de elementos de TElement. Esto es mucho más flexible y te libera de ataduras de tipos y restricciones, con el mismo resultado. Ambos no dejan de ser una Lista de Listas o una colección de colecciones.

Este podría ser un ejemplo de una POCO sin las restricciones de IGrouping<TKey, TElement> :


public class DatoAgrupado<TKey, TElement>
{
    public TKey Clave { get; private set; }
 
    public IEnumerable<TElement> Grupo { get; private set; }
 
    public DatoAgrupado(TKey clave, IEnumerable<TElement> grupo)
    {
        Clave = clave;
        Grupo = grupo;
    }
 
}

U olvidándonos de Generics, esta otra, mucho más simple, pero de valor mucho más acotado:


public class DatoAgrupado
{
    public string Clave { get; private set; }
 
    public IEnumerable<MiTipo> Grupo { get; private set; }
 
    public DatoAgrupado(string clave, IEnumerable<MiTipo> grupo)
    {
        Clave = clave;
        Grupo = grupo;
    }
 
}


Esto rompería las cadenas de IGrouping<TKey, TElement> , permitiéndonos crear extensiones de PseudoAgrupación, como esta:


public static IEnumerable<DatoAgrupado<int, TElement>> Paginar<TElement>(this IEnumerable<TElement> source, int elementosPorGrupo)
{
    IList<DatoAgrupado<int, TElement>> resultado = new List<DatoAgrupado<int, TElement>>();
 
    int numeroDeGrupos = source.Count() / elementosPorGrupo;
 
    for (int i = 0; i <= numeroDeGrupos; i++)
    {
        var datosGrupo = source.Skip(i * elementosPorGrupo).Take(elementosPorGrupo);
 
        resultado.Add
            (
                new DatoAgrupado<int, TElement>(i, datosGrupo)
            );
    }
 
    return resultado;
}


Un método extensor que realiza paginación de datos, eligiendo el nº de elementos por grupo. Como podemos apreciar, la clave de nuestros elementos agrupados son de tipo int, y este no forma parte de nuestro TElement, que era una de las limitaciones que tenía IGrouping<TKey, TElement>  .


 Así lo usaríamos:


static void Main(string[] args)
{
    Console.WriteLine("{0}{0}{0}", Environment.NewLine);
 
 
    var personas = Persona.ConstruirPersonas();
 
    var personasPaginadas = personas.Paginar(3);
 
    foreach (var item in personasPaginadas)
    {
        Console.WriteLine($"**** Grupo {item.Clave + 1}");
 
        foreach (var personaEnGrupo in item.Grupo)
        {
            Console.WriteLine($"      {personaEnGrupo.Nombre}");
        }
    }
 
    Console.Read();
}


Y este sería su resultado:






Y de aquí, sin límites, solo los que nosotros nos queramos poner, plena libertad de creación, como siempre en estos casos según la dedicación claro, jejeje.



No hay comentarios :

Publicar un comentario