Uso avançado de coleções e generics em Dart

Uso avançado de coleções e generics em Dart

Ao trabalhar com desenvolvimento de aplicações, precisamos manipular os dados de modo otimizado, pensando sempre na evolução e no crescimento da aplicação.

Em Dart, as coleções básicas como listas, conjuntos e mapas, nos ajudam a lidar com o armazenamento temporário de dados em nossa aplicação.

Porém, uma forma de tornar isso ainda mais avançado é utilizar generics, que adicionam maior flexibilidade e reduz a duplicação de trechos de código. Tudo isso sem perder a segurança dos tipos. Neste artigo, você aprenderá:

  • O que são e quando usar coleções em Dart como listas, maps e conjuntos;
  • Operações avançadas em coleção com os métodos where, map, reduce e fold;
  • O que é e quando usar generics com exemplos práticos de código;
  • Utilizar generics em construtores de classes;
  • O que são mixins e extensões e como utilizá-los;
  • O que são covariância e contravariância como e quando utilizá-las;
  • Utilizar algoritmos genéricos em classes e coleções;
  • Lidar com erros e validação em classes e coleções genéricas;
  • Conclusão.

Vamos lá?

O que são coleções?

Quando estamos trabalhando em um projeto, é super normal recebermos os dados que vamos utilizar em nossa aplicação, sejam eles dados das pessoas usuárias ou informações relacionadas ao tema do projeto.

Por exemplo, em um projeto de exibição de filmes teremos uma lista de filmes incluindo seus atributos como, nome, sinopse, ano de lançamento, etc.

Se você notou, eu mencionei uma “lista de filmes”, porque é exatamente isso que acontece, recebemos vários dados para trabalhar, dificilmente apenas um.

Utilizando o exemplo de site de filmes, ele possui muitos filmes com diversos atributos. É aí que as coleções entram, para tratar esse monte de dados, e organizá-los da melhor forma.

As principais coleções são as listas, conjuntos e mapas. Veremos na prática, começando pelas listas.

Listas

As listas em Dart e qualquer linguagem de programação, são coleções ordenadas, seguindo a ordem a qual inserimos os dados.

Os elementos dentro dessa lista podem ser acessados através do índice, ela pode receber qualquer tipo de dado, sendo uma lista de textos(string), números(int), ou até mesmo objetos(object).

Por exemplo, se você precisa trabalhar com uma lista de convidados para uma festa de aniversário, e precisa adicionar os nomes de seus amigos, essa estrutura de lista vai te ajudar.

Para isso, vamos criar uma lista, precisamos primeiro definir o tipo de dados. Nesse caso utilizaremos o tipo String que é responsável por armazenar textos e nomes, nada mais são do que textos.

Esse tipo, fica dentro dos colchetes angulares <>, indicando que a lista será composta por elementos do tipo texto, confira o código:

Nesse caso criamos a listaDeConvidados e imprimimos no console o primeiro nome da lista passando o índice [0].

void main() {
  List<String> listaDeConvidados = ['Scott', 'Fernanda', 'João', ‘Nicolas’, ‘Bob’];
  print(listaDeConvidados [0]);
}
//A saída será Scott

Map

Já a coleção Map, nos mostra os dados de uma forma mais amigável, e até mais fácil de visualizar. Como ela usa o conceito de chave-valor, podemos facilmente visualizar o nome do elemento e o seu valor correspondente.

Outro ponto é que precisamos definir os tipos da chave e do valor ao criar um Map. Os elementos podem ser acessados através de sua chave única, que pode ser o nome do elemento.

Imagine uma loja de calçados, roupas e acessórios, onde teremos uma coleção de produtos, com a chave representando o nome do produto e o valor representando o preço. Veja como é fácil de visualizar e entender o código:

void main() {
  Map<String, double> produtosPrecos = {
    'Camiseta': 89.90,
    'Calça': 89.90,
    'Tênis': 149.90,
  };
  print(produtosPrecos);
  }
//Saída: {Camiseta: 89.9, Calça: 89.9, Tênis: 149.9}

Observação importante: Não confundir o Map com a função .map. No entanto, podemos usar a função .map para iterar cada chave e valor de uma coleção Map.

O Map é perfeito quando precisamos associar uma chave a um valor, e se encaixa muito bem no contexto de produto e preço.

