Tema 13: Estructuras
Concepto de
estructura
Una estructura es un tipo especial de clase pensada para
representar objetos ligeros. Es decir, que ocupen poca memoria y
deban ser manipulados con velocidad, como objetos que representen
puntos, fechas, etc. Ejemplos de estructuras incluidas en la BCL
son la mayoría de los tipos básicos (excepto
string y object), y de hecho las estructuras junto
con la redefinición de operadores son la forma ideal de
definir nuevos tipos básicos a los que se apliquen las misma
optimizaciones que a los predefinidos.
Diferencias entre clases
y estructuras
A diferencia de una clase y fielmente a su espíritu de
"ligereza", una estructura no puede derivar de
ningún tipo y ningún tipo puede derivar de ella Por
estas razones sus miembros no pueden incluir modificadores
relativos a herencia, aunque con una excepción: pueden
incluir override para redefinir los miembros de
System.Object.
Otra diferencia entre las estructuras y las clases es que sus
variables no almacenan referencias a zonas de memoria
dinámica donde se encuentran almacenados objetos sino
directamente referencian a objetos. Por ello se dice que las clases
son tipos referencia y las estructuras son tipos
valor, siendo posible tanto encontrar objetos de estructuras en
pila (no son campos de clases) como en memoria dinámica (son
campos de clases)
Una primera consecuencia de esto es que los accesos a miembros de
objetos de tipos valor son mucho más rápidos que los
accesos a miembros de pilas, ya que es necesario pasar por una
referencia menos a la hora de acceder a ellos. Además, el
tiempo de creación y destrucción de estructuras
también es inferior. De hecho, la destrucción de los
objetos almacenados en pila es prácticamente inapreciable ya
que se realiza con un simple decremento del puntero de pila y no
interviene en ella el recolector de basura.
Otra consecuencia de lo anterior es que cuando se realicen
asignaciones entre variables de tipos valor, lo que se va a copiar
en la variable destino es el objeto almacenado por la variable
fuente y no la dirección de memoria dinámica a la que
apuntaba ésta. Por ejemplo, dado el siguiente tipo
(nótese que las estructuras se definen igual que las clases
pero usando la palabra reservada struct en vez de
class):
struct Point
{
public int x, y;
public Point(int x, int y)
{
this.x = x;
this.y = y;
}
}
Si usamos este tipo en un código como el siguiente:
Punto p = new Punto(10,10);
Punto p2 = p;
p2.x = 100;
Console.WriteLine(p.x);
Lo que se mostrará por pantalla será 10. Esto se debe
a que el valor de x modificado es el de p2, que es como es una
copia de p los cambios que se le hagan no afectarán a p. Sin
embargo, si Punto hubiese sido definido como una clase entonces
sí que se hubiese mostrado por pantalla 100, ya que en ese
caso lo que se habría copiado en p2 habría sido una
referencia a la misma dirección de memoria dinámica
referenciada por p, por lo que cualquier cambio que se haga en esa
zona a través de p2 también afectará a p.
De lo anterior se deduce que la asignación entre objetos de
tipos estructuras es mucho más lenta que la
asignación entre objetos de clases, ya que se ha de copiar
un objeto completo y no solo una referencia. Para aliviar esto al
pasar objetos de tipos estructura como parámetros, se da la
posibilidad de pasarlos como parámetros por referencia
(modificador ref) o parámetros de salida (out)
en vez de como parámetros de entrada.
Todas las estructuras derivan implícitamente del tipo
System.ValueType, que a su vez deriva de la clase primigenia
System.Object. ValueType tiene los mismos miembros
que su padre, y la única diferencia señalable entre
ambos es que en ValueType se ha redefinido Equals()
de modo que devuelva true si los objetos comparados tienen
el mismo valor en todos sus campos y false si no. Es decir,
la comparación entre estructuras con Equals() se
realiza por valor.
Respecto a la implementación de la igualdad en los tipos
definidos como estructuras, también es importante tener muy
en cuenta que el operador == no es en principio aplicable a
las estructuras que defina el programador. Si se desea que lo tenga
ha de dársele explícitamente una redefinición
al definir dichas estructuras.
Boxing y unboxing
Dado que toda estructura deriva de System.Object, ha de ser
posible a través del
polimorfismo almacenar objetos de estos tipos en objetos
object. Sin embargo, esto no puede hacerse directamente
debido a las diferencias semánticas y de almacenamiento que
existen entre clases y estructuras: un object siempre ha de
almacenar una referencia a un objeto en memoria dinámica y
una estructura no tiene porqué estarlo. Por ello ha de
realizársele antes al objeto de tipo valor una
conversión conocida como boxing.
Recíprocamente, al proceso de conversión de un
object que contenga un objeto de un tipo valor al tipo valor
original se le denomina unboxing.
El proceso de boxing es muy sencillo. Consiste en envolver el
objeto de tipo valor en un objeto de un tipo referencia creado
específicamente para ello. Por ejemplo, para un objeto de un
tipo valor T, el tipo referencia creado sería de la forma:
class T_Box
{
T value;
T_Box(T t)
{
value = t;
}
}
En realidad todo esto ocurre de forma transparente al programador,
el cual simplemente asigna el objeto de tipo valor a un objeto de
tipo referencia como si de cualquier asignación
polimórfica se tratase. Por ejemplo:
int p = new Punto(10,10);
object o = p; // boxing. Es equivalente a object o = new Punto_Box(p);
En realidad la clase envoltorio arriba escrita no se crea nunca,
pero conceptualmente es como si se crease. Esto se puede comprobar
viendo a través del siguiente código que el verdadero
tipo del objeto o del ejemplo anterior sigue siendo Punto (y
no Punto_Box):
Console.WriteLine((p is Punto));
La salida por pantalla de este código es True, lo que
confirma que se sigue considerando que en realidad o almacena un
Punto (recuérdese que el operador is sólo
devuelve true si el objeto que se le pasa como operando
izquierdo es del tipo que se le indica como operando derecho)
El proceso de unboxing es también transparente al
programador. Por ejemplo, para recuperar como Punto el valor de
tipo Punto almacenado en el objeto o anterior se haría:
p = (Punto) o; // Es equivalente a ((Punto_Box) o).value
Obviamente durante el unboxing se hará una
comprobación de tipo para asegurar que el objeto almacenado
en o es realmente de tipo Punto. Esta comprobación es tan
estricta que se ha de cumplir que el tipo especificado sea
exactamente el mismo que el tipo original del objeto, no vale que
sea un compatible. Por tanto, este código es
inválido:
int i = 123;
object o = i;
long l = (long) o // Error: o contiene un int, no un long
Sin embargo, lo que si sería válido es hacer:
long l = (long) (int) o;
Como se puede apreciar en el constructor del tipo envoltorio
creado, durante el boxing el envoltorio que se crea recibe una
copia del valor del objeto a convertir, por lo que los cambios que
se le hagan no afectarán al objeto original. Por ello, la
salida del siguiente código será 10:
Punto p = new Punto(10,10);
object o = p; // boxing
p.X = 100;
Console.WriteLine( ((Punto) o).X); // unboxing
Sin embargo, si Punto se hubiese definido como una clase entonces
sí que se mostraría por pantalla un 100 ya que
entonces no se haría boxing en la asignación de p a o
sino que se aplicaría el mecanismo de polimorfismo normal,
que consiste en tratar p a través de o como si fuese de tipo
object pero sin realizarse ninguna conversión.
El problema del boxing y el unboxing es que son procesos
lentos, ya que implican la creación y destrucción de
objetos envoltorio. Por ello puede interesar evitarlos en
aquellas situaciones donde la velocidad de ejecución de la
aplicación sea crítica, y para ello se proponen
varias técnicas:
-
Si el problema se debe al paso de estructuras como
parámetros de métodos genéricos que tomen
parámetros de tipo object, puede convenir definir
sobrecargas de esos métodos que en lugar de tomar
objects tomen objetos de los tipos estructura que en
concreto la aplicación utiliza
-
Siguiendo en la línea de lo anterior, puede que interese
usar plantillas en lugar de tipos genéricos.
Éstas no son más que definiciones de tipos de
datos en las que no se indica cuál es el tipo exacto de
ciertas variables sino que se deja en función de
parámetros a los que puede dárseles distintos
valores al crear cada objeto de ese tipo. Así, en
vez de crearse siempre objetos con métodos que tomen
parámetros object, se podrían ir creando
diferentes versiones del tipo según el tipo de
estructura con la que se vaya a trabajar.
Actuálmente el CLR puede trabajar con plantillas, pero se
espera que en versiones futuras del .NET Framework lo haga y el
lenguaje C# las incluya.
-
Muchas veces conviene hacer unboxing para poder acceder a
miembros específicos de ciertas estructuras almacenadas
en objects, aunque a continuación vuelva a
necesitarse realmacenar la estructura en un object. Para
evitar esto una posibilidad sería almacenar en el objecto
no directamente la estructura sino un objeto de una clase
envolvente creada a medida por el programador y que incluya los
miembros necesarios para hacer las operaciones anteriores.
Así se evitaría tener que hacer unboxing, pues se
convertiría de object a esa clase, que no es un
tipo valor y por tanto no implica unboxing.
-
Con la misma idea, otra posibilidad sería que el tipo
estructura implementase ciertas interfaces mediante las que se
pudiese hacer las operaciones antes comentadas. Aunque las
interfaces no se tratarán hasta el Tema 15:
Interfaces, por ahora basta saber que las interfaces son
también tipos referencia y por tanto convertir de
object a un tipo interfaz no implica unboxing.
Constructores
Los constructores de las estructuras se comportan de una forma
distinta a los de las clases. Por un lado, no pueden incluir
ningún inicializador base debido a que como no puede haber
herencia el compilador siempre sabe que ha de llamar al constructor
sin parámetros de System.ValueType. Por otro, dentro
de su cuerpo no se puede acceder a sus miembros hasta
inicializarlos, pues para ahorrar tiempo no se les da ningún
valor inicial antes de llamar al constructor.
Sin embargo, la diferencia más importante entre los
constructores de ambos tipos se encuentra en la
implementación del constructor sin parámetros: como
los objetos estructura no puede almacenar el valor por defecto
null cuando se declaran sin usar constructor ya que ese
valor indica referencia a posición de memoria
dinámica indeterminada y los objetos estructura no almacenan
referencias, toda estructura siempre tiene definido un constructor
sin parámetros que lo que hace es darle en esos casos un
valor por defecto a los objetos declarados. Ese valor consiste en
poner a cero toda la memoria ocupada por el objeto, lo que tiene el
efecto de dar como valor a cada campo el cero de su tipo. Por
ejemplo, el siguiente código imprime un 0 en pantalla:
Punto p = new Punto();
Console.WriteLine(p.X);
Y el siguiente también:
using System;
struct Punto
{
public int X,Y;
}
class EjemploConstructorDefecto
{
Punto p;
public static void Main()
{
Console.WriteLine(p.X);
}
}
Sin embargo, el hecho de que este constructor por defecto se
aplique no implica que se pueda acceder a las variables locales sin
antes inicializarlas con otro valor. Por ejemplo, el siguiente
fragmente de código de un método sería
incorrecto:
Punto p;
Console.WriteLine(p.X); // X no inicializada
Sin embrago, como a las estructuras declaradas sin constructor no
se les da el valor por defecto null, sí que
sería válido:
Punto p;
p.X = 2;
Console.WriteLine(p.X);
Para asegurar un valor por defecto común a todos los objetos
estructura, se prohíbe al programador dar una
definición propia de su constructor sin parámetros.
Mientras que en las clases es opcional implementarlo y si no se
hace el compilador introduce uno por defecto, en las estructuras no
es válido hacerlo. Además, aún en el caso de
que se definan otros constructores, el constructor sin
parámetros seguirá siendo introducido
automáticamente por el compilador a diferencia de
cómo ocurría con las clases donde en ese caso el
compilador no lo introducía.
Por otro lado, para conseguir que el valor por defecto de todos los
objetos estructuras sea el mismo, se prohíbe darles una
valor inicial a sus campos en el momento de declararlos, pues si no
el constructor por defecto habría de tenerlos en cuenta y su
ejecución sería más ineficiente. Por esta
razón, los constructores definidos por el programador para
una estructura han de inicializar todos sus miembros no
estáticos en tanto que antes de llamarlos no se les da
ningún valor inicial.
Nótese que debido a la existencia de un constructor por
defecto cuya implementación escapa de manos del programador,
el código de los métodos de una estructura puede
tener que considerar la posibilidad de que se acceda a ellos con
los valores resultantes de una inicialización con ese
constructor. Por ejemplo, dado:
struct A
{
public readonly string S;
public A(string s)
{
if (s==null)
throw (new ArgumentNullException());
this.s = S;
}
}
Nada asegura que en este código los objetos de clase A
siempre se inicialicen con un valor distinto de null en su
campo S, pues aunque el constructor definido para A comprueba que
eso no ocurra lanzando una excepción en caso de que se le
pase una cadena que valga null, si el programador usa el
constructor por defecto creará un objeto en el que S valga
null. Además, ni siquiera es válido
especificar un valor inicial a S en su definición, ya que
para inicializar rápidamente las estructuras sus campos no
estáticos no pueden tener valores iniciales.