EXES
Aproximación a C#. Programación orientada a objetos
Bases generales: introducción a la tecnología .NET y bases sintácticas de C#
Espacios de nombres, clases e indicadores. Sistema de tipos de C# (CTS de .NET Framework)
Operadores de C#
Nuestra primera aplicación en C#: "Hola mundo"
Métodos (sobrecarga, argumentos y métodos static)
Constructores, destructores y el recolector de basura
Campos y propiedades
Control de flujo condicional
Control de flujo iterativo: bucles, salto y recursividad
Arrays
Indizadores, sobrecarga de operadores y conversiones definidas
Estructuras; Herencia e Interfaces
Pistas y resolución de ejercicios
Créditos
La Compañía
 
Área de Programación y Desarrollo
  Curso de Iniciación a C#
www.exes.es
Tel: 902 360 417
   Principal
 Exes   Contactar 
Constructores, el Recolector de Basura y los Destructores | Curso de C#: Séptima entrega

He de confesar que esta entrega, por el momento, es la me más me ha gustado escribir. En la parte que corresponde a la recolección de basura y los destructores me ha sido de gran utilidad el formidable artículo de Jeffrey Richter titulado: "Garbage Collection: Automatic memory management in .NET Framework" (o sea, Recolección de basura: gestión automática de memoria en .NET Framework, "pa' que nos entendamos"). Obviamente, no te voy a reproducir el artículo, además está en inglés... Sí he incluido las conclusiones que me parecen más jugosas de las que he podido extraer de su lectura. No obstante, si quieres tener una idea clara (muy clara, diría yo) de cómo funciona "por dentro" el recolector de basura, te recomiendo, cómo no, que te leas el original. 

 

CONSTRUCTORES

 

El concepto es muy sencillo de comprender: el constructor de una clase es un método que se encarga de ejecutar las primeras acciones de un objeto cuando este se crea al instanciar la clase. Estas acciones pueden ser, por ejemplo, inicializar variables, abrir archivos, asignar valores por defecto a las propiedades... Sobre los constructores hay un par de reglas que no debes olvidar:

 

1º. El constructor ha de llamarse exactamente igual que la clase.

2º. El constructor nunca puede retornar un valor.

 

Por lo tanto, lo primero que se ejecuta al crear un objeto es el constructor de la clase a la que dicho objeto pertenece (claro, siempre que haya un constructor, pues el compilador no exige que exista). Vamos a verlo:

 

using System;

 

namespace Constructores

{

    class Objeto

    {

        public Objeto()

        {

            Console.WriteLine("Instanciado el objeto");

        }

    }

 

    class ConstructoresApp

    {

        static void Main()

        {

            Objeto o = new Objeto();

            string a=Console.ReadLine();

        }

    }

}

 

En este pequeño ejemplo, la clase Objeto tiene un constructor (está en negrilla). Presta especial atención a que se llama exactamente igual que la clase (Objeto) y se declara igual que un método, con la salvedad de que no se pone ningún tipo de retorno puesto que, como he dicho antes, un constructor no puede retornar ningún dato. Al ejecutar este programa, la salida en la consola sería esta:

 

Instanciado el objeto

 

Seguramente te habrás dado cuenta de lo que ha ocurrido: en efecto, en el método Main no hemos dicho que escriba nada en la consola. Sin embargo, al instanciar el objeto se ha ejecutado el constructor, y ha sido este el que ha escrito esa línea en la consola.

 

Igual que los métodos, los constructores también se pueden sobrecargar. Las normas para hacerlo son las mismas: la lista de argumentos ha de ser distinta en cada una de las sobrecargas. Se suele hacer cuando se quiere dar la posibilidad de instanciar objetos de formas diferentes. Para que lo veas, vamos a sobrecargar el constructor de la clase Objeto. Ahora, el código de esta clase es el siguiente:

 

class Objeto

{

    public Objeto()

    {

        Console.WriteLine("Instanciado el objeto sin datos");

    }

 

    public Objeto(string Mensaje)

    {

        Console.WriteLine(Mensaje);

    }

 

    public Objeto(int dato1, int dato2)

    {

        Console.WriteLine("Los datos pasados al constructor son: {0} y {1}",

            dato1, dato2);

    }

}

 

Ahora podríamos instanciar objetos de esta clase de tres formas distintas: una sin pasarle datos; otra pasándole una cadena y otra pasándole dos datos de tipo int. Fíjate en este fragmento de código:

 

Objeto o1 = new Objeto();

Objeto o2 = new Objeto("Pasando una cadena");

Objeto o3 = new Objeto(34, 57);

 

La salida en la consola sería esta:

 

Instanciado el objeto

Pasando una cadena

Los datos pasados al constructor son: 34 y 57

 

