CodeGym /Cursos /C++ SELF /Contrato da interface

Contrato da interface

C++ SELF
Nível 52, Lição 1
Disponível

1. Por que uma interface precisa de um contrato

Se você alguma vez pegou um código alheio e pensou "o que essa função realmente promete?", então você já se deparou com o problema do contrato. Uma interface sem contrato é como um botão sem rótulo: dá medo apertar, porque não se sabe se é "Salvar" ou "Apagar tudo e enviar um e-mail para o chefe".

O contrato da interface não é uma formalidade acadêmica. É uma forma de garantir que diferentes implementações da interface se comportem da mesma maneira "do ponto de vista do usuário". Sem contrato você obtém a situação: uma implementação retorna string vazia em erro, outra retorna nullopt, a terceira imprime uma mensagem no console (sim, isso também acontece...), e a quarta "silenciosamente não faz nada". E neste momento seu código vira uma novela de enredo imprevisível.

Importante: mesmo na documentação do padrão C++ as formulações de "postconditions" aparecem como parte normal da especificação, porque a biblioteca também precisa cumprir o acordo.

Pré-condições e pós-condições: quem deve o quê

Quando falamos de "contrato", quase sempre é conveniente pensar em duas metades: pré-condições e pós-condições. Aqui muitos iniciantes pensam: "Ah, é como 'antes' e 'depois'". Sim — e é uma ótima analogia, até começarmos a discutir quem deve lavar a louça.

Pré-condição é aquilo que o código chamador deve garantir antes de chamar o método. Ou seja, são exigências sobre os argumentos e/ou o estado do objeto. Por exemplo: "a chave não deve ser vazia", "o índice deve estar no intervalo", "o ponteiro pode ser nullptr — e isso é um caso distinto".

Pós-condição é aquilo que a implementação do método deve garantir após a execução. Por exemplo: "se o método retornou true, então o elemento foi realmente removido", "se foi retornado std::optional com valor, então ele corresponde ao registro encontrado", "o estado do objeto permanece válido".

Para ser prático, aqui vai uma pequena "tabela de acordo" (não uma lista, mas um guia compacto):

Termo Pergunta em linguagem humana Quem responde
Pré-condição "Com o que eu posso chamar o método para que seja correto?" Código chamador
Pós-condição "O que o método garante se eu o chamei corretamente?" Implementação

O mais importante: pré-condições e pós-condições devem ser consistentes para todas as implementações da interface. Caso contrário a interface deixa de ser interface e vira um "mistério abstrato".

2. Por que a interface deve ser enxuta

Há a tentação de fazer uma "interface rica", que faz de tudo. Na prática, tal interface frequentemente vira um canivete suíço... só que metade das ferramentas é decorativa, e o saca-rolhas de repente apaga arquivos.

Quando os métodos são pequenos, fica mais fácil entender o contrato, testar, implementar, manter múltiplas implementações e expandir o programa sem reescrever tudo.

Agora uma ideia importante: "método pequeno" não significa necessariamente "método com uma linha". É um método que faz uma operação clara. Mesmo que tenha 20 linhas por dentro — pode ser aceitável, se a operação for única e o propósito compreensível.

Mini-antiexemplo: interface "faz tudo pra mim"

Começamos com um exemplo ruim. Ele é ruim não porque o compilador reclama. É ruim porque é impossível de usar corretamente sem ler a implementação.

#include <string>

struct IStorageBad {
    virtual ~IStorageBad() = default;
    virtual std::string do_everything(const std::string& input) = 0;
};

O contrato aqui soa mais ou menos assim: "passe uma string — você receberá... algo". E se input estiver vazio? E se tiver espaços? E se não houver chave? E se ocorrer um erro? Retornamos string vazia? A string "ERROR"? Escrevemos em std::cout? Caos.

Melhorando: interface de pequenas operações

Queremos que cada ação seja separada, e que resultado/falha sejam expressos pelo tipo.

#include <optional>
#include <string>
#include <string_view>

struct IKeyValueStore {
    virtual ~IKeyValueStore() = default;

    // Pré-condição: key não é vazia.
    // Pós-condição: se a chave existir — retorna o valor, caso contrário nullopt.
    virtual std::optional<std::string> get(std::string_view key) const = 0;

