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.