Clases y objetos en C++: constructores, RAII y operadores

Lección 5 de 8: clases y objetos en C++. Las clases son lo que C++ añadió a C en su origen, y siguen siendo su herramienta principal para organizar programas grandes. Esta lección enseña a escribir clases que se usan bien y es difícil usar mal: constructores que dejan el objeto listo, métodos const, operadores y, sobre todo, RAII, la idea que hace que en C++ moderno casi nunca haga falta liberar nada a mano.

De struct a class

En la lección anterior usamos struct Producto para juntar un nombre y un precio. Una clase va un paso más allá: junta datos y las funciones que trabajan con ellos (los métodos), y decide qué se puede tocar desde fuera. En C++, struct y class son casi lo mismo; la única diferencia es que en un struct todo es público por omisión y en una class, privado. Por costumbre, struct se usa para datos sueltos y class para objetos con reglas:

#include <print>
#include <string>

struct Punto {               // datos sueltos: todo público
    double x = 0;
    double y = 0;
};

class Cuenta {
public:
    explicit Cuenta(std::string titular) : titular_{std::move(titular)} {}

    void depositar(double cantidad) { saldo_ += cantidad; }

    bool retirar(double cantidad)
    {
        if (cantidad > saldo_) return false;   // la regla: nunca en negativo
        saldo_ -= cantidad;
        return true;
    }

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

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

int main()
{
    Punto p{3, 4};
    std::println("Punto ({}, {})", p.x, p.y);

    Cuenta c{"Ana"};
    c.depositar(100);
    std::println("¿Retira 150? {}", c.retirar(150));
    std::println("¿Retira 30? {}", c.retirar(30));
    std::println("{} tiene {}", c.titular(), c.saldo());
}
salidaPunto (3, 4)
¿Retira 150? false
¿Retira 30? true
Ana tiene 70

Lo que va tras public: es la interfaz que usa el resto del programa; lo que va tras private: solo lo ven los métodos de la propia clase. Así, la única forma de cambiar el saldo es pasar por depositar y retirar, que hacen cumplir la regla de no quedar en negativo. A esa regla que la clase garantiza siempre se le llama invariante, y protegerla es la razón de ser de private. El guion bajo al final de saldo_ es una convención muy extendida para distinguir los miembros de las variables locales.

El error que te ahorra: tocar un miembro privado

#include <string>

class Cuenta {
public:
    void depositar(double cantidad) { saldo_ += cantidad; }
private:
    double saldo_ = 0;
};

int main()
{
    Cuenta c;
    c.saldo_ = 1'000'000;
}
g++: errorprivado_mal.cpp: In function 'int main()':
privado_mal.cpp:13:7: error: 'double Cuenta::saldo_' is private within this context
   13 |     c.saldo_ = 1'000'000;
      |       ^~~~~~
privado_mal.cpp:7:12: note: declared private here
    7 |     double saldo_ = 0;

El compilador no deja saltarse la interfaz: es un error, no un aviso, y señala dónde se declaró privado el miembro.

Constructores

Un constructor es el método que se ejecuta al crear el objeto, y se llama como la clase. Su trabajo es dejar el objeto en un estado válido desde el primer instante. Los miembros se inicializan en la lista de inicialización, tras los dos puntos, y pueden tener un valor por omisión en su declaración. Un constructor puede delegar en otro:

#include <format>
#include <print>
#include <string>

class Fecha {
public:
    Fecha(int anio, int mes, int dia) : anio_{anio}, mes_{mes}, dia_{dia} {}
    Fecha(int anio) : Fecha{anio, 1, 1} {}       // delega en el anterior
    Fecha() = default;                           // usa los valores por omisión

