Modelos de gestión de memoria I: Gestión manual
Menú de navegaciónMenú
Categorías

La mejor forma de Aprender Programación online y en español www.campusmvp.es

Modelos de gestión de memoria I: Gestión manual

gestion-manualUno de los aspectos más importantes en todo lenguaje de programación es cómo se gestiona la memoria. Es decir cuándo, cómo y quién libera los objetos que se van creando durante la ejecución del programa. De hecho podemos detectar cuatro grandes técnicas de gestión de la memoria: gestión manual, garbage collector, contador de referencias manual y contador de referencias automático. Cada una de ellas tiene sus casuísticas las cuales veremos, de forma resumida, en sucesivos posts.

Gestión manual

La gestión manual de memoria es la que tienen lenguajes como C o C++ clásico. En este modelo toda la responsabilidad se deja el desarrollador. Sus defensores afirman que la gestión de memoria es tan importante que no puede ser dejada al sistema y debe efectuarla el desarrollador.

Por ejemplo, en C++ se usa la palabra clave new para crear un objeto y obtener un puntero que lo apunte y se usa delete para destruir el objeto:

class Beer {
	public: std::string name;
};

int _tmain(int argc, _TCHAR* argv[])
{
	auto myBeer = new Beer();
	myBeer->name = "Voll Damm";
	// más código
	delete myBeer;
	myBeer->name = "Heineken";
	return 0;

}

Este código crea un objeto Beer mediante new, opera con él y finalmente lo elimina (mediante delete). Al eliminar el objeto el puntero myBeer sigue apuntando a la misma dirección de memoria, pero ahora ya no contiene el objeto. Es lo que se conoce como un dangling pointer. Acceder a un dangling pointer (como en la línea posterior a delete) nunca trae buenas noticias:

image

En el ejemplo anterior el error se observa muy claramente, pero cuando tenemos más de un puntero accediendo al mismo objeto entonces las cosas se complican: debemos usar delete cuando ya no vayamos a usar nunca más el objeto, desde ninguno de sus punteros. Al pasar punteros como parámetros es cuándo empiezan las dudas:

auto myBeer = new Beer();
myBeer->name = "Voll Damm";
drink(myBeer);

Si la función drink llama a delete del puntero pasado entonces, una vez llamada ya no podemos acceder más al objeto usando myBeer, ni podemos volver a usar delete (eliminar dos veces un objeto es otro error fatal). Pero si drink no llama a delete entonces debemos hacerlo nosotros.

Y por supuesto si no llamamos nunca a delete entonces tenemos un memory leak. Eso implica que al usar gestión manual debemos tener muy claro quién crea qué, quién usa qué y hasta cuándo y quién destruye qué. Y eso no siempre es fácil.

En posteriores partes de este artículo iremos desgranando los diferentes métodos de gestión de la memoria en diversos lenguajes. ¡No te los pierdas!

Nota: Imagen de cabecera "Writing tools by Pete O'shea" usaba bajo licencia CC

Eduard Tomás Eduard es ingeniero informático y atesora muchos años de experiencia como desarrollador. Está especializado en el desarrollo de aplicaciones web y móviles. Mantiene un conocido blog sobre desarrollo en .NET en general y ASP.NET MVC en particular. Colabora con la comunidad impartiendo charlas en formato webcast y en eventos de distintos grupos de usuarios. Puedes seguirlo en Twitter en @eiximenis. Ver todos los posts de Eduard Tomás

No te pierdas ningún post

Únete gratis a nuestro canal en Telegram y te avisaremos en el momento en el que publiquemos uno nuevo.

Archivado en: Lenguajes y plataformas

Comentarios (6) -

Hola Eduard! Muy buena explicación. Yo por lo que se ve no tenía muy bien entendido lo que hace el operador delete. Es decir, yo pensé que al utilizarlo, el puntero dejaba de apuntar a la dirección de memoria... pero parece que no es así (de hecho es lo que me desconcertó en un código mío).

A ver si me he enterado: lo que hace es seguir apuntando a la dirección pero "libera" el contenido de la dirección a la que se apunta, "llenándose" de nuevo con un valor basura, ¿no es así? (Es lo que deduzco con mis pruebas).

Y si hay otro puntero que apunta a la misma dirección del puntero para el que hemos usado delete, también apunta a ese valor basura...

Lo curioso es que no he tenido problemas para acceder a esa memoria tras destruir el objeto con ninguno de los punteros.

Es decir, te paso mi código por si no me he explicado bien:

--------------