Por otro lado, tenemos los constructores estáticos (static). La misión de estos constructores es inicializar los valores de los campos static o bien hacer otras tareas que sean necesarias para el funcionamiento de de la clase en el momento en que se haga el primer uso de ella, ya sea para instanciar un objeto, para ejecutar un método static o para ver el valor de un campo static. Ya sé que aún no hemos visto estos últimos (los campos), pero como los vamos a tratar en la próxima entrega creo que podemos seguir adelante con la explicación (no es fácil establecer un orden lógico para un curso de C#, porque todo está profundamente relacionado). Los constructores static, como te decía, no se pueden ejecutar más de una vez durante la ejecución de un programa, y además la ejecución del mismo no puede ser explícita, pues lo hará el compilador la primera vez que detecte que se va a usar la clase. Vamos a poner un ejemplo claro y evidente de constructor static: supongamos que la clase System.Console tiene uno (digo supongamos porque no he encontrado nada que me lo confirme, pero viendo cómo funciona, deduzco que su comportamiento se debe a esto): La primera vez que el CLR detecta que se va a utilizar esta clase o alguno de sus miembros se ejecuta su constructor static, y lo que hace este constructor es inicializar las secuencias de lectura y escritura en la ventana de DOS (o sea, en la consola), para que los miembros de esta clase puedan hacer uso de ella. Evidentemente, este constructor no se ejecutará más durante la vida le programa, porque de lo contrario se inicializarían varias secuencias de escritura y lectura, lo cual sería contraproducente. Vamos a poner un ejemplo que te lo acabe de aclarar:

 

using System;

 

namespace ConstructoresStatic

{

    class Mensaje

    {

        public static string Texto;

 

        static Mensaje()

        {

            Texto="Hola, cómo andamos";

        }

    }

 

    class ConstructoresStaticApp

    {

        static void Main()

        {

            Console.WriteLine(Mensaje.Texto);

 

            string a=Console.ReadLine();

        }

    }

}

En este ejemplo tenemos una clase Mensaje con dos miembros: un campo static de tipo string llamado Texto que aún no está inicializado (digamos que es como una variable a la que se puede acceder sin necesidad de instanciar la clase) y un constructor static para esta clase. En el método Main no instanciamos ningún objeto de esta clase, sino que simplemente escribimos en la consola el valor del campo Texto (que es static, y recuerda que aún no ha sido inicializado). Como hay un constructor static en la clase, el compilador ejecutará primero este constructor, asignando el valor adecuado al campo Texto. Por este motivo, la salida en la consola es la siguiente:

 

Hola, cómo andamos

 

Cuando hablemos de la herencia os mostraré cómo se comportan los constructores, tanto de la clase base como de sus clases derivadas. De momento podemos dar por terminadas las explicaciones, aunque a partir de ahora procuraré poner constructores por todas partes, para que no se te olviden.

 

EL RECOLECTOR DE BASURA Y LOS DESTRUCTORES

 

Y aquí va a empezar la polémica entre los programadores de C++: los auténticos gurús de este lenguaje defenderán que quieren seguir gestionando la memoria a su antojo, y los que no estén tan avanzados dirán que C# les acaba de quitar un gran peso de encima.

 

En efecto, tal y como os imagináis, C# (más propiamente, el código gestionado) incluye un recolector de basura (GC, Garbage Collector), es decir, que ya no es necesario liberar la memoria dinámica cuando no se necesita más una referencia a un objeto. El GC funciona de un modo perezoso, esto es, no libera las referencias en el momento en el que se dejan de utilizar, sino que lo hace siempre que se de uno de estos tres casos: cuando no hay espacio suficiente en el montón para meter un objeto que se pretende instanciar; cuando detecta que la aplicación va a finalizar su ejecución; y cuando se invoca el método Collect de la clase System.GC.

 

Sí, sí, ya sé que los que estéis empezando a programar ahora no os habéis enterado de nada. Veamos, hasta ahora sabéis que cuando se instancia un objeto se reserva un bloque de memoria en el montón y se devuelve una referencia (o puntero) al comienzo del mismo. Pues bien, cuando este objeto deja de ser utilizado (por ejemplo, estableciéndolo a null) lo que se hace es destruir la referencia al mismo, pero el objeto permanece en el espacio de memoria que estaba ocupando, y ese espacio de memoria no se puede volver a utilizar hasta que no sea liberado. En C++ era tarea del programador liberar estos espacios de memoria, y para ello se utilizaban los destructores. Sin embargo, en C# esto es tarea del GC.

 

No obstante, el hecho de que tengamos un GC no quiere decir que los destructores dejen de existir. Bueno, en realidad sí bajo el punto de vista semántico, aunque no en cuanto al punto de vista sintáctico (si estás empezando y no entiendes muy bien esto no te preocupes demasiado, porque va especialmente dirigido a los programadores de C++ para que no caigan en el error de pensar que los destructores de C# y los de C++ son la misma cosa, aunque sí te recomiendo que lo leas para que, al menos, te vayas haciendo una idea). Ya me imagino que alguno se estará haciendo un lío importante con esto: ¿cómo es que hay sintaxis para el destructor, pero desaparece su semántica? Piénsalo un poco: semánticamente un destructor implica la liberación de la memoria, pero ocurre que en el código gestionado esto es tarea del GC y, por lo tanto, la semántica del destructor es necesariamente incompatible con el GC. Sin embargo, sí podemos incluir un método que se ocupe de realizar las otras tareas de finalización, como eliminar archivos temporales que estuviera utilizando el objeto, por poner un ejemplo. Pues bien, lo que ocurre realmente cuando escribimos un destructor es que el compilador sobreescribe la función virtual Finalize de la clase System.Object, colocando en ella el código que hemos incluido en el destructor, y lo que invoca realmente el GC cuando va a liberar la memoria de un objeto es este método Finalize, aunque la sintaxis del destructor se mantiene simplemente para evitar más complicaciones en el aprendizaje de C# a los programadores de C++. No olvides esto: los destructores de C# (mejor dicho, en el código gestionado) son, en realidad, finalizadores. Sin embargo yo voy a seguir llamándolos destructores para no liarte cuando leas otros libros, puesto que la mayoría de los autores utilizan esta terminología. Entonces, ¿quiere esto decir que podemos elegir entre sobreescribir el método Finalize de la clase System.Object y crear un destructor? Pues no. Si intentas sobreescribir dicho método en C# el compilador te indicará que debes escribir un destructor.

 

Los destructores de C# no pueden ser invocados explícitamente como si fueran un método más. Tampoco sirve la trampa de invocar el método Finalize, ya que su modificador de acceso es protected. ¿Y desde las clases derivadas? Tampoco, pues el compilador no lo permite, ya que esta llamada se hace implícitamente en el destructor. Ya hablaremos de esto cuando lleguemos a la herencia, no nos precipitemos... Por lo tanto, los destructores serán invocados por el GC cuando este haga la recolección de basura. Como consecuencia, los destructores no admiten modificadores de acceso ni argumentos (claro, tampoco pueden ser sobrecargados). Se han de nombrar igual que la clase, precedidos por el signo ~. Lo veremos mejor con un ejemplo:

 

class Objeto

{

    ~Objeto()

    {

        Console.WriteLine("Objeto liberado");

    }

}

 

Tienes el destructor escrito en negrilla. Como decía, el destructor se llama igual que la clase precedido con el signo ~ (ALT presionado más las teclas 1 2 6 sucesivamente). Veamos cómo se comporta esto con un programa completo:

 

using System;

 

namespace Destructores

{

    class Objeto

    {

        ~Objeto()

        {

            Console.WriteLine("Referencia liberada");

        }

    }

 

    class DestructoresApp

    {

        static void Main()

        {

            Objeto o=new Objeto();

        }

    }

}

 

En este caso, para probar este programa, lo vamos a ejecutar sin depuración, o sea, Ctrl+F5 (de lo contrario no nos daría tiempo a ver el resultado). El programa no hace casi nada: sencillamente instancia la clase Objeto y finaliza inmediatamente. Al terminar, es cuando el GC entra en acción y ejecuta el destructor de la clase Objeto, por lo que la salida en la consola sería la siguiente:

 

Referencia liberada

 

¿Y por qué no hemos ejecutado el método ReadLine dentro de Main, como hemos hecho siempre? Sabía que me preguntarías eso. Vamos a volver a poner el método Main con esa línea que me dices y luego os lo comento:

 

static void Main()

{

    Objeto o=new Objeto();

    string a=Console.ReadLine();

}

 

Bien, tenemos dos motivos por los que no lo hemos hecho: el primero es que no serviría de nada, puesto que el destructor no se ejecutará hasta que el GC haga la recolección de basura, y esta no se hará hasta que finalice la aplicación, y la aplicación finaliza después de haber ejecutado todo el código del método Main. El segundo motivo es que esto provocaría un error. ¿¿¿CÓMO??? ¡Si está bien escrito! En efecto, pero se produce el siguiente error: "No se puede tener acceso a una secuencia cerrada". El porqué se produce tiene una explicación bastante sencilla: La primera vez que se usa la clase Console se inicializan las secuencias de lectura y escritura en la consola (seguramente en un constructor static), y estas secuencias se cierran justo antes de finalizar la aplicación. En el primer ejemplo funcionaría correctamente, puesto que esta secuencia se inicia justamente en el destructor, ya que antes de este no hay ninguna llamada a la clase Console. Sin embargo en el segundo se produce un error, porque las secuencias se inician dentro del método Main (al ejecutar Console.ReadLine), y se cierran cuando va a finalizar el programa. El problema viene aquí: los hilos de ejecución del GC son de baja prioridad, de modo que, para cuando el GC quiere ejecutar el destructor, las secuencias de escritura y lectura de la consola ya han sido cerradas, y como los constructores static no se pueden ejecutar más de una vez, la clase Console no puede abrirlas por segunda vez.

 

Sigamos con el ejemplo que funcionaba correctamente. En efecto, puede parecer que el GC ha sido sumamente rápido, pues ha liberado el objeto en el momento en el que este ya no era necesario. Sin embargo, veamos el siguiente ejemplo:

 

namespace Destructores

{

    class Objeto

    {

        ~Objeto()

        {

            Console.WriteLine("Referencia liberada");

        }

    }

 

    class DestructoresApp

    {

        static void Main()

        {

            Objeto o=new Objeto();

            Console.WriteLine("El objeto acaba de ser instanciado. Pulsa INTRO");

            string a = Console.ReadLine();

 

            o=null;

            Console.WriteLine("La referencia acaba de ser destruida. Pulsa INTRO");

            a = Console.ReadLine();

 

            GC.Collect();

            Console.WriteLine("Se acaba de ejecutar GC.Collect(). Pula INTRO");

            a = Console.ReadLine();

        }

    }

}

 

La salida en la consola sería esta:

 

El objeto acaba de ser instanciado. Pulsa INTRO

 

La referencia acaba de ser destruida. Pulsa INTRO

 

Se acaba de ejecutar GC.Collect(). Pulsa INTRO

Referencia liberada

 

Fíjate bien en que el destructor de la clase Objeto no se ha ejecutado cuando se destruyó la referencia (o=null), sino cuando se ha forzado la recolección con GC.Collect().

 

Sé que algunos programadores de C++ estarán pensando que, al fin y al cabo, establecer la referencia a null y ejecutar después GC.Collect() viene a ser lo mismo que el delete de C++. Aunque puede parecer que esto es correcto a la vista del ejemplo anterior te puedo asegurar que no es así. Vamos con este otro ejemplo:

 

using System;

namespace Destructores2

{

    class Objeto

    {

        public int dato;

        public Objeto(int valor)

        {

            this.dato=valor;

            Console.WriteLine("Construido Objeto con el valor {0}",

                valor);

        }

 

        ~Objeto()

        {

            Console.WriteLine("Destructor de Objeto con el valor {0}",

                this.dato);

        }

    }

 

    class Destructores2App

    {

        static void Main()

        {

            Objeto a=new Objeto(5);

            Objeto b=a;

            string c;

 

            Console.WriteLine("Valor de a.dato: {0}", a.dato);

            Console.WriteLine("Valor de b.dato: {0}", b.dato);

            Console.WriteLine("Pulsa INTRO para ejecutar a.dato++");

            c=Console.ReadLine();

 

            a.dato++;

            Console.WriteLine("Ejecutado a.dato++");

            Console.WriteLine("Valor de a.dato: {0}", a.dato);

            Console.WriteLine("Valor de b.dato: {0}", b.dato);

            Console.WriteLine("Pulsa INTRO para ejecutar a=null; GC.Collect()");

            c=Console.ReadLine();

 

            a=null;

            GC.Collect();

            Console.WriteLine("a=null; GC.Collect() han sido ejecutados");

            Console.WriteLine("Pulsa INTRO para ejecutar b=null; GC.Collect()");

            c=Console.ReadLine();

 

            b=null;

            GC.Collect();

            Console.WriteLine("b=null; GC.Collect() han sido ejecutados");

            c=Console.ReadLine();

        }

    }

}

 

Veamos ahora cómo sería en C++ no gestionado usando delete y luego comparamos las salidas de ambos programas:

 

// ¡¡¡ATENCIÓN!!! Este código está escrito en C++

#include <iostream.h>

 

class Objeto

{

public:

    Objeto(int valor)

    {

        dato=valor;

        cout << "Construido Objeto con el valor "

            << ("%d", valor) << "\n";

    }

 

    ~Objeto()

    {

        cout << "Destructor de Objeto con el valor "

            << ("%d", this->dato) << "\n";

    }

 

    int dato;

};

 

void main()

{

    Objeto* a=new Objeto(5);

    Objeto* b = a;

    char c;

 

    cout << "Valor de a->dato: " << ("%d", a->dato) << "\n";

    cout << "Valor de b->dato: " << ("%d", b->dato) << "\n";

    cout << "Pulsa INTRO para ejecutar a->dato++\n";

    cin.get(c);

    a->dato++;

 

    cout << "Ejecutado a->dato++\n";

    cout << "Valor de a->dato: " << ("%d", a->dato) << "\n";

    cout << "Valor de b->dato: " << ("%d", b->dato) << "\n";

    cout << "Pulsa INTRO para ejecutar delete a\n";

    cin.get(c);

 

    delete a;

    cout << "delete a ha sido ejecutado\n";

    cout << "Pulsa INTRO para ejecutar delete b (esto provocará un error)\n";

    cin.get(c);

 

    delete b;

}

 

Estas son las salidas de ambos programas:.

 

SALIDA DEL PROGRAMA EN C# SALIDA DEL PROGRAMA EN C++

Construido Objeto con el valor 5

Valor de a.dato: 5

Valor de b.dato: 5

Pulsa INTRO para ejecutar a.dato++

 

Ejecutado a.dato++

Valor de a.dato: 6

Valor de b.dato: 6

Pulsa INTRO para ejecutar a=null;GC.Collect

 

a=null; GC.Collect() han sido ejecutados

Pulsa INTRO para ejecutar b=null;GC.Collect

 

b=null; GC.Collect() han sido ejecutados

Destructor de Objeto con el valor 6

Construido Objeto con el valor 5

Valor de a->dato: 5

Valor de b->dato: 5

Pulsa INTRO para ejecutar a->dato++

 

Ejecutado a->dato++

Valor de a->dato: 6

Valor de b->dato: 6

Pulsa INTRO para ejecutar delete a

 

Destructor de Objeto con el valor 6

delete a ha sido ejecutado

Pulsa INTRO para ejecutar delete b (e...

 

AQUÍ SE PRODUCE UN ERROR

 

Presta atención a que en estos programas tenemos una doble referencia hacia el mismo objeto, es decir, tanto "a" como "b" apuntan a la misma zona de memoria. Sabemos esto porque el constructor se ha ejecutado únicamente una vez cuando se hizo "a=new Objeto(5)", pero cuando se asignó "b=a" lo que hicimos fue crear la doble referencia. La parte en la que se incrementa el campo "dato" es para demostrar que dicha alteración afecta a ambas referencias. Las diferencias vienen a partir de aquí: Cuando se ejecuta a=null; GC.Collect() en C# se ha destruido la referencia de "a", pero no se ha ejecutado el destructor porque aún hay una referencia válida hacia el objeto: la referencia de "b". Después, cuando se destruye la referencia de "b" y se vuelve a ejecutar GC.Collect() observamos que sí se ejecuta el destructor, ya que el GC no ha encontrado ninguna referencia válida y puede liberar el objeto. Sin embargo, en el programa escrito en C++ ha ocurrido algo muy distinto: el destructor se ha ejecutado en el momento de hacer el "delete a", ya que delete libera la memoria en la que se alojaba el objeto independientemente de las referencias que haya hacia él. Por este motivo se produce un error cuando se intenta ejecutar "delete b", puesto que el objeto fue liberado con anterioridad.

 

Por otro lado, el GC garantiza que se ejecutará el destructor de todos los objetos alojados en el montón (recuerda, tipos referencia) cuando no haya referencias hacia ellos, aunque esta finalización de objetos no sea determinista, es decir, no libera la memoria en el instante en que deja de ser utilizada. Por contra, en C++ se puede programar una finalización determinista, pero esta tarea es sumamente compleja en la mayoría de las ocasiones y, además, suele ser una importante fuente de errores y un gran obstáculo para un adecuado mantenimiento del código. Veamos un ejemplo, muy simple, eso sí, de esto último. Usaremos la misma clase Objeto que en el ejemplo anterior, pero este método Main:

 

static void Main()

{

    Objeto a;

    string c;

 

    Console.WriteLine("Pulsa INTRO para instanciar el primer objeto");

    c=Console.ReadLine();

 

    a=new Objeto(1);

    Console.WriteLine("Pulsa INTRO para instanciar el segundo objeto");

    c=Console.ReadLine();

 

    a=new Objeto(2);

    Console.WriteLine("Pulsa INTRO para instanciar el tercer objeto");

    c=Console.ReadLine();

 

    a=new Objeto(3);

    Console.WriteLine("Pulsa INTRO para ejecutar a=null");

    c=Console.ReadLine();

 

    a=null;

    Console.WriteLine("Pulsa INTRO para ejecutar CG.Collect()");

    c=Console.ReadLine();

    GC.Collect();

 

    c=Console.ReadLine();

}

 

Esta sería la función main en C++ no gestionado:

 

// ¡¡¡ATENCIÓN!!! Este código está escrito en C++

void main()

{

    Objeto* a;

    char c;

 

    cout << "Pulsa INTRO para construir el primer objeto\n";

    cin.get(c);

    a=new Objeto(1);

 

    cout << "Pulsa INTRO para construir el segundo objeto\n";

    cin.get(c);

    a=new Objeto(2);

 

    cout << "Pulsa INTRO para construir el tercer objeto\n";

    cin.get(c);

    a=new Objeto(3);

 

    cout << "Pulsa INTRO para ejecutar delete a\n";

    cin.get(c);

 

    delete a;

}

 

Y estos son los resultados de ambos programas:

 

SALIDA DEL PROGRAMA EN C# SALIDA DEL PROGRAMA EN C++

Pulsa INTRO para construir el primer objeto

 

Construido Objeto con el valor 1

Pulsa INTRO para construir el segundo objeto

 

Construido Objeto con el valor 2

Pulsa INTRO para construir el tercer objeto

 

Construido Objeto con el valor 3

Pulsa INTRO para ejecutar a=null

 

Pulsa INTRO para ejecutar GC.Collect()

 

Destructor de Objeto con el valor 3

Destructor de Objeto con el valor 1

Destructor de Objeto con el valor 2

Pulsa INTRO para construir el primer objeto

 

Construido Objeto con el valor 1

Pulsa INTRO para construir el segundo objeto

 

Construido Objeto con el valor 2

Pulsa INTRO para construir el tercer objeto

 

Construido Objeto con el valor 3

Pulsa INTRO para ejecutar delete a

 

Destructor de Objeto con el valor 3

 

En estos dos programas estamos creando nuevos objetos constantemente con la misma variable. Al hacerlo se destruye la referencia anterior para crear la nueva. Puedes ver que, tanto en C# como en C++, no se ha ejecutado ningún destructor cuando se destruía una referencia antigua para crear la nueva. Cuando en el programa escrito en C# hemos destruido la última referencia (a=null) y ejecutado GC.Collect() se han ejecutado los destructores de los tres objetos que habíamos creado, es decir, el CG ha liberado los espacios de memoria que estaban ocupando. Sin embargo, al ejecutar "delete a" en el programa escrito en C++, solamente se ha liberado el último objeto. ¿Qué ha sido de los otros? Nada, y nunca mejor dicho. No se ha hecho nada con ellos y, por lo tanto, siguen ocupando la memoria que tenían asignada y esta memoria no se puede volver a asignar hasta que no se libere. Como consecuencia, los destructores no se han ejecutado y, por consiguiente, los otros recursos que pudieran estar utilizando siguen en uso (archivos temporales, bases de datos, conexiones de red...). ¿Y cuándo se liberan? Pues, por ejemplo, cuando apaguemos el ordenador (claro). Para que esto no hubiera sucedido (en el programa de C++, se entiende) habría que haber liberado cada una de las instancias de la clase Objeto antes crear otra, es decir, habría que haber ejecutado "delete a" antes de crear la segunda y la tercera referencia. En este ejemplo la solución era, como has visto, bastante fácil, pero ahora quiero que te imagines esto en un contexto algo más complejo (y real), con referencias circulares (es decir, uno objeto apunta a otro y este, a su vez, al primero), referencias compartidas por múltiples procesos y cosas así. La cosa se puede complicar muchísimo.

 

A pesar de todo, el hecho de que el GC no ofrezca una finalización determinista también podría ser un contratiempo (de hecho lo sería en muchos casos): ¿qué ocurre si, por ejemplo, un objeto abre una base de datos en modo exclusivo? Efectivamente, necesitaríamos que la base de datos fuera cerrada lo antes posible para que otros procesos pudieran acceder a ella, independientemente de si el objeto se libera o no de la memoria. ¿Cómo lo hacemos? Pues bien, para estos casos Microsoft recomienda escribir un método llamado Close y/o Dispose en la clase para liberar estos recursos además de hacerlo en el destructor, e incluir en la documentación de la misma un aviso para que se invoque este método cuando el objeto no se vaya a usar más. En general, se recomienda escribir un método Dispose si el objeto necesitara finalización determinista y no se pudiera volver a utilizar hasta una nueva instanciación, y/o un método Close si también necesitara finalización determinista pero pudiera ser reutilizado de nuevo sin volverlo a instanciar (usando por ejemplo un método Open). Ojo, ninguno de los métodos Close o Dispose sustituyen al destructor, sino que se escriben simplemente para liberar otros recursos antes (o mucho antes) de que el objeto sea liberado de la memoria. Pero, si escribimos un método Close o Dispose para que se haga la finalización, ¿para qué necesitamos el destructor? Pues lo necesitamos por si el programador que está usando nuestra clase olvida invocar el método Close o el método Dispose. Así nos aseguramos de que, tarde o temprano, los recursos que utilizaba el objeto se liberarán. Ahora bien, hay que tener cuidado con esto: si la aplicación cliente ha invocado el método Close o el Dispose debemos evitar que se ejecute el destructor, pues ya no hace falta. Para esto tenemos el método SupressFinalize de la clase System.GC. Veamos un ejemplo de esto:

 

using System;

namespace MetodosCloseYDispose

{

    class FinalizarDeterminista

    {

        public void Dispose()

        {

            Console.WriteLine("Liberando recursos");

            // Aquí iría el código para liberar los recursos

 

            GC.SuppressFinalize(this);

        }

 

        ~FinalizarDeterminista()

        {

            this.Dispose();

        }

    }

 

    class MetodosCloseYDisposeApp

    {

        static void Main()

        {

            string c;

            FinalizarDeterminista a=new FinalizarDeterminista();

            Console.WriteLine("Pulsa INTRO para ejecutar a.Dispose()");

            c=Console.ReadLine();

            a.Dispose();

 

            Console.WriteLine("Pulsa INTRO para ejecutar a=null; GC.Collect()");

            c=Console.ReadLine();

 

            a=null;

            GC.Collect();

 

            Console.WriteLine("Ejecutado a=null; GC.Collect()");

            Console.WriteLine("Pulsa INTRO para volver a instanciar a");

            c=Console.ReadLine();

 

            a=new FinalizarDeterminista();

 

            Console.WriteLine("Pulsa INTRO para ejecutar a=null; GC.Collect()");

            c=Console.ReadLine();

 

            a=null;

            GC.Collect();

 

            c=Console.ReadLine();

        }

    }

}

 

Efectivamente, en este ejemplo hemos escrito el código de finalización de la clase dentro del método Dispose, y en el destructor simplemente hemos puesto una llamada a este método. Fíjate en que, al final del método Dispose, hemos invocado el método SupressFinalize de la clase GC para que el recolector de basura no ejecute el destructor, ya que se ha ejecutado el método Dispose. En caso de que el cliente no ejecutara este método, el GC ejecutaría el destructor al hacer la recolección, con lo cual nos aseguramos de que todos los recursos quedarán libres independientemente de si el programador que usa nuestra clase olvidó o no hacerlo invocando Dispose.

 

¡Uffff! ¿Ya hemos terminado con esto? Pues sí..., hemos terminado... de empezar. Como os he dicho, .NET Framework ofrece la clase System.GC para proporcionarnos un cierto control sobre el recolector de basura. Como son varios los métodos de esta clase y considero que este tema es muy interesante, me extenderé un poquito más, si no os importa.

 

Un fenómeno curioso (y a la vez peligroso) que sucede con la recolección de basura es la resurrección de objetos. Sí, sí, he dicho resurrección. No es que tenga mucha utilidad, pero quiero contaros qué es, pues puede que os libre de algún que otro quebradero de cabeza en el futuro. Sucede cuando un objeto que va a ser eliminado vuelve a crear una referencia a sí mismo durante la ejecución de su destructor. Veamos un ejemplo:

 

using System;

 

namespace Resurreccion

{

    class Objeto

    {

        public int dato;

 

        public Objeto(int valor)

        {

            this.dato=valor;

            Console.WriteLine("Construido Objeto con el valor {0}",

                valor);

        }

        ~Objeto()

        {

            Console.WriteLine("Destructor de Objeto con el valor {0}",

                this.dato);

            ResurreccionApp.resucitado=this;

        }

    }

 

    class ResurreccionApp

    {

        static public Objeto resucitado;

 

        static void Main()

        {

            string c;

            Console.WriteLine("Pulsa INTRO para crear el objeto");

            c=Console.ReadLine();

 

            resucitado=new Objeto(1);

            Console.WriteLine("Valor de resucitado.dato: {0}", resucitado.dato);

            Console.WriteLine("Pulsa INTRO para ejecutar resucitado=null; GC.Collect()");

            c=Console.ReadLine();

 

            resucitado=null;

            GC.Collect();

            GC.WaitForPendingFinalizers();

            Console.WriteLine("Valor de resucitado.dato: {0}", resucitado.dato);

            Console.WriteLine("Pulsa INTRO para ejecutar resucitado=null; GC.Collect()");

            c=Console.ReadLine();

 

            resucitado=null;

            GC.Collect();

            Console.WriteLine("Ejecutado resucitado=null; GC.Collect()");

 

            c=Console.ReadLine();

        }

    }

}

 

Ahora vamos a ver la sorprendente salida en la consola y después la examinamos:

 

Pulsa INTRO para crear el objeto

 

Construido Objeto con el valor 1

Valor de resucitado.dato: 1

Pulsa INTRO para ejecutar resucitado=null; GC.Collect()

 

Destructor de Objeto con el valor 1

Valor de resucitado.dato: 1

Pulsa INTRO para ejecutar resucitado=null; GC.Collect()

 

Ejecutado resucitado=null; GC.Collect()

 

Vamos poco a poco, que si no podemos perdernos con bastante facilidad. Al principio, todo bien, como se esperaba: al instanciar el objeto se ejecuta el constructor del mismo. Ahora es cuando viene lo bueno: se anula la referencia y, por lo tanto, el GC determina que puede liberarlo y ejecuta su destructor. Sin embargo, cuando volvemos a escribir el valor del campo "dato" ¡este vuelve a aparecer! En efecto, el GC no lo liberó a pesar de haber ejecutado su destructor, y lo más curioso es que el motivo por el que no lo ha liberado no es que se haya creado una nueva referencia al objeto en el destructor, sino otro que explicaremos después. Pero ahí no queda la cosa: cuando destruimos la referencia y forzamos la recolección por segunda vez el destructor no se ha ejecutado. Y las dudas se acrecentan, claro: ¿se ha liberado o no se ha liberado? y, si se ha liberado, ¿por qué no se ha ejecutado el destructor? Pues bien, sí se ha liberado, pero no se ha ejecutado el destructor. En resumen: lo que ha ocurrido es que la primera vez que destruimos la referencia y ejecutamos GC.Collect se ejecutó el destructor pero no se liberó, y la segunda vez se liberó pero no se ejecutó el destructor. La explicación de todo este embrollo es la siguiente: Cuando se instancia un objeto, el GC comprueba si este tiene un destructor. En caso afirmativo, guarda un puntero hacia el objeto en una lista de finalizadores. Al ejecutar la recolección, el GC determina qué objetos se pueden liberar, y posteriormente comprueba en la lista de finalizadores cuáles de ellos tenían destructor. Si hay alguno que lo tiene, el puntero se elimina de esta lista y se pasa a una segunda lista, en la que se colocan, por lo tanto, los destructores que se deben invocar. El GC, por último, libera todos los objetos a los que el programa ya no hace referencia excepto aquellos que están en esta segunda lista, ya que si lo hiciera no se podrían invocar los destructores, y aquí acaba la recolección. Como consecuencia, un objeto que tiene destructor no se libera en la primera recolección en la que se detecte que ya no hay referencias hacia él, sino en la siguiente, y este es el motivo por el que, en nuestro ejemplo, el objeto no se liberó en la primera recolección. Tras esto, un nuevo hilo de baja prioridad del GC se ocupa de invocar los destructores de los objetos que están en esta segunda lista, y elimina los punteros de ella según lo va haciendo. Claro, la siguiente vez que hemos anulado la referencia y forzado la recolección en nuestro ejemplo, el GC determinó que dicho objeto se podía liberar y lo liberó, pero no ejecutó su destructor porque la dirección del objeto ya no estaba ni el la lista de finalizadores ni en la segunda lista. ¿Y si, a pesar de todo, queríamos que se volviera a ejecutar el destructor, no podíamos hacerlo? Bien, para eso tenemos el método ReRegisterForFinalize de la clase GC, que lo que hace es volver a colocar un puntero al objeto en la lista de finalizadores.

 

Como te decía, son pocas las utilidades que se le pueden encontrar a la resurrección. De hecho, yo no he encontrado ninguna (desde aquí os invito a que me mandéis un E-mail si a vosotros se os ocurre algo). Por este motivo la he calificado de "fenómeno curioso y peligroso" en lugar de "potente característica", pues creo que es más un "efecto colateral" del propio funcionamiento del GC que algo diseñado así a propósito. ¿Que por qué digo que es peligroso? Porque, dependiendo de cómo hayamos diseñado la clase, el efecto puede ser de lo más inesperado. Imagina, por ejemplo, un objeto (llamémosle Padre) de una clase que, a su vez, crea sus propios objetos de otras clases (llamémosles Hijos). Al hacer la recolección, el GC determina que el objeto Padre se puede liberar, y con él todos aquellos a los que este hace referencia, es decir, los Hijos. Como consecuencia, puede que el GC libere varios de estos Hijos referenciados en el objeto Padre. Sin embargo, si hemos resucitado al Padre se puede armar un buen lío (de hecho se armará seguro) cuando este intente acceder a los objetos Hijos que sí han sido liberados.

 

Por otro lado, quiero que te fijes de nuevo en el ejemplo: verás que hay una invocación al método GC.WaitForPendingFinalizers. Este método interrumpe la ejecución del programa hasta que el GC termine de ejecutar todos los destructores que hubiera que ejecutar. Y en este caso tenemos que interrumpir la ejecución porque la siguiente línea a GC.Collect() intenta recuperar el valor del campo "dato". Claro, como la recolección acaba antes de que se hayan ejecutado los destructores y el hilo de ejecución de estos es de baja prioridad, cuando se quiere recuperar este valor resulta que el destructor todavía no se ha ejecutado, de modo que la referencia del objeto todavía es null.

 

Tenemos también una serie de características relacionadas con el GC que son verdaderamente interesantes. Sabemos hasta ahora que uno de los momentos en los que el GC hará la recolección de basura es cuando detecte que se está quedando sin memoria (es decir, cuando intentemos instanciar un objeto y el GC se de cuenta de que no le cabe en el montón). Pues bien, vamos a pensar en qué ocurriría si tenemos un objeto que ocupa bastante memoria y que, además, se suele tardar bastante tiempo en crear. Con lo que sabemos hasta ahora, el GC lo liberará siempre que no haya referencias hacia él, pero el problema sería saber cuándo destruimos la referencia hacia él: no sería bueno hacerlo en el momento en el que lo dejemos de necesitar, porque si nos vuelve a hacer falta tendríamos que volver a crearlo, y hemos dicho que este proceso es demasiado lento; lo peor es que tampoco es bueno destruir la referencia hacia él al final de la ejecución del programa porque hemos dicho que ocupa demasiada memoria, y esto puede hacer que, en algún momento determinado, nos quedemos sin espacio para crear otros objetos. Claro, hablando siempre en el supuesto de que el objeto no es necesario en un momento determinado, lo ideal sería que el objeto permaneciera en memoria siempre que esto no afectara al resto de la aplicación, es decir, siempre que hubiera memoria suficiente para crear más objetos, y que se quitara de la misma en el caso de que fuera necesario liberar memoria para la creación de otros objetos. En otras circunstancias, la decisión sería bastante complicada. La buena noticia es que .NET Framework nos proporciona precisamente esta solución, gracias a las referencias frágiles. Esto se ve muy bien con un ejemplo:

 

using System;

namespace ReferenciaFragil

{

    class ObjetoGordo

    {

        public int Dato;

        public ObjetoGordo()

        {

            this.Dato=4;

            Console.WriteLine("Creando objeto gordo y costoso");

            for (ulong i=0;i<2000000000;i++) {}

            Console.WriteLine("El objeto gordo y costoso fue creado");

            Console.WriteLine();

        }

    }

 

    class ReferenciaFragilApp

    {

        static void Main()

        {

            Console.WriteLine("Pulsa INTRO para crear el objeto gordo");

            string c=Console.ReadLine();

            ObjetoGordo a=new ObjetoGordo();

 

            Console.WriteLine("El valor de a.Dato es {0}", a.Dato);

            Console.WriteLine();

            WeakReference wrA=new WeakReference(a);

            a=null;

 

            Console.WriteLine("Ejecutado wrA=new WeakReference(a);a=null;");

            Console.WriteLine("El resultado de wrA.IsAlive es: {0}", wrA.IsAlive);

            Console.WriteLine("Pulsa INTRO para recuperar el objeto gordo");

            c=Console.ReadLine();

 

            a=(ObjetoGordo) wrA.Target;

            Console.WriteLine("Ejecutado a=(ObjetoGordo) wrA.Target");

            Console.WriteLine("El valor de a.Dato es {0}", a.Dato);

            Console.WriteLine("Pulsa INTRO para ejecutar a=null;GC.Collect");

            c=Console.ReadLine();

            a=null;

 

            GC.Collect();

            Console.WriteLine("El resultado de wrA.IsAlive es: {0}", wrA.IsAlive);

            Console.WriteLine("Como ha sido recolectado no se puede recuperar");

            Console.WriteLine("Habría que instanciarlo de nuevo");

            c=Console.ReadLine();

        }

    }

}

 

La salida en la consola sería esta:

 

Pulsa INTRO para crear el objeto gordo

 

Creando Objeto gordo y costoso

El objeto gordo y costos fue creado

 

El valor de a.Dato es 4

 

Ejecutado wrA=new WeakReference(a); a=null;

El resultado de wrA.IsAlive es: True

Pulsa INTRO para recuperar el objeto gordo

 

Ejecutado a=(ObjetoGordo) wrA.Target

El valor de a.Dato es 4

Pulsa INTRO para ejecutar a=null; GC.Collect

 

El resultado de wrA.IsAlive es: False

Como ha sido recolectado no se puede recuperar

Habría que instanciarlo de nuevo

 

En este pequeño ejemplo hemos diseñado una clase que tarda un poco en terminar de ejecutar el constructor. Después de crear el objeto, vemos cuál es el valor del campo Dato para que os deis cuenta de que el objeto se ha creado con éxito. Bien, después creamos el objeto wrA de la clase System.WeakReference, que es, en efecto, la referencia frágil. En el constructor de esta clase (WeakReference) hay que pasarle el objeto hacia el que apuntará dicha referencia. Lo más importante viene ahora: como ves, hemos destruido la referencia de a (en a=null). Sin embargo, cuando recuperamos el valor de la propiedad IsAlive de wrA, vemos que esta retorna True, es decir, que el objeto sigue en la memoria (por lo tanto, no ha sido recolectado por el GC). Como está vivo recuperamos la referencia (en la línea a=(ObjetoGordo) wrA.Target) y volvemos a escribir el valor de a.Dato, para que veas que, en efecto, el objeto se ha recuperado. Para terminar, al final volvemos a destruir la referencia (a=null) y, además, forzamos la recolección de basura. Por este motivo, cuando después pedimos el valor de la propiedad IsAlive vemos que retorna False (el objeto ha sido recolectado por el GC), así que ya no se podría recuperar y habría que instanciarlo de nuevo para volverlo a usar. La enseñanza principal con la que quiero que te quedes es con la siguiente: si esto fuera un programa grande y hubiéramos tenido un objeto verdaderamente gordo, lo ideal es crear una referencia frágil (con la clase WeakReference) hacia él cuando, de momento, no se necesite más. Así, si el GC necesita memoria podrá recolectarlo, pero no lo hará en caso de que no se necesite. De este modo, si más adelante el programa vuelve a necesitar el objeto nos bastará con comprobar lo que devuelve la propiedad IsAlive de la referencia frágil: si devuelve False habrá que instanciarlo de nuevo, pero si devuelve True bastará con recuperar la referencia frágil, de modo que no habrá que perder tiempo en volver a crear el objeto.

 

¿Y qué ocurre si el objeto tiene un destructor? Bueno, aquí la cosa se complica un poco: la clase System.WeakReference tiene dos sobrecargas para su constructor: una de ellas la has visto ya en el ejemplo anterior: hay que pasarle el objeto hacia el que queremos esta referencia. La otra sobrecarga requiere, además, un valor boolean para el segundo argumento (que se llama TrackResurrection). Este argumento sirve para que podamos indicar si queremos que se pueda recuperar o no el objeto después de que se haya ejecutado su destructor: si pasamos true, se podrá recuperar, y si pasamos false no. ¿Que cómo se va a poder recuperar si se ha recolectado? ¡Cuidado aquí! Recuerda que, si una clase tiene destructor, los objetos de la misma no se liberan la primera vez que el GC determina que no hay referencias hacia ellos, sino que solamente se ejecuta su destructor, y se liberarán en la siguiente recolección. Por este motivo, si pasamos true en el argumento TrackResurrection del constructor de la clase WeakReference puede ocurrir alguna de estas tres cosas cuando queramos recuperar el objeto: si el GC no ha hecho ninguna recolección lo recuperaremos sin más; si el GC hizo una recolección, el destructor del objeto se habrá ejecutado, pero aún así podremos recuperarlo, pues aún no se ha liberado, con lo cual es una resurrección pura y dura; si el GC hizo dos recolecciones el objeto no será recuperable.

 

¿Cómo lo llevas? ¿Bien? ¿Seguimos? Venga, anímate hombre, que ya queda poco...

 

Nos queda hablar de las generaciones. No, no voy a empezar un debate sobre padres e hijos, no. A ver si nos centramos un poquito, ¿eh? Veeeeenga... Sacúdete la cabeza un poco... te hará sentir mejor... ¿Ya? Pues vamos. El GC, para mejorar su rendimiento, agrupa los objetos en diferentes generaciones. ¿Por qué? Pues porque, generalmente, los últimos objetos que se construyen suelen ser los primeros en dejar de ser utilizados. Piensa, por ejemplo, en una aplicación para Windows, Internet Explorer, por ejemplo. El primer objeto que se crea es la ventana de la aplicación (bueno, puede que no sea así siendo estrictos, pero si me admitís la licencia entenderéis lo que quiero decir mucho mejor). Bien, cuando abres un cuadro de diálogo con alguna opción de menú, se crea otro objeto, o sea, ese cuadro de diálogo precisamente. Hasta aquí, entonces, tenemos dos objetos. ¿Cuál de ellos cerrarás primero? Efectivamente, el cuadro de diálogo. Es decir, el último que se creó. ¿Lo ves? No estoy diciendo que esto sea así siempre (si fuera así, el montón sería una pila, y no el montón), sino que es muy frecuente. La gran ventaja de que el GC use generaciones es que, cuando necesita memoria, no revisa todo el montón para liberar todo lo liberable, sino que libera primero todo lo que pueda de la última generación, pues lo más probable es que sea aquí donde encuentre más objetos inútiles. Volvamos al ejemplo del Internet Explorer. Antes de la primera recolección se han abierto y cerrado, por ejemplo tres cuadros de diálogo. Todos estos objetos están, por lo tanto, en la primera generación, y cuando se ejecute el GC se pueden liberar excepto, claro está, la ventana de la aplicación, que sigue activa. Todos los objetos que se creen a partir de la primera recolección pasarán a formar parte de la segunda generación. Así, cuando el CG vuelva a ejecutarse comprobará solamente si puede liberar los objetos de la segunda generación, y, si ha liberado memoria suficiente, no mirará los de la primera (que, recuerda, solamente quedaba la ventana de la aplicación). Efectivamente, ha ganado tiempo, ya que era poco probable que tuviera que recolectar algún objeto de la generación anterior. Imagina que mientras se hacía esta segunda recolección había un cuadro de diálogo abierto. Claro, habrá liberado todo lo de la segunda generación (que no hiciera falta, por supuesto) menos este cuadro de diálogo, pues aún está en uso. A partir de aquí, los objetos pertenecerán a la tercera generación, y el GC tratará de liberar memoria solamente entre estos. Si consigue liberar lo que necesita, no mirará en las dos generaciones anteriores, aunque haya objetos que se pudieran liberar (como el cuadro de diálogo que teníamos abierto cuando se ejecutó la segunda recolección). Por supuesto, en caso de que liberando toda la memoria posible de la tercera generación no consiguiera el espacio que necesita, trataría de liberar también espacio de la segunda generación y, si aún así no tiene suficiente, liberaría también lo que pudiera de la primera generación (en caso de que no encuentre memoria suficiente para crear un objeto después de recolectar todo lo recolectable, lanzaría una excepción... o error). A partir de aquí, no hay más generaciones, es decir, el GC agrupa un máximo de tres generaciones.

 

Para el manejo de las generaciones tenemos el método GetGeneration, con dos sobrecargas: una devuelve la generación de una referencia frágil (objeto WeakReference) que se le pasa como argumento, y la otra devuelve la generación de un objeto de cualquier clase que se le pasa como argumento. El método Collect (que tanto hemos usado) también tiene dos sobrecargas: una de ellas es la que hemos venido usando hasta ahora, es decir, sin argumentos, que hace una recolección total del montón (es decir, sin tener en cuenta las generaciones), y otra en la que hace solamente la recolección de una generación que se le pase como argumento. Hay que tener cuidado con esto: la generación más reciente siempre es la generación cero, la anterior es la generación uno y la anterior la generación dos. Con esta entrega hay también un ejemplo que trata sobre las generaciones, pero no te lo reproduzco aquí porque es demasiado largo, y esta entrega ya es, de por sí, bastante "hermosota". En el sumario de este ejemplo te digo qué es lo más importante.

 

Hay un último detalle que no quiero dejar escapar: debido a cómo funciona el recolector de basuras, lo más recomendable es limitar la utilización de destructores solamente a aquellos casos en los que sea estrictamente necesario ya que, como te dije antes, la liberación de la memoria de un objeto que tiene destructor no se efectúa en la primera recolección en la que detecte que ya no hay referencias hacia él, sino en la siguiente.

 

Aunque te parezca mentira, esto del GC tiene todavía más "miga", pero como lo que queda tiene mucho que ver con la ejecución multi-hilo lo voy a dejar para más adelante, que tampoco quiero provocar dolores de cabeza a nadie...

 

Para esta entrega tienes nada menos que once ejemplos, entre los cuales están dos ejemplos en C++. Estos están es las carpetas Destructores2CPP y Desctructores3CPP, y también puedes correrlos en la versión de C++ que viene con la Beta2 de Visual Studio.NET.





··> Ver todos los cursos
··> Si necesitas más información, contáctanos aquí
 
 
 
Sello de Calidad   Sello de Calidad
  EXES - C/ Albasanz, 14 Bis, 1-C. 28037 Madrid - Tel: 902 360 417 Fax: 902 931 305 - exes@exes.es