Tema 18: Código inseguro
Concepto de
código inseguro
Código inseguro es todo aquél fragmento de
código en C# dentro del cual es posible hacer uso de
punteros.
Un puntero en C# es una variable que es capaz de almacenar
direcciones de memoria. Generalmente suele usarse para almacenar
direcciones que almacenen objetos, por lo en esos casos su
significado es similar al de variables normales de tipos
referencia. Sin embargo, los punteros no cuentan con muchas de las
restricciones de éstas a la hora de acceder al objeto. Por
ejemplo, al accederse a los elementos de una tabla mediante un
puntero no se pierde tiempo en comprobar que el índice
especificado se encuentre dentro de los límites de la tabla,
lo que permite que el acceso se haga más rápidamente.
Aparte de su mayor eficiencia, también hay ciertos casos en
que es necesario disponer del código inseguro, como cuando
se desea hacer llamadas a funciones escritas en lenguajes no
gestionados cuyos parámetros tengan que ser punteros.
Es importante señalar que los punteros son una
excepción en el sistema de tipos de .NET, ya que no derivan
de la clase primigenia System.Object, por lo que no
dispondrán de los métodos comunes a todos los objetos
y una variable object no podrá almacenarlos (tampoco
existen procesos similares al boxing y unboxing que permitan
simularlo)
Compilación de
códigos inseguros
El uso de punteros hace el código más proclive a
fallos en tanto que se salta muchas de las medidas incluidas en el
acceso normal a objetos, por lo que es necesario incluir ciertas
medidas de seguridad que eviente la instroducción accidental
de esta inseguridad
La primera medida tomada consiste en que explícitamente hay
que indicar al compilador que deseamos compilar código
inseguro. Para ello, al compilador de línea de comandos
hemos de pasarle la opción /unsafe, como se muestra
el ejemplo:
csc códigoInseguro.cs /unsafe
Si no se indica la opción unsafe, cuando el compilador
detecte algún fuente con código inseguro
producirá un mensaje de error como el siguiente:
códigoInseguro(5,23): error CS0277: unsafe code may only appear if compiling with /unsafe
En caso de que la compilación se vaya a realizar a
través de Visual Studio.NET, la forma de indicar que se
desea compilar código inseguro es activando la casilla
View -> Property Pages ->
Configuration Properties -> Build ->
Allow unsafe code blocks
Marcación de
códigos inseguros
Aparte de forzarse a indicar explícitamente que se desea
compilar código inseguro, C# también obliga a que
todo uso de código inseguro que se haga en un fichero fuente
tenga que ser explícitamente indicado como tal. A las zonas
de código donde se usa código inseguro se les
denomina contextos inseguros, y C# ofrece varios mecanismos
para marcar este tipo de contextos.
Una primera posibilidad consiste en preceder un bloque de
instrucciones de la palabra reservada unsafe siguiendo la
siguiente sintaxis:
unsafe <instrucciones>
En el código incluido en <instrucciones> podrá
definirse variables de tipos puntero y podrá hacerse uso de
las mismas. Por ejemplo:
public void f()
{
unsafe
{
int *x;
}
}
Otra forma de definir contextos inseguros consiste en añadir
el modificador unsafe a la definición de un miembro,
caso en que dentro de su definición se podrá hacer
uso de punteros. Así es posible definir campos de tipo
puntero, métodos con parámetros de tipos puntero,
etc. El siguiente ejemplo muestra cómo definir dos campos de
tipo puntero. Nótese sin embargo que no es posible definir
los dos en una misma línea:
struct PuntoInseguro
{
public unsafe int *X; // No es válido hacer public unsafe int *X, Y;
public unsafe int *Y; // Tampoco lo es hacer public unsafe int *X, *Y;
}
Obviamente, en un método que incluya el modificador
unsafe no es necesario preceder con dicha palabra sus
bloques de instrucciones inseguros.
Hay que tener en cuenta que el añadido de modificadores
unsafe es completamente inocuo. Es decir, no influye para nada en
cómo se haya de redefinir y si un método
Main() lo tiene sigue siendo un punto de entrada
válido.
Una tercera forma consiste en añadir el modificador
unsafe en el definición de un tipo, caso en que todas
las definiciones de miembros del mismo podrán incluir
código inseguro sin necesidad de añadir a cada una el
modificador unsafe o preceder sus bloques de instrucciones
inseguras de la palabra reservada unsafe. Por ejemplo:
unsafe struct PuntoInseguro
{
public int * X, *Y;
}
Definición de
punteros
Para definir una variable puntero de un determinado tipo se sigue
una sintaxis parecida a la usada para definir variables normales
sólo que al nombre del tipo se le postpone un símbolo
de asterisco (*) O sea, un puntero se define así:
<tipo> * <nombrePuntero>;
Por ejemplo, una variable puntero llamada a que pueda almacenar
referencias a posiciones de memoria donde se almacenen objetos de
tipo int se declara así:
int * a;
En caso de quererse declarar una tabla de punteros, entonces el
asterisco hay que incluirlo tras el nombre del tipo pero antes de
los corchetes. Por ejemplo, una tabla de nombre t que pueda
almacenar punteros a objetos de tipo int se declara
así:
int*[] t;
Hay un tipo especial de puntero que es capaz de almacenar
referencias a objetos de cualquier tipo. Éstos punteros de
declara indicando void como <tipo>. Por
ejemplo:
void * punteroACualquierCosa;
Hay que tener en cuenta que en realidad lo que indica el tipo que
se dé a un puntero es cuál es el tipo de objetos que
se ha de considerar que se almacenan en la dirección de
memoria almacenada por el puntero. Si se le da el valor void
lo que se está diciendo es que no se desea que se considere
que el puntero apunta a ningún tipo específico de
objeto. Es decir, no se está dando información sobre
el tipo apuntado.
Se pueden declarar múltiples variables locales de tipo
puntero en una misma línea. En ese caso el asterisco
sólo hay que incluirlo antes del nombre de la primera. Por
ejemplo:
int * a, b; // a y b son de tipo int *
// No sería válido haberlas definido como int *a, *b;
Hay que tener en cuenta que esta sintaxis especial para definir en
una misma definición varios punteros de un mismo tipo
sólo es válida en definiciones de variables locales.
Al definir campos no sirve y hay que dar para cada campo una
definición independiente.
El recolector de basura no tiene en cuenta los datos a los que se
referencie con punteros, pues ha de conocer cuál es el
objeto al referenciado por cada variable y un puntero en realidad
no tiene porqué almacenar referencias a objetos de
ningún tipo en concreto. Por ejemplo, pueden tenerse
punteros int * que en realidad apunten a objeto char,
o punteros void * que no almacenen información sobre
el tipo de objeto al que debería considerarse que apuntan, o
punteros que apunte a direcciones donde no hayan objetos, etc.
Como el recolector de basura no trabaja con punteros, no es posible
definir punteros de tipos que se almacenen en memoria
dinámica o contengan miembros que se almacenen en memoria
dinámica, ya que entonces podría ocurrir que un
objeto sólo referenciado a través de punteros sea
destruido por considear el recolector que nadie le referenciaba.
Por ello, sólo es válido definir punteros de tipos
cuyos objetos se puedan almacenar completamente en pila, pues la
vida de estos objetos no está controlada por el recolector
de basura sino que se destruyen cuando se abandona el ámbito
donde fueron definidos.
En concreto, los únicos punteros válidos son aquellos
cuyo tipos sean tipos valor básicos, enumeraciones o
estructuras que no contengan campos de tipos referencias.
También pueden definirse punteros de tipos puntero, como
muestra este ejemplo de declaración de un puntero a puntero
de tipo int llamando punteroApuntero:
int ** punteroApuntero;
Obviamente la anidación puede hacerse a cualquier nivel de
profundidad, pudiéndose definir punteros a punteros a
punteros, o punteros a punteros a punteros a punteros, etc.
Manipulación de
punteros
Obtención de
dirección de memoria. Operador &
Para almacenar una referencia a un objeto en un puntero se puede
aplicar al objeto el operador prefijo &, que lo que hace
es devuelver la dirección que en memoria ocupa el objeto
sobre el que se aplica. Un ejemplo de su uso para inicializar un
puntero es:
int x =10;
int * px = &x;
Este operador no es aplicable a expresiones constantes, pues
éstas no se almacenan en ninguna dirección de memoria
específica sino que se incrustan en las instrucciones. Por
ello, no es válido hacer directamente:
int px = &10; // Error 10 no es una variable con dirección propia
Tampoco es válido aplicar & a campos
readonly, pues si estos pudiesen ser apuntados por punteros
se correría el riesgo de poderlos modificar ya que a
través de un puntero se accede a memoria directamente, sin
tenerse en cuenta si en la posición accedida hay
algún objeto, por lo que mucho menos se considerará
si éste es de sólo lectura.
Lo que es sí válido almacenar en un puntero es la
dirección de memoria apuntada por otro puntero. En ese caso
ambos punteros apuntarían al mismo objeto y las
modificaciones a éste realizadas a través de un
puntero también afectarían al objeto visto por el
otro, de forma similar a como ocurre con las variables normales de
tipos referencia. Es más, los operadores relacionales
típicos
(==, !=, <, > ,<=
y >=) se han redefinido para que cuando se apliquen entre
dos punteros de cualesquiera dos tipos lo que se compare sean las
direcciones de memoria que estos almacenan. Por ejemplo:
int x = 10;
int px = &x;
int px2 = px; // px y px2 apuntan al objeto almacenado en x
Console.WriteLine( px == px2); // Imprime por pantalla True
En realidad las variables sobre las que se aplique & no
tienen porqué estar inicializadas. Por ejemplo, es
válido hacer:
private void f()
{
int x;
unsafe
{ int px = &x;}
}
Esto se debe a que uno de los principales usos de los punteros en
C# es poderlos pasar como parámetros de funciones no
gestionadas que esperen recibir punteros. Como muchas de esas
funciones han sido programadas para inicializar los contenidos de
los punteros que se les pasan, pasarles punteros inicializados
implicaría perder tiempo innecesariamente en inicializarlos.
Acceso a contenido de
puntero. Operador *
Un puntero no almacena directamente un objeto sino que suele
almacenar la dirección de memoria de un objeto (o sea,
apunta a un objeto) Para obtener a partir de un puntero el objeto
al que apunta hay que aplicarle al mismo el operador prefijo
*, que devuelve el objeto apuntado. Por ejemplo, el
siguiente código imprime en pantalla un 10:
int x = 10;
int * px= &x;
Console.WriteLine(*px);
Es posible en un puntero almacenar null para indicar que no
apunta a ninguna dirección válida. Sin embargo, si
luego se intenta acceder al contenido del mismo a través del
operador * se producirá generalmente una
excepción de tipo NullReferenceException (aunque realmente
esto depende de la implementación del lenguaje) Por ejemplo:
int * px = null;
Console.WriteLine(*px); // Produce una NullReferenceException
No tiene sentido aplicar * a un puntero de tipo void
* ya que estos punteros no almacenan información sobre
el tipo de objetos a los que apuntan y por tanto no es posible
recurperarlos a través de los mismos ya que no se sabe
cuanto espacio en memoria a partir de la dirección
almacenada en el puntero ocupa el objeto apuntado y, por tanto, no
se sabe cuanta memoria hay que leer para obtenerlo.
Acceso a miembro de
contenido de puntero. Operador ->
Si un puntero apunta a un objeto estructura que tiene un
método F() sería posible llamarlo a
través del puntero con:
(*objeto).F();
Sin embargo, como llamar a objetos apuntados por punteros es algo
bastante habitual, para facilitar la sintaxis con la que hacer esto
se ha incluido en C# el operador ->, con el que la
instrucción anterior se escribiría así:
objeto->f();
Es decir, del mismo modo que el operador . permite acceder a
los miembros de un objeto referenciado por una variable normal,
-> permite acceder a los miembros de un objeto
referenciado por un puntero. En general, un acceso de la forma O
-> M es equivalente a hacer (*O).M. Por
tanto, al igual que es incorrecto aplicar * sobre punteros
de tipo void *, también lo es aplicar ->
Conversiones de
punteros
De todo lo visto hasta ahora parece que no tiene mucho sentido el
uso de punteros de tipo void * Pues bien, una utilidad de
este tipo de punteros es que pueden usarse como almacén de
punteros de cualquier otro tipo que luego podrán ser
recuperados a su tipo original usando el operador de
conversión explícita. Es decir, igual que los objetos
de tipo object pueden almacenar implícitamente
objetos de cualquier tipo, los punteros void * pueden
almacenar punteros de cualquier tipo y son útiles para la
escritura de métodos que puedan aceptar parámetros de
cualquier tipo de puntero.
A diferencia de lo que ocurre entre variables normales, las
conversiones entre punteros siempre se permiten, al realizarlas
nunca no se comprueba si son válidas. Por ejemplo, el
siguiente código es válido:
char c = 'A';
char* pc = &c;
void* pv = pc;
int* pi = (int*)pv;
int i = *pi; // Almacena en 16 bits del char de pv
// + otros 16 indeterminados
Console.WriteLine(i);
*pi = 123456; // Machaca los 32 bits apuntados por pi
En este código pi es un puntero a un objeto de tipo
int (32 bits), pero en realidad el objeto al que apunta es
de tipo char (16 bits), que es más pequeño. El
valor que se almacene en i es en principio indefinido, pues
depende de lo que hubiese en los 16 bits extras resultantes de
tratar pv como puntero a int cuando en realidad
apuntaba a un char.
Del mismo modo, conversiones entre punteros pueden terminar
produciendo que un puntero apunte a un objeto de mayor
tamaño que los objetos del tipo del puntero. En estos casos,
el puntero apuntaría a los bits menos significativos del
objeto apuntado.
También es posible realizar conversiones entre punteros y
tipos básicos enteros. La conversión de un puntero en
un tipo entero devuelve la dirección de memoria apuntada por
el mismo. Por ejemplo, el siguiente código muestra por
pantalla la dirección de memoria apuntada por px:
int x = 10;
int *px = &10;
Console.WriteLine((int) px);
Por su parte, convertir cualquier valor entero en un puntero tiene
el efecto de devolver un puntero que apunte a la dirección
de memoria indicada por ese número. Por ejemplo, el
siguiente código hace que px apunte a la
dirección 1029 y luego imprime por pantalla la
dirección de memoria apuntada por px (que será
1029):
int *px = (int *) 10;
Console.WriteLine((int) px);
Nótese que aunque en un principio es posible hacer que un
puntero almacene cualquier dirección de memoria, si dicha
dirección no pertenece al mismo proceso que el código
en que se use el puntero se producirá un error al leer el
contenido de dicha dirección. El tipo de error ha producir
no se indica en principio en la especifricación del
lenguaje, pero la implementación de Microsoft lanza una
referencia NullReferenceException. Por ejemplo, el siguiente
código produce una excepción de dicho tipo al
ejecturase:
using System;
class AccesoInválido
{
public unsafe static void Main()
{
int * px = (int *) 100;
Console.Write(*px); // Se lanza NullReferenceException
}
}
Aritmética de
punteros
Los punteros se suelen usar para recorrer tablas de elementos sin
necesidad de tener que comprobarse que el índice al que se
accede en cada momento se encuentra dentro de los límites de
la tabla. Por ello, los operadores aritméticos definidos
para los punteros están orientados a facilitar este tipo de
recorridos.
Hay que tener en cuenta que todos los operadores aritméticos
aplicables a punteros dependen del tamaño del tipo de dato
apuntado, por lo que no son aplicables a punteros void * ya
que estos no almacenan información sobre dicho tipo. Esos
operadores son:
-
++ y --: El operador ++ no suma uno a la
dirección almacenada en un puntero, sino que le suma el
tamaño del tipo de dato al que apunta. Así, si el
puntero apuntaba a un elemento de una tabla pasará a
apuntar al siguiente (los elementos de las tablas se almacenan
en memoria consecutivamente) Del mismo modo, -- resta a
la dirección almacenada en el puntero el tamaño de
su tipo de dato. Por ejemplo, una tabla de 100 elementos a cuyo
primer elemento inicialmente apuntase pt podría
recorrerse así:
for (int i=0; i<100; i++)
Console.WriteLine("Elemento {0}={1}", i, (*p)++);
El problema que puede plantear en ciertos casos el uso de ++
y -- es que hacen que al final del recorrido el puntero deje
de apuntar al primer elemento de la tabla. Ello podría
solucionarse almacenando su dirección en otro puntero antes
de iniciar el recorrido y restaurándola a partir de
él tras finalizarlo.
-
+ y -: Permiten solucionar el problema de
++ y -- antes comentado de una forma
más cómoda basada en sumar o restar un cierto
entero a los punteros. + devuelve la dirección
resultante de sumar a la dirección almacenada en el
puntero sobre el que se aplica el tamaño del tipo de
dicho puntero tantas veces como indique el entero sumado.
- tiene el mismo significado pero restando dicha cantidad
en vez de sumarla. Por ejemplo, usando + el bucle
anterior podría reescribrise así:
for (int i=0; i<100; i++)
Console.WriteLine("Elemento {0}={1}", i, *(p+i));
El operador - también puede aplicarse entre dos
punteros de un mismo tipo, caso en
que devuelve un long que indica cuántos elementos del
tipo del puntero pueden almacenarse entre las direcciones de los
punteros indicados.
-
[]: Dado que es frecuente usar + para acceder a
elementos de tablas, también se ha redefinido el operador
[] para que cuando se aplique a una tabla haga lo mismo y
devuelva el objeto contenido en la dirección resultante.
O sea *(p+i) es equivalente a
p[i], con lo que el código anterior
equivale a:
for (int i=0; i<100; i++)
Console.WriteLine("Elemento {0}={1}", i, p[i]);
No hay que confundir el acceso a los elementos de una tabla
aplicando [] sobre una variable de tipo tabla normal con el
acceso a través de un puntero que apunte a su primer
elemento. En el segundo caso no se comprueba si el índice
indicado se encuentra dentro del rango de la tabla, con lo que el
acceso es más rápido pero también más
proclive a errores difíciles de detectar.
Finalmente, respecto a la aritmética de punteros, hay que
tener en cuenta que por eficiencia, en las operaciones con punteros
nunca se comprueba si se producen desbordamientos, y en caso de
producirse se truncan los resultados sin avisarse de ello mediante
excepciones. Por eso hay que tener especial cuidado al operar con
punteros no sea que un desbordamiento no detectado cause errores de
causas difíciles de encontrar.
Operadores relacionados
con código inseguro
Operador sizeof.
Obtención de tamaño de tipo
El operador unario y prefijo sizeof devuelve un objeto
int con el tamaño en bytes del tipo de dato sobre el
que se aplica. Sólo puede aplicarse en contextos inseguros y
sólo a tipos de datos para los que sea posible definir
punteros, siendo su sintaxis de uso:
sizeof(<tipo>)
Cuando se aplica a tipos de datos básicos su resultado es
siempre constante. Por ello, el compilador optimiza dichos usos de
sizeof sustituyéndolos internamente por su valor
(inlining) y considerando que el uso del operador es una
expresión constante. Estas constantes correspondientes a los
tipos básicos son las indicadas en la Tabla 10:
| Tipos |
Resultado |
|
sbyte, byte, bool
|
1
|
|
short, ushort, char
|
2
|
|
int, uint, float
|
4
|
|
long, ulong, double
|
8
|
Tabla 10: Resultados
de sizeof para tipos básicos
Para el resto de tipos a los que se les puede aplicar,
sizeof no tiene porqué devuelver un resultado
constante sino que los compiladores pueden alinear en memoria las
estructuras incluyendo bits de relleno cuyo número y valores
sean en principio indeterminado. Sin embargo, el valor devuelto por
sizeof siempre devolverá el tamaño en memoria
exacto del tipo de dato sobre el que se aplique, incluyendo bits de
relleno si los tuviese.
Nótese que es fácil implementar los operadores de
aritmética de punteros usando sizeof. Para ello,
++ se definiría como añadir a la
dirección almacenada en el puntero el resultado de aplicar
sizeof a su tipo de dato, y -- consistiría en
restarle dicho valor. Por su parte, el operador + usado de
la forma P + N (P es un puntero de tipo T y N un entero) lo que
devuelve es el resultado de añadir al puntero sizeof(T)*N, y
P - N devuelve el resultado de restarle sizeof(T)*N. Por
último, si se usa - para restar dos punteros P1 y P2
de tipo T, ello es equivalente a calcular (((long)P1) -
((long)P2)))/sizeof(T)
Operador stackalloc.
Creación de tablas en pila.
Cuando se trabaja con punteros puede resultar interesante reservar
una zona de memoria en la pila donde posteriormente se puedan ir
almacenando objetos. Precisamente para eso está el operador
stackalloc, que se usa siguiéndose la siguiente
sintaxis:
stackalloc <tipo>[<número>]
stackalloc reserva en pila el espacio necesario para
almacenar contiguamente el número de objetos de tipo
<tipo> indicado en <número> (reserva
sizeof(<tipo>)*<número> bytes) y devuelve un
puntero a la dirección de inicio de ese espacio. Si no
quedase memoria libre suficiente para reservarlo se
produciría una excepción
System.StackOverflowException.
stackalloc sólo puede usarse para inicializar
punteros declarados como variables locales y sólo en el
momento de su declaración.. Por ejemplo, un puntero pt que
apuntase al principio de una región con capacidad para 100
objetos de tipo int se declararía con:
int * pt = stackalloc int[100];
Sin embargo, no sería válido hacer:
int * pt;
pt = stackalloc int[100]; // ERROR: Sólo puede usarse stackalloc en declaraciones
Aunque pueda parecer que stackalloc se usa como sustituto de
new para crear tablas en pila en lugar de en memoria
dinámica, no hay que confundirse: stackalloc
sólo reserva un espacio contiguo en pila para objetos de un
cierto tipo, pero ello no significa que se cree una tabla en pila.
Las tablas son objetos que heredan de System.Array y cuentan
con los miembros heredados de esta clase y de object, pero
regiones de memoria en pila reservadas por stackalloc no.
Por ejemplo, el siguiente código es inválido.
int[] tabla;
int * pt = stackalloc int[100];
tabla = *pt; // ERROR: El contenido de pt es un int, no una tabla (int[])
Console.WriteLine(pt->Length); // ERROR: pt no apunta a una tabla
Sin embargo, gracias a que como ya se ha comentado en este tema el
operador [] está redefinido para trabajar con
punteros, podemos usarlo para acceder a los diferentes objetos
almacenados en las regiones reservadas con stackalloc como
si fuesen tablas. Por ejemplo, este código guarda en pila
los 100 primeros enteros y luego los imprime:
class Stackalloc
{
public unsafe static void Main()
{
int * pt = stackalloc int[100];
for (int i=0; i<100; i++)
pt[i] = i;
for(int i=0; i<100; i++)
System.Console.WriteLine(pt[i]);
}
}
Nótese que, a diferencia de lo que ocurriría si pt
fuese una tabla, en los accesos con pt[i] no se comprueba que i no
supere el número de objetos para los que se ha reservado
memoria. Como contrapartida, se tiene el inconveniente de que al no
ser pt una tabla no cuenta con los métodos típicos de
éstas y no puede usarse foreach para recorrerla.
Otra ventaja de la simulación de tablas con
stackalloc es que se reserva la memoria mucho más
rápido que el tiempo que se tardaría en crear una
tabla. Esto se debe a que reservar la memoria necesaria en pila tan
sencillo como incrementar el puntero de pila en la cantidad
correspondiente al tamaño a reservar, y no hay que perder
tiempo en solicitar memoria dinámica. Además,
stackalloc no pierde tiempo en inicializar con algún
valor el contenido de la memoria, por lo que la "tabla"
se crea antes pero a costa de que luego sea más inseguro
usarla ya que hay que tener cuidado con no leer trozos de ella
antes de asignarles valores válidos.
Fijación de
variables apuntadas
Aunque un puntero sólo puede apuntar a datos de tipos que
puedan almacenarse completamente en pila (o sea, que no sean ni
objetos de tipos referencia ni estructuras con miembros de tipos
referencia), nada garantiza que los objetos apuntado en cada
momento estén almacenados en pila. Por ejemplo, las
variables estáticas de tipo int o los elementos de
una tabla de tipo int se almacenan en memoria
dinámica aún cuando son objetos a los que se les
puede apuntar con punteros.
Si un puntero almacena la dirección de un objeto almacenado
en memoria dinámica y el recolector de basura cambia al
objeto de posición tras una compactación de memoria
resultante de una recolección, el valor almacenado en el
puntero dejará de ser válido. Para evitar que esto
ocurra se puede usar la instrucción fixed, cuya
sintaxis de uso es:
fixed(<tipo> <declaraciones>)
<instrucciones>
El significado de esta instrucción es el siguiente: se
asegura que durante la ejecución del bloque de
<instrucciones> indicado el recolector de basura nunca cambie
la dirección de ninguno de los objetos apuntados por los
punteros de tipo <tipo> declarados. Estas
<declaraciones> siempre han de incluir una
especificación de valor inicial para cada puntero declarado,
y si se declaran varios se han de separar con comas.
Los punteros declarados en <declaraciones> sólo
existirán dentro de <instrucciones>, y al salir de
dicho bloque se destruirán. Además, si se les indica
como valor inicial una tabla o cadena que valga null
saltará una NullReferenceException. También
hay que señalar que aunque sólo pueden declarase
punteros de un mismo tipo en cada fixed, se puede simular
facilmente la declaración de punteros de distintos tipos
anidando varios fixed.
Por otro lado, los punteros declarados en <declaraciones> son
de sólo lectura, ya que si no podría
cambiárseles su valor por el de una dirección de
memoria no fijada y conducir ello a errores difíciles de
detectar.
Un uso frecuente de fixed consiste en apuntar a objetos de
tipos para los que se puedan declarar punteros pero que
estén almacenados en tablas, ya que ello no se puede hacer
directamente debido a que las tablas se almacenan en memoria
dinámica. Por ejemplo, copiar usando punteros una tabla de
100 elementos de tipo int en otra se haría
así:
class CopiaInsegura
{
public unsafe static void Main()
{
int[] tOrigen = new int[100];
int[] tDestino = new int[100];
fixed (int * pOrigen=tOrigen, pDestino=tDestino)
{
for (int i=0; i<100; i++)
pOrigen[i] = pDestino[i];
}
}
}
Como puede deducirse del ejemplo, cuando se inicializa un puntero
con una tabla, la dirección almacenada en el puntero en la
zona <declaraciones> del fixed es la del primer
elemento de la tabla (también podría haberse hecho
pOrigen = &tOrigen[0]), y luego es posible usar la
aritmética de punteros para acceder al resto de elementos a
partir de la dirección del primero ya que éstos se
almacenan consecutivamente.
Al igual que tablas, también puede usarse fixed para
recorrer cadenas. En este caso lo que hay que hacer es inicializar
un puntero de tipo char * con la dirección del primer
carácter de la cadena a la que se desee que apunte tal y
como muestra este ejemplo en el que se cambia el contenido de una
cadena "Hola" por "XXXX":
class CadenaInsegura
{
public unsafe static void Main()
{
string s="Hola";
Console.WriteLine("Cadena inicial: {0}", s);
fixed (char * ps=s)
{
for (int i=0;i<s.Length;i++)
ps[i] = 'A';
}
Console.WriteLine("Cadena final: {0}", s);
}
}
La salida por pantalla de este último programa es:
Hola
AAAA
La ventaja de modificar la cadena mediante punteros es sin ellos no
sería posible hacerlo ya que el indizador definido para los
objetos string es de sólo lectura.
Cuando se modifiquen cadenas mediante punteros hay que tener en
cuenta que, aunque para facilitar la comunicación con
código no gestionado escrito en C o C++ las cadenas en C#
también acaban en el carácter '\0', no se
recomienda confiar en ello al recorrerlas con punteros porque
'\0' también puede usarse como carácter
de la cadena. Por ello, es mejor hacer como en el ejemplo y
detectar su final a través de su propiedad Length.
Hay que señalar que como fixed provoca que no pueda
cambiarse de dirección a ciertos objetos almacenados en
memoria dinámica, ello puede producir la generación
de huecos en memoria dinámica, lo que tiene dos efectos muy
negativos:
-
El recolector de basura está optimizado para trabajar con
memoria compactada, pues si todos los objetos se almacenan
consecutivamente en memoria dinámica crear uno nuevo es
tan sencillo como añadirlo tras el último. Sin
embargo, fixed rompe esta consecutividad y la
creación de objetos en memoria dinámica dentro de
este tipo de instrucciones es más lenta porque hay que
buscar huecos libres.
-
Por defecto, al eliminarse objetos de memoria durante una
recolección de basura se compacta la memoria que queda
ocupada para que todos los objetos se almacenen en memoria
dinámica. Hacer esto dentro de sentencias fixed es
más lento porque hay que tener en cuenta si cada objeto
se puede o no mover.
Por estas razones es conveniente que el contenido del bloque de
instrucciones de una sentencia fixed sea el mínimo
posible, para que así el fixed se ejecute lo antes
posible.