Ensinando Símbolos ao seu Tipo — Sobrecarga de Operadores

Ensinando Símbolos ao seu Tipo — Sobrecarga de Operadores

Somar com mais, comparar com igual, imprimir direto no cout: operadores são funções com nome esquisito, e ensiná-los ao seu tipo é o que o faz parecer nativo. O artigo trata da mecânica, do caso do operador de saída que precisa ser função livre, do presente do C++20 e do bom senso que evita abuso.
Linguagem C++

11 min de leitura

Chegamos ao artigo que fecha a Fase 3. No artigo Contratos e Destruição Segura — Classes Abstratas e Destrutores Virtuais completamos a orientação a objetos com classes abstratas e destrutores virtuais. Agora damos aos nossos tipos um poder que, até aqui, só os tipos embutidos e alguns da biblioteca (como std::string) tinham: responder aos operadores da linguagem. Você já usou a + b com strings, c1 == c2 para comparar, std::cout << x para imprimir — e talvez tenha se perguntado como o + "sabe" concatenar strings. A resposta é a sobrecarga de operadores, que nada mais é do que a sobrecarga de funções do artigo Um Nome, Vários Sentidos — Sobrecarga de Funções aplicada aos símbolos. Hoje você aprende a fazer seus tipos somarem, compararem e se imprimirem naturalmente — com a honestidade de sempre sobre onde isso ilumina o código e onde vira abuso.

Operadores são funções com nome esquisito

A ideia-chave, que desmistifica tudo: em C++, um operador como + é apenas uma função com um nome especial, operator+. Quando você escreve a + b, o compilador procura uma função operator+ que aceite os tipos de a e b. Para tipos embutidos, ela é interna; para os seus, você a fornece. Vamos dar a uma classe Vetor2D a capacidade de somar:

#include <iostream>

class Vetor2D {
public:
    Vetor2D(double x, double y) : x_(x), y_(y) {}

    // operator+ como MÉTODO: 'this' é o operando à esquerda, 'outro' o à direita.
    // Recebe por const& (não modifica, não copia) e devolve um novo Vetor2D.
    Vetor2D operator+(const Vetor2D& outro) const {
        return Vetor2D(x_ + outro.x_, y_ + outro.y_);
    }

    double x() const { return x_; }
    double y() const { return y_; }
private:
    double x_, y_;
};

int main() {
    Vetor2D a(1.0, 2.0);
    Vetor2D b(3.0, 4.0);
    Vetor2D c = a + b;   // chama a.operator+(b)
    std::cout << c.x() << ", " << c.y() << '\n';   // 4, 6
    return 0;
}

a + b é açúcar sintático para a.operator+(b). Note as decisões de projeto que já são reflexo seu: o parâmetro é const Vetor2D& (artigos Referências e Ponteiros, Frente a Frente, A Disciplina do const — Promessas que o Compilador Cobra), o método é const porque somar não altera o operando esquerdo (Projetando Abstrações), e retornamos um objeto novo por valor — que, graças a move semantics (Quando Copiar Dá Errado), é barato. Toda a bagagem do curso converge aqui.

Imprimir com <<: o operador que precisa ser função livre

Para fazer std::cout << meu_objeto funcionar, sobrecarregamos operator<<. Aqui há uma sutileza importante: o operando à esquerda é o stream (std::ostream), não o seu objeto. Portanto operator<< não pode ser um método da sua classe (isso o tornaria membro do seu tipo, com this à esquerda); precisa ser uma função livre:

#include <iostream>

class Vetor2D {
public:
    Vetor2D(double x, double y) : x_(x), y_(y) {}
    double x() const { return x_; }
    double y() const { return y_; }
private:
    double x_, y_;
};

// operator<< como função LIVRE: à esquerda o stream, à direita o objeto.
// Recebe o stream por referência (para modificá-lo) e o devolve, para encadear.
std::ostream& operator<<(std::ostream& os, const Vetor2D& v) {
    os << "(" << v.x() << ", " << v.y() << ")";
    return os;   // devolver o stream permite: cout << v1 << v2 << '\n';
}

int main() {
    Vetor2D v(3.0, 4.0);
    std::cout << "vetor = " << v << '\n';   // vetor = (3, 4)
    return 0;
}

Dois pontos: o retorno é std::ostream& para permitir encadear (cout << a << b), o mesmo truque do return *this que vimos em interfaces fluentes (Projetando Abstrações); e como aqui usei os getters públicos, operator<< não precisou ser friend. Quando o operador livre precisa acessar membros privados, declara-se friend dentro da classe — um mecanismo que concede acesso privado a uma função externa específica, útil e a ser usado com parcimônia.

Comparações e o presente do C++20

Comparar objetos com ==, <, etc., é outro uso natural. Antes do C++20, você escrevia cada operador de comparação à mão — seis funções (==, !=, <, <=, >, >=), tedioso e propenso a inconsistências. O C++20 trouxe uma dádiva: o operador de comparação de três vias <=> (apelidado "spaceship") e a capacidade de pedir os operadores com = default, gerando todos de uma vez de forma consistente:

#include <iostream>
#include <compare>   // necessário para <=> com = default

class Versao {
public:
    Versao(int maior, int menor) : maior_(maior), menor_(menor) {}

    // Uma linha gera ==, !=, <, <=, >, >= de forma consistente (C++20).
    // A comparação segue a ordem de declaração dos membros: primeiro maior_, depois menor_.
    auto operator<=>(const Versao&) const = default;
    bool operator==(const Versao&) const = default;

private:
    int maior_, menor_;
};

int main() {
    Versao v1(2, 5), v2(2, 8);
    std::cout << (v1 < v2)  << '\n';   // 1 (2.5 < 2.8)
    std::cout << (v1 == v2) << '\n';   // 0
    std::cout << (v1 != v2) << '\n';   // 1
    return 0;
}

Isto é C++20 — sempre digo a versão. Antes dele, você escreveria pelo menos operator== e operator< à mão. O <=> = default compara os membros na ordem de declaração, exatamente a ordem que estudamos no artigo A Vida Começa Aqui — Inicialização de Membros a Fundo, agora servindo à comparação. É um dos recursos modernos que mais reduzem código repetitivo.

A honestidade: sobrecarregar com bom senso

Prometo sempre o trade-off, e com operadores ele é sobre legibilidade, não desempenho. A regra de ouro: sobrecarregue um operador apenas quando o significado for óbvio e natural. Somar dois vetores com + é claro. Concatenar strings com + é claro. Mas sobrecarregar + para "adicionar um funcionário a um departamento" é confuso — ninguém adivinha o que departamento + funcionario faz, e um método bem-nomeado como departamento.contrata(funcionario) comunica muito melhor. O critério: se um leitor que nunca viu seu código consegue prever o que o operador faz, sobrecarregue; se ele teria de consultar a documentação, use um método com nome. Operadores existem para tornar o óbvio conciso, não para tornar o obscuro enigmático.

Sobrecarregar operadores existe para que um tipo próprio se comporte como os embutidos — somar com +, comparar com ==, sair em std::cout com <<. O critério é a única parte difícil: o símbolo precisa significar no seu tipo o que significa em todo lugar, senão o código fica conciso e ilegível ao mesmo tempo. O << ilustra a mecânica com clareza — ele é função livre porque o operando da esquerda é o stream, não o seu objeto.

Com isto se encerra a orientação a objetos, e o ganho maior não é sintaxe: é juízo de projeto. Saber quando uma classe ajuda e quando atrapalha, quando herança é a ferramenta certa e quando composição resolve melhor, o que deve ser público e por quê. São decisões que a linguagem permite tomar de vários jeitos e cobra depois, quando o código precisa mudar.

Fontes e leituras recomendadas

  • Bjarne Stroustrup, A Tour of C++ (3ª ed.), seção sobre operadores: a visão do criador sobre quando sobrecarregar.
  • cppreference.com/w/cpp/language/operators: a referência técnica completa, listando quais operadores podem ser sobrecarregados e como.
  • cppreference.com/w/cpp/language/default_comparisons: os detalhes do operator<=> e das comparações padronizadas do C++20.
  • Scott Meyers, Effective C++ (3ª ed.), Item 23 ("Prefer non-member non-friend functions") e Item 24: orientações sobre operadores como membros vs funções livres.
  • ISO C++ Core Guidelines, seção "C.over" (Overloading), regras C.160 ("Define operators primarily to mimic conventional usage") e C.161: as diretrizes sobre sobrecarregar com bom senso.

Exercícios

Exercício 1

Dê à classe Vetor2D da aula um operator- (subtração) e um operator* que multiplique o vetor por um escalar (double). Demonstre os dois.

Ver resposta

✓ Resposta: Subtração e multiplicação por escalar:

#include <iostream>
class Vetor2D {
public:
    Vetor2D(double x, double y) : x_(x), y_(y) {}
    Vetor2D operator-(const Vetor2D& o) const { return Vetor2D(x_ - o.x_, y_ - o.y_); }
    Vetor2D operator*(double k) const { return Vetor2D(x_ * k, y_ * k); }  // vetor * escalar
    double x() const { return x_; }
    double y() const { return y_; }
private:
    double x_, y_;
};
int main() {
    Vetor2D a(5, 6), b(1, 2);
    Vetor2D d = a - b;      // (4, 4)
    Vetor2D e = a * 2.0;    // (10, 12)
    std::cout << d.x() << "," << d.y() << "  " << e.x() << "," << e.y() << '\n';
    return 0;
}

Exercício 2

Escreva uma classe Dinheiro (com centavos como int) e sobrecarregue operator+ e operator<< para que Dinheiro a + b e std::cout << a funcionem, imprimindo no formato "R$ X,YY".

Ver resposta

✓ Resposta: A classe Dinheiro:

#include <iostream>
class Dinheiro {
public:
    Dinheiro(int centavos) : centavos_(centavos) {}
    Dinheiro operator+(const Dinheiro& o) const { return Dinheiro(centavos_ + o.centavos_); }
    int centavos() const { return centavos_; }
private:
    int centavos_;
};
std::ostream& operator<<(std::ostream& os, const Dinheiro& d) {
    os << "R$ " << d.centavos() / 100 << ","
       << (d.centavos() % 100 < 10 ? "0" : "") << d.centavos() % 100;
    return os;
}
int main() {
    Dinheiro a(1250), b(375);   // R$ 12,50 e R$ 3,75
    std::cout << a + b << '\n'; // R$ 16,25
    return 0;
}

O << cuida de imprimir os centavos com zero à esquerda quando menores que 10.

Exercício 3

Explique por que operator<< para impressão precisa ser uma função livre e não pode ser um método da sua classe. O que estaria "à esquerda" se fosse método?

Ver resposta

✓ Resposta: operator<< precisa ser função livre porque o operando à esquerda de std::cout << obj é o stream (std::ostream), não o seu objeto. Se fosse um método da sua classe, o operando à esquerda seria o seu objeto (this), e a chamada teria de ser obj << std::cout — o inverso do que se escreve. Como você não pode adicionar métodos à classe std::ostream (ela não é sua), a única forma de ter o stream à esquerda é uma função livre operator<<(std::ostream&, const SeuTipo&).

Exercício 4

Usando C++20, dê à classe Dinheiro do exercício 2 os operadores de comparação com uma única linha = default. Depois explique, com base na ordem de membros, o que aconteceria se Dinheiro tivesse dois membros (reais e centavos) declarados nessa ordem.

Ver resposta

✓ Resposta: Com C++20:

#include <compare>
class Dinheiro {
public:
    Dinheiro(int c) : centavos_(c) {}
    auto operator<=>(const Dinheiro&) const = default;
    bool operator==(const Dinheiro&) const = default;
private:
    int centavos_;
};

Se Dinheiro tivesse dois membros int reais e int centavos declarados nessa ordem, o <=> padrão compararia primeiro reais, depois centavos — a ordem de declaração. Isso daria a comparação correta para dinheiro (R$ 5,00 > R$ 4,99 porque 5 > 4 no primeiro membro). Se a ordem de declaração fosse invertida (centavos antes de reais), a comparação ficaria errada, comparando centavos antes de reais — um lembrete de que a ordem de declaração dos membros tem consequências reais, como vimos no artigo A Vida Começa Aqui — Inicialização de Membros a Fundo.

Exercício 5

Critique o seguinte design: uma classe Playlist que sobrecarrega operator+ para adicionar uma música e operator- para removê-la. Isso é bom uso de sobrecarga? Proponha uma alternativa e justifique com a regra de ouro da aula.

Ver resposta

✓ Resposta: O design é ruim. playlist + musica e playlist - musica não têm significado óbvio: + universalmente sugere soma/combinação de coisas do mesmo tipo, não "inserir um elemento numa coleção"; um leitor não adivinha se - musica remove a primeira ocorrência, todas, ou por índice. Alternativa clara:

class Playlist {
public:
    void adiciona(const Musica& m);
    void remove(const Musica& m);
};
// playlist.adiciona(m);  playlist.remove(m);  — intenção explícita

Pela regra de ouro da aula, um operador só se justifica quando seu efeito é previsível sem consulta à documentação. adiciona/remove comunicam exatamente o que fazem; +/- sobre uma playlist exigiriam explicação — logo, métodos nomeados são superiores aqui. Operadores servem ao óbvio, não ao arbitrário.

Comentários

Mais em Linguagem C++

Operações Indivisíveis — std::atomic
Operações Indivisíveis — std::atomic

Quando basta um contador ou uma flag, o mutex é peso desnecessário: o atomic…

Quando o Contrato se Quebra — Exceções e a Aliança com o RAII
Quando o Contrato se Quebra — Exceções e a Aliança com o RAII

Lançar uma exceção separa o caminho do erro do caminho normal, e é o RAII que…

Extensão — Dados que Sobrevivem: Integrando SQLite com RAII
Extensão — Dados que Sobrevivem: Integrando SQLite com RAII

Envolver a API C do SQLite numa classe é o exercício de RAII mais próximo do…