Mas, e quando precisamos de uma lista onde não pode haver valores duplicados? Aí entram os conjuntos(Set)! Vamos aprender a seguir sobre essa coleção incrível que garante que cada elemento seja único.

Conjuntos

Os conjuntos ou Sets, são diferentes das listas, como eu disse anteriormente não aceitam valores duplicados, e não são ordenados. Esses valores duplicados são removidos automaticamente.

É interessante utilizar quando queremos valores únicos, sem se importar com a ordem dos elementos. Pense em um set como um bilhete da mega-sena, onde cada número deve ser único para formar uma aposta válida.

Veremos na prática. No código abaixo, quando tentamos inserir um valor repetido em um conjunto, esse valor é ignorado, não permitindo números duplicados. Tentaremos adicionar o número “5” utilizando o método .add(5), porém esse número já está presente na coleção, veja a saída:

void main() {
   Set<int> numeros = {1, 2, 3, 4, 5};
   numeros.add(5);
  print(numeros); 
}
// a saída será: {1, 2, 3, 4, 5}

Nesse caso ele não adiciona o número “5” por ele já existir na coleção.

Observação: devemos tomar cuidado quando vamos criar um conjunto que inicializa vazio {}, pois ele pode ser interpretado como um Map. Para que isso não aconteça devemos definir um tipo para o conjunto, exemplo: var conjunto = <String>{} ou sempre utilizar a palavra Set no início Set conjunto = {}.

Operações avançadas em coleções

Agora que você já conhece as coleções básicas como listas, map e conjuntos, vamos explorar como adicionar maior poder a elas, utilizando métodos que permitem filtrar, transformar e combinar elementos, que são: where, map, reducee fold.

São muito úteis para manipular e analisar os dados de uma coleção. Por exemplo, se tivermos uma lista extensa de números e precisamos filtrar para analisar alguns deles, podemos utilizar o método where.

Where

O método where trabalha como um filtro que analisa a lista original e retorna uma nova lista baseada na condição aplicada. Imagine uma caixa repleta de números, alguns positivos e outros negativos.

O método where funcionará como um scanner, onde os critérios definidos no where atuam como os sensores, permitindo que apenas os números positivos, que atendem a essa condição, passem através dele, enquanto os números negativos são ignorados.

void main() {
    List<int> numeros = [-1, 2, -3, 4, 5];
    List<int> numerosPositivos = numeros.where((numbero) => numbero > 0).toList();
    print(numerosPositivos); 
}
//Saída: [2, 4, 5]

Note que usamos o .toList() no final do where para converter o resultado em uma lista. Sem isso, ele fica como um Iterable, o que não permitiria atribuir o resultado a uma variável do tipo List<int>.

Map

O método .map() cria uma nova coleção transformando cada elemento da coleção original de acordo com uma determinada operação, mas sem alterar a lista original.

Por exemplo, se temos uma lista de números [1, 2, 3, 4, 5] e queremos triplicar cada um deles, podemos usar o map() para pegar cada número da lista e multiplicar por “3”, da seguinte forma:

void main(){
   List<int> numeros = [1, 2, 3, 4, 5];
   List<int> doubled = numeros.map((numero) => numero * 3).toList();
   print(doubled); 
}
// Saída: [3, 6, 9, 12, 15]

Reduce:

O método .reduce() acumula valores em um único resultado, por isso é conhecido como função acumulativa. Podemos utilizá-lo tanto em listas(List), conjuntos(set), como em mapas(map) e, neste caso, vamos utilizar no Map.

Imagine que você tenha uma coleção de produtos do tipo Map e queira somar o valor total desses produtos. Usando o reduce(), você pode percorrer o valor de cada produto e somar para obter o total.

void main() {
  Map<String, double> produtos = {
    'Shampoo': 12.99,
    'Creme': 14.49,
    'Gel': 9.99,
    'Condicionador': 15.89
  };

  double valorTotal = produtos.values.reduce((acumulador, preco) => acumulador + preco);
  print('Valor total dos produtos R\$ $valorTotal');
}
// Saída: Valor total dos produtos R$ 53.36

Fold

O método .fold() é muito parecido com o .reduce, pois ambos permitem "acumular" os elementos de uma coleção em um único valor. No entanto, o fold() é mais flexível por permitir um valor inicial para a acumulação. Imagine que você tenha uma lista de números [1, 2, 3, 4, 5] e queira somar todos eles.

