Zona HTML Zona Java Zona PHP Zona ASP Zona Bases de datos
Inicio > Tutoriales > Lenguajes orientados a objeto > C# > El lenguaje de programación C#
-Tutoriales

El lenguaje de programación C#


Tema 8: Métodos

. Concepto de método

Un método es un conjunto de instrucciones a las que se les da un determinado nombre de tal manera que sea posible ejecutarlas en cualquier momento sin tenerlas que reescribir sino usando sólo su nombre. A estas instrucciones se les denomina cuerpo del método, y a su ejecución a través de su nombre se le denomina llamada al método.

La ejecución de las instrucciones de un método puede producir como resultado un objeto de cualquier tipo. A este objeto se le llama valor de retorno del método y es completamente opcional, pudiéndose escribir métodos que no devuelvan ninguno.

La ejecución de las instrucciones de un método puede depender del valor de unas variables especiales denominadas parámetros del método, de manera que en función del valor que se dé a estas variables en cada llamada la ejecución del método se pueda realizar de una u otra forma y podrá producir uno u otro valor de retorno.

Al conjunto formado por el nombre de un método y el número y tipo de sus parámetros se le conoce como signatura del método. La signatura de un método es lo que verdaderamente lo identifica, de modo que es posible definir en un mismo tipo varios métodos con idéntico nombre siempre y cuando tengan distintos parámetros. Cuando esto ocurre se dice que el método que tiene ese nombre está sobrecargado.

. Definición de métodos

Para definir un método hay que indicar tanto cuáles son las instrucciones que forman su cuerpo como cuál es el nombre que se le dará, cuál es el tipo de objeto que puede devolver y cuáles son los parámetros que puede tomar. Esto se indican definiéndolo así:

<tipoRetorno> <nombreMétodo>(<parámetros>)
{
  <cuerpo>
}

En <tipoRetorno> se indica cuál es el tipo de dato del objeto que el método devuelve, y si no devuelve ninguno se ha de escribir void en su lugar.

Como nombre del método se puede poner en <nombreMétodo> cualquier identificador válido. Como se verá más adelante en el Tema 15: Interfaces, también es posible incluir en <nombreMétodo> información de explicitación de implementación de interfaz, pero por ahora podemos considerar que siempre será un identificador.

Aunque es posible escribir métodos que no tomen parámetros, si un método los toma se ha de indicar en <parámetros> cuál es el nombre y tipo de cada uno de ellos, separándolos con comas si son más de uno y siguiendo la sintaxis que más adelante en este mismo tema es explica.

El <cuerpo> del método también es opcional, pero si el método retorna algún tipo de objeto entonces ha de incluir al menos una instrucción return que indique cuál es el objeto a devolver.

La sintaxis anteriormente vista no es la que se usa para definir métodos abstractos. Como ya se vio en el Tema 5: Clases, en esos casos lo que se hace es sustituir el cuerpo del método y las llaves que lo encierran por un simple punto y coma (;) Más adelante en este tema veremos que eso es también lo que se hace para definir métodos externos.

A continuación se muestra un ejemplo de cómo definir un método de nombre Saluda cuyo cuerpo consista en escribir en la consola el mensaje "Hola Mundo" y que devuelva un objeto int de valor 1:

int Saluda()
{
  Console.WriteLine("Hola Mundo");
  return 1;
}

. Llamada a métodos

La forma en que se puede llamar a un método depende del tipo de método del que se trate. Si es un método de objeto (método no estático) se ha de usar la notación:

<objeto>.<nombreMétodo>(<valoresParámetros>)

El <objeto> indicado puede ser directamente una variable del tipo de datos al que pertenezca el método o puede ser una expresión que produzca como resultado una variable de ese tipo (recordemos que, debido a la herencia, el tipo del <objeto> puede ser un subtipo del tipo donde realmente se haya definido el método); pero si desde código de algún método de un objeto se desea llamar a otro método de ese mismo objeto, entonces se ha de dar el valor this a <objeto>.