int main()
{

    int *p=NULL;
    int *prim=NULL;

    p=new int;
    *p=4;
    prim=p;

    cout<<"direccion a la que apunta p: "<<p<<endl;
    cout<<"direccion a la que apunta prim: "<<prim<<endl;
    cout<<"valor al que apunta p: "<<*p<<endl;
    cout<<"valor al que apunta prim: "<<*prim<<endl;

    delete p;

    cout<<"------------------"<<endl;
    cout<<"TRAS USAR DELETE P:"<<endl;
    cout<<"------------------"<<endl;

    cout<<"direccion a la que apunta p: "<<p<<endl;
    cout<<"direccion a la que apunta prim: "<<prim<<endl;
    cout<<"valor al que apunta p: "<<*p<<endl;
    cout<<"valor al que apunta prim: "<<*prim<<endl;

}

---------------

Por otro lado, si he entendido bien, entonces  lo correcto sería destruir entonces el objeto UNA VEZ (sea con "delete p" o "delete prim"), y después hacer que ambos punteros apunten a NULL, y por supuesto no tratar de acceder más a ese objeto tras usar delete para evitar problemas. Quizá en este caso no los he tenido, pero podría tenerlos, ¿cierto?

Te estaría muy agradecido si me lo explicas. Soy autodidacta y llevo pocos meses aprendiendo.

Un saludo.

Responder

Eduard Tomàs
Spain Eduard Tomàs

Buenas! Por partes :)

Efectivamente "delete" simplemente borra el contenido de la dirección de memoria, pero el puntero sigue apuntando allí. El puntero pasa a apuntar a contenido inválido (basura). Es lo que llamamos un "dangling pointer". Y efectivamente cualquier otro puntero que estuviese apuntando a la misma dirección se convierte en un "dangling pointer" también.

El código que pasas te funciona porque delete libera dicho contenido, pero delete NO TIENE POR QUE sobreescribir el valor con basura. Piensa que C++ está pensado por rendimiento: tener que sobreescribir el contenido con basura cada vez que lo liberas es un coste. Así que la memoria queda marcada como "libre" pero no tiene por que sobreescribirse con basura (de ahí que tu código funcione). Pero en cualquier momento puede ser machacada.

Hay compiladores que en modo debug, sobreescriben la memoria para ayudarte a detectar esos errores... Pero depende del compilador, versión y entorno. Y en release, con optimizaciones activadas, ningún compilador la sobreescribe.

Saludos!

Responder

Muchas gracias por la excelente explicación, Eduard.

Hay algo que no entiendo bien... Yo le llamo basura a ese número indefinido y aleatorio al que apunta el puntero tras utilizar delete. Es decir, deja de ser 4 y pasa a ser, por ejemplo, 50805, con el cuál el código sigue operando con "normalidad" al tratarse de otro número entero... y si hago num+1, aparecerá 50806. A eso me refiero con que funciona.

¿Te refieres a que no tiene por qué aparecer ese número 50805 tras utilizar delete, y puede seguir siendo 4, pero a disposición de que otra variable lo machaque si ocupa la misma dirección de memoria?

Entonces, el riesgo de hacer ese delete en mi código puede ser que en cualquier otra parte de código, a una variable cualquiera se le pudiese asignar esa dirección de memoria a la que apunta nuestro "dangling pointer" y empezar a tener problemas, ¿no?

Si no he entendido mal, lo que se debe hacer es utilizar delete sólo cuando estés seguro de que no vas a acceder más a ese dato (al final de la aplicación, por ejemplo), y acto seguido apuntar a NULL todos los punteros que apuntaran hacia ese dato, para asegurarse de que no den problemas...

Perdona tanta pregunta, es un mundillo muy bonito pero difícil de pillarle el tranquillo.

Responder

Eduard Tomàs
Spain Eduard Tomàs

Buenas!
Llamamos "basura" a lo mismo :)
Y sí, es básicamente como comentas :)

Es decir, cuando haces "delete p", la DIRECCIÓN a la que apunta p, no cambia. Lo que "delete p" hace es marcar que el contenido que está en esa dirección ya no es válido y puede ser sobreescrito.
La mayoría de compiladores en modo DEBUG (que es el normal cuando estamos desarrollando) colocan "basura" en las direcciones liberadas.
Es decir, si ANTES de llamar a delete p, tenías que *p valía 4, después tienes que *p vale cualquier otro valor (50805 ese que comentabas). Ese valor es la "basura" que te coloca el compilador SOLO PARA QUE TE DES CUENTA DE QUE ESA ZONA DE MEMORIA YA NO CONTIENE INFO VÁLIDA.
Así, cuando depuras, si tu ves que *p vale 50805 y sabes que debería tener otro valor (4) es que alguien ha hecho un delete antes.
Pero ya está, es decir, si *p vale 50805, puedes sumar 1 a ese valor y obtendrás 50806, o puedes hacer *p=x y estás asignando el valor de x a esta dirección de memoria. Y si nadie más la usa, NO OCURRE NADA. El problema es que aunque tu le asignes un valor (*p=x) a esa zona de memoria liberada, para el runtime de C++, esa zona tiene memoria libre, por lo que cuando alguien haga un new puede ser que se le reserve la misma dirección de memoria. Por lo que entonces p, pasaría a estar apuntando a una zona de memoria que contiene datos de otro objeto. Y podría machacar esos datos y corromper la memoria. Cuando hay esas corrupciones de memoria, a veces, el programa rebienta, pero a veces no. A veces simplemente aparecen valores erroneos, o si tenemos cadenas carácteres extraños.