Com o .reduce(), você poderia fazer isso, mas precisaria definir uma função que realizasse a soma. Já com o .fold(), você pode passar não só a função de acumulação, mas também um valor inicial.

Nesse caso, você poderia iniciar com 0 e, a cada iteração, somar o número atual ao valor acumulado.

void main(){
   List<int> numeros = [1, 2, 3, 4, 5];
   int soma = numeros.fold(0, (acumulador, numero) => acumulador + numero);
   print(soma); 
}
// Saída: 15
Banner promocional da Alura, com um design futurista em tons de azul, apresentando dois blocos de texto, no qual o bloco esquerdo tem os dizeres:

O que são Generics em Dart?

É uma forma de criar classes, interfaces e funções que vão esperar um tipo sem precisar definir explicitamente esse tipo. Eu sei, parece confuso, mas é por aí mesmo...

Imagina como se fosse uma caixa de ferramentas, que está com a etiqueta com o nome “Ferramentas”, porém dentro tem vários tipos diferentes de ferramentas na qual utilizaremos cada uma de acordo com serviço estipulado sem precisar criar uma caixa nova para cada.

No contexto da programação, pense em um método ou classe que pode trabalhar com qualquer tipo de dado, em vez de criar versões diferentes para números, textos, e outros, podemos usar uma versão única que serve para tudo.

Por que utilizar generics?

Um dos pontos principais é a reutilização de código e a manutenção. Com tipos genéricos podemos criar classes e métodos que funcionam com qualquer tipo de dado sem precisar criar um para cada tipo, facilitando na manutenção dos mesmos.

Outro ponto é a segurança na compilação, podendo acusar o erro no código antes do tempo de execução.

Usando generics em coleções

Vamos imaginar que somos responsáveis por gerenciar diferentes tipos de coleções de dados em uma empresa, porém esses dados são de tipos variados, podendo ser uma lista de nomes, números ou pessoas, entre outros exemplos de tipos.

Cada coleção tem as suas características e não queremos ter que criar uma classe toda vez que precisar lidar com um novo tipo de dado. É aí que entra o generics.

Agora vou mostrar um exemplo. Imagina que vamos organizar números de série com um Repositorio<int>, porém surgiu a necessidade de gerenciar nomes, ou seja, outro tipo de dados.

Agora o tipo mudaria para <String>, então adaptamos o repositório Repositorio<String> usando generics. Agora precisamos de uma lista de pessoas, então temos Repositorio<Pessoa>.

Podemos definir a classe Repositorio e a lista de itens com generics passando o parâmetro , da seguinte forma:

class Repositorio<T> {
  List<T> itens = [];

  void adicionar(T item) {
    itens.add(item);
  }

  void remover(T item) {
    itens.remove(item);
  }

  @override
  String toString() {
    return itens.toString();
  }
}

Depois podemos criar a classe Pessoa com os seguintes atributos:

class Pessoa {
  String nome;
  int idade;

  Pessoa(this.nome, this.idade);

  @override
  String toString() {
    return 'Pessoa(nome: $nome, idade: $idade)';
  }
}

Na main() podemos instanciar a classe Repositorio passando cada um dos tipos:

Instância para <int>:

void main(){
  var repositorioInteiros = Repositorio<int>();
  repositorioInteiros.adicionar(2);
  repositorioInteiros.adicionar(4);
  repositorioInteiros.adicionar(6);
  print('Repositório de Inteiros: $repositorioDeInteiros'); 

// Saída: Repositório de Inteiros: [2, 4, 6]
}

Instância para <String>:

var repositorioStrings = Repositorio<String>();
  repositorioStrings.adicionar('Mobile');
  repositorioStrings.adicionar('Front-end');
  print('Repositório de Strings: $repositorioStrings'); 

// Saída: Repositório de Strings: [Mobile, Front-end]

Instância para <Pessoa>:

var repositorioPessoas = Repositorio<Pessoa>();
  repositorioPessoas.adicionar(Pessoa('João', 70));
  repositorioPessoas.adicionar(Pessoa('Fernanda', 27));
  print('Repositório de Pessoas: $repositorioPessoas'); // Saída: Repositório de Pessoas: [Pessoa(nome: João, idade: 70), Pessoa(nome: Fernanda, idade: 27)]