En caso de que sea un método de tipo (método estático), entones se ha de usar:

<tipo>.<nombreMétodo>(<valoresParámetros>)

Ahora en <tipo> ha de indicarse el tipo donde se haya definido el método o algún subtipo suyo. Sin embargo, si el método pertenece al mismo tipo que el código que lo llama entonces se puede usar la notación abreviada:

<nombreMétodo>(<valoresParámetros>)

El formato en que se pasen los valores a cada parámetro en <valoresParámetros> a aquellos métodos que tomen parámetros depende del tipo de parámetro que sea. Esto se explica en el siguiente apartado.

. Tipos de parámetros. Sintaxis de definición

La forma en que se define cada parámetro de un método depende del tipo de parámetro del que se trate. En C# se admiten cuatro tipos de parámetros: parámetros de entrada, parámetros de salida, parámetros por referencia y parámetros de número indefinido.

. Parámetros de

Un parámetro de entrada recibe una copia del valor que almacenaría una variable del tipo del objeto que se le pase. Por tanto, si el objeto es de un tipo valor se le pasará una copia del objeto y cualquier modificación que se haga al parámetro dentro del cuerpo del método no afectará al objeto original sino a su copia; mientras que si el objeto es de un tipo referencia entonces se le pasará una copia de la referencia al mismo y cualquier modificación que se haga al parámetro dentro del método también afectará al objeto original ya que en realidad el parámetro referencia a ese mismo objeto original.

Para definir un parámetro de entrada basta indicar cuál el nombre que se le desea dar y el cuál es tipo de dato que podrá almacenar. Para ello se sigue la siguiente sintaxis:

<tipoParámetro> <nombreParámetro>

Por ejemplo, el siguiente código define un método llamado Suma que toma dos parámetros de entrada de tipo int llamados par1 y par2 y devuelve un int con su suma:

int Suma(int par1, int par2)
{
  return par1+par2;
}

Como se ve, se usa la instrucción return para indicar cuál es el valor que ha de devolver el método. Este valor es el resultado de ejecutar la expresión par1+par2; es decir, es la suma de los valores pasados a sus parámetros par1 y par2 al llamarlo.

En las llamadas a métodos se expresan los valores que se deseen dar a este tipo de parámetros indicando simplemente el valor deseado. Por ejemplo, para llamar al método anterior con los valores 2 y 5 se haría <objeto>.Suma(2,5), lo que devolvería el valor 7.

Todo esto se resume con el siguiente ejemplo:

using System;

class ParámetrosEntrada
{
  public int a = 1;
  
  public static void F(ParémetrosEntrada p)
  {
    p.a++;
  }

  public static void G(int p)
  {
    p++;
  }
  
  public static void Main()
  {
    int obj1 = 0;
    ParámetrosEntrada obj2 = new ParámetrosEntrada();
    
    G(obj1);
    F(obj2);
    
    Console.WriteLine("{0}, {1}", obj1, obj2.a);
  }
}

Este programa muestra la siguiente salida por pantalla:

0, 2

Como se ve, la llamada al método G() no modifica el valor que tenía obj1 antes de llamarlo ya que obj1 es de un tipo valor (int) Sin embargo, como obj2 es de un tipo referencia (ParámetrosLlamadas) los cambios que se le hacen dentro de F() al pasárselo como parámetro sí que le afectan.

. Parámetros de salida

Un parámetro de salida se diferencia de uno de entrada en que todo cambio que se le realice en el código del método al que pertenece afectará al objeto que se le pase al llamar dicho método tanto si éste es de un tipo por como si es de un tipo referencia. Esto se debe a que lo que a estos parámetros se les pasa es siempre una referencia al valor que almacenaría una variable del tipo del objeto que se les pase.

