Desenvolvimento

9 abr, 2019

Entendendo LINQ – Parte 01: como filtrar itens de uma coleção com Where

350 visualizações
Publicidade

Prólogo

Minha ideia é criar uma pequena série de artigos explicando brevemente o que fazem alguns dos métodos de extensão do namespace System.Linq, como usá-los e como eles funcionam por trás das cortinas.

  • Disclaimer: nestes artigos falo apenas sobre os métodos do chamado LINQ to Objects. Sendo assim, nada do que for dito aqui se aplica aos métodos LINQ de providers específicos (Entity Framework, XML, NHibernate, entre outros).

Uma das operações mais comuns para se fazer em coleções de dados é filtrá-los. Instintivamente, a implementação que vem à cabeça é criar uma nova coleção com base na original, contendo todos os elementos que correspondam à condição do filtro.

Vamos a um breve exemplo. Imagine que precisamos resolver o problema do enunciado a seguir:

  • Tendo uma lista com todos os números de 1 a 30, escreva um código que mostre na tela apenas os números pares desta lista.

A implementação mais óbvia seria algo parecido com o código abaixo.

Observação: usei o método Range para gerar a coleção de números. O objetivo era facilitar a escrita do código e focar na parte realmente importante.

using System.Linq;
using System.Collections.Generic;
using static System.Console;

class MainClass 
{
    public static void Main (string[] args) 
    {
        var numeros = Enumerable.Range(1, 31);
        WriteLine("Números de 1 a 30");
        foreach(var n in numeros)
            WriteLine(n);
            
        var pares = ApenasPares(numeros);
        WriteLine("\nNúmeros Pares");
        foreach(var n in pares)
            WriteLine(n);
    }

    private static List<int> ApenasPares(IEnumerable<int> entrada)
    {
        var saida = new List<int>();     
        foreach(var item in entrada) 
        {
            if(item % 2 == 0)
                saida.Add(item);
        }
        return saida;
    }
}

O método ApenasPares recebe uma coleção de inteiros como entrada, cria uma nova lista para representar a saída, itera sobre cada elemento da entrada e, se o elemento atender a condição item % 2 == 0, ele é adicionado à lista de saída.

E é assim mesmo que se faz?

Sim! A essência é justamente essa: criar uma nova coleção com os dados que atenderem a condição do filtro.

Vamos complicar um pouco. E se agora eu te pedisse para retornar todos os números que sejam múltiplos de 3? Bem, continua fácil, é só adaptar o método para receber um parâmetro dizendo qual é o múltiplo que precisa ser checado. Algo como:

List<int> ApenasMultiplosDe(IEnumerable<int> entrada, int m) 
{ 
   var saida = new List<int>(); 
   foreach(var item in entrada) 
   { 
      if(item % m == 0) 
         saida.Add(item);
   } 
   return saida; 
}

Sagaz! Mas e se a tarefa agora for retornar todos os números que sejam múltiplos de 3 e 5 ao mesmo tempo e que sejam maiores do que 15? Ou retornar todos os números ímpares?

Qual seria a melhor solução?

  • Criar um método para cada condição diferente?
  • Desistir de filtrar os dados?
  • Pedir uma tarefa mais fácil?

É aí que entra o tal do LINQ e o método Where.

Where e… MAGIC

using System.Linq;
using System.Collections.Generic;
using static System.Console;

class MainClass 
{
    public static void Main (string[] args) 
    {
        var numeros = Enumerable.Range(1, 31);
            
        var pares = numeros.Where(n => n % 2 == 0);
        var impares = numeros.Where(n => n % 2 != 0);
        var multiplosDe3e5 = numeros.Where(n => n % 3 == 0 && n % 5 == 0);
        var mult3e5maioresQue15 = multiplosDe3e5.Where(n => n > 15);

        Print(pares, "Números Pares");
        Print(impares, "Números Ímpares");
        Print(multiplosDe3e5, "Múltiplos de 3 e 5");
        Print(mult3e5maioresQue15, "Múltiplos de 3 e 5 maiores que 15");
    }

    private static void Print(IEnumerable<int> elementos, string descricao)
    {
        WriteLine(new string('=', 10));
        WriteLine(descricao);
        foreach(var n in elementos)
            WriteLine(n);
    }
}

Com o código acima, resolvi todos os requisitos descritos anteriormente usando apenas uma linha de código para cada um.

E como isso funciona?

De forma geral, bem parecido com a implementação de filtro que fizemos acima, no método ApenasPares().

O Where é um método de extensão disponível para qualquer coleção enumerável (que implemente IEnumerable). Esse método recebe como parâmetro implícito a coleção que será filtrada e como parâmetro explícito um predicado – função booleana que define a condição que cada item da coleção deve atender para fazer parte da nova coleção. Essa função é um delegate do tipo Func<T, bool>, onde T é o tipo de cada um dos elementos da lista (int, no caso do exemplo).

Essa assinatura de Func define uma função que recebe um parâmetro do tipo T e retorna um booleano.