Pensando na segurança dos tipos com generics, quando utilizamos o Repositorio<int>, temos a certeza de que apenas números serão adicionados, e isso vale para os outros tipos também, assim não precisamos focar tanto em erros de tipos em nosso projeto.

Generics em construtores

Em Dart, os construtores são fundamentais para criar instâncias de uma classe. Eles nos permitem inicializar objetos, definindo valores iniciais para suas propriedades e garantindo que estejam prontos para uso.

Com os construtores, podemos criar objetos personalizados, adequados às necessidades específicas de nosso código.

Entendendo bem os construtores, podemos então aplicar o conceito mais avançado que são os generics, para tornar os construtores reutilizáveis.

Não diferente do exemplo anterior com a classe, aqui também utilizaremos de generics, pense que precisamos utilizar tipos de dados diferentes na mesma classe e construtor.

Podemos definir uma classe com o nome Personalizada que seja genérica, e passar o construtor genérico também, assim conseguimos utilizá-la com tipos diferentes. Confira logo abaixo:

Criando a classe com o nome Personalizada para utilizar o construtor genérico:

class Personalizada<T> {
  T valor;

  Personalizada(this.valor);

  void mostrarValor() {
    print('O valor é: $valor');
  }
}
  • Usando o construtor da classe Personalizada com dois tipos diferentes, Int e String:

1. Utilizando Int:

var valorInt = Personalizada<int>(10);
valorInt.mostrarValor(); 

// Saída: O valor é: 10

2. Utilizando String:

var valorString = Personalizada<String>('Dart a linguagem do Flutter');
valorString.mostrarValor(); 

// Saída: Dart a linguagem do Flutter

Assim, a classe se torna mais flexível e reutilizável, permitindo o uso de outros tipos, como, por exemplo, um objeto ao invés de apenas Int ou String.

Generics em métodos estáticos

A diferença dos métodos estáticos dos métodos normais, é que eles não trabalham com dados específicos de uma instância da classe.

Mesmo assim, podemos utilizar generics para permitir trabalhar com diferentes tipos. Essa combinação permite criar operações reutilizáveis e flexíveis.

Vamos ver isso na prática utilizando uma classe genérica chamada BaseRepository. Nessa classe vamos implementar métodos estáticos genéricos que permitem salvar, buscar e deletar itens em um armazenamento simulado.

Esses métodos são extremamente úteis em situações onde precisamos manipular dados de forma centralizada, sem precisar instanciar a classe para cada operação. veja:

Criando a classe genérica:

Vamos criar a classe genérica BaseRepository e definir os métodos genéricos estáticos para essa classe, utilizaremos o Map para desempenhar a função de um armazenamento em memória, e o _dataStore guarda os dados que são salvos pelo repositório.

class BaseRepository<T> {
  static final Map<int, dynamic> _dataStore = {};
  static int _nextId = 1;

  // Método estático genérico para salvar um item
  static int save<T>(T item) {
    final id = _nextId++;
    _dataStore[id] = item;
    return id;
  }

  // Método estático genérico para obter um item por ID
  static T? getById<T>(int id) {
    return _dataStore[id] as T?;
  }

  // Método estático genérico para deletar um item pelo ID
  static bool delete(int id) {
    return _dataStore.remove(id) != null;
  }
}

Exemplo utilizando a classe e os métodos:

void main() {
  // Salvando novos itens
  final userId = BaseRepository.save('User: Alice');
  final productId = BaseRepository.save('Product: Widget');

  // Obtendo itens por ID
  print(BaseRepository.getById<String>(userId)); 
  // Saída: User: Alice

  print(BaseRepository.getById<String>(productId)); 
  // Saída: Product: Widget

  // Deletando um item
  BaseRepository.delete(userId);
  print(BaseRepository.getById<String>(userId)); 
  // Saída: null
}

Mixins e extensões em coleções genéricas:

A começar pelos Mixins. É uma maneira de adicionar funcionalidades e propriedades a uma classe sem usar herança.

É muito útil quando precisamos passar uma propriedade ou método para uma subclasse que precisa desse método sem depender da herança, já que na herança todas subclasses têm o acesso a todas propriedades da classe pai. Pensando com o generics, isso ajuda a criarmos funcionalidades reutilizáveis.