    std::string texto() const { return std::format("{:04}-{:02}-{:02}", anio_, mes_, dia_); }

private:
    int anio_ = 1970;
    int mes_ = 1;
    int dia_ = 1;
};

int main()
{
    Fecha a{2026, 10, 30};
    Fecha b{2027};
    Fecha c;
    std::println("{} {} {}", a.texto(), b.texto(), c.texto());
}
salida2026-10-30 2027-01-01 1970-01-01

Inicializa siempre en la lista y no con asignaciones dentro de las llaves del constructor: los miembros se construyen antes de entrar en ellas, así que asignar después es hacer el trabajo dos veces. Además, hay miembros, como las referencias y las constantes, que solo se pueden inicializar en la lista.

explicit: que no se cuelen conversiones

Un constructor con un solo parámetro sirve también para convertir. Si no se marca explicit, C++ lo usa por su cuenta cuando le hace falta, y eso produce sorpresas como esta:

#include <print>

class Metros {
public:
    Metros(double valor) : valor_{valor} {}      // sin explicit
    double valor() const { return valor_; }
private:
    double valor_;
};

void cercar(Metros perimetro)
{
    std::println("Hace falta valla para {} metros", perimetro.valor());
}

int main()
{
    double pies = 328;
    cercar(pies);          // compila: convierte 328 pies en «328 metros»
}
salidaHace falta valla para 328 metros

El error que te ahorra: una conversión que nadie pidió

class Metros {
public:
    explicit Metros(double valor) : valor_{valor} {}
    double valor() const { return valor_; }
private:
    double valor_;
};

void cercar(Metros perimetro) { (void) perimetro; }

int main()
{
    double pies = 328;
    cercar(pies);              // error: hay que decirlo: cercar(Metros{pies})
}
g++: errorexplicit_mal.cpp: In function 'int main()':
explicit_mal.cpp:14:12: error: could not convert 'pies' from 'double' to 'Metros'
   14 |     cercar(pies);              // error: hay que decirlo: cercar(Metros{pies})
      |            ^~~~

Con explicit, quien llama tiene que escribir Metros{pies} y ver lo que hace. La regla práctica: marca explicit todos los constructores de un parámetro, salvo que quieras de verdad la conversión automática. Es lo que hicimos con Cuenta.

Métodos const

Un método marcado const promete no modificar el objeto, y el compilador lo vigila. Importa más de lo que parece: sobre un objeto const, o recibido por referencia a const (lo normal al pasar objetos a funciones), solo se pueden llamar métodos const:

El error que te ahorra: olvidar el const de un método

#include <print>

class Termometro {
public:
    double grados() { return grados_; }      // falta const
private:
    double grados_ = 21.5;
};

void mostrar(const Termometro& t)
{
    std::println("{}", t.grados());
}

int main()
{
    Termometro t;
    mostrar(t);
}
g++: errorconst_mal.cpp: In function 'void mostrar(const Termometro&)':
const_mal.cpp:12:32: error: passing 'const Termometro' as 'this' argument discards qualifiers [-fpermissive]
   12 |     std::println("{}", t.grados());
      |                        ~~~~~~~~^~
const_mal.cpp:5:12: note:   in call to 'double Termometro::grados()'
    5 |     double grados() { return grados_; }      // falta const

El mensaje habla de «descartar calificadores»: llamar a un método no const le quitaría el const al objeto. La solución es escribir double grados() const. Marca const todo método que solo consulte.

Destructores y RAII

El destructor es lo contrario del constructor: se llama como la clase con una tilde delante (~Registro) y se ejecuta automáticamente cuando el objeto deja de existir. Para una variable local, eso es al llegar a la llave de cierre de su bloque, pase lo que pase: aunque se salga con return o con una excepción. Los objetos se destruyen en orden inverso al de su creación:

#include <print>
#include <stdexcept>
#include <string>

class Registro {
public:
    explicit Registro(std::string nombre) : nombre_{std::move(nombre)} { std::println("  abre {}", nombre_); }
    ~Registro() { std::println("  cierra {}", nombre_); }
private:
    std::string nombre_;
};

void trabajar(bool fallar)
{
    Registro a{"a"};
    Registro b{"b"};
    if (fallar) throw std::runtime_error("algo salió mal");
    std::println("  trabajando con a y b");
}

int main()
{
    std::println("Sin errores:");
    trabajar(false);
    std::println("Con una excepción:");
    try {
        trabajar(true);
    } catch (const std::exception& e) {
        std::println("  capturada: {}", e.what());
    }
}
salidaSin errores:
  abre a
  abre b
  trabajando con a y b
  cierra b
  cierra a
Con una excepción:
  abre a
  abre b
  cierra b
  cierra a
  capturada: algo salió mal

Un bloque crea el objeto a y luego el b, los usa y, al llegar a la llave de cierre, destruye primero b y después a, en orden inverso. El destructor se ejecuta solo, también si sale una excepción, y libera el recursoRAII: el destructor se ejecuta solo al salir del bloque{    Registro a{"a"};    Registro b{"b"};    trabajar();}construye aconstruye busa a y bdestruye bdestruye aLos objetos se destruyen en orden inverso al de su construcción, aunque salga una excepción.Si el destructor libera el recurso (memoria, archivo, cerrojo), nunca se queda abierto.

RAII: el recurso se adquiere al construir el objeto y se libera en su destructor, que C++ llama siempre al salir del bloque. Esquema: decodigo.com.

Este mecanismo tiene un nombre algo críptico, RAII (Resource Acquisition Is Initialization, «adquirir un recurso es inicializar un objeto»), y es la idea más importante de C++. Si cada recurso (memoria, un archivo, una conexión, un cerrojo) lo guarda un objeto que lo libera en su destructor, ya no hay que acordarse de liberarlo, ni siquiera cuando salta una excepción. La biblioteca estándar está hecha así: std::string y std::vector liberan su memoria, y los archivos se cierran solos:

#include <fstream>
#include <print>
#include <string>

int main()
{
    {
        std::ofstream salida{"/tmp/notas.txt"};
        salida << "primera línea\n" << "segunda línea\n";
    }   // aquí se cierra el archivo: no hay close() que olvidar

    std::ifstream entrada{"/tmp/notas.txt"};
    std::string linea;
    int n = 0;
    while (std::getline(entrada, linea)) {
        std::println("{}: {}", ++n, linea);
    }
}
salida1: primera línea
2: segunda línea

Operadores: ==, <=> y los tuyos

Una clase puede definir qué significan los operadores para sus objetos. Desde C++20, lo más habitual se escribe en una línea: = default le pide al compilador que compare miembro a miembro, en el orden en que están declarados. Con operator<=>, el operador «nave espacial», salen de golpe <, <=, > y >=, y con ellos se puede ordenar:

#include <algorithm>
#include <compare>
#include <print>
#include <vector>

struct Version {
    int mayor = 0, menor = 0, parche = 0;
    auto operator<=>(const Version&) const = default;   // también da ==
};

class Dinero {
public:
    explicit Dinero(long centimos) : centimos_{centimos} {}
    Dinero operator+(Dinero otro) const { return Dinero{centimos_ + otro.centimos_}; }
    bool operator==(const Dinero&) const = default;
    long centimos() const { return centimos_; }
private:
    long centimos_;
};

int main()
{
    Version a{1, 10, 2}, b{1, 9, 30};
    std::println("¿1.10.2 > 1.9.30? {}", a > b);
    std::println("¿Iguales? {}", a == Version{1, 10, 2});

    std::vector<Version> vs{{2, 0, 0}, {1, 10, 2}, {1, 9, 30}};
    std::ranges::sort(vs);
    for (const auto& v : vs) std::println("{}.{}.{}", v.mayor, v.menor, v.parche);

    Dinero total = Dinero{1250} + Dinero{399};
    std::println("Total: {} céntimos; ¿es 16.49? {}", total.centimos(), total == Dinero{1649});
}
salida¿1.10.2 > 1.9.30? true
¿Iguales? true
1.9.30
1.10.2
2.0.0
Total: 1649 céntimos; ¿es 16.49? true

Comparar versiones como números enteros, y no como texto, es justo lo que hace falta: como texto, «1.10.2» sería menor que «1.9.30». Y fíjate en que Dinero guarda céntimos en un entero: guardar dinero en un double acumula errores de redondeo, como vimos en la lección 2. Sobrecarga operadores solo cuando su significado sea obvio, como sumar dinero o comparar versiones; un + que haga algo inesperado es peor que un método con un buen nombre.

Miembros static

Un miembro static pertenece a la clase y no a cada objeto: hay uno solo, compartido. Sirve para contadores, constantes de la clase o funciones que no necesitan un objeto, como las de fabricación:

#include <print>
#include <string>

class Pedido {
public:
    static constexpr int maximo_lineas = 50;     // constante de la clase