Vamos tentar entender olhando para o código que escrevemos acima.

numeros.Where(n => n % 3 == 0 && n % 5 == 0)

Primeiramente, numeros é o parâmetro implícito do método de extensão. Não quero entrar em detalhes sobre isso, mas só pra deixar claro: uma chamada a um método de extensão é apenas um syntactic sugar para fazer uma chamada a um método estático.

O código compilado é, na verdade:

Enumerable.Where(numeros, n => n % 3 == 0 && n % 5 == 0)

Vamos focar na parte que realmente interessa, o predicado:

n => n % 3 == 0 && n % == 0

Isso é uma função anônima (também chamada de expressão lambda), onde n é o parâmetro que essa função recebe e o que vem após o operador => é a o que será retornado por essa expressão, ou seja, a expressão que será usada para validar os itens da coleção.

Observação: note que, ao escrever a função, não é necessário explicitar o tipo do parâmetro e nem usar a keyword return. O parâmetro é desnecessário porque é inferido a partir do tipo genérico na definição da função que, por sua vez, é inferido a partir do tipo genérico da coleção trabalhada.

Ou seja, se for uma lista de strings, o predicado recebido será um Func<string, bool>. Logo, o parâmetro da função será uma string; se for uma lista de objetos do tipo Cliente, o parâmetro será um objeto do tipo do Cliente.

O return não é necessário porque uma expressão lambda retorna implicitamente o que vem depois do operador =>, desde que ele não seja seguido por um bloco delimitado por chaves.

Para ilustrar de forma mais intuitiva, a expressão usada acima pode ser escrita de forma mais verbosa, porém mais fácil de ler por quem não está acostumado.

Três formas diferentes de escrever a mesma função lambda

Essa expressão recebida por parâmetro será avaliada para cada um dos valores contidos em numeros e os itens que atenderem a condição farão parte da nova coleção.

A tabela abaixo contém uma ilustração da execução dessa expressão para os valores contidos na coleção inicial. Os elementos com o valor TRUE na última coluna atendem ao filtro e farão parte da nova coleção.

Tabela verdade representando os resultados da função para cada entrada

Simples, não? Como eu disse anteriormente, bem parecido com a implementação feita no início do artigo.

Implementando seu próprio método de filtro

Embora eu tenha dito anteriormente que isso é mágica (e eu quase nunca minto), vou mostrar como é possível e, relativamente fácil, implementar seu próprio método para filtrar coleções de qualquer tipo.

Abaixo eu implementei o método de extensão MeuWhere que funciona de forma semelhante ao método Where original.

Aproveitei para criar casos de testes aplicando os filtros também em uma coleção de objetos do tipo Usuario para mostrar que, mesmo sendo uma implementação simples, ela funciona independente do tipo da coleção.

using System;
using System.Linq;
using System.Collections.Generic;
using static System.Console;

class MainClass 
{
    public static void Main (string[] args) 
    {
        var numeros = Enumerable.Range(1, 31);
        var maioresQue10 = numeros.MeuWhere(x => x > 10);

        foreach(var n in maioresQue10)
            WriteLine(n);
        
        var usuarios = new[]
        {
            new Usuario { Nome = "Jéferson", DataRegistro = new DateTime(2017, 5, 1) },
            new Usuario { Nome = "Mário", DataRegistro = new DateTime(2018, 2, 10) },
            new Usuario { Nome = "Juliana", DataRegistro = new DateTime(2019, 1, 10) },
        };

        WriteLine("\nUsuários registrados em 2018 ou depois");
        foreach(var u in usuarios.MeuWhere(x => x.DataRegistro.Year >= 2018))
            WriteLine($"{u.Nome} registrado em {u.DataRegistro:dd/MM/yyyy}");
        
        WriteLine("\nUsuários cujo nome inicia com a letra J");
        foreach(var u in usuarios.MeuWhere(x => x.Nome.StartsWith("J")))
            WriteLine(u.Nome);
    }
}

static class MinhasExtensoes
{
    public static IEnumerable<T> MeuWhere<T>(this IEnumerable<T> src, Func<T, bool> predicate)
    {
        var saida = new List<T>();
        
        foreach(var item in src)
            if (predicate(item))
                saida.Add(item);

        return saida;
    }
}

class Usuario
{
    public string Nome { get; set; }
    public DateTime DataRegistro { get; set; }
}

Simples, não?

O código poderia ser ainda menor, usando yield return e removendo a necessidade de criar uma lista.

public static IEnumerable<T> MeuWhere<T>(this IEnumerable<T> src, Func<T, bool> predicate)
{
    foreach(var item in src)
        if (predicate(item))
            yield return item;
}

Note que, embora esta seja uma implementação funcional, usando as mesmas diretrizes básicas da original, o método Where do LINQ é implementado com algumas abstrações e cuidados a mais.

Um pequeno spoiler que posso deixar, é: uma das diferenças da implementação original é que a coleção com os dados filtrados não é criada na chamada do Where.

Talvez seja um bom assunto para um próximo artigo.