AulaML Python · C++
C++ intermedio · 46 min · Programación orientada a objetos

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.

Lección 1 de 4 del módulo
Al terminar esta lección podrás
  • 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
  1. ¿Qué es una clase?
  2. ¿Para qué sirve el encapsulamiento?
  3. ¿Cómo funciona por dentro?
  4. Sintaxis
  5. Ejemplo básico: un sensor con estado válido
  6. Ejemplo intermedio: contador de instancias y destructor
  7. Caso práctico: declaración en el header, implementación en el .cpp
  8. Visualización: construir y destruir objetos
  9. Errores comunes
  10. Buenas prácticas
  11. Ejercicios
  12. Soluciones explicadas
  13. Resumen
  14. 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: public para la interfaz, private para la representación interna.

¿Para qué sirve el encapsulamiento?

  • Mantener la coherencia: un método retirar puede 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, un Escalador o una Prediccion se 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

CPP declaracion de una clase
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.

El constructor en los dos lenguajes
Python
PYTHON sensor.py
class Sensor:
    def __init__(self, nombre, valor):
        self.nombre = nombre
        self.valor = valor

s = Sensor("temp", 21.5)
print(s.valor)
C++
CPP sensor.cpp
#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

CPP sensor.cpp
#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;
}
  1. Sensor temp{...} reserva el objeto completo en el stack: un std::string y un double contiguos.
  2. El constructor recibe los argumentos por valor y mueve el string al miembro con std::move, evitando una copia.
  3. valor() es const, así que puede llamarse sobre un objeto que no se va a modificar y el compilador lo verifica.
  4. actualizar(22.8) escribe en valor_ del objeto temp; 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.

CPP cuentas.cpp
#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.

CPP escalador.h
#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_;
};

#endif

Los 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.

CPP escalador.cpp
#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.

CPP main.cpp
#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.

Ejecución visual ·Constructores, métodos y destructores
1 / 6
1 #include <iostream>
2 #include <string>
3 #include <utility>
4
5 struct Sensor {
6 std::string nombre;
7 Sensor(std::string n) : nombre(std::move(n)) {
8 std::cout << "crear " << nombre << '\n';
9 }
10 ~Sensor() { std::cout << "destruir " << nombre << '\n'; }
11 };
12
13 int main() {
14 Sensor a{"temp"};
15 Sensor b{"humedad"};
16 std::cout << a.nombre << '\n';
17 return 0;
18 }
La 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.
Variables
a = —
b = —
Salida
—

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

ErrorCausaSolución
assignment of read-only memberAsignaste un miembro const dentro del cuerpo del constructorInicialízalo en la lista: : altura_(h)
no matching function for call to ... discards qualifiersLlamaste a un método no const sobre un objeto constMarca el método como const si solo lee
undefined reference to Cuenta::cuentas_Declaraste el static pero no lo definisteAñade int Cuenta::cuentas_ = 0; en el .cpp
expected ';' after class definitionFalta el punto y coma tras la llave de cierreEscribe }; al final de la clase
redefinition of class SensorEl header se incluyó dos veces sin guardaUsa #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.

Datos públicos frente a datos protegidos
Incorrecto
PYTHON Incorrecto: cualquiera rompe el invariante
#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;
}
Correcto
PYTHON Correcto: la interfaz valida
#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 private y expón solo lo que el usuario necesita.
  • Inicializa siempre en la lista de inicialización, en el mismo orden de declaración.
  • Marca const todo método que no modifique el objeto.
  • Usa sufijo _ o prefijo m_ para distinguir miembros de parámetros.
  • Pasa objetos grandes como const& y guarda strings con std::move en el constructor.
  • Define los static fuera de la clase (o usa inline static desde C++17).
  • Un header por clase, con guarda, y la implementación en su propio .cpp.

Ejercicios

Ejercicio 1

Cuenta bancaria encapsulada

Escribe una clase 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.

Ejercicio 2

Dataset con estadísticas y contador estático

Crea una clase 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.

Completar código Completa el constructor y el método const
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.
  • class es privada por defecto; struct es 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 const no modifica el objeto y puede llamarse sobre objetos const.
  • this es la dirección del objeto y permite distinguir miembros de parámetros.
  • Un static existe 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

Evaluación 1

¿Qué garantiza double valor() const en una clase?

Evaluación 2

¿Por qué conviene inicializar en la lista : nombre_(std::move(n)) en lugar de asignar en el cuerpo?

Evaluación 3

Si una clase declara static int instancias_; pero no lo define fuera, ¿qué ocurre al usarlo?

Tus notas

¿Ya lo entendiste?

Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.