    static Pedido urgente(std::string cliente)   // función de fabricación
    {
        Pedido p{std::move(cliente)};
        p.urgente_ = true;
        return p;
    }

    explicit Pedido(std::string cliente) : cliente_{std::move(cliente)}, numero_{++creados_} {}

    void mostrar() const { std::println("Pedido {} de {}{}", numero_, cliente_, urgente_ ? " (urgente)" : ""); }
    static int creados() { return creados_; }

private:
    inline static int creados_ = 0;              // uno para toda la clase
    std::string cliente_;
    int numero_;
    bool urgente_ = false;
};

int main()
{
    Pedido a{"Eva"};
    Pedido b = Pedido::urgente("Luis");
    a.mostrar();
    b.mostrar();
    std::println("Creados: {}, máximo de líneas: {}", Pedido::creados(), Pedido::maximo_lineas);
}
salidaPedido 1 de Eva
Pedido 2 de Luis (urgente)
Creados: 2, máximo de líneas: 50

El inline static (C++17) permite dar valor al miembro en la propia clase; antes había que definirlo aparte, en un archivo .cpp. Ojo con los miembros static que cambian: son, en el fondo, variables globales, con sus mismos problemas, sobre todo con hilos.

La regla del cero, del tres y del cinco

Al copiar un objeto, C++ copia sus miembros uno a uno. Con std::string o std::vector eso funciona, porque saben copiarse. Pero si una clase guarda un puntero a memoria que reservó ella misma, la copia se lleva el puntero, no los datos, y los dos objetos acaban creyendo que son dueños del mismo bloque. Al destruirse, los dos lo liberan:

El error que te ahorra: una doble liberación por copiar

#include <cstddef>
#include <print>

class Bufer {
public:
    explicit Bufer(std::size_t n) : datos_{new int[n]{}}, n_{n} {}
    ~Bufer() { delete[] datos_; }
    std::size_t size() const { return n_; }
private:
    int* datos_;
    std::size_t n_;
};

int main()
{
    Bufer a{100};
    Bufer b = a;          // copia el puntero, no los 100 enteros
    std::println("{} y {}", a.size(), b.size());
}                         // ~b libera el bloque y ~a lo libera otra vez
salida con error==71==ERROR: AddressSanitizer: attempting double-free on 0x735087be0040 in thread T0:
…
    #1 0x000000407505 in Bufer::~Bufer() /curso/doble_liberacion.cpp:7
    #2 0x0000004048b0 in main /curso/doble_liberacion.cpp:19
…
0x735087be0040 is located 0 bytes inside of 400-byte region [0x735087be0040,0x735087be01d0)
freed by thread T0 here:
…
    #1 0x000000407505 in Bufer::~Bufer() /curso/doble_liberacion.cpp:7
    #2 0x0000004048a4 in main /curso/doble_liberacion.cpp:19
…
previously allocated by thread T0 here:
…
    #1 0x0000004073e3 in Bufer::Bufer(unsigned long) /curso/doble_liberacion.cpp:6
    #2 0x00000040470e in main /curso/doble_liberacion.cpp:16
…
SUMMARY: AddressSanitizer: double-free /curso/doble_liberacion.cpp:7 in Bufer::~Bufer()

Es un error grave: una doble liberación puede corromper el gestor de memoria y es un fallo de seguridad explotable. Y el compilador no avisa, porque copiar es legal.

De ahí salen tres reglas que todo programador de C++ conoce:

  • ReglaQué diceCuándo
  • Del cerono escribas destructor, ni copia, ni movimiento: que los miembros (string, vector, unique_ptr) se encarguencasi siempre: es la que hay que buscar
  • Del tressi escribes destructor, constructor de copia u operador de asignación por copia, escribe los tresclases que gestionan un recurso a mano (C++98)
  • Del cincolo mismo, más el constructor y la asignación por movimientolo mismo, desde C++11; el movimiento se ve en la lección 6

La solución buena casi nunca es escribir esas cinco funciones, sino aplicar la regla del cero: guardar los datos en algo que ya sepa copiarse y liberarse. Con un std::vector, la clase de antes no necesita ni destructor:

#include <cstddef>
#include <print>
#include <vector>

class Bufer {
public:
    explicit Bufer(std::size_t n) : datos_(n) {}       // n ceros
    std::size_t size() const { return datos_.size(); }
    int& operator[](std::size_t i) { return datos_[i]; }
private:
    std::vector<int> datos_;                           // sabe copiarse y liberarse
};

int main()
{
    Bufer a{100};
    Bufer b = a;           // copia de verdad los 100 enteros
    b[0] = 7;
    std::println("a[0]={} b[0]={} tamaños {} y {}", a[0], b[0], a.size(), b.size());
}
salidaa[0]=0 b[0]=7 tamaños 100 y 100

Fíjate en los paréntesis de datos_(n): con llaves, datos_{n} crearía un vector con un solo elemento que vale n. Es otra de las rarezas de C++. Cuando de verdad haga falta un puntero propio, la lección 6 enseña std::unique_ptr, que aplica la regla del cero también ahí.

Ojo con la seguridad: objetos que nunca son inválidos

El constructor es el sitio perfecto para validar: si un objeto no puede existir en un estado inválido, el resto del programa no tiene que comprobarlo una y otra vez, y un dato malo que venga de fuera se rechaza en un solo punto. Si el valor no sirve, el constructor lanza una excepción y el objeto no llega a crearse:

#include <print>
#include <stdexcept>

class Porcentaje {
public:
    explicit Porcentaje(int valor) : valor_{valor}
    {
        if (valor < 0 || valor > 100) {
            throw std::invalid_argument("porcentaje fuera de 0..100");
        }
    }
    int valor() const { return valor_; }
private:
    int valor_;
};

int main()
{
    for (int v : {25, 140}) {
        try {
            Porcentaje p{v};
            std::println("Descuento del {} %", p.valor());
        } catch (const std::invalid_argument& e) {
            std::println("Rechazado {}: {}", v, e.what());
        }
    }
}
salidaDescuento del 25 %
Rechazado 140: porcentaje fuera de 0..100

Con los miembros privados y sin métodos que rompan la regla, un Porcentaje vale siempre entre 0 y 100. Es la misma idea que el saldo de Cuenta: los datos que entran desde fuera (formularios, archivos, la red) se validan al construir el objeto, y no en cada sitio donde se usan.

Más en Los fallos de seguridad típicos de C++.

Retos

Reto 1. Escribe una clase Rectangulo con ancho y alto privados, un constructor que rechace medidas negativas o cero, y métodos const para el área y el perímetro.

Ver la solución
#include <print>
#include <stdexcept>

class Rectangulo {
public:
    Rectangulo(double ancho, double alto) : ancho_{ancho}, alto_{alto}
    {
        if (ancho <= 0 || alto <= 0) throw std::invalid_argument("medidas no válidas");
    }
    double area() const { return ancho_ * alto_; }
    double perimetro() const { return 2 * (ancho_ + alto_); }
private:
    double ancho_, alto_;
};

int main()
{
    const Rectangulo r{3, 4.5};
    std::println("área {}, perímetro {}", r.area(), r.perimetro());
    try {
        Rectangulo malo{-1, 2};
    } catch (const std::invalid_argument& e) {
        std::println("{}", e.what());
    }
}
salidaárea 13.5, perímetro 15
medidas no válidas

El constructor valida y lanza una excepción si las medidas no sirven, así que no puede existir un rectángulo inválido. Como r es const, si area o perimetro no lo fueran, no compilaría.

Reto 2. ¿En qué orden se imprimen los mensajes? Registro x{"x"}; { Registro y{"y"}; Registro z{"z"}; } Registro w{"w"};, con el Registro de la sección de RAII.

Ver la solución
#include <print>
#include <string>

class Registro {
public:
    explicit Registro(std::string nombre) : nombre_{std::move(nombre)} { std::println("abre {}", nombre_); }
    ~Registro() { std::println("cierra {}", nombre_); }
private:
    std::string nombre_;
};

int main()
{
    Registro x{"x"};
    {
        Registro y{"y"};
        Registro z{"z"};
    }
    Registro w{"w"};
}
salidaabre x
abre y
abre z
cierra z
cierra y
abre w
cierra w
cierra x

y y z se destruyen al cerrar su bloque, en orden inverso; x y w, al terminar main, también en orden inverso: primero w, que se creó después.

Reto 3. Ordena un std::vector de fechas {anio, mes, dia} de la más antigua a la más reciente sin escribir ninguna función de comparación.

Ver la solución
#include <algorithm>
#include <compare>
#include <print>
#include <vector>

struct Fecha {
    int anio, mes, dia;
    auto operator<=>(const Fecha&) const = default;
};

int main()
{
    std::vector<Fecha> fechas{{2026, 10, 30}, {2025, 12, 1}, {2026, 1, 15}};
    std::ranges::sort(fechas);
    for (const auto& f : fechas) std::println("{:04}-{:02}-{:02}", f.anio, f.mes, f.dia);
}
salida2025-12-01
2026-01-15
2026-10-30

El <=> por omisión compara los miembros en el orden en que se declaran: primero el año, luego el mes, luego el día. Por eso el orden de los miembros importa: si el día fuera el primero, el orden saldría mal.

Todo el código de esta lección se compiló con GCC 15.3 (C++23, -std=c++23 -Wall -Wextra) y se ejecutó en un contenedor limpio antes de publicarla; los avisos del compilador y las salidas están copiados de esa ejecución. ¿Algo no te compila? Revisa tu versión con g++ --version: hace falta GCC 14 o más nuevo (o Clang 19 con libstdc++ 14) para std::print y el resto de C++23.