    // Pré-condição: key não é vazia.
    // Pós-condição: após set(key, v) get(key) retornará v.
    virtual void set(std::string_view key, std::string_view value) = 0;
};

Há mais métodos, mas a interface ficou muito mais clara. E o principal — o contrato é legível aos olhos, sem necessidade de telepatia.

4. Contrato na assinatura: tipos, const, string_view, optional

É tentador pensar que o contrato é apenas comentários. Mas na prática o melhor contrato é aquele que o compilador ajuda a reforçar.

Assinatura (tipos de parâmetros, tipo de retorno, const no método) é sua oportunidade de "imprimir" parte do acordo diretamente no código.

const no método como promessa "eu não altero o estado"

Quando um método de interface é marcado como const, você diz ao usuário: "Esta é uma operação de leitura. Ela não altera o estado observável do objeto". E isso é uma garantia forte, porque não pode ser "esquecida" em uma implementação e "lembrada" em outra.

#include <optional>
#include <string>
#include <string_view>

struct ITaskQueries {
    virtual ~ITaskQueries() = default;

    // Pós-condição: não altera as tarefas internas (método const).
    virtual std::optional<std::string> title_of(std::string_view id) const = 0;
};

Sim, a implementação pode ter caches e otimizações "auxiliares", mas para o usuário const significa: "comportamento de leitura, sem alterar a lógica dos dados".

string_view como contrato "eu não possuo a string"

Quando você aceita std::string_view, você diz: "Quero apenas olhar o texto, não pretendo ficar com ele".

Isso é conveniente e rápido, mas tem uma consequência contratual importante: o código chamador deve garantir que os dados vivam tempo suficiente. Na interface isso costuma ser normal, porque a string frequentemente vem de std::string ou de literal, e o string_view vive apenas durante a chamada.

#include <string_view>

struct IValidator {
    virtual ~IValidator() = default;

    // Pré-condição: view é válido durante a chamada.
    // Pós-condição: retorna true somente para formato correto.
    virtual bool is_valid_id(std::string_view text) const = 0;
};

optional como contrato "pode não haver resultado, e tudo bem"

std::optional<T> é uma forma muito "honesta" de dizer: "Às vezes não há resultado". Isso é mais forte do que retornar string vazia ou -1, porque string vazia pode ser um valor válido, e -1 às vezes é um ID válido (em projetos estranhos — especialmente).

#include <optional>
#include <string>

std::optional<int> parse_age(const std::string& s) {
    if (s.empty()) return std::nullopt;
    // Suponha que aqui haja parsing...
    return 42;
}

O contrato fica imediatamente claro: se nullopt, então "não funcionou".

5. Contrato em caso de falha: sem exceções, mas honesto

Hoje escolhemos não entrar no mundo de exceções e try/catch (isso fica para outra aula). Mas a interface ainda precisa definir o que considerar erro e como reportá-lo.

Ao projetar um método, você quase sempre escolhe uma das três "linguagens de resposta": bool — sucesso/falha; optional<T> — há resultado/não há; "valor + flag" — por exemplo, pair<bool, T> (mas isso costuma ser menos legível).

O importante é escolher e fixar um estilo para a interface em questão, e não misturar tudo.

Por exemplo, para a operação de remoção é conveniente retornar bool, porque o resultado normalmente não traz dados, apenas o fato:

#include <string_view>

struct ITaskStore {
    virtual ~ITaskStore() = default;

    // Pré-condição: id não é vazio.
    // Pós-condição: true => tarefa removida, false => não havia tarefa.
    virtual bool remove(std::string_view id) = 0;
};

Aqui o contrato é "ferro": false não significa "ops, o disco quebrou". Significa especificamente "não encontrado". Se você precisa detalhar razões, isso é outro contrato e outro tipo de resultado, mas hoje mantemos a interface simples.

6. Contrato de estado: invariantes e validade

Uma boa interface não é só sobre métodos, mas também sobre a promessa: "o objeto permanece em estado consistente". Em C++ isso é especialmente importante, porque um estado quebrado é fácil de esconder e pode explodir em outro lugar.

Se seu armazenamento de tarefas tem a regra "ID é único", isso faz parte do invariante. Então o contrato do método add deve ou proibir adicionar com ID já existente (pré-condição), ou descrever o comportamento em conflito (pós-condição). Ambos são válidos, mas não se pode fazer com que uma implementação "sobrescreva" e outra "ignore".

