Orientação a objetos: Abstração

A abstração na orientação a objetos consiste em esconder os detalhes desnecessários de uma classe e expor apenas o que importa para quem a utiliza. Ela é um dos quatro pilares do paradigma e, neste artigo, você vai ver como esse conceito aparece no C# por meio de modificadores de acesso, interfaces e injeção de dependência.

O que é abstração

De forma geral, abstrair significa esconder os detalhes de algo, no caso, os detalhes desnecessários. Assim, você foca no que a coisa faz e deixa de lado como ela faz.

No mundo real, utilizamos abstrações o tempo todo. Na prática, tudo o que usamos sem saber como funciona por baixo dos panos pode ser considerado uma abstração.

Para exemplificar, pense na concessionária que realiza a manutenção do seu carro. Você leva o carro até lá com um problema e, depois de alguns dias, ele volta funcionando.

Em suma, pouco importam os detalhes do que aconteceu durante a manutenção. O que importa é que o carro voltou funcionando e que você continua dirigindo da mesma forma.

O mesmo vale para o volante, os pedais e o câmbio. Eles formam uma interface simples que esconde todo o mecanismo do motor, da injeção eletrônica e da transmissão.

Abstração na orientação a objetos

Dentro da OOP (Object Oriented Programming, ou Programação Orientada a Objetos), a abstração aparece de várias formas. Por exemplo, ela surge nas interfaces e nas classes que escondem seus detalhes internos.

A documentação oficial do C# define a abstração como a modelagem dos atributos e interações relevantes de uma entidade em classes. Ou seja, a classe vira uma representação abstrata de um conceito do sistema.

Além disso, a mesma documentação destaca um benefício direto. Quando uma classe abstrai bem um conceito, você consegue modificar a implementação dela sem afetar o código que a utiliza.

Por isso, a abstração na orientação a objetos caminha lado a lado com o encapsulamento. Enquanto o encapsulamento agrupa e protege o estado, a abstração decide o que merece ficar visível para o mundo externo.

Se você ainda não conhece os quatro pilares, vale começar pelo artigo de introdução à orientação a objetos. Em seguida, volte aqui para aprofundar este pilar específico.

Abstração na orientação a objetos com modificadores de acesso

A forma mais simples de aplicar a abstração na orientação a objetos é controlar a visibilidade dos membros. Para começar, vamos tomar como base uma classe que representa um pedido.

public class Order
{
    public List<Item> Items { get; } = [];
    public decimal Total { get; set; }

    public void CalculateTotal()
    {
        Total = Items.Sum(x => x.Price * x.Quantity);
    }

    public void AddItem(Item item)
    {
        Items.Add(item);
        CalculateTotal();
    }
}

public record Item(string Name, decimal Price, int Quantity);

Neste exemplo, a classe Order representa um pedido e possui uma propriedade chamada Total, que define o valor total. Além disso, ela tem dois métodos, CalculateTotal e AddItem.

Listas: para saber mais sobre listas, confira a aula "Listas" do curso Fundamentos da Orientação a Objetos.

Por que esconder detalhes

Quanto mais contato sua classe tem com o mundo externo, maiores são os impactos quando algo muda dentro dela. Afinal, cada membro público vira uma promessa para quem consome o código.

Por exemplo, imagine que essa classe esteja sendo utilizada em 20 lugares diferentes. Se você renomear a propriedade Total para Amount, isso vai gerar uma grande refatoração.

Embora em alguns cenários isso aconteça e seja necessário, quanto mais detalhes pudermos esconder, melhor. Dessa forma, as mudanças ficam contidas dentro da própria classe.

Esse é o raciocínio central da abstração na orientação a objetos. Cada membro que fica privado é um membro que você pode mudar sem pedir licença a ninguém.

Tornando o cálculo privado

Olhando novamente para o código, você identifica algo que poderia mudar? Repare que o cálculo do total já acontece a cada item adicionado.