Utilizando Mixins:

Assim como a herança tem a palavra reservada Extends para acessar a classe a ser herdada, o Mixin também utiliza uma palavra reservada de acesso, que é a palavra With.

O legal é que conseguimos herdar a superclasse e ainda herdar o Mixin na mesma classe. Veja comigo um exemplo básico:

Imagine uma empresa onde tem os funcionários, e cada funcionário vai ter as suas habilidades, por exemplo, um funcionário pode ser um Lider, outro pode ser um Desenvolvedor(a), outro pode ter as duas habilidades de Lider e Desenvolvedor(a). Podemos criar uma classe com uma função que venha herdar a superclasse e incorporar o mixin. Vem comigo:

  1. Primeiro vamos definir os Mixins de Lider e Desenvolvedor, veja isso no código:
mixin Lider {
  void liderarEquipe() {
    print('Liderando a equipe...');
  }
}

mixin Desenvolvedor {
  void programar() {
    print('Desenvolvendo uma aplicação...');
  }
}
  1. Agora vamos criar a classe Funcionario:
class Funcionario {
  final String nome;

  Funcionario(this.nome);

  void trabalhar() {
    print('$nome está trabalhando...');
  }
}
  1. O próximo passo é criar a classe com a função do funcionário, que vai herdar da classe Funcionario e incorporar o Mixin:
class Gerente extends Funcionario with Lider {
  Gerente(super.nome);
}

class EspecialistaDesenvolvedor extends Funcionario with Desenvolvedor {
  EspecialistaDesenvolvedor(super.nome);
}

class LiderDesenvolvedor extends Funcionario with Lider, Desenvolvedor {
  LiderDesenvolvedor(super.nome);
}
  1. Por último, podemos criar diferentes tipos de funcionários e ver como eles utilizam suas habilidades específicas:
void main() {
  var gerente = Gerente('Alice');
  gerente.trabalhar(); 
  // Saída: Alice está trabalhando...

  gerente.liderarEquipe(); 
  // Saída: Liderando a equipe...

  var especialista = EspecialistaDesenvolvedor('Bob');
  especialista.trabalhar(); 
  // Saída: Bob está trabalhando...

  especialista.programar(); 
  // Saída: Desenvolvendo uma aplicação...

  var liderApresentador = LiderDesenvolvedor('Carol');
  liderApresentador.trabalhar(); 
  // Saída: Carol está trabalhando...

  liderApresentador.liderarEquipe(); 
  // Saída: Liderando a equipe...

  liderApresentador.programar(); 
  // Saída: Desenvolvendo uma aplicação...
}

Mixins em coleções genéricas

Agora que entendemos o que são Mixins e como usá-los, podemos combiná-los com coleções genéricas para criar um código ainda mais versátil e flexível.

Podemos testar isso com um exemplo real, utilizando um cenário de trabalho, como um gerenciamento de estoque de produtos em uma loja.

Nesse caso, o mixin adicionar funcionalidades para calcular o valor total do estoque e filtrar os produtos com base nas condições.

  1. Vamos criar um mixin chamado GerenciamentoEstoque<T> que adiciona métodos para calcular o valor total do estoque e filtrar produtos com base em um critério específico. Vamos lá, olhando o código fica mais fácil de entender:
mixin GerenciamentoEstoque<T> {
  // Método para calcular o valor total do estoque
  double calcularValorTotal(Iterable<T> produtos, double Function(T) getPreco) {
    return produtos.fold(0, (total, produto) => total + getPreco(produto));
  }

  // Método para filtrar produtos com base em um critério
  Iterable<T> filtrarProdutos(Iterable<T> produtos, bool Function(T) criterio) {
    return produtos.where(criterio);
  }
}
  1. Agora, vamos criar uma classe genérica com o nome Estoque<T> que incorpora e utiliza o mixin GerenciamentoEstoque para adicionar as funcionalidades definidas:
class Estoque<T> with GerenciamentoEstoque<T> {
  final Iterable<T> produtos;

  Estoque(this.produtos);

  // Método para exibir o valor total do estoque
  void exibirValorTotal(double Function(T) getPreco) {
    print('Valor total do estoque: R\$${calcularValorTotal(produtos, getPreco)}');
  }