Las cosas en RELEASE (que es como se suelen desplegar los programas) son incluso peor, porque en este caso el compilador no coloca esa basura (por rendimiento, RELEASE está optimizado por rendimiento). Es decir, tu tienes que *p vale 4, haces delete p, y en la siguiente línea *p sigue valiendo 4. Eso hace que haya deletes que pasen desapercibidos, o el típico caso que en RELEASE el programa parece funcionar bien y en DEBUG da errores extraños (por supuesto, el error está ahí, y en DEBUG lo detectas gracias a la "basura", que en RELEASE funcione es coincidencia). Y por supuesto, dado que eso depende de como el runtime asigne la memoria, esos errores son los tipicos de que "funciona bien x veces y luego empieza a dar errores".

>Si no he entendido mal, lo que se debe hacer es utilizar delete sólo cuando estés seguro de que no vas a acceder más a ese dato (al final de la aplicación, por ejemplo), y acto seguido apuntar a NULL todos los punteros que apuntaran hacia ese dato, para asegurarse de que no den problemas...

Al final de la aplicación no tiene sentido, porque cuando el proceso muere el runtime destruye toda la memoria asociada, no hay memory leak una vez el proceso termina. Pero estás en lo cierto: debes usar delete cuando estás seguro que nadie accederá nunca más a ese dato, pero debes usar delete LO ANTES posible. Y luego deberías colocar a NULL todos los punteros que apuntaban a dicho dato, siempre y cuando dichos punteros sigan en ámbito. Es decir imagina eso:

void foo() {
    int* a =  new int;
    *a=42;
}

Aquí tengo un memory leak: cada vez que se llama a foo, se reserva un int y nunca se libera. Y una vez se termina foo, la variable a "desaparece", por lo que nadie puede hacer delete de esa dirección de memoria. En este caso, el delete debe ir forzosamente dentro de foo.
Por supuesto si foo devuelve a, entonces todo cambia:
int* foo() {
   int* a = new int;
   *a=42;
   return a;
}

Ahora foo NO puede hacer delete de a, porque con eso liberaría la memoria, así que el delete debe hacerlo quien llame a foo. Hacer este seguimiento de saber qué punteros acceden al mismo objeto, y quien debe liberar la memoria es la gran complicación de este sistema.

También te digo, que es necesario conocer este sistema, new y delete forman parte integral de C++, pero hay AHORA mejores maneras en C++ de gestionar la memoria, en concreto, hoy en día se debe usar lo que se conoce como RAII (Resource Acquisition Is Initialization) que es un patrón que determina las responsabilidades sobre quien inicializa y quien libera un recurso (sea memoria u otra cosa). Explicar RAII es complejo, porque requiere conocimientos relativamente avanzados de clases, templates (y en el caso de la memoria, de punteros), pero su uso es mucho más simple (¡por suerte!). En C++ el uso de RAII para memoria se implementa con un conjunto de clases que se conocen genéricamente como "smart pointers". Los introduzco, muy brevemente, en el post num IV de esta serie de posts: el contador de referencias automático (/recursos/post/Modelos-de-gestion-de-memoria-IV-Contador-de-referencias-automatico.aspx)

Saludos!

Responder

Hola Eduard.

En primer lugar, y de nuevo, muchas gracias por tan excelente explicación.

Sí, mis conocimientos de C++ son aún muy básicos. El RAII será algo parecido al recolector de basura que incorporan otros lenguajes, ¿no?

Yo por suerte o por desgracia ya el mes que viene empiezo a estudiar un CFGS en Desarrollo Web y me voy a tener que pasar por obligación a Java. Muchos programadores lo odian, yo aún no sé por qué, claro está. Pero quería dejar mis conocimientos en C++ tan avanzados como sea posible, aunque haya cosas que en Java no voy a tener que utilizar directamente (punteros).

¿Conoces algún buen libro de C++ para gente autodidacta? Ante todo que explique las cosas de manera sencilla, para los que no somos Ingenieros ni vamos a serlo.

Un saludo y gracias!

Además, me parece un lenguaje muy bonito que no quisiera dejar de lado.

Responder

Hola Fran:

Por desgracia, como el mercado es muy pequeño, en español hay pocos libros de calidad sobre lenguajes de programación. No obstante Anaya tiene un par de ellos que están bastante bien:

· www.anayamultimedia.com/libro.php?id=3608312: si partes de cero absoluto. No entra en profundidad, pero es una buena introducción básica al lenguaje.
· www.anayamultimedia.com/libro.php?id=2687988: un libro más profesional y que profundiza más en el lenguaje.

Saludos.

Responder

Agregar comentario