Trabalhando com tipos genéricos em C#

Os tipos genéricos são um dos pilares da programação em .NET. Eles permitem criar código reutilizável, flexível e seguro, resolvendo um dos grandes desafios do desenvolvimento de software: a criação de componentes que podem trabalhar com diferentes tipos de dados. Neste artigo, vamos explorar o que são tipos genéricos, como e por que utilizá-los, e como implementá-los em C#.

O que são tipos genéricos?

Tipos genéricos permitem que você defina classes, métodos, interfaces e estruturas sem especificar o tipo exato de dado com o qual eles vão trabalhar. Em vez disso, você usa parâmetros de tipo, que serão substituídos por tipos reais no momento da utilização.

Por exemplo, em vez de criar uma classe “ListaDeInteiros” para armazenar inteiros e outra “ListaDeStrings” para armazenar strings, você pode criar uma classe genérica “Lista<T>”, em que  “T” representa o tipo de dado. Esse conceito é extremamente poderoso, pois oferece:

  • Reutilização de código: ao invés de duplicar código para diferentes tipos, você cria uma única versão genérica;
  • Segurança em tempo de compilação: o compilador verifica o tipo utilizado, prevenindo erros em tempo de execução;
  • Manutenção mais fácil: com menos código duplicado, a manutenção torna-se mais simples e menos sujeita a erros.

Exemplo de classe genérica em C#

Agora vamos ver um exemplo simples da utilização de genéricos. O primeiro passo é criar uma classe genérica chamada “Caixa”, na qual vamos processar um tipo que ainda não conhecemos.

				
					public class Caixa<T>
{
    private T _conteudo;

    public Caixa(T conteudo)
    {
        _conteudo = conteudo;
    }

    public void MostrarConteudo()
    {
        Console.WriteLine($"Conteúdo: {_conteudo}");
    }
}

				
			

Acima, criamos uma classe que vai receber um tipo quando ela for instanciada. O T entre os sinais de menor e maior (<T>) indica que a classe é genérica e pode trabalhar com qualquer tipo de dado. Dentro dela, temos o método construtor “Caixa(T conteudo)” que recebe o valor do tipo “T” e armazena em uma variável privada “_conteudo” e o método “MostrarConteudo()” que exibe o conteúdo no console.

Agora, vamos criar uma variável do tipo “Caixa” e informar que ela será do tipo “int”. Isso permite que nossa classe processe números inteiros:

				
					Caixa<int> caixaInt = new Caixa<int>(123);
				
			

Aqui, estamos dizendo que a classe “Caixa” vai trabalhar com o tipo “int” e estamos passando o valor “123” para o conteúdo. Depois disso, podemos chamar o método “MostrarConteudo”, que vai exibir no console o valor “Conteúdo: 123”:

				
					caixaInt.MostrarConteudo(); 
				
			

Agora, suponha que eu queira utilizar a mesma classe “Caixa”, mas desta vez passando um tipo diferente, como uma “string”. Podemos criar outra instância de “Caixa” para processar texto:

				
					Caixa<string> caixaString = new Caixa<string>("Texto");
				
			

Aqui, a classe “Caixa” vai trabalhar com “string”, e o valor “Texto” será armazenado. Podemos chamar o método “MostrarConteudo” novamente, e ele vai exibir o texto:

Interfaces genéricas

Interfaces genéricas nos permitem criar contratos que funcionam de forma flexível com diferentes tipos de dados. Isso é útil, especialmente em cenários nos quais o comportamento é o mesmo para diferentes tipos, mas os dados podem variar. Um exemplo comum de utilização de interfaces genéricas é na criação de repositórios para operações CRUD (Create, Read, Update, Delete), que é utilizado para manipular entidades em uma aplicação.

Vamos começar criando uma interface genérica que define as operações básicas de um repositório, como adicionar, obter e remover itens:

				
					public interface IRepositorio<T>
{
    void Adicionar(T item); 
    T Obter(int id);         
    void Remover(int id);   
}

				
			

Aqui, o “T” representa o tipo de entidade com o qual o repositório vai trabalhar. Isso significa que podemos criar implementações dessa interface para qualquer tipo de entidade que nossa aplicação precise gerenciar. Os métodos “Adicionar”, “Obter” e “Remover” são genéricos e vão funcionar com qualquer tipo de dado que seja passado quando a interface for implementada.

Agora vamos criar uma classe “Produto” que funcionará como entidade a ser manipulada pelo repositório:

				
					public class Produto
{
    public int Id { get; set; }
    public string Nome { get; set; }
}

				
			

Em seguida podemos implementar o repositório para “Produto” utilizando a interface genérica “IRepositorio<T>”:

				
					public class RepositorioDeProdutos : IRepositorio<Produto>
{
    private List<Produto> _produtos = new List<Produto>();  

    public void Adicionar(Produto produto)
    {
        _produtos.Add(produto);      }

    public Produto Obter(int id)
    {
        return _produtos.FirstOrDefault(p => p.Id == id);      }

    public void Remover(int id)
    {
        var produto = _produtos.FirstOrDefault(p => p.Id == id);
        if (produto != null)
        {
            _produtos.Remove(produto);        
        }
    }
}

				
			

Essa implementação usa uma lista de “Produto” para armazenar os itens, simulando um banco de dados em memória. O método “Adicionar” insere um novo produto na lista, o “Obter” retorna um produto com base no id, e o “Remover” exclui o produto da lista se ele for encontrado.

Uma das principais vantagens de interfaces genéricas é que podemos reutilizar a mesma lógica para diferentes tipos de entidades, sem duplicar o código. Por exemplo, podemos criar um “RepositorioDeClientes” da mesma forma que criamos o “RepositorioDeProdutos”, simplesmente mudando o tipo genérico de “Produto” para “Cliente”.

Restrições de tipos (constraints) em genéricos

Em C#, os tipos genéricos oferecem muita flexibilidade ao permitir que classes, métodos e interfaces possam trabalhar com diferentes tipos de dados. No entanto, em alguns cenários, pode ser necessário impor restrições sobre quais tipos podem ser utilizados com genéricos. Essas restrições são chamadas de constraints. Elas garantem que o tipo passado ao genérico atenda a certos critérios, como herdar de uma classe específica, implementar uma interface, ou possuir um construtor sem parâmetros.

Por exemplo, você pode querer garantir que o tipo genérico seja uma classe, e não uma struct (um tipo de valor). Isso pode ser útil quando você está lidando com referências de objetos e precisa evitar que o genérico seja aplicado a tipos de valor.

				
					public class IRepositorio<T> where T : class
{
	//Implementações
}

				
			

Quando adicionamos o “where T : class” estamos colocando uma restrição que garante que o tipo “T” seja uma classe, não permitindo o uso de outros tipos. A mesma estrutura pode ser usada para aplicar outras restrições. Por exemplo, no caso do repositório, podemos querer que apenas classes que herdam de um tipo base “Entidade” sejam usadas como parâmetro:

				
					public class IRepositorio<T> where T : Entidade
{
	//Implementações
}

				
			

Ou, ainda, podemos exibir que o tipo passado como parâmetro contenha um construtor padrão, sem parâmetros:

				
					public class IRepositorio<T> where T : new()
{
	//Implementações
}

				
			

Essas e outras restrições podem ser aplicadas para garantir maior consistência ao código, evitando usos incorretos dos tipos genéricos.

Acelere a sua carreira conosco!

Se você é Desenvolvedor .NET Júnior e quer acelerar sua carreira até nível Pleno com salário de R$7k+, ou mesmo busca a primeira vaga, conheça a Mentoria .NET StartClique aqui Se é Desenvolvedor .NET Pleno ou Sênior e quer virar referência técnica em sua equipe e mercado, com salário de R$10k+, conheça a Mentoria .NET ExpertClique aqui

Conclusão

Os tipos genéricos são uma das funcionalidades mais poderosas do C# e .NET, permitindo que você escreva código flexível, reutilizável e seguro. Ao dominar o uso de classes, métodos e interfaces genéricas, você se tornará um desenvolvedor mais eficiente, criando soluções que podem ser aplicadas a uma ampla gama de problemas.

Além disso, ao entender e aplicar constraints, você pode limitar o escopo dos tipos que seus genéricos aceitam, garantindo maior segurança de tipo e evitando erros em tempo de execução.