  // Método para exibir os produtos filtrados
  void exibirProdutosFiltrados(bool Function(T) criterio) {
    var produtosFiltrados = filtrarProdutos(produtos, criterio);
    for (var produto in produtosFiltrados) {
      print(produto);
    }
  }
}
  1. Por último, podemos criar uma classe Produto para representar os itens no estoque e usar a classe Estoque<T> para gerenciar esses produtos. Vamos ao código:
class Produto {
  final String nome;
  final double preco;
  final int quantidade;

  Produto(this.nome, this.preco, this.quantidade);

  @override
  String toString() {
    return '$nome - R\$$preco - Quantidade: $quantidade';
  }
}

void main() {
  // Exemplo com uma lista de produtos
  var produtos = [
    Produto('Notebook', 3500.0, 5),
    Produto('Smartphone', 2500.0, 10),
    Produto('Mouse', 150.0, 50),
    Produto('Teclado', 200.0, 20),
  ];

  var estoque = Estoque<Produto>(produtos);

  // Exibindo o valor total do estoque
  estoque.exibirValorTotal((produto) => produto.preco * produto.quantidade);
  // Saída: Valor total do estoque: R$37500.0

  // Filtrando e exibindo produtos com preço acima de R$1000
  print('Produtos com preço acima de R\$1000:');
  estoque.exibirProdutosFiltrados((produto) => produto.preco > 1000.0);
  // Saída:
  // Notebook - R$3500.0 - Quantidade: 5
  // Smartphone - R$2500.0 - Quantidade: 10
}

Combinando Mixins e coleções genéricas, conseguimos um código robusto e adaptável. Agora, vamos explorar como as extensões podem aprimorar ainda mais o trabalho com coleções genéricas em Dart.

Extensões em coleções genéricas

As extensões em Dart são como aquelas sugestões de autocompletar que a IDE oferece, mas de forma personalizada, porém criada por nós.

Sabe quando você está codando e a IDE sugere métodos nativos que facilitam seu trabalho? Com as extensões você pode criar essas mesmas sugestões, mas do seu jeito. Elas permitem que você adicione novos métodos ou funcionalidades a tipos já existentes sem precisar modificar o código original dessas classes.

Criando a extensão:

Um exemplo prático é criar uma extensão para encontrar o valor máximo e mínimo em uma coleção. Isso é útil quando precisamos garantir que os números inseridos estejam dentro de um intervalo aceitável.

Por exemplo, em um formulário, podemos usar essa extensão para identificar a pessoa de maior e menor idade entre os dados coletados.

Definimos a extensão passando a palavra extension seguida do nome, nesse exemplo utilizaremos o nome MinMax, em seguida passamos à propriedade on seguida do tipo Iterable a qual a extensão vai ser aplicada.

extension MinMax<E extends num> on Iterable<E> {
  E? encontrarMaximo() {
    return isEmpty ? null : reduce((a, b) => a > b ? a : b);
  }

  E? encontrarMinimo() {
    return isEmpty ? null : reduce((a, b) => a < b ? a : b);
  }
}

//Utilizando a extensão
void main() {
  var lista = [13, 15, 17, 22, 28, 21];

  print(lista.encontrarMaximo()); 
  // Saída: 28

  print(lista.encontrarMinimo()); 
  // Saída: 13

}

Covariância e contravariância em generics

Agora que exploramos como as extensões podem adicionar funcionalidades personalizadas aos tipos existentes, é hora de aprofundar um pouco mais na flexibilidade que os generics oferecem.

Vamos ver como conceitos como covariância e contravariância podem aprimorar ainda mais a forma como trabalhamos com tipos genéricos em Dart.

Covariância e contravariância em generics é uma forma de definir como os tipos de dados podem ser substituídos, especialmente em coleções genéricas.

Vamos imaginar uma caixa de frutas onde podemos colocar diferentes tipos de frutas. Essa caixa pode ser específica para uma única fruta ou pode ser uma caixa genérica de frutas, onde podemos colocar qualquer tipo de fruta.

Covariância

É como dizer: eu tenho uma caixa de maçãs, mas posso usar como uma caixa genérica para qualquer fruta. Isso é muito útil se alguém pedir uma caixa de frutas, podemos entregar a caixa de maçãs sem problemas.

Se tratando do Dart, as listas já são covariantes, isso significa que podemos tratar uma List<string>(caixa de maçãs) como se fosse uma List<Object(caixa de frutas). Vem comigo:

void main() {
  // Uma lista genérica de frutas e objetos
List<Object> frutas = ['Maçã', 123, true]; 
List<String> macas = ['Fuji', 'Gala'];
 // Tratando uma caixa de maçãs como se fosse uma caixa de frutas
frutas = macas; 

print('Frutas $frutas');
}

//Saída: Frutas [Fuji, Gala]

Contravariância

Seria o oposto de variância. É como se tivesse uma caixa de frutas genérica e quisesse usá-la apenas para maçãs. Faz mais sentido e é mais utilizada em funções, é como se tivesse uma função que aceita diferentes tipos de caixas de frutas. Vou deixar esse exemplo com função:

void adicionarFrutasNaCaixa(List<Object> caixaDeFrutas) {
  // Adiciona itens genéricos na caixa de frutas
}

void main() {
  List<String> macas = ['Fuji', 'Gala'];
  adicionarFrutasNaCaixa(macas); 
}

Algoritmos genéricos em classes e coleções

Esses algoritmos podem operar sobre diferentes tipos de coleções de maneira flexível e reutilizável, através dos tipos genéricos.

Vamos ver agora como utilizar generics para resolver problemas comuns, utilizando algoritmos genéricos que trabalham com coleções.

Já vamos pensar em um jogo, onde precisamos encontrar o vencedor através de sua pontuação, no caso o maior pontuador vence. Podemos criar um algoritmo genérico para encontrar o maior valor em uma lista. Confere comigo no código abaixo:

T maiorValor<T extends Comparable>(List<T> itens) {
  T itemMaximo = itens[0];
  for (var item in itens) {
    if (item.compareTo(itemMaximo) > 0) {
      itemMaximo = item;
    }
  }
  return itemMaximo;
}

Utilizando o tipo Int:

void main() {
  var valores = [3, 5, 1, 10, 4];
  var numeroMaximo = maiorValor(valores);
  print('Maior número: $numeroMaximo');
  }
//Saída: Maior número é 10

Utilizando o tipo String:

Utilizaremos esse mesmo código para uma lista de Strings. Temos agora um estoque de produtos de uma loja de roupas, tênis e acessórios, onde queremos descobrir qual é o último produto (em ordem alfabética) da nossa lista de produtos. Veja a praticidade e flexibilidade em nosso código genérico:

void main() {

  var produtos = ['Sapato', 'Blusa', 'Calça', "Pulseira"];
  var ultimoProduto = maiorValor(produtos);
  print('Última palavra em ordem alfabética é $ultimoProduto');
}
//Saída: Última palavra em ordem alfabética é Sapato

Podemos pensar em um outro caso utilizando um algoritmo genérico para filtrar elementos de uma lista. Isso é útil quando precisamos categorizar elementos em uma lista, sem utilizar o método adicional(Where).

Por exemplo, considere uma brincadeira de futebol, onde precisamos escolher os(as) jogadores(as) para os times. Podemos criar um algoritmo genérico para determinar os(as) jogadores(as) de idade pares para um time, filtrando os números pares de uma lista:

List<T> filtrar<T>(List<T> itens, bool Function(T) teste) {
  List<T> listaFiltrada = [];
  for (var item in itens) {
    if (teste(item)) {
      listaFiltrada.add(item);
    }
  }
  return listaFiltrada;
}

Utilizando o tipo Int:

void main() {
  var idades = [13, 18, 20, 22, 14, 23, 16, 19, 17, 25];
  var numerosPares = filtrar(idades, (n) => n % 2 == 0);
  print('Números pares $numerosPares');
}
// Saída: Números pares [18, 20, 22, 14, 16]

Utilizando o tipo String:

Aqui podemos usar um jogo de palavras como exemplo. Vamos supor que precisamos utilizar em nosso jogo somente palavras que tenham 5 ou menos letras. Confira o código:

void main(){
  var palavras = ['Dart', 'Flutter', 'Mobile', 'App'];
  var palavrasCurtas = filtrar(palavras, (palavra) => palavra.length <= 5);
  print('Palavras curtas $palavrasCurtas');
}

// Saída: Palavras curtas [Dart, App]

Depois de aprender um monte de coisas, como generics e coleções genéricas, precisamos começar a tratar esses dados e garantir que tudo funcione corretamente, não é mesmo?

