</>waridocu

C++

Clases: encapsulación, constructores y destructores

Cómo una clase protege sus datos, garantiza nacer en un estado válido con constructores y limpia lo suyo con un destructor.

cppclasespooencapsulacionconstructoresdestructores

De dónde venimos

Structs cerró con una frase muy corta que aquí se convierte en el tema entero: en un struct los miembros son públicos por defecto; en una class son privados por defecto. Esa es, mecánicamente, la única diferencia entre los dos.

Si es solo un valor por defecto, la pregunta obvia es por qué existe la palabra class. La respuesta no está en el compilador sino en la intención: struct se usa para datos sueltos que cualquiera puede tocar; class para tipos que controlan sus propios datos y solo dejan entrar por las puertas que ellos deciden abrir.

Esta página también apoya en tres cosas que ya viste:

  • Las funciones, porque un método no es más que una función que vive dentro de un tipo.
  • El ámbito de un bloque, porque de ahí sale el momento exacto en que se ejecuta un destructor.
  • Los punteros, porque this es uno.
Nota

Esta página cubre la mitad de la programación orientada a objetos que ya se nombró en Paradigmas que soporta: encapsular datos y darles comportamiento. La otra mitad (herencia y polimorfismo) queda fuera de Fundamentos, igual que new/delete quedaron fuera de Punteros.

Qué es una clase

Una clase es un tipo definido por ti que agrupa datos y las funciones que operan sobre esos datos, y decide cuáles de esas piezas son visibles desde afuera.

La diferencia de fondo con un struct no es sintáctica, es de responsabilidad. Un Punto con x e y públicos confía en que quien lo use no escriba cualquier cosa. Una clase, en cambio, puede garantizar que sus datos siempre cumplan una regla: que un saldo nunca sea negativo, que una fecha nunca tenga mes 13, que un texto nunca quede sin terminador. A esa garantía se le llama invariante de la clase, y solo se puede sostener si nadie de afuera puede modificar los datos directamente.

Ejemplo: struct y class solo se diferencian en el acceso por defectocpp
#include <iostream>

struct Abierto {
    int valor;              // público por defecto
};

class Cerrado {
    int valor;              // privado por defecto

public:
    void poner(int v) { valor = v; }
    int leer() const { return valor; }
};

int main() {
    Abierto a;
    a.valor = 10;                       // permitido: el miembro es público
    std::cout << a.valor << "\n";

    Cerrado c;
    // c.valor = 10;                    // ERROR: el miembro es privado
    c.poner(10);                        // la única puerta de entrada
    std::cout << c.leer() << "\n";
    return 0;
}
bashbash
g++ -std=c++17 -Wall -o acceso acceso.cpp
./acceso
texttext
10
10

Si descomentas la línea c.valor = 10;, el programa deja de compilar con un mensaje muy explícito:

texttext
error: 'int Cerrado::valor' is private within this context

Fíjate en que el error ocurre al compilar, no al ejecutar. Ese es el punto: la protección no cuesta nada en tiempo de ejecución, es una regla que el compilador hace cumplir y después desaparece.

Encapsulación: public, private y protected

Encapsulación es exactamente lo que acabas de ver: separar lo que se puede tocar desde afuera de lo que es asunto interno del tipo. C++ lo expresa con tres especificadores de acceso, que se escriben como etiquetas seguidas de : y afectan a todo lo que venga después hasta la siguiente etiqueta.

EspecificadorQuién puede accederPara qué se usa
publicCualquiera, desde fuera de la claseLa interfaz: lo que la clase ofrece al mundo
privateSolo el código de la propia claseLos datos internos y los métodos auxiliares
protectedLa propia clase y las clases que hereden de ellaSolo tiene sentido con herencia
Ejemplo: las tres etiquetas de acceso en una clasecpp
class Ejemplo {
public:
    void interfaz();        // visible desde cualquier parte

private:
    int secreto;            // solo accesible desde métodos de Ejemplo

protected:
    int compartido;         // como private, pero visible para clases derivadas
};
Nota

protected solo cobra sentido cuando existe herencia (una clase que se construye a partir de otra), y la herencia queda fuera del alcance de Fundamentos, igual que la POO completa quedó fuera de Structs. Por ahora te basta con reconocerlo cuando lo veas: es “privado, salvo para mis descendientes”.

Ese : de public: es el mismo carácter que ya viste en otros dos contextos completamente distintos —las etiquetas case de un switch y el separador del for basado en rango— y en un minuto vas a ver un tercero. Que el mismo símbolo signifique cosas distintas según el contexto es habitual en C++, tal como pasaba con * y &.

Tip

El orden convencional es public: primero y private: al final. La razón es humana, no técnica: quien lee tu clase por primera vez quiere saber qué puede hacer con ella (la interfaz pública), no cómo está construida por dentro. El compilador acepta cualquier orden y permite repetir las etiquetas cuantas veces quieras.

Atributos y métodos

Dentro de una clase conviven dos clases de miembros:

  • Miembros de datos (o atributos): las variables que guardan el estado del objeto. Son los mismos campos que ya conoces de los structs.
  • Funciones miembro (o métodos): funciones declaradas dentro de la clase, que pueden leer y modificar esos atributos sin recibirlos como parámetros. Ya vienes usando métodos sin llamarlos así: miString.size() o .c_str() de cadenas son métodos de la clase std::string.

Un método se llama con el mismo operador punto de los structs (objeto.metodo()), o con -> si lo que tienes es un puntero al objeto.

Métodos const

Fíjate en el const que aparece después de la lista de parámetros en int leer() const:

Ejemplo: un método que promete no modificar el objetocpp
class Cerrado {
public:
    int leer() const { return valor; }   // const: este método no cambia nada
    void poner(int v) { valor = v; }     // sin const: sí lo cambia

private:
    int valor;
};

Ese const es una promesa verificada por el compilador: dentro de un método const no puedes modificar ningún atributo, y si lo intentas el código no compila. Es la misma idea de const en variables, aplicada al objeto completo.

No es un adorno: si alguien recibe tu objeto por referencia constante (const CuentaBancaria&, la forma habitual de pasar objetos grandes sin copiarlos), solo podrá llamar a los métodos marcados const. Olvidarte de marcarlos convierte tu clase en algo incómodo de usar.

Definir un método fuera de la clase

Hasta ahora escribimos el cuerpo del método dentro de la clase, pero eso es solo una comodidad para ejemplos cortos. Lo habitual en proyectos reales es declarar dentro y definir fuera, exactamente igual que un prototipo de función:

Ejemplo: declarar dentro de la clase, definir fuera con ::cpp
#include <iostream>

class Contador {
public:
    Contador(int inicial);          // solo la declaración
    void incrementar();
    int valor() const;

private:
    int valor_;
};

Contador::Contador(int inicial) : valor_(inicial) {}

void Contador::incrementar() {
    ++valor_;
}

int Contador::valor() const {
    return valor_;
}

int main() {
    Contador c(0);
    c.incrementar();
    c.incrementar();
    std::cout << c.valor() << "\n";
    return 0;
}
texttext
2

Contador::incrementar usa el operador de resolución de ámbito ::, el mismo de std::cout: ahí significaba “el cout que vive dentro del namespace std”, y aquí significa “el incrementar que pertenece a la clase Contador”. Sin ese prefijo estarías definiendo una función global suelta que no tiene nada que ver con la clase.

Importante

:: (dos caracteres) y : (uno) son símbolos distintos aunque se parezcan. :: es resolución de ámbito; : aparece en las etiquetas de acceso, en los case, en el for de rango y —lo que viene a continuación— en la lista de inicialización de un constructor. Confundirlos produce errores de compilación desconcertantes.

Constructores

Un constructor es un método especial que se ejecuta automáticamente al crear un objeto. Se reconoce porque se llama igual que la clase y no declara tipo de retorno (ni siquiera void).

Su razón de existir es concreta. Con un struct tienes que acordarte de inicializar cada campo a mano después de crearlo, y si te olvidas de uno queda con basura. Un constructor le quita esa responsabilidad a quien usa la clase: no existe forma de crear el objeto sin pasar por él, así que el objeto nace ya en un estado válido.

Ejemplo: constructor por defecto y constructor con parámetroscpp
#include <iostream>
#include <string>

class Persona {
public:
    Persona() : nombre_("(sin nombre)"), edad_(0) {}                // constructor por defecto
    Persona(const std::string& n, int e) : nombre_(n), edad_(e) {}  // con parámetros

    void mostrar() const {
        std::cout << nombre_ << ", " << edad_ << " años\n";
    }

private:
    std::string nombre_;
    int edad_;
};

int main() {
    Persona anonima;                    // usa el constructor por defecto
    Persona ana("Ana", 29);             // usa el de dos parámetros

    anonima.mostrar();
    ana.mostrar();
    return 0;
}
bashbash
g++ -std=c++17 -Wall -o personas personas.cpp
./personas
texttext
(sin nombre), 0 años
Ana, 29 años

Dos constructores con el mismo nombre y distintos parámetros: eso es simplemente sobrecarga de funciones aplicada a constructores. El compilador elige cuál usar según los argumentos que le des.

  • Constructor por defecto: el que no recibe parámetros. Es el que se usa al escribir Persona anonima;.
  • Constructor con parámetros: recibe los datos necesarios para nacer completo.
Advertencia

Si no escribes ningún constructor, el compilador te genera uno por defecto gratis (por eso Persona p; funcionaba con los structs de la página anterior). Pero en cuanto escribes uno solo —aunque sea el de dos parámetros—, ese regalo desaparece: Persona anonima; deja de compilar salvo que declares tú también el constructor sin parámetros. Es una de las sorpresas más frecuentes al escribir la primera clase.

La lista de inicialización de miembros

Esta parte del constructor merece su propia sección porque es sintaxis nueva y no se parece a nada de lo anterior:

Ejemplo: dónde empieza la lista de inicializacióncpp
Persona(const std::string& n, int e) : nombre_(n), edad_(e) {}
//                                   ^ desde aquí hasta la llave de apertura

Entre el ) de los parámetros y el { del cuerpo puede ir un : seguido de una lista, separada por comas, de la forma miembro(valor). Cada entrada inicializa un atributo con ese valor, antes de que el cuerpo del constructor empiece a ejecutarse.

Ese : es un cuarto uso del carácter, distinto del :: de arriba, del case de un switch y del separador del for de rango. Aquí significa: “y estos son los valores con los que nacen mis miembros”.

La alternativa es asignar dentro del cuerpo, y parece equivalente pero no lo es:

Ejemplo: inicializar en la lista frente a asignar en el cuerpocpp
#include <iostream>
#include <string>

class ConLista {
public:
    ConLista(const std::string& t) : texto_(t) {}   // un paso: nace con su valor
    const std::string& ver() const { return texto_; }
private:
    std::string texto_;
};

class ConAsignacion {
public:
    ConAsignacion(const std::string& t) { texto_ = t; }  // dos pasos: nace vacío y luego se le asigna
    const std::string& ver() const { return texto_; }
private:
    std::string texto_;
};

int main() {
    ConLista a("hola");
    ConAsignacion b("hola");
    std::cout << a.ver() << " / " << b.ver() << "\n";
    return 0;
}
texttext
hola / hola

El resultado visible es idéntico, pero lo que ocurre por dentro no:

  • Con lista de inicialización, texto_ se construye una sola vez, directamente con el valor final.
  • Con asignación en el cuerpo, texto_ se construye primero vacío (su propio constructor por defecto ya corrió antes de entrar al cuerpo) y después se le asigna el valor. Son dos operaciones donde bastaba una.

Con un int la diferencia es imperceptible. Con un std::string, un vector o cualquier objeto que reserve memoria, estás pagando una construcción entera para tirarla a la basura una línea después.

Importante

Para algunos miembros la lista de inicialización no es una preferencia sino la única opción: los miembros const y los miembros que son referencias (int&) tienen que recibir su valor en el momento de nacer, porque después ya no se pueden asignar. Intentarlo produce dos errores en cascada:

texttext
error: uninitialized const member in 'const int'
error: assignment of read-only member 'Config::limite'
Advertencia

Los miembros se inicializan en el orden en que están declarados en la clase, no en el orden en que los escribes en la lista. Si declaras int ancho_; antes que int alto_; pero escribes : alto_(a), ancho_(b), el compilador inicializa igualmente ancho_ primero. Con -Wall recibes un aviso (-Wreorder); el bug real aparece si un miembro se inicializa usando el valor de otro que todavía no existe. Mantén siempre la lista en el mismo orden que las declaraciones.

El puntero this

Dentro de cualquier método, this es un puntero al objeto sobre el que se llamó ese método. Si escribes ana.mostrar(), dentro de mostrar() el valor de this es la dirección de ana.

Su tipo se deduce de la clase y del método:

  • En un método normal de Persona, this es un Persona* const: un puntero constante a un Persona modificable (puedes cambiar el objeto, pero no hacer que this apunte a otro).
  • En un método const, es un const Persona* const: tampoco puedes modificar el objeto a través de él.

La mayor parte del tiempo no necesitas escribirlo: cuando dentro de un método usas edad_, el compilador ya entiende this->edad_. Se vuelve necesario cuando hay una ambigüedad de nombres, típicamente porque un parámetro se llama igual que un atributo:

Ejemplo: this para distinguir el miembro del parámetrocpp
#include <iostream>

class Contador {
public:
    Contador(int valor) : valor(valor) {}   // en la lista no hace falta this

    void ajustar(int valor) {
        this->valor = valor;                // izquierda: el miembro; derecha: el parámetro
    }

    int leer() const { return valor; }

private:
    int valor;
};

int main() {
    Contador c(5);
    std::cout << c.leer() << "\n";
    c.ajustar(12);
    std::cout << c.leer() << "\n";
    return 0;
}
bashbash
g++ -std=c++17 -Wall -o contador contador.cpp
./contador
texttext
5
12

Dentro de ajustar, valor a secas se refiere al parámetro (el nombre más cercano gana, por la regla de ámbito), así que sin this-> la línea valor = valor; se asignaría el parámetro a sí mismo y el atributo nunca cambiaría. this->valor desambigua sin lugar a dudas.

El caso de la lista de inicialización es distinto y por eso no lleva this: en : valor(valor), el nombre antes del paréntesis siempre es el miembro y el de dentro siempre se resuelve como el parámetro. La ambigüedad ahí no existe.

Tip

Muchos proyectos evitan el problema de raíz con una convención de nombres: guion bajo al final para los atributos (valor_, saldo_, nombre_), como en el resto de los ejemplos de esta página. Así un parámetro nunca choca con un miembro, y además el nombre limpio (saldo) queda libre para el método que lo devuelve. No es obligatorio, pero te ahorra decisiones.

Destructores

Un destructor es el método que se ejecuta automáticamente cuando el objeto deja de existir. Se escribe como el constructor pero con una virgulilla ~ delante, no recibe parámetros y no devuelve nada:

Ejemplo: la forma de un destructorcpp
~CuentaBancaria() { /* ... */ }

No lo llamas tú: lo llama C++. ¿Cuándo, exactamente? Cuando el objeto sale de su ámbito — al cerrarse la llave del bloque donde se declaró. Aquella regla de “una variable declarada dentro de un bloque solo existe dentro de ese bloque” no era solo sobre visibilidad: en el instante en que el bloque termina, C++ ejecuta el destructor de cada objeto que vivía ahí, en orden inverso al de creación.

Para qué sirve: para devolver lo que el objeto haya pedido prestado. Si la clase abrió un archivo, reservó memoria, tomó una conexión de red o bloqueó un recurso compartido, el destructor es el lugar donde se cierra, se libera y se suelta. Y como se ejecuta siempre —salgas del bloque por el camino normal, por un return anticipado o por una excepción— es imposible olvidarse.

Nota

Esa combinación (adquirir el recurso en el constructor, soltarlo en el destructor, dejar que el ámbito decida el cuándo) tiene nombre propio: RAII, la idea que ya se mencionó al pasar en Bloques y ámbito y en Punteros. Es el mecanismo que hace que en C++ moderno casi nunca escribas delete a mano. No lo desarrollamos aquí, pero ya tienes la pieza de la que depende: el destructor.

La mayoría de las clases sencillas no necesitan destructor: si tus atributos son int, double o std::string, cada uno sabe limpiarse solo y el destructor que el compilador genera por ti hace exactamente lo correcto. En el ejemplo que viene, el destructor solo imprime un mensaje — está ahí para que veas con tus ojos el momento en que se ejecuta.

Getters y setters

Con los atributos en private, hace falta una forma controlada de leerlos y modificarlos. Ese es el patrón de getter (método que devuelve el valor de un atributo) y setter (método que lo modifica).

La pregunta razonable es: si al final expones un método por cada dato, ¿qué ganaste frente a dejarlos públicos? Tres cosas concretas:

  1. Validación. Un atributo público acepta cualquier cosa: cuenta.saldo = -9999; compila. Un setter puede rechazar valores imposibles antes de que corrompan el objeto.
  2. Libertad para cambiar el interior. Si mañana saldo_ pasa de double a un tipo entero de centavos para evitar errores de redondeo, el método saldo() sigue existiendo con la misma firma y ningún código que use la clase se rompe. Con el atributo público, cada uso en todo el proyecto habría que revisarlo.
  3. Control de escritura. Puedes ofrecer getter sin setter y hacer un dato de solo lectura desde afuera, algo que un atributo público no permite expresar.
Advertencia

Escribir mecánicamente un getter y un setter para cada atributo es un antipatrón conocido: el resultado es una clase con los datos igual de expuestos que si fueran públicos, solo que con más código de por medio. La pregunta correcta no es “¿qué datos tengo?” sino “¿qué operaciones ofrece este tipo?”. En el ejemplo siguiente, CuentaBancaria no tiene un setter de saldo: tiene depositar y retirar, que son las dos únicas maneras legítimas de que un saldo cambie. Eso es diseñar la interfaz, no exponer los campos.

Ejemplo completo: una cuenta bancaria

Este programa junta todo lo anterior: atributos privados, constructor con lista de inicialización, métodos que validan, un getter const y un destructor que anuncia cuándo se ejecuta.

Ejemplo: clase CuentaBancaria completacpp
#include <iostream>
#include <string>

class CuentaBancaria {
public:
    CuentaBancaria(const std::string& titular, double saldoInicial)
        : titular_(titular), saldo_(saldoInicial) {
        std::cout << "[cuenta de " << titular_ << " abierta con " << saldo_ << "]\n";
    }

    ~CuentaBancaria() {
        std::cout << "[cuenta de " << titular_ << " cerrada]\n";
    }

    void depositar(double monto) {
        if (monto <= 0.0) {
            std::cout << "Depósito inválido: " << monto << "\n";
            return;
        }
        saldo_ += monto;
    }

    bool retirar(double monto) {
        if (monto <= 0.0 || monto > saldo_) {
            std::cout << "Retiro rechazado: fondos insuficientes\n";
            return false;
        }
        saldo_ -= monto;
        return true;
    }

    double saldo() const { return saldo_; }
    const std::string& titular() const { return titular_; }

private:
    std::string titular_;
    double saldo_;
};

int main() {
    {
        CuentaBancaria cuenta("Ana", 1000.0);

        cuenta.depositar(500.0);
        cuenta.retirar(2000.0);          // más de lo que hay: rechazado
        cuenta.retirar(300.0);           // aceptado

        std::cout << "Saldo de " << cuenta.titular() << ": " << cuenta.saldo() << "\n";
    }   // aquí termina el bloque: se ejecuta el destructor de cuenta

    std::cout << "Fin de main()\n";
    return 0;
}
bashbash
g++ -std=c++17 -Wall -o cuenta cuenta.cpp
./cuenta
texttext
[cuenta de Ana abierta con 1000]
Retiro rechazado: fondos insuficientes
Saldo de Ana: 1200
[cuenta de Ana cerrada]
Fin de main()

Lee la salida de arriba a abajo y fíjate en el orden, que es la lección entera de la página:

  1. El primer mensaje se imprime sin que nadie lo pida: es el constructor, disparado por CuentaBancaria cuenta("Ana", 1000.0);.
  2. retirar(2000.0) no toca saldo_: el método comprobó los fondos y se negó. Desde fuera de la clase no hay ninguna forma de saltarse esa comprobación, porque saldo_ es privado.
  3. El saldo final es 1200 (1000 + 500 − 300), leído a través del getter saldo().
  4. El mensaje de cierre aparece antes de “Fin de main()”, aunque en el código esté escrito después. Es el destructor ejecutándose en la llave que cierra el bloque interior. Ese bloque suelto está puesto a propósito para que el efecto se vea; sin él, el destructor correría igual, pero al final de main().
Advertencia

No olvides el ; después de la llave que cierra la clase: };, no }. Es la misma trampa que ya advertía Structs, y sigue siendo válida aquí porque una definición de clase es una declaración, no un bloque de código como el cuerpo de una función (que sí termina en } a secas). Si lo olvidas, el compilador arrastra el error a la línea siguiente y suele quejarse de algo que no tiene nada que ver, como expected initializer before 'int' señalando tu main().

Lo que deliberadamente no vimos aquí

Con lo de esta página ya puedes escribir clases útiles y leer la mayoría del código orientado a objetos que te encuentres. Quedan fuera, por pertenecer a un nivel más avanzado:

  • Herencia y polimorfismo: construir una clase a partir de otra, métodos virtual, clases abstractas. Es donde protected cobra sentido.
  • RAII completo y gestión de recursos: el constructor de copia, el operador de asignación y los constructores de movimiento (la llamada “regla de cinco”), que importan en cuanto tu clase administra memoria propia.
  • Templates: clases genéricas que funcionan con cualquier tipo, como std::vector<T>.

Siguiente paso

En esta página apareció una forma nueva de darle valor a algo (: nombre_(n)), sumada a las que ya venías usando: int x = 5;, double y{1.75};, Persona ana("Ana", 29);, int notas[5] = {7, 4, 6, 3, 5};. Son cinco sintaxis distintas para la misma idea, y no son intercambiables: una de ellas rechaza conversiones peligrosas y otra ni siquiera crea el objeto que parece crear. Sigue con Declaración e inicialización a fondo.