Hola! En esta tercera parte de mi serie sobre sintaxis de C++, hablaré de dos detalles que son cruciales en mi opinión para conseguir programas eficientes que trabajen de la manera más óptima posible con las variables que utilizan en su código: cuándo utilizar la memoria dinámica (heap) y cómo configurar los parámetros de una función para evitar copias innecesarias de recursos y evitar la sobreescritura de argumentos que, en principio, son sólo de entrada.
Variables y memoria
En esta sección vamos a ver un tema que creo que es importante y es dejado a un lado a mi parecer: ¿en qué parte de la memoria deberíamos guardar las variables con las que trabajamos? ¿En la pila (stack) o en el montículo (heap, memoria dinámica)?
Tanto stack como heap funcionan de forma diferente al momento de asignar o liberar memoria. En el caso del stack, el sistema operativo asigna una cantidad de memoria a cada proceso para que en él guarde la pila de llamadas (call stack) y las variables locales que cada función pudiera tener. No es una cantidad excesivamente grande (unos cuantos megabytes), pero es suficiente para que un programa pueda alcanzar una buena profundidad de llamadas sin colapsar. El heap es una historia diferente. En este caso podemos pensar literalmente en un montículo lleno de bytes disponibles que se van asignando a los diferentes procesos según lo requieran. Cuando un programa solicita memoria al heap (usando internamente la función malloc en sistemas UNIX), este recibe a cambio un puntero ubicando en algún sector de la memoria los bytes que solicitamos. Cuando ya se hizo uso de la memoria, esta se devuelve al montículo (función free) para que otros procesos puedan utilizarla. Tanto stack como heap son memoria RAM al fin y al cabo, ¿verdad? Entonces, ¿debiera importarme en cuál de ellas guardo mi información? Absolutamente.
Aunque en la práctica ambas van a ser igual de rápidas y eficientes, para el programador debe ser importante hacer un buen uso de estas, especialmente considerando que el stack tiene una capacidad limitada (en mi sistema son 8 MiB, para que se hagan una idea) y, al menos en C++, el heap requiere trabajar con punteros, algo que hasta para el programador más avezado podría significar varios dolores de cabeza.
Una regla que deberían considerar cuando programen es usar punteros la menor cantidad de veces posible, porque aunque sepan al revés y al derecho cómo usarlos, nunca va a faltar la ocasión en que usen . en lugar de ->, terminen con errores de compilación incomprensibles y vean que el programa se la pasa tirando fallos de segmentación porque también les faltó desreferenciar su puntero a alguna estructura y el programa empezó a volverse loco. Lo ideal sería trabajar con punteros sólo cuando tengan que implementar estructuras de datos (como listas, vectores o mapas) y el resto de las ocasiones usar smart pointers (un tipo de objeto que implementa una interfaz más ordenada y menos proclive a errores para trabajar con punteros a memoria). Más adelante podremos ver cómo se utilizan y cuándo.
Entonces, yendo al grano, ¿cuándo deberíamos usar el heap o el stack? En el caso del stack, podemos utilizarlo con calma cuando trabajemos con arreglos pequeños de tipos pequeños (un arreglo de enteros, de caracteres), estructuras que no tengan un gran tamaño o clases tambien pequeñas:
int numeros[50];
char nombre[64];
struct {
int num;
char tipo[8];
} peque; // Esta notación permite definir una estructura al
// mismo tiempo que creamos una variable de ese tipo
double x = 2.0; // Variables comunes y corrientes como esta
// ya se almacenan en el stack
// La clase que creamos anteriormente
Persona nic("Nicholas Cage", 57);
// INNECESARIO
// Solo ocupa 28 bytes (x86 modernos)
Persona *keanu = new Persona("Keanu Reeves", 56);Una excepción a esto son tipos como std::string o std::vector, los cuales solicitan ellos mismos memoria al heap y cuyo tamaño como tal solo corresponde a punteros y números que almacenan información sobre los datos que almacenan:
std::string saludo = "¡Hola, mundo!";
std::vector primos{2, 3, 5, 7, 11, 13};
// INNECESARIO
// Tanto ctor como dtor de std::list
// ya contemplan solicitar memoria
// del heap
std::list<int> *lista = new std::list<int>();Luego, con respecto al heap, donde brilla es utilizándolo para almacenar grandes cantidades de información, ya sea un gran número de estructuras pequeñas, un número reducido de estructuras enormes o cualquier otra monstruosidad que su programa necesite:
// Definitivamente así no es cómo un juego guarda una textura,
// pero en memoria esto va a ocupar ~196 KiB, así que de
// guardarse en el stack lo vamos a llenar bien rápido.
// Por tanto, nos sirve para el ejemplo
class Textura {
private:
char r[256][256];
char g[256][256];
char b[256][256];
public:
// ...
};
// Esto va a ocupar ~4 MiB de memoria, así que, de guardarse
// en el stack, este no podría aguantar muchas variables
// como esta
Textura *mapa_texs = new Textura[64];
double *coefs = new double[1024]; // 8 KiB de memoria
Textura *jugador = new Textura(...);
// ...
// No se olviden de liberar la memoria que usen, especialmente
// si el programa va a seguir corriendo después de hacerlo!
delete[] mapa_texs;
delete[] coefs;
delete jugador;Insisto en que manejen bien estas reglas e idealmente investiguen también por cuenta propia cómo funciona lo que explico, porque los malos hábitos nunca mueren y, cuando menos se lo esperen, el proyecto en el que han trabajado por meses empezó a dejar de funcionar porque o llenaron el stack de su programa o el heap ya no tiene espacio para asignar 500 MiB porque ya asignaron chorrocientos elementos de 10 bytes cada uno (creo que esto último no es algo plausible, pero más vale prevenir que lamentar).
Uso adecuado de parámetros
Tengo que confesarles algo, buena parte de los ejemplos de código que he entregado no son idóneos realmente. Quiero decir que funcionan como tal, pero he omitido deliberadamente algunos detalles con respecto a algunas variables para motivos de simplicidad. Aquí voy a enmendar mi “error” y les explicaré cómo esperar parámetros adecuadamente en una función y cómo utilizar los for basados en rango de forma adecuada desde un punto de vista de memoria, ¡todo en un Solo Cómodo Paquete™! ¡Llame ahora, mientras nuestras operadoras no estén ocupadas!
Parámetros y argumentos
Antes de empezar, les cuento una cosita. No es una regla escrita en piedra, pero por lo general se le denomina parámetros a la lista de variables que una función lleva en su huella (o signature) al momento de definirla, mientras que argumentos se llaman los valores que se le entregan a una función cuando se invoca.
void func(int a, char b); // Aquí se llaman parámetros
// ...
func(123, 'a'); // Y aquí argumentosNo es una convención que la totalidad de los programadores siga, pero nunca está de más saberlo.
¿Cómo definimos los parámetros, entonces?
Muy simple. Esta tabla que tienen a continuación explica bien cómo definir los tipos de cada variable según las circunstancias en las que los usen:
Cortesía de Modernes C++
De todos modos, explicaré caso a caso cuándo se usa cuál.
Parámetros de entrada (in)
Este es el caso más usual que se van a encontrar. Ocurre cuando entregan un valor para que la función lo consuma sin preocuparse de retornarlo a través del mismo:
void imprimir_texto(const std::string& str) {
std::cout << str;
}
imprimir_texto("Hola\n");Acá podemos ver dos cosas: primero, que estamos pasando str como una referencia (&), lo que quiere decir que no estamos duplicando la variable que entregamos al invocar imprimir_texto (estamos trabajando con el mismo literal "Hola\n" sin hacer una copia de él). Además, al definirlo como const, nos aseguramos de que no vamos a editar innecesariamente a str, pues no es el objeto de esta función. Usar const o no no afecta su código, sino que simplemente le dice al compilador que esté atento a que la variable no puede reasignarse.
Este comportamiento de no duplicación no ocurre si omitimos el &, lo que puede ser una muy mala idea si estamos trabajando con variables muy grandes;
std::string texto_muy_largo;
// ...
// Imaginemos que texto_muy_largo contiene un archivo
// de texto de 5 MiB y queremos imprimirlo en pantalla.
void imprimir_texto_mal(const std::string str) {
std::cout << str;
}
// En este caso, vamos a trabajar sobre la variable
// misma sin duplicarla, lo que nos ahorra tiempo
// y memoria
imprimir_texto(texto_muy_largo);
// En cambio, acá estamos haciendo lo mismo, pero
// de forma más ineficiente, pues estamos duplicando
// el contenido de texto_muy_largo en otra variable
// (el parámetro que recibe la función), lo que
// implica desperdiciar memoria y tiempo copiando
// los contenidos del string
imprimir_texto_mal(texto_muy_largo);Todo esto que expliqué aplica únicamente para tipos que no son baratos de copiar o que simplemente no se pueden copiar (como std::unique_ptr, que veremos prontamente). Tipos primitivos como int o boolean son baratísimos de copiar, por lo que la optimización de usar & cuando son parámetros de entrada es innecesaria:
// Bien: Barato porque es una referencia a constante
void f1(const std::string& str);
// Mal: Puede ser muy costoso si el string es muy
// grande
void f2(std::string s);
// OK: Imbatible. Podría hasta ser copiado en un
// registro de CPU
void f3(int x);
// Innecesario: Implica una sobrecarga extra para
// convertir la referencia en un valor
void f4(const int& x);Parámetros de salida (out)
Con parámetro de salida me refiero a aquel que, en vez de ser usado para que la función reciba datos, es usado para retornar información al programador. Esto incluye también al valor de retorno de la función en cierto modo.
La mayoría de las veces va a ser posible retornar información como parte del retorno de una función sin mayor problema:
// Todo esto está bien
int f5(...); // Se entrega en el stack
std::string f6(...); // Ídem. El contenido del string se
// mantiene en el heap.En estos casos corren ciertas optimizaciones que permiten que retornar objetos como un std::string no implique un mayor costo para el programa, así que si tienen que retornar uno como en f6, pueden hacerlo como si fuera un tipo primitivo más.
Sin embargo, existe una excepción que, aunque no es algo muy frecuente, vale tener en consideración. Si trabajan con estructuras grandes como esta:
struct Packet {
char header[128];
char payload[4096];
}pueden encontrarse con problemas si definen una función como Packet gen_paquete(), pues retornar más de 4 KiB en el stack puede ser ineficiente, especialmente si se tratara de un arreglo de Packet.
Entonces, ¿qué hacemos para retornar estructuras enormes como esta? Aquí vamos a hacer uso de las referencias no constantes:
void gen_paquete(Packet& packet) {
// ...
}Acá, en vez de retornar el paquete de la forma tradicional, lo que hacemos es definir uno vacío y que sea la función la que lo rellene, esto gracias a que estamos trabajando con una copia por referencia. Así, esta función puede ejecutarse de este modo;
Packet p; // Sus arreglos van a estar no inicializados
gen_paquete(p); // Acá los inicializamos
// Ya en este punto la variable p contiene valores válidosYa con esto tenemos definidos la mayoría de los casos en que van a encontrarse con parámetros en una función. Existen otros más, los que pueden ver si abren el enlace de donde conseguí la tabla, pero considero que estos son los esenciales y los que importa saber si aún no conocen conceptos más avanzados del lenguaje.
Adenda: for basados en rango
Considerando lo que hemos visto, si están trabajando con bucles for basados en rango, es posible también usar referencias en estas, especialmente si piensan editar los valores que reciben en cada iteración, como pueden ver en este ejemplo:
std::vector<int> nums{1, 2, 3, 4, 5};
for (int& num : nums) {
num *= 2;
}
// Saliendo del bucle, veremos que nums
// ahora contiene los números {2, 4,
// 6, 8, 10};Con valores chicos como int, no debería ser necesario usar referencias si no van a modificarlos, pero, sin embargo, recomiendo encarecidamente usar const para evitar posibles bugs si terminan editando el valor involuntariamente:
for (const int num : nums) {
nun *= 2; // En este caso el compilador
// saltará con un error
}Con esto terminamos esta entrega de mi ahora ya serie sobre sintaxis básica de C++. Espero verlos en la próxima entrega. ¡Hasta pronto!