Agora vamos explorar como lidar com erros e validar informações para garantir que tudo funcione corretamente.

Lidando com erros e validação em coleções genéricas

Sempre que estamos codando e trabalhando com diferentes tipos de dados, problemas e erros podem aparecer durante o desenvolvimento de nossa aplicação. Trabalhando com generics não é diferente, alguém pode definir uma tipagem e tentar passar outra na hora de utilizar.

Para que isso não aconteça, precisamos então, tratar esses erros para garantir que o nosso código funcione corretamente.

Tratamento de erro em tipos

Como estamos utilizando generics, o tipo de dados é definido em tempo de compilação, porém se alguém tentar adicionar um tipo de dado incorreto em uma coleção ou classe genérica, o compilador pode detectar isso como um erro.

Temos uma classe genérica chamada Customizada. Na hora de utilizá-la, podemos “tipá-la” para receber somente números inteiros: var customizadaInt = Customizada<int>();. Imagina alguém tentar adicionar uma String. Veja abaixo o que acontece:

class Customizada<T> {
  T? item;

  void add(T novoItem) {
    item = novoItem;
  }

  T? get() {
    return item;
  }
}

Correto:

void main() {
  var customizadaInt = Customizada<int>();
  customizadaInt.add(5); // Correto
  print(customizadaInt.item);
}
// Saída: 5

Errado:

void main() {
  var customizadaInt = Customizada<int>();
  customizadaInt.add("Hello"); 
  print(customizadaInt.item); 
 }

Dessa forma passando uma String onde espera um Int, um erro será exibido:

Erro: The argument type 'String' can't be assigned to the parameter type 'int'.

Validação de dados

Mesmo trabalhando com generics, pode ser necessário validar os dados antes de adicioná-los a uma coleção ou utilizá-los. Para fazer essa validação, podemos utilizar nossos amigos: if e else para testar uma condição.

Vamos pensar em um sistema de conta bancária onde precisamos depositar um valor positivo na conta, onde não podemos depositar um valor que seja negativo ou zero. Podemos utilizar o if e else para aceitar somente valores maiores que 0. Vamos ver no código:

Definindo a classe:

class NumeroPositivo<T extends num> {
  List<T> itens = [];

  void add(T item) {
    if (item > 0) {
      itens.add(item);
    } else {
      print("O valor precisa ser maior que zero. Valor: $item");
    }
  }

  List<T> get() {
    return itens;
  }
}

Utilizando:

Se o número inserido for maior que 0, está correto e o número será adicionado a lista, caso contrário se for um número igual ou menor que zero, exibe a mensagem de erro: O valor precisa ser maior que zero. Valor -5:

void main() {
  var positivos = NumeroPositivo<int>();

  positivos.add(10); // Correto
  positivos.add(-5); // Erro: O valor precisa ser maior que zero. Valor -5

  print(positivos.get()); // Saída: [10]
}

Com essa validação garantimos que quem utilizar nossa aplicação, poderá inserir somente números positivos e do tipo inteiro em nossa coleção. Com isso, temos um código que funciona corretamente de forma segura.

Conclusão

Neste artigo, vimos como funcionam as coleções e seus métodos principais. Depois, falamos da importância dos generics e como utilizá-los para deixar o nosso código mais flexível e reutilizável, tanto para coleções quanto para classes.

Também vimos sobre mixins e extensões para deixar o código ainda mais dinâmico. E, no final, passamos pela validação e tratamento de erros para garantir que tudo funcione direitinho.

E aí? Gostou dessa tecnologia? Pronto para colocar em prática?

Confira esses conteúdos que separamos para você:

Marque os perfis da Alura em suas redes sociais e usando a hashtag #AprendiNaAlura para compartilhar sua experiência de desenvolvimento.

Obrigado por ler até aqui e até a próxima. Bons estudos!

Renan Lima
Renan Lima

Sou desenvolvedor .NET e React, sempre em busca de aprendizado prático. Fiz uma transição de carreira e estou investindo em cursos e pesquisas para expandir meu conhecimento. Formado em Análise e Desenvolvimento de Sistemas, focando em manutenção, novas funcionalidades e projetos do zero. Estou sempre aprendendo e aplicando novas tecnologias como DevOps.

Veja outros artigos sobre Mobile