En esta segunda parte de esta serie veremos uno de los principales elementos que distinguen a C++ de su antecesor: las clases. Además, haremos un pequeño paseo por algunos contenedores básicos que ofrece la STL.
Clases
Las clases son la piedra angular de la programación orientada a objetos. Básicamente son armazones con los cuales uno crea objetos que pueden contener datos y que, además, pueden operar con esos datos. Una clase, por lo general, va a estar compuesta de dos archivos: un archivo de cabecera (generalmente usando la extensión .h o .hpp), que define su estructura, y un archivo fuente (.cpp, .cxx, .cc, entre otras; queda a gusto del programador cuál usar), que implementa el código definido por la estructura.
El archivo de cabecera de una clase puede verse de la siguiente forma:
// persona.h
// Estas líneas (llamadas directrices de preprocesador,
// que, en este caso, forman un "include guard") le
// indican al compilador que lea este archivo sólo
// una vez, en caso de que sea utilizado en más de un sitio.
// Todo archivo .h debiera utilizarlas
#ifndef PERSONA_H
#define PERSONA_H
// Una alternativa no estándar pero soportada por la
// mayoría de los compiladores es #pragma once. Si usaran
// esta forma, omitan el include guard (las líneas que parten
// con #ifndef y #define y la línea #endif al final del
// documento). Solo deberían usar una de estas a la vez.
#pragma once
#include <string>
// Una clase puede tener miembros:
// - públicos (accesibles por cualquiera)
// - privados (accesibles sólo por la clase misma)
// - protegidos (accesibles sólo por la clase
// misma y sus subclases)
class Persona {
// Estos no podrán ser accedidos desde fuera de la clase.
// Si tuviéramos una instancia de Persona llamada p,
// escribir p.nombre va a generar un error de compilación
private:
// Tanto nombre como edad se denominan atributos
// (o campos). Funcionan como variables, las cuales
// serán únicas para cada instancia de Persona (es
// decir, cada persona tendrá su propio nombre y edad)
std::string nombre;
unsigned edad;
// En cambio, estos pueden utilizarse desde fuera y funcionan
// como la cara visible de la clase
public:
// Este es el constructor (puede haber más de uno), donde
// se inicializa cada instancia de la clase, se definen
// sus valores iniciales, entre otras cosas. Un
// constructor no especifica un tipo de retorno.
Persona(std::string nombre, unsigned edad);
// Estas funciones se denominan métodos. Los métodos
// pueden acceder a los atributos de cada instancia y
// trabajar con estos.
std::string get_nombre();
unsigned get_edad();
};
#endif // PERSONA_H Omitir si usan #pragma once// clase.cpp
// En este archivo vamos a implementar tanto el
// constructor como los dos métodos que están definidos
// en el archivo de cabecera.
#include "persona.h"
// Para implementar los métodos de una clase,
// anteponemos al nombre de cada uno el de la clase,
// seguido de ::
Persona::Persona(std::string nombre, unsigned edad)
: nombre(nombre), edad(edad) {
// Esta de arriba es una notación especial que nos
// permite asignar directamente el valor de cada
// argumento con un atributo de la clase (en este
// caso, el atributo nombre recibe el valor del
// parámetro del mismo nombre; lo mismo con edad)
// Podemos hacer esto en lugar de la forma
// anterior si gustan
// this->nombre = nombre;
// this->edad = edad;
// El puntero this nos permite acceder a todos los
// atributos de la instancia actual de la clase.
// La usamos en este caso para evitar ambigüedades
// (ya que atributo y parámetro se llaman igual)
}
std::string Persona::get_nombre() {
// Como en este caso no hay otro elemento llamado
// nombre en la función, podemos prescindir del
// this-> con seguridad
return nombre;
}
unsigned Persona::get_edad() {
return edad;
}Luego, para utilizar la clase en nuestro código, podemos hacer lo siguiendo, sin olvidar incluir el archivo .cpp entre los que el compilador va a recibir:
// main.cpp
// Los archivos de cabecera que son parte del proyecto
// debieran ser incluidos usando comillas dobles (""),
// mientras que las librerías de sistema con <>
#include <iostream>
#include "persona.h"
int main() {
// Así creamos una instancia cuyo atributo
// nombre contendrá el string "Juan López"
// y edad el número 40. Noten que los argumentos
// dados coinciden con los parámetros definidos
// en persona.cpp y persona.h
Persona p("Juan López", 40);
std::cout << p.get_nombre() << " tiene "
<< p.get_edad() << " años.\n";
// Retornamos 0 para indicar que el programa
// se ejecutó exitosamente
return 0;
}Podríamos compilar el programa con esta llamada a g++:
g++ -o prueba_persona main.cpp persona.cppSi desean mantener los archivos de cabecera y fuente separados, pueden hacer uso de esta estructura de archivos:
prueba_persona/
|-- src/ Archivos fuente
| |- main.cpp
| \- persona.cpp
\-- include/ Archivos de cabecera
\- persona.hPara compilar, pueden usar este comando (asegurándose de estar en la carpeta prueba_persona):
g++ -o prueba_persona src/main.cpp src/persona.cpp -I include/El argumento -I <carpeta> le indica al compilador dónde tiene que buscar los archivos .h que hemos creado, ya que no están en la misma carpeta que los .cpp.
Constructores y destructores
Antes de continuar, me quiero detener un momento para explicar un poco más sobre los contenedores y destructores, también llamados ctor(s) y dtor(s) (a partir de los términos constructor(s) y destructor(s) en inglés). Estos dos son métodos especiales dentro de una clase y son llamados al momento de crear una instancia de esta o cuando el objeto se sale de scope o se invoca a delete, respectivamente (veremos estos últimos dos casos en detalle después).
Constructores
Como mencionaba, los constructores se ejecutan al momento de crear una instancia de clase. Por lo general se encargan de inicializar los atributos de la nueva instancia y, por tanto, puede recibir argumentos de parte del programador. También es posible definir más de un constructor en caso de ser necesario.
Cabe también agregar que si no definimos un constructor en nuestra clase, el compilador nos proveerá uno trivial. Además, los atributos que puedan construirse por defecto (como los contenedores y tipos de la STL) lo harán automáticamente, añadamos o no un constructor.
class Clase {
private:
std::string nombre;
unsigned edad;
public:
Clase();
Clase(std::string nombre, unsigned edad);
};
Clase::Clase() : nombre(""), edad(0) {
// ...
}
Clase::Clase(std::string nombre, unsigned edad)
: nombre(nombre), edad(edad) {
// ...
}En este ejemplo, podemos ver que Clase(std::string, unsigned) asignará el nombre y edad que le entregamos a los atributos del mismo nombre, lo que es usual. Con respecto a Clase(), lo que ocurrirrá es que tanto nombre como edad se definan como "" y 0, respectivamente. Ambas definiciones son válidas y no producen conflictos entre sí, ya que los parámetros que esperan son diferentes (ninguno contra un std::string y un unsigned).
Si, por ejemplo, Clase también contuviera un puntero bruto a memoria, podríamos inicializarlo haciendo uso de malloc o free dentro del constructor, sin olvidarse de liberar los recursos solicitados en el destructor, que es lo que veremos a continuación.
Destructores
Cuando nuestra instancia se sale de scope o ejecutamos delete, el programa llamará al destructor de nuestra clase, el cual se encargará de liberar graciosamente cualquier asignación de memoria que hayamos hecho, además de “saldar cuentas” en nuestra función si es necesario:
class ClaseConDtor {
private:
std::string str;
public:
ClaseConDtor();
~ClaseConDtor(); // Este es nuestro destructor.
// Un destructor nunca recibe
// argumentos
}
if (...) {
ClaseConDtor a(); // Creamos una instancia
// ...
}
// Al momento de abandonar el if, el destructor
// es llamado automáticamente. El atributo str
// también invoca al suyo y libera la memoria
// que utilizaba el string
class ClaseConDtorMem {
private:
// ...
public:
ClaseConDtorMem();
~ClaseConDtorMem();
}
// Creamos una instancia en el heap
// (traten de usar punteros lo menos posible, insisto)
ClaseConDtorMem *c = new ClaseConDtorMem();
// ...
delete c; // Aquí invocamos al destructor de nuestra
// clase. Una vez hecho, la memoria que
// solicitó es devuelta al SOSi su clase no utiliza punteros en bruto, por lo general no va a ser necesario definir un destructor, pues el que entrega el compilador por defecto va a servir en la mayoría de los casos. Como veíamos anteriormente, cualquier instancia de clase que ya tenga definido su propio destructor (como el string de ClaseConDtor) lo va a llamar al momento de invocar el nuestro, lo que nos permite despreocuparnos de ver si el destructor de los atributos fue llamado, pues se hará automáticamente.
Contenedores
Ya que casi todo programa necesita almacenar datos de una forma u otra, se vuelve necesario trabajar con estructuras que los almacenen y nos permitan operar con ellos. Para aplicaciones simples podemos recurrir al clásico arreglo (ya sea de tamaño fijo o variable), pero si nuestros datos ganan complejidad o necesitamos ciertas garantías de tiempo (como adición o lectura en tiempo constante), vamos a tener que utilizar otro tipo de enfoque. El tema es que muchas de las veces estamos reinventando la rueda y no necesitamos realmente crear nuestras propias clases cuando podemos utilizar código hecho por expertos en el tema (a menos que nos estén enseñando cómo funcionan estas estructuras, claro está). Es por esto que les mostraré algunas de estas estructuras que vienen “de fábrica” en cualquier implementación de la librería estándar de C++.
Arreglos dinámicos (std::vector)
Esta es uno de los contenedores más simples de usar y el que más se van a encontrar. std::vector implementa un arreglo de tamaño variable que se encarga de forma automática de agrandar la estructura subyacente que almacena la información:
// Este arreglo es de tamaño fijo. Podemos
// alterar los números que ya contiene, pero
// no podemos agregarle ni quitarle celdas.
int nums[] = {0, 1, 1, 2, 3, 5, 8};
nums[8] = 13; // A menos que quieran que su programa
// falle o no compile, no hagan esto.#include <iostream>
#include <vector> // Tenemos que incluir esta librería
// para usar vectores
#include "persona.h"
// Como nums2 es un vector, podemos alterar los números
// que contiene, agregar y eliminar a libertad.
std::vector<int> nums2{0, 1, 1, 2, 3, 5, 8};
nums2.push_back(13); // Así agregamos un número al final
int trece = nums2.pop_back(); // Y así lo eliminamos
// Así podemos obtener el tamaño del vector
std::cout << nums2.size() << std::endl;
// Así inicializamos un vector vacío
std::vector<Persona> gente;
// Y así podemos construir "in situ" una instancia,
// si es que el tipo es construible. Tan solo tenemos
// que pasar los mismos parámetros que de costumbre.
gente.emplace_back("Alan Turing", 41);
// Eso es equivalente a esto:
Persona alan("Alan Turing", 41);
gente.push_back(alan);
// Si queremos limpiar el vector, llamamos a clear()
gente.clear();
// Para comprobar que lo limpiamos, podemos usar
// el método empty() o chequear que size() == 0
if (gente.empty()) {
std::cout << "Vacío" << std::endl;
}
// if (gente.size() == 0) {
// std::cout << "Vacío" << std::endl;
// }Listas enlazadas (std::list)
A diferencia de los vectores, que almacenan su información en un trozo contiguo de memoria que se va moviendo a medida que aumenta el número de elementos que debe almacenar, las listas enlazadas pueden perfectamente estar desperdigadas en la RAM y siguen siendo totalmente accesibles y modificables.
Una lista enlazada se compone de nodos, que contienen la información que queremos guardar, y punteros que apuntan al siguiente elemento en la cadena (lista simplemente enlazada) o, incluso, al elemento anterior y siguiente (lista doblemente enlazada):
Cortesía de Studytonight
La ventaja de las listas doblemente enlazadas es que permiten la inserción y eliminación de elementos en tiempo constante tanto al inicio y final de ella.
#include <list>
// Una lista doblemente enlazada
std::list<int> lista{1, 2, 3, 4, 5};
lista.push_back(6);
// Ahora la lista queda
// 1 <-> 2 <-> 3 <-> 4 <-> 5 <-> 6
lista.push_front(7);
// Ahora la lista queda
// 7 <-> 1 <-> 2 <-> 3 <-> 4 <-> 5 <-> 6
lista.pop_back();
lista.pop_back();
lista.pop_front();
// Finalmente:
// 1 <-> 2 <-> 3 <-> 4Como es posible recorrer una lista doblemente enlazada de atrás hacia adelante, podemos iterar un std::list en reversa, algo que veremos con detalle en otra ocasión:
std::list<int> fibonacci{0, 1, 1, 2, 3, 5, 8, 13};
// Iteración regular (de inicio a fin)
for (int n : fibonacci) {
std::cout << n << std::endl;
}
// Iteración en reversa
auto it = fibonacci.rbegin();
auto end = fibonacci.rend();
for (; it != end; ++it) {
std::cout << n << std::endl;
}
// El tipo auto nos permite omitir el tipo de una variable
// si es muy largo y deja su determinación a cargo del
// compilador
// La anterior sintaxis no es la más cómoda (de hecho,
// es una de las razones por las que puse el iterador
// inicial y de fin en líneas aparte para mejorarlo
// un poco), pero desde C++20 en adelante existe una
// forma mucho más simple que podemos usar, gracias
// a la nueva librería ranges
#include <ranges>
for (int n : fibonacci | std::views::reverse) {
std::cout << n << std::endl;
}
// El operador tubería (|) le entrega la lista a
// std::views::reverse, el cual invierte los iteradores
// y permite recorrerlo en reversa con menos códigoListas simplemente enlazadas
Si queremos trabajar con listas simplemente enlazadas (tanto porque no nos interesa la iteración en reversa o porque queremos ahorrar espacio) basta con usar la variante std::forward_list, que funciona de forma idéntica a std::list:
#include <forward_list>
std::forward_list<int> simple{1, 2, 3};
// Iteración regular (de inicio a fin)
for (int n : simple) {
std::cout << n << std::endl;
}
// ESTO NO VA A FUNCIONAR
auto it = simple.rbegin();
auto end = simple.rend();
for (; it != end; ++it) {
std::cout << n << std::endl;
}Conclusión
Esperando que les haya servido este artículo, en la próxima iteración veremos las dos principales formas de almacenar variables en memoria (heap y stack), además de cómo las funciones debieran recibir parámetros si queremos lograr un manejo eficiente de la memoria. ¡Nos vemos en la próxima!