Pular para conteúdo

9. Classes, encapsulamento e construtores

Classe e objeto: a planta e a casa

Antes de escrever a primeira class, vale fixar o vocabulário — porque ele vai aparecer em toda mensagem de erro do compilador daqui para frente. E a boa notícia é que você já usa tudo isso desde o capítulo 1, só não com esses nomes.

std::string nome = "Ana";
std::cout << nome.size() << "\n";

Nessas duas linhas:

  • std::string é uma classe — um tipo que alguém escreveu, com dados e operações juntos.
  • nome é um objeto dessa classe: uma variável concreta, com um valor concreto, ocupando um lugar na memória.
  • .size() é um método — uma função que pertence à classe e é chamada através de um objeto.
  • E nome guarda, por dentro, os campos de que a std::string precisa para funcionar (um ponteiro para os caracteres, um tamanho, uma capacidade). Você nunca viu esses campos, nunca precisou vê-los, e é exatamente esse o ponto.

A imagem que costuma ajudar: a classe é a planta da casa; o objeto é a casa construída. A planta é desenhada uma vez e diz quais são os quartos; as casas são muitas, e cada uma tem os seus próprios móveis. Escrever class Conta { ... } não cria conta nenhuma — só descreve como uma conta é. Cada ContaBancaria c("Ana", 100.0); é que constrói uma casa.

Termo O que é No exemplo acima
Classe o tipo, a planta std::string
Objeto (ou instância) uma variável daquele tipo, a casa nome
Membro qualquer coisa declarada dentro da classe size, e os campos internos
Campo (ou atributo) membro que guarda dado o tamanho guardado pela string
Método membro que é função size()

Instância é só um sinônimo de objeto: "nome é uma instância de std::string" quer dizer "nome é um objeto da classe std::string".

De struct para class: o que muda

Você já vem usando struct com métodos desde a Unidade 1 — Retangulo, Aluno, Produto. Então, na prática, você já escreveu classes: tecnicamente, em C++, struct e class são a mesma coisa, com uma única diferença — os membros de struct são public por padrão, e os de class são private por padrão.

struct Ponto {        // campos publicos por padrao
    double x, y;
};

class Ponto2 {        // campos privados por padrao
    double x, y;      // ninguem de fora enxerga x e y
};

public significa "qualquer código pode acessar"; private, "só os métodos da própria classe podem acessar". São esses dois rótulos que carregam toda a diferença prática entre struct e class, e é sobre eles que o resto do capítulo fala.

Convenção do curso (e da maior parte do código C++ real): use struct para agrupar dados simples, sem nenhuma regra a proteger (como Ponto, ou o Aluno das listas anteriores). Use class quando o tipo tem um invariante — uma regra que precisa ser sempre verdadeira, e que código externo não deveria poder quebrar.

Encapsulamento: por que esconder os campos

Considere uma conta bancária representada como struct. A regra do domínio é simples: saldo nunca é negativo.

struct ContaBancaria {
    double saldo;
};

ContaBancaria c{100.0};
c.saldo = -50.0;

Pare e pense

Essa última linha compila? E se compilar, quem no programa vai impedir que ela aconteça?

Compila sem um aviso sequer. Nada — nem o compilador, nem a struct — impede código em qualquer lugar do programa de atribuir um saldo negativo direto no campo, mesmo que a regra de negócio diga o contrário. O campo está exposto, e uma regra que depende da boa vontade de todo mundo que toca no dado não é uma regra: basta um c.saldo = ... esquecido num canto do sistema para o invariante cair.

Com class, os campos ficam private por padrão, e o acesso passa a ser controlado por métodos public:

class ContaBancaria {
private:
    double saldo_;

public:
    void deposita(double valor) {
        if (valor > 0) {
            saldo_ += valor;
        }
    }

    bool saca(double valor) {
        if (valor > 0 && valor <= saldo_) {
            saldo_ -= valor;
            return true;
        }
        return false;   // saque invalido: nao mexe no saldo
    }

    double saldo() const {
        return saldo_;
    }
};

Agora saldo_ é inacessível de fora da classe — a única forma de alterá-lo é através de deposita/saca, que garantem a regra "saldo nunca fica negativo". Isso é encapsulamento: a classe é responsável por proteger seu próprio invariante, em vez de confiar que todo código externo vai se comportar direito. E repare no ganho de manutenção: se amanhã a regra mudar (passa a existir limite de crédito, por exemplo), existe um lugar no programa para mexer.

Note também que só há dois rótulos no corpo da classe, e que tudo o que vem depois de um deles obedece a ele até o próximo: private: vale para saldo_, public: vale para os três métodos abaixo. Você pode repetir os rótulos quantas vezes quiser; a convenção é agrupar, como acima.

Convenção de nome: um sufixo _ (saldo_) para campos privados é comum em C++ e evita colisão com o nome do getter (saldo()).

Invariante não é assunto só de banco

Uma Fracao cujo denominador nunca pode ser zero, uma Data em que o mês fica entre 1 e 12, um Termometro que só aceita leituras acima do zero absoluto: todos têm invariante, e todos são bons candidatos a class. A pergunta que decide é sempre a mesma — existe algum valor dos meus campos que seria absurdo? Se existe, esconda os campos.

this: o objeto em que o método foi chamado

Dentro de deposita, escrevemos saldo_ += valor. Mas de qual objeto é esse saldo_? Se existem três contas no programa, cada uma com seu próprio saldo_, qual delas o método altera?

A resposta é: aquela através da qual o método foi chamado. Em conta_da_ana.deposita(50), o saldo_ dentro do método é o saldo_ da conta_da_ana. C++ passa esse objeto para o método por baixo dos panos, num ponteiro chamado this — e saldo_, dentro de um método, é apenas um atalho para this->saldo_.

void deposita(double valor) {
    if (valor > 0) {
        this->saldo_ += valor;   // exatamente o mesmo que 'saldo_ += valor'
    }
}

Na prática você quase nunca escreve this->, porque o atalho é mais limpo. O caso em que ele se torna obrigatório é quando um parâmetro tem o mesmo nome do campo — aí o nome sozinho se refere ao parâmetro, e só this-> alcança o campo:

void define_saldo(double saldo_) {
    this->saldo_ = saldo_;   // à esquerda o campo, à direita o parametro
}

(É justamente para não precisar desse malabarismo que a convenção do sufixo _ nos campos existe.) Vale conhecer o this por um segundo motivo, bem prático: ele aparece com esse nome nas mensagens de erro do compilador, como você vai ver na seção de erros comuns no fim do capítulo.

Getters e setters

Um getter lê um campo privado; um setter o altera, geralmente validando. Na nossa conta, saldo() já é um getter. Faltava o titular:

class ContaBancaria {
private:
    std::string titular_;
    double saldo_;

public:
    std::string titular() const { return titular_; }   // getter
    double saldo() const { return saldo_; }            // getter

    void define_titular(const std::string& novo_titular) {   // setter, com validacao
        if (!novo_titular.empty()) {
            titular_ = novo_titular;
        }
    }

    // ... deposita/saca como antes
};

Repare que não existe um define_saldo. Não é esquecimento: o saldo só pode mudar por depósito ou saque, e expor um setter para ele jogaria fora todo o encapsulamento que acabamos de construir — seria o c.saldo = -50.0 de volta, agora com um nome mais respeitável.

É esse o recado da seção: getters e setters não são "obrigatórios para toda classe", e uma classe que oferece um par deles para cada campo é uma struct com passos a mais. Ofereça o que o design pedir, com a validação que cada campo pedir; a ausência de um método também é uma decisão de projeto.

Construtores

Um construtor é um método especial, com o mesmo nome da classe e sem tipo de retorno, chamado automaticamente quando um objeto é criado. Ele garante que o objeto nasce já em um estado válido — a primeira linha de defesa do invariante. Sem ele, os métodos protegem a conta a partir do segundo instante de vida, mas o primeiro fica descoberto.

class ContaBancaria {
private:
    std::string titular_;
    double saldo_;

public:
    ContaBancaria(std::string titular, double saldo_inicial) {
        titular_ = titular;
        saldo_ = (saldo_inicial >= 0) ? saldo_inicial : 0.0;
    }

    // ... deposita/saca/saldo como antes
};

ContaBancaria c("Ana", 100.0);   // chama o construtor automaticamente

Pare e pense

Com esse construtor escrito, a linha ContaBancaria c2; — sem argumento nenhum — compila?

Não compila. E é bom que não compile.

Se você não escrever construtor nenhum, o compilador gera um construtor padrão (sem argumentos) que não inicializa campos de tipo primitivo — um saldo_ assim pode começar com lixo de memória, e o objeto nasce inválido antes de qualquer método ter chance de opinar. Mas, assim que você escreve qualquer construtor, esse padrão automático desaparece. É por isso que ContaBancaria c2; passa a ser erro de compilação: a classe agora exige os dados que garantem o invariante, e o compilador cobra isso de quem cria a conta.

Lista de inicialização (member initializer list)

A forma idiomática de inicializar campos não é atribuir dentro do corpo do construtor, e sim usar a lista de inicialização, entre : e {:

class ContaBancaria {
private:
    std::string titular_;
    double saldo_;

public:
    ContaBancaria(std::string titular, double saldo_inicial)
        : titular_(titular), saldo_(saldo_inicial >= 0 ? saldo_inicial : 0.0) {
        // corpo pode ficar vazio, ou conter validacao mais complexa
    }
};

Pare e pense

As duas versões do construtor — atribuir no corpo e inicializar na lista — terminam com o objeto no mesmo estado. Por que, então, preferir a segunda?

Por dois motivos. O primeiro é que campos const e campos que são referências só podem ser inicializados dessa forma: atribuição no corpo não compila para eles, porque atribuir pressupõe que o campo já existe com algum valor, e const/referência não admite "existir primeiro e receber valor depois", em dois passos.

O segundo vale para qualquer campo: a lista de inicialização inicializa diretamente, com o valor final; atribuir no corpo primeiro constrói o campo com um valor padrão e depois o sobrescreve. Para um double a diferença é irrelevante, mas para um std::string titular_ são duas operações onde bastava uma — a string é criada vazia e em seguida recebe o conteúdo. Um passo desnecessário, em todo objeto criado.

Regra prática: inicialize na lista, sempre; use o corpo só para validação que não cabe numa expressão.

Múltiplos construtores (sobrecarga)

Assim como funções (capítulo 2), construtores podem ser sobrecarregados — vários construtores com o mesmo nome, distinguidos pelos parâmetros:

class Retangulo {
private:
    double largura_;
    double altura_;

public:
    Retangulo(double largura, double altura)
        : largura_(largura), altura_(altura) {}

    explicit Retangulo(double lado)     // construtor para quadrado
        : largura_(lado), altura_(lado) {}

    double area() const { return largura_ * altura_; }
};

Retangulo r1(4.0, 3.0);   // usa o primeiro construtor
Retangulo r2(5.0);        // usa o segundo -- um quadrado 5x5

A palavra explicit no segundo construtor impede que o compilador use esse construtor para conversões implícitas indesejadas — sem ela, um double solto passado a uma função que espera Retangulo viraria silenciosamente um quadrado, o que quase nunca é a intenção de quem escreveu a chamada. Regra prática: marque explicit todo construtor de um argumento só, a menos que você queira mesmo permitir a conversão implícita.

Método const (revisão)

Igual ao que você já viu com struct na Unidade 1: um método marcado const promete não alterar os campos do objeto, e o compilador garante isso. Todo getter deveria ser const.

double saldo() const { return saldo_; }    // so le -- const

void deposita(double valor) { ... }        // altera saldo_ -- sem const

Erros comuns

"error: passing ‘const ContaBancaria’ as ‘this’ argument discards qualifiers" — você tentou chamar um método não-const (deposita) através de uma referência const ContaBancaria&. Agora que você sabe o que é o this, a mensagem se lê inteira: o objeto que chegou ao método é const, e um método que altera campos não pode receber um objeto const — é exatamente a garantia que const promete.

"error: ‘saldo_’ is private within this context" — você tentou ler ou escrever um campo privado de fora da classe (algo como c.saldo_). Não é um bug a contornar: é o encapsulamento funcionando. Use o getter, ou pergunte-se se falta um método na classe que faça o que você queria fazer na mão.

"error: use of deleted function ‘ContaBancaria::ContaBancaria()’" — você tentou criar um objeto sem argumentos (ContaBancaria c;), mas a classe só tem construtor(es) que exigem argumentos. Forneça os argumentos, ou adicione um construtor sem argumentos se fizer sentido para o domínio.

Esquecer const no getter e o compilador reclamar em outro lugar — mesmo padrão do capítulo 3: se você tentar chamar um getter não-const a partir de um objeto const T&, o compilador barra. Marque getters como const por padrão.

O que vem a seguir

O construtor resolveu uma das pontas do ciclo de vida do objeto: o nascimento. Ele garante que nenhuma ContaBancaria existe em estado inválido, nem por um instante.

Falta a outra ponta. O que acontece quando o objeto morre — quando a variável sai de escopo e o programa segue adiante? Enquanto a classe só guarda double e std::string, a resposta é "nada de especial". Mas assim que ela passa a ser dona de um recurso — memória alocada, um arquivo aberto, uma conexão —, alguém precisa devolvê-lo, e esquecer disso é a fonte clássica de bugs em C. É o assunto do capítulo 10, o mais importante do curso.

Resumo

  • Classe é a planta, objeto (ou instância) é a casa construída. Campo é membro que guarda dado; método é membro que é função.
  • class tem membros private por padrão; struct, public. Use class quando há um invariante a proteger.
  • Encapsulamento: esconder os campos e expor só métodos que garantem as regras do domínio — assim a regra vive num lugar só.
  • this é o objeto em que o método foi chamado; campo_ dentro de um método é atalho para this->campo_.
  • Getters/setters: nem todo campo precisa dos dois — a ausência de um setter também é decisão de projeto.
  • Construtor: garante que o objeto nasce em estado válido; sem construtor nenhum escrito, campos primitivos podem começar com lixo.
  • Lista de inicialização (: campo(valor)) é a forma idiomática de inicializar — obrigatória para const/referência, mais eficiente para o resto.
  • explicit em construtores de um argumento evita conversões implícitas surpreendentes.