sábado, 3 de junio de 2017

Curso de C# : constructores y destructores

Constructores


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: inicializar variables, abrir archivos, asignar valores por defecto a las propiedades. Un par de reglas:

1º. El constructor ha de llamarse exactamente igual que la clase.
2º. El constructor nunca puede retornar un valor.

Lo primero que se ejecuta al crear un objeto es el constructor de la clase a la que dicho objeto pertenece (el compilador no exige que exista). 

    class Objeto
    {
        public Objeto()
        {
            Console.WriteLine("objeto instanciado");
        }
    }

    class ConstructoresApp
    {
        static void Main()
        {
            Objeto o = new Objeto();
            string a=Console.ReadLine();
        }
    }

En este ejemplo, la clase Objeto tiene un constructor (en negrita). Se declara igual que un método, con la salvedad de que no se pone ningún tipo de retorno Al ejecutar este programa, la salida en la consola sería esta:

Curso de C# : constructores y destructores



objeto instanciado 

En el método Main no se ha dicho que escriba nada en la consola. Pero 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 hace cuando se desea dar la posibilidad de instanciar objetos de formas diferentes.

Por otro lado, también existen los constructores estáticos (static). La misión de estos constructores es inicializar los valores de los campos static o hacer otras tareas necesarias para el funcionamiento de la clase en el momento en que se haga el primer uso de ella. 
Los constructores static, 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. 

El recolector de basura y los destructores


C#  incluye un recolector de basura (GC, Garbage Collector), es decir, ya no es necesario liberar la memoria dinámica cuando no se necesita más una referencia a un objeto. El GC no libera las referencias en el momento en el que se dejan de utilizar, sino que lo hace siempre que se produzca uno de estos tres casos:

Cuando no hay espacio suficiente en la memoria para meter un objeto que se pretende instanciar.

Cuando detecta que la aplicación va a finalizar su ejecución. 

Cuando se invoca el método Collect de la clase System.GC.

Cuando se instancia un objeto se reserva un bloque de memoria y se devuelve una referencia (o puntero) al comienzo del mismo. 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.

El hecho de que tengamos un GC no quiere decir que los destructores dejen de existir. 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. Lo que ocurre realmente cuando escribimos un destructor es que el compilador sobre-escribe 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. 

Los destructores de C# no pueden ser invocados explícitamente como si fueran un método más. Tampoco sirve invocar el método Finalize, ya que su modificador de acceso es protected. 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, tampoco pueden ser sobrecargados. Se han de nombrar igual que la clase, precedidos por el signo ~. 

class Objeto
{
    ~Objeto()
    {
        Console.WriteLine("Objeto liberado");
    }
}

El destructor se llama igual que la clase precedido con el signo ~ (ALT 126). Ejemplo completo:

    class Objeto
    {
        ~Objeto()
        {
            Console.WriteLine("Referencia liberada");
        }
    }

    class DestructoresApp
    {
        static void Main()
        {
            Objeto o=new Objeto();
        }
    }

El programa no hace casi nada: 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

Vamos a volver a poner el método Main con la línea Console.ReadLine:

static void Main()
{
    Objeto o=new Objeto();
    string a=Console.ReadLine();
}

Hay dos motivos por los que no es necesario poner Console.ReadLine: 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. Se produce el siguiente error: "No se puede tener acceso a una secuencia cerrada". La primera vez que se utiliza la clase Console se inicializan las secuencias de lectura y escritura en la consola, 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. 

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.

Resurrección


Un fenómeno curioso pero peligroso, que sucede con la recolección de basura es la resurrección de objetos. No tiene mucha utilidad pero puede generar problemas. Sucede cuando un objeto que va a ser eliminado vuelve a crear una referencia a sí mismo durante la ejecución de su destructor. 


     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();
        }
    }


Vemos la salida en la consola:

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()

Al instanciar el objeto se ejecuta el constructor del mismo pero 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, además cuando destruimos la referencia y forzamos la recolección por segunda vez el destructor no se ha ejecutado. 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 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 el 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. La siguiente vez que anula la referencia y el forzado de la recolección del 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 en la lista de finalizadores ni en la segunda lista. 

Generaciones 


El GC, para mejorar su rendimiento, agrupa los objetos en diferentes generaciones. Los últimos objetos que se construyen suelen ser los primeros en dejar de ser utilizados. Cuando se abre un cuadro de diálogo con alguna opción de menú, se crea otro objeto, pero el cuadro de diálogo se cerrará primero.  No será siempre así pero es muy frecuente. Cuando el GC necesita memoria, no revisa toda la memoria 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. 

Si el GC consigue liberar lo que necesita, no mirará en las generaciones anteriores, aunque haya objetos que se pudieran liberar, en caso de que liberando toda la memoria posible no consiguiera el espacio que necesita, trataría de liberar también espacio de la siguiente generación y, así sucesivamente. 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 también tiene dos sobrecargas: una de ellas es la que se ha  usado hasta ahora, es decir, sin argumentos, que hace una recolección total del montón (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. 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. 

No hay comentarios:

Publicar un comentario