Cualquier parámetro de salida de un método siempre ha de modificarse dentro del cuerpo del método y además dicha modificación ha de hacerse antes que cualquier lectura de su valor. Si esto no se hiciese así el compilador lo detectaría e informaría de ello con un error. Por esta razón es posible pasar parámetros de salida que sean variables no inicializadas, pues se garantiza que en el método se inicializarán antes de leerlas. Además, tras la llamada a un método se considera que las variables que se le pasaron como parámetros de salida ya estarán inicializadas, pues dentro del método seguro que se las inicializació.

Nótese que este tipo de parámetros permiten diseñar métodos que devuelvan múltiples objetos: un objeto se devolvería como valor de retorno y los demás se devolverían escribiendos en los parámetros de salida.

Los parámetros de salida se definen de forma parecida a los parámetros de entrada pero se les ha de añadir la palabra reservada out. O sea, se definen así:

out <tipoParámetro> <nombreParámetro>

Al llamar a un método que tome parámetros de este tipo también se ha preceder el valor especificado para estos parámetros del modificador out. Una utilidad de esto es facilitar la legibilidad de las llamadas a métodos. Por ejemplo, dada una llamada de la forma:

a.f(x, out z)

Es fácil determinar que lo que se hace es llamar al método f() del objeto a pasándole x como parámetro de entrada y z como parámetro de salida. Además, también se puede deducir que el valor de z cambiará tras la llamada.

Sin embargo, la verdadera utilidad de forzar a explicitar en las llamadas el tipo de paso de cada parámetro es que permite evitar errores derivados de que un programador pase una variable a un método y no sepa que el método la puede modificar. Teniéndola que explicitar se asegura que el programador sea consciente de lo que hace.

. Parámetros por referencia

Un parámetro por referencia es similar a un parámetro de salida sólo que no es obligatorio modificarlo dentro del método al que pertenece, por lo que será obligatorio pasarle una variable inicializada ya que no se garantiza su inicialización en el método.

Los parámetros por referencia se definen igual que los parámetros de salida pero sustituyendo el modificador out por el modificador ref. Del mismo modo, al pasar valores a parámetros por referencia también hay que precederlos del ref.

. Parámetros de número indefinido

C# permite diseñar métodos que puedan tomar cualquier número de parámetros. Para ello hay que indicar como último parámetro del método un parámetro de algún tipo de tabla unidimensional o dentada precedido de la palabra reservada params. Por ejemplo:

static void F(int x, params object[] extras)
{}

Todos los parámetros de número indefinido que se pasan al método al llamarlo han de ser del mismo tipo que la tabla. Nótese que en el ejemplo ese tipo es la clase primigenia object, con lo que se consigue que gracias al polimorfismo el método pueda tomar cualquier número de parámetros de cualquier tipo. Ejemplos de llamadas válidas serían:

F(4);    // Pueden pasarse 0 parámetros indefinidos
F(3,2);
F(1, 2, "Hola", 3.0, new Persona());
F(1, new object[] {2,"Hola", 3.0, new Persona});

El primer ejemplo demuestra que el número de parámetros indefinidos que se pasen también puede ser 0. Por su parte, los dos últimos ejemplos son totalmente equivalentes, pues precisamente la utilidad de palabra reservada params es indicar que se desea que la creación de la tabla object[] se haga implícitamente.

Es importante señalar que la prioridad de un método que incluya el params es inferior a la de cualquier otra sobrecarga del mismo. Es decir, si se hubiese definido una sobrecarga del método anterior como la siguiente:

static void F(int x, int y)
{}

Cuando se hiciese una llamada como F(3,2) se llamaría a esta última versión del método, ya que aunque la del params es también aplicable, se considera que es menos prioritaria.

. Sobrecarga de tipos de parámetros

En realidad los modificadores ref y out de los parámetros de un método también forman parte de lo que se conoce como signatura del método, por lo que esta clase es válida:

class Sobrecarga
{
  public void f(int x)
  {}

  public void f(out int x)
  {}
}

Nótese que esta clase es correcta porque cada uno de sus métodos tiene una signatura distinta: el parámetro es de entrada en el primero y de salida en el segundo.

Sin embargo, hay una restricción: no puede ocurrir que la única diferencia entre la signatura de dos métodos sea que en uno un determinado parámetro lleve el modificador ref y en el otro lleve el modificador out. Por ejemplo, no es válido:

class SobrecargaInválida
{
  public void f(ref int x)
  {}

  public void f(out int x)
  {}
}

. Métodos externos

Un método externo es aquél cuya implementación no se da en el fichero fuente en que es declarado. Estos métodos se declaran precediendo su declaración del modificador extern. Como su código se da externamente, en el fuente se sustituyen las llaves donde debería escribirse su cuerpo por un punto y coma (;), quedando una sintaxis de la forma:

extern <nombreMétodo>(<parámetros>);

La forma en que se asocie el código externo al método no está definida en la especificación de C# sino que depende de la implementación que se haga del lenguaje. El único requisito es que no pueda definirse un método como abstracto y externo a la vez, pero por todo lo demás puede combinarse con los demás modificadores, incluso pudiéndose definir métodos virtuales externos.

La forma más habitual de asociar código externo consiste en preceder la declaración del método de un atributo de tipo System.Runtime.InteropServices.DllImport que indique en cuál librería de enlace dinámico (DLL) se ha implementado. Este atributo requiere que el método externo que le siga sea estático, y un ejemplo de su uso es:

using System.Runtime.InteropServices;  // Aquí está definido DllImport

public class Externo
{
  [DllImport("kernel32")]
  public static extern void CopyFile(string fuente, string destino);

  public static void Main()
  {
    CopyFile("fuente.dat", "destino.dat");
  }
}

El concepto de atributo se explica detalladamente en el Tema 14:Atributos. Por ahora basta saber que los atributos se usan de forman similar a los métodos sólo que no están asociados a ningún objeto ni tipo y se indican entre corchetes ([]) antes de declaraciones de elementos del lenguaje. En el caso concreto de DllImport lo que indica el parámetro que se le pasa es cuál es el fichero (por defecto se considera que su extensión es .dll) donde se encuentra la implementación del método externo a continuación definido.

Lo que el código del ejemplo anterior hace es simplemente definir un método de nombre CopyFile() cuyo código se corresponda con el de la función CopyFile() del fichero kernel32.dll del API Win32. Este método es llamado en Main() para copiar el fichero de nombre fuente.dat en otro de nombre destino.dat. Nótese que dado que CopyFile() se ha declarado como static y se le llama desde la misma clase donde se ha declarado, no es necesario precederlo de la notación <nombreClase>. para llamarlo.

Como se ve, la utilidad principal de los métodos externos es permitir hacer llamadas a código nativo desde código gestionado, lo que puede ser útil por razones de eficiencia o para reutilizar código antiguamente escrito pero reduce la portabilidad de la aplicación.

. Constructores

. Concepto de constructores

Los constructores de un tipo de datos son métodos especiales que se definen como miembros de éste y que contienen código a ejecutar cada vez que se cree un objeto de ese tipo. Éste código suele usarse para labores de inicialización de los campos del objeto a crear, sobre todo cuando el valor de éstos no es constante o incluye acciones más allá de una asignación de valor (aperturas de ficheros, accesos a redes, etc.)

Hay que tener en cuenta que la ejecución del constructor siempre se realiza después de haberse inicializado todos los campos del objeto, ya sea con los valores iniciales que se hubiesen especificado en su definición o dejándolos con el valor por defecto de su tipo.

Aparte de su especial sintaxis de definición, los constructores y los métodos normales tienen una diferencia muy importante: los constructores no se heredan.

. Definición de constructores

La sintaxis básica de definición de constructores consiste en definirlos como cualquier otro método pero dándoles el mismo nombre que el tipo de dato al que pertenecen y no indicando el tipo de valor de retorno debido a que nunca pueden devolver nada. Es decir, se usa la sintaxis:

<modificadores> <nombreTipo>(<parámetros>)
{
  <código>
}

Un constructor nunca puede devolver ningún tipo de objeto porque, como ya se ha visto, sólo se usa junto al operador new, que devuelve una referencia al objeto recién creado. Por ello, es absurdo que devuelva algún valor ya que nunca podría ser capturado en tanto que new nunca lo devolvería. Por esta razón el compilador considera erróneo indicar algún tipo de retorno en su definición, incluso aunque se indique void.

. Llamada al constructor

Al constructor de una clase se le llama en el momento en que se crea algún objeto de la misma usando el operador new. De hecho, la forma de uso de este operador es:

new <llamadaConstructor>

Por ejemplo, el siguiente programa demuestra cómo al crearse un objeto se ejecutan las instrucciones de su constructor:

class Prueba
{
  Prueba(int x)
  {
    System.Console.Write("Creado objeto Prueba con x={0}",x);
  }
  
  public static void Main()
  {
    Prueba p = new Prueba(5);
  }
}

La salida por pantalla de este programa demuestra que se ha llamado al constructor del objeto de clase Prueba creado en Main(), pues es:

Creado objeto Prueba con x=5;

. Llamadas entre constructores

Al igual que ocurre con cualquier otro método, también es posible sobrecargar los constructores. Es decir, se pueden definir varios constructores siempre y cuando estos tomen diferentes números o tipos de parámetros. Además, desde el código de un constructor puede llamarse a otros constructores del mismo tipo de dato antes de ejecutar las instrucciones del cuerpo del primero. Para ello se añade un inicializador this al constructor, que es estructura que precede a la llave de apertura de su cuerpo tal y como se muestra en el siguiente ejemplo:

class A
{
  int total;
  
  A(int valor): this(valor, 2);  // (1)
  {
  }
  
  A(int valor, int peso) // (2)
  {
    total = valor*peso;
  }
}

El this incluido hace que la llamada al constructor (1) de la clase A provoque una llamada al constructor (2) de esa misma clase en la que se le pase como primer parámetro el valor originalmente pasado al constructor (1) y como segundo parámetro el valor 2. Es importante señalar que la llamada al constructor (2) en (1) se hace antes de ejecutar cualquier instrucción de (1)

Nótese que la sobrecarga de constructores -y de cualquier método en general- es un buen modo de definir versiones más compactas de métodos de uso frecuente en las que se tomen valores por defecto para parámetros de otras versiones menos compactas del mismo método. La implementación de estas versiones compactas consistiría en hacer una llamada a la versión menos compacta del método en la que se le pasen esos valores por defecto (a través del this en el caso de los constructores) y si acaso luego (y/o antes, si no es un constructor) se hagan labores específicas en el cuerpo del método compacto.

Del mismo modo que en la definición de un constructor de un tipo de datos es posible llamar a otros constructores del mismo tipo de datos, también es posible hacer llamadas a constructores de su tipo padre sustituyendo en su inicializador la palabra reservada this por base. Por ejemplo:

class A
{
  int total;
  
  A(int valor, int peso)
  {
    total = valor*peso;
  }
}

class B:A
{
  B(int valor):base(valor,2)
  {}
}

En ambos casos, los valores pasados como parámetros en el inicializador no pueden contener referencias a campos del objeto que se esté creando, ya que se considera que un objeto no está creado hasta que no se ejecute su constructor y, por tanto, al llamar al inicializador aún no está creado. Sin embargo, lo que sí pueden incluir son referencias a los parámetros con los que se llamó al constructor. Por ejemplo, sería válido hacer:

A(int x, int y): this(x+y)
{}

. Constructor por defecto

Todo tipo de datos ha de disponer de al menos un constructor. Cuando se define un tipo sin especificar ninguno el compilador considera que implícitamente se ha definido uno sin cuerpo ni parámetros de la siguiente forma:

public <nombreClase>(): base()
{}

En el caso de que el tipo sea una clase abstracta, entonces el constructor por defecto introducido es el que se muestra a continuación, ya que el anterior no sería válido porque permitiría crear objetos de la clase a la que pertenece:

protected <nombreClase>(): base()
{}

En el momento en se defina explícitamente algún constructor el compilador dejará de introducir implícitamente el anterior. Hay que tener especial cuidado con la llamada que este constructor por defecto realiza en su inicializador, pues pueden producirse errores como el del siguiente ejemplo:

class A
{
  public A(int x)
  {}
}

class B:A
{
  public static void Main()
  {
    B b = new B();  // Error: No hay constructor base
  }
}

En este caso, la creación del objeto de clase B en Main() no es posible debido a que el constructor que por defecto el compilador crea para la clase B llama al constructor sin parámetros de su clase base A, pero A carece de dicho constructor porque no se le ha definido explícitamente ninguno con esas características pero se le ha definido otro que ha hecho que el compilador no le defina implícitamente el primero.

Otro error que podría darse consistiría en que aunque el tipo padre tuviese un constructor sin parámetros, éste fuese privado y por tanto inaccesible para el tipo hijo.

También es importante señalar que aún en el caso de que definamos nuestras propios constructores, si no especificamos un inicializador el compilador introducirá por nosotros uno de la forma :base() Por tanto, en estos casos también hay que asegurarse de que el tipo donde se haya definido el constructor herede de otro que tenga un constructor sin parámetros no privado.

. Llamadas polimórficas en constructores

Es conveniente evitar en la medida de lo posible la realización de llamadas a métodos virtuales dentro de los constructores, ya que ello puede provocar errores muy difíciles de detectar debido a que se ejecuten métodos cuando la parte del objeto que manipulan aún no se ha sido inicializado. Un ejemplo de esto es el siguiente:

using System;

public class Base
{
  public Base()
  {
    Console.WriteLine("Constructor de Base");
    this.F();
  }

  public virtual void F()
  {
    Console.WriteLine("Base.F");               
  }
}

public class Derivada:Base
{
  Derivada()
  {
    Console.WriteLine("Constructor de Derivada");
  }

  public override void F()
  {
    Console.WriteLine("Derivada.F()");
  }

  public static void Main() 
  {
    Base b = new
    Derivada();                      
  }
}

La salida por pantalla mostrada por este programa al ejecutarse es la siguiente:

Constructor de Base
Derivada.F()
Constructor de Derivada

Lo que ha ocurrido es lo siguiente: Al crearse el objeto Derivada se ha llamado a su constructor sin parámetros, que como no tiene inicializador implícitamente llama al constructor sin parámetros de su clase base. El constructor de Base realiza una llamada al método virtual F(), y como el verdadero tipo del objeto que se está contruyendo es Derivada, entonces versión del método virtual ejecutada es la redefinición del mismo incluida en dicha clase. Por último, se termina llamando al constructor de Derivada y finaliza la construcción del objeto.

Nótese que se ha ejecutado el método F() de Derivada antes que el código del constructor de dicha clase, por lo que si ese método manipulase campos definidos en Derivada que se inicializasen a través de constructor, se habría accedido a ellos antes de inicializarlos y ello seguramente provocaría errores de causas difíciles de averiguar.

. Constructor de tipo

Todo tipo puede tener opcionalmente un constructor de tipo, que es un método especial que funciona de forma similar a los constructores ordinarios sólo que para lo que se usa es para inicializar los campos static del tipo donde se ha definido.

Cada tipo de dato sólo puede tener un constructor de tipo. Éste constructor es llamado automáticamente por el compilador la primera vez que se accede al tipo, ya sea para crear objetos del mismo o para acceder a sus campos estáticos. Esta llamada se hace justo después de inicializar los campos estáticos del tipo con los valores iniciales especificados al definirlos (o, en su ausencia, con los valores por defecto de sus tipos de dato), por lo que el programador no tiene forma de controlar la forma en que se le llama y, por tanto, no puede pasarle parámetros que condicionen su ejecución.

Como cada tipo sólo puede tener un constructor de tipo no tiene sentido poderse usar this en su inicializador para llamar a otro. Y además, tampoco tiene sentido usar base debido a que éste siempre hará referencia al constructor de tipo sin parámetros de su clase base. O sea, un constructor de tipo no puede tener inicializador.

Además, no tiene sentido darle modificadores de acceso ya que el programador nunca lo podrá llamar sino que sólo será llamado automáticamente y sólo al accederse al tipo por primera vez. Como es absurdo, el compilador considera un error dárselos.

La forma en que se define el constructor de tipo es similar a la de los constructores normales, sólo que ahora la definición ha de ir prefijada del modificador static y no puede contar con parámetros ni inicializador. O sea, se define de la siguiente manera:

static <nombreTipo>()
{
  <código>
}

En la especificicación de C# no se ha recogido cuál ha de ser el orden exacto de las llamadas a los constructores de tipos cuando se combinan con herencia, aunque lo que sí se indica es que se ha de asegurar de que no se accede a un campo estático sin haberse ejecutado antes su constructor de tipo. Todo esto puede verse más claro con un ejemplo:

using System;

class A
{
  public static X;
  
  static A()
  {
    Console.WriteLine("Constructor de A");
    X=1;
  }
}

class B:A
{
  static B()
  {
    Console.WriteLine("Constructor de B");
    X=2;
  }
  
  public static void Main()
  {
    B b = new B();
    Console.WriteLine(B.X);
  }
}

La salida que muestra por pantalla la ejecución de este programa es la siguiente:

Inicializada clase B
Inicializada clase A
2

En principio la salida de este programa puede resultar confusa debido a que los primeros dos mensajes parecen dar la sensación de que la creación del objeto b provocó que se ejecutase el constructor de la clase hija antes que al de la clase padre, pero el último mensaje se corresponde con una ejecución en el orden opuesto. Pues bien, lo que ha ocurrido es lo siguiente: como el orden de llamada a constructores de tipo no está establecido, el compilador de Microsoft ha llamado antes al de la clase hija y por ello el primer mensaje mostrado es Inicializada clase B. Sin embargo, cuando en este constructor se va a acceder al campo X se detecta que la clase donde se definió aún no está inicializada y entonces se llama a su constructor de tipo, lo que hace que se muestre el mensaje Inicializada clase A. Tras esta llamada se machaca el valor que el constructor de A dio a X (valor 1) por el valor que el constructor de B le da (valor 2) Finalmente, el último WriteLine() muestra un 2, que es el último valor escrito en X.

. Destructores

Al igual que es posible definir métodos constructores que incluyan código que gestione la creación de objetos de un tipo de dato, también es posible definir un destructor que gestione cómo se destruyen los objetos de ese tipo de dato. Este método suele ser útil para liberar recursos tales como los ficheros o las conexiones de redes abiertas que el objeto a destruir estuviese acaparando en el momento en que se fuese a destruir.

La destrucción de un objeto es realizada por el recolector de basura cuando realiza una recolección de basura y detecta que no existen referencias a ese objeto ni en pila, ni en registros ni desde otros objetos sí referenciados. Las recolecciones se inician automáticamente cuando el recolector detecta que queda poca memoria libre o que se va a finalizar la ejecución de la aplicación, aunque también puede forzarse llamando al método Collect() de la clase System.GC

La sintaxis que se usa para definir un destructor es la siguiente:

~<nombreTipo>()
{
  <código>
}

Tras la ejecución del destructor de un objeto de un determinado tipo siempre se llama al destructor de su tipo padre, formándose así una cadena de llamadas a destructores que acaba al llegarse al destructor de object. Éste último destructor no contiene código alguno, y dado que object no tiene padre, tampoco llama a ningún otro destructor.

Los destructores no se heredan. Sin embargo, para asegurar que la cadena de llamadas a destructores funcione correctamente si no incluimos ninguna definición de destructor en un tipo, el compilador introducirá en esos casos una por nosotros de la siguiente forma:

~<nombreTipo>()
{}

El siguiente ejemplo muestra como se definen destructores y cómo funciona la cadena de llamada a destructores:

using System;

class A
{
  ~A()
  {
    Console.WriteLine("Destruido objeto de clase A");
  }
}

class B:A
{
  ~B()
  {          
    Console.WriteLine("Destruido objeto de clase B");
  }
  public static void Main()
  {
    new B();
  }
}

El código del método Main() de este programa crea un objeto de clase B pero no almacena ninguna referencia al mismo. Luego finaliza la ejecución del programa, lo que provoca la actuación del recolector de basura y la destrucción del objeto creado llamando antes a su destructor. La salida que ofrece por pantalla el programa demuestra que tras llamar al destructor de B se llama al de su clase padre, ya que es:

Destruido objeto de clase B
Destruido objeto de clase A

Nótese que aunque no se haya guardado ninguna referencia al objeto de tipo B creado y por tanto sea innacesible para el programador, al recolector de basura no le pasa lo mismo y siempre tiene acceso a los objetos, aunque sean inútiles para el programador.

Es importante recalcar que no es válido incluir ningún modificador en la definición de un destructor, ni siquiera modificadores de acceso, ya que como nunca se le puede llamar explícitamente no tiene ningún nivel de acceso para el programador. Sin embargo, ello no implica que cuando se les llame no se tenga en cuenta el verdadero tipo de los objetos a destruir, como demuestra el siguiente ejemplo:

using System;

public class Base
{
  public virtual void F()
  {
    Console.WriteLine("Base.F");               
  }

  ~Base()
  {
    Console.WriteLine("Destructor de Base");
    this.F();
  }
}

public class Derivada:Base
{
  ~Derivada()
  {
    Console.WriteLine("Destructor de Derivada");
  }
  
  public override void F()
  {
    Console.WriteLine("Derivada.F()");
  }
  
  public static void Main() 
  {
    Base b = new Derivada();                      
  }
}

La salida mostrada que muestra por pantalla este programa al ejecutarlo es:

Destructor de Derivada
Destructor de Base
Derivada.F()

Como se ve, aunque el objeto creado se almacene en una variable de tipo Base, su verdadero tipo es Derivada y por ello se llama al destructor de esta clase al destruirlo. Tras ejecutarse dicho contructor se llama al constructor de su clase padre siguiéndose la cadena de llamadas a destructores. En este constructor padre hay una llamada al método virtual F(), que como nuevamente el objeto que se está destruyendo es de tipo Derivada, la versión de F() a la que se llamará es a la de la dicha clase.

Nótese que una llamada a un método virtual dentro de un destructor como la que se hace en el ejemplo anterior puede dar lugar a errores difíciles de detectar, pues cuando se llama al método virtual ya se ha destruido la parte del objeto correspondiente al tipo donde se definió el método ejecutado. Así, en el ejemplo anterior se ha ejecutado Derivada.F() tras Derivada.~F(), por lo que si en Derivada.F() se usase algún campo destruido en Derivada.~F() podrían producirse errores difíciles de detectar.

 
Patrocinados
 

Copyright © 1999-2006 Programación en castellano. Todos los derechos reservados.
Formulario de Contacto - Datos legales - Publicidad

Hospedaje web y servidores dedicados linux por Ferca Network