Aqui uma ilustração: escolhemos previamente o sentido de add e o fixamos por palavras.

#include <string>
#include <string_view>

struct Task {
    std::string id;
    std::string title;
};

struct ITaskStore {
    virtual ~ITaskStore() = default;

    // Pré-condição: task.id não é vazio.
    // Pós-condição: se o id já estiver ocupado, a tarefa não é adicionada.
    virtual bool add(const Task& task) = 0;
};

Poderíamos escolher outro contrato ("se o id já existe, substituir"), mas o importante é que seja único e documentado.

7. Como fixar o contrato: comentários, assert, [[nodiscard]]

O contrato é útil na medida em que é visível e difícil de violar. Por isso, em projetos didáticos costumamos combinar três coisas: um comentário curto próximo ao método, assert para autoverificação em modo debug e o atributo [[nodiscard]] quando ignorar o resultado quase sempre é um erro.

Comentário junto ao método

É óbvio, mas funciona. E o importante não é o comprimento, e sim a concretude. Não "tudo vai ficar bem", mas "o que é considerado bem".

assert como "guarda" de pré-condições

assert não substitui o contrato, mas ajuda a detectar violação do contrato mais cedo, antes de perder horas no debugger.

#include <cassert>
#include <string_view>

void require_non_empty(std::string_view s) {
    assert(!s.empty()); // se estiver vazio — alguém violou a pré-condição
}

Na implementação da interface isso fica especialmente natural: você verifica diretamente aquilo que descreveu como pré-condição.

[[nodiscard]]: "não ignore, isso é importante"

Se um método retorna bool ou optional, quase sempre é um erro ignorar isso e continuar como se nada tivesse acontecido. Então [[nodiscard]] torna o contrato mais disciplinado.

#include <optional>
#include <string>
#include <string_view>

struct ITaskStore {
    virtual ~ITaskStore() = default;

    [[nodiscard]] virtual std::optional<std::string>
    title_of(std::string_view id) const = 0;
};

8. Exemplo prático: TaskBoard e a interface de armazenamento

Continuamos nosso aplicativo de console TaskBoard: um pequeno tracker de tarefas. Já tínhamos o modelo Task e operações básicas com um vetor. Agora queremos que "onde as tarefas ficam" possa mudar: hoje memória, amanhã arquivo, depois outra coisa qualquer. Para isso serve a interface.

Primeiro descrevemos o modelo e a interface. Note: aqui ainda não discutimos propriedade via unique_ptr e fábricas — isso fica para a próxima aula. Hoje o importante é o contrato.

#include <optional>
#include <string>
#include <string_view>
#include <vector>

struct Task {
    std::string id;
    std::string title;
};

struct ITaskStore {
    virtual ~ITaskStore() = default;

    // Pré-condição: task.id não é vazio.
    // Pós-condição: true => tarefa adicionada, false => id já ocupado.
    [[nodiscard]] virtual bool add(const Task& task) = 0;

    // Pré-condição: id não é vazio.
    // Pós-condição: se a tarefa existe — retorna uma cópia, caso contrário nullopt.
    [[nodiscard]] virtual std::optional<Task> get(std::string_view id) const = 0;

    // Pré-condição: id não é vazio.
    // Pós-condição: true => tarefa removida, false => não havia tarefa.
    [[nodiscard]] virtual bool remove(std::string_view id) = 0;

    // Pós-condição: retorna um snapshot de todas as tarefas.
    virtual std::vector<Task> list() const = 0;
};

Observe um ponto importante: list() retorna uma cópia. Não é a opção mais rápida do mundo, mas o contrato fica simples e seguro para iniciantes: quem obteve a lista — é dono dela, e não precisa se preocupar com tempo de vida de referências.

Implementação mais simples "em memória" para verificar o contrato

Agora fazemos uma implementação só para verificar que o contrato é realizável.

#include <algorithm>
#include <cassert>

struct MemoryTaskStore : ITaskStore {
    std::vector<Task> tasks;

    bool add(const Task& task) override {
        assert(!task.id.empty());
        if (get(task.id).has_value()) return false;
        tasks.push_back(task);
        return true;
    }
};

Sim, por enquanto só add está implementado, e isso é normal num passo didático: mostramos como o contrato afeta o código. Veja como assert materializa diretamente a pré-condição.

Vamos adicionar mais um método para ficar mais realista:

std::optional<Task> get(std::string_view id) const override {
    assert(!id.empty());
    auto it = std::find_if(tasks.begin(), tasks.end(),
        [&](const Task& t){ return t.id == id; });
    if (it == tasks.end()) return std::nullopt;
    return *it;
}

E agora podemos fazer um pequeno main que usa apenas o contrato, e não os detalhes:

#include <iostream>

int main() {
    MemoryTaskStore store;
    store.add(Task{"T1", "Write C++ code"});           // ok
    store.add(Task{"T1", "Duplicate id"});             // retornará false (não adiciona)

    auto t = store.get("T1");
    std::cout << (t ? t->title : "not found") << '\n'; // Write C++ code
}

Note a beleza do momento: o usuário de ITaskStore (aqui é o main) sabe exatamente o que significa false em add, e o que significa nullopt em get. Não é preciso adivinhar.

Pequeno diagrama: como o contrato mantém a interface dentro dos limites

Às vezes é útil ver isso como um processo. Aqui vai um fluxograma simples onde o contrato atua como "guardião" entre o código chamador e a implementação:

flowchart TD
    A[Codigo chamador] -->|cumpre as pré-condicoes| B[Metodo da interface]
    B -->|interno: assert/verificacoes| C[Implementacao]
    C -->|garante pos-condicoes| D[Resultado: bool/optional/valor]
    D -->|codigo chamador verifica o resultado| A

Se as pré-condições não forem cumpridas, ou é erro do código chamador (e assert ajuda a detectar rápido), ou você formulou mal o contrato e o tornou muito rígido para a realidade.

9. Erros típicos

Erro nº1: o contrato "vive na cabeça do autor", não no código.
Muito comum o desenvolvedor iniciante achar que "é óbvio". Em uma semana já não é óbvio nem para ele, e em um mês um novo membro quebra a interface simplesmente por não ter adivinhado o que false significava. A solução: pré/post-condições curtas junto aos métodos e tipos de retorno que expressem o sentido.

Erro nº2: um método faz três ações diferentes ao mesmo tempo.
Quando um método "parseia, valida e persiste" ao mesmo tempo, ele deixa de ser um ponto único de contrato: muitos cenários de sucesso/falha. Começa o caos sobre o que exatamente é garantido. É melhor dividir em operações pequenas ou, ao menos, em operações com um único propósito, para que o contrato caiba em uma ou duas frases.

Erro nº3: diferentes implementações tratam "falha" de forma distinta.
Por exemplo, uma implementação de remove(id) retorna false se não encontrou, e outra retorna true pensando "pois bem, no fim das contas o elemento não existe". Logicamente parecido, mas contratualmente — desastre: o chamador não consegue trabalhar igualmente com ambas. É preciso escolher um significado e fixá-lo como pós-condição.

Erro nº4: a assinatura diz uma coisa e a realidade faz outra.
Clássico: método marcado como const, mas internamente altera dados importantes, e o comportamento do objeto muda dependendo de quantas vezes você o leu. Formalmente isso pode ser feito com mutable, mas o contrato fica turvo. Se o método realmente altera estado observável, não deve ser const.

Erro nº5: usar string_view como "armazenar para sempre".
std::string_view é para "ver", não para "possuir". Se a implementação guarda um string_view dentro do objeto, o contrato de tempo de vida vira uma mina: chamou com uma string temporária — e pronto, dangling reference. Na interface, string_view é bom para parâmetros, mas dentro da implementação geralmente é necessário copiar para std::string se você armazena dados.

1
Tarefa
C++ SELF, nível 52, lição 1
Bloqueado
Contador do elevador
Contador do elevador
1
Tarefa
C++ SELF, nível 52, lição 1
Bloqueado
Validação de login
Validação de login
1
Tarefa
C++ SELF, nível 52, lição 1
Bloqueado
Dicionário de comandos
Dicionário de comandos
1
Tarefa
C++ SELF, nível 52, lição 1
Bloqueado
Armazenamento de notas
Armazenamento de notas
Comentários
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION