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.
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
thises uno.
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.
#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;
}g++ -std=c++17 -Wall -o acceso acceso.cpp
./acceso10
10Si descomentas la línea c.valor = 10;, el programa deja de compilar con un mensaje muy explícito:
error: 'int Cerrado::valor' is private within this contextFí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.
| Especificador | Quién puede acceder | Para qué se usa |
|---|---|---|
public | Cualquiera, desde fuera de la clase | La interfaz: lo que la clase ofrece al mundo |
private | Solo el código de la propia clase | Los datos internos y los métodos auxiliares |
protected | La propia clase y las clases que hereden de ella | Solo tiene sentido con herencia |
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
};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 &.
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 clasestd::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:
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:
#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;
}2Contador::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.
:: (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.
#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;
}g++ -std=c++17 -Wall -o personas personas.cpp
./personas(sin nombre), 0 años
Ana, 29 añosDos 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.
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:
Persona(const std::string& n, int e) : nombre_(n), edad_(e) {}
// ^ desde aquí hasta la llave de aperturaEntre 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:
#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;
}hola / holaEl 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.
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:
error: uninitialized const member in 'const int'
error: assignment of read-only member 'Config::limite'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,thises unPersona* const: un puntero constante a unPersonamodificable (puedes cambiar el objeto, pero no hacer quethisapunte a otro). - En un método
const, es unconst 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:
#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;
}g++ -std=c++17 -Wall -o contador contador.cpp
./contador5
12Dentro 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.
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:
~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.
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:
- 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. - Libertad para cambiar el interior. Si mañana
saldo_pasa dedoublea un tipo entero de centavos para evitar errores de redondeo, el métodosaldo()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. - 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.
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.
#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;
}g++ -std=c++17 -Wall -o cuenta cuenta.cpp
./cuenta[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:
- El primer mensaje se imprime sin que nadie lo pida: es el constructor, disparado por
CuentaBancaria cuenta("Ana", 1000.0);. retirar(2000.0)no tocasaldo_: el método comprobó los fondos y se negó. Desde fuera de la clase no hay ninguna forma de saltarse esa comprobación, porquesaldo_es privado.- El saldo final es
1200(1000 + 500 − 300), leído a través del gettersaldo(). - 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().
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 dondeprotectedcobra 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.