Se pararmos para pensar, não faz sentido alguém de fora acionar o método que calcula o pedido. O pedido, por si só, deve saber se gerenciar.

Como a chamada ao CalculateTotal já acontece dentro do AddItem, podemos simplesmente marcar o método como privado.

public class Order
{
    public List<Item> Items { get; } = [];
    public decimal Total { get; private set; }

    // Detalhe interno: ninguém de fora precisa chamar este método
    private void CalculateTotal()
    {
        Total = Items.Sum(x => x.Price * x.Quantity);
    }

    public void AddItem(Item item)
    {
        Items.Add(item);
        CalculateTotal();
    }
}

Desta forma, o modificador de acesso private torna o método CalculateTotal inacessível externamente. Também aproveitamos para trocar o set da propriedade Total por private set, porque só o próprio pedido deve alterar o valor.

Com isso, a superfície pública da classe diminuiu. Agora, quem usa o pedido conhece apenas Items, Total e AddItem, e o cálculo pode mudar à vontade.

Ainda assim, a lista Items continua exposta como List<Item>, o que permite adicionar itens sem passar pelo AddItem. Em um modelo de domínio rico, o ideal seria expor uma coleção somente leitura, como IReadOnlyCollection<Item>.

Modificadores de acesso: todos os modificadores de acesso com exemplos práticos estão no curso Fundamentos da Orientação a Objetos.

Abstração na orientação a objetos por interfaces

As interfaces funcionam como contratos que definem o que as implementações, ou seja, as classes, devem conter. Esse tema aparece no Módulo 1, Aula 17, do curso Fundamentos da Orientação a Objetos.

Em suma, as interfaces dizem "o que" e não "como". Sendo assim, podemos tratar o "como" como os detalhes e o "o que" como a abstração na orientação a objetos.

public interface ICustomerRepository
{
    void Save(Customer customer);
}

Tomando como base a interface acima, temos um contrato que diz que um Customer pode ser salvo. No entanto, ela não diz como isso deve ser feito.

Esse conceito nos leva ao princípio DIP, o Dependency Inversion Principle, que faz parte do SOLID e prega o seguinte:

Sempre que puder, dependa de abstrações em vez de implementações.

As diretrizes de design de frameworks da Microsoft reforçam a mesma ideia. Segundo elas, abstrações estão no centro de padrões como plug-ins e inversão de controle, além de serem essenciais para a testabilidade.

Várias implementações do mesmo contrato

Esse processo simples abre a possibilidade de criar várias implementações de um mesmo contrato. Vamos começar por uma versão direcionada ao uso do Entity Framework.

public class CustomerRepository : ICustomerRepository
{
    private readonly AppDbContext _context;

    public CustomerRepository(AppDbContext context)
    {
        _context = context;
    }

    public void Save(Customer customer)
    {
        // Implementação real, que grava no banco de dados
        _context.Customers.Add(customer);
        _context.SaveChanges();
    }
}

Porém, durante os testes de unidade, não podemos depender de dados externos. Logo, precisamos simular o nosso repositório.

Sendo assim, criamos outra implementação baseada no contrato ICustomerRepository, que vai enganar os nossos testes.

public class FakeCustomerRepository : ICustomerRepository
{
    public void Save(Customer customer)
    {
        // Não faz nada: apenas simula a gravação
    }
}

Pronto, neste momento temos duas implementações, CustomerRepository e FakeCustomerRepository, baseadas no contrato ICustomerRepository. Para ver o padrão completo com EF Core, confira o artigo sobre Repository Pattern com C# e Entity Framework.

Interfaces ou classes abstratas

As interfaces não são a única forma de criar contratos no C#. As classes abstratas, marcadas com abstract, também definem membros que as classes derivadas precisam implementar.

Segundo a documentação de interfaces do C#, use uma classe abstrata quando os tipos relacionados compartilham estado, construtores ou membros não públicos. Por outro lado, prefira uma interface quando o contrato atravessa hierarquias diferentes.

Além disso, existe uma restrição importante. No C#, uma classe herda de apenas uma classe base, mas pode implementar várias interfaces ao mesmo tempo.

Por isso, no caso do repositório, a interface é a escolha mais flexível. Afinal, o repositório real e o falso não compartilham nenhum estado, apenas o contrato.

Nas duas opções, porém, o objetivo é o mesmo. Tanto a interface quanto a classe abstrata são ferramentas da abstração na orientação a objetos, porque ambas separam o contrato dos detalhes.

Aplicando a abstração na orientação a objetos na prática

Agora vamos para um cenário maior, com o objeto OrderHandler, que manipula os pedidos. Esse objeto precisa se comunicar com a base de clientes para salvar dados.

Aqui, a abstração na orientação a objetos mostra todo o seu valor, porque durante os testes não teremos uma base de dados disponível.

Desta forma, os testes falhariam, ou teríamos que preparar sempre uma massa de testes, o que muitas vezes toma tempo.

A saída aqui é usar um mock, ou seja, simular que fomos ao banco de dados e algo aconteceu. Então, teremos as versões CustomerRepository e FakeCustomerRepository sendo utilizadas em momentos diferentes.

Testes de unidade: veja mais sobre mocks e muitos testes de unidade no curso Refatorando para Testes de Unidade.

Dependendo de abstrações

Nosso primeiro passo é depender de abstrações, as interfaces, e não de implementações, as classes concretas. Isso acontece porque uma interface pode ter várias implementações, enquanto uma classe concreta amarra o código a um único comportamento.

public class OrderHandler
{
    private readonly ICustomerRepository _repository;

    // O handler recebe o contrato, e não uma implementação específica
    public OrderHandler(ICustomerRepository repository)
    {
        _repository = repository;
    }

    public void Handle(Customer customer)
    {
        _repository.Save(customer);
    }
}

Repare que o OrderHandler não sabe se o cliente vai para o SQL Server, para a memória ou para lugar nenhum. Ele apenas confia no contrato definido pela interface.

Em outras palavras, o handler aplica a abstração na orientação a objetos do lado de quem consome. Ele conhece somente o "o que", enquanto o "como" fica a cargo de quem montar o objeto.

Realizando os testes

Tudo o que precisamos fazer agora é instanciar o OrderHandler, que nos obriga a informar um ICustomerRepository. A boa notícia é que tanto o CustomerRepository quanto o FakeCustomerRepository são compatíveis.

Neste cenário, vamos ficar com a versão falsa. Assim, o teste roda sem banco de dados e em poucos milissegundos.

[TestClass]
public class OrderHandlerTests
{
    [TestMethod]
    public void ShouldHandleOrderWithoutDatabase()
    {
        // Usa a implementação falsa no lugar do banco real
        var repository = new FakeCustomerRepository();
        var handler = new OrderHandler(repository);

        handler.Handle(new Customer());
    }
}

Se você quer entender melhor como estruturar esse tipo de teste, o artigo sobre fundamentos dos testes de unidade aprofunda o assunto.

Utilizando nos controllers

Caso estejamos em um cenário web com MVC, também podemos usar a abstração nos controladores. O atributo [FromServices] pede ao ASP.NET Core uma implementação da interface diretamente na action.

[HttpPost]
public IActionResult Post([FromServices] ICustomerRepository repository, Customer customer)
{
    // O controller também depende apenas do contrato
    repository.Save(customer);
    return Ok();
}

Dessa forma, o controller continua sem conhecer a implementação concreta. Ele sabe apenas que existe algo capaz de salvar um cliente.

Vale notar que a injeção pelo construtor continua sendo a forma mais comum nos controllers. Ainda assim, o [FromServices] é útil quando apenas uma action precisa da dependência.

Injeção de dependência

Neste momento, você deve estar se perguntando como o ASP.NET sabe qual implementação chamar, a CustomerRepository ou a FakeCustomerRepository. Quem define isso é a configuração do container de injeção de dependência da aplicação.

// Program.cs: associa o contrato à implementação real
builder.Services.AddTransient<ICustomerRepository, CustomerRepository>();

Desta forma, o ASP.NET sabe qual implementação utilizar sempre que alguém pedir a interface. Nos testes, por outro lado, nós mesmos entregamos a versão falsa.

O método AddTransient cria uma nova instância a cada solicitação. Além dele, existem AddScoped e AddSingleton, explicados na documentação de injeção de dependência do .NET.

Para ir além, o artigo sobre Dependency Injection mostra o tema em mais detalhes.

Benefícios da abstração na orientação a objetos

Depois dos exemplos, fica mais fácil enxergar os ganhos concretos. O primeiro deles é a redução do impacto das mudanças, já que os detalhes ficam escondidos atrás de uma superfície pública pequena.

O segundo benefício é a testabilidade. Como vimos, a abstração na orientação a objetos permite trocar o repositório real por um falso sem alterar uma linha do OrderHandler.

Além disso, a abstração facilita a substituição de tecnologias. Se amanhã o projeto trocar o Entity Framework pelo Dapper, basta criar uma nova implementação de ICustomerRepository e mudar o registro no container.

Outro ganho está na leitura do código. Quem abre o OrderHandler entende rapidamente o que ele faz, porque não precisa navegar pelos detalhes de acesso a dados.

Por fim, a abstração ajuda o trabalho em equipe. Enquanto uma pessoa implementa o repositório, outra pode escrever o handler e os testes usando apenas o contrato combinado.

Cuidados com a abstração na orientação a objetos

Apesar dos benefícios, a abstração na orientação a objetos também exige equilíbrio. As diretrizes de design de frameworks da Microsoft alertam que criar uma boa abstração é difícil.

Segundo elas, o principal desafio está em acertar o conjunto de membros, nem mais nem menos. Uma abstração com membros demais fica difícil de implementar, enquanto uma com membros de menos se torna inútil em muitos cenários.

Além disso, abstrações em excesso também prejudicam a usabilidade. Nomes genéricos e camadas sem propósito deixam o código difícil de entender sem conhecer o quadro completo.

Por isso, aplique a abstração na orientação a objetos com propósito e crie interfaces quando houver um motivo concreto, como permitir testes ou trocar implementações. Caso contrário, uma classe bem encapsulada costuma resolver o problema com menos complexidade.

Conclusão

A abstração na orientação a objetos parece um assunto simples e básico, mas gera boas reflexões. Afinal, decidir o que esconder e o que expor afeta diretamente o custo de manutenção do sistema.

Neste artigo, vimos a abstração em duas formas. Primeiro, com modificadores de acesso que reduzem a superfície pública de uma classe; depois, com interfaces que separam o contrato da implementação.

Na prática, essas duas formas de abstração na orientação a objetos se combinam com a injeção de dependência e os testes de unidade. Com isso, o código fica mais fácil de testar, de evoluir e de entender.

Por fim, vale revisitar esses conteúdos de tempos em tempos. Assim, você confirma que domina a abstração na orientação a objetos ou descobre o que ainda precisa aprender.

FAQ

O que é abstração na programação?

É a prática de esconder os detalhes de implementação e expor apenas o essencial. Dessa forma, quem consome o código depende do que ele faz, e não de como ele faz.

Qual a diferença entre abstração e encapsulamento?

O encapsulamento agrupa dados e comportamentos e controla o acesso a eles. A abstração decide quais desses membros devem ficar visíveis para o mundo externo.

Interface é uma abstração?

Sim. A interface define um contrato com o que deve ser feito, sem dizer como, e permite várias implementações para o mesmo contrato.

Quando usar classe abstrata em vez de interface?

Use classe abstrata quando os tipos compartilham estado, construtores ou membros não públicos. Prefira interface quando o contrato precisa atravessar hierarquias diferentes.

Por que a abstração facilita os testes de unidade?

Porque você pode substituir a implementação real por uma versão falsa. Assim, o teste roda sem banco de dados, rede ou outros recursos externos.