Classes e Objetos em C++: Uma Análise Aprofundada

Fundamentos de Classes e Objetos

Em C++, a programação orientada a objetos (POO) é construída sobre os conceitos de classes e objetos. Uma classe serve como um modelo ou projeto para criar objetos, que são instâncias dessa classe. Essencialmente, uma classe define as propriedades (dados) e comportamentos (funções) que seus objetos terão.

Uma distinção fundamental no C++ é a diferença de visibilidade padrão entre class e struct. Por padrão, os membros de uma class são private, enquanto os membros de uma struct são public. Além dessa diferença, ambas as palavras-chave são funcionalmente muito semelhantes para definir tipos compostos.

Pilares da Programação Orientada a Objetos

Os três pilares da POO são:

  • Encapsulamento: O encapsulamento é o princípio de agrupar dados e os métodos que operam sobre esses dados em uma única unidade (a classe), e restringir o acesso direto a alguns dos componentes do objeto. Em C++, isso é conseguido usando especificadores de acesso como public, private e protected, permitindo um controle rigoroso sobre como os membros de uma classe são acessados e modificados.
  • Herança: Permite que uma classe (subclasse ou classe derivada) herde propriedades e comportamentos de outra classe (superclasse ou classe base), promovendo a reutilização de código e a criação de hierarquias de tipos.
  • Polimorfismo: Permite que objetos de diferentes classes sejam tratados de forma uniforme por meio de uma interface comum, possibilitando comportamentos distintos para a mesma operação dependendo do tipo do objeto.

Estrutura de Memória de Classes e Objetos

Quando um objeto é instanciado a partir de uma classe, a memória é alocada para armazenar suas variáveis membro. Curiosamente, as funções membro da classe não são armazenadas dentro de cada objeto individual. Em vez disso, elas residem em uma área de memória comum (tipicamente o segmento de código), e todos os objetos da mesma classe compartilham o mesmo endereço para essas funções. Isso significa que objetos distintos terão endereços de memória diferentes para suas variáveis membro, mas compartilharão os endereços das funções membro.

Se uma classe não possui variáveis membro, seu tamanho é geralmente de 1 byte. Este byte é uma exigência mínima para que o objeto tenha um endereço de memória único, permitindo que ele seja endereçável e identificável.

Regras de Alinhamento de Memória para Estruturas (e Classes)

A forma como os membros de uma etsrutura ou classe são dispostos na memória segue regras de alinhamento para otimizar o desempenho do acesso à memória. As regras comuns são:

  1. O primeiro membro de uma estrutura é armazenado no deslocamento 0 do endereço da estrutura.
  2. Outras variáveis membro são alinhadas a um endereço que é um múltiplo de seu "número de alinhamento". O número de alinhamento é o menor valor entre o alinhamento padrão do compilador (geralmente 8 bytes no Visual Studio) e o tamanho do próprio membro.
  3. O tamanho total da estrutura deve ser um múltiplo do maior número de alinhamento entre todos os seus membros (incluindo o alinhamento padrão do compilador).
  4. Se houver estruturas aninhadas, a estrutura interna é alinhada a um múltiplo do seu próprio maior número de alinhamento, e o tamanho total da estrutura externa também deve ser um múltiplo do maior número de alinhamento de todos os membros, incluindo o da estrutura aninhada.

É possível especificar um alinhamento diferente usando a diretiva #pragma pack(N), onde N é o número de bytes para alinhamento. Por exemplo, #pragma pack(1) força o alinhamento de 1 byte, eliminando preenchimento de bytes e potencialmente reduzindo o tamanho da estrutura, mas pode impactar o desempenho.

O Ponteiro this

Dentro de qualquer função membro não estática de uma classe, existe um ponteiro implícito chamado this. Este ponteiro aponta para o objeto no qual a função membro foi invocada. Sua natureza é TipoClasse* const this, significando que o ponteiro this em si não pode ser modificado (ou seja, não pode apontar para outro objeto), mas o objeto para o qual ele aponta (seus dados) pode ser modificado.

O ponteiro this é, na verdade, um parâmetro oculto passado para as funções membro. Como um parâmetro de função, ele reside na pilha durante a execução da função. Em alguns compiladores ou em certas otimizações, ele pode ser armazenado em um registrador da CPU para acesso mais rápido.

Exemplo de Uso do Ponteiro this

Considere o seguinte código:

#include <iostream>

class Exemplo {
public:
    void imprimirDados() {
        std::cout << "Endereço do objeto (via this): " << this << std::endl;
        std::cout << "Valor do membro '_valor': " << _valor << std::endl; // Acesso ao membro via this implícito
        std::cout << "Função 'imprimirDados()' executada." << std::endl;
    }
private:
    int _valor;
};

int main() {
    Exemplo* obj_nulo = nullptr;

    // A chamada do método é válida, 'this' é passado como nullptr
    // Mas o acesso a um membro de dados via 'this' nulo causará uma falha.
    obj_nulo->imprimirDados(); 
    // A linha acima compila, mas provavelmente falhará em tempo de execução
    // quando tentar acessar '_valor' porque 'this' é nullptr.

    return 0;
}

Neste exemplo, obj_nulo->imprimirDados() irá compilar. O ponteiro this é simplesmente passado como nullptr para a função imprimirDados. No entanto, quando a linha std::cout << _valor << std::endl; é executada, o código tenta desreferenciar o ponteiro this (que é nullptr) para acessar _valor, resultando em uma falha de segmentação (segmentation fault) ou acesso de memória inválido em tempo de execução. Se _valor não fosse acessado, a função poderia até mesmo ser executada sem falha (por exemplo, se apenas std::cout << "Função 'imprimirDados()' executada." << std::endl; estivesse presente), pois a chamada do método em si não requer um objeto válido, apenas a desreferenciação explícita ou implícita de this.

Construtores para Inicialização de Objetos

A inicialização de objetos em C++ é um aspecto crucial para garantir que os objetos estejam em um estado válido desde o momento de sua criação. Os construtores são funções membro especiais que são chamadas automaticamente quando um objeto de uma classe é criado.

C++ distingue entre dois tipos de dados:

  • Tipos intrínsecos/primitivos: Como int, char, double, ponteiros. A linguagem define esses tipos.
  • Tipos definidos pelo usuário: Como struct e class.

Antes do C++11, um construtor padrão (gerado pelo compilador) não inicializava membros de tipos intrínsecos. Ele apenas chamava os construtores padrão para membros de tipos definidos pelo usuário. A partir do C++11, é possível fornecer valores padrão para membros diretamente em sua declaração de classe, o que afeta tanto os membros de tipos intrínsecos quanto os de tipos definidos pelo usuário, mesmo se um construtor padrão gerado for usado.

Tipos de Construtores Padrão

Uma classe pode ter apenas um "construtor padrão" no sentido de um construtor que pode ser chamado sem argumentos. Isso inclui:

  • Um construtor sem parâmetros (ex: MinhaClasse() { /* ... */ }).
  • Um construtor com todos os parâmetros tendo valores padrão (ex: MinhaClasse(int a = 0, int b = 1) { /* ... */ }).
  • Um construtor gerado automaticamente pelo compilador (se nenhum construtor for explicitamente definido pelo usuário).

Se o programador define qualquer construtor (mesmo um com parâmetros), o compilador não gerará o construtor padrão sem parâmetros. Se um construtor com parâmetros for o único definido e um objeto for instanciado sem argumentos, ocorrerá um erro de compilação. A solução é fornecer um construtor sem parâmetros ou um construtor com todos os parâmetros padrão.

Sobrecarga de Construtores

Assim como outras funções, os construtores podem ser sobrecarregados, o que significa que uma classe pode ter múltiplos construtores com diferentes listas de parâmetros, permitindo várias maneiras de inicializar um objeto. Por exemplo:

#include <iostream>

class Ponto {
public:
    Ponto() : x_(0), y_(0) { // Construtor padrão sem parâmetros
        std::cout << "Ponto() - Default" << std::endl;
    }
    Ponto(int x_coord, int y_coord) : x_(x_coord), y_(y_coord) { // Construtor com dois parâmetros
        std::cout << "Ponto(int, int) - Parametrizado" << std::endl;
    }
    Ponto(int valor_unico) : x_(valor_unico), y_(valor_unico) { // Construtor com um parâmetro
        std::cout << "Ponto(int) - Parametro unico" << std::endl;
    }
    void imprimir() {
        std::cout << "Ponto: (" << x_ << ", " << y_ << ")" << std::endl;
    }
private:
    int x_;
    int y_;
};

int main() {
    Ponto p1;             // Chama Ponto()
    Ponto p2(5, 10);      // Chama Ponto(int, int)
    Ponto p3(7);          // Chama Ponto(int)
    // Ponto p4();        // ERRO! Não use parênteses para chamar o construtor padrão sem argumentos.
                         // Isso seria interpretado como uma declaração de função.
    Ponto p4;             // Correto para chamar o construtor padrão sem argumentos.

    p1.imprimir();
    p2.imprimir();
    p3.imprimir();
    p4.imprimir();

    return 0;
}

Lista de Inicialização de Membros

A lista de inicialização de membros é a maneira preferida e, em alguns casos, obrigatória de inicializar os membros de uma classe. Ela aparece após a lista de parâmetros do construtor, separada por dois pontos (:), e lista cada membro seguido por parênteses contendo seu valor de inicialização. Os membros são inicializados na ordem em que são declarados na classe, não na ordem em que aparecem na lista de inicialização.

class ExemploLista {
public:
    ExemploLista(int val1, int val2, int val3)
        : membro_const_(val1), // Membro const deve ser inicializado aqui
          membro_ref_(val2),   // Membro de referência deve ser inicializado aqui
          membro_normal_(val3) // Membro normal pode ser inicializado aqui
    {
        // Outras operações podem ser realizadas no corpo do construtor
        // Mas para inicialização, a lista é preferida.
    }
private:
    const int membro_const_;
    int& membro_ref_;
    int membro_normal_;
};

Quando a Lista de Inicialização é Obrigatória:

  1. Para membros const (que não podem ser atribuídos após a construção).
  2. Para membros de tipo referência (que devem ser inicializados na declaração).
  3. Para membros que são objetos de classes sem um construtor padrão (default constructor). Nesses casos, o construtor do membro deve ser chamado explicitamente na lista de inicialização.

A partir do C++11, é possível fornecer valores padrão para membros diretamente na declaração da classe (chamados de inicializadores no local ou in-class member initializers). Esses valores são usados se o membro não for explicitamente inicializado na lista de inicialização de um construtor.

Conversão Implícita e explicit

Um construtor com um único parâmetro (ou um construtor em que todos os parâmetros, exceto um, têm valores padrão) pode ser usado para conversões implícitas de tipo. Por exemplo:

#include <iostream>

class WrapperInt {
public:
    WrapperInt(int val) : valor_(val) {
        std::cout << "Construtor WrapperInt(int) chamado." << std::endl;
    }
    void imprimir() {
        std::cout << "WrapperInt valor: " << valor_ << std::endl;
    }
private:
    int valor_;
};

int main() {
    WrapperInt w1 = 10; // Conversão implícita: 10 é convertido para WrapperInt
    w1.imprimir();

    // Uma função esperando um WrapperInt pode aceitar um int
    // void aceitaWrapper(WrapperInt obj);
    // aceitaWrapper(20); // Conversão implícita de 20 para WrapperInt

    return 0;
}

Para evitar essas conversões implícitas, que podem levar a erros difíceis de depurar, a palavra-chave explicit pode ser usada no construtor:

#include <iostream>

class WrapperIntExpl {
public:
    explicit WrapperIntExpl(int val) : valor_(val) { // Construtor explicit
        std::cout << "Construtor WrapperIntExpl(int) chamado." << std::endl;
    }
    void imprimir() {
        std::cout << "WrapperIntExpl valor: " << valor_ << std::endl;
    }
private:
    int valor_;
};

int main() {
    // WrapperIntExpl w2 = 10; // ERRO: Não permite conversão implícita
    WrapperIntExpl w2(10);     // OK: Chamada direta do construtor
    w2.imprimir();

    // WrapperIntExpl w3 = (WrapperIntExpl)20; // OK: Conversão explícita (cast)
    // WrapperIntExpl w4 {30}; // OK (C++11 em diante): Inicialização com lista
    //
    // Apenas chamadas diretas ou casts explícitos são permitidos com 'explicit'.
    return 0;
}

O explicit se aplica a construtores com um único argumento que não seja um initializer_list, ou a construtores com múltiplos argumentos se todos os argumentos, exceto um, tiverem valores padrão. Com C++11 em diante, explicit também pode ser usado com construtores que aceitam um std::initializer_list para prevenir conversões implícitas via inicialização de lista.

Destrutores

Um destrutor é uma função membro especial de uma classe que é chamada automaticamente quando um objeto é destruído (por exemplo, quando sai do escopo ou é explicitamente deletado com delete). O destrutor tem o mesmo nome da classe, precedido por um til (~), e não aceita parâmetros nem tem tipo de retorno.

O principal objetivo de um destrutor é liberar recursos que o objeto adquiriu durante sua vida útil, como memória alocada dinamicamente, manipuladores de arquivos, conexões de rede, etc. Isso é crucial para prevenir vazamentos de memória e outros problemas de recursos.

#include <iostream>

class Recurso {
public:
    Recurso(const char* nome) : nome_recurso_(nome) {
        std::cout << "Recurso '" << nome_recurso_ << "' alocado." << std::endl;
    }
    ~Recurso() { // Destrutor
        std::cout << "Recurso '" << nome_recurso_ << "' desalocado." << std::endl;
    }
private:
    const char* nome_recurso_;
};

Recurso g_recurso_global("Global"); // Objeto global

void func_local_estatico() {
    static Recurso s_recurso_estatico("Local Estatico"); // Objeto estático local
}

int main() {
    func_local_estatico(); // Objeto estático local é inicializado na primeira chamada

    Recurso l_recurso_main("Local Main"); // Objeto local na função main
    // ...
    return 0;
} // 'l_recurso_main' é destruído aqui
// 's_recurso_estatico' é destruído no final do programa
// 'g_recurso_global' é destruído no final do programa

Ordem de Destruição

A ordem em que os destrutores são chamados é inversa à ordem de construção, dentro de cada categoria de armazenamento:

  • Objetos automáticos (locais): Destruídos quando saem do escopo. A ordem é inversa à da criação dentro do mesmo escopo.
  • Objetos estáticos locais: Destruídos no final do programa, na ordem inversa à da inicialização.
  • Objetos globais: Destruídos no final do programa, na ordem inversa à da inicialização.

Construtor de Cópia

O construtor de cópia é um tipo especial de construtor que é invocado quando um objeto é inicializado com outro objeto da mesma classe. Ele permite que um novo objeto seja criado como uma cópia de um objeto existente. A assinatura típica de um construtor de cópia é NomeClasse(const NomeClasse& outro).

#include <iostream>

class Ponto2D {
public:
    Ponto2D(int x = 0, int y = 0) : x_(x), y_(y) {
        std::cout << "Construtor Ponto2D(int, int) chamado para (" << x_ << ", " << y_ << ")" << std::endl;
    }

    Ponto2D(const Ponto2D& outro) : x_(outro.x_), y_(outro.y_) { // Construtor de cópia
        std::cout << "Construtor de cópia chamado para (" << x_ << ", " << y_ << ") de (" << outro.x_ << ", " << outro.y_ << ")" << std::endl;
    }

    ~Ponto2D() {
        std::cout << "Destrutor Ponto2D chamado para (" << x_ << ", " << y_ << ")" << std::endl;
    }

    void imprimir() const {
        std::cout << "Ponto: (" << x_ << ", " << y_ << ")" << std::endl;
    }
private:
    int x_;
    int y_;
};

void funcao_que_recebe_por_valor(Ponto2D p) {
    std::cout << "Dentro de funcao_que_recebe_por_valor" << std::endl;
    p.imprimir();
    std::cout << "Saindo de funcao_que_recebe_por_valor" << std::endl;
}

int main() {
    Ponto2D p_origem(10, 20); // Construtor normal
    p_origem.imprimir();

    Ponto2D p_copia = p_origem; // Construtor de cópia
    p_copia.imprimir();

    Ponto2D p_outra(p_origem); // Construtor de cópia (sintaxe alternativa)
    p_outra.imprimir();

    funcao_que_recebe_por_valor(p_origem); // Construtor de cópia para o parâmetro
    
    // Se você definir um construtor de cópia, e não definir um construtor padrão,
    // o compilador não o gerará. Você pode forçar sua geração:
    // Ponto2D() = default;

    return 0;
}

Observações Importantes sobre o Construtor de Cópia:

  • O parâmetro do construtor de cópia deve ser uma referência constante (const NomeClasse&). Se fosse uma cópia por valor (NomeClasse outro), a chamada ao construtor de cópia para criar o parâmetro outro levaria a uma recursão infinita, resultando em uma falha de estouro de pilha.
  • O qualificador const no parâmetro garante que o objeto original não será modificado durante o processo de cópia.
  • Se você define qualquer construtor em sua classe, o compilador não gerará automaticamente um construtor padrão (sem argumentos). Similarmente, se você define um construtor de cópia, o compilador não gerará um construtor de cópia padrão. No entanto, você pode forçar a geração de construtores padrão (e construtores de cópia padrão) usando = default;.

Construtor de Cópia Padrão (Cópia Rasa)

Se você não definir um construtor de cópia, o compilador gerará um por padrão. Este construtor de cópia padrão realiza uma cópia rasa (shallow copy), copiando bit a bit os valores de todos os membros. Para membros de tipos intrínsecos, isso geralmente funciona bem. No entanto, para classes que gerenciam recursos alocados dinamicamente (como ponteiros para memória no heap), uma cópia rasa pode levar a problemas sérios:

  • Múltiplos objetos podem apontar para a mesma área de memória.
  • A liberação de memória em um destrutor de um objeto afetará outros objetos que compartilham a mesma memória.
  • Pode ocorrer uma tentativa de liberar a mesma memória múltiplas vezes (double free), causando falhas no programa.

Nesses casos, é necessária uma cópia profunda (deep copy), onde um novo recurso é alocado e o conteúdo do recurso original é copiado para ele. Isso exige que você escreva seu próprio construtor de cópia.

#include <iostream>
#include <cstring> // Para memcpy

class Pilha {
public:
    Pilha(size_t capacidade) : _tamanho(0), _capacidade(capacidade) {
        _array = new int[_capacidade];
        std::cout << "Pilha(size_t) construida, capacidade: " << _capacidade << std::endl;
    }

    Pilha(const Pilha& outra) : _tamanho(outra._tamanho), _capacidade(outra._capacidade) {
        _array = new int[_capacidade]; // Aloca nova memória
        if (_array) {
            // Copia o conteúdo da memória, não apenas o ponteiro
            memcpy(_array, outra._array, _tamanho * sizeof(int)); 
        } else {
            // Tratar erro de alocação, se necessário
        }
        std::cout << "Pilha(const Pilha&) - Copia profunda, capacidade: " << _capacidade << std::endl;
    }

    ~Pilha() {
        if (_array) {
            delete[] _array;
            _array = nullptr;
        }
        std::cout << "Pilha destruida." << std::endl;
    }
private:
    int* _array;
    size_t _tamanho;
    size_t _capacidade;
};

int main() {
    Pilha p1(5); // Pilha original
    // Adicionar elementos a p1...

    Pilha p2 = p1; // Copia profunda: p2 terá sua própria memória
    // p2 e p1 agora têm cópias independentes dos dados

    return 0;
}

Qualificador const em Funções Membro

Quando const é adicionado ao final da declaração de uma função membro (ex: void imprimir() const;), ele indica que a função não modificará o estado do objeto para o qual this aponta. Isso significa que, dentro de uma função membro const, o ponteiro this é implicitamente do tipo const NomeClasse*, impedindo a modificação de variáveis membro não mutáveis.

class ExemploConst {
public:
    void modificar() { // Não const, pode modificar
        _dado++;
    }
    void ler() const { // Const, não pode modificar _dado
        // _dado++; // ERRO: Tenta modificar um membro em função const
        int temp = _dado; // OK: Pode ler
        std::cout << "Dado: " << temp << std::endl;
    }
private:
    int _dado = 0;
};

Sobrecarga de Operadores

A sobrecarga de operadores em C++ permite redefinir o comportamento de operadores existentes (como +, -, ==, =, etc.) para trabalhar com tipos definidos pelo usuário (classes e estruturas). Isso torna o código mais intuitivo e legível, permitindo que objetos de classe se comportem de forma semelhante a tipos intrínsecos.

Operadores podem ser sobrecarregados como funções membro da classe ou como funções globais (não membro).

Sobrecarga como Função Membro vs. Função Global

  • Como Função Membro: O operador atua no objeto para o qual a função é chamada (o operando à esquerda para operadores binários, o único operando para unários). O operando à esquerda é implicitamente o objeto *this.
  • Como Função Global: O operador recebe todos os operandos como argumentos explícitos. Isso é necessário quando o operando à esquerda é de um tipo intrínseco ou quando o operador precisa acessar membros privados de dois objetos de diferentes classes (com a ajuda de funções friend).

Um problema comum com a sobrecarga global é o acesso a membros privados da classe. Isso geralmente é resolvido declarando a função global como friend da classe.

#include <iostream>

class Data {
public:
    Data(int ano = 1900, int mes = 1, int dia = 1)
        : ano_(ano), mes_(mes), dia_(dia) {}

    // Sobrecarga como função membro
    bool operator==(const Data& outra) const {
        return ano_ == outra.ano_ && mes_ == outra.mes_ && dia_ == outra.dia_;
    }

    // Sobrecarga como função global (neste caso, pode ser membro ou friend)
    // Para ilustrar, vamos declarar uma global que compara duas Datas:
    // friend bool operator<(const Data& d1, const Data& d2); // Se fosse global e precisasse de acesso privado
    
    // Função membro 'imprimir' para demonstração
    void imprimir() const {
        std::cout << ano_ << "/" << mes_ << "/" << dia_ << std::endl;
    }

private:
    int ano_;
    int mes_;
    int dia_;
};

// Exemplo de sobrecarga global (não necessita friend neste caso, pois '==' já existe como membro)
// É mais comum para operadores de stream como << e >>
bool operator!=(const Data& d1, const Data& d2) {
    return !(d1 == d2); // Reutiliza o operador== membro
}

void testarOperadores() {
    Data data1(2023, 10, 26);
    Data data2(2023, 10, 27);
    Data data3(2023, 10, 26);

    std::cout << "data1 == data2: " << (data1 == data2) << std::endl; // false (0)
    std::cout << "data1 == data3: " << (data1 == data3) << std::endl; // true (1)
    std::cout << "data1 != data2: " << (data1 != data2) << std::endl; // true (1)
}

Regras e Restrições da Sobrecarga de Operadores:

  1. Não é possível criar novos operadores. Apenas operadores existentes podem ser sobrecarregados. Por exemplo, operator@ não é válido.
  2. Um operador sobrecarregado deve ter pelo menos um operando de tipo classe ou enumeração. Não é possível sobrecarregar operadores para tipos intrínsecos puros (ex: redefinir int + int).
  3. A semântica de operadores para tipos intrínsecos não pode ser alterada. Por exemplo, + para inteiros sempre significará adição.
  4. Quando sobrecarregado como função membro, o operador tem um parâmetro a menos do que o número de operandos. O primeiro operando (o da esquerda para binários) é o objeto *this.
  5. Os seguintes operadores não podem ser sobrecarregados: . (acesso a membro), .* (ponteiro para membro), :: (resolução de escopo), sizeof (tamanho do tipo), ? : (condicional ternário).
  6. Para permitir o encadeamento de operadores (ex: d1 = d2 = d3), a função de sobrecarga deve retornar uma referência ao tipo da classe. Isso ocorre porque o resultado da operação (ex: d2 = d3) se torna o operando esquerdo para a próxima operação (d1 = (resultado)).
  7. Se você não sobrecarregar o operador de atribuição (operator=), o compilador gerará um padrão que realiza uma cópia rasa, semelhante ao construtor de cópia padrão. Para classes com recursos dinâmicos, uma cópia profunda é essencial e requer uma sobrecarga explícita de operator=.
  8. O operador de atribuição (operator=) deve ser sobrecarregado como uma função membro da classe para evitar ambiguidades e garantir o comportamento correto. Ele não pode ser uma função global.

Exemplo Completo: Classe Data com Sobrecarga de Operadores

Vamos revisar uma classe Data (Date) para incluir vários operadores sobrecarregados, demonstrando as melhores práticas.

Date.h

#pragma once
#include <iostream>
#include <cassert> // Para assert

class Data {
public:
    // Construtor com valores padrão
    Data(int ano = 1, int mes = 1, int dia = 1);

    // Operadores de comparação (const pois não modificam o objeto)
    bool operator<(const Data& outra) const;
    bool operator<=(const Data& outra) const;
    bool operator>(const Data& outra) const;
    bool operator>=(const Data& outra) const;
    bool operator==(const Data& outra) const;
    bool operator!=(const Data& outra) const;

    // Operadores aritméticos de atribuição (modificam o objeto, retornam referência)
    Data& operator+=(int dias); // d1 += 100
    Data& operator-=(int dias); // d1 -= 100

    // Operadores aritméticos (não modificam o objeto, retornam nova Data por valor)
    Data operator+(int dias) const; // d1 + 100
    Data operator-(int dias) const; // d1 - 100

    // Operadores de incremento/decremento prefixo (modificam o objeto, retornam referência)
    Data& operator++(); // ++d1
    Data& operator--(); // --d1

    // Operadores de incremento/decremento posfixo (retornam uma cópia ANTES da modificação)
    // O 'int' dummy parameter é para distinguir do prefixo na sobrecarga
    Data operator++(int); // d1++
    Data operator--(int); // d1--

    // Operador de subtração para diferença entre datas (retorna int)
    int operator-(const Data& outra) const; // d1 - d2

    // Função auxiliar para obter dias no mês (const, pois não modifica o objeto)
    int obterDiasNoMes(int ano, int mes) const;

    // Função para imprimir a data (const, pois não modifica o objeto)
    void imprimir() const;

private:
    int ano_;
    int mes_;
    int dia_;
};

Date.cpp

#define _CRT_SECURE_NO_WARNINGS // Para Visual Studio, pode ser necessário para algumas funções C
#include "Date.h"

// Construtor
Data::Data(int ano, int mes, int dia) : ano_(ano), mes_(mes), dia_(dia) {
    // Validação básica da data (pode ser mais robusta)
    assert(mes >= 1 && mes <= 12);
    assert(dia >= 1 && dia <= obterDiasNoMes(ano, mes));
}

// Implementação da função auxiliar
int Data::obterDiasNoMes(int ano, int mes) const {
    assert(mes >= 1 && mes <= 12);
    static const int diasPorMes[13] = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};

    // Lógica para ano bissexto (fevereiro)
    if (mes == 2 && ((ano % 4 == 0 && ano % 100 != 0) || (ano % 400 == 0))) {
        return 29;
    }
    return diasPorMes[mes];
}

void Data::imprimir() const {
    std::cout << ano_ << "/" << mes_ << "/" << dia_ << std::endl;
}

// Operadores de Comparação
bool Data::operator<(const Data& outra) const {
    if (ano_ < outra.ano_) return true;
    if (ano_ == outra.ano_ && mes_ < outra.mes_) return true;
    if (ano_ == outra.ano_ && mes_ == outra.mes_ && dia_ < outra.dia_) return true;
    return false;
}

bool Data::operator<=(const Data& outra) const {
    return (*this < outra || *this == outra);
}

bool Data::operator>(const Data& outra) const {
    return !(*this <= outra);
}

bool Data::operator>=(const Data& outra) const {
    return !(*this < outra);
}

bool Data::operator==(const Data& outra) const {
    return (ano_ == outra.ano_ && mes_ == outra.mes_ && dia_ == outra.dia_);
}

bool Data::operator!=(const Data& outra) const {
    return !(*this == outra);
}

// Operadores Aritméticos de Atribuição
Data& Data::operator+=(int dias) {
    if (dias < 0) return (*this -= -dias); // Delega para -= se for negativo

    dia_ += dias;
    while (dia_ > obterDiasNoMes(ano_, mes_)) {
        dia_ -= obterDiasNoMes(ano_, mes_);
        mes_++;
        if (mes_ > 12) {
            mes_ = 1;
            ano_++;
        }
    }
    return *this;
}

Data& Data::operator-=(int dias) {
    if (dias < 0) return (*this += -dias); // Delega para += se for negativo

    dia_ -= dias;
    while (dia_ <= 0) {
        mes_--;
        if (mes_ < 1) {
            mes_ = 12;
            ano_--;
        }
        dia_ += obterDiasNoMes(ano_, mes_);
    }
    return *this;
}

// Operadores Aritméticos (reutilizam operadores de atribuição)
Data Data::operator+(int dias) const {
    Data temp = *this; // Cria uma cópia para não modificar o original
    temp += dias;       // Usa o operador += sobrecarregado
    return temp;        // Retorna a cópia modificada por valor
}

Data Data::operator-(int dias) const {
    Data temp = *this;
    temp -= dias;
    return temp;
}

// Operadores de Incremento/Decremento Prefixo
Data& Data::operator++() { // ++data
    return (*this += 1);
}

Data& Data::operator--() { // --data
    return (*this -= 1);
}

// Operadores de Incremento/Decremento Posfixo
Data Data::operator++(int) { // data++
    Data temp = *this; // Salva o estado atual
    (*this += 1);      // Incrementa o objeto
    return temp;       // Retorna o estado ANTES do incremento
}

Data Data::operator--(int) { // data--
    Data temp = *this;
    (*this -= 1);
    return temp;
}

// Operador de Subtração de Datas (diferença em dias)
int Data::operator-(const Data& outra) const {
    // Garante que 'min' é a data menor e 'max' a maior
    Data temp_max = *this;
    Data temp_min = outra;
    int direcao = 1; // 1 se *this > outra, -1 se *this < outra

    if (*this < outra) {
        direcao = -1;
        temp_max = outra;
        temp_min = *this;
    }

    int contador_dias = 0;
    while (temp_min != temp_max) {
        temp_min += 1; // Incrementa a data menor dia a dia
        contador_dias++;
    }
    return contador_dias * direcao;
}

Test.cpp

#include <iostream>
#include "Date.h" // Inclua o arquivo de cabeçalho da classe Data

int main() {
    Data d1(2024, 1, 29);
    Data d2 = d1 + 20; // Usa operator+
    std::cout << "d1 (original): "; d1.imprimir();
    std::cout << "d2 (d1 + 20 dias): "; d2.imprimir();

    d2 -= 15; // Usa operator-=
    std::cout << "d2 (d2 - 15 dias): "; d2.imprimir();

    Data d3(2023, 11, 15);
    d3 += 100; // Usa operator+=
    std::cout << "d3 (d3 + 100 dias): "; d3.imprimir();

    std::cout << "Pre-incremento de d3: ";
    (++d3).imprimir(); // Usa operator++() prefixo
    std::cout << "d3 após pre-incremento: "; d3.imprimir();

    std::cout << "Pos-incremento de d3: ";
    (d3++).imprimir(); // Usa operator++(int) posfixo (retorna o valor antigo)
    std::cout << "d3 após pos-incremento: "; d3.imprimir();

    Data d4(2024, 1, 1);
    Data d5(2024, 3, 15);
    std::cout << "Dias entre d5 e d4: " << (d5 - d4) << " dias." << std::endl; // Usa operator-

    Data d6(2024, 3, 15);
    Data d7(2024, 1, 1);
    std::cout << "Dias entre d7 e d6: " << (d7 - d6) << " dias." << std::endl; // Usa operator- (resultado negativo)

    return 0;
}

Membros Estáticos

Membros estáticos (variáveis e funções) de uma classe pertencem à própria classe, e não a qualquer objeto individual dela. Isso significa que há apenas uma cópia de uma variável membro estática para todos os objetos da classe, e as funções membro estáticas podem ser chamadas mesmo sem uma instância de objeto.

  • Variáveis Membro Estáticas: São declaradas dentro da classe, mas definidas fora dela (tipicamente no arquivo .cpp), onde sua memória é alocada e elas podem ser inicializadas. Elas são compartilhadas por todas as instâncias da classe.
  • Funções Membro Estáticas: Não possuem um ponteiro this implícito, o que significa que não podem acessar membros não estáticos da classe, pois esses membros pertencem a objetos específicos. Funções estáticas podem acessar apenas outros membros estáticos ou variáveis globais.
#include <iostream>

class ContadorObjetos {
public:
    ContadorObjetos() { // Construtor
        num_objetos_++;
        std::cout << "Objeto construido. Total: " << num_objetos_ << std::endl;
    }

    ContadorObjetos(const ContadorObjetos& outro) { // Construtor de cópia
        num_objetos_++;
        std::cout << "Objeto copiado. Total: " << num_objetos_ << std::endl;
    }

    ~ContadorObjetos() { // Destrutor
        num_objetos_--;
        std::cout << "Objeto destruido. Total: " << num_objetos_ << std::endl;
    }

    static int obterNumeroDeObjetos() { // Função membro estática
        // membro_nao_estatico_++; // ERRO: Não pode acessar membros não estáticos
        return num_objetos_;
    }

private:
    static int num_objetos_; // Declaração de variável membro estática
    int membro_nao_estatico_ = 0; // Exemplo de membro não estático
};

// Definição e inicialização da variável membro estática fora da classe
int ContadorObjetos::num_objetos_ = 0;

ContadorObjetos criarEretornarObjeto() {
    ContadorObjetos temp_obj; // Construtor
    return temp_obj; // Pode envolver construtor de cópia (ou otimização RVO/NRVO)
}

int main() {
    ContadorObjetos obj1; // Construtor
    ContadorObjetos obj2; // Construtor
    
    // Acesso ao membro estático e função estática através do nome da classe
    std::cout << "Número atual de objetos: " << ContadorObjetos::obterNumeroDeObjetos() << std::endl;

    ContadorObjetos obj3 = obj1; // Construtor de cópia

    std::cout << "Número atual de objetos: " << ContadorObjetos::obterNumeroDeObjetos() << std::endl;

    ContadorObjetos obj4 = criarEretornarObjeto(); // Construtor e potencialmente cópia/otimização

    std::cout << "Número atual de objetos: " << obj1.obterNumeroDeObjetos() << std::endl; // Também pode acessar via objeto

    return 0;
} // Destrutores são chamados

Funções Amigas (Friend Functions)

Uma função amiga é uma função (não membro da classe) que recebe permissão especial para acessar os membros privados e protegidos de uma classe. Ela é declarada dentro da classe usando a palavra-chave friend. O uso de funções amigas quebra o encapsulamento, portanto, deve ser feito com parcimônia e apenas quando estritamente necessário.

Um caso de uso comum para funções amigas é a sobrecarga dos operadores de inserção (<<) e extração (>>) de stream (para std::cout e std::cin, respectivamente). Estes operadores geralmente precisam ser funções globais porque o operando à esquerda (o objeto de stream) é de um tipo intrínseco (ou de uma classe diferente, como std::ostream), e não o objeto da sua classe. Como funções globais, eles normalmente não teriam acesso aos membros privados da sua classe, mas torná-los friend resolve isso.

#include <iostream>

class Ponto3D {
    // Declara o operador de inserção de stream como uma função amiga
    friend std::ostream& operator<<(std::ostream& os, const Ponto3D& p);
    // Declara o operador de extração de stream como uma função amiga
    friend std::istream& operator>>(std::istream& is, Ponto3D& p);

public:
    Ponto3D(int x = 0, int y = 0, int z = 0)
        : x_(x), y_(y), z_(z) {}

private:
    int x_;
    int y_;
    int z_;
};

// Definição da função amiga (operador de inserção)
std::ostream& operator<<(std::ostream& os, const Ponto3D& p) {
    os << "Ponto3D(" << p.x_ << ", " << p.y_ << ", " << p.z_ << ")";
    return os;
}

// Definição da função amiga (operador de extração)
std::istream& operator>>(std::istream& is, Ponto3D& p) {
    std::cout << "Digite X: ";
    is >> p.x_;
    std::cout << "Digite Y: ";
    is >> p.y_;
    std::cout << "Digite Z: ";
    is >> p.z_;
    return is;
}

int main() {
    Ponto3D meu_ponto(1, 2, 3);
    std::cout << "Meu ponto: " << meu_ponto << std::endl;

    Ponto3D outro_ponto;
    std::cout << "Digite os dados para outro ponto:" << std::endl;
    std::cin >> outro_ponto;
    std::cout << "Outro ponto: " << outro_ponto << std::endl;

    return 0;
}

Características das Funções Amigas:

  1. Uma função amiga não é uma função membro da classe. Ela é uma função comum, definida externamente à classe.
  2. Uma função amiga não possui um ponteiro this, portanto, não pode ser qualificada com const (que se aplica ao this).
  3. A declaração friend pode ser colocada em qualquer seção de acesso (public, private, protected) da classe; isso não afeta sua visibilidade ou permissões.
  4. Uma única função pode ser amiga de várias classes.
  5. Funções amigas são chamadas como funções normais, não através do operador de acesso a membro (. ou ->).

Classes Amigas (Friend Classes)

Uma classe amiga é uma classe inteira que recebe permissão para acessar os membros privados e protegidos de outra classe. Quando uma classe A é declarada como amiga da classe B, todas as funções membro de A têm acesso aos membros privados e protegidos de B.

#include <iostream>

class Maquina; // Declaração antecipada da classe Maquina

class Controle {
public:
    void iniciarMaquina(Maquina& maquina);
    void pararMaquina(Maquina& maquina);
private:
    int nivel_acesso_ = 1;
};

class Maquina {
    friend class Controle; // Declara Controle como classe amiga
public:
    Maquina(int id) : id_(id), status_("Desligada") {}
    void exibirStatus() const {
        std::cout << "Máquina ID " << id_ << " Status: " << status_ << std::endl;
    }
private:
    int id_;
    std::string status_;
    // Outros detalhes internos que Controle pode acessar
};

void Controle::iniciarMaquina(Maquina& maquina) {
    if (nivel_acesso_ > 0) {
        maquina.status_ = "Ligada"; // Acessa membro privado da Maquina
        std::cout << "Controle: Iniciando Máquina ID " << maquina.id_ << std::endl;
    } else {
        std::cout << "Controle: Acesso negado para iniciar Máquina ID " << maquina.id_ << std::endl;
    }
}

void Controle::pararMaquina(Maquina& maquina) {
    if (nivel_acesso_ > 0) {
        maquina.status_ = "Desligada"; // Acessa membro privado da Maquina
        std::cout << "Controle: Parando Máquina ID " << maquina.id_ << std::endl;
    } else {
        std::cout << "Controle: Acesso negado para parar Máquina ID " << maquina.id_ << std::endl;
    }
}

int main() {
    Maquina m(101);
    Controle c;

    m.exibirStatus();
    c.iniciarMaquina(m);
    m.exibirStatus();
    c.pararMaquina(m);
    m.exibirStatus();

    return 0;
}

Observações Importantes sobre Classes Amigas:

  1. Relação Unidirecional: A amizade não é recíproca. Se a Classe A é amiga da Classe B, isso não significa automaticamente que a Classe B é amiga da Classe A. Para que B tenha acesso aos membros privados de A, A também deve declarar B como sua amiga.
  2. Não Transmitível: A amizade não é transitiva. Se a Classe A é amiga da Classe B, e a Classe B é amiga da Classe C, isso não implica que a Classe A seja amiga da Classe C.
  3. Não Hereditária: A amizade não é herdada. Se uma classe base é amiga de outra classe, suas classes derivadas não herdam essa relação de amizade automaticamente.

Classes Aninhadas (Nested Classes)

Uma classe aninhada é uma classe declarada dentro do escopo de outra classe. A classe aninhada é um tipo de dado completamente separado e não é um membro de dado da classe externa. O uso de classes aninhadas é menos comum em C++ do que em linguagens como Java, mas possui suas aplicações específicas.

Características das Classes Aninhadas:

  1. A classe aninhada é um tipo independente e não um membro do objeto da classe externa. O tamanho de um objeto da classe externa não é afetado pela existência de uma classe aninhada.
  2. Uma classe aninhada pode ser declarada nas seções public, protected ou private da classe externa. Isso afeta apenas a visibilidade do nome da classe aninhada, não suas permissões de acesso.
  3. A classe aninhada pode acessar membros estáticos da classe externa diretamente (sem precisar de um objeto da classe externa).
  4. Para acessar membros não estáticos da classe externa, a classe aninhada precisa de uma referência ou ponteiro para um objeto da classe externa. A classe aninhada não é automaticamente uma classe amiga da classe externa, a menos que seja explicitamente declarada como tal.
#include <iostream>
#include <string>

class Monitor {
private:
    static std::string modelo_padrao_;
    int brilho_ = 50;

public:
    Monitor() {}

    // Classe aninhada: Pixel
    class Pixel {
    public:
        Pixel(int r, int g, int b) : r_(r), g_(g), b_(b) {}

        void imprimirPixel(const Monitor& m) { // Precisa de um objeto Monitor para acessar membros não estáticos
            std::cout << "Pixel RGB(" << r_ << ", " << g_ << ", " << b_ << ")"
                      << ", Brilho do Monitor (via obj externo): " << m.brilho_ // Acesso a membro privado NÃO estático
                      << ", Modelo Padrão (via static): " << Monitor::modelo_padrao_ // Acesso a membro privado estático
                      << std::endl;
        }

    private:
        int r_, g_, b_;
    };
};

// Definição do membro estático da classe externa
std::string Monitor::modelo_padrao_ = "Modelo XYZ";

int main() {
    // Para criar um objeto da classe aninhada, usamos o escopo da classe externa
    Monitor::Pixel p1(255, 0, 0); 
    // Se Pixel fosse private em Monitor, não poderíamos criar p1 diretamente aqui.

    Monitor meu_monitor;
    p1.imprimirPixel(meu_monitor); // Passando uma instância da classe externa

    return 0;
}

Objetos Anônimos (Temporários)

Um objeto anônimo (ou objeto temporário) é um objeto sem nome que é criado para uso imediato dentro de uma única expressão. Sua vida útil é muito curta; ele é construído e destruído no final da expressão em que é criado. Isso pode ser útil para chamadas de funções únicas ou para inicializações simples sem a necessidade de uma variável intermediária nomeada.

#include <iostream>
#include <string>

class Logador {
public:
    Logador(const std::string& mensagem) : mensagem_(mensagem) {
        std::cout << "Logador construído: '" << mensagem_ << "'" << std::endl;
    }
    ~Logador() {
        std::cout << "Logador destruído: '" << mensagem_ << "'" << std::endl;
    }
    void registrar() const {
        std::cout << "Registrando mensagem: '" << mensagem_ << "'" << std::endl;
    }
private:
    std::string mensagem_;
};

class Calculadora {
public:
    int somar(int a, int b) const {
        // Logador("Calculando soma").registrar(); // Exemplo de uso temporário
        return a + b;
    }
};

int main() {
    // Criação de objetos nomeados
    Logador l1("Evento de inicializacao");
    l1.registrar();

    // Criação e uso de objetos anônimos
    // O objeto Logador("Aviso temporario") é criado, usado, e destruído imediatamente.
    Logador("Aviso temporario").registrar(); 

    // Uso de objeto anônimo para chamar um método
    int resultado = Calculadora().somar(5, 7);
    std::cout << "Resultado da soma: " << resultado << std::endl;

    // A otimização de valor de retorno (RVO) e nomeada RVO (NRVO) 
    // pode eliminar cópias de objetos temporários em alguns cenários.

    return 0;
}

Tags: C++ OOP classes Objects constructors

Publicado em 7-20 19:55