Clases, constructores y encapsulamiento
Agrupar datos y comportamiento sin dejar que cualquiera rompa las reglas
Una clase reúne datos y las operaciones que los mantienen válidos. En esta lección escribes constructores que inicializan de verdad, métodos const que prometen no modificar el objeto, miembros estáticos compartidos y separas la declaración en un header de la implementación en un .cpp.
- Definir clases con atributos privados y una interfaz pública mínima
- Inicializar miembros con la lista de inicialización y explicar por qué importa el orden
- Marcar como const los métodos que solo leen y usarlos sobre objetos const
- Usar this y miembros static cuando el diseño lo requiere
- Separar una clase en header y archivo de implementación
Antes necesitas: Arrays, std::vector y std::string · Paso por valor, referencia, sobrecarga y recursión
En esta lección
- ¿Qué es una clase?
- ¿Para qué sirve el encapsulamiento?
- ¿Cómo funciona por dentro?
- Sintaxis
- Ejemplo básico: un sensor con estado válido
- Ejemplo intermedio: contador de instancias y destructor
- Caso práctico: declaración en el header, implementación en el .cpp
- Visualización: construir y destruir objetos
- Errores comunes
- Buenas prácticas
- Ejercicios
- Soluciones explicadas
- Resumen
- Evaluación
Hasta ahora tus datos vivían en variables sueltas: un vector por aquí, un umbral por allá, y funciones que reciben todo por parámetro. Una clase cambia eso: define un tipo nuevo que lleva sus datos y las operaciones que los mantienen coherentes. El molde es la clase; cada objeto concreto es una instancia con sus propios valores.
La idea central es el invariante: una condición que siempre debe cumplirse, por ejemplo «el saldo nunca es negativo» o «el mínimo es menor que el máximo». Si los datos son públicos, cualquiera puede romper esa condición desde fuera. El encapsulamiento consiste en esconder los datos y ofrecer métodos que los cambien solo de formas válidas.
¿Qué es una clase?
Una clase es una plantilla que describe dos cosas: qué datos guarda cada objeto y qué mensajes acepta. En C++ la diferencia entre class y struct es solo el acceso por defecto: en class los miembros son privados y en struct son públicos. Todo lo demás es idéntico, y por convención se usa struct para agrupaciones simples sin invariantes.
- Miembros de datos: las variables que forman parte de cada objeto (
saldo_,nombre_). - Métodos: funciones que operan sobre ese objeto y pueden leer o modificar sus datos.
- Constructor: se ejecuta al crear el objeto y deja todos los miembros en un estado válido.
- Destructor: se ejecuta al destruirlo y libera lo que el objeto posea.
- Especificadores de acceso:
publicpara la interfaz,privatepara la representación interna.
¿Para qué sirve el encapsulamiento?
- Mantener la coherencia: un método
retirarpuede rechazar montos negativos que un atributo público aceptaría sin protestar. - Cambiar la implementación sin romper a nadie: si mañana guardas el saldo en céntimos, la interfaz
saldo()sigue igual. - Expresar intención:
modelo.entrenar(datos)dice más que cinco líneas sueltas que tocan tres vectores. - Controlar la vida del recurso: el constructor reserva y el destructor libera, sin que el usuario lo recuerde.
- Modelar el dominio: un
Dataset, unEscaladoro unaPrediccionse leen como conceptos, no como tuplas.
En Machine Learning casi todo lo que usas ya es un objeto: un std::vector sabe su tamaño y un modelo expone fit y predict sobre datos internos. Escribir tus propias clases te permite construir un pipeline con piezas comprobables por separado.
¿Cómo funciona por dentro?
Cada objeto es un bloque de memoria contiguo con sus miembros de datos en el orden en que se declaran. Los métodos no ocupan espacio por objeto: son código compartido que recibe implícitamente la dirección del objeto, y esa dirección es this. Por eso valor_ dentro de un método significa en realidad this->valor_.
El compilador genera varios miembros especiales si no los escribes: un constructor por defecto (solo si no declaraste otro), un constructor de copia, un operador de asignación y un destructor. Esa generación automática es cómoda hasta que la clase posee un recurso; de eso se ocupa la lección de la regla de 0/3/5.
El acceso private es una comprobación del compilador, no un mecanismo en tiempo de ejecución: una vez compilado no hay verificación de permisos. Un miembro static tampoco pertenece a ningún objeto: hay una sola copia para toda la clase.
Sintaxis
class Sensor {
public: // interfaz visible
Sensor(std::string nombre, double valor);
double valor() const; // no modifica el objeto
void actualizar(double nuevo);
private: // representacion interna
std::string nombre_;
double valor_;
static int instancias_; // una copia compartida
};El guion bajo final en nombre_ y valor_ es una convención para distinguir miembros de parámetros. El const después de la lista de parámetros pertenece al método, no al valor devuelto: promete que la función no modificará el objeto. El punto y coma tras la llave de cierre es obligatorio y su olvido produce un error de compilación confuso.
La lista de inicialización se escribe entre : y el cuerpo: Sensor(std::string n, double v) : nombre_(std::move(n)), valor_(v) {}. Los miembros se inicializan en el orden en que están declarados, no en el orden en que aparecen en la lista. Si el orden no coincide, algunos compiladores avisan con -Wreorder y el resultado puede sorprenderte.
class Sensor:
def __init__(self, nombre, valor):
self.nombre = nombre
self.valor = valor
s = Sensor("temp", 21.5)
print(s.valor)#include <iostream>
#include <string>
#include <utility>
class Sensor {
public:
Sensor(std::string nombre, double valor)
: nombre_(std::move(nombre)), valor_(valor) {}
double valor() const { return valor_; }
private:
std::string nombre_;
double valor_;
};
int main() {
Sensor s{"temp", 21.5};
std::cout << s.valor() << '\n';
return 0;
}Python permite crear atributos en cualquier momento y no tiene privacidad real. En C++ los miembros se declaran antes de usarlos, el constructor los inicializa en un orden fijo y `private` lo verifica el compilador.
Ejemplo básico: un sensor con estado válido
#include <iostream>
#include <string>
#include <utility>
class Sensor {
public:
Sensor(std::string nombre, double valor)
: nombre_(std::move(nombre)), valor_(valor) {}
double valor() const { return valor_; }
void actualizar(double nuevo) { valor_ = nuevo; }
private:
std::string nombre_;
double valor_;
};
int main() {
Sensor temp{"temperatura", 21.5};
std::cout << temp.valor() << '\n';
temp.actualizar(22.8);
std::cout << temp.valor() << '\n';
return 0;
}Sensor temp{...}reserva el objeto completo en el stack: unstd::stringy undoublecontiguos.- El constructor recibe los argumentos por valor y mueve el string al miembro con
std::move, evitando una copia. valor()esconst, así que puede llamarse sobre un objeto que no se va a modificar y el compilador lo verifica.actualizar(22.8)escribe envalor_del objetotemp; el cambio se ve en la segunda lectura.
La salida es 21.5 y luego 22.8. Nada fuera de la clase puede escribir temp.valor_ = 99 porque el miembro es privado: la única vía es actualizar, y ahí podrías añadir una validación de rango cuando el dominio lo exija.
Ejemplo intermedio: contador de instancias y destructor
Un miembro static sirve para llevar información común a todos los objetos. Aquí cuenta cuántas cuentas bancarias están vivas: el constructor incrementa y el destructor decrementa. Observa el bloque interno de main: al salir de él, b se destruye y el contador baja sin que nadie lo llame explícitamente.
#include <iostream>
#include <string>
#include <utility>
class Cuenta {
public:
Cuenta(std::string titular, double saldo_inicial)
: titular_(std::move(titular)), saldo_(saldo_inicial) {
if (saldo_ < 0.0) {
saldo_ = 0.0;
}
++cuentas_;
}
~Cuenta() { --cuentas_; }
bool retirar(double monto) {
if (monto <= 0.0 || monto > saldo_) {
return false;
}
saldo_ -= monto;
return true;
}
void mostrar() const {
std::cout << titular_ << ": " << saldo_ << '\n';
}
static int activas() { return cuentas_; }
private:
std::string titular_;
double saldo_;
static int cuentas_;
};
int Cuenta::cuentas_ = 0;
int main() {
Cuenta a{"Ana", 100.0};
{
Cuenta b{"Luis", 50.0};
std::cout << Cuenta::activas() << '\n';
b.retirar(20.0);
b.mostrar();
}
std::cout << Cuenta::activas() << '\n';
a.mostrar();
std::cout << a.retirar(500.0) << '\n';
return 0;
}La salida es 2, Luis: 30, 1, Ana: 100 y 0. El 0 final es el bool de retirar(500.0): la operación se rechazó y el objeto conserva 100 porque el método comprobó antes de tocar el estado.
int Cuenta::cuentas_ = 0; es la definición del miembro estático, obligatoria y fuera de la clase. Si la omites, el enlace falla con undefined reference to Cuenta::cuentas_. En C++17 podrías escribir inline static int cuentas_ = 0; dentro de la clase y evitar la línea externa.
Caso práctico: declaración en el header, implementación en el .cpp
En un proyecto real la clase se declara en un header y se implementa en un archivo fuente. Así los demás módulos ven la interfaz sin recompilar el cuerpo de cada método. El header incluye lo mínimo, usa guardas de inclusión y declara; el .cpp incluye su propio header y define.
#ifndef ESCALADOR_H
#define ESCALADOR_H
#include <vector>
class Escalador {
public:
Escalador(double minimo, double maximo);
std::vector<double> aplicar(const std::vector<double>& datos) const;
double minimo() const { return minimo_; }
double maximo() const { return maximo_; }
private:
double minimo_;
double maximo_;
};
#endifLos métodos de una línea se definen dentro del header; el compilador los trata como inline y no hay problema de definiciones múltiples. Los métodos con lógica van al .cpp, donde se repite el nombre de la clase antes del método: Escalador::aplicar.
#include "escalador.h"
#include <stdexcept>
Escalador::Escalador(double minimo, double maximo)
: minimo_(minimo), maximo_(maximo) {
if (maximo_ <= minimo_) {
throw std::invalid_argument("maximo debe ser mayor que minimo");
}
}
std::vector<double> Escalador::aplicar(const std::vector<double>& datos) const {
std::vector<double> salida;
salida.reserve(datos.size());
const double rango = maximo_ - minimo_;
for (double x : datos) {
salida.push_back((x - minimo_) / rango);
}
return salida;
}El constructor valida el invariante «máximo mayor que mínimo» y lanza std::invalid_argument si no se cumple; las excepciones se explican en su propia lección, pero aquí ya ves para qué sirve tener un punto único de validación. aplicar es const porque solo lee los límites y devuelve un vector nuevo.
#include "escalador.h"
#include <iomanip>
#include <iostream>
#include <vector>
int main() {
const Escalador escalador{0.0, 10.0};
const std::vector<double> datos{2.0, 5.0, 9.0};
for (double x : escalador.aplicar(datos)) {
std::cout << std::fixed << std::setprecision(2) << x << ' ';
}
std::cout << '\n';
return 0;
}Con g++ -std=c++20 escalador.cpp main.cpp -o programa se compilan ambas unidades. La salida es 0.20 0.50 0.90. Llamar a un método no const sobre escalador no compilaría: esa es la protección que buscabas.
Visualización: construir y destruir objetos
La traza muestra el ciclo de vida de dos objetos. Fíjate en el orden: se construyen a y luego b, pero se destruyen al revés, como una pila. Ese orden inverso es lo que permite que un objeto use recursos creados por otro anterior.
#include <iostream>#include <string>#include <utility>struct Sensor { std::string nombre; Sensor(std::string n) : nombre(std::move(n)) { std::cout << "crear " << nombre << '\n'; } ~Sensor() { std::cout << "destruir " << nombre << '\n'; }};int main() { Sensor a{"temp"}; Sensor b{"humedad"}; std::cout << a.nombre << '\n'; return 0;}struct define el molde. Cada Sensor ocupará lo que mida un std::string más el código de sus métodos, que es compartido.—
Avanza paso a paso: observa la línea resaltada, las variables y la salida.
Si el objeto poseyera memoria reservada con new, el destructor sería el lugar para liberarla. Ese patrón, adquirir en el constructor y liberar en el destructor, es RAII, y es la razón por la que C++ no necesita un recolector de basura.
Errores comunes
| Error | Causa | Solución |
|---|---|---|
assignment of read-only member | Asignaste un miembro const dentro del cuerpo del constructor | Inicialízalo en la lista: : altura_(h) |
no matching function for call to ... discards qualifiers | Llamaste a un método no const sobre un objeto const | Marca el método como const si solo lee |
undefined reference to Cuenta::cuentas_ | Declaraste el static pero no lo definiste | Añade int Cuenta::cuentas_ = 0; en el .cpp |
expected ';' after class definition | Falta el punto y coma tras la llave de cierre | Escribe }; al final de la clase |
redefinition of class Sensor | El header se incluyó dos veces sin guarda | Usa #ifndef/#define o #pragma once |
El error de discards qualifiers casi siempre significa un const olvidado en un método de consulta: añádelo al final de la firma y en la definición del .cpp.
#include <iostream>
struct Cuenta {
double saldo;
};
int main() {
Cuenta c{100.0};
c.saldo = -50.0; // nada lo impide
std::cout << c.saldo << '\n';
return 0;
}#include <iostream>
class Cuenta {
public:
explicit Cuenta(double saldo) : saldo_(saldo) {}
bool retirar(double monto) {
if (monto <= 0.0 || monto > saldo_) {
return false;
}
saldo_ -= monto;
return true;
}
double saldo() const { return saldo_; }
private:
double saldo_;
};
int main() {
Cuenta c{100.0};
c.retirar(150.0);
std::cout << c.saldo() << '\n';
return 0;
}La versión con `struct` imprime `-50` y deja la cuenta en un estado imposible. La versión encapsulada imprime `100`: el intento de retirar más de lo disponible devuelve `false` sin tocar el saldo. `explicit` impide además que un `double` se convierta en `Cuenta` por accidente.
Buenas prácticas
- Declara los datos en
privatey expón solo lo que el usuario necesita. - Inicializa siempre en la lista de inicialización, en el mismo orden de declaración.
- Marca
consttodo método que no modifique el objeto. - Usa sufijo
_o prefijom_para distinguir miembros de parámetros. - Pasa objetos grandes como
const&y guarda strings constd::moveen el constructor. - Define los
staticfuera de la clase (o usainline staticdesde C++17). - Un header por clase, con guarda, y la implementación en su propio
.cpp.
Ejercicios
Cuenta bancaria encapsulada
CuentaBancaria con titular y saldo privados. El constructor debe rechazar saldos negativos dejándolos en cero. Añade depositar y retirar, que devuelven false ante montos no positivos o retiradas mayores que el saldo, y un método saldo() const. Prueba un depósito de 50, una retirada de 500 y muestra el titular.Pista
Valida en el constructor con un ternario o un if antes de asignar. Recuerda que retirar debe comprobar antes de restar, no después.
Dataset con estadísticas y contador estático
Dataset que reciba un std::vector<double> por valor y lo mueva a un miembro privado. Debe lanzar std::invalid_argument si el vector está vacío, ofrecer media() const y rango() const (máximo menos mínimo, con std::minmax_element) y llevar la cuenta de instancias en un miembro static. Prueba con {2.0, 4.0, 9.0}.Pista
Para el rango, std::minmax_element devuelve un par de iteradores; con auto [minimo, maximo] = ... obtienes ambos. Define el static fuera de la clase.
class Punto { public: Punto(int x, int y) : x_(), y_() {} int x() { return ; } private: int x_; int y_; };
La lista de inicialización usa el nombre del parámetro; el método que no modifica el objeto lleva `const` al final.
Soluciones explicadas
En la cuenta bancaria, la decisión de diseño importante es dónde validar. Si validaras en main antes de llamar a retirar, cualquier otro código podría olvidarlo. Al validar dentro del método, el invariante «saldo no negativo» se cumple para todos los usuarios de la clase, presentes y futuros. Eso es encapsulamiento en el sentido útil: no ocultar por ocultar, sino concentrar la regla en un solo lugar.
En el dataset, explicit evita que un std::vector<double> se convierta en Dataset sin querer, y el std::move del parámetro al miembro evita copiar el vector entero. La media y el rango son const porque solo leen; si olvidaras ese const, el objeto const Dataset d del ejemplo no compilaría. Y el contador estático demuestra que hay estado que pertenece a la clase, no a cada instancia.
Resumen
- Una clase agrupa datos y las operaciones que mantienen su invariante.
classes privada por defecto;structes pública por defecto.- El constructor deja el objeto válido y la lista de inicialización construye cada miembro directamente.
- Los miembros se inicializan en orden de declaración, no en el orden de la lista.
- Un método
constno modifica el objeto y puede llamarse sobre objetosconst. thises la dirección del objeto y permite distinguir miembros de parámetros.- Un
staticexiste una sola vez por clase y debe definirse fuera de ella. - La clase se declara en un header con guarda y se implementa en un
.cpp.
Evaluación
¿Qué garantiza double valor() const en una clase?
¿Por qué conviene inicializar en la lista : nombre_(std::move(n)) en lugar de asignar en el cuerpo?
Si una clase declara static int instancias_; pero no lo define fuera, ¿qué ocurre al usarlo?
¿Ya lo entendiste?
Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.