Projetando Abstrações — Encapsulamento, Invariantes e o Ponteiro this

Projetando Abstrações — Encapsulamento, Invariantes e o Ponteiro this

A palavra class não é o que distingue um tipo projetado de um punhado de campos: o que distingue é haver um invariante a proteger. Este artigo trata do encapsulamento como consequência disso, do construtor como porta única de entrada, do ponteiro this e da tentação de gerar getter e setter para tudo.
Linguagem C++

12 min de leitura

Fechamos a Fase 2 sabendo gerenciar recursos com maestria. Mas até aqui nossas classes foram embrulhos modestos — um arquivo, um buffer, um ponteiro inteligente por dentro. A partir de agora subimos um degrau: vamos projetar abstrações de verdade, tipos que não apenas guardam dados, mas garantem que esses dados façam sempre sentido. Essa é a promessa central da orientação a objetos em C++, e ela começa com uma palavra que você conhece de struct no C mas que ganha músculos aqui: encapsulamento. Ao fim desta aula, você entenderá por que esconder dados é um ato de projeto, não de paranoia, e conhecerá o ponteiro this, que responde à pergunta "de qual objeto estamos falando?".

O que muda de struct para class

No C, uma struct é transparente: qualquer um mexe em qualquer campo, a qualquer hora. Isso é liberdade e é perigo. Considere uma fração — numerador e denominador. Nada em C impede alguém de zerar o denominador e detonar a próxima divisão. Em C++, uma class pode proteger seus dados, expondo só o que é seguro. A diferença técnica entre struct e class é mínima (visibilidade padrão), mas a intenção idiomática é clara: use class quando o tipo tem invariantes a proteger.

#include <iostream>

class Fracao {
public:
    // Construtor: única porta de entrada. Garante o invariante desde o nascimento.
    Fracao(int numerador, int denominador)
        : num_(numerador), den_(denominador) {
        if (den_ == 0) {
            den_ = 1;   // política simples: denominador nulo vira 1 (evita divisão por zero)
            std::cout << "aviso: denominador zero corrigido para 1\n";
        }
    }

    double valor() const { return static_cast<double>(num_) / den_; }

    void mostra() const {
        std::cout << num_ << "/" << den_ << '\n';
    }

private:
    int num_;   // escondido: ninguém de fora mexe direto
    int den_;   // escondido: protegido contra virar zero
};

int main() {
    Fracao f(3, 4);
    f.mostra();                       // 3/4
    std::cout << f.valor() << '\n';   // 0.75

    // f.den_ = 0;   // ERRO de compilação: den_ é privado. O invariante está a salvo.
    Fracao ruim(1, 0);               // o construtor corrige para 1
    ruim.mostra();                   // 1/1
    return 0;
}

Os membros sob private: são invisíveis fora da classe. Toda manipulação passa pela interface public: — o construtor e os métodos —, e é aí que a classe impõe suas regras. den_ nunca será zero, porque o único caminho para defini-lo (o construtor) checa. Essa garantia que a classe mantém sobre seu próprio estado tem um nome que você vai ouvir muito: invariante.

Invariantes: a razão de ser do encapsulamento

Um invariante é uma verdade que sempre vale sobre um objeto, do nascimento à morte. "O denominador nunca é zero", "o tamanho nunca é negativo", "o saldo nunca fica abaixo do limite". Encapsular não é esconder por esconder; é criar uma fronteira onde essas verdades são estabelecidas (no construtor) e mantidas (nos métodos). Se qualquer código pudesse alterar os campos diretamente, nenhum invariante seria confiável — bastaria um deslize em qualquer canto do programa para quebrá-lo. Ao trancar os dados como private e obrigar todo acesso a passar por métodos que respeitam as regras, você concentra a responsabilidade num só lugar. É a diferença entre "espero que ninguém estrague isto" e "é impossível estragar isto de fora".

Getters, setters e a tentação de expor tudo

O padrão comum de dar acesso controlado é o par getter/setter: métodos que leem e escrevem um campo com validação. Mas há uma armadilha honesta aqui, e vou apontá-la de saída: um setter que simplesmente atribui sem checar nada é apenas um campo público disfarçado — deu trabalho e não protegeu invariante nenhum. O bom setter valida:

#include <iostream>

class Termostato {
public:
    Termostato() : temp_(20) {}   // começa numa temperatura segura

    int temperatura() const { return temp_; }   // getter: só lê

    // Setter COM validação: mantém o invariante 10 <= temp <= 30.
    void ajustar(int nova) {
        if (nova < 10)      temp_ = 10;
        else if (nova > 30) temp_ = 30;
        else                temp_ = nova;
    }

private:
    int temp_;   // invariante: sempre entre 10 e 30
};

int main() {
    Termostato t;
    t.ajustar(100);                          // clampeado para 30
    std::cout << t.temperatura() << '\n';    // 30
    t.ajustar(22);
    std::cout << t.temperatura() << '\n';    // 22
    return 0;
}

Repare no const do getter — a disciplina do artigo A Disciplina do const — Promessas que o Compilador Cobra aplicada a métodos: temperatura() const promete não modificar o objeto, então pode ser chamado até sobre um Termostato constante. Adote isso como reflexo: todo método que só lê o objeto leva const.

O ponteiro this: quem sou eu?

Quando você chama t.ajustar(22), o método precisa saber sobre qual objeto agir — o t, e não outro Termostato qualquer. O C++ passa, invisível, um ponteiro para o objeto atual chamado this. Dentro de qualquer método, this aponta para o objeto sobre o qual o método foi invocado, e temp_ é, na verdade, this->temp_. Quase sempre o this fica implícito, mas ele aparece explicitamente em situações reais. Uma clássica: quando o parâmetro tem o mesmo nome do membro.

#include <iostream>

class Contador {
public:
    Contador(int valor) {
        // Parâmetro 'valor' e membro 'valor_' — sem ambiguidade aqui por causa do _.
        // Mas se o membro se chamasse 'valor', precisaríamos de this->valor = valor;
        valor_ = valor;
    }

    // Retornar *this permite ENCADEAR chamadas: c.incrementa().incrementa();
    Contador& incrementa() {
        ++valor_;
        return *this;   // devolve referência ao próprio objeto
    }

    int atual() const { return valor_; }

private:
    int valor_;
};

int main() {
    Contador c(0);
    c.incrementa().incrementa().incrementa();   // encadeamento graças ao *this
    std::cout << c.atual() << '\n';             // 3
    return 0;
}

Dois usos de this que você verá o tempo todo: desambiguar nomes (this->membro = membro;) e devolver *this para permitir encadeamento de métodos — o mesmo mecanismo que fez operator= retornar *this no artigo Quando Copiar Dá Errado — a Regra dos Três e dos Cinco. Você já tinha visto this de relance ali; agora sabe o que ele é. Por convenção, este curso usa o sufixo _ nos membros (temp_, num_) justamente para evitar a colisão de nomes e tornar o this-> quase nunca necessário — mas é bom reconhecê-lo, porque muito código por aí não segue essa convenção.

Juntando tudo: uma abstração completa

Reunindo encapsulamento, invariante, validação, const e this, eis uma pequena classe ContaBancaria que protege um invariante real — o saldo nunca fica negativo — no espírito de "cada artigo é o curso em miniatura":

#include <iostream>
#include <string>

class ContaBancaria {
public:
    ContaBancaria(std::string titular, int saldo_inicial)
        : titular_(std::move(titular)), saldo_(saldo_inicial < 0 ? 0 : saldo_inicial) {}

    // Deposita: só aceita valores positivos (mantém a coerência).
    ContaBancaria& depositar(int valor) {
        if (valor > 0) saldo_ += valor;
        return *this;   // permite encadear: conta.depositar(10).depositar(5);
    }

    // Saca: respeita o invariante "saldo nunca negativo".
    bool sacar(int valor) {
        if (valor > 0 && valor <= saldo_) {
            saldo_ -= valor;
            return true;
        }
        return false;   // recusa saque inválido em vez de quebrar o invariante
    }

    int saldo() const { return saldo_; }               // getter só-leitura
    const std::string& titular() const { return titular_; }

private:
    std::string titular_;   // std::string membro → Regra do Zero (Artigo Quando Copiar Dá Errado)
    int saldo_;             // invariante: saldo_ >= 0
};

int main() {
    ContaBancaria conta("Ana", 100);
    conta.depositar(50).depositar(25);          // encadeado
    std::cout << conta.titular() << ": " << conta.saldo() << '\n';  // Ana: 175

    if (!conta.sacar(1000))
        std::cout << "saque recusado; saldo preservado: " << conta.saldo() << '\n';
    return 0;
}

Note como todas as lições convivem: titular_ é um std::string, então a classe herda cópia/movimento corretos de graça (Regra do Zero); os getters são const; depositar devolve *this para encadear; e o invariante "saldo não-negativo" é impossível de violar de fora. Isso é projetar uma abstração: a classe é a guardiã das suas próprias regras.

O que separa uma class de um agregado de dados não é a palavra-chave, é a existência de um invariante — uma afirmação que precisa valer durante toda a vida do objeto e que ninguém de fora pode quebrar. É isso que dá sentido ao encapsulamento: o construtor estabelece o invariante, os métodos o preservam, e o privado impede que alguém o viole por engano. Onde não há invariante a defender, getter e setter para cada campo apenas reescrevem uma struct com mais cerimônia.

Fontes e leituras recomendadas

  • Bjarne Stroustrup, A Tour of C++ (3ª ed.), capítulo sobre classes: a apresentação do encapsulamento e da distinção struct/class pelo criador da linguagem.
  • cppreference.com/w/cpp/language/access: a referência técnica sobre public, private e protected, com as regras precisas de visibilidade.
  • cppreference.com/w/cpp/language/this: a especificação do ponteiro this, seus usos e seu tipo em métodos const.
  • ISO C++ Core Guidelines, seção "C" (Classes), regras C.2 ("Use class if the class has an invariant") e C.9 ("Minimize exposure of members"): as diretrizes que justificam tudo o que vimos hoje.
  • Scott Meyers, Effective C++ (3ª ed.), Item 22 ("Declare data members private"): o argumento clássico a favor do encapsulamento estrito.

Exercícios

Exercício 1

Escreva uma classe Retangulo com largura e altura privadas, um construtor que rejeite valores negativos (tratando-os como zero), e métodos const area() e perimetro(). Demonstre no main.

Ver resposta

✓ Resposta: A classe:

#include <iostream>
class Retangulo {
public:
    Retangulo(int largura, int altura)
        : largura_(largura < 0 ? 0 : largura),
          altura_(altura  < 0 ? 0 : altura) {}
    int area()      const { return largura_ * altura_; }
    int perimetro() const { return 2 * (largura_ + altura_); }
private:
    int largura_;
    int altura_;
};
int main() {
    Retangulo r(3, 4);
    std::cout << "área: " << r.area() << ", perímetro: " << r.perimetro() << '\n';
    Retangulo degenerado(-5, 10);   // largura vira 0
    std::cout << "área degenerada: " << degenerado.area() << '\n';  // 0
    return 0;
}

O construtor é a porta única: os valores negativos são corrigidos ali, e os getters const só leem.

Exercício 2

A classe abaixo expõe um setter inútil. Explique por que ele não protege invariante algum e reescreva-o para garantir que idade_ fique entre 0 e 150.

class Pessoa {
public:
    void set_idade(int i) { idade_ = i; }   // por que isto é só um campo público disfarçado?
    int  idade() const { return idade_; }
private:
    int idade_;
};
Ver resposta

✓ Resposta: O setter set_idade(int i) { idade_ = i; } não valida nada: aceita idades negativas ou absurdas, exatamente como se idade_ fosse público — deu o trabalho de escrever um método sem ganhar proteção nenhuma. A versão que protege o invariante:

class Pessoa {
public:
    void set_idade(int i) {
        if (i < 0)        idade_ = 0;
        else if (i > 150) idade_ = 150;
        else              idade_ = i;
    }
    int idade() const { return idade_; }
private:
    int idade_ = 0;   // invariante: 0 <= idade_ <= 150
};

Agora o método faz jus à sua existência: nenhuma idade fora da faixa entra no objeto.

Exercício 3

Explique o papel do return *this; no método depositar da classe ContaBancaria. O que deixaria de funcionar se o método retornasse void?

Ver resposta

✓ Resposta: return *this; devolve uma referência ao próprio objeto sobre o qual depositar foi chamado, o que permite encadear chamadas: conta.depositar(50).depositar(25) — o resultado da primeira depositar é a própria conta, sobre a qual a segunda opera. Se o método retornasse void, conta.depositar(50).depositar(25) não compilaria, porque void não tem método depositar; você teria de escrever duas instruções separadas. O *this é o que viabiliza a interface fluente.

Exercício 4

Dada a classe Contador da aula, adicione um método decrementa() que também encadeia e que respeite o invariante "o valor nunca fica abaixo de zero". Demonstre um encadeamento misto de incrementa() e decrementa().

Ver resposta

✓ Resposta: Com o invariante de não ficar negativo:

#include <iostream>
class Contador {
public:
    Contador(int valor) : valor_(valor < 0 ? 0 : valor) {}
    Contador& incrementa() { ++valor_; return *this; }
    Contador& decrementa() {
        if (valor_ > 0) --valor_;   // respeita "nunca abaixo de zero"
        return *this;
    }
    int atual() const { return valor_; }
private:
    int valor_;
};
int main() {
    Contador c(1);
    c.incrementa().incrementa().decrementa().decrementa().decrementa();
    // 1 → 2 → 3 → 2 → 1 → 0 (o último decrementa não passa de 0)
    std::cout << c.atual() << '\n';   // 0
    return 0;
}

Ambos retornam Contador&, então encadeiam livremente, e decrementa protege o piso zero.

Exercício 5

Projete uma classe Estoque que mantenha o invariante "quantidade sempre entre 0 e uma capacidade máxima fixada na construção". Ofereça adicionar(int) e remover(int) que respeitem os limites (sem estourar nem ficar negativo) e um getter const. Explique qual é o invariante e onde exatamente ele é protegido.

Ver resposta

✓ Resposta: A classe:

#include <iostream>
class Estoque {
public:
    Estoque(int capacidade, int inicial = 0)
        : capacidade_(capacidade < 0 ? 0 : capacidade),
          quantidade_(0) {
        adicionar(inicial);   // reusa a lógica validada em vez de atribuir cru
    }

    void adicionar(int n) {
        if (n <= 0) return;
        if (quantidade_ + n > capacidade_) quantidade_ = capacidade_;  // não estoura
        else                               quantidade_ += n;
    }
    void remover(int n) {
        if (n <= 0) return;
        if (n > quantidade_) quantidade_ = 0;   // não fica negativo
        else                 quantidade_ -= n;
    }
    int quantidade() const { return quantidade_; }
    int capacidade() const { return capacidade_; }

private:
    int capacidade_;   // fixada na construção
    int quantidade_;   // invariante: 0 <= quantidade_ <= capacidade_
};

int main() {
    Estoque e(100, 30);
    e.adicionar(90);                                  // 30+90=120 > 100 → 100
    std::cout << e.quantidade() << '\n';              // 100
    e.remover(150);                                   // não fica negativo → 0
    std::cout << e.quantidade() << '\n';              // 0
    return 0;
}

O invariante é 0 <= quantidade_ <= capacidade_. Ele é protegido em três lugares que constituem toda a superfície de escrita do objeto: o construtor (que delega para adicionar, reaproveitando a validação em vez de duplicá-la), o adicionar (que impede ultrapassar a capacidade) e o remover (que impede ficar negativo). Como não há como escrever em quantidade_ de fora, o invariante é inquebrável.

Comentários

Mais em Linguagem C++

Pare de Escrever Laços — a Biblioteca <algorithm>
Pare de Escrever Laços — a Biblioteca <algorithm>

Escrever o laço à mão conta como percorrer; chamar o algoritmo diz o que se…

Um Nome, Vários Sentidos — Sobrecarga de Funções
Um Nome, Vários Sentidos — Sobrecarga de Funções

Ter três nomes para valor absoluto não era capricho da biblioteca do C: dois…

Adeus NULL, Bem-vindo à Dedução — nullptr e auto
Adeus NULL, Bem-vindo à Dedução — nullptr e auto

Dois recursos pequenos em aparência e presentes em quase toda linha de C++…