Tema 7: Variables y tipos de datos
(C) 2001 José Antonio González Seco
Definición de
variables
Una variable puede verse simplemente como un almacén
de objetos de un determinado tipo al que se le da un cierto nombre.
Por tanto, para definir una variable sólo hay que decir
cuál será el nombre que se le dará y
cuál será el tipo de datos que podrá
almacenar, lo que se hace con la la siguiente sintaxis:
<tipoVariable> <nombreVariable>;
Una variable puede ser definida dentro de una definición de
clase, en cuyo caso se correspondería con el tipo de miembro
que hasta ahora hemos denominado campo. También puede
definirse como un variable local a un método, que es
una variable definida dentro del código del método a
la que sólo puede accederse desde dentro de dicho
código. Otra posibilidad es definirla como
parámetro de un método, que son variables que
almacenan los valores de llamada al método y que, al igual
que las variables locales, sólo puede ser accedidas desde
código ubicado dentro del método. El siguiente
ejemplo muestra como definir variables de todos estos casos:
class A
{
int x, z;
int y;
void F(string a, string b)
{
Persona p;
}
}
En este ejemplo las variables x, z e y son campos de tipo
int, mientras que p es una variable local de tipo
Persona y a y b son parámetros de tipo string. Como
se muestra en el ejemplo, si un método toma varios
parámetros las definiciones de éstos se separan
mediante comas (carácter ,), y si queremos definir
varios campos o variables locales (no válido para
parámetros) de un mismo tipo podemos incluirlos en una misma
definición incluyendo en <nombreVariable> sus nombres
separados por comas.
Con la sintaxis de definición de variables anteriormente
dada simplemente definimos variables pero no almacenamos
ningún objeto inicial en ellas. El compilador dará un
valor por defecto a los campos para los que no se indique
explícitamente ningún valor según se explica
en el siguiente apartado. Sin embargo, a la variables locales no
les da ningún valor inicial, pero detecta cualquier intento
de leerlas antes de darles valor y produce erores de
compilación en esos casos.
Ya hemos visto que para crear objetos se utiliza el operador
new. Por tanto, una forma de asignar un valor a la variable
p del ejemplo anterior sería así:
Persona p;
p = new Persona("José", 22, "76543876-A");
Sin embargo, C# también proporciona una sintaxis más
sencilla con la que podremos asignar un objeto a una variable en el
mismo momento se define. Para ello se la ha de definir usando esta
otra notación:
<tipoVariable> <nombreVariable> = <valorInicial>;
Asi por ejemplo, la anterior asignación de valor a la
variable p podría reescribirse de esta otra forma más
compacta:
Persona p = new Persona("José", 22, "76543876-A");
La especificación de un valor inicial también
combinarse con la definición de múltiples variables
separadas por comas en una misma línea. Por ejemplo, las
siguientes definiciones son válidas:
Persona p1 = new Persona("José", 22, "76543876-A"),
p2 = new Persona("Juan", 21,"87654212-S");
Y son tratadas por el compilador de forma completamente
equivalentes a haberlas declarado como:
Persona p1 = new Persona("José", 22, "76543876-A");
Persona p2 = new Persona("Juan", 21, "87654212-S");
Tipos de datos
básicos
Los tipos de datos básicos son ciertos tipos de datos
tan comúnmente utilizados en la escritura de aplicaciones
que en C# se ha incluido una sintaxis especial para tratarlos. Por
ejemplo, para representar números enteros de 32 bits con
signo se utiliza el tipo de dato System.Int32 definido en la
BCL, aunque a la hora de crear un objeto a de este tipo que
represente el valor 2 se usa la siguiente sintaxis:
System.Int32 a = 2;
Como se ve, no se utiliza el operador new para crear un objeto
System.Int32, sino que directamente se indica el literal que
representa el valor a crear, con lo que la sintaxis necesaria para
crear entero de este tipo se reduce considerablemente. Es
más, dado lo frecuente que es el uso de este tipo
también se ha predefinido en C# el alias int para el
mismo, por lo que la definición de variable anterior queda
así de compacta:
int a = 2;
System.Int32 no es el único tipo de dato
básico incluido en C#. En el espacio de nombres
System se han incluido todos estos:
|
Tipo
|
Descripción
|
Bits
|
Rango de valores
|
Alias
|
|
SByte
|
Bytes con signo
|
8
|
-128 - 127
|
sbyte
|
|
Byte
|
Bytes sin signo
|
8
|
0 - 255
|
byte
|
|
Int16
|
Enteros cortos con signo
|
16
|
[-32.768, 32.767]
|
short
|
|
UInt16
|
Enteros cortos sin signo
|
16
|
[0, 65.535]
|
ushort
|
|
Int32
|
Enteros normales
|
32
|
[-2.147.483.648, 2.147.483.647]
|
int
|
|
UInt32
|
Enteros normales sin signo
|
32
|
[0, 4.294.967.295]
|
uint
|
|
Int64
|
Enteros largos
|
64
|
[-9.223.372.036.854.775.808,
9.223.372.036.854.775.807]
|
long
|
|
UInt64
|
Enteros largos sin signo
|
64
|
[0-18.446.744.073.709.551.615]
|
ulong
|
|
Single
|
Reales con 7 dígitos de precisión
|
32
|
[1,5×10-45 -
3,4×1038]
|
float
|
|
Double
|
Reales de 15-16 dígitos de
precisión
|
64
|
[5,0×10-324 -
1,7×10308]
|
double
|
|
Decimal
|
Reales de 28-29 dígitos de
precisión
|
128
|
[1,0×10-28 -
7,9×1028]
|
decimal
|
|
Boolean
|
Valores lógicos
|
32
|
true, false
|
bool
|
|
Char
|
Caracteres Unicode
|
16
|
['\u0000', '\uFFFF']
|
char
|
|
String
|
Cadenas de caracteres
|
Variable
|
El permitido por la memoria
|
string
|
|
Object
|
Cualquier objeto
|
Variable
|
Cualquier objeto
|
object
|
Tabla 5: Tipos de datos básicos
Pese a su sintaxis especial, en C# los tipos básicos son
tipos del mismo nivel que cualquier otro tipo del lenguaje. Es
decir, heredan de System.Object y pueden ser tratados como
objetos de dicha clase por cualquier método que espere un
System.Object, lo que es muy útil para el
diseño de rutinas genéricas que admitan
parámetros de cualquier tipo y es una ventaja importante de
C# frente a lenguajes similares como Java donde los tipos
básicos no son considerados objetos.
El valor que por defecto se da a los campos de tipos básicos
consiste en poner a cero todo el área de memoria que ocupen.
Esto se traduce en que los campos de tipos básicos
numéricos se inicializan por defecto con el valor 0, los de
tipo bool lo hacen con false, los de tipo char
con '\u0000', y los de tipo string y
object con null.
Ahora que sabemos cuáles son los tipos básicos, es el
momento de comentar cuáles son los sufijos que admiten los
literales numéricos para indicar al compilador cuál
es el tipo que se ha de considerar que tiene. Por ejemplo, si
tenemos en una clase los métodos:
public static void F(int x)
{...}
public static void F(long x)
{...}
Ante una llamada como F(100), ¿a cuál de los
métodos se llamara? Pues bien, en principio se considera que
el tipo de un literal entero es el correspondiente al primero de
estos tipos básicos que permitan almacenarlo: int,
uint, long, ulong, por lo que en el caso
anterior se llamaría al primer F() Para llamar al otro
podría añadirse el sufijo L al literal y
hacer la llamada con F(100L) En la Tabla 6 se resumen los
posibles sufijos válidos:
|
Sufijo
|
Tipo del literal entero
|
|
ninguno
|
Primero de: int, uint, long,ulong
|
|
L ó l
|
Primero de: long, ulong
|
|
U ó u
|
Primero de: int, uint
|
|
UL, Ul, uL, ul, LU, Lu, lU ó lu
|
ulong
|
Tabla 6: Sufijos de
literales enteros
Por su parte, en la Tabla 7 se indican los sufijos que
admiten los literales reales son:
|
Sufijo
|
Tipo del literal real
|
|
F ó f
|
float
|
|
ninguno,D ód
|
double
|
|
M ó m
|
decimal
|
Tabla 7: Sufijos de
literales reales
Tablas
Tablas
unidimensionales
Una tabla unidimensional es un tipo especial de variable que
es capaz de almacenar en su interior y de manera ordenada uno o
varios datos de un determinado tipo. Para declarar variables de
este tipo especial se usa la siguiente sintaxis:
<tipoDatos>[] <nombreTabla>;
Por ejemplo, una tabla que pueda almacenar objetos de tipo
int se declara así:
int[] tabla;
Con esto la tabla creada no almacenaría ningún
objeto, sino que valdría null. Si se desea que
verdaderamente almacene objetos hay que indicar cuál es el
número de objetos que podrá almacenar, lo que puede
hacerse usando la siguiente sintaxis al declararla:
<tipoDatos>[] <nombreTabla> = new <tipoDatos>[<númeroDatos>];
Por ejemplo, una tabla que pueda almacenar 100 objetos de tipo
int se declara así:
int[] tabla = new int[100];
Aunque también sería posible definir el tamaño
de la tabla de forma separada a su declaración de este modo:
int[] tabla;
tabla = new int[100];
Con esta última sintaxis es posible cambiar
dinámicamente el número de elementos de una variable
tabla sin más que irle asignando nuevas tablas. Ello no
significa que una tabla se pueda redimensionar conservando los
elementos que tuviese antes del cambio de tamaño, sino que
ocurre todo lo contrario: cuando a una variable tabla se le asigna
una tabla de otro tamaño, sus elementos antiguos son
sobreescritos por los nuevos.
Si se crea una tabla con la sintaxis hasta ahora explicada todos
sus elementos tendrían el valor por defecto de su tipo de
dato. Si queremos darles otros valores al declarar la tabla, hemos
de indicarlos entre llaves usando esta sintaxis:
<tipoDatos>[] <nombreTabla> = new <tipoDatos>[] {<valores>};
Ha de especificarse tantos <valores> como número de
elementos se desee que tenga la tabla, y si son más de uno
se han de separar entre sí mediante comas (,)
Nótese que ahora no es necesario indicar el número de
elementos de la tabla (aunque puede hacerse) si se desea), pues el
compilador puede deducirlo del número de valores
especificados. Por ejemplo, para declarar una tabla de cuatro
elementos de tipo int con valores 5,1,4,0 se podría
hacer lo siguiente:
int[] tabla = new int[] {5,1,4,0};
Incluso se puede compactar aún más la sintaxis
declarando la tabla así:
int[] tabla = {5,1,4,0};
También podemos crear tablas cuyo tamaño se pueda
establecer dinámicamente a partir del valor de cualquier
expresión que produzca un valor de tipo entero. Por ejemplo,
para crear una tabla cuyo tamaño sea el valor indicado por
una variable de tipo int (luego su valor será de tipo
entero) se haría:
int i = 5;
...
int[] tablaDinámica = new int[i];
A la hora de acceder a los elementos almacenados en una tabla basta
indicar entre corchetes, y a continuación de la referencia a
la misma, la posición que ocupe en la tabla el elemento al
que acceder. Cuando se haga hay que tener en cuenta que en C# las
tablas se indexan desde 0, lo que significa que el primer elemento
de la tabla ocupará su posición 0, el segundo
ocupará la posición 1, y así sucesivamente
para el resto de elementos. Por ejemplo, aunque es más
ineficiente, la tabla declarada en el último fragmento de
código de ejemplo también podría haberse
definido así:
int[] tabla = new int[4];
tabla[0] = 5;
tabla[1]++; // Por defecto se inicializó a 0, luego
// ahora el valor de tabla[1] pasa a ser 1
tabla[2] = tabla[0] - tabla[1]; // tabla[2] pasa a
// valer 4, pues 5-4 = 1
// El contenido de la tabla será {5,1,4,0}, pues
// tabla[3] se inicializó por defecto a 0.
Hay que tener cuidado a la hora de acceder a los elementos de una
tabla ya que si se especifica una posición superior al
número de elementos que pueda almacenar la tabla se
producirá una excepción de tipo
System.OutOfBoundsException. En el Tema 16:
Instrucciones se explica qué son las excepciones, pero
por ahora basta considerar que son objetos que informan de
situaciones excepcionales (generalmente errores) producidas durante
la ejecución de una aplicación. Para evitar este tipo
de excepciones puede consultar el valor del campo de sólo
lectura Length que está asociado a toda tabla y
contiene el número de elementos de la misma. Por ejemplo,
para asignar un 7 al último elemento de la tabla anterior se
haría:
tabla[tabla.Length - 1] = 7; // Se resta 1 porque tabla.Length
// devuelve 4 pero el último elemento
// de la tabla es tabla[3]
Tablas dentadas
Una tabla dentada no es más que una tabla cuyos
elementos son a su vez tablas, pudiéndose así anidar
cualquier número de tablas. Para declarar tablas de este
tipo se usa una sintaxis muy similar a la explicada para las tablas
unidimensionales solo que ahora se indican tantos corchetes como
nivel de anidación se desee. Por ejemplo, para crear una
tabla de tablas de elementos de tipo int formada por dos
elementos, uno de los cuales fuese una tabla de elementos de tipo
int formada por los elementos de valores 1,2 y el otro fuese
una tabla de elementos de tipo int y valores 3,4,5, se puede
hacer:
int[][] tablaDentada = new int[2][] {new int[] {1,2}, new int[] {3,4,5}};
Como se indica explícitamente cuáles son los
elementos de la tabla declarada no hace falta indicar el
tamaño de la tabla, por lo que la declaración
anterior es equivalente a:
int[][] tablaDentada = new int[][] {new int[] {1,2}, new int[] {3,4,5}};
Es más, igual que como se vió con las tablas
unidimensionales también es válido hacer:
int[][] tablaDentada = {new int[] {1,2}, new int[] {3,4,5}};
Si no quisiésemos indicar cuáles son los elementos de
las tablas componentes, entonces tendríamos que indicar al
menos cuál es el número de elementos que
podrán almacenar (se inicializarán con valores por
defecto) quedando:
int[][] tablaDentada = {new int[2], new int[3]};
Si no queremos crear las tablas componentes en el momento de crear
la tabla dentada, entonces tendremos que indicar por lo menos
cuál es el número de tablas componentes posibles
(cada una valdría null), con lo que quedaría:
int[][] tablaDentada = new int[2][];
Es importante señalar que no es posible especificar todas
las dimensiones de una tabla dentada en su definición si no
se indica explícitamente el valor inicial de éstas
entre llaves. Es decir, esta declaración es incorrecta:
int[][] tablaDentada = new int[2][5];
Esto se debe a que el tamaño de cada tabla componente puede
ser distinto y con la sintaxis anterior no se puede decir
cuál es el tamaño de cada una. Una opción
hubiese sido considerar que es 5 para todas como se hace en Java,
pero ello no se ha implementado en C# y habría que declarar
la tabla de, por ejemplo, esta manera:
int[][] tablaDentada = {new int[5], new int[5]);
Finalmente, si sólo queremos declarar una variable tabla
dentada pero no queremos indicar su número de elementos,
(luego la variable valdría null), entonces basta
poner:
int[][] tablaDentada;
Hay que precisar que aunque en los ejemplos hasta ahora presentes
se han escrito ejemplos basados en tablas dentadas de
sólo dos niveles de anidación, también es
posible crear tablas dentadas de cualquier número de niveles
de anidación. Por ejemplo, para una tabla de tablas de
tablas de enteros de 2 elementos en la que el primero fuese una
tabla dentada formada por dos tablas de 5 enteros y el segundo
elemento fuese una tabla dentada formada por una tabla de 4 enteros
y otra de 3 se podría definir así:
int[][][] tablaDentada = new int[][][]
{ new int[][] {new int[5], new int[5]},
new int[][] {new int[4], new int[3]}};
A la hora de acceder a los elementos de una tabla dentada lo
único que hay que hacer es indicar entre corchetes
cuál es el elemento exacto de las tablas componentes al que
se desea acceder, indicándose un elemento de cada nivel de
anidación entre unos corchetes diferentes pero
colocándose todas las parejas de corchetes juntas y
ordenadas de la tabla más externa a la más interna.
Por ejemplo, para asignar el valor 10 al elemento cuarto de la
tabla que es elemento primero de la tabla que es elemento segundo
de la tabla dentada declarada en último lugar se
haría:
tablaDentada[1][0][3] = 10;
Tablas
multidimensionales
Una tabla multidimensional es una tabla cuyos elementos se
encuentran organizando una estructura de varias dimensiones. Para
definir este tipo de tablas se usa una sintaxis similar a la usada
para declarar tablas unidimensionales pero separando las diferentes
dimensiones mediante comas (,) Por ejemplo, una tabla
multidimensional de elementos de tipo int que conste de 12
elementos puede tener sus elementos distribuidos en dos dimensiones
formando una estructura 3x4 similar a una matriz de la forma:
1 2 3 4
5 6 7 8
9 10 11 12
Esta tabla se podría declarar así:
int[,] tablaMultidimensional = new int[3,4]
En realidad no es necesario indicar el número de elementos
de cada dimensión de la tabla ya que pueden deducirse de los
valores explícitamente indicados entre llaves, por lo que la
definición anterior es similar a esta:
int[,] tablaMultidimensional = new int[,]
Incluso puede reducirse aún más la sintaxis necesaria
quedando tan sólo:
int[,] tablaMultidimensional =
Si no queremos indicar explícitamente los elementos de la
tabla al declararla, podemos obviarlos pero aún así
indicar el tamaño de cada dimensión de la tabla (a
los elementos se les daría el valor por defecto de su tipo
de dato) así:
int[,] tablaMultidimensional = new int[3,4];
También podemos no especificar ni siquiera el número
de elementos de la tabla de esta forma (tablaMultidimensional
contendría ahora null):
int[,] tablaMultidimensional;
Aunque los ejemplos de tablas multidimensionales hasta ahora
mostrados son de tablas de dos dimensiones, en general
también es posible crear tablas de cualquier número
de dimensiones. Por ejemplo, una tabla que almacene 24 elementos de
tipo int y valor 0 en una estructura tridimensional 3x4x2 se
declararía así:
int[,,] tablaMultidimensional = new int[3,4,2];
El acceso a los elementos de una tabla multidimensional es muy
sencillo: sólo hay que indicar los índices de la
posición que ocupe en la estructura multidimensional el
elemento al que se desee acceder. Por ejemplo, para incrementar en
una unidad el elemento que ocupe la posición (1,3,2) de la
tabla anterior se haría (se indiza desde 0):
tablaMultidimensional[0,2,1]++;
Nótese que tanto las tablas dentadas como las tablas
multidimensionales pueden ser utilizadas tanto para representar
estructuras matriciales como para, en general, representar
cualquier estructura de varias dimensiones. La diferencia entre
ambas son:
-
Como las tablas dentadas son tablas de tablas, cada uno de sus
elementos puede ser una tabla de un tamaño diferente.
Así, con las tablas dentadas podemos representar matrices en
las que cada columna tenga un tamaño distinto (por el
aspecto "aserrado" de este tipo de matrices es por lo
que se les llama tablas dentadas), mientras que usando tablas
multidimensionales sólo es posible crear matrices
rectangulares o cuadradas. Las estructuras aserradas pueden
simularse usando matrices multidimensionales con todas sus columnas
del tamaño de la columna más grande necesaria, aunque
ello implica desperdiciar mucha memoria sobre todo si los
tamaños de cada columna son muy diferentes y la tabla es
grande De todos modos, las estructuras más comunes que se
usan en la mayoría de aplicaciones suelen ser rectangulares
o cuadradas.
-
Los tiempos que se tardan en crear y destruir tablas dentadas son
superiores a los que se tardan en crear y destruir tablas
multidimensionales. Esto se debe a que las primeras son tablas de
tablas mientras que las segundas son una única tabla, Por
ejemplo, para crear una tabla dentada [100][100] hay que crear 101
tablas (la tabla dentada más las 100 tablas que contiene),
mientras que para crear una crear una tabla bidimensional [100,100]
hay que crear una única tabla.
-
Las tablas dentadas no forman parte del CLS, por lo que no todos
los lenguajes gestionados los tienen porqué admitir. Por
ejemplo Visual Basic.NET no las admite, por lo que al usarlas en
miembros públicos equivale a perder interoperabilidad
con estos lenguajes.
Tablas mixtas
Una tabla mixta es simplemente una tabla formada por tablas
multidimensionales y dentadas combinadas entre sí de
cualquier manera. Para declarar una tabla de este tipo basta con
tan solo combinar las notaciones ya vistas para las
multidimensionales y dentadas. Por ejemplo, para declarar una tabla
de tablas multidimensionales cuyos elementos sean tablas
unidimensionales de enteros se haría lo siguiente:
int[][,][] tablaMixta;
Covarianza de tablas
La covarianza de tablas es el resultado de llevar el
polimorfismo al mundo de las tablas. Es decir, es la capacidad de
toda tabla de poder almacenar elementos de clases hijas de la clase
de elementos que pueda almacenar. Por ejemplo, en tanto que todas
clases son hijas de System.Object, la siguiente
asignación es válida:
string[] tablaCadenas = {"Manolo", "Paco", "Pepe"};
object[] tablaObjetos = tablaCadenas;
Hay que tener en cuenta que la covarianza de tablas sólo se
aplica a objetos de tipos referencia y no a objetos de tipos valor
Por ejemplo, la siguiente asignación no sería
válida en tanto que int es un tipo por valor:
int[] tablaEnteros = {1, 2, 3};
object[] tablaObjetos = tablaEnteros;
La clase
System.Array
En realidad, todas las tablas que definamos, sea cual sea el tipo
de elementos que contengan, son objetos que derivan de
System.Array. Es decir, van a disponer de todos los miembros
que se han definido para esta clase, entre los que son destacables:
-
Length: Campo de sólo lectura que
informa del número total de elementos que contiene la
tabla. Si la tabla tiene más de una dimensión o
nivel de anidación indica el número de elementos
de todas sus dimensiones y niveles. Por ejemplo:
int[] tabla = {1,2,3,4};
int[][] tabla2 = {new int[] {1,2}, new int[] {3,4,5}};
int[,] tabla3 =
Console.WriteLine(tabla.Length); //Imprime 4
Console.WriteLine(tabla2.Length); //Imprime 5
Console.WriteLine(tabla3.Length); //Imprime 6
-
Rank: Campo de sólo lectura que almacena el
número de dimensiones de la tabla. Obviamente si la tabla
no es multidimensional valdrá 1. Por ejemplo:
int[] tabla = {1,2,3,4};
int[][] tabla2 = {new int[] {1,2}, new int[] {3,4,5}};
int[,] tabla3 =
Console.WriteLine(tabla.Rank); //Imprime 1
Console.WriteLine(tabla2.Rank); //Imprime 1
Console.WriteLine(tabla3.Rank); //Imprime 2
-
void CopyTo(Array destino, int posición): Copia
todos los elementos de la tabla sobre la que es aplica en la que
se le pasa como primer parámetro a partir de la
posición de la misma indicada como segundo
parámetro. Por ejemplo:
int[] tabla1 = {1,2,3,4};
int[] tabla2 = {5,6,7,8};
tabla1.CopyTo(tabla2,0); // A partir de ahora, ambas tablas contienen {1,2,3,4}
Ambas tablas han de ser unidimensionales. Por otro lado, y como es
obvio, la tabla de destino ha de ser de un tipo que pueda almacenar
los objetos de la tabla fuente, el índice especificado ha de
ser válido (mayor o igual que cero y menor que el
tamaño de la tabla de destino) y no ha de valer
null ninguna. Si no fuese así, saltarían
excepciones de diversos tipos informando del error cometido (en la
documentación del SDK puede ver cuáles son en
concreto).
Aparte de los miembros aquí señalados, de
System.Array cuenta con muchos más que permiten
realizar tareas tan frecuentes como búsquedas de elementos,
ordenaciones, etc.
Cadenas de texto
Una cadena de texto no es más que una secuencia de
caracteres Unicode. En C# se representan mediante objetos del tipo
tipo de dato llamado string, que no es más que un
alias del tipo System.String incluido en la BCL.
Las cadenas de texto suelen crearse a partir literales de cadena o
de otras cadenas previamente creadas. Ejemplos de ambos casos se
muestran a continuación:
string cadena1 = "José Antonio";
string cadena2 = cadena;
En el primer caso se ha creado un objeto string que
representa a la cadena formada por la secuencia de caracteres
José Antonio indicada literalmente (nótese que las
comillas dobles entre las que se encierran los literales de cadena
no forman parte del contenido de la cadena que representan
sino que sólo se usan como delimitadores de la misma) En el
segundo caso la variable cadena2 creada se genera a partir de la
variable cadena1 ya existente, por lo que ambas variables
apuntarán al mismo objeto en memoria.
Hay que tener en cuenta que el tipo string es un tipo
referencia, por lo que en principio la comparación entre
objetos de este tipo debería comparar sus direcciones de
memoria como pasa con cualquier tipo referencia. Sin embargo, si
ejecutamos el siguiente código veremos que esto no ocurre en
el caso de las cadenas:
using System;
public class IgualdadCadenas
{
public static void Main()
{
string cadena1 = "José Antonio";
string cadena2 = String.Copy(cadena1);
Console.WriteLine(cadena1==cadena2);
}
}
El método Copy() de la clase String usado
devuelve una copia del objeto que se le pasa como parámetro.
Por tanto, al ser objetos diferentes se almacenarán en
posiciones distintas de memoria y al compararlos debería
devolverse false como pasa con cualquier tipo referencia.
Sin embargo, si ejecuta el programa verá que lo que se
obtiene es precisamente lo contrario: true. Esto se debe a
que para hacer para hacer más intuitivo el trabajo con
cadenas, en C# se ha modificado el operador de igualdad para que
cuando se aplique entre cadenas se considere que sus operandos son
iguales sólo si son lexicográficamente equivalentes y
no si referencian al mismo objeto en memoria. Además, esta
comparación se hace teniendo en cuenta la
capitalización usada, por lo que
"Hola"=="HOLA" ó
"Hola"=="hola" devolverán
false ya que contienen las mismas letras pero con distinta
capitalización.
Si se quisiese comparar cadenas por referencia habría que
optar por una de estas dos opciones: compararlas con
Object.ReferenceEquals() o convertirlas en objects y
luego compararlas con == Por ejemplo:
Console.WriteLine(Object.ReferecenceEquals(cadena1, cadena2));
Console.WriteLine( (object) cadena1 == (object) cadena2);
Ahora sí que lo que se comparan son las direcciones de los
objetos que representan a las cadenas en memoria, por lo que la
salida que se mostrará por pantalla es:
False
False
Hay que señalar una cosa, y es que aunque en principio el
siguiente código debería mostrar la misma salida por
pantalla que el anterior ya que las cadenas comparadas se
deberían corresponder a objetos que aunque sean
lexicográficamente equivalentes se almacenan en posiciones
diferentes en memoria:
using System;
public class IgualdadCadenas2
{
public static void Main()
{
string cadena1 = "José Antonio";
string cadena2 = "José Antonio";
Console.WriteLine(Object.ReferenceEquals(cadena1, cadena2));
Console.WriteLine( ((object) cadena1) == ((object) cadena2));
}
}
Si lo ejecutamos veremos que la salida obtenida es justamente la
contraria:
True
True
Esto se debe a que el compilador ha detectado que ambos literales
de cadena son lexicográficamente equivalentes y ha
decidido que para ahorra memoria lo mejor es almacenar en memoria
una única copia de la cadena que representan y hacer que
ambas variables apunten a esa copia común. Esto va a afectar
a la forma en que es posible manipular las cadenas como se
explicará más adelante.
Al igual que el significado del operador == ha sido
especialmente modificado para trabajar con cadenas, lo mismo ocurre
con el operador binario +. En este caso, cuando se aplica
entre dos cadenas o una cadena y un carácter lo que hace es
devolver una nueva cadena con el resultado de concatenar sus
operandos. Así por ejemplo, en el siguiente código
las dos variables creadas almacenarán la cadena Hola Mundo:
public class Concatenación
{
public static void Main()
{
string cadena = "Hola" + " Mundo";
string cadena2 = "Hola Mund" + 'o';
}
}
Por otro lado, el acceso a las cadenas se hace de manera similar a
como si de tablas de caracteres se tratase: su "campo"
Length almacenará el número de caracteres que
la forman y para acceder a sus elementos se utiliza el operador
[]. Por ejemplo, el siguiente código muestra por
pantalla cada carácter de la cadena Hola en una línea
diferente:
using System;
public class AccesoCadenas
{
public static void Main()
{
string cadena = "Hola";
Console.WriteLine(cadena[0]);
Console.WriteLine(cadena[1]);
Console.WriteLine(cadena[2]);
Console.WriteLine(cadena[3]);
}
}
Sin embargo, hay que señalar una diferencia importante
respecto a la forma en que se accede a las tablas: las cadenas son
inmutables, lo que significa que no es posible modificar los
caracteres que las forman. Esto se debe a que el compilador
comparte en memoria las referencias a literales de cadena
lexicográficamente equivalentes para así ahorrar
memoria, y si se permitiese modificarlos los cambios que se
hiciesen a través de una variable a una cadena compartida
afectarían al resto de variables que la compartan, lo que
podría causar errores difíciles de detectar. Por
tanto, hacer esto es incorrecto:
string cadena = "Hola";
cadena[0]="A"; //Error: No se pueden modificar las cadenas
Sin embargo, el hecho de que no se puedan modificar las cadenas no
significa que no se puedan cambiar los objetos almacenados en las
variables de tipo string. Por ejemplo, el siguiente
código es válido:
String cad = "Hola";
cad = "Adios"; // Correcto, pues no se modifica la cadena
// almacenada en cad sino que se hace que cad
// pase a almacenar otra cadena distinta..
Si se desea trabajar con cadenas modificables puede usarse
Sytem.Text.StringBuilder, que funciona de manera similar a
string pero permite la modificación de sus cadenas en
tanto que estas no se comparten en memoria. Para crear objetos de
este tipo basta pasar como parámetro de su constructor el
objeto string que contiene la cadena a representar mediante
un StringBuilder, y para convertir un StringBuilder
en String siempre puede usarse su método
ToString() heredado de System.Object. Por ejemplo:
using System.Text;
using System;
public class ModificaciónCadenas
{
public static void Main()
{
StringBuilder cadena = new StringBuilder("Pelas");
String cadenaInmutable;
cadena[0] = 'V';
Console.WriteLine(cadena); // Muestra Velas
cadenaInmutable = cadena.ToString();
Console.WriteLine(cadenaInmutable); // Muestra Velas
}
}
Aparte de los métodos ya vistos, en la clase
System.String se definen muchos otros métodos
aplicables a cualquier cadena y que permiten manipularla. Los
principales son:
Es preciso incidir en que aunque hayan métodos de
inserción, reemplazo o eliminación de caracteres que
puedan dar la sensación de que es posible modificar el
contenido de una cadena, en realidad las cadenas son inmutables y
dicho métodos lo que hacen es devolver una nueva cadena con
el contenido correspondiente a haber efectuado las operaciones de
modifiación solicitadas sobre la cadena a la que se aplican.
Por ello, las cadenas sobre las que se aplican quedan intactas como
muestra el siguiente ejemplo:
Using System;
public class EjemploInmutabilidad
{
public static void Main()
{
string cadena1="Hola";
string cadena2=cadena1.Remove(0,1);
Console.WriteLine(cadena1);
Console.WriteLine(cadena2);
}
}
La salida por pantalla de este ejemplo demuestra lo antes dicho,
pues es:
Hola
ola
Como se ve, tras el Remove() la cadena1 permance intacta y
el contenido de cadena2 es el que debería tener cadena1 si
se le hubiese eliminado su primer carácter.
Constantes
Una constante es una variable cuyo valor puede determinar el
compilador durante la compilación y puede aplicar
optimizaciones derivadas de ello. Para que esto sea posible se ha
de cumplir que el valor de una constante no pueda cambiar durante
la ejecución, por lo que el compilador informará con
un error de todo intento de modificar el valor inicial de una
constante. Las constantes se definen como variables normales pero
precediendo el nombre de su tipo del modificador const y
dándoles siempre un valor inicial al declararlas. O sea, con esta sintaxis:
const <tipoConstante> <nombreConstante> = <valor>;
Así, ejemplos de definición de constantes es el
siguiente:
const int a = 123;
const int b = a + 125;
Dadas estas definiciones de constantes, lo que hará el
compilador será sustituir en el código generado todas
las referencias a las constantes a y b por los valores 123 y 248
respectivamente, por lo que el código generado será
más eficiente ya que no incluirá el acceso y
cálculo de los valores de a y b. Nótese que puede
hacer esto porque en el código se indica
explícitamente cual es el valor que siempre tendrá a
y, al ser este un valor fijo, puede deducir cuál será
el valor que siempre tendrá b. Para que el compilador pueda
hacer estos cálculos se ha de cumplir que el valor que se
asigne a las constantes en su declaración sea una
expresión constante. Por ejemplo, el siguiente código
no es válido en tanto que el valor de x no es constante:
int x = 123; // x es una variable normal, no una constante
const int y = x +123; // Error: x no tiene porqué tener valor
// constante (aunque aquí lo tenga)
Debido a la necesidad de que el valor dado a una constante sea
precisamente constante, no tiene mucho sentido crear constantes de
tipos de datos no básicos, pues a no ser que valgan
null sus valores no se pueden determinar durante la
compilación sino únicamente tras la ejecución
de su constructor. La única excepción a esta regla
son los tipos enumerados, cuyos valores se pueden determinar al
compilar como se explicará cuando los veamos en el Tema
14: Enumeraciones
Todas las constantes son implícitamente estáticas,
por lo se considera erróneo incluir el modificador
static en su definición al no tener sentido hacerlo.
De hecho, para leer su valor desde códigos externos a la
definición de la clase donde esté definida la
constante, habrá que usar la sintaxis
<nombreClase>.<nombreConstante> típica de los
campos static.
Por último, hay que tener en cuenta que una variable
sólo puede ser definida como constante si se es una variable
local o un campo, pero no si es un parámetro.
Variables de sólo
lectura
Dado que hay ciertos casos en los que resulta interesante disponer
de la capacidad de sólo lectura que tienen las constantes
pero no es posible usarlas debido a las restricciones que hay
impuestas sobre su uso, en C# también se da la posibilidad
de definir variables que sólo puedan ser leídas. Para
ello se usa la siguiente sintaxis:
readonly <tipoConstante> <nombreConstante> = <valor>;
Estas variables superan la mayoría de las limitaciones de
las constantes. Por ejemplo:
-
No es obligatorio darles un valor al definirlas, sino que puede
dárseles en el constructor. Ahora bien, una vez dado un
valor a una variable readonly ya no es posible volverlo a
modificar. Si no se le da ningún valor ni en su
constructor ni en su definición tomará el valor
por defecto correspondiente a su tipo de dato.
-
No tienen porqué almacenar valores constantes, sino que
el valor que almacenen puede calcularse durante la
ejecución de la aplicación.
-
No tienen porqué definirse como estáticas, aunque
si se desea puede hacerse.
-
Su valor se determina durante la ejecución de la
aplicación, lo que permite la actualización de
códigos cliente sin necesidad de recompilar. Por ejemplo,
dado:
namespace Programa1
{
public class Utilidad
{
public static readonly int X = 1;
}
}
namespace Programa2
{
class Test
{
public static void Main() {
System.Console.WriteLine(Programa1.Utilidad.X);
}
}
}
En principio, la ejecución de este programa producirá
el valor 1. Sin embargo, si cada espacio de nombres se compilan en
módulos de código separados que luego se enlazan
dinámicamete y cambiamos el valor de X, sólo
tendremos que recompilar el módulo donde esté
definido Programa1.Utilidad y Programa2.Test podrá
ejecutarse usando el nuevo valor de X sin necesidad de recompilalo.
Sin embargo, pese a las ventajas que las variables de sólo
lectura ofrecen respecto a las constantes, tienen dos
inconvenientes respecto a éstas: sólo pueden
definirse como campos (no como variables locales) y con ellas no es
posible realizar las optimizaciones de código comentadas
para las constantes.
Orden de
inicialización de variables
Para deducir el orden en que se inicializarán las variables
de un tipo de dato basta saber cuál es el momento en que se
inicializa cada una y cuando se llama a los constructores:
-
Los campos estáticos sólo se
inicializan la primera vez que se accede al tipo al que
pertenecen, pero no en sucesivos accesos. Estos accesos pueden
ser tanto para crear objetos de dicho tipo como para acceder a
sus miembros estáticos. La inicialización se hace
de modo que en primer lugar se dé a cada variable el
valor por defecto correspondiente a su tipo, luego se dé
a cada una el valor inicial especificado al definirlas, y por
último se llame al constructor del tipo. Un constructor
de tipo es similar a un constructor normal sólo que en su
código únicamente puede accederse a miembros
static (se verá en el Tema 8:
Métodos)
-
Los campos no estáticos se inicializan cada vez
que se crea un objeto del tipo de dato al que pertenecen. La
inicialización se hace del mismo modo que en el caso de
los campos estáticos, y una vez terminada se pasa a
ejecutar el código del constructor especificado al crear
el objeto. En caso de que la creación del objeto sea el
primer acceso que se haga al tipo de dato del mismo, entonces
primero se inicializarán los campos estáticos y
luego los no estáticos.
-
Los parámetros se inicializan en cada llamada al
método al que pertenecen con los valores especificados al
llamarlo.
-
Las variables locales se inicializan en cada llamada al
método al cual pertenecen pero tras haberse inicializado
los parámetros definidos para el mismo. Si no se les da
valor inicial no toman ninguno por defecto,
considerándose erróneo todo acceso de lectura que
se haga a las mismas mientras no se les escriba algún
valor.
Hay que tener en cuenta que al definirse campos estáticos
pueden hacerse definiciones cíclicas en las que el valor de
unos campos dependa del de otros y el valor de los segundos dependa
del de los primeros. Por ejemplo:
class ReferenciasCruzadas
{
static int a = b + 1;
static int b = a + 1;
public static void Main()
{
System.Console.WriteLine("a = {0}, b = {1}", a, b);
}
}
Esto sólo es posible hacerlo al definir campos
estáticos y no entre campos no estáticas o variables
locales, ya que no se puede inicializar campos no estáticos
en función del valor de otros miembros no estáticos
del mismo objeto porque el objeto aún no estaría
incializados, y no se puede inicializar variables locales en
función del valor de otras variables locales definidas
más adelante porque no se pueden leer variables no
inicializadas. Además, aunque las constantes sean
implícitamente estáticas tampoco puede hacerse
definiciones cíclicas entre constantes.
En primer lugar, hay que señalar que escribir un
código como el del ejemplo anterior no es un buen
hábito de programación ya que dificulta
innecesariamente la legibilidad del programa. Aún
así, C# admite este tipo de códigos y para determinar
el valor con que se inicializarán basta tener en cuenta que
siempre se inicializan primero todos los campos con sus valores por
defecto y luego se inicializan aquellos que tengan valores
iniciales con dichos valores iniciales y en el mismo orden en que
aparezcan en el código fuente. De este modo, la salida del
programa de ejemplo anterior será:
a = 1, b = 2
Nótese que lo que se ha hecho es inicializar primero a y b
con sus valores por defecto (0 en este caso), luego calcular el
valor final de a y luego calcular el valor final de b. Como b vale
0 cuando se calcula el valor final de a, entonces el valor final de
a es 1; y como a vale 1 cuando se calcula el valor final de b,
entonces el valor final de b es 2.