Interfaces: Contratos Que Qualquer Tipo Pode Assinar

Interfaces: Contratos Que Qualquer Tipo Pode Assinar

Interfaces em C#: contratos que tipos sem parentesco assinam, várias interfaces numa classe, IEnumerable, IReadOnlyList e IComparer na biblioteca padrão, depender do contrato e não da implementação, injeção de dependência, Strategy e Composite, e classe abstrata contra interface.
Linguagem C#

• • 14 min de leitura

Uma interface não diz o que um tipo é, diz o que ele sabe fazer, e é por isso que tipos sem nenhum parentesco podem compartilhá-la.

A herança que estudamos em "Herança: Quando um Tipo É uma Versão Especializada de Outro" funciona quando os tipos compartilham identidade: livros impressos e e-books são produtos. Mas a Livraria precisa de descontos vindos de fontes muito diferentes: a regra de volume que carregamos desde a Fase 1, cupons digitados pelo cliente, campanhas sazonais. Esses conceitos não têm dados em comum nem uma identidade compartilhada. Compartilham apenas uma capacidade: dado um pedido, dizer quanto abater. Para expressar capacidades, o C# tem as interfaces.

Declarando uma interface

Uma interface lista membros sem implementação: é um contrato que diz "quem me implementar precisa oferecer isto".

interface IPoliticaDesconto
{
    string Descricao { get; }
    decimal CalcularDesconto(decimal subtotal, int quantidadeTotal);
}

Por convenção, o nome de toda interface no .NET começa com I. Os membros dispensam modificador de acesso: numa interface, eles são públicos por padrão, porque são o próprio contrato. Uma interface não tem campos de instância nem construtores, porque não guarda estado. Ela só descreve comportamento.

Uma classe implementa a interface com a mesma sintaxe de dois-pontos da herança:

sealed class SemDesconto : IPoliticaDesconto
{
    public string Descricao => "sem desconto";
    public decimal CalcularDesconto(decimal subtotal, int quantidadeTotal) => 0m;
}

sealed class DescontoPorVolume : IPoliticaDesconto
{
    public string Descricao => "desconto por volume";

    public decimal CalcularDesconto(decimal subtotal, int quantidadeTotal) =>
        subtotal * (quantidadeTotal switch
        {
            >= 10 => 0.15m,
            >= 3  => 0.10m,
            _     => 0m
        });
}

sealed class CupomPercentual : IPoliticaDesconto
{
    public CupomPercentual(string codigo, decimal percentual)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(codigo);
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(percentual);
        ArgumentOutOfRangeException.ThrowIfGreaterThan(percentual, 0.5m);
        Codigo = codigo.Trim().ToUpperInvariant();
        Percentual = percentual;
    }

    public string Codigo { get; }
    public decimal Percentual { get; }
    public string Descricao => $"cupom {Codigo} ({Percentual:P0})";

    public decimal CalcularDesconto(decimal subtotal, int quantidadeTotal) => subtotal * Percentual;
}

Se uma classe declarar que implementa a interface e esquecer um membro, o compilador recusa. O contrato é verificado.

As três classes não têm parentesco. CupomPercentual guarda um código e um percentual, e as outras duas não guardam nada. O que as une é a assinatura do contrato. E, como a interface é um tipo, uma variável do tipo IPoliticaDesconto aceita qualquer uma delas:

IPoliticaDesconto politica = new CupomPercentual("LEITURA20", 0.20m);
Console.WriteLine(politica.Descricao);                        // cupom LEITURA20 (20%)
Console.WriteLine(politica.CalcularDesconto(200m, 2));        // 40,00

É o mesmo despacho dinâmico da aula anterior: a chamada executa a implementação do objeto real.

Várias interfaces, uma classe base

Uma classe só pode herdar de uma classe, mas pode implementar quantas interfaces quiser. A base vem primeiro, e as interfaces depois:

sealed class LivroImpresso : Produto, IComparable<LivroImpresso>, IDisposable
{
    // ...
}

É assim que o C# oferece os benefícios da herança múltipla sem seus problemas. Da classe base vem a identidade, os dados e o código compartilhado. Das interfaces, as capacidades adicionais. Como interfaces não carregam estado, não existe a ambiguidade de "qual das duas bases guarda este campo".

Interfaces que você já usa

A biblioteca padrão do .NET é construída sobre interfaces, e várias já apareceram no curso sem esse nome.

IEnumerable<T> é o contrato "pode ser percorrido". O foreach funciona com arrays, listas, dicionários, strings e com qualquer tipo seu que implemente essa interface. A Fase 3 dedica uma aula a ela e mostra como escrever as suas.

IReadOnlyList<T>, que usamos em "Propriedades e Encapsulamento" para expor os itens do pedido, é o contrato "pode ser lido por índice e percorrido". O pedido promete apenas isso, e quem usa não depende de ser uma List por trás.

IComparer<T> é o contrato "sabe comparar dois objetos de tipo T". Com ele, você ensina o Sort a ordenar do jeito que quiser:

sealed class PorPreco : IComparer<Produto>
{
    public int Compare(Produto? x, Produto? y)
    {
        if (ReferenceEquals(x, y)) return 0;
        if (x is null) return -1;
        if (y is null) return 1;
        return x.Preco.CompareTo(y.Preco);
    }
}

catalogo.Sort(new PorPreco());   // do mais barato ao mais caro

O contrato de Compare é devolver um número negativo se x vem antes, zero se são equivalentes e positivo se x vem depois. O List.Sort não sabe nada sobre produtos ou preços: ele só sabe usar qualquer coisa que cumpra o contrato IComparer<T>. Esse desacoplamento é a essência das interfaces.

IDisposable, o contrato "tem recursos a liberar", é a base da instrução using que prometemos em "Quando Algo Dá Errado" e que veremos na aula sobre memória.

Depender do contrato, não da implementação

Agora vem a ideia que torna as interfaces indispensáveis em sistemas reais. O Pedido da aula de propriedades calculava o desconto por volume dentro de si mesmo. Para aceitar cupons, precisaríamos de um if para cada tipo de desconto. Para campanhas, mais ifs. Cada nova regra de marketing exigiria mexer no Pedido. Em vez disso, o pedido pode receber a política como um contrato:

sealed class Pedido
{
    private readonly List<ItemPedido> _itens = [];
    private readonly IPoliticaDesconto _politica;

    public Pedido(IPoliticaDesconto politica)
    {
        ArgumentNullException.ThrowIfNull(politica);
        _politica = politica;
    }

    // ... Itens, Adicionar, QuantidadeTotal e Subtotal como antes ...

    public decimal Desconto => _politica.CalcularDesconto(Subtotal, QuantidadeTotal);
    public decimal Total => Subtotal - Desconto;
    public string DescricaoDesconto => _politica.Descricao;
}

O Pedido não sabe, nem quer saber, qual política recebeu. Ele sabe que ela cumpre o contrato. Criar uma nova forma de desconto passa a significar escrever uma nova classe que implementa IPoliticaDesconto, sem tocar em uma linha de Pedido. Esse princípio tem nome: aberto para extensão, fechado para modificação. E essa forma de organizar código, em que uma família de algoritmos intercambiáveis fica atrás de um contrato comum, é o padrão de projeto Strategy.

Receber as dependências pelo construtor, em vez de criá-las por dentro, também tem nome: injeção de dependência. Por enquanto fazemos isso à mão. Na fase de bancos de dados e na de API, o .NET vai montar esses objetos automaticamente, e o que você aprendeu aqui é exatamente o que aquele mecanismo faz por baixo.

Há ainda um ganho que só fica evidente com o tempo: testabilidade. Para verificar se o Pedido soma corretamente, você não precisa das regras reais de desconto. Basta uma implementação falsa, feita para o teste, que devolva um valor conhecido. O exercício 4 mostra isso, e a aula de testes automatizados, na Fase 4, se apoia inteira nessa ideia.

Classe abstrata ou interface?

As duas definem contratos que outros tipos cumprem. Como escolher? Um critério prático:

  • Classe abstrata quando os tipos compartilham identidade e estado: são variações de uma mesma coisa, com dados e código em comum. Produto guarda Id, Titulo e Preco e tem a validação do preço, e herdar isso é o que torna a hierarquia útil.
  • Interface quando os tipos compartilham uma capacidade, sem parentesco. Uma política de desconto, algo comparável, algo que pode ser percorrido, algo que libera recursos.

Na dúvida, comece pela interface. Ela impõe menos: uma classe pode implementar várias e continua livre para escolher sua base. E nada impede combinar: uma classe abstrata pode implementar uma interface e servir de base para implementações que compartilham código.

Uma observação sobre um recurso que você vai encontrar: desde o C# 8, interfaces podem ter métodos com implementação padrão. O objetivo original é permitir que os autores de bibliotecas acrescentem membros a interfaces já publicadas sem quebrar quem as implementa. É útil nesse cenário. Usá-lo para tentar transformar interfaces em classes abstratas costuma produzir desenhos confusos. Use com parcimônia.

Juntando tudo: descontos intercambiáveis

Com as três políticas declaradas acima e o Pedido que recebe a política pelo construtor (usando o ItemPedido da aula de propriedades, agora com Produto no lugar de Livro):

using System.Globalization;

CultureInfo.CurrentCulture = new CultureInfo("pt-BR");

var dom = new LivroImpresso("Dom Casmurro", "Machado de Assis", 59.90m, pesoGramas: 350, estoque: 10);
var sertao = new LivroImpresso("Grande Sertão: Veredas", "Guimarães Rosa", 89.00m, pesoGramas: 1200, estoque: 5);
var ebook = new Ebook("Vidas Secas", "Graciliano Ramos", 24.90m, tamanhoMb: 1.8);

IPoliticaDesconto[] politicas =
[
    new SemDesconto(),
    new DescontoPorVolume(),
    new CupomPercentual("leitura20", 0.20m),
];

foreach (IPoliticaDesconto politica in politicas)
{
    var pedido = new Pedido(politica);
    pedido.Adicionar(dom, 2);
    pedido.Adicionar(sertao, 1);
    pedido.Adicionar(ebook, 1);

    Console.WriteLine($"{pedido.DescricaoDesconto,-28} subtotal {pedido.Subtotal,10:C}" +
                      $"   desconto {pedido.Desconto,9:C}   total {pedido.Total,10:C}");
}
sem desconto                 subtotal  R$ 233,70   desconto   R$ 0,00   total  R$ 233,70
desconto por volume          subtotal  R$ 233,70   desconto  R$ 23,37   total  R$ 210,33
cupom LEITURA20 (20%)        subtotal  R$ 233,70   desconto  R$ 46,74   total  R$ 186,96

O mesmo pedido, com os mesmos itens, calculado com três regras diferentes, e a classe Pedido não contém um único if sobre descontos. (Subtotal: 2 × 59,90 + 89,00 + 24,90 = 233,70. São 4 unidades, então o volume dá 10%.) Repare também no IPoliticaDesconto[]: um array cujo tipo de elemento é a interface, que aceita objetos de três classes sem nenhuma relação entre si.

Um ponto de desenho ficou em aberto de propósito: o que acontece quando o cliente tem um cupom e o pedido se qualifica para o desconto por volume? A resposta de negócio mais comum é "aplica o melhor dos dois". Graças à interface, essa regra também pode ser só mais uma política, que combina outras políticas. É o exercício 5.

Com classes, propriedades, herança e interfaces, a Livraria já tem um modelo de objetos respeitável. Mas uma pergunta deixada no ar em "Classes e Objetos" continua sem resposta: quando dois objetos são iguais? Dois ItemPedido com o mesmo produto e a mesma quantidade representam a mesma coisa, mas == diz que não, porque compara referências. Para objetos que são essencialmente dados, como um item de pedido, um endereço ou uma cotação, essa semântica está errada. O C# tem tipos desenhados para ter igualdade por conteúdo e imutabilidade, e para decidir entre eles é preciso entender a diferença profunda entre class, struct e record.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Crie a política CupomValorFixo, que abate um valor fixo em reais (por exemplo, R$ 15), mas nunca mais do que o próprio subtotal.

Ver resposta

✓ Resposta:

sealed class CupomValorFixo : IPoliticaDesconto
{
    public CupomValorFixo(string codigo, decimal valor)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(codigo);
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(valor);
        Codigo = codigo.Trim().ToUpperInvariant();
        Valor = valor;
    }

    public string Codigo { get; }
    public decimal Valor { get; }
    public string Descricao => $"cupom {Codigo} ({Valor:C})";

    public decimal CalcularDesconto(decimal subtotal, int quantidadeTotal) =>
        Math.Min(Valor, subtotal);
}

Math.Min devolve o menor dos dois valores: num pedido de R$ 10 com um cupom de R$ 15, o desconto é R$ 10, e o total fica zero em vez de negativo. Nenhuma linha de Pedido precisou mudar para aceitar esta política nova, e isso é o "aberto para extensão, fechado para modificação" na prática. O parâmetro quantidadeTotal não é usado aqui. Faz parte do contrato porque outras políticas precisam dele, e um contrato serve a todos os que o assinam.

Exercício 2

Explique por que uma interface não pode ter campos de instância nem construtor, e dê um exemplo de situação em que uma classe abstrata seria mais adequada que uma interface.

Ver resposta

✓ Resposta: Uma interface descreve o que um tipo sabe fazer, e não o que ele é nem como guarda seus dados. Campos e construtores são justamente sobre estado e inicialização, e pertencem a cada implementação. Como uma classe pode implementar várias interfaces, permitir estado nelas traria de volta os problemas da herança múltipla: se duas interfaces tivessem um campo Nome, qual valeria? Uma classe abstrata é mais adequada quando as implementações compartilham dados e código de verdade. Produto é o exemplo do curso: todo produto tem Id, Titulo, Autor e Preco, com a mesma validação de preço e o mesmo gerador de identificadores. Com uma interface IProduto, cada classe teria de repetir esses dados e essas validações, e a duplicação que a herança eliminou voltaria.

Exercício 3

Implemente um IComparer<Produto> chamado PorTitulo que ordene pelo título respeitando as regras do português (acentos no lugar certo), e use-o para ordenar o catálogo.

Ver resposta

✓ Resposta:

sealed class PorTitulo : IComparer<Produto>
{
    public int Compare(Produto? x, Produto? y)
    {
        if (ReferenceEquals(x, y)) return 0;
        if (x is null) return -1;
        if (y is null) return 1;
        return string.Compare(x.Titulo, y.Titulo, StringComparison.CurrentCulture);
    }
}

catalogo.Sort(new PorTitulo());

A escolha importante é StringComparison.CurrentCulture. Com a cultura pt-BR, "Ópera" é ordenada junto com as palavras em "O", como num dicionário. Com StringComparison.Ordinal, que compara os códigos numéricos dos caracteres, "Ó" (código 211) viria depois de todas as letras sem acento, inclusive do "Z" (código 90). Ordinal é o certo para chaves técnicas (identificadores, nomes de arquivo, comparações de segurança). Cultural é o certo para textos exibidos a pessoas. É a mesma distinção que citamos nas práticas recomendadas de strings em "Operadores, Texto e Conversa com o Usuário".

Exercício 4

Escreva uma política falsa, DescontoFixoDeTeste, que sempre devolve o valor recebido no construtor. Use-a para verificar, com um if, que Pedido.Total é igual a Subtotal - desconto, independentemente das regras reais de desconto.

Ver resposta

✓ Resposta:

sealed class DescontoFixoDeTeste(decimal valor) : IPoliticaDesconto
{
    public string Descricao => "teste";
    public decimal CalcularDesconto(decimal subtotal, int quantidadeTotal) => valor;
}

var pedido = new Pedido(new DescontoFixoDeTeste(5m));
pedido.Adicionar(new Ebook("Teste", "Autor", 20m, 1), 1);

if (pedido.Total == 15m)
    Console.WriteLine("OK: o pedido aplica o desconto que a política informa.");
else
    Console.WriteLine($"FALHOU: esperado 15, obtido {pedido.Total}");

A sintaxe class DescontoFixoDeTeste(decimal valor) é um construtor primário (C# 12): o parâmetro fica disponível em toda a classe, sem campo nem construtor escritos à mão. É ótimo para classes pequenas como esta. O ponto do exercício é o isolamento. Esse teste verifica apenas a responsabilidade do Pedido (somar e subtrair corretamente), sem depender de a regra de volume estar certa. Se amanhã o marketing mudar os percentuais, este teste continua valendo. Na Fase 4 trocaremos o if por um framework de testes, mas a ideia de substituir dependências por implementações controladas é exatamente esta.

Exercício 5

Implemente a política MelhorDesconto, que recebe várias políticas e aplica a que der o maior desconto, informando qual foi escolhida na descrição.

Ver resposta

✓ Resposta:

sealed class MelhorDesconto : IPoliticaDesconto
{
    private readonly IPoliticaDesconto[] _opcoes;
    private IPoliticaDesconto? _escolhida;

    public MelhorDesconto(params IPoliticaDesconto[] opcoes)
    {
        ArgumentOutOfRangeException.ThrowIfZero(opcoes.Length);
        _opcoes = opcoes;
    }

    public string Descricao => _escolhida is null
        ? "melhor desconto (ainda não calculado)"
        : $"melhor desconto: {_escolhida.Descricao}";

    public decimal CalcularDesconto(decimal subtotal, int quantidadeTotal)
    {
        decimal maior = -1m;
        foreach (IPoliticaDesconto opcao in _opcoes)
        {
            decimal desconto = opcao.CalcularDesconto(subtotal, quantidadeTotal);
            if (desconto > maior)
            {
                maior = desconto;
                _escolhida = opcao;
            }
        }
        return maior;
    }
}

var politica = new MelhorDesconto(new DescontoPorVolume(), new CupomPercentual("LEITURA20", 0.20m));

Esta é a ideia do padrão Composite: um objeto que implementa o contrato combinando outros objetos que implementam o mesmo contrato. Para o Pedido, MelhorDesconto é só mais uma IPoliticaDesconto. O params da aula de métodos permite passar quantas políticas se quiser. Um aviso honesto sobre o desenho: _escolhida guarda estado que muda a cada cálculo, então a Descricao depende de CalcularDesconto ter sido chamado antes, e a mesma instância compartilhada entre dois pedidos diferentes misturaria as escolhas. Uma versão mais robusta devolveria o desconto e a política escolhida juntos, sem guardar estado. Isso fica natural com os tipos de dados imutáveis que estudaremos a seguir.

Comentários

Mais em Linguagem C#

Do Console ao Jogo Online: o Mapa da Jornada em C#
Do Console ao Jogo Online: o Mapa da Jornada em C#

O mapa do curso de C#: setenta e três aulas em onze fases, dos fundamentos e…

Source Generators: Código Que Escreve Código Durante a Compilação
Source Generators: Código Que Escreve Código Durante a Compilação

Source generators no C#: o compilador escrevendo código durante a compilação…

Tempo real com SignalR: quando o servidor toma a iniciativa
Tempo real com SignalR: quando o servidor toma a iniciativa

A Livraria passa a avisar quem está conectado: polling, SSE e WebSockets, hubs…