Tutorial de Visual C#
Tutorial de Visual C#
con C#
PRIMERA APROXIMACIÓN A C#
Antes de nada, quiero que sepas que hasta ahora soy programador de Visual Basic, y
la curiosidad me ha llevado a interesarme por el nuevo C#, de modo que, básicamente, me vas
a acompañar durante todo mi proceso de aprendizaje. No es que vaya a escribir cosas sin estar
seguro de ellas, estoy bien documentado, sino que puede que encuentres algo de código que,
con el tiempo, te des cuenta de que se podía haber mejorado.
Te diré que, poco a poco, C# ha ido superando con creces todas mis expectativas: es
un lenguaje moderno, potente, flexible y orientado a objetos. No te puedo decir nada
comparándolo con Java ni con C++, porque, básicamente, tengo muy poquita idea de cómo
son estos lenguajes. No obstante, sí te puedo decir que, en una de mis muchas incursiones por
la web en busca de información sobre este lenguaje encontré el siguiente párrafo:
Por lo poco que yo sé sobre Java y C++, y lo que he leído en diversa documentación,
creo que esta descripción se ajusta bastante a la realidad. Lo que sí te puedo asegurar con
toda certeza es que C# combina la rapidez de desarrollo de Visual Basic con la enorme
capacidad bruta de C++.
Bien, vamos allá. Si conoces bien la programación orientada a objetos, puedes pasar
adelante. De lo contrario te recomiendo que hagas una lectura lenta y cuidadosa de lo que
viene a continuación, pues es básico para después comprender cómo funciona el lenguaje C#.
Los conceptos están ilustrados con código de C#. Si no entiendes dicho código no desesperes,
ya que el objetivo de esta introducción es que comprendas dichos conceptos, y no el código.
La programación orientada a objetos es algo más que “el último grito en programación”.
No se trata de una moda, sino de un modo de trabajo más natural, que te permite centrarte en
solucionar el problema que tienes que resolver en lugar de tener que andar pensando en cómo
le digo al ordenador que haga esto o lo otro. Si alguna vez utilizaste algún lenguaje de los del
“año la polca” me comprenderás enseguida. El 90% del código estaba dedicado a comunicarte
con el ordenador (que si diseñar la pantalla, que si reservar memoria, que si el monitor me
aguanta esta resolución...), y el otro 10% a resolver el problema. Ya no digamos si alguna vez
has hecho, o intentado, algún programa para Windows usando C en bajo nivel. La
programación orientada a objetos (POO en adelante) te abstrae de muchas de estas
preocupaciones para que puedas dedicarte a escribir realmente el código útil, es decir, resolver
el problema y ya está. Veamos un ejemplo muy claro de lo que quiero decir:
Imagina hacer un programa que mantenga una base de datos de personas. Simple y
llanamente. ¿Cómo era esto antes? ¡JA! ¡JAJA! Recoge los datos, abre el archivo, define la
longitud del registro, define la longitud y el tipo de cada campo, pon cada campo en su sitio,
guarda el registro en el lugar del archivo donde le corresponde y cierra el archivo. Después,
para una búsqueda, recoge los datos a buscar, abre el archivo, busca los datos, cierra el
archivo, presenta los resultados. Si además permites modificaciones, recoge los nuevos datos,
vuelve a abrir el archivo, guarda los datos modificados en el registro que le corresponde, cierra
el archivo... Pesado, ¿eh? Ciertamente. La mayor parte del tiempo la dedicábamos a
comunicarnos con el ordenador. ¿Cómo sería esto con un lenguaje orientado a objetos, como
C#? Mucho más sencillo. Tenemos un objeto Persona. Para agregar un registro, sencillamente
habría que dar los valores a dicho objeto y decirle que los guarde. Ya está. Nos da igual cómo
haga el objeto Persona para guardar. Veámoslo:
Persona.Nombre = Pepe
Persona.Apellido = Pepe (otra vez, hala)
Persona.Dirección = la dirección que sea
Persona.Guardar
Persona.Buscar(Manolo)
Clases y objetos
Ya hemos visto algunas de las principales ventajas de la POO. Vamos a entrar ahora
en más detalles: qué son las clases, qué son los objetos y en qué se diferencian.
A menudo es fácil confundir ambos términos. ¿Ambas cosas son iguales? No, ni mucho
menos, aunque están íntimamente relacionados. Para que pueda haber un objeto debe existir
previamente una clase, pero no al revés. Me explico: la clase es la "plantilla" en la que nos
basamos para crear el objeto. Volvamos al ejemplo del coche: todos ellos tienen una serie de
características comunes: todos tienen un motor, ruedas, un volante, pedales, chasis,
carrocería...; todos funcionan de un modo parecido para acelerar, frenar, meter las marchas,
dar las luces...; sin embargo, cada uno de ellos es diferente de los demás, puesto que cada uno
es de su marca, modelo, color, número de bastidor..., propiedades que lo diferencian de los
demás, aunque una o varias de ellas puedan coincidir en varios coches. Diríamos entonces que
todos los coches están basados en una plantilla, o un tipo de objeto, es decir, pertenecen todos
a la misma clase: la clase coche. Sin embargo, cada uno de los coches es un objeto de esa
clase: todos comparten la "interfaz", pero no tienen por qué compartir los datos (marca, modelo,
color, etc). Se dice entonces que cada uno de los objetos es una instancia de la clase a la que
pertenece, es decir, un objeto. En resumen, la clase es algo genérico (la idea que todos
tenemos sobre lo que es un coche) y el objeto es algo mucho más concreto (el coche del
vecino, el nuestro, el papamóvil...). Veamos cómo sería esto en C#. El diseño de la clase
Coche sería algo parecido a esto (aunque más ampliado):
class Coche
{
public Coche(string marca, string modelo, string color, string
numbastidor)
{
this.Marca=marca;
this.Modelo=modelo;
this.Color=color;
this.NumBastidor=numbastidor;
}
Veamos una clase con un método Main para ver cómo se comportaría esta clase:
class EjemploCocheApp
{
static void Main()
{
Coche MiCoche=new Coche("Peugeot", "306", "Azul","1546876");
MiCoche.Acelerar(100);
Console.WriteLine("La velocidad actual es de {0}
km/h",MiCoche.Velocidad);
MiCoche.Frenar(75);
Console.WriteLine("La velocidad actual es de {0}
km/h",MiCoche.Velocidad);
MiCoche.Girar(45);
}
}
¿Qué es eso del encapsulamiento? Podríamos definirlo como la capacidad que tienen
los objetos de ocultar su código al cliente y proteger sus datos, ofreciendo única y
exclusivamente una interfaz que garantiza que el uso del objeto es el adecuado.
La ocultación del código es algo evidente: cuando se invoca el método Acelerar del
objeto MiCoche, lo único que sabemos es que el coche acelerará, pero el cómo lo haga es algo
que no podremos ver desde el cliente. En cuanto a la protección de datos, fíjate también en un
detalle del ejemplo: no podríamos modificar directamente el valor de la propiedad Velocidad,
dado que está definida como propiedad de sólo lectura. La única forma de modificar su valor
sería invocar los métodos Acelerar y/o Frenar. Esta importante característica asegura que los
datos de los objetos pertenecientes a esta clase se van a manejar del modo adecuado.
Otro de los pilares básicos de la POO es la herencia. Gracias a ella podemos definir
clases nuevas basadas en clases antiguas, añadiéndoles más datos o más funcionalidad. Para
ver esto más claro sigamos con el ejemplo del coche. Imaginemos que la clase Coche ofrece
una interfaz básica para cualquier tipo de coche. Sin embargo queremos un coche que,
además de todo lo que tienen los demás coches, es capaz de aparcar él solito, sin necesidad
de que nosotros andemos haciendo maniobras. ¿Tendríamos que definir otra clase para
incorporar esta nueva capacidad? Pues no. Podemos heredar todos los miembros de la clase
Coche y después agregarle lo que deseemos en la nueva clase:
class CocheAparcador:Coche
{
public CocheAparcador(string marca, string modelo, string color,
string numbastidor): base(marca, modelo, color, numbastidor) {}
¿Qué ha pasado? ¿Dónde están todos los demás miembros de la clase? Aunque
parezca mentira, están. La clase CocheAparcador ha heredado todos los miembros de su clase
base (Coche). Lo único que ha añadido ha sido el método Aparcar, de modo que cualquier
objeto de la clase CocheAparcador (ojo, no de la clase Coche) tendrá todos los miembros de la
clase Coche más el método Aparcar incorporado en la clase derivada CocheAparcador. ¿Y
cómo se instancian objetos de una clase derivada? Pues exactamente igual que si se
instanciara de cualquier otra clase. Veámoslo con el ejemplo anterior, modificando ligeramente
el método Main:
class EjemploCocheApp
{
static void Main()
{
CocheAparcador MiCoche=new CocheAparcador("Peugeot", "306",
"Azul","1546876");
MiCoche.Acelerar(100);
Console.WriteLine("La velocidad actual es de {0}
km/h",MiCoche.Velocidad);
MiCoche.Frenar(75);
Console.WriteLine("La velocidad actual es de {0}
km/h",MiCoche.Velocidad);
MiCoche.Girar(45);
MiCoche.Aparcar();
string a=Console.ReadLine();
}
}
Ahora, el objeto MiCoche tiene los mismos miembros que tenía cuando era de la clase
Coche más el método Aparcar implementado por la clase derivada CocheAparcador.
Y entonces, ¿podría construir clases más complejas a partir de otras clases más
sencillas? Hombre, este es el objetivo principal de la herencia. No obstante, C# soporta la
herencia simple, pero no la herencia múltiple. Por lo tanto, en C# podemos construir una clase
derivada a partir de otra clase, pero no de varias clases. Sobre este aspecto, lo ideal para
construir una clase coche hubiera sido construir clases más sencillas (ruedas, motor, chasis,
carrocería, volante, ...), y después construir la clase coche derivándola de todas ellas:
Sin embargo ya digo que esto no es posible en C#. Una clase puede derivarse de otra,
pero no de varias. Sí se puede derivar una clase de otra clase y varias interfaces, pero de esto
hablaremos más adelante, cuando tratemos las interfaces.
El polimorfismo es otra de las maravillas que incorpora la POO. ¿Qué ocurre si,
siguiendo con el manido ejemplo de los coches, cada coche ha de comportarse de un modo
diferente dependiendo de su marca, esto es, si es un Peugeot, por ejemplo, el acelerador
acciona un cable, pero si es un Volkswagen, el acelerador acciona un mecanismo electrónico?.
Bien, alguien acostumbrado a la programación procedimental dirá: "Eso está chupao. Basta
con un Switch". Bien, veámoslo:
class Coche
{
public Coche(string marca, string modelo, string color, string
numbastidor)
{
this.Marca=marca;
this.Modelo=modelo;
this.Color=color;
this.NumBastidor=numbastidor;
}
¡Muy bien! ¿Y si aparece una marca nueva con un mecanismo diferente, machote?
-Estoooo, bueno... pueees... se añade al switch y ya está.- ¡Buena respuesta! Entonces, habría
que buscar el código fuente de la clase Coche, y hacer las modificaciones oportunas, ¿no?
-Pues sí, claro- Bien. Imagínate ahora que la clase Coche no es una clase en programación,
sino una clase de verdad, o sea, coches de verdad. Si se crea un nuevo sistema de
aceleración, ¿tienen que buscar el manual de reparación del coche, modificarlo para
contemplar el nuevo sistema y después redistribuirlo otra vez todo entero a todo el mundo?
Claro que no. Lo que se hace es, simplemente, escribir un nuevo manual únicamente con las
innovaciones y distribuir esta parte a aquellos que lo vayan a necesitar para que se añada a lo
que ya existe, ni más ni menos. Pues esto es, más o menos, lo que proporciona el polimorfismo
en la POO. No es necesario modificar el código de la clase original. Si esta está bien diseñada,
basta con derivar otra clase de la original y modificar el comportamiento de los métodos
necesarios. Claro, para esto la clase Coche debería estar bien construida. Algo como esto:
class Coche
{
public Coche(string marca, string modelo, string color, string
numbastidor)
{
this.Marca=marca;
this.Modelo=modelo;
this.Color=color;
this.NumBastidor=numbastidor;
}
class CocheAceleradorAvanzado:Coche
{
public CocheAceleradorAvanzado(string marca, string modelo, string
color, string numbastidor): base(marca, modelo, color, numbastidor) {}
Ya está. La clase base queda intacta, es decir, no hay que modificar absolutamente
nada. La clase derivada únicamente sobrescribe aquello que no le sirve de la clase base, que
es en este caso el método acelerar. Fíjate que para poder hacerlo hemos puesto la palabra
override en la declaración del método. Pero puede que alguno piense: "Vamos a ver si yo me
aclaro. En ese caso, en la clase derivada habría dos métodos Acelerar: uno el derivado y otro
el sobrescrito que, además, tienen los mismos argumentos. ¿Cómo sabrá el compilador cuál de
ellos ha de ejecutar?" El compilador siempre ejecuta el método sobrescrito si el objeto
pertenece a la clase derivada que lo sobrescribe. Es como si eliminara completamente el
método virtual de la clase derivada, sustituyéndolo por el nuevo. Veamos un ejemplo:
CocheAceleradorAvanzado MiCoche;
...
MiCoche = new CocheAceleradorAvanzado("Peugeot", "306", "Azul",
"54668742635");
MiCoche.Acelerar(100);
En este caso, está muy claro. El objeto MiCoche está declarado como un objeto de la
clase CocheAceleradorAvanzado, de modo que al ejecutar el método acelerar se ejecutará sin
problemas el método de la clase derivada. Por lo tanto, la salida por pantalla de este fragmento
sería:
Coche MiCoche;
...
MiCoche = new CocheAceleradorAvanzado("Peugeot", "306", "Azul",
"54668742635");
MiCoche.Acelerar(100);
Bueno, creo que ya está bien de conceptos. Aunque parezca mentira, hoy has dado un
paso crucial para entender y aprender a utilizar este nuevo lenguaje, dado que en C# todo,
hasta los tipos de datos de toda la vida, son objetos (bueno, todo, lo que se dice todo, no: los
punteros no son objetos, pero hablaremos de ellos cuando lleguemos al código inseguro... todo
se andará). Sigue conmigo.
Bueno, lamento tener que comunicarte que todavía no podemos empezar con el
lenguaje C# propiamente dicho (ya me gustaría, ya). Antes quiero comentarte un poco
cómo funciona todo esto, más que nada para que te hagas una idea clara de cómo
funcionará un programa hecho en C#.
EL MUNDO .NET
La biblioteca de clases del .NET Framework es, como su propio nombre indica,
una biblioteca de clases... (Vaya descubrimiento, ¿eh?) Vaaaale... me explicaré mejor.
Si has programado en C++ conocerás la MFC (Microsoft Foundation Classes), o la
OWL de Borland (Object Windows Library). Si no eres programador de C++ y/o no las
conoces, pues me has hecho la puñeta... A volver a empezar. Veamos, la biblioteca de
clases del .NET Framework te ofrece un conjunto de clases base común para todos los
lenguajes de código gestionado. O sea, si, por ejemplo, quieres escribir algo en la
pantalla, en Visual Basic sería así:
Console.WriteLine(“Algo”)
En C# sería así:
Console.WriteLine(“Algo”);
Console::WriteLine(“Algo”);
Como ves, es igual (o casi igual) en todos los lenguajes. En C++ hay que
poner :: en lugar de un punto por una cuestión meramente sintáctica propia de este
lenguaje para separar el nombre de una clase de uno de sus miembros. Ojo, no quiero
decir que todos los lenguajes sean iguales, no, sino que todos usan la misma biblioteca
de clases o, dicho de otro modo, todos usan las mismas clases de base. Ya sé, ya sé:
ahora estaréis pensando que, si esto es así, todos los lenguajes tienen la misma
capacidad. Bueno, pues es cierto, aunque sólo relativamente, pues no todos los
lenguajes implementan toda la biblioteca de clases completa, ya que basta con que el
compilador se ajuste a los mínimos que exige el CLS.
Por otro lado, la compilación de un programa gestionado por el CLR no se hace
directamente a código nativo, sino a un lenguaje, más o menos como el ensamblador,
llamado MSIL (Microsoft Intermediate Language). Después es el CLR el que va
compilando el código MSIL a código nativo usando lo que se llaman los compiladores
JIT (Just In Time). Ojo, no se compila todo el programa de golpe, sino que se van
compilando los métodos según estos se van invocando, y los métodos compilados
quedan en la caché del ordenador para no tener que compilarlos de nuevo si se vuelven
a usar. Hay tres tipos de JIT, pero ya los trataremos más adelante, pues creo que será
más oportuno. ¿Se trata entonces de lenguajes compilados o interpretados? Pues me has
“pillao”, porque no sabría qué decirte. No es interpretado porque no se enlaza línea por
línea, y no es compilado porque no se enlaza todo completo en el momento de ejecutar.
Llámalo x.
BASES SINTÁCTICAS DE C#
Ahora sí, ahora por fin empezamos a ver algo de C#. Agárrate bien, que
despegamos.
int a;
int* pA;
No obstante, hay que prestar atención especial a que, aunque un código sea
sintácticamente idéntico, semánticamente puede ser muy distinto, es decir: mientras en
C la una variable de tipo int es eso y nada más, en C# una variable de tipo int es en
realidad un objeto de la clase System.Int32 (ya dijimos en la introducción que en C#
todo es un objeto salvo los punteros). En resumen, las diferencias más importantes entre
C y C# no suelen ser sintácticas sino sobre todo semánticas.
Bien, una vez aclarado todo esto, podemos seguir adelante. Primera premisa: en
C# todas las instrucciones y declaraciones deben terminar con ; (punto y coma),
salvo que haya que abrir un bloque de código. Si programas en Pascal o Modula2
dirás: “Hombre, claro”, pero si programas en Visual Basic no te olvides del punto y
coma, pecadorrrrrr. ¿Por qué? Porque, al contrario que en Visual Basic, aquí puedes
poner una instrucción que sea muy larga en varias líneas sin poner ningún tipo de signo
especial al final de cada una. Es cuestión de cambiar el chip. Fíjate en esta simulación:
El compilador entiende que todo forma parte de la misma instrucción hasta que
encuentre un punto y coma.
En este caso, los bloques de código están muy claramente delimitados por las
llaves, pero como puedes apreciar, ambos bloques están delimitados del mismo modo,
es decir, ambos se delimitan con llaves. Además, fíjate en que detrás de la línea en que
se declara el método no está escrito el punto y coma, igual que en el if, lo cual quiere
decir que la llave de apertura del bloque correspondiente se podía haber escrito a
continuación, y no en la línea siguiente. Según está escrito, es fácil determinar cuáles
son las llaves de apertura y cierre de un bloque y cuáles las del otro. Sin embargo, si
hubiésemos quitado las tabulaciones y colocado la llave de apertura en la misma línea,
esto se habría complicado algo:
Si, además, dentro de este método hubiera tres bucles for anidados, un switch,
dos bucles While y cuatro o cinco if, unos dentro de otros, con algún que otro else y else
if, pues la cosa se puede convertir en un galimatías de dimensiones olímpicas. De ahí la
importancia de tabular correctamente el código en todos los lenguajes, pero
especialmente en los lenguajes basados en C, como el propio C, C++, Java y C#, ya que
así será fácil ver dónde empieza y dónde termina un bloque de código. Digo esto
porque, a pesar de que Visual Studio.NET pone todas las tabulaciones de modo
automático, siempre puede haber alguno que las quite porque no le parezcan útiles. ¡NO
QUITES LAS TABULACIONES! ¿Cómo? ¿Que podría haber abreviado mucho el
código en este ejemplo? Sí, ya lo sé. Pero entonces no habríamos visto bien lo de los
bloques. Un poco de paciencia, hombre...
Para terminar, puedes poner los comentarios a tu código de dos formas: // indica
que es un comentario de una sola línea. /* ... comentario ... */ es un comentario de una o
varias líneas. Observa el ejemplo:
namespace NISUNavegacionEspacial
{
// Aquí van las clases del espacio de nombres
}
Por otro lado, ten presente que dentro de un mismo proyecto podemos definir
tantos espacios de nombres como necesitemos.
NISUNavegacionEspacial.Propulsion.Combustibles.JP8 objeto =
new
NISUNavegacionEspacial.Propulsion.Combustibles.JP8
(argumentos);
Ciertamente, escribir chorizos tan largos sólo para decir que quieres usar la clase
JP8 puede resultar muy incómodo. Para situaciones como esta C# incorpora la directiva
using. Para que os hagáis una idea, sería como cuando poníamos PATH = lista de rutas
en nuestro viejo y querido MS-DOS. ¿Qué ocurría? Pues cuando escribíamos el nombre
de un archivo ejecutable primero lo buscaba en el directorio donde estábamos
posicionados. Si no lo encontraba aquí revisaba todas las rutas que se habían asignado al
PATH. Si lo encontraba en alguna de estas rutas lo ejecutaba directamente, y si no lo
encontraba nos saltaba un mensaje de error ¿Os acordáis del mensaje de error?
“Comando o nombre de archivo incorrecto” (je je, qué tiempos aquellos...) Bueno, a lo
que vamos, no me voy a poner nostálgico ahora... Básicamente, eso mismo hace la
directiva using con los espacios de nombres: si utilizamos un nombre que no se
encuentra en el espacio de nombres donde lo queremos usar, el compilador revisará
todos aquellos que se hayan especificado con la directiva using. Si lo encuentra, pues
qué bien, y si no lo encuentra nos lanza un mensaje de error. Qué te parece, tanto
Windows, tanto .NET, tanta nueva tecnología... ¡y resulta que seguimos como en el
DOS! Fuera de bromas, quiero recalcar que no equivale a la directiva #include de C,
ni mucho menos. La directiva #include significaba que íbamos a usar funciones de un
determinado archivo de cabecera. Si no se ponía, las funciones de dicho archivo,
simplemente, no estaban disponibles. Sin embargo podemos usar cualquier miembro de
los espacios de nombres sin necesidad de poner ninguna directiva using. Espero que
haya quedado claro. Vamos con un ejemplo. Lo que habíamos puesto antes se podría
haber hecho también de esta otra forma:
using NISUNavegacionEspacial.Propulsion.Combustibles;
...
JP8 objeto = new JP8 (argumentos);
using System.Console;
namespace Espacio1
{
...
WriteLine(“Hola”);
...
}
namespace Espacio2
{
...
WriteLine(“Hola otra vez”)
...
}
o bien
namespace Espacio1
{
using System.Console;
...
WriteLine(“Hola”);
...
}
namespace Espacio2
{
...
WriteLine(“Hola otra vez”) // Aquí saltaría un error.
using solo es efectivo para Espacio1
...
}
¿Y qué pasa si tengo dos clases que se llaman igual en distintos espacios de
nombres? ¿No puedo poner using para abreviar? En este caso, lo mejor sería utilizar los
alias, los cuales se definen tambén con using:
...
NISU.ModuloLunar modulo = new NISU.ModuloLunar();
IBM.ModuloLunar modulo2 = new IBM.ModuloLunar();
...
Para terminar ya con esto, que sepas que puedes poner tantas directivas using
como estimes oportunas siempre que cumplas las reglas de colocación de las mismas.
class NombreClase
{
// Aquí se codifican los miembros de la clase
}
Como puedes apreciar, es muy simple. Basta con poner la palabra class seguida
del nombre de la clase y, a continuación, poner el signo de apertura de bloque "{" para
empezar a codificar sus miembros. El fin de la clase se marca con el signo de cierre de
bloque "}". Pero, claro, no todas las clases tienen por qué ser igualmente accesibles
desde otra aplicación. Me explico: puede que necesites una clase que sólo se pueda usar
por código que pertenezca al mismo ensamblado. En este caso, bastaría con poner el
modificador de acceso internal delante de la palabra class o bien no poner nada, pues
private es el modificador de acceso por defecto para las clases:
Nota (25/Oct/07):
En C#, el modificador por defecto de las clases es private, no internal
como se indica en este artículo.
Para más info: Ámbitos predeterminados (si no se indica).
Si lo que quieres es una clase que sea accesible desde otros ensamblados,
necesitarás que sea pública, usando el modificador de acceso public:
class GestorClientesApp
{
public static void Main()
{
string Nombre; // Declaración de la variable nombre, que es de
tipo string
Console.Write("¿Cómo se llama el cliente? ");
Nombre = Console.ReadLine();
Console.WriteLine("Mi cliente se llama {0}", Nombre);
}
}
Date cuenta que para que el programa nos pueda decir cómo se llama el cliente
no hemos usado el nombre literal (Antonio), ni la posición de memoria donde estaba
este dato, sino simplemente hemos usado el indicador variable que habíamos definido
para este propósito. De aquí en adelante, cuando hable de variables me estaré refiriendo
a este tipo de indicadores.
int num=10;
using System;
namespace Circunferencia1
{
class CircunferenciaApp
{
public static void Main()
{
const double PI=3.1415926; // Esto es una
constante
double Radio=4; // Esto es una variable
Los indicadores, al igual que las clases, también tienen modificadores de acceso.
Si se pone, ha de colocarse en primer lugar. Si no se pone, el compilador entenderá que
es private. Dichos modificadores son:
MODIFICADOR COMPORTAMIENTO
public Hace que el indicador sea accesible desde otras clases.
protected Hace que el indicador sea accesible desde otras clases derivadas
de aquella en la que está declarado, pero no desde el cliente
private Hace que el indicador solo sea accesible desde la clase donde
está declarado. Este es el modificador de acceso por omisión.
internal Hace que el indicador solo sea accesible por los miembros del
ensamblaje actual.
EL SISTEMA DE TIPOS DE C#
Entonces, si los tipos valor se van a tratar como tipos primitivos, ¿para qué se
han liado tanto la manta a la cabeza? Pues porque una variable de un tipo valor
funcionará como un tipo primitivo siempre que sea necesario, pero podrá funcionar
también como un tipo referencia, es decir como un objeto, cuando se necesite que sea
un objeto. Un ejemplo claro sería un método que necesite aceptar un argumento de
cualquier tipo: en este caso bastaría con que dicho argumento fuera de la clase object; el
método manejará el valor como si fuera un objeto, pero si le hemos pasado un valor int,
este ocupa únicamente 32 bits en la pila. Hacer esto en otros lenguajes, como Java, es
imposible, dado que los tipos primitivos en Java no son objetos.
Aquí tienes la tabla de los tipos que puedes manejar en C# (mejor dicho, en
todos los lenguajes basados en el CLS), con su equivalente en el CTS (Common Type
System).
System.Int16 short Enteros de 2 bytes con signo Desde -32.768 hasta 32.767
Desde
Enteros de 8 bytes con -9.223.372.036.854.775.808
System.Int64 long
signo hasta
9.223.372.036.854.775.807
Caracteres Unicode de 2
System.Char char Desde 0 hasta 65.535
bytes
Aunque ya lo has visto antes, aún no lo hemos explicado: para declarar una
variable de uno de estos tipos en C# hay que colocar primero el tipo del CTS o bien el
alias que le corresponde en C#, después el nombre de la variable y después,
opcionalmente, asignarle su valor:
System.Int32 num=10;
int num=10;
En cualquier caso, y para cualquier lenguaje que cumpla las especificaciones del
CLS, los tipos son los mismos.
Todos los tipos son necesarios en aras de una mayor eficiencia. Realmente,
podríamos ahorrarnos todos los tipos numéricos y quedarnos, por ejemplo, con el tipo
Decimal, pero si hacemos esto cualquier número que quisiéramos meter en una variable
ocuparía 16 bytes de memoria, lo cual supone un enorme desperdicio y un excesivo
consumo de recursos que, por otra parte, es absolutamente innecesario. Si sabemos que
el valor de una variable va a ser siempre entero y no va a exceder de, por ejemplo,
10.000, nos bastaría un valor de tipo short, y si el valor va a ser siempre positivo, nos
sobra con un tipo ushort, ya que estos ocupan únicamente 2 bytes de memoria, en lugar
de 16 como las variables de tipo decimal. Por lo tanto, no es lo mismo el número 100 en
una variable de tipo short que en una de otro tipo, porque cada uno consume una
cantidad diferente de memoria. En resumen: hay que ajustar lo máximo posible el
tipo de las variables a los posibles valores que estas vayan a almacenar. Meter
valores pequeños en variables con mucha capacidad es como usar un almacén de 200
metros cuadrados sólo para guardar una pluma. ¿Para qué, si basta con un pequeño
estuche? Para asignar un valor numérico a una variable numérica basta con igualarla a
dicho valor:
int num=10;
Un tipo que admite valores de coma flotante admite valores con un número de
decimales que no está fijado previamente, es decir, números enteros, o con un decimal,
o con dos, o con diez... Por eso se dice que la coma es flotante, porque no está siempre
en la misma posición con respecto al número de decimales (el separador decimal en el
código siempre es el punto).
double num=10.75;
double num=10.7508;
¿Y las fechas? Para las fechas también hay una clase, aunque en C# no hay
ningún alias para estos datos. Es la clase System.DateTime:
System.DateTime fecha;
Una vez conocido todo esto, es importante también hablar de las conversiones.
A menudo necesitarás efectuar operaciones matemáticas con variables de distintos tipos.
Por ejemplo, puede que necesites sumar una variable de tipo int con otra de tipo double
e introducir el valor en una variable de tipo decimal. Para poder hacer esto necesitas
convertir los tipos. Pues bien, para convertir una expresión a un tipo definido basta con
poner delante de la misma el nombre del tipo entre paréntesis. Por ejemplo, (int) 10.78
devolvería 10, es decir, 10.78 como tipo int. Si ponemos (int) 4.5 * 3 el resultado sería
12, ya que (int) afecta únicamente al valor 4.5, de modo que lo convierte en 4 y después
lo multiplica por 3. Si, por el contrario, usamos la expresión (int) (4.5 * 3), el resultado
sería 13, ya que en primer lugar hace la multiplicación que está dentro del paréntesis
(cuyo resultado es 13.5) y después convierte ese valor en un tipo int. Hay que tener un
cuidado especial con las conversiones: no podemos convertir lo que nos de la gana en lo
que nos apetezca, ya que algunas conversiones no son válidas: por ejemplo, no podemos
convertir una cadena en un tipo numérico:
Para este caso necesitaríamos hacer uso de los métodos de conversión que
proporcionan cada una de las clases del .NET Framework para los distintos tipos de
datos:
Bueno, creo que ya podemos dar por concluido el sistema de tipos. Menudo
testamento...
Los operadores sirven, como su propio nombre indica, para efectuar operaciones
con uno o más parámetros (sumar, restar, comparar...) y retornar un resultado. Se
pueden agrupar de varios modos, pero yo te los voy a agrupar por primarios, unitarios y
binarios. Aquí tienes una tabla con los operadores de C#, y luego te los explico todos
con calma:
Operadores Descripción Tipo Asociatividad
(expresión)
objeto.miembro Control de precedencia Primario Ninguna
método(argumento, argumento, Acceso a miembro de objeto Primario Ninguna
...) Enumeración de argumentos Primario Ninguna
array[indice] Elemento de un array Primario Ninguna
var++, var-- Postincremento y postdecremento Primario Ninguna
new Creación de objeto Primario Ninguna
typeof Recuperación de tipo (reflexión) Primario Ninguna
sizeof Recuperación de tamaño Primario Ninguna
checked, unchecked Comprobación de desbordamiento Primario Ninguna
+ Operando en forma original Unitario Ninguna
- Cambio de signo Unitario Ninguna
! Not lógico Unitario Ninguna
~ Complemento bit a bit Unitario Ninguna
++var, --var Preincremente y predecremento Unitario Ninguna
(conversión) var Conversión de tipos Unitario Ninguna
*, / Multiplicación, división Binario Izquierda
% Resto de división Binario Izquierda
+, - Suma, resta Binario Izquierda
<<, >> Desplazamiento de bits Binario Izquierda
<, >, <=, >=, is, ==, != Relacionales Binario Izquierda
& AND a nivel de bits Binario Izquierda
^ XOR a nivel de bits Binario Izquierda
| OR a nivel de bits Binario Izquierda
&& AND lógico Binario Izquierda
|| OR lógico Binario Izquierda
?: QUESTION Binario Izquierda
=, *=, /=, %=, +=, -=, <<=, De asignación Binario Derecha
>>=, &=, ^=, |=
num++;
hará que num incremente su valor en una unidad, es decir, si valía 10 ahora vale
11. Los operadores de incremento y decremento se pueden poner delante
(preincremento ó predecremento) o bien detrás (postincremento ó postdecremento),
teniendo comportamientos distintos. Me explico: si hay un postincremento o
postdecremento, primero se toma el valor de la variable y después se incrementa o
decrementa. En caso contrario, si lo que hay es un preincremento o un predecremento,
primero se incrementa o decrementa la variable y después se toma el valor de la misma.
En una línea como la anterior esto no se ve claro, porque, además, el resultado sería el
mismo que poniendo ++num. Sin embargo, veamos este otro ejemplo (num vale 10):
a = ++num;
b = a--;
Después de ejecutar la primera línea, tanto a como num valdrían 11, ya que el
preincremento hace que primero se incremente num y después se tome su valor,
asignándolo así a la variable a. Ahora bien, después de ejecutar la segunda línea, b
valdrá 11, y a valdrá 10. ¿Por qué? Porque el postdecremento de a hace que primero se
asigne su valor actual a b y después se decremente el suyo propio.
El operador “new” sirve para instanciar objetos. A estas alturas nos hemos
hartado de verlo, y como vamos a seguir hartándonos no me voy a enrollar más con él.
byte i=253;
checked {i+=10;}
Console.WriteLine(i);
byte i=253;
unchecked {i+=10;}
Console.WriteLine(i);
El programa no produciría error de desbordamiento, ya que unchecked hace que
se omitan estos errores. En su lugar, i toma el valor truncado, de modo que después de
esa línea valdría 7. Un asunto importante en el que quiero que te fijes: checked y
unchecked son bloques de código, como se deduce al ver que está escrito con llaves,
de modo que puedes incluir varias líneas en un mismo bloque checked o unchecked.
Sí, sí, ya sé que no entiendes eso del valor truncado. Veamos: las variables
numéricas tienen un rango de datos limitado, es decir, una variable de tipo byte, por
ejemplo, no puede almacenar valores menores que 0 ni mayores que 255. Cuando se
trunca el valor, lo que se hace es, para que me entiendas, colocar seguidos en una lista
todos los valores que la variable acepta, empezando de nuevo por el primero cuando el
rango acaba, y después se va recorriendo esta lista hasta terminar la operación de suma.
Como dicho así resulta bastante ininteligible, fíjate en la siguiente tabla y lo verás
enseguida:
sbyte i=126;
unchecked {i+=10;}
Console.WriteLine(i);
... 125 126 127 -128 -127 -126 -125 -124 -123 -122 -121 -120 -119 -118 -117 ...
(126 + 10) +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 ...
int i=10;
int b=-i;
Console.WriteLine("Valor de i: {0} Valor de b: {1}", i,
b);
bool i=true;
bool b=!i;
uint i=10;
Console.WriteLine("Valor de i: {0:X8} Valor de ~i:
{1:X8}", i, ~i);
int i=15;
int b;
int c;
Console.WriteLine("Valor de i: {0:X}", i);
b = i >> 1;
Console.WriteLine("Ejecutado b = i >> 1;");
Console.WriteLine("Valor de b: {0:X}", b);
c = i << 1;
Console.WriteLine("Ejecutado c = i << 1;");
Console.WriteLine("Valor de c: {0:X}", c);
Valor de i: F
Ejecutado b = i >> 1;
Valor de b: 7
Ejecutado b = i << 1;
Valor de b: 1E
Los operadores relacionales < (menor que), > (mayor que), <= (menor o igual
que), >= (mayor o igual que), is, == (igual que), != (distinto de) establecen una
comparación entre dos valores y devuelven como resultado un valor de tipo boolean
(true o false). Veamos un ejemplo:
int i;
int b;
Console.Write("Escribe el valor de i: ");
i=Int32.Parse(Console.ReadLine());
Escribe el valor de i: 2
Escribe el valor de b: 3
i<b devuelve: True
i<=b devuelve: True
i>b devuelve: False
i>=b devuelve: False
i==b devuelve: False
i!=b devuelve: True
El resultado es muy obvio cuando se trata con números (o, mejor dicho, con
tipos valor). Sin embargo, ¿Qué ocurre cuando utilizamos variables de un tipo
referencia?
El resultado de comparar c==d sería False. Sí, sí, False. A pesar de que
ambos objetos sean idénticos el resultado es, insisto, False. ¿Por qué? Porque una
variable de un tipo referencia no retorna internamente un dato específico, sino un
puntero a la dirección de memoria donde está almacenado el objeto. De este modo, al
comparar, el sistema compara los punteros en lugar de los datos del objeto, y, por lo
tanto, devuelve False, puesto que las variables c y d no apuntan a la misma dirección de
memoria. Para eso tendríamos que utilizar el método Equals heredado de la clase object
(en C#, todas las clases que construyas heredan automáticamente los miembros de la
clase base System.Object), así:
Los operadores & (and a nivel de bits), | (or a nivel de bits) y ^ (xor -o
exclusivo- a nivel de bits) hacen una comparación binaria (bit a bit) de dos números
devolviendo el resultado de dicha comparación como otro número. Vamos con un
ejemplo para que veas que no te engaño:
int i=10;
int b=7;
int res;
res = i & b;
Console.WriteLine("{0} & {1} retorna: Decimal: {2} Hexadecimal:
{3:X}", i, b, res, res);
res = (i | b);
Console.WriteLine("{0} | {1} retorna: Decimal: {2} Hexadecimal:
{3:X}", i, b, res, res);
res = (i ^ b);
Console.WriteLine("{0} ^ {1} retorna: Decimal: {2} Hexadecimal:
{3:X}", i, b, res, res);
Fíjate primero en que en ninguno de los casos se ha hecho una suma normal de
los dos números. Veamos estas tres operaciones de un modo algo más claro.
Marcaremos en negrilla los valores que provocan que el bit resultante en esa posición
valga 1:
Operación: i & b
Variable Valor dec. Valor hex.
i 10 A 0000 0000 0000 0000 0000 0000 0000 1010
b 7 7 0000 0000 0000 0000 0000 0000 0000 0111
Resultado 2 2 0000 0000 0000 0000 0000 0000 0000 0010
Operación: i | b
Variable Valor dec. Valor hex. Valor binario
i 10 A 0000 0000 0000 0000 0000 0000 0000 1010
b 7 7 0000 0000 0000 0000 0000 0000 0000 0111
Resultado 15 F 0000 0000 0000 0000 0000 0000 0000 1111
Operación: i ^ b
Variable Valor dec. Valor hex. Valor binario
i 10 A 0000 0000 0000 0000 0000 0000 0000 1010
b 7 7 0000 0000 0000 0000 0000 0000 0000 0111
Resultado 13 D 0000 0000 0000 0000 0000 0000 0000 1101
Los operadores && (AND lógico) y || (OR lógico) se ocupan de comparar dos
valores de tipo boolean y retornan como resultado otro valor de tipo boolean. El
operador && devuelve true cuando ambos operandos son true, y false cuando uno de
ellos o los dos son false. El operador || devuelve true cuando al menos uno de los
operandos es true (pudiendo ser también true los dos), y false cuando los dos operandos
son false. Suelen combinarse con las operaciones relacionales para establecer
condiciones más complejas. Por ejemplo, la siguiente expresión devolvería true si un
número es mayor que 10 y menor que 20:
Para que veas otro ejemplo, la siguiente expresión devolvería true si el número
es igual a 10 o igual a 20:
Fíjate bien. Delante del interrogante se pone la expresión que debe retornar un
valor boolean. Si dicho valor es true, el operador retornará lo que esté detrás del
interrogante, y si es false retornará lo que esté detrás de los dos puntos. No tiene por qué
retornar siempre un string. Puede retornar un valor de cualquier tipo. Por lo tanto, si en
este ejemplo num valiera 10, la cadena que se asignaría a mensaje sería "El número es
10", y en caso contrario se le asignaría "El número no es 10".
El operador de asignación (=) (sí, sí, ya sé que nos hemos hartado de usarlo, pero
vamos a verlo con más profundidad) asigna lo que hay a la derecha del mismo en la
variable que está a la izquierda. Por ejemplo, la expresión a = b asignaría a la variable a
lo que valga la variable b. La primera norma que no debes olvidar es que ambas
variables han de ser compatibles. Por ejemplo, no puedes asignar un número a una
cadena, y viceversa, tampoco puedes asignar una cadena a una variable de algún tipo
numérico.
Sobre esto hay que tener en cuenta una diferencia importante entre la asignación
de una variable de tipo valor a otra (también de tipo valor, obviamente) y la asignación
de una variable de tipo referencia a otra. Veamos el siguiente fragmento de código:
int a=5;
int b=a;
b++;
Está claro que tanto a como b serán objetos de la clase circunferencia. Después
de la ejecución de estas líneas, cuánto valdrá el radio de la circunferencia b?
Efectivamente, 5. ¿Y el de la circunferencia a? ¡¡Sorpresa!! También 5. ¿Cómo es esto,
si el valor que le hemos asignado al radio de la circunferencia a es 4? Volvamos a lo que
decíamos sobre los tipos referencia: reservaban espacio en el montón y devolvían un
puntero de tipo seguro a la dirección de memoria reservada. Lo que ha ocurrido aquí,
por lo tanto, es que al hacer la asignación Circunferencia b=a; no se ha reservado un
nuevo espacio en el montón para la circunferencia b: dado que a, al tratarse de una
variable de tipo referencia, devuelve internamente un puntero a la dirección reservada
para este objeto, es este puntero el que se ha asignado a la variable b, de modo que las
variables a y b apuntan ambas al mismo espacio de memoria. Por lo tanto, cualquier
modificación que se haga en el objeto usando alguna de estas variables quedará
reflejado también en la otra variable, ya que, en realidad, son la misma cosa o, dicho de
otro modo, representan al mismo objeto. Si queríamos objetos distintos, o sea espacios
de memoria distintos en el montón, teníamos que haberlos instanciado por separado, y
después asignar los valores a las propiedades una por una:
Para esta entrega hay dos ejemplos: uno para ver el funcionamiento de los
operadores typeof, sizeof, checked y unchecked y otro para ver el funcionamiento de los
operadores de manejo de bits. Sigue este vínculo si quieres bajártelos.
Vamos a empezar con una sencilla aplicación para que te vayas familiarizando tanto
con el entorno de desarrollo de Visual Studio .NET como con la clase System.Console que
tanto has visto hasta ahora (y vas a seguir viéndola hasta que empecemos a desarrollar
aplicaciones para Windows). Se trata, cómo no, de la aplicación más repetida en todos los
lenguajes de programación de la Historia de la humanidad: la aplicación "Hola mundo".
Venga, iremos paso a paso. Lo primero que hay que hacer es ejecutar Visual Studio
.NET. Ya sé que es evidente, pero tenía que decirlo... Cuando lo hagas aparecerá este cuadro
de diálogo:
Dale el nombre y ubicación que quieras para tu aplicación. Lo que sí te aconsejo es
que marques la casilla de verificación "Crear directorio para la solución", como está en la
imagen. Así tendrás bien organizados todos los archivos de la misma. Fíjate en los dos cuadros
que hay: el de la izquierda sirve, como puedes ver, para indicar el lenguaje que vas a utilizar, y
el de la derecha para indicar el tipo de aplicación que vas a diseñar. Más adelante los iremos
viendo, pero aún no es el momento más adecuado. De momento vamos a crear aplicaciones
de consola (es decir, programas que se ejecutan en una ventana de DOS). ¿Cómo? ¿Que
quieres empezar ya a hacer aplicaciones para Windows? Así me gusta, hombre, que seáis
valientes. Sin embargo, no vamos a empezar con eso aún. Por ahora lo que quiero es
enseñaros el lenguaje C# propiamente dicho, ya que comenzar desde cero (o prácticamente
cero) diseñando aplicaciones para Windows sin conocer el lenguaje no haría sino entorpecer el
aprendizaje del mismo. Cuando conozcas el lenguaje podrás comprobar que no te cuesta
ningún trabajo adaptarte al diseño para Windows.
Bueno, ahora que ya me he asegurado de que nos vamos a entender cuando yo hable
de una ventana o de otra, podemos continuar. Visual Studio .NET guarda los distintos archivos
que componen un programa con distintas extensiones, dependiendo de para qué sirva. Los
distintos archivos que contienen el código fuente en lenguaje C# los guarda con la extensión
"cs" (fijaos en el explorador de soluciones, en el cual tenéis Class1.cs y AssemblyInfo.cs). Al
archivo de proyecto que está escrito en C# le pone la extensión "csproj". El archivo de proyecto
contiene diversa información sobre el mismo: opciones de compilación, una lista de referencias
y una lista de los archivos que lo componen. Más adelante entraremos en más detalles. El
último archivo que nos interesa por ahora es el archivo de solución, al cual le pone la extensión
"sln". Este contiene información sobre los proyectos que componen la solución.
using System;
namespace HolaMundo
{
/// <summary>
/// Summary description for Class1.
/// </summary>
class Class1
{
static void Main(string[] args)
{
//
// TODO: Add code to start application here
//
}
}
}
Siempre que crees una aplicación de Consola en C#, Visual Studio .NET añadirá este
código. Seguro que, con lo que hemos visto hasta ahora, te suena mucho. La directiva using
System nos permitirá usar miembros de este espacio de nombres sin poner la palabra System
delante. Luego hay definido un espacio de nombres para la aplicación, que se llama igual que
la misma (HolaMundo). Luego está el sumario, que sirve para que puedas poner ahí lo que
quieras (un resumen de lo que hace el programa, una lista de bodas, los Evangelios..., aunque
normalmente se suele poner un resumen de lo que hace el programa). Por último, una clase
llamada Class1 con un método Main que es static, que es el método por el que empezará la
ejecución del programa. Esas tres líneas que hay dentro del método contienen un comentario
(realmente hubiera bastado una línea sola, pero supongo que así se ve mejor). Te lo traduzco:
"Para hacer: Añade aquí el código para que empiece la aplicación". O sea, que ya sabes dónde
hay que escribir el código de nuestra aplicación "Hola Mundo": en el método Main. Escribe
esto:
Console.WriteLine("Hola Mundo");
Ya está. ¿Ya? Sí, sí. Ya está. Vamos a probarla, a ver qué tal funciona. Haz clic en el
menú "Depurar_Iniciar", o bien haz clic en el botón que tiene un triángulo azul apuntando hacia
la derecha, o bien pulsa la tecla F5. Se ha abierto una ventana de DOS y se ha cerrado
rápidamente. No pasa nada. Todo está bien. Para evitar que se cierre inmediatamente después
de ejecutarse tienes dos opciones: ejecutar la aplicación sin opciones de depuración (menú
Depurar_Iniciar sin depurar" o bien pulsar Control+F5); o añadir una línea de código más para
que espere a que se pulse intro antes de cerrar la ventana. Yo sé que, seguramente, vas a
elegir la primera de ellas (ejecutar sin depuración), pero es mejor la segunda, y luego te explico
por qué. Escribe la siguiente línea a continuación de la que escribimos antes:
string a = Console.ReadLine();
Tranquilo que luego te explico el código. Ahora vuelve a ejecutar como hicimos la
primera vez. Como ves, ahora la ventana de DOS se queda abierta hasta que pulses intro. ¿Y
por qué es mejor hacerlo así que ejecutar sin depuración? Porque si ejecutamos sin
depuración, obviamente, no podremos usar las herramientas de depuración, como poner
puntos de interrupción, ejecutar paso a paso y cosas así. En esta aplicación no tendría mucho
sentido, es cierto, pero cuando hagamos programas más grandes podréis comprobar que todas
estas herramientas son verdaderamente útiles.
using System;
namespace HolaMundo
{
/// <summary>
/// Summary description for Class1.
/// </summary>
class Class1
{
static void Main(string[] args)
{
//
// TODO: Add code to start application here
//
Console.WriteLine("Hola Mundo");
string a = Console.ReadLine();
}
}
}
¿Qué es eso de "Console"? Bueno, Console es una clase que pertenece a la biblioteca
de clases de .NET Framework y está dentro del espacio de nombres System. Sirve para
efectuar diversas operaciones de consola. Nosotros nos fijaremos principalmente en dos de
ellas: Escribir datos en la consola y leer datos de la misma.´
Para escribir los datos tenemos los métodos Write y WriteLine. Este último, como
puedes apreciar, es el que hemos usado para nuestra aplicación. La diferencia entre ambos es
que Write escribe lo que sea sin añadir el carácter de fin de línea a la cadena, de modo que lo
siguiente que se escriba se pondrá a continuación de lo escrito con Write. WriteLine sí añade el
carácter de fin de línea a la cadena, de modo que lo siguiente que se escriba se colocará en la
siguiente línea. Es decir, el siguiente fragmento de código:
Console.Write("Hola");
Console.WriteLine("Pepe");
Console.Write("¿Cómo andas, ");
Console.WriteLine("tío?");
HolaPepe
¿Cómo andas, tío?
Hay 19 sobrecargas del método WriteLine, y otras 18 del método Write. ¿Qué es eso
de las sobrecargas? Cómo, ¿he dicho sobrecargas?, ¿sí? Pues no quería decirlo todavía. En
fin... veremos muy detallada la sobrecarga de métodos cuando lleguemos al tema de los
métodos. Mientras tanto, que sepas que, más o menos, quiero decir que podemos usar estos
métodos de diversas maneras (si es que me meto en unos líos...).
Para leer datos de la consola tenemos los métodos Read y ReadLine. No, no son
equivalentes a Write y WriteLine pero en dirección contraria. El método Read obtiene el primer
carácter que aún no se haya extraído del buffer de entrada. En caso de que el buffer esté vacío
o bien contenga un caracter no válido, retornará -1. Sin embargo, ReadLine obtiene una línea
completa del buffer de entrada, es decir, toda la cadena de caracteres hasta encontrar el
carácter de fin de línea (bueno, realmente el fin de línea está definido por dos caracteres, pero
tampoco vamos a darle más importancia, ya que esto de la consola viene muy bien para
aprender, pero poco más). Por cierto, estos dos métodos no están sobrecargados. ¿Qué es
eso del buffer de entrada? Pues vamos a ver: cuando el programa ejecuta un método Read y tú
escribes Hola, don Pepito, hola don José y después pulsas la tecla intro, todo lo que has escrito
va a dicho buffer. Read, decíamos, obtiene el primer carácter no extraído del buffer, de modo
que la primera vez devuelve la H, la segunda la o, luego la l, luego la a, y así hasta terminar el
buffer. Por lo tanto, el buffer de entrada es, para que me entiendas, una zona de memoria
donde se almacenan datos que se han ingresado pero que aún no se han leído.
No te voy a contar una por una cómo son todas las sobrecargas de los métodos Write y
WriteLine (tampoco merece la pena, la verdad). Te voy a explicar de qué formas los vamos a
usar más comúnmente, y si en algún momento del curso los usamos de otro modo, ya te lo
explicaré. El método Write lo usaremos de las siguientes formas:
Console.Write(cadena);
Console.Write(objeto);
Console.Write(objeto.miembro);
Console.Write("literal {0} literal {1} literal {2}...", dato0, dato1,
dato2);
En la primera línea, "cadena" puede ser una cadena literal (por lo tanto, entre comillas)
o bien una variable de tipo string (por lo tanto, sin comillas). En la segunda línea, "objeto" será
cualquier variable de cualquier tipo, o bien una expresión. En la tercera línea, "objeto" será de
nuevo cualquier variable de cualquier tipo, y lo usaremos así cuando queramos escribir lo que
devuelva un miembro de ese objeto (una propiedad o un método). La última forma del método
Write la usaremos cuando queramos intercalar datos dentro de un literal. Write escribiría esa
cadena intercalando dato0 donde está {0}, dato1 donde está {1}, y dato 2 donde está {2}, y así
tantos datos como queramos intercalar. Por cierto, estos datos pueden ser objetos de cualquier
tipo, o bien expresiones o bien lo que devuelva algún miembro de algún objeto. Para los
programadores de C, es algo parecido a printf, pero mucho más fácil de usar.
El método WriteLine lo usaremos de las mismas formas que el método Write, además
de esta:
Console.WriteLine();
variable = Console.Read();
cadena = Console.ReadLine();
En la primera línea he puesto "variable" porque Read devolverá un valor de tipo int,
equivalente al código del carácter unicode recibido del buffer. Por este motivo, la variable
solamente puede ser de tipo int. En la segunda línea, ReadLine retorna siempre un valor de
tipo string, por lo tanto, "cadena" debe ser una variable de tipo string.
using System;
namespace HolaMundo
{
class HolaMundoApp
{
static void Main(string[] args)
{
Console.WriteLine("Hola Mundo");
string a = Console.ReadLine();
}
}
}
¡Qué bonito! Sigue este vínculo si quieres bajártelo. Hasta la próxima entrega.
Curso de iniciación a la programación
con C#
MÉTODOS
Ya dijimos en la introducción a la POO que los métodos son todos aquellos bloques de
código que se ocupan de manejar los datos de la clase. Recapitulemos un momento y echemos
un nuevo vistazo al ejemplo del coche que pusimos en la introducción. En él teníamos tres
métodos: Acelerar, Girar y Frenar, que servían para modificar la velocidad y la dirección de los
objetos de la clase coche. Como ves, los métodos sirven para que los objetos puedan ejecutar
una serie de acciones. Veamos cómo se define un método en C#:
Veamos: acceso es el modificador de acceso del método, que puede ser private,
protected, internal o public (como las variables). Posteriormente el tipo de retorno, es decir, el
tipo de dato que devolverá el método (que puede ser cualquier tipo). Luego el nombre del
método (sin espacios en blanco ni cosas raras). Después, entre paréntesis y separados unos
de otros por comas, la lista de argumentos que aceptará el método: cada uno de ellos se
especificará poniendo primero el tipo y después el nombre del mismo. Por fin, la llave de
apertura de bloque seguida del código del método y, para terminarlo, la llave de cierre del
bloque.
Vamos a ilustrar esto con un ejemplo: vamos a construir una clase Bolígrafo; sus
métodos serán, por ejemplo, Pintar y Recargar, que son las operaciones que se suelen efectuar
con un bolígrafo. Ambos métodos modificarán la cantidad de tinta del boli, valor que podríamos
poner en una propiedad llamada Tinta, por ejemplo. Para aquellos que conozcáis la
programación procedimental, un método es como un procedimiento o una función. En
determinadas ocasiones necesitaremos pasarle datos a los métodos para que estos puedan
hacer su trabajo. Por ejemplo, siguiendo con el bolígrafo, puede que necesitemos decirle al
método Pintar la cantidad de tinta que vamos a gastar, igual que hacíamos con el método
Acelerar de la clase Coche, que teníamos que decirle cuánto queríamos acelerar. Pues bien,
estos datos se llaman argumentos. Vamos a verlo:
using System;
class Boligrafo
{
protected int color=0;
protected byte tinta=100;
this.tinta -= gasto;
Console.WriteLine("Se gastaron {0} unidades de tinta.",
gasto);
return true;
}
De momento fíjate bien en lo que conoces y en lo que estamos explicando, que son los
métodos. Lo demás lo iremos conociendo a su debido tiempo. En este ejemplo tienes los
métodos Pintar y Recargar (presta especial atención a la sintaxis). El primero disminuye la
cantidad de tinta, y el segundo establece esta cantidad nuevamente a 100, es decir, rellena el
bolígrafo de tinta.
class BoligrafoApp
{
static void Main()
{
// Instanciación del objeto
Boligrafo boli = new Boligrafo();
Console.WriteLine("El boli tiene {0} unidades de tinta",
boli.Tinta);
boli.Recargar();
Console.WriteLine("Al boli le quedan {0} unidades de tinta",
boli.Tinta);
string a = Console.ReadLine();
}
}
Veamos todo esto con otro ejemplo. Vamos a escribir una clase (muy simplificada, eso
sí) que se ocupe de manejar gastos e ingresos, sin intereses ni nada:
class Cuentas
{
protected double saldo=0;
public double Saldo
{
get
{
return this.saldo;
}
}
this.saldo -= cantidad;
return true;
}
this.saldo += cantidad;
return true;
}
}
En esta clase hay una variable protected (o sea, que es visible dentro de la clase y
dentro de clases derivadas, pero no desde el cliente), una propiedad y dos métodos. Como te
dije antes, presta especial atención a lo que conoces y, sobre todo, a los métodos, que es con
lo que estamos. Los métodos NuevoIngreso y NuevoGasto se ocupan de modificar el valor de
la variable saldo según cuánto se ingrese o se gaste. Ahora bien, si la cantidad que se
pretende ingresar es menor o igual que cero, el método no modificará el valor de la variable
saldo y devolverá false. Quiero que te fijes de nuevo en cómo se declara un método: en primer
lugar el modificador de acceso (que puede ser public, protected, private o internal), después el
tipo de dato que retornará, que podrá ser cualquier tipo de dato ( y en caso de que el método
no devuelva ningún dato, hay que poner void), después el nombre del método y, por último, la
lista de argumentos entre paréntesis. Ya sé que me estoy repitiendo, pero es que esto es muy
importante.
Sobrecarga de métodos
Cada sobrecarga tiene marcado en negrilla el elemento que la hace diferente de las
demás. Y así hasta hartarnos de añadir sobrecargas. Hay un detalle que también es importante
y que no quiero pasar por alto: lo que diferencia las listas de argumentos de las diferentes
sobrecargas no es el nombre de las variables, sino el tipo de cada una de ellas. Por
ejemplo, la siguiente sobrecarga tampoco sería válida:
MisCuentas.NuevoIngreso(200.53);
Efectivamente, aquí podría haber dudas, ya que el número 200.53 puede ser tanto
double, como single. Para números decimales, el compilador ejecutará la sobrecarga con el
argumento de tipo double. En el caso de números enteros, el compilador ejecutará la
sobrecarga cuyo argumento mejor se adapte con el menor consumo de recursos (int, uint, long
y unlong, por este orden). Y ahora vendrá la otra pregunta: ¿y si yo quiero que, a pesar de
todo, se ejecute la sobrecarga con el argumento de tipo single? Bien, en ese caso tendríamos
que añadir un sufijo al número para indicarle al compilador cuál es el tipo de dato que debe
aplicar para el argumento:
MisCuentas.NuevoIngreso(200.53F);
Los sufijos para literales de los distintos tipos de datos numéricos son los siguientes:
Cuando un método recibe un argumento por valor, lo que ocurre es que se crea una
copia local de la variable que se ha pasado en una nueva dirección de memoria. Así, si el
método modifica ese valor, la modificación se hace en la nueva dirección de memoria,
quedando la variable original sin cambio alguno. Por ejemplo, si hubiéramos escrito el método
NuevoIngreso de este modo:
this.saldo += cantidad;
cantidad=this.saldo;
return true;
}
double dinero=345.67;
MisCuentas.NuevoIngreso(dinero);
Console.Write(dinero);
¿Eres programador de Visual Basic? Pues te has equivocado. La salida sería 345.67,
es decir, la variable dinero no ha sido modificada, ya que se ha pasado al método por valor (en
C#, si no se indica otra cosa, los argumentos de los métodos se pasan por valor).
Veamos qué es lo que ha ocurrido:
Sin embargo, si escribimos el método del siguiente modo para que reciba los valores
por referencia:
public bool NuevoIngreso(ref double cantidad)
{
if (cantidad <=0)
return false;
this.saldo += cantidad;
cantidad=this.saldo;
return true;
}
double dinero=345.67;
MisCuentas.NuevoIngreso(ref dinero);
Console.Write(dinero);
Fíjate bien en que, ahora, la variable cantidad apunta a la misma zona de memoria a la
que apunta la variable dinero. Por este motivo, cualquier modificación que se haga sobre la
variable cantidad afectará también a la variable dinero, ya que dichas modificaciones se harán
en la zona de memoria reservada para ambas.
Sin embargo, las variables que se pasen a un método usando ref deben de haber sido
inicializadas previamente, es decir, el programa no se habría compilado si no se hubiera
inicializado la variable dinero. Si queremos pasar por referencia argumentos cuyo valor inicial
no nos interesa deberíamos usar out en lugar de ref. Por ejemplo, imagina que queremos
devolver en otro argumento el valor del saldo redondeado. ¿Para qué? No sé, hombre, sólo es
un ejemplo... Habría que hacerlo así:
double dinero=345.67;
int redoneo;
MisCuentas.NuevoIngreso(ref dinero, out redondeo);
Console.Write(redondeo);
Ahora la salida en la consola sería 346. Fíjate que la variable redondeo no ha sido
inicializada antes de efectuar la llamada al método (no ha recibido ningún valor). Por otro lado,
este argumento debe recibir algún valor antes de que el método retorne, por lo que se asigna
antes del if y luego se asigna otra vez después de hacer el ingreso. Sin embargo, la variable
dinero sí ha sido inicializada antes de invocar el método, puesto que el método necesitaba
saber cuánto había que ingresar, pero no necesita saber nada del valor redondeado, ya que
este se calculará a partir del saldo.
Métodos static
En efecto, por fin vas a saber qué significaba eso de que el método Main tenía que ser
static. Bien, los métodos static, son aquellos que se pueden ejecutar sin necesidad de
instanciar la clase donde está escrito. La definición de Tom Archer en el capítulo 6 de su libro
"A fondo C#" me parece excelente; dice así: "Un método estático es un método que existe en
una clase como un todo más que en una instancia específica de la clase". Mucho mejor,
¿verdad? Por lo tanto, el hecho de que el método Main tenga que ser static no es un capricho,
ya que, de lo contrario, el CLR no sería capaz de encontrarlo pues antes de que se ejecute la
aplicación, lógicamente, no puede haber instancias de ninguna de las clases que la componen.
Estos métodos suelen usarse para hacer una serie de operaciones globales que tienen
mucho más que ver con la clase como tal que con una instancia específica de la misma: por
ejemplo, si tenemos una clase Coche y queremos listar todas las marcas de coches de que
disponemos, lo más propio es un método static. ¿Qué necesidad tenemos de instanciar un
objeto de esa clase, si solamente queremos ver las marcas disponibles? Otro ejemplo podría
ser un método static en la clase Bolígrafo que devolviera una cadena con el nombre del color
que le corresponde a un determinado número, ya que no necesitaría instanciar un objeto de la
clase para saber si al número uno le corresponde el color negro, o al 5 el rojo, por ejemplo. Por
lo tanto, los métodos static no aparecen como miembros de las instancias de una clase, sino
como parte integrante de la propia clase. Vamos a poner un pequeño programa completo que
ilustre el uso de los métodos static:
using System;
namespace VisaElectron
{
class VisaElectron
{
public static ushort Limite()
{
return 300;
}
class VisaElectronApp
{
static void Main()
{
Console.WriteLine("El límite de la Visa electrón es: {0}",
VisaElectron.Limite());
string a=Console.ReadLine();
}
}
}
Para hacer que un método sea static hay que poner esta palabra después del
modificador de acceso (si lo hay) y antes del tipo de retorno del método. Este método
devolvería el límite que tienen todas las tarjetas Visa Electrón para extraer dinero en un sólo
día, (que no sé cuál es, pero límite tienen). Ahora presta especial atención a cómo se invoca
este método dentro del método Main (está en negrilla). En efecto, no se ha instanciado ningún
objeto de la clase VisaElectron, sino que se ha puesto directamente el nombre de la clase.
Por otro lado, soy consciente de que este no es el mejor diseño para esta clase en
concreto, pero mi interés principal ahora es que veas muy claro cómo se define un método
static y cómo se invoca. Más adelante, cuando empecemos con la herencia, trataremos la
redefinición de métodos y el polimorfismo. Por hoy, creo que tenemos suficiente.
CONSTRUCTORES
using System;
namespace Constructores
{
class Objeto
{
public Objeto()
{
Console.WriteLine("Instanciado el objeto");
}
}
class ConstructoresApp
{
static void Main()
{
Objeto o = new Objeto();
string a=Console.ReadLine();
}
}
}
En este pequeño ejemplo, la clase Objeto tiene un constructor (está en negrilla). Presta
especial atención a que se llama exactamente igual que la clase (Objeto) y se declara igual que
un método, con la salvedad de que no se pone ningún tipo de retorno puesto que, como he
dicho antes, un constructor no puede retornar ningún dato. Al ejecutar este programa, la salida
en la consola sería esta:
Instanciado el objeto
Igual que los métodos, los constructores también se pueden sobrecargar. Las normas
para hacerlo son las mismas: la lista de argumentos ha de ser distinta en cada una de las
sobrecargas. Se suele hacer cuando se quiere dar la posibilidad de instanciar objetos de
formas diferentes. Para que lo veas, vamos a sobrecargar el constructor de la clase Objeto.
Ahora, el código de esta clase es el siguiente:
class Objeto
{
public Objeto()
{
Console.WriteLine("Instanciado el objeto sin datos");
}
Instanciado el objeto
Pasando una cadena
Los datos pasados al constructor son: 34 y 57
Por otro lado, tenemos los constructores estáticos (static). La misión de estos
constructores es inicializar los valores de los campos static o bien hacer otras tareas que sean
necesarias para el funcionamiento de la clase en el momento en que se haga el primer uso de
ella, ya sea para instanciar un objeto, para ejecutar un método static o para ver el valor de un
campo static. Ya sé que aún no hemos visto estos últimos (los campos), pero como los vamos
a tratar en la próxima entrega creo que podemos seguir adelante con la explicación (no es fácil
establecer un orden lógico para un curso de C#, porque todo está profundamente relacionado).
Los constructores static, como te decía, no se pueden ejecutar más de una vez durante la
ejecución de un programa, y además la ejecución del mismo no puede ser explícita, pues lo
hará el compilador la primera vez que detecte que se va a usar la clase. Vamos a poner un
ejemplo claro y evidente de constructor static: supongamos que la clase System.Console tiene
uno (digo supongamos porque no he encontrado nada que me lo confirme, pero viendo cómo
funciona, deduzco que su comportamiento se debe a esto): La primera vez que el CLR detecta
que se va a utilizar esta clase o alguno de sus miembros se ejecuta su constructor static, y lo
que hace este constructor es inicializar las secuencias de lectura y escritura en la ventana de
DOS (o sea, en la consola), para que los miembros de esta clase puedan hacer uso de ella.
Evidentemente, este constructor no se ejecutará más durante la vida le programa, porque de lo
contrario se inicializarían varias secuencias de escritura y lectura, lo cual sería
contraproducente. Vamos a poner un ejemplo que te lo acabe de aclarar:
using System;
namespace ConstructoresStatic
{
class Mensaje
{
public static string Texto;
static Mensaje()
{
Texto="Hola, cómo andamos";
}
}
class ConstructoresStaticApp
{
static void Main()
{
Console.WriteLine(Mensaje.Texto);
string a=Console.ReadLine();
}
}
}
En este ejemplo tenemos una clase Mensaje con dos miembros: un campo static de
tipo string llamado Texto que aún no está inicializado (digamos que es como una variable a la
que se puede acceder sin necesidad de instanciar la clase) y un constructor static para esta
clase. En el método Main no instanciamos ningún objeto de esta clase, sino que simplemente
escribimos en la consola el valor del campo Texto (que es static, y recuerda que aún no ha sido
inicializado). Como hay un constructor static en la clase, el compilador ejecutará primero este
constructor, asignando el valor adecuado al campo Texto. Por este motivo, la salida en la
consola es la siguiente:
Y aquí va a empezar la polémica entre los programadores de C++: los auténticos gurús
de este lenguaje defenderán que quieren seguir gestionando la memoria a su antojo, y los que
no estén tan avanzados dirán que C# les acaba de quitar un gran peso de encima.
class Objeto
{
~Objeto()
{
Console.WriteLine("Objeto liberado");
}
}
Tienes el destructor escrito en negrilla. Como decía, el destructor se llama igual que la
clase precedido con el signo ~ (ALT presionado más las teclas 1 2 6 sucesivamente). Veamos
cómo se comporta esto con un programa completo:
using System;
namespace Destructores
{
class Objeto
{
~Objeto()
{
Console.WriteLine("Referencia liberada");
}
}
class DestructoresApp
{
static void Main()
{
Objeto o=new Objeto();
}
}
}
En este caso, para probar este programa, lo vamos a ejecutar sin depuración, o sea,
Ctrl+F5 (de lo contrario no nos daría tiempo a ver el resultado). El programa no hace casi nada:
sencillamente instancia la clase Objeto y finaliza inmediatamente. Al terminar, es cuando el GC
entra en acción y ejecuta el destructor de la clase Objeto, por lo que la salida en la consola
sería la siguiente:
Referencia liberada
¿Y por qué no hemos ejecutado el método ReadLine dentro de Main, como hemos
hecho siempre? Sabía que me preguntarías eso. Vamos a volver a poner el método Main con
esa línea que me dices y luego os lo comento:
Bien, tenemos dos motivos por los que no lo hemos hecho: el primero es que no
serviría de nada, puesto que el destructor no se ejecutará hasta que el GC haga la recolección
de basura, y esta no se hará hasta que finalice la aplicación, y la aplicación finaliza después de
haber ejecutado todo el código del método Main. El segundo motivo es que esto provocaría un
error. ¿¿¿CÓMO??? ¡Si está bien escrito! En efecto, pero se produce el siguiente error: "No se
puede tener acceso a una secuencia cerrada". El porqué se produce tiene una explicación
bastante sencilla: La primera vez que se usa la clase Console se inicializan las secuencias de
lectura y escritura en la consola (seguramente en un constructor static), y estas secuencias se
cierran justo antes de finalizar la aplicación. En el primer ejemplo funcionaría correctamente,
puesto que esta secuencia se inicia justamente en el destructor, ya que antes de este no hay
ninguna llamada a la clase Console. Sin embargo en el segundo se produce un error, porque
las secuencias se inician dentro del método Main (al ejecutar Console.ReadLine), y se cierran
cuando va a finalizar el programa. El problema viene aquí: los hilos de ejecución del GC son de
baja prioridad, de modo que, para cuando el GC quiere ejecutar el destructor, las secuencias
de escritura y lectura de la consola ya han sido cerradas, y como los constructores static no se
pueden ejecutar más de una vez, la clase Console no puede abrirlas por segunda vez.
Sigamos con el ejemplo que funcionaba correctamente. En efecto, puede parecer que
el GC ha sido sumamente rápido, pues ha liberado el objeto en el momento en el que este ya
no era necesario. Sin embargo, veamos el siguiente ejemplo:
namespace Destructores
{
class Objeto
{
~Objeto()
{
Console.WriteLine("Referencia liberada");
}
}
class DestructoresApp
{
static void Main()
{
Objeto o=new Objeto();
Console.WriteLine("El objeto acaba de ser instanciado.
Pulsa INTRO");
string a = Console.ReadLine();
o=null;
Console.WriteLine("La referencia acaba de ser destruida.
Pulsa INTRO");
a = Console.ReadLine();
GC.Collect();
Console.WriteLine("Se acaba de ejecutar GC.Collect(). Pula
INTRO");
a = Console.ReadLine();
}
}
}
using System;
namespace Destructores2
{
class Objeto
{
public int dato;
public Objeto(int valor)
{
this.dato=valor;
Console.WriteLine("Construido Objeto con el valor {0}",
valor);
}
~Objeto()
{
Console.WriteLine("Destructor de Objeto con el valor {0}",
this.dato);
}
}
class Destructores2App
{
static void Main()
{
Objeto a=new Objeto(5);
Objeto b=a;
string c;
Console.WriteLine("Valor de a.dato: {0}", a.dato);
Console.WriteLine("Valor de b.dato: {0}", b.dato);
Console.WriteLine("Pulsa INTRO para ejecutar a.dato++");
c=Console.ReadLine();
a.dato++;
Console.WriteLine("Ejecutado a.dato++");
Console.WriteLine("Valor de a.dato: {0}", a.dato);
Console.WriteLine("Valor de b.dato: {0}", b.dato);
Console.WriteLine("Pulsa INTRO para ejecutar a=null;
GC.Collect()");
c=Console.ReadLine();
a=null;
GC.Collect();
Console.WriteLine("a=null; GC.Collect() han sido
ejecutados");
Console.WriteLine("Pulsa INTRO para ejecutar b=null;
GC.Collect()");
c=Console.ReadLine();
b=null;
GC.Collect();
Console.WriteLine("b=null; GC.Collect() han sido
ejecutados");
c=Console.ReadLine();
}
}
}
Veamos ahora cómo sería en C++ no gestionado usando delete y luego comparamos
las salidas de ambos programas:
class Objeto
{
public:
Objeto(int valor)
{
dato=valor;
cout << "Construido Objeto con el valor "
<< ("%d", valor) << "\n";
}
~Objeto()
{
cout << "Destructor de Objeto con el valor "
<< ("%d", this->dato) << "\n";
}
int dato;
};
void main()
{
Objeto* a=new Objeto(5);
Objeto* b = a;
char c;
cout << "Valor de a->dato: " << ("%d", a->dato) << "\n";
cout << "Valor de b->dato: " << ("%d", b->dato) << "\n";
cout << "Pulsa INTRO para ejecutar a->dato++\n";
cin.get(c);
a->dato++;
delete a;
cout << "delete a ha sido ejecutado\n";
cout << "Pulsa INTRO para ejecutar delete b (esto provocará un
error)\n";
cin.get(c);
delete b;
}
Presta atención a que en estos programas tenemos una doble referencia hacia el
mismo objeto, es decir, tanto "a" como "b" apuntan a la misma zona de memoria. Sabemos
esto porque el constructor se ha ejecutado únicamente una vez cuando se hizo "a=new
Objeto(5)", pero cuando se asignó "b=a" lo que hicimos fue crear la doble referencia. La parte
en la que se incrementa el campo "dato" es para demostrar que dicha alteración afecta a
ambas referencias. Las diferencias vienen a partir de aquí: Cuando se ejecuta a=null;
GC.Collect() en C# se ha destruido la referencia de "a", pero no se ha ejecutado el destructor
porque aún hay una referencia válida hacia el objeto: la referencia de "b". Después,
cuando se destruye la referencia de "b" y se vuelve a ejecutar GC.Collect() observamos que sí
se ejecuta el destructor, ya que el GC no ha encontrado ninguna referencia válida y puede
liberar el objeto. Sin embargo, en el programa escrito en C++ ha ocurrido algo muy distinto: el
destructor se ha ejecutado en el momento de hacer el "delete a", ya que delete libera la
memoria en la que se alojaba el objeto independientemente de las referencias que haya hacia
él. Por este motivo se produce un error cuando se intenta ejecutar "delete b", puesto que el
objeto fue liberado con anterioridad.
Por otro lado, el GC garantiza que se ejecutará el destructor de todos los objetos
alojados en el montón (recuerda, tipos referencia) cuando no haya referencias hacia ellos,
aunque esta finalización de objetos no sea determinista, es decir, no libera la memoria en el
instante en que deja de ser utilizada. Por contra, en C++ se puede programar una finalización
determinista, pero esta tarea es sumamente compleja en la mayoría de las ocasiones y,
además, suele ser una importante fuente de errores y un gran obstáculo para un adecuado
mantenimiento del código. Veamos un ejemplo, muy simple, eso sí, de esto último. Usaremos
la misma clase Objeto que en el ejemplo anterior, pero este método Main:
a=new Objeto(1);
Console.WriteLine("Pulsa INTRO para instanciar el segundo
objeto");
c=Console.ReadLine();
a=new Objeto(2);
Console.WriteLine("Pulsa INTRO para instanciar el tercer objeto");
c=Console.ReadLine();
a=new Objeto(3);
Console.WriteLine("Pulsa INTRO para ejecutar a=null");
c=Console.ReadLine();
a=null;
Console.WriteLine("Pulsa INTRO para ejecutar CG.Collect()");
c=Console.ReadLine();
GC.Collect();
c=Console.ReadLine();
}
delete a;
}
using System;
namespace MetodosCloseYDispose
{
class FinalizarDeterminista
{
public void Dispose()
{
Console.WriteLine("Liberando recursos");
// Aquí iría el código para liberar los recursos
GC.SuppressFinalize(this);
}
~FinalizarDeterminista()
{
this.Dispose();
}
}
class MetodosCloseYDisposeApp
{
static void Main()
{
string c;
FinalizarDeterminista a=new FinalizarDeterminista();
Console.WriteLine("Pulsa INTRO para ejecutar
a.Dispose()");
c=Console.ReadLine();
a.Dispose();
a=null;
GC.Collect();
a=new FinalizarDeterminista();
a=null;
GC.Collect();
c=Console.ReadLine();
}
}
}
¡Uffff! ¿Ya hemos terminado con esto? Pues sí..., hemos terminado... de empezar.
Como os he dicho, .NET Framework ofrece la clase System.GC para proporcionarnos un cierto
control sobre el recolector de basura. Como son varios los métodos de esta clase y considero
que este tema es muy interesante, me extenderé un poquito más, si no os importa.
using System;
namespace Resurreccion
{
class Objeto
{
public int dato;
class ResurreccionApp
{
static public Objeto resucitado;
resucitado=new Objeto(1);
Console.WriteLine("Valor de resucitado.dato: {0}",
resucitado.dato);
Console.WriteLine("Pulsa INTRO para ejecutar
resucitado=null; GC.Collect()");
c=Console.ReadLine();
resucitado=null;
GC.Collect();
GC.WaitForPendingFinalizers();
Console.WriteLine("Valor de resucitado.dato: {0}",
resucitado.dato);
Console.WriteLine("Pulsa INTRO para ejecutar
resucitado=null; GC.Collect()");
c=Console.ReadLine();
resucitado=null;
GC.Collect();
Console.WriteLine("Ejecutado resucitado=null;
GC.Collect()");
c=Console.ReadLine();
}
}
}
Vamos poco a poco, que si no podemos perdernos con bastante facilidad. Al principio,
todo bien, como se esperaba: al instanciar el objeto se ejecuta el constructor del mismo. Ahora
es cuando viene lo bueno: se anula la referencia y, por lo tanto, el GC determina que puede
liberarlo y ejecuta su destructor. Sin embargo, cuando volvemos a escribir el valor del campo
"dato" ¡este vuelve a aparecer! En efecto, el GC no lo liberó a pesar de haber ejecutado su
destructor, y lo más curioso es que el motivo por el que no lo ha liberado no es que se haya
creado una nueva referencia al objeto en el destructor, sino otro que explicaremos después.
Pero ahí no queda la cosa: cuando destruimos la referencia y forzamos la recolección por
segunda vez el destructor no se ha ejecutado. Y las dudas se acrecentan, claro: ¿se ha
liberado o no se ha liberado? y, si se ha liberado, ¿por qué no se ha ejecutado el destructor?
Pues bien, sí se ha liberado, pero no se ha ejecutado el destructor. En resumen: lo que ha
ocurrido es que la primera vez que destruimos la referencia y ejecutamos GC.Collect se ejecutó
el destructor pero no se liberó, y la segunda vez se liberó pero no se ejecutó el destructor. La
explicación de todo este embrollo es la siguiente: Cuando se instancia un objeto, el GC
comprueba si este tiene un destructor. En caso afirmativo, guarda un puntero hacia el objeto
en una lista de finalizadores. Al ejecutar la recolección, el GC determina qué objetos se
pueden liberar, y posteriormente comprueba en la lista de finalizadores cuáles de ellos tenían
destructor. Si hay alguno que lo tiene, el puntero se elimina de esta lista y se pasa a una
segunda lista, en la que se colocan, por lo tanto, los destructores que se deben invocar. El
GC, por último, libera todos los objetos a los que el programa ya no hace referencia excepto
aquellos que están en esta segunda lista, ya que si lo hiciera no se podrían invocar los
destructores, y aquí acaba la recolección. Como consecuencia, un objeto que tiene
destructor no se libera en la primera recolección en la que se detecte que ya no hay
referencias hacia él, sino en la siguiente, y este es el motivo por el que, en nuestro ejemplo,
el objeto no se liberó en la primera recolección. Tras esto, un nuevo hilo de baja prioridad del
GC se ocupa de invocar los destructores de los objetos que están en esta segunda lista,
y elimina los punteros de ella según lo va haciendo. Claro, la siguiente vez que hemos
anulado la referencia y forzado la recolección en nuestro ejemplo, el GC determinó que dicho
objeto se podía liberar y lo liberó, pero no ejecutó su destructor porque la dirección del objeto
ya no estaba ni el la lista de finalizadores ni en la segunda lista. ¿Y si, a pesar de todo,
queríamos que se volviera a ejecutar el destructor, no podíamos hacerlo? Bien, para eso
tenemos el método ReRegisterForFinalize de la clase GC, que lo que hace es volver a colocar
un puntero al objeto en la lista de finalizadores.
Como te decía, son pocas las utilidades que se le pueden encontrar a la resurrección.
De hecho, yo no he encontrado ninguna (desde aquí os invito a que me mandéis un E-mail si a
vosotros se os ocurre algo). Por este motivo la he calificado de "fenómeno curioso y peligroso"
en lugar de "potente característica", pues creo que es más un "efecto colateral" del propio
funcionamiento del GC que algo diseñado así a propósito. ¿Que por qué digo que es peligroso?
Porque, dependiendo de cómo hayamos diseñado la clase, el efecto puede ser de lo más
inesperado. Imagina, por ejemplo, un objeto (llamémosle Padre) de una clase que, a su vez,
crea sus propios objetos de otras clases (llamémosles Hijos). Al hacer la recolección, el GC
determina que el objeto Padre se puede liberar, y con él todos aquellos a los que este hace
referencia, es decir, los Hijos. Como consecuencia, puede que el GC libere varios de estos
Hijos referenciados en el objeto Padre. Sin embargo, si hemos resucitado al Padre se puede
armar un buen lío (de hecho se armará seguro) cuando este intente acceder a los objetos Hijos
que sí han sido liberados.
Por otro lado, quiero que te fijes de nuevo en el ejemplo: verás que hay una invocación
al método GC.WaitForPendingFinalizers. Este método interrumpe la ejecución del programa
hasta que el GC termine de ejecutar todos los destructores que hubiera que ejecutar. Y en este
caso tenemos que interrumpir la ejecución porque la siguiente línea a GC.Collect() intenta
recuperar el valor del campo "dato". Claro, como la recolección acaba antes de que se hayan
ejecutado los destructores y el hilo de ejecución de estos es de baja prioridad, cuando se
quiere recuperar este valor resulta que el destructor todavía no se ha ejecutado, de modo que
la referencia del objeto todavía es null.
using System;
namespace ReferenciaFragil
{
class ObjetoGordo
{
public int Dato;
public ObjetoGordo()
{
this.Dato=4;
Console.WriteLine("Creando objeto gordo y costoso");
for (ulong i=0;i<2000000000;i++) {}
Console.WriteLine("El objeto gordo y costoso fue creado");
Console.WriteLine();
}
}
class ReferenciaFragilApp
{
static void Main()
{
Console.WriteLine("Pulsa INTRO para crear el objeto
gordo");
string c=Console.ReadLine();
ObjetoGordo a=new ObjetoGordo();
Console.WriteLine("El valor de a.Dato es {0}", a.Dato);
Console.WriteLine();
WeakReference wrA=new WeakReference(a);
a=null;
Console.WriteLine("Ejecutado wrA=new
WeakReference(a);a=null;");
Console.WriteLine("El resultado de wrA.IsAlive es: {0}",
wrA.IsAlive);
Console.WriteLine("Pulsa INTRO para recuperar el objeto
gordo");
c=Console.ReadLine();
a=(ObjetoGordo) wrA.Target;
Console.WriteLine("Ejecutado a=(ObjetoGordo) wrA.Target");
Console.WriteLine("El valor de a.Dato es {0}", a.Dato);
Console.WriteLine("Pulsa INTRO para ejecutar
a=null;GC.Collect");
c=Console.ReadLine();
a=null;
GC.Collect();
Console.WriteLine("El resultado de wrA.IsAlive es: {0}",
wrA.IsAlive);
Console.WriteLine("Como ha sido recolectado no se puede
recuperar");
Console.WriteLine("Habría que instanciarlo de nuevo");
c=Console.ReadLine();
}
}
}
El valor de a.Dato es 4
En este pequeño ejemplo hemos diseñado una clase que tarda un poco en terminar de
ejecutar el constructor. Después de crear el objeto, vemos cuál es el valor del campo Dato para
que os deis cuenta de que el objeto se ha creado con éxito. Bien, después creamos el objeto
wrA de la clase System.WeakReference, que es, en efecto, la referencia frágil. En el
constructor de esta clase (WeakReference) hay que pasarle el objeto hacia el que apuntará
dicha referencia. Lo más importante viene ahora: como ves, hemos destruido la referencia de a
(en a=null). Sin embargo, cuando recuperamos el valor de la propiedad IsAlive de wrA, vemos
que esta retorna True, es decir, que el objeto sigue en la memoria (por lo tanto, no ha sido
recolectado por el GC). Como está vivo recuperamos la referencia (en la línea a=(ObjetoGordo)
wrA.Target) y volvemos a escribir el valor de a.Dato, para que veas que, en efecto, el objeto se
ha recuperado. Para terminar, al final volvemos a destruir la referencia (a=null) y, además,
forzamos la recolección de basura. Por este motivo, cuando después pedimos el valor de la
propiedad IsAlive vemos que retorna False (el objeto ha sido recolectado por el GC), así que ya
no se podría recuperar y habría que instanciarlo de nuevo para volverlo a usar. La enseñanza
principal con la que quiero que te quedes es con la siguiente: si esto fuera un programa grande
y hubiéramos tenido un objeto verdaderamente gordo, lo ideal es crear una referencia frágil
(con la clase WeakReference) hacia él cuando, de momento, no se necesite más. Así, si el GC
necesita memoria podrá recolectarlo, pero no lo hará en caso de que no se necesite. De este
modo, si más adelante el programa vuelve a necesitar el objeto nos bastará con comprobar lo
que devuelve la propiedad IsAlive de la referencia frágil: si devuelve False habrá que
instanciarlo de nuevo, pero si devuelve True bastará con recuperar la referencia frágil, de modo
que no habrá que perder tiempo en volver a crear el objeto.
¿Cómo lo llevas? ¿Bien? ¿Seguimos? Venga, anímate hombre, que ya queda poco...
Nos queda hablar de las generaciones. No, no voy a empezar un debate sobre padres
e hijos, no. A ver si nos centramos un poquito, ¿eh? Veeeeenga... Sacúdete la cabeza un
poco... te hará sentir mejor... ¿Ya? Pues vamos. El GC, para mejorar su rendimiento, agrupa
los objetos en diferentes generaciones. ¿Por qué? Pues porque, generalmente, los últimos
objetos que se construyen suelen ser los primeros en dejar de ser utilizados. Piensa, por
ejemplo, en una aplicación para Windows, Internet Explorer, por ejemplo. El primer objeto que
se crea es la ventana de la aplicación (bueno, puede que no sea así siendo estrictos, pero si
me admitís la licencia entenderéis lo que quiero decir mucho mejor). Bien, cuando abres un
cuadro de diálogo con alguna opción de menú, se crea otro objeto, o sea, ese cuadro de
diálogo precisamente. Hasta aquí, entonces, tenemos dos objetos. ¿Cuál de ellos cerrarás
primero? Efectivamente, el cuadro de diálogo. Es decir, el último que se creó. ¿Lo ves? No
estoy diciendo que esto sea así siempre (si fuera así, el montón sería una pila, y no el montón),
sino que es muy frecuente. La gran ventaja de que el GC use generaciones es que, cuando
necesita memoria, no revisa todo el montón para liberar todo lo liberable, sino que libera
primero todo lo que pueda de la última generación, pues lo más probable es que sea aquí
donde encuentre más objetos inútiles. Volvamos al ejemplo del Internet Explorer. Antes de la
primera recolección se han abierto y cerrado, por ejemplo tres cuadros de diálogo. Todos estos
objetos están, por lo tanto, en la primera generación, y cuando se ejecute el GC se pueden
liberar excepto, claro está, la ventana de la aplicación, que sigue activa. Todos los objetos que
se creen a partir de la primera recolección pasarán a formar parte de la segunda generación.
Así, cuando el GC vuelva a ejecutarse comprobará solamente si puede liberar los objetos de la
segunda generación, y, si ha liberado memoria suficiente, no mirará los de la primera (que,
recuerda, solamente quedaba la ventana de la aplicación). Efectivamente, ha ganado tiempo,
ya que era poco probable que tuviera que recolectar algún objeto de la generación anterior.
Imagina que mientras se hacía esta segunda recolección había un cuadro de diálogo abierto.
Claro, habrá liberado todo lo de la segunda generación (que no hiciera falta, por supuesto)
menos este cuadro de diálogo, pues aún está en uso. A partir de aquí, los objetos pertenecerán
a la tercera generación, y el GC tratará de liberar memoria solamente entre estos. Si consigue
liberar lo que necesita, no mirará en las dos generaciones anteriores, aunque haya objetos que
se pudieran liberar (como el cuadro de diálogo que teníamos abierto cuando se ejecutó la
segunda recolección). Por supuesto, en caso de que liberando toda la memoria posible de la
tercera generación no consiguiera el espacio que necesita, trataría de liberar también espacio
de la segunda generación y, si aún así no tiene suficiente, liberaría también lo que pudiera de la
primera generación (en caso de que no encuentre memoria suficiente para crear un objeto
después de recolectar todo lo recolectable, lanzaría una excepción... o error). A partir de aquí,
no hay más generaciones, es decir, el GC agrupa un máximo de tres generaciones.
Hay un último detalle que no quiero dejar escapar: debido a cómo funciona el recolector
de basuras, lo más recomendable es limitar la utilización de destructores solamente a aquellos
casos en los que sea estrictamente necesario ya que, como te dije antes, la liberación de la
memoria de un objeto que tiene destructor no se efectúa en la primera recolección en la que
detecte que ya no hay referencias hacia él, sino en la siguiente.
Aunque te parezca mentira, esto del GC tiene todavía más "miga", pero como lo que
queda tiene mucho que ver con la ejecución multi-hilo lo voy a dejar para más adelante, que
tampoco quiero provocar dolores de cabeza a nadie...
Para esta entrega tienes nada menos que once ejemplos, entre los cuales están dos
ejemplos en C++. Estos están es las carpetas Destructores2CPP y Desctructores3CPP, y
también puedes correrlos en la versión de C++ que viene con la Beta2 de Visual Studio.NET.
Sigue este vínculo para bajarte los ejemplos.
using System;
namespace Circunferencia
{
class Circunferencia
{
public Circunferencia(double radio)
{
this.Radio=radio;
}
class CircunferenciaApp
{
static void Main()
{
Circunferencia c;
string rad;
double radio=0;
do
{
try
{
Console.Write("Dame un radio para la
circunferencia: ");
rad=Console.ReadLine();
radio=Double.Parse(rad);
}
catch
{
continue;
}
} while (radio<=0);
c=new Circunferencia(radio);
c.Perimetro=2 * Circunferencia.PI * c.Radio;
c.Area=Circunferencia.PI * Math.Pow(c.Radio,2);
string a=Console.ReadLine();
}
}
}
Bueno, una vez más te pido que no te preocupes por lo que no entiendas, porque hay
cosas que veremos más adelante, como los bloques do, try y catch. Están puestos para evitar
errores en tiempo de ejecución (para que veáis que me preocupo de que no tengáis
dificultades). Bien, lo más importante de todo es la clase circunferencia. ¿Qué es lo que te he
puesto en negrilla? Efectivamente, tres variables y una constante. Pues bien, esos son los
campos de la clase Circunferencia. ¿Serías capaz de ver si hay declarado algún campo en la
clase CircunferenciaApp, que es donde hemos puesto el método Main? A ver... a ver... Por ahí
hay uno que dice que hay tres: uno de la clase Circunferencia, otro de la clase string y otro de
la clase double. ¿Alguien está de acuerdo...? Pues yo no. En efecto, hay tres variables dentro
del método Main, pero no son campos de la clase CircunferenciaApp, porque están dentro
de un método. Por lo tanto, todos los campos son indicadores, pero no todos los
indicadores son campos, ya que si una variable representa o no un campo depende del lugar
donde se declare.
Recuerda los modificadores de acceso de los que hablamos por primera vez en la
tercera entrega de este curso (private, protected, internal y public). Pues bien, estos
modificadores son aplicables a las variables y constantes solamente cuando estas representan
los campos de una clase, y para ello deben estar declaradas como miembros de la misma
dentro de su bloque. Sin embargo, una variable que esté declarada en otro bloque distinto
(dentro de un método, por ejemplo) no podrá ser un campo de la clase, pues será siempre
privada para el código que esté dentro de ese bloque, de modo que no se podrá acceder a ella
desde fuera del mismo. Por este motivo, las tres variables que están declaradas dentro del
método Main en nuestro ejemplo no son campos, sino variables privadas accesibles solamente
desde el código de dicho método. Del mismo modo, si hubiéramos declarado una variable
dentro del bloque "do", esta hubiera sido accesible solamente dentro del bloque "do", e
inaccesible desde el resto del método Main.
Ahora quiero que te fijes especialmente en el código del método Main. Estamos
accediendo a los campos del objeto con la sintaxis "nombreobjeto.campo", igual que se hacía
para acceder a los métodos, aunque sin poner paréntesis al final. Sin embargo, hay una
diferencia importante entre el modo de acceder a los tres campos variables (Area, Perimetro y
Radio) y el campo constante (PI): En efecto, a los campos variables hemos accedido como si
fueran métodos normales, pero al campo constante hemos accedido como accedíamos a los
métodos static, es decir, poniendo el nombre de la clase en lugar del nombre del objeto. ¿Por
qué? Porque, dado que un campo constante mantendrá el mismo valor para todas las
instancias de la clase, el compilador ahorra memoria colocándolo como si fuera static, evitando
así tener que reservar un espacio de memoria distinto para este dato (que, recuerda, siempre
es el mismo) en cada una de las instancias de la clase.
Ya sé que alguno estará pensando: "pues vaya una clase Circunferencia has hecho,
que tienes que hacerte todos los cálculos a mano en el método Main. Para eso nos habíamos
declarado las variables en dicho método y nos ahorrábamos la clase". Pues tienes razón. Lo
suyo sería que fuera la clase Circunferencia la que hiciera todos los cálculos a partir del radio,
en lugar de tener que hacerlos en el método Main o en cualquier otro método o programa que
utilice esta clase. Vamos con ello, a ver qué se puede hacer:
class Circunferencia
{
public Circunferencia(double radio)
{
this.Radio=radio;
this.Perimetro=2 * PI * this.Radio;
this.Area=PI * Math.Pow(this.Radio,2);
}
Bueno, ahora, como ves, hemos calculado los valores de todos los campos en el
constructor. Así el cliente no tendrá que hacer cálculos por su cuenta para saber todos los
datos de los objetos de esta clase, sino que cuando se instancie uno, las propiedades tendrán
los valores adecuados. ¿Qué ocurriría si en el cliente escribiéramos este código?:
class Circunferencia
{
public Circunferencia(double radio)
{
this.Radio=radio;
this.Perimetro=2 * PI * this.Radio;
this.Area=PI * Math.Pow(this.Radio,2);
}
public double Radio;
public readonly double Perimetro;
public readonly double Area;
public const double PI=3.1415926;
}
Bien, ahora tenemos protegidos los campos Perimetro y Area, pues son de sólo lectura,
de modo que ahora el cliente no podrá modificar los valores de dichos campos. Para hacerlo
fíjate que hemos puesto la palabra readonly delante del tipo del campo. Sin embargo, seguimos
teniendo un problema: ¿qué pasa si, después de instanciar la clase, el cliente modifica el valor
del radio? Pues que estamos en las mismas... El radio volvería a no ser coherente con el resto
de los datos del objeto. ¿Qué se os ocurre para arreglarlo? Claro, podríamos poner el campo
Radio también de sólo lectura, pero en este caso tendríamos que instanciar un nuevo objeto
cada vez que necesitemos un radio distinto, lo cual puede resultar un poco engorroso. Quizá
podríamos hacer un pequeño rodeo: ponemos el radio también como campo de sólo lectura y
escribimos un método para que el cliente pueda modificar el radio, y escribimos en él el código
para modificar los tres campos, de modo que vuelvan a ser coherentes. Sin embargo, esto no
se puede hacer. ¿Por qué? Porque los campos readonly solamente pueden ser asignados una
vez en el constructor, y a partir de aquí su valor es constante y no se puede variar en esa
instancia. Y entonces, ¿por qué no usamos constantes en vez de campos de sólo lectura? Pero
hombre..., cómo me preguntas eso... Para poder usar constantes hay que saber previamente el
valor que van a tener (como la constante PI, que siempre vale lo mismo), pero, en este caso,
no podemos usar constantes para radio, área y perímetro porque no sabremos sus valores
hasta que no se ejecute el programa. Resumiendo: los campos de sólo lectura almacenan
valores constantes que no se conocerán hasta que el programa esté en ejecución. Habrá que
hacer otra cosa para que esto funcione mejor, pero la haremos después... Antes tengo que
contaros más cosas sobre los campos.
Por otro lado, los campos, igual que los métodos y los constructores, también pueden
ser static. Su comportamiento sería parecido: un campo static es aquel que tiene mucho más
que ver con la clase que con una instancia particular de ella. Por ejemplo, si quisiéramos añadir
una descripción a la clase circunferencia, podríamos usar un campo static, porque todas las
instancias de esta clase se ajustarán necesariamente a dicha descripción. Si ponemos el
modificador static a un campo de sólo lectura, este campo ha de ser inicializado en un
constructor static. Ahora bien, recuerda que las constantes no aceptan el modificador de
acceso static: si su modificador de acceso es public o internal ya se comportará como si fuera
un campo static. Pongamos un ejemplo de esto:
class Circunferencia
{
static Circunferencia()
{
Descripcion="Polígono regular de infinitos lados";
}
Con lo que hemos aprendido hasta ahora ya tenemos herramientas suficientes como
para solventar el problema, aunque, como veremos después, no sea el modo más idóneo de
hacerlo. Veamos: podríamos cambiar los modificadores de acceso de los campos, haciéndolos
private o protected en lugar de public, y después escribir métodos para retornar sus valores.
Vamos a ver cómo se podría hacer esto:
using System;
namespace CircunferenciaMetodos
{
class Circunferencia
{
public Circunferencia(double rad)
{
this.radio=rad;
}
class CircunferenciaApp
{
static void Main()
{
Circunferencia c=new Circunferencia(4);
c.Radio(c.Radio()+1);
Console.WriteLine("El radio de la circunferencia es
{0}",c.Radio());
Console.WriteLine("El perímetro de la circunferencia es
{0}",
c.Perimetro());
Console.WriteLine("El área de la circunferencia es {0}",
c.Area());
a=Console.ReadLine();
}
}
}
Como ves, ahora la clase Circunferencia garantiza que sus datos contendrán siempre
valores coherentes, además de permitir que se pueda modificar el radio, pues el método Radio
está sobrecargado: una de las sobrecargas simplemente devuelve lo que vale la variable
protected radio y la otra no devuelve nada, sino que da al radio un nuevo valor. Por otro lado,
ya que hemos escrito métodos para devolver perímetro y área nos ahorramos las variables
para estos datos, pues podemos calcularlos directamente en dichos métodos. Sin embargo, la
forma de usar esta clase es muy forzada y muy poco intuitiva, es decir, poco natural. En efecto,
no resulta natural tener que poner los paréntesis cuando lo que se quiere no es ejecutar una
operación, sino simplemente obtener un valor. El colmo ya es cuando queremos incrementar el
radio en una unidad, en la línea c.Radio(c.Radio()+1); esto es completamente antinatural, pues
lo más lógico hubiera sido poder hacerlo con esta otra línea: c.Radio++. Pero, tranquilos, C#
también nos soluciona estas pequeñas deficiencias, gracias a las propiedades.
PROPIEDADES
Como dije al principio, las propiedades también representan los datos de los objetos de
una clase, pero lo hacen de un modo completamente distinto a los campos. Antes vimos que
los campos no nos permitían tener el control de su valor salvo que fueran de sólo lectura, y si
eran de sólo lectura solamente se podían asignar una vez en el constructor. Esto puede ser
verdaderamente útil en muchas ocasiones (y por eso os lo he explicado), pero no en este caso
e concreto. Pues bien, las propiedades solventan todos estos problemas: por un lado nos
permiten tener un control absoluto de los valores que reciben o devuelven, y además no
tenemos limitaciones para modificar y cambiar sus valores tantas veces como sea preciso.
Las propiedades funcionan internamente como si fueran métodos, esto es, ejecutan el
código que se encuentra dentro de su bloque, pero se muestran al cliente como si fueran
campos, es decir, datos. Soy consciente de que, dicho así, suena bastante raro, pero verás que
es muy fácil. La sintaxis de una propiedad es la siguiente:
set
{
// Código para validar y/o asignar el valor de la propiedad
}
}
Veamos: primero el modificador de acceso, que puede ser cualquiera de los que se
usan también para los campos. Si no se indica, será private. Después la palabra static si
queremos definirla como propiedad estática, es decir, que sería accesible sin instanciar objetos
de la clase, pero no accesible desde las instancias de la misma (como los campos static).
Posteriormente se indica el tipo del dato que almacenará la propiedad (cualquier tipo valor o
cualquier tipo referencia), seguido del nombre de la propiedad. Dentro del bloque de la
propiedad ves que hay otros dos bloques: el bloque get es el bloque de retorno, es decir, el que
nos permitirá ver lo que vale la propiedad desde la aplicación cliente; y el bloque set es el
bloque de asignación de la propiedad, es decir, el que nos permitirá asignarle valores desde la
aplicación cliente. El orden en que se pongan los bloques get y set es indiferente, pero,
obviamente, ambos han de estar dentro del bloque de la propiedad. Por otro lado, si se omite el
bloque de asignación (set) habremos construido una propiedad de sólo lectura. Veremos esto
mucho mejor con un ejemplo. Vamos a modificar la clase Circunferencia para ver cómo podría
ser usando propiedades:
using System;
namespace Circunferencia
{
class Circunferencia
{
public Circunferencia(double radio)
{
this.radio=radio;
}
set
{
this.radio=value;
}
}
Bueno, lo cierto es que, desde la primera clase Circunferencia que escribimos a esta
hay un abismo... Ahora no hemos escrito métodos para modificar el radio ni para obtener los
valores de los otros datos, sino que hemos escrito propiedades. Gracias a esto conseguimos
que el cliente pueda acceder a los datos de un modo mucho más natural. Pongamos un
método Main para que aprecies las diferencias, y luego lo explicamos con calma:
c.Radio++;
Console.WriteLine("El radio de la circunferencia es
{0}",c.Radio);
Console.WriteLine("El perímetro de la circunferencia es {0}",
c.Perimetro);
Console.WriteLine("El área de la circunferencia es {0}",
c.Area);
a=Console.ReadLine();
}
Ahora puedes apreciar claramente las diferencias: accedemos a las propiedades tal y
como hacíamos cuando habíamos definido los datos de la clase a base de campos. Sin
embargo tenemos control absoluto sobre los datos de la clase gracias a las propiedades. En
efecto, podemos modificar el valor del Radio con toda naturalidad (en la línea c.Radio++) y esta
modificación afecta también a las propiedades Perimetro y Area. Vamos a ver poco a poco
cómo ha funcionado este programa: cuando instanciamos el objeto se ejecuta su constructor,
asignándose el valor que se pasa como argumento al campo radio (que es protected y, por lo
tanto, no accesible desde el cliente). Cuando recuperamos el valor de la propiedad Radio para
escribirlo en la consola se ejecuta el bloque "get" de dicha propiedad, y este bloque devuelve,
precisamente el valor del campo radio, que era la variable donde se almacenaba este dato.
Cuando se recuperan los valores de las otras dos propiedades también para escribirlos en la
consola sucede lo mismo, es decir, se ejecutan los bloques get de cada una de ellas que, como
veis, retornan el resultado de calcular dichos datos. Por último, cuando incrementamos el valor
del radio (c.Radio++) lo que se ejecuta es el bloque set de la propiedad, es decir, que se asigna
el nuevo valor (representado por "value") a la variable protected radio. ¿Y por qué las
propiedades Area y Perimetro no tienen bloque set? Recuerda que el bloque set es el bloque
de asignación; por lo tanto, si se omite, tendremos una propiedad de sólo lectura. ¿Y cuál es la
diferencia con los campos de sólo lectura? Pues la diferencia es evidente: un campo de sólo
lectura ha de estar representado necesariamente por una variable, y, además, solamente
se le puede asignar el valor una vez en el constructor; por contra, el que una propiedad
sea de sólo lectura no implica que su valor sea constante, sino única y exclusivamente
que no puede ser modificado por el cliente. Si hubiéramos puesto campos de sólo lectura no
los podría modificar ni el cliente, ni la propia clase ni el mismísimo Bill Gates en persona. ¿Y de
dónde ha salido el value? Bien, value es una variable que declara y asigna implícitamente el
compilador en un bloque set para que nosotros sepamos cuál es el valor que el cliente quiere
asignar a la propiedad, es decir, si se escribe c.Radio=8, value valdría 8. Así podremos
comprobar si el valor que se intenta asignar a la propiedad es adecuado. Por ejemplo, si el
valor que se intenta asignar al radio fuera negativo habría que rechazarlo, puesto que no
tendría sentido, pero como aún no hemos llegado a esa parte, lo dejamos para la próxima
entrega.
No me gustaría acabar esta entrega sin evitar que alguien pueda tomar conclusiones
equivocadas. Veamos, os he dicho que las propiedades funcionan internamente como si fueran
métodos, pero que no es necesario poner los paréntesis cuando son invocadas, pues se
accede a ellas como si fueran campos. Sin embargo esto no quiere decir que siempre sea
mejor escribir propiedades en lugar de métodos (claro, si no requieren más de un argumento).
Las propiedades se han inventado para hacer un uso más natural de los objetos, y no para otra
cosa. ¿Entonces, cuándo es bueno escribir un método y cuándo es bueno escribir una
propiedad? Pues bien, hay que escribir un método cuando este implique una acción, y una
propiedad cuando esta implique un dato. Vamos a retomar una vez más el "remanido" ejemplo
de la clase Coche. Podíamos haber escrito el método Frenar como una propiedad, con lo que,
en la aplicación cliente tendríamos que invocarlo así: coche.Frenar=10. Sin embargo, aunque
funcionaría exactamente igual, esto no tendría mucho sentido, pues frenar es una acción y no
un dato, y el modo más natural de ejecutar una acción es con un método, o sea,
coche.Frenar(10).
EJERCICIO 1
Bien, creo que, con todo lo que hemos aprendido hasta ahora, llega el momento de
proponeros un "pequeño ejercicio". Aparecerá resuelto con la próxima entrega (no antes, que
de lo contrario no tendría gracia), pero te recomiendo que intentes hacerlo por tu cuenta y mires
la solución cuando ya te funcione o si te quedas atascado sin remedio ya que, de lo contrario,
no aprenderás nunca a escribir código. Eso sí: mírate los apuntes y la teoría tanto como lo
necesites, porque estos siempre los vas a tener a tu disposición. También es importante que no
te rindas a las primeras de cambio: cuando te aparezcan errores de compilación intenta
resolverlos tú mismo, porque cuando estés desarrrollando una aplicación propia tendrás que
hacerlo así, de modo que lo mejor será que empieces cuanto antes. Por último, te aconsejo que
antes de mirar el ejercicio resuelto si ves que no te sale eches un vistazo a las pistas que te voy
poniendo, a ver si así lo vas sacando. Bueno, venga, vale de rollos y vamos a lo que vamos:
Ejercicio 1: Aunque soy consciente de que este ejercicio te parecerá un mundo si no
habías programado antes, te aseguro que es muy fácil. Es un poco amplio para que puedas
practicar casi todo lo que hemos visto hasta ahora. Vete haciéndolo paso por paso con
tranquilidad, y usa el tipo uint para todos los datos numéricos: Escribe una aplicación con estos
dos espacios de nombres: Geometria y PruebaGeometria. Dentro del espacio de nombres
Geometria tienes que escribir dos clases: Punto y Cuadrado. La clase Punto ha de tener dos
campos de sólo lectura: X e Y (que serán las coordenadas del punto). La clase Cuadrado ha de
tener las siguientes propiedades del tipo Punto (de solo lectura): Vertice1, Vertice2, Vertice3 y
Vertice4 (que corresponden a los cuatro vértices del cuadrado). La base de todos los
cuadrados de esta clase será siempre horizontal. También ha de tener las propiedades Lado,
Area y Perimetro, siendo la primera de lectura/escritura y las otras dos de sólo lectura. Por otro
lado, debe tener dos constructores: uno para construir el cuadrado por medio de los vértices 1 y
3 y otro para construir el cuadrado a través del Vertice1 y la longitud del lado. En el espacio de
nombres PruebaGeometria es donde escribirás una clase con un método Main para probar si
funcionan las clases escritas anteriormente. En este espacio de nombres quiero que utilices la
directiva using para poder utilizar todos los miembros del espacio de nombres Geometría
directamente. En este espacio de nombres escribe también un método que muestre todos los
datos de un cuadrado en la consola. Hala, al tajo...
Sigue este vínculo si quieres ver las pistas para resolver el ejercicio.
Hemos visto hasta ahora que los programas van ejecutando las líneas de código con
orden. Sin embargo, hay muchas situaciones en las que es preciso alterar ese orden, o bien
puede ocurrir que sea necesario que se efectúen una serie de operaciones que pueden ser
distintas en otras circunstancias. Por ejemplo, si el programa pide una clave de acceso, deberá
continuar con la ejecución normal en caso de que la clave introducida por el usuario sea
correcta, y deberá salir del mismo en caso contrario. Pues bien: para todas estas cuestiones
que, por otra parte, son muy frecuentes, tenemos las estructuras de control de flujo.
En C# contamos con varias de estas estructuras, así que las iremos explicando con
calma una a una, empezando en esta entrega con las estructuras condicionales. De nuevo he
de avisar a los programadores de C/C++: el comportamiento de algunas de estas
estructuras cambia ligeramente en C#, así que leed esta entrega atentamente, pues de lo
contrario podéis encontraros con varios problemas a la hora de usarlas.
Empezaré diciendo que, para los que no sepan inglés, if significa si condicional, es
decir, si te portas bien, te compro un helado y te dejo ver la tele. Pues bien, en programación,
es más o menos lo mismo. Pongamos un poco de pseudo-código para que los principiantes se
vayan haciendo a la idea:
if (num==10)
{
Console.WriteLine("El número es igual a 10");
}
En este pequeño ejemplo, se evalúa como verdadero o falso lo que está dentro de los
paréntesis, es decir, num==10. Por lo tanto, el operador == retornará true siempre que num
valga 10, y false si vale otra cosa. Por cierto, ya que estamos, no confundas el operador de
comparación == con el de asignación =. Digo esto porque en otros lenguajes (Visual Basic,
por ejemplo) se usa el mismo operador (=) para ambas cosas, y es el compilador el que
determina si es de comparación o de asignación según el contexto. No ocurre así en C#: == es
de comparación siempre, y = es de asignación siempre. Por lo tanto, qué hubiera sucedido
si hubiéramos escrito el ejemplo así:
if (num==10)
Console.WriteLine("El número es igual a 10");
O bien:
También puede ocurrir que tengamos que ejecutar una serie de acciones si se da una
condición y otras acciones en caso de que esa condición no se dé. Pues bien, para eso
tenemos la instrucción else. Volviendo a la interpretación lingüística para favorecer todo esto a
los principiantes, sería como un "de lo contrario", es decir, si te portas bien, te compro un
helado y te dejo ver la tele; de lo contrario, te castigo en tu cuarto y te quedas sin cenar.
¿Quieres un poquito de pseudo-código para ver esto? Venga, aquí lo tienes:
if (num==10)
{
Console.WriteLine("El número es igual a 10");
}
else
{
Console.WriteLine("El número no es igual a 10");
}
Esto es muy fácil, ¿no te parece? Si se cumple la condición, se ejecuta el código del
bloque if, y si no se cumple se ejecuta el código del bloque else. Del mismo modo, si el bloque
consta de una única línea, podemos ahorrarnos las llaves, así:
if (num==10)
Console.WriteLine("El número es igual a 10");
else
Console.WriteLine("El número no es igual a 10");
O bien:
Como veis, sucede lo mismo que cuando nos ahorramos las llaves anteriormente.
Ahora bien, recordad que si no se ponen las llaves, tanto if como else afectan únicamente a la
primera línea que se encuentre tras la condición. Vamos a ver estos dos ejemplos, para que os
quede esto bien claro:
if (num==10)
Console.WriteLine("El número es igual a 10");
Console.WriteLine("He dicho");
else // Incorrecto: el compilador no sabe a qué if se refiere
Console.WriteLine("El número no es igual a 10");
------------------------------------------------------
if (num==10)
Console.WriteLine("El número es igual a 10");
else
Console.WriteLine("El número no es igual a 10");
Console.WriteLine("He dicho"); // Esta línea se ejecuta siempre
En el primer caso se produciría un error en tiempo de compilación, porque el
compilador no sabría enlazar el else con el if ya que, al no haber llaves de bloque, da por
terminada la influencia de este después de la primera línea que está tras él. En el segundo
caso no se produciría un error, pero la última línea (la que escribe "He dicho" en la consola) se
ejecutaría siempre, independientemente de si se cumple o no la condición, pues no hay llaves
dentro del bloque else, por lo cual este afecta solamente a la línea que le sigue. Para que else
afectara a estas dos líneas habría que haber escrito las llaves de bloque:
if (num==10)
Console.WriteLine("El número es igual a 10");
else
{
Console.WriteLine("El número no es igual a 10");
Console.WriteLine("He dicho"); // Esta línea se ejecuta siempre
}
Ahora sí, en caso de cumplirse la condición se ejecutaría la línea que hay delante del if,
y en caso contrario se ejecutarían las dos líneas escritas dentro del bloque else.
También podría suceder que hubiera que enlazar varios if con varios else. Volvamos
con otro ejemplo para ver si nos entendemos: si compras el libro te regalo el separador, de lo
contrario, si compras la pluma te regalo el cargador, de lo contrario, si compras el cuaderno te
regalo un llavero, y, de lo contario, no te regalo nada. Veamos de nuevo el pseudo-código de
esto:
Si (compras el libro)
{
te regalo el separador;
}
de lo contrario si (compras la pluma)
{
te regalo el cargador;
}
de lo contrario si (compras el cuaderno)
{
te regalo un llavero;
}
de lo contrario
{
no te regalo nada;
}
O sea, queda claro lo que sucede: si se cumple alguna de las condiciones se producen
una serie de consecuencias, y si no se cumple ninguna de las tres condiciones la consecuencia
es que no hay regalo alguno. En este caso, sin embargo, hay que tomarse el pseudo-código al
pie de la letra para que la relación con la programación sea exacta: ¿qué ocurre si se dan dos o
las tres condiciones? Pues en la vida real, probablemente, te llevarías varios regalos, pero si lo
tomamos al pie de la letra, solamente te podrías llevar el primer regalo en el que se cumpliese
la condición, pues las demás están precedidas por la expresión "de lo contrario", que es, en sí
misma, otra condición, es decir, si se cumple una las demás ya no se contemplan aunque
también se cumplan. Esto es exactamente lo que ocurre en programación: el compilador no
sigue analizando las demás condiciones en el momento en el que encuentre una que retorna
true. Veamos algo de esto en C#:
if (num==10)
{
Console.WriteLine("El número es igual a 10");
}
else if (num>5)
{
Console.WriteLine("El número es mayor que 5");
}
else if (num>15)
{
Console.WriteLine("El número es mayor que 15");
}
else
{
Console.WriteLine("El número no es 10 ni mayor que 5");
}
Bien, examinemos las diferentes posibilidades. Si num vale 10, se ejecutará el bloque
del primer if, por lo que la salida en la consola será "El número es igual a 10". Sin embargo, a
pesar de que 10 es también mayor que cinco, no se ejecutará el bloque del primer else if, pues
ni siquiera se llega a comprobar dado que ya se ha cumplido una condición en la estructura. Si
num vale un número mayor que 5, menor que 15 y distinto de 10, o sea, 9, por ejemplo, se
ejecuta el bloque del primer else if saliendo "el número es mayor que 5" en la consola. Ahora
bien: ¿qué sucede si el número es mayor que 15? Pues sucede exactamente lo mismo, ya que,
si es mayor que 15 también es mayor que 5, de modo que se ejecuta el bloque del primer else
if y después se dejan de comprobar el resto de las condiciones. Por lo tanto, en este ejemplo, el
bloque del segundo else if no se ejecutaría en ningún caso. Por último, el bloque del else se
ejecutará siempre que num valga 5 o menos de 5, pues es el único caso en el que no se
cumple ninguna de las condiciones anteriores.
Por otra parte, las condiciones que se evalúen en un if o en un else if no tienen por qué
ser tan sencillas. Recuerda que en C# estas expresiones (las condiciones) han de retornar true
o false necesariamente, por lo que podemos usar y combinar todo aquello que pueda retornar
true o false, como variables de tipo bool, métodos o propiedades que retornen un tipo bool,
condiciones simples o compuestas mediante los operadores lógicos && (AND lógico) || (OR
lógico) y ! (NOT lógico), o incluso mezclar unas con otras. por ejemplo:
En este ejemplo, "Existe" es un método de la clase Ficheros que retorna true o false,
"Crear" es una variable bool y "EspacioDisco" es una variable de tipo uint. Como ves, en una
sola condición están combinados varios elementos que pueden retornar valores boolean. Se
encierra entre paréntesis la expresión Ficheros.Existe(Archivo) || Crear porque necesitamos
que se evalúe todo esto primero para después comparar con la otra expresión, ya que el
operador && se ejecuta antes que el ||. Así esta expresión retornará true en caso de que el
método Existe devuelva true, la variable crear valga true o sucedan ambas cosas.
Posteriormente se establece el resultado de la otra expresión, es decir EspacioDisco>1000, y
después se comparan los dos resultados con el operador &&, obteniendo el resultado final, que
será true si ambos operandos valen true, y false si alguno de ellos o los dos valen false. Así, si
el archivo existe o bien si se quiere crear en caso de que no exista se guardarán los datos si,
además, hay espacio suficiente, y si, por el contrario, el archivo no existe y no se quiere crear o
bien si no hay espacio, los datos no se guardarán.
Antes de terminar con la instrucción if, quiero puntualizar una cosa. Cuando queramos
hacer una simple asignación a una variable dependiendo de un determinado valor, podemos
hacerlo con if o bien podemos usar el operador Question (?:) (revisa cómo funciona este
operador en la entrega 4), que, a mi entender, es más cómodo. Por ejemplo, tenemos un
método en el que necesitamos saber, de entre dos números, cuál es el mayor y cuál el menor.
Podemos hacerlo con if, así:
if (num1>num2)
{
mayor=num1;
menor=num2;
}
else
{
mayor=num2;
menor=num1
}
Correcto, pero también podemos hacer la lectura del código algo más cómoda si
usamos el operador Question, así:
INSTRUCCIÓN SWITCH
Una instrucción switch funciona de un modo muy similar a una construcción con if...else
if... else. Sin embargo, hay un diferencia que es fundamental: mientras en las construcciones
if...else if... else las condiciones pueden ser distintas en cada uno de los if ... else if, en
un switch se evalúa siempre la misma expresión, comprobando todos los posibles
resultados que esta pueda retornar. Un switch equivaldría a comprobar las diferentes
situaciones que se pueden dar con respecto a una misma cosa. Por ejemplo, si te compras un
coche y tienes varias opciones de financiación: En caso de usar la primera opción te descuento
un 10 por ciento, en caso de usar la segunda opción te descuento un cinco por ciento, en caso
de usar la tercera opción te descuento un dos por ciento, y en cualquier otro caso no te
descuento nada. Como ves, se comprueba siempre el valor de un solo elemento, que en este
caso sería la opción. Pongamos un poco de pseudo-código otra vez:
comprobemos (opcion)
{
en caso de 1:
te descuento un 10%;
Nada más;
en caso de 2:
te descuento un 5%;
Nada más;
en caso de 3:
te descuento un 2%;
Nada más;
en otro caso:
no te descuento nada;
Nada más;
}
Bueno, como veis, se comprueba siempre lo que vale "opcion"; si vale 1 sucede el
primer caso, si vale 2 el segundo, si vale 3 el tercero, y si vale cualquier otra cosa sucede el
último caso. Vamos a verlo en C#:
switch (opcion)
{
case 1:
descuento=10;
break;
case 2:
descuento=5;
break;
case 3:
descuento=2;
break;
default:
descuento=0;
break;
}
Hay algunas cosas importantes en las que quiero que te fijes especialmente: solamente
se establece un bloque para la instrucción "switch", pero ninguno de los "case" abre ningún
bloque (tampoco lo hace "default"). Una vez que se terminan las instrucciones para cada caso
hay que poner "break" para que el compilador salga del switch (esto lo digo especialmente para
los programadores de Visual Basic, ya que en VB no había que poner nada al final de cada
Case en un Select Case). ¿Y qué ocurre si no se cierra el "case" con un "break"? Bien, pueden
ocurrir dos cosas (los programadores de C/C++, por favor, que no se precipiten...). Veamos: si
queremos que el programa haga las mismas cosas en distintos casos, habrá que poner todos
estos casos y no cerrarlos con break. Por ejemplo, si el descuento es 5 tanto para la segunda
como para la tercera opción, habría que hacerlo así:
switch (opcion)
{
case 1:
descuento=10;
break;
case 2:
case 3:
descuento=5;
break;
default:
descuento=0;
break;
}
Así, en caso de que opcion valiera 2 ó tres, el descuento sería del 5%, pues, si opcion
vale 2, el flujo del programa entraría por case 2 y continuaría por case 3 ejecutando el código
de este último al no haber cerrado el case 2 con break, y si opción vale 3 entraría por case 3
ejecutando, por lo tanto, el mismo código. Sin embargo, y aquí quiero que se fijen
especialmente los programadores de C/C++, no hubiera sido válido establecer acciones para
el case 2 sin cerrarlo con break. Vamos a ponerlo primero, y luego lo explico más
detenidamente:
switch (opcion)
{
case 1:
descuento=10;
break;
case 2:
regalo="Cargador de CD"
case 3:
descuento=5;
break;
default:
descuento=0;
break;
}
Voy a examinar esto como si estuviera escrito en C/C++: si opcion vale 2, el flujo
entraría por case 2, estableciendo el regalo y seguiría por case 3 estableciendo también el
descuento, dado que case 2 no ha sido cerrado con un break. Si opción vale 3 entraría
solamente por case 3, estableciendo únicamente el descuento y no el regalo. Ciertamente, esto
era muy cómodo si querías definir acciones específicas para un valor determinado y añadirles
otras que fueran comunes para varios valores. Sin embargo, esto no se puede hacer en C#,
dado que el compilador avisaría de un error, diciendo que hay que cerrar case 2. ¿Por qué?
Pues bien, a pesar de la comodidad de esta construcción en C/C++ para determinadas
circunstancias, lo cierto es que lo más común es que se omita el break por error que por
intención, lo cual provoca muchos fallos que serían muy difíciles de detectar. Por este motivo,
los diseñadores del lenguaje C# decidieron que el riesgo no merecía la pena, ya que esta
funcionalidad se puede conseguir fácilmente con un simple if, así:
switch (opcion)
{
case 1:
descuento=10;
break;
case 2:
case 3:
if (opcion==2) regalo="Cargador de CD";
descuento=5;
break;
default:
descuento=0;
break;
}
Es cierto que esto es un poco más incómodo, pero también es cierto que es mucho
más seguro, ya que se evitan los problemas que sobrevenían en C/C++ cuando te olvidabas de
poner un break.
Para esta entrega tienes un ejemplo del switch (sigue este vínculo para bajártelo), pero
no he diseñado ninguno sobre if, ya que lo vamos a usar constantemente a partir de ahora.
Además, no quiero daros todo tan hecho, porque corremos el riesgo de que aprendáis mucha
teoría pero luego no seáis capaces de llevarla a la práctica. Ciertamente, había pensado en
modificar la clase Cuadrado (sí, la del ejercicio que propuse en la entrega anterior). Recuerda
que uno de los constructores de esta clase, concretamente el que construía un cuadrado a
partir de los vértices uno y tres, era muy inseguro, puesto que si dábamos componentes x
menores para el vértice 3 que para el vértice 1, luego no nos coincidía la longitud del lado,
además de que los vértices se colocaban al revés. Sin embargo he decidido complicaros un
poco la vida, y poner esto como un ejercicio en vez de un ejemplo. Sí, sí, lo que lees, un
ejercicio. Así matamos dos pájaros de un tiro: te devanas los sesos para hacerlo y, si no te
sale, lo puedes ver hecho cuando llegue la próxima entrega. Aquí va el enunciado:
EJERCICIO 2
Pues bien, lo que hay que arreglar es esto, precisamente. El constructor debe aceptar
dos argumentos del tipo Punto sin presuponer que alguno de ellos corresponde a algún vértice
determinado, y después hacer las comprobaciones necesarias para calcular el vértice1 y la
longitud del lado correctos. Ojo, que es un poco más complicado de lo que puede parecer a
simple vista. Hala, al lío... Por cierto, no hay pistas, lo siento...
.
Aquellos de vosotros que conozcáis otros lenguajes veréis que todos estos bucles se
parecen mucho a los que ya conocéis. Los que os estéis iniciando ahora en la programación
puede que tardéis un poco en hallar la utilidad de todo esto: ¿para qué vamos a hacer que el
programa repita varias veces el mismo código? Bueno, de momento os diré que en todo
programa, al igual que los bloques if y los bloques switch, los bucles son también el pan
nuestro de cada día, así que no tardaréis en acostumbraros a ellos.
BUCLES FOR
Los bucles for van asignando valores a una variable desde un valor inicial hasta un
valor final, y cuando la variable contiene un valor que está fuera del intervalo el bucle termina.
Veamos la sintaxis para hacernos mejor a la idea:
for (var=inicial;condición;siguientevalor)
{
Instrucciones
}
Sé que esto es algo difícil de leer, incluso para aquellos que hayan programado en
otros lenguajes, puesto que los bucles for de C no se parecen mucho, en cuanto a su sintaxis,
al resto de los bucles for de los otros lenguajes, así que trataré de explicarlo con detenimiento.
Como veis, tras la sentencia for se indican las especificaciones del bucle entre paréntesis.
Dichas especificaciones están divididas en tres partes separadas por punto y coma: la parte de
asignación del valor inicial en primer lugar; la parte que verifica la continuidad del bucle
(mediante una condición) en segundo lugar; y la parte en que se calcula del siguiente valor en
tercer lugar. Pongamos un ejemplo: vamos a calcular el factorial de un número dado, que se
encuentra almacenado en la variable num. Se podría hacer de dos formas:
O bien:
Claro, para que esto funcione, la variable fact ha de valer 1 antes de que el programa
comience a ejecutar el bucle. Bien, veamos ahora cómo se van ejecutando estas instrucciones
paso a paso:
for (byte i=num; i>1 ; for (byte i=num; i>1 ; for (byte i=num; i>1 ;
i--) i--) i--)
{ { {
fact*=i; fact*=i; fact*=i;
} } }
4º paso: 5º paso: 6º paso:
for (byte i=num; i>1 ; for (byte i=num; i>1 ; for (byte i=num; i>1 ;
i--) i--) i--)
{ { {
fact*=i; fact*=i; fact*=i;
} } }
En primer lugar se asigna a la variable i el valor de num (vamos a suponer que num
vale 3), es decir, después del primer paso, el valor de i es 3. Posteriormente se comprueba si
dicha variable es mayor que 1, es decir, si 3>1. Como la condición del segundo paso se cumple
se ejecuta el código del bucle en el tercer paso, fact*=i, con lo que fact (que valía 1) ahora vale
3 (1*3). En el cuarto paso se asigna el siguiente valor a i (i--), con lo que, ahora, i valdrá 2. En
el quinto se vuelve a comprobar si i es mayor que 1, y como esto se cumple, el sexto paso
vuelve a ejecutar el código del bucle (de nuevo, fact*=i), con lo que ahora fact vale 6 (3*2). El
séptimo paso es idéntico al cuarto, es decir, se asigna el siguiente valor a la variable i (de
nuevo, i--), con lo que ahora i valdría 1. El octavo paso es idéntico al quinto, comprobando por
lo tanto si i es mayor que 1. Sin embargo esta vez, la condición no se cumple (1 no es mayor
que 1, sino igual), por lo que la ejecución saldría del bucle y ejecutaría la siguiente línea del
programa que esté fuera de él. Date cuenta de que el bucle se seguirá ejecutando siempre que
la condición ( i>1 ) se cumpla, y dejará de ejecutarse cuando la condición no se cumpla. Por lo
tanto, no habría sido válido poner i==2 en lugar de i>1, ya que esta condición se cumpliría
únicamente cuando num valiera 2, pero no en cualquier otro caso. ¿Serías capaz de ver cómo
funcionaría el otro bucle? Venga, inténtalo.
Efectivamente, se pueden colocar bucles for dentro de otros bucles for, con lo que
obtendríamos lo que se llaman los bucles for anidados. Son también muy útiles: por ejemplo,
piensa que tienes almacenadas unas cuantas facturas en una base de datos, y quieres leerlas
todas para presentarlas en pantalla. El problema está en que cada factura tiene una o varias
líneas de detalle. ¿Cómo podríamos hacer para cargar cada factura con todas sus líneas de
detalle? Pues usando bucles anidados. Colocaríamos un bucle for para cargar las facturas, y
otro bucle for dentro de él para que se cargaran las líneas de detalle de cada factura. Así, el
segundo bucle se ejecutará completo en cada iteración del primer bucle. Veamos un ejemplo
que nos aclare todo esto un poco más:
using System;
namespace BuclesAnidados
{
class BuclesAnidadosApp
{
static void Main()
{
for (int i=1; i<=3; i++)
{
Console.WriteLine("Factura número {0}", i);
Console.WriteLine("Detalles de la factura");
Console.WriteLine();
}
string a=Console.ReadLine();
}
}
}
Como ves, el bucle "j" está dentro del bucle "i", de modo que se ejecutará completo
tantas veces como se itere el bucle i. Por este motivo, la salida en consola sería la siguiente:
Factura número 1
Detalles de la factura
Línea de detalle 1
Línea de detalle 2
Línea de detalle 3
Factura número 2
Detalles de la factura
Línea de detalle 1
Línea de detalle 2
Línea de detalle 3
Factura número 3
Detalles de la factura
Línea de detalle 1
Línea de detalle 2
Línea de detalle 3
¿Sigues sin verlo claro? Bueno, veamos cómo se van ejecutando estos bucles:
for (int i=1; i<=3; i+ for (int i=1; i<=3; i+ for (int i=1; i<=3; i+
+) +) +)
{ { {
for (int j=1; j<=3; for (int j=1; j<=3; for (int j=1; j<=3;
j++) j++) j++)
{ { {
... ... ...
} } }
} } }
4º paso: 5º paso: 6º paso:
for (int i=1; i<=3; i+ for (int i=1; i<=3; i+ for (int i=1; i<=3; i+
+) +) +)
{ { {
for (int j=1; j<=3; for (int j=1; j<=3; for (int j=1; j<=3;
j++) j++) j++)
{ { {
... ... ...
} } }
} } }
7º paso: 8º paso: 9º paso:
for (int i=1; i<=3; i+ for (int i=1; i<=3; i+ for (int i=1; i<=3; i+
+) +) +)
{ { {
for (int j=1; j<=3; for (int j=1; j<=3; for (int j=1; j<=3;
j++) j++) j++)
{ { {
... ... ...
} } }
} } }
10º paso: 11º paso: 12º paso:
for (int i=1; i<=3; i+ for (int i=1; i<=3; i+ for (int i=1; i<=3; i+
+) +) +)
{ { {
for (int j=1; j<=3; for (int j=1; j<=3; for (int j=1; j<=3;
j++) j++) j++)
{ { {
... ... ...
} } }
} } }
13º paso: 14º paso: 15º paso:
for (int i=1; i<=3; i+ for (int i=1; i<=3; i+ for (int i=1; i<=3; i+
+) +) +)
{ { {
for (int j=1; j<=3; for (int j=1; j<=3; for (int j=1; j<=3;
j++) j++) j++)
{ { {
... ... ...
} } }
} } }
16º paso: 17º paso: 18º paso:
for (int i=1; i<=3; i+ for (int i=1; i<=3; i+ for (int i=1; i<=3; i+
+) +) +)
{ { {
for (int j=1; j<=3; for (int j=1; j<=3; for (int j=1; j<=3;
j++) j++) j++)
{ { {
... ... ...
} } }
} } }
El decimonoveno paso sería igual que el sexto, el vigésimo igual que el séptimo, y así
hasta terminar el bucle i. Bueno, donde están los puntos suspensivos estaría el código que
forma parte del bucle j. Como ves, el segundo bucle (el bucle j) se ejecuta completo para cada
valor que toma la variable i del primero de los bucles. Vete haciendo el cálculo mental de
cuánto van valiendo las variables para que lo veas claro. Por supuesto, se pueden anidar
tantos bucles como sea necesario.
Otra forma de anidar los bucles es utilizando solamente una única sentencia for,
aunque no es un modo muy recomendable de hacerlo puesto que resulta mucho más difícil de
leer. El siguiente código:
BUCLES WHILE
Bien, para los que no sepan inglés, "while" significa "mientras", de modo que ya os
podéis hacer la idea: un bucle while se repetirá mientras una condición determinada se
cumpla, o sea, devuelva true. Veamos su sintaxis:
Efectivamente, las "Instrucciones" que se hallen dentro del bucle while se ejecutarán
continuamente mientras la expresión de tipo boolean retorne true. Por ejemplo, podemos
escribir un bucle while para pedir una contraseña de usuario. Algo así:
using System;
namespace BuclesWhile
{
class BuclesWhileApp
{
static void Main()
{
string Clave="Compadre, cómprame un coco";
string Res="";
while (Res!=Clave)
{
Console.Write("Dame la clave: ");
Res=Console.ReadLine();
}
string a=Console.ReadLine();
}
}
}
En este pequeño ejemplo el programa pedirá una y otra vez la clave al usuario, y
cuando este teclee la clave correcta será cuando finalice la ejecución del mismo. Así, la salida
en la consola de este programa sería algo como esto (en rojo está lo que se ha tecleado
durante su ejecución):
using System;
namespace BuclesWhile
{
class BuclesWhileApp
{
static void Main()
{
string Clave="Compadre, cómprame un coco";
string Res=Clave;
while (Res!=Clave)
{
Console.Write("Dame la clave: ");
Res=Console.ReadLine();
}
Console.WriteLine("La clave es correcta");
string a=Console.ReadLine();
}
}
}
La clave es correcta
Ya que la ejecución no pasa por el bucle. Bueno, ya veis que es muy sencillo. Por
cierto, luego os propondré algunos ejercicios para que practiquéis un poco todo esto de los
bucles (a ver si pensabais que os ibais a escaquear).
BUCLES DO
Ciertamente, estos bucles tienen mucho que ver con los bucles while. La diferencia es
que estos se ejecutan siempre al menos una vez, mientras que los bucles while, como
acabamos de ver antes, pueden no ejecutarse ninguna vez. Veamos la sintaxis de los bucles
"do":
do
{
Instrucciones
} while (expresión bool);
Como ves, también hay un while y una expresión boolean, pero en este caso se
encuentra al final. De este modo, la ejecución pasará siempre por las instrucciones del bucle
una vez antes de evaluar dicha expresión. Vamos a rehacer el ejemplo anterior cambiando el
bucle while por un bucle do:
using System;
namespace BuclesDo
{
class BuclesDoApp
{
static void Main()
{
string Clave="Compadre, cómprame un coco";
string Res="";
do
{
Console.Write("Dame la clave: ");
Res=Console.ReadLine();
} while (Res!=Clave);
string a=Console.ReadLine();
}
}
}
El resultado sería el mismo que antes. La diferencia está en que aquí daría
exactamente lo mismo lo que valiera la variable Res antes de llegar al bucle, puesto que este
se va a ejecutar antes de comprobar dicho valor, y al ejecutarse, el valor de Res se sustituye
por lo que se introduzca en la consola. Por lo tanto, repito, los bucles do se ejecutan siempre al
menos una vez.
Por otro lado tenemos otro tipo de bucle, los bucles foreach, pero no hablaremos de
ellos hasta que hayamos visto arrays e indizadores. Tened un poco de paciencia, que todo se
andará.
INSTRUCCIONES DE SALTO
No es que vaya a salirnos un tirinene en la pantalla dando brincos como un poseso, no.
Las instrucciones de salto permiten modificar también el flujo del programa, forzando la
siguiente iteración de un bucle antes de tiempo, o la salida del mismo o bien mandando la
ejecución directamente a un punto determinado del programa (esto último está altamente
perseguido y penado por la ley, o sea, los jefes de proyecto). Son pocas y muy sencillas, así
que podéis estar tranquilos, que no os voy a soltar otra biblia con esto...
LA INSTRUCCIÓN BREAK
Algo hemos visto ya sobre la instrucción break. ¿Cómo que no? Anda, repásate la
entrega anterior, hombre... Mira que se te ha olvidado pronto... En fin... a lo que vamos. La
instrucción break fuerza la salida de un bucle antes de tiempo o bien de una estructura de
control de flujo condicional en la que se encuentre (un switch). Ahora nos fijaremos en los
bucles, que es donde andamos. Pondremos un ejemplo sencillo: El siguiente programa
escribirá múltiplos de 5 hasta llegar a 100:
using System;
namespace InstruccionBreak
{
class InstruccionBreakApp
{
static void Main()
{
int num=0;
while (true)
{
Console.WriteLine(num);
num+=5;
if (num>100) break;
}
string a=Console.ReadLine();
}
}
}
¿Qué es eso de while (true)? Pues un bucle infinito. ¿No decíamos que dentro de los
paréntesis había que colocar una expresión boolean? Pues entonces... true es una expresión
boolean. De este modo, el bucle es infinito (claro, true siempre es true). Sin embargo, cuando la
variable num tiene un valor mayor que 100 la ejecución del bucle terminará, pues se ejecuta
una instrucción break.
LA INSTRUCCIÓN CONTINUE
La instrucción continue fuerza la siguiente iteración del bucle donde se encuentre (que
puede ser un bucle for, while, do o foreach). Como esto se ve muy bien con un ejemplo, vamos
con ello: El siguiente programa mostrará todos los números del uno al veinte a excepción de los
múltiplos de tres:
using System;
namespace InstruccionContinue
{
class InstruccionContinueApp
{
static void Main()
{
for (int i=1; i<=20; i++)
{
if (i % 3 == 0) continue;
Console.WriteLine(i);
}
string a=Console.ReadLine();
}
}
}
En este ejemplo, el bucle for va asignando valores a la variable i entre 1 y 20. Sin
embargo, cuando el valor de i es tres o múltiplo de tres (es decir, cuando el resto de la división
entre i y 3 es cero) se ejecuta una instrucción continue, de modo que se fuerza una nueva
iteración del bucle sin que se haya escrito el valor de i en la consola. Por este motivo,
aparecerían todos los números del uno al veinte a excepción de los múltiplos de tres.
Sí, C# mantiene vivo al "maldito goto". Si te digo la verdad, el goto, aparte de ser el
principal baluarte de la "programación des-estructurada", es un maestro de la supervivencia...
de lo contrario no se explicaría que siguiera vivo. En fin... Trataré de explicaros cómo funciona
sin dejarme llevar por mis sentimientos... De momento te diré que goto hace que la ejecución
del programa salte hacia el punto que se le indique. Simple y llanamente. Luego te pongo
ejemplos, pero antes quiero contarte alguna cosilla sobre esta polémica instrucción.
Según tengo entendido, la discusión sobre mantener o no el goto dentro del lenguaje
C# fue bastante importante. Puede que alguno se esté preguntando por qué. Veamos: la
primera polémica sobre el goto surgió cuando se empezaba a hablar de la programación
estructurada, allá por finales de los 60 (hay que ver, yo aún no había nacido). Si alguno ha
leído algún programa escrito por un "aficionado" al goto sabrá perfectamente a qué me refiero:
esos programas son como la caja de pandora, puesto que no sabes nunca qué puede pasar
cuando hagas un cambio aparentemente insignificante, ya que no tienes modo se saber a qué
otras partes del programa afectará ese cambio.
switch (opcion)
{
case 1:
descuento=10;
break;
case 2:
case 3:
if (opcion==2) regalo="Cargador de CD";
descuento=5;
break;
default:
descuento=0;
break;
}
En este ejemplo, si opción valía 2 se asignaba una cadena a la variable regalo y,
además se asignaba 5 a la variable descuento. Pues bien, en este caso un goto habría
resultado mucho más natural, intuitivo y fácil de leer. Veámoslo:
switch (opcion)
{
case 1:
descuento=10;
break;
case 2:
regalo="Cargador de CD";
goto case 3;
case 3:
descuento=5;
break;
default:
descuento=0;
break;
}
Como veis, hemos resuelto el problema anterior de un modo mucho más natural que
antes, sin tener que usar una sentencia if. Veamos ahora un ejemplo de cómo NO se debe
usar un goto:
Uno:
{
descuento=10;
goto Fin;
}
Dos:
{
regalo="Cargador de CD";
}
Tres:
{
descuento=5;
goto Fin;
}
Otro:
descuento=0;
Fin:
Console.WriteLine("El descuento es {0} y el regalo {1}",
descuento, regalo);
Este fragmento de código hace lo mismo que el anterior, pero, indudablemente, está
muchísimo más enredado, es mucho más difícil de leer, y hemos mandado a paseo a todos los
principios de la programación estructurada. Como ves, un mal uso del goto puede hacer que un
programa sencillo en principio se convierta en un auténtico desbarajuste. En resumen, no
hagáis esto nunca.
Si queréis mi opinión, yo soy partidario de usar el goto sólo en casos muy concretos
en los que verdaderamente haga la lectura del código más fácil (como en el ejemplo del
switch), aunque, si te digo la verdad, no me hubiera molestado nada en absoluto si el goto
hubiera sido suprimido por fin. De todos modos, si no tienes muy claro cuándo es bueno usarlo
y cuándo no, lo mejor es no usarlo nunca, sobre todo si vives de esto y quieres seguir
haciéndolo. Nadie se lleva las manos a la cabeza si se da un pequeño rodeo para evitar el
goto, pero mucha gente se pone extremadamente nerviosa nada más ver uno, aunque esté
bien puesto.
RECURSIVIDAD
Bueno, en realidad esto no tiene mucho que ver con las estructuras de control de flujo,
pero he decidido ponerlo aquí porque en algunos casos un método recursivo puede reemplazar
a un bucle. Además no sabría cómo hacer para colocarlo en otra entrega... y no quería dejarlo
sin explicar, aunque sea un poco por encima y a pesar de que esta entrega se alargue un poco
más de lo normal.
Bien, vamos al tajo: los métodos recursivos son métodos que se llaman a sí mismos.
Sé que puede dar la impresión de que, siendo así, la ejecución no terminaría nunca, pero sin
embargo esto no es cierto. Los métodos recursivos han de finalizar la traza en algún punto.
Veámoslo con un ejemplo. ¿Recordáis cómo habíamos calculado el factorial mediante un
bucle? Pues ahora vamos a hacerlo con un método recursivo. Fíjate bien:
Sí, lo sé, reconozco que es algo confuso, sobre todo para aquellos que estéis
empezando. Pero tranquilos, que trataré de explicaros esto con detenimiento. Primero explicaré
los motivos por los que uso un tipo double como valor de retorno y un tipo byte para el
argumento. Veamos, uso el tipo double porque es el que admite valores más grandes, sí, más
que el tipo Decimal, ya que se almacena en memoria de un modo diferente. Por otro lado, uso
el tipo byte para el argumento sencillamente porque no tendría sentido usar un tipo que acepte
números mayores, ya que pasando de 170 el valor del factorial no cabe ni si quiera en el tipo
double. Una vez aclarado esto, veamos cómo funciona. Primero os dibujo la traza, tal y como
funciona si se quiere calcular el factorial de 3 (o sea, num vale 3):
Para asegurarme de que comprendes esto bien, observa el código y el gráfico según
vas siguiendo la explicación. Cuando en el programa hacemos una llamada al método (Fact(3)
en el gráfico) este, evidentemente, comienza su ejecución. Primero comprueba si el argumento
que se le ha pasado es igual a cero (revisa el código). Como en este caso el argumento vale 3,
el método retornará lo que valga el producto de 3 por el factorial de 3-1, o sea, 3 por el factorial
de 2. Claro, para poder retornar esto debe calcular previamente cuánto vale el factorial de 2,
por lo se produce la segunda llamada al método Fact. En esta segunda llamada, sucede algo
parecido: el argumento vale 2, y como no es igual a cero el método procede a retornar 2 por el
factorial de 1 (2 - 1), pero, obviamente, vuelve a suceder igual. Para poder retornar esto ha de
calcular previamente cuánto vale el factorial de 1, por lo que se produce la tercera llamada al
método Fact, volviendo a darse de nuevo la misma situación: como 1 no es igual a cero,
procede a retornar el producto de 1 por el factorial de cero, y de nuevo tiene que calcular
cuánto vale el factorial de cero, por lo que se produce una nueva llamada al método Fact. Sin
embargo esta vez sí se cumple la condición, es decir, cero es igual a cero, por lo que esta vez
el método Fact retorna 1 al método que lo llamó, que era el que tenía que calcular previamente
cuánto valía el factorial de 0 y multiplicarlo por 1. Así, la función que tenía que calcular
1*Fact(0) ya sabe que la última parte, es decir, Fact(0), vale 1, por lo que hace el producto y
retorna el resultado al método que lo llamó, que era el que tenía que calcular cuánto valía 2 *
Fact(1). Como este ya tiene el resultado de Fact(1) (que es, recuerda 1*1), ejecuta el producto,
retornando 2 al método que lo llamó, que era el que tenía que calcular cuánto valía 3*Fact(2).
Como ahora este método ya sabe que Fact(2) vale 2, ejecuta el producto y retorna el resultado,
que es 6, finalizando la traza. Si te das cuenta, un método recursivo va llamándose a sí mismo
hasta que se cumple la condición que hace que termine de llamarse, y empieza a retornar
valores en el orden inverso a como se fueron haciendo las llamadas.
Bueno, creo que ya está todo dicho por hoy, así que llega el momento de los ejercicios.
Sujétate fuerte a la silla, porque esta vez te voy a poner en unos cuantos aprietos.
EJERCICIO 3
Antes de nada, no te asustes que es muy fácil. Si no sabes qué es alguna cosa, en las
pistas te doy las definiciones de todo. En este ejercicio te voy a pedir que escribas seis
métodos, los cuales te detallo a continuación:
• El método rFact: debe ser recursivo y retornar el factorial de un número. Ahora
bien, no ve vale que copies el que está escrito en esta entrega. A ver si eres capaz de hacerlo
con una sola línea de código en lugar de dos.
• El método itFact: debe retornar también el factorial de un número, pero esta
vez tiene que ser iterativo (o sea, no recursivo).
• El método rMCD: debe ser recursivo y retornar el máximo común divisor de dos
números. En las pistas te escribo el algoritmo para poder hacerlo.
• El método itMCD: también debe retornar el máximo común divisor de dos
números, pero esta vez debe ser iterativo (o sea, no recursivo).
• El método MCM: debe ser iterativo y retornar el mínimo común múltiplo de dos
números.
• El método EsPerfecto: debe ser iterativo y retornar true si un número dado es
perfecto y false si el número no es perfecto.
Obviamente, todos ellos han de ser static, para que se puedan llamar sin necesidad de
instanciar ningún objeto. Escribe también un método Main que pruebe si todos ellos funcionan.
Por cierto, trata de hacerlos de modo que sean lo más eficientes posible, esto es, que hagan el
menor número de operaciones posible. Hala, al tajo...
ARRAYS
Antes de comenzar a explicaros con mayor claridad qué es un array quiero advertir
nuevamente a los programadores de C/C++: En C#, aunque parecidos, los arrays son
diferentes tanto semántica como sintácticamente, de modo que te recomiendo que no pases
por alto esta entrega.
Bien, una vez hechas todas las aclaraciones previas, creo que podemos comenzar. Un
array es un indicador que puede almacenar varios valores simultáneamente. Cada uno de
estos valores se identifica mediante un número al cual se llama índice. Así, para acceder al
primer elemento del array habría que usar el índice cero, para el segundo el índice uno, para el
tercero el índice dos, y así sucesivamente. Que nadie se preocupe si de momento todo esto es
un poco confuso, ya que lo voy a ir desmenuzando poco a poco. Vamos a ver cómo se declara
un array:
tipo[] variable;
Bien, como veis es muy parecido a como se declara una variable normal, sólo que hay
que poner corchetes detrás del tipo. Los programadores de C/C++ habrán observado
inmediatamente la diferencia sintáctica. En efecto, en la declaración de un array en C# los
corchetes se colocan detrás del tipo y no detrás de la variable. Esta pequeña diferencia
sintáctica se debe a una importante diferencia semántica: aquí los arrays son objetos derivados
de la clase System.Array. Por lo tanto, y esto es muy importante, cuando declaramos un array
en C# este aún no se habrá creado, es decir, no se habrá reservado aún memoria para él. En
consecuencia, los arrays de C# son todos dinámicos, y antes de poder usarlos habrá que
instanciarlos, como si fuera cualquier otro objeto. Veamos un breve ejemplo de lo que quiero
decir:
string[] nombres; // Declaración del array
nombres = new string[3]; // Instanciación del array
En efecto, tal como podéis apreciar, el array nombres será utilizable únicamente a partir
de su instanciación. En este ejemplo, el número 3 que está dentro de los corchetes indica el
número total de elementos de que constará el array. No os equivoquéis, puesto que todos los
arrays de C# están basados en cero, esto es, el primer elemento del array es cero. Por lo tanto,
en este caso, el último elemento sería 2 y no 3, ya que son tres los elementos que lo
componen (0, 1 y 2). Veamos un ejemplo algo más completo y después lo comentamos:
using System;
namespace Arrays
{
class ArraysApp
{
static void Main()
{
string[] nombres; // Declaración del array
ushort num=0;
do
{
try
{
Console.Write("¿Cuántos nombres vas a introducir?
");
num=UInt16.Parse(Console.ReadLine());
}
catch
{
continue;
}
} while (num==0);
string a=Console.ReadLine();
a=Console.ReadLine();
}
}
}
Veamos ahora la salida en la consola (en rojo, como siempre, lo que se ha escrito
durante la ejecución del programa):
using System;
namespace Arrays2
{
class Arrays2App
{
static void Main()
{
ushort num=3;
do
{
try
{
Console.Write("¿Cuántos nombres vas a introducir?
");
num=UInt16.Parse(Console.ReadLine());
}
catch
{
continue;
}
} while (num==0);
string a=Console.ReadLine();
a=Console.ReadLine();
}
}
}
Bien, ahora, como puedes observar, el array ha sido instanciado en la misma línea en
la que fue declarado. El funcionamiento de este ejemplo, por lo tanto, sería el mismo que el del
ejemplo anterior. Veamos ahora otro ejemplo de inicialización del array asignándole los valores
en la declaración:
using System;
namespace Arrays3
{
class Arrays3App
{
static void Main()
{
// Declaración e inicialización del array
string[] nombres={"Juanito", "Jaimito", "Joselito"};
string a=Console.ReadLine();
}
}
}
En este caso, el array nombres ha sido inicializado en la propia declaración del mismo,
asignándole los tres valores que va a contener. Como ves, dichos valores están entre llaves y
separados por comas. Las comillas son necesarias en este caso, ya que el array es de tipo
string. ¿Que dónde está la instanciación del array? Bueno, cuando hacemos esto, la
instanciación la hace por debajo el compilador, es decir, de forma implícita. Presta atención
también a la condición del bucle: ahora hemos usado la propiedad Length del array nombres en
lugar de una variable. En efecto, esta propiedad nos devuelve el número de elementos de un
array. Por lo tanto, la salida en consola de este programa sería esta:
Elemento 0: Juanito
Elemento 1: Jaimito
Elemento 2: Joselito
Por otro lado, el hecho de que un array haya sido inicializado no quiere decir que sea
inamovible. Si un array que ya contiene datos se vuelve a instanciar, el array volverá a estar
vacío, y obtendrá las dimensiones de la nueva instanciación.
Bien, todos estos arrays que hemos explicado hasta el momento son arrays
unidimensionales, es decir, que tienen una sola dimensión (un solo índice). Sin embargo esto
no soluciona aún todas las necesidades del programador. Pongamos, por ejemplo, que
queremos almacenar las combinaciones de las ocho columnas de una quiniela de fútbol en un
array.¿Cómo lo hacemos? Pues bien, el mejor modo es utilizar un array multidimensional.
ARRAYS MULTIDIMENSIONALES
Los arrays multidimensionales son aquellos que constan de dos o más dimensiones, es
decir, que cada elemento del array viene definido por dos o más índices. Vamos a echar un
vistazo a la declaración de un array multidimensional (en este caso, será tridiensional, es decir,
con tres dimensiones):
tipo[,,] variable;
Como ves, hay dos comas dentro de los corchetes, lo cual indica que el array es
tridimensional, puesto que los tres índices del mismo se separan uno de otro por comas.
Veamos un pequeño ejemplo que lo clarifique un poco más:
alumnos[0,0]="Lolo";
alumnos[0,1]="Mario";
alumnos[0,2]="Juan";
alumnos[0,3]="Pepe";
alumnos[1,0]="Lola";
alumnos[1,1]="María";
alumnos[1,2]="Juana";
alumnos[1,3]="Pepa";
AULA 0 AULA 1
NOMBRE 0 Lolo Lola
NOMBRE 1 Mario María
NOMBRE 2 Juan Juana
NOMBRE 3 Pepe Pepa
¿Que quieres saber por qué he separado a los chicos de las chicas? Bueno, no es que
sea un retrógrado, es para que se vea mejor todo esto. Mira que sois detallistas... Bueno, creo
que va quedando bastante claro. ¿Y cómo recorremos un array multidimensional? Pues con
bucles anidados. Vamos ya con un ejemplo más completito de todo esto. Este pequeño
programa pregunta al usuario por el número de columnas que quiere generar de una quiniela
de fútbol, y después las rellena al azar y las muestra en pantalla:
using System;
namespace Quinielas
{
class QuinielasApp
{
static void Main()
{
const char local='1';
const char empate='X';
const char visitante='2';
const byte numFilas=14;
byte numColumnas=0;
char[,] quiniela;
byte azar;
Random rnd=new Random(unchecked((int)
DateTime.Now.Ticks));
do
{
try
{
Console.WriteLine("Mínimo una columna y máximo
ocho");
Console.Write("¿Cuántas columnas quieres generar?
");
numColumnas=Byte.Parse(Console.ReadLine());
}
catch
{
continue;
}
} while (numColumnas<1 || numColumnas>8);
a=Console.ReadLine();
}
}
}
Como veis, esto se va poniendo cada vez más interesante. De este programa, aparte
de la clase Random, hemos visto todo excepto los bloques try y catch, de modo que si hay algo
que no entiendes te recomiendo que revises las entregas anteriores. La clase Random es para
generar números aleatorios (al azar). En la instanciación de dicha clase hemos puesto algo que
puede resultarte algo confuso. Es esta línea:
Bien, el constructor de esta clase tiene dos sobrecargas: una de ellas es sin
argumentos, y la otra acepta un argumento de tipo int, que es la que hemos usado. ¿Por qué?
Porque de lo contrario siempre generaría los mismos números en cada ejecución del programa,
lo cual no sería muy útil en este caso. Como necesitamos que se generen números distintos
tenemos que pasarle números diferentes en el argumento int del constructor de la clase
Random, y el modo más eficaz de conseguirlo es hacer que ese número dependa del tiempo
que lleve encendido el ordenador. Por otro lado, el número lo generamos al ejecutar el método
NextDouble, el cual nos retorna un número mayor o igual a 0 y menor que 1. Esta es la línea:
azar=(byte) (rnd.NextDouble()*3D);
¿Por qué lo hemos multiplicado por 3D? Pues bien, como queremos números enteros
entre 0 y 2 (o sea, 0, 1 o 2) bastará con multiplicar este número (recuerda que está entre cero y
uno) por 3. ¿Y la D? Ahora voy, hombre. ¿Os acordáis de los sufijos en los literales, para
indicar si se debía considerar si el número era de un tipo o de otro? Pues aquí está la
explicación. Dado que el método NextDouble retorna un valor double, tenemos que multiplicarlo
por otro valor double. Por eso le ponemos el sufijo "D" al número tres. Después todo ese
resultado se convierte a byte y se asigna a la variable azar, que es la que se comprueba en el
switch para asignar el carácter necesario según su valor a cada elemento del array.
Por lo demás creo que a estas alturas no debería tener que explicaros gran cosa:
tenemos un par de bucles anidados para asignar los valores al array y después otros dos
bucles anidados para recorrer dicho array y mostrar su contenido en la consola.
Otra cuestión importante en la que quiero que te fijes es en que ya estoy empezando a
dejar de usar "literales y números mágicos", usando constantes en su lugar. Efectivamente,
podría haberme ahorrado las cuatro constantes: local, empate, visitante y numFilas, poniendo
sus valores directamente en el código, algo así:
...
for (byte i=0; i<numColumnas; i++)
{
for (byte j=0; j<14; j++)
{
azar=(byte) (rnd.NextDouble()*3D);
switch (azar)
{
case 0:
quiniela[i,j]='1';
break;
case 1:
quiniela[i,j]='X';
break;
case 2:
quiniela[i,j]='2';
break;
}
}
}
...
En efecto, funcionaría exactamente igual, pero ¿qué ocurriría si otra persona que no
sabe qué es una quiniela, o por qué tiene que ser el número 14, o qué significan el 1, la X o el
2? Pues que el código sería menos claro. Las constantes, sin embargo, hacen la lectura del
código más fácil. Por otro lado, si algún día cambiaran los signos, por ejemplo, si hubiese que
poner una "a" en lugar del "1", una "b" en lugar de la "x" y una "c" en lugar del "2" y no
hubiésemos usado constantes habría que buscar todos estos literales por todo el código y
sustituirlos uno por uno, mientras que usando constantes (que están declaradas al principio)
basta con modificar sus valores, haciendo así el cambio efectivo ya para todo el programa. Así
que ya lo sabéis: a partir de ahora vamos a evitar en lo posible los "literales y los números
mágicos".
Para terminar con esto, el número de dimensiones de un array se llama rango. Para
conocer el rango de un array mediante código basta con invocar la propiedad Rank del mismo
(heredada de la clase System.Array). Veamos un ejemplo de esto:
using System;
namespace Rangos
{
class RangosApp
{
static void Main()
{
int[] array1=new int[2];
int[,] array2=new int[2,2];
int[,,] array3=new int[2,2,2];
int[,,,] array4=new int[2,2,2,2];
string a=Console.ReadLine();
}
}
}
Rango de array1: 1
Rango de array2: 2
Rango de array3: 3
Rango de array4: 4
ARRAYS DE ARRAYS
En efecto, para liar un poco más la madeja, tenemos también los arrays de arrays.
Estos son arrays que pueden contener otros arrays. ¿Y para qué diablos queremos meter un
array dentro de otro? ¿No nos basta con los arrays multidimensionales? Pues realmente podría
bastarnos, en efecto, pero habría ocasiones en las que tendríamos que hacer bastantes
cabriolas con el código por no usar los arrays de arrays. Pensad en un programa en el que el
usuario tiene que manejar simultáneamente múltiples objetos de distintas clases derivadas de
una clase base, por ejemplo, triángulos y cuadrados derivados de la clase figura. Si solamente
pudiéramos usar arrays unidimensionales o multidimensionales tendríamos que declarar un
array distinto para cada tipo de objeto (uno para triángulos y otro para cuadrados). La dificultad
viene ahora: ¿Qué ocurre si hay que redibujar todos los objetos, ya sean cuadrados o
triángulos? Evidentemente, habría que escribir un bucle para cada uno de los arrays para poder
invocar los métodos Redibujar de cada uno de los elementos. Sin embargo, si metemos todos
los arrays dentro de un array de arrays nos bastaría con escribir un par de bucles anidados
para recorrer todos los objetos y dejar el resto en manos del polimorfismo. Ciertamente, aún no
hemos estudiado a fondo ninguno de los mecanismos de la herencia. No obstante, con lo que
sabemos hasta ahora, podemos poner un ejemplo sobre los arrays de arrays, aunque
probablemente no se aprecie realmente la ventaja. Veamos el ejemplo, y luego lo comentamos.
Eso sí, presta especial atención a la sintaxis, tanto en la declaración como en las
instanciaciones:
using System;
namespace ArraysdeArrays
{
class ArraysDeArraysApp
{
static void Main()
{
object[][] numeros; // Declaración del array de arrays
numeros=new object[2][]; // Instanciación del array de
arrays
numeros[0][0]=3.325D;
numeros[0][1]=6.25D;
numeros[0][2]=3D;
numeros[1][0]=3u;
numeros[1][1]=7u;
numeros[1][2]=4u;
numeros[1][3]=87u;
string a=Console.ReadLine();
}
}
}
En este ejemplo vamos a usar un array en el que incluiremos dos arrays: uno para
números de tipo double y otro para números de tipo ulong. Como estos dos tipos están
derivados de la clase System.Object, lo que hacemos es declarar el array de este tipo en la
primera línea del método Main, y después lo instanciamos diciéndole que contendrá dos arrays
(en la segunda línea). Después instanciamos también como tipo object los dos arrays que
contendrá el primero, y le asignamos valores: al array numeros[0] le asignamos valores de tipo
double, y al array numeros[1] le asignamos valores de tipo ulong. Después usamos un par de
bucles anidados para recorrer todos los elementos del array de arrays con el objeto de invocar
el método ToString() de todos ellos (heredado de la clase System.Object). Como ves, el bucle
"i" recorre el array de arrays (fíjate en la condición, i<numeros.Length), y el bucle "j" recorre
cada uno de los elementos del array numeros[i], según sea el valor de i en cada iteración. Con
los ejemplos de esta entrega se incluye también el de los cuadrados y los triángulos que te
mencioné antes (en la carpeta Figuras), pero no lo reproduzco aquí porque aún no hemos visto
la herencia. Sin embargo, cuando lo ejecutes, verás mejor la utilidad de los arrays de arrays.
Bien, creo que ya es suficiente para esta entrega. No te pondré ejercicios sobre los
arrays todavía, pues prefiero esperar a que hayamos vistos los indizadores y los bucles
foreach. Por cierto... espero que la próxima entrega no se haga esperar tanto como esta. A ver
si hay algo de suerte...
Cada vez estamos más cerca de las opciones más avanzadas de este lenguaje. En
esta entrega comenzaremos hablando de un concepto más o menos nuevo: los indizadores.
Ciertamente, el palabro es un tanto extraño, pero os aseguro que esta vez no he sido yo el que
le ha puesto ese nombre...
INDIZADORES
...
}
Los indizadores, sin embargo, ofrecen la posibilidad de tratar al objeto Libro como si
fuera un array o una colección en sí mismo, haciendo la codificación más intuitiva a la hora de
usarlo. Si hubiéramos escrito la clase Libro como un indizador, el código equivalente al anterior
podría ser algo como esto:
...
}
Sin duda, este código resulta mucho más natural: ya que el objeto Libro no es más que
un conjunto de capítulos, lo suyo es tratarlo como si fuera un array, independientemente de que
dicho objeto pueda ofrecer también otra serie de propiedades, métodos y demás.
Bien, ahora que ya sabemos para qué sirve un indizador podemos ver su sintaxis. Ya
veréis que no es nada del otro mundo:
class Libro
{
public object this[int index]
{
get
{
...
}
set
{
...
}
}
...
}
using System;
using System.Collections;
namespace IndizadorLibros
{
class Libro
{
private ArrayList capitulos=new ArrayList();
class IndizadorLibrosApp
{
static void Main()
{
Libro miLibro=new Libro();
string a=Console.ReadLine();
}
}
}
Por otro lado, veis que he declarado el campo privado "capitulos" del tipo
System.Collections.ArrayList. ¿Queréis saber por qué? Pues porque necesito meter los
capítulos en algún array o en alguna colección (en este caso, se trata de una colección). Es
decir, una vez más, el hecho de implementar un indizador no convierte a una clase en una
colección o en un array, sino simplemente hace que ofrezca una interfaz similar a estos, nada
más. Por lo tanto, si tengo que almacenar elementos en un array o en una colección,
lógicamente, necesito un array o una colección donde almacenarlos. En definitiva, el indizador
hace que podamos "encubrir" dicha colección en aras de obtener una lógica más natural y
manejable.
Para terminar, la línea que dice "throw new Exception..." manda un mensaje de error al
cliente. No os preocupéis demasiado porque veremos las excepciones o errores a su debido
tiempo.
SOBRECARGA DE OPERADORES
Esto es algo que te sonará muy extraño, sobre todo al principio, si no eras programador
de C++, así que procura tomártelo con calma y entenderlo bien porque aquí es bastante fácil
armarse un buen jaleo mental.
Todos sabemos que es perfectamente factible sumar dos números de tipo int, o dos de
tipo short, e incluso se pueden sumar dos números de tipos distintos. Pero, por ejemplo, ¿qué
ocurriría si tengo una variable en la que almaceno cantidades en metros y otra en centímetros y
las sumo? Pues ocurrirá que el resultado sería incorrecto, puesto que solo puedo sumar metros
con metros y centímetros con centímetros. Esto es:
double m=10;
double c=10;
double SumaMetros=m+c;
double SumaCentimetros=m+c;
Console.WriteLine(SumaMetros);
Console.WriteLine(SumaCentimetros);
Metros SumaMetros=m+c;
Centimetros SumaCentimetros=c+m;
Console.WriteLine(SumaMetros.Cantidad);
Console.WriteLine(SumaCentimetros.Cantidad);
Y que el resultado sea correcto, es decir, 10.1 metros y 1010 centímetros, sin
necesidad de convertir previamente los centímetros a metros y viceversa. ¿Lo vamos pillando?
¿Sí? Bien es cierto que esto resulta aún un tanto extraño al tener que usar los constructores y
la propiedad Cantidad, pero más adelante veremos que esto también tiene solución. Por ahora
vamos a ver la sintaxis de un operador + sobrecargado:
public Metros() {}
return retValue;
}
}
Como ves, la sobrecarga del operador está abajo del todo y en negrilla. ¿Cómo
fucionaría esto? Pues vamos a ver si consigo explicártelo bien para que lo entiendas: Digamos
que esto es un método que se ejecutará cuando el compilador se tope con una suma de dos
objetos, el primero de la clase Metros y el segundo de la clase Centimetros. Por ejemplo, si "m"
es un objeto de la clase Metros, y "c" un objeto de la clase Centimetros, la línea:
Metros SumaMetros=m+c;
Haría que se ejecutara el método anterior. Es decir, se crea un nuevo objeto de la clase
Metros (el objeto retValue). En su propiedad Cantidad se suman lo que valga la propiedad
Cantidad del argumento m y la centésima parte de la propiedad Cantidad del argumento c,
retornando al final el objeto con la suma hecha, objeto que se asignaría, por lo tanto, a
SumaMetros. Es decir, al haber sobrecargado el operador + en la clase Metros, cuando el
compilador se encuentra con la suma en cuestión lo que hace es lo que hayamos
implementado en el método, en lugar de la suma normal. Dicho de otro modo, hemos
modificado el comportamiento del operador +.
Ahora bien, ¿qué ocurriría si nos encontramos con una resta en lugar de una suma?
Metros SumaMetros=m-c;
return retValue;
}
Ya ves que es muy parecido, sólo que esta vez restamos en lugar de sumar. Lo mismo
habría que hacer con los operadores * y /, para que también se hicieran correctamente las
multiplicaciones y divisiones.
Por otro lado, ¿cómo reaccionaría el compilador si se encuentra con esta otra línea?
Centimetros SumaCentimetros=c+m;
Pues volvería a dar error, puesto que la sobrecarga en la clase Metros afecta a las
sumas cuando el primer operando es de la clase Metros y el segundo es de la clase
Centímetros. Ya sé que estarás pensando en añadir otra sobrecarga del operador + en la clase
Metros en la que pongas los centímetros en el primer argumento y los metros en el segundo.
Sin embargo, eso seguiría dando error, puesto que SumaCentimetros no es un objeto de la
clase Metros, sino de la clase Centimetros. Por lo tanto, lo más adecuado sería sobrecargar el
operador de ese modo pero en la clase Centimetros y no en la clase Metros.
Puede que ahora estés pensando también en sobrecargar el operador =, para que
puedas asignar directamente un objeto de la clase centímetros a otro de la clase metros. Sin
embargo el operador de asignación (es decir, =) no se puede sobrecargar. Mala suerte... En
lugar de esto podemos usar las conversiones definidas, como vemos a continuación.
Binarios: +, -, *, /, %, &, |, ^,<<, >>, ==, !=, >, <, >=, <=
Efectivamente, tal como supones, las comas las he puesto para separar los
operadores, porque la coma no se puede sobrecargar.
CONVERSIONES DEFINIDAS
Son también un elemento de lo más útil. Hasta ahora hemos visto que con la
sobrecarga de operadores podemos hacer algo tan inverosímil como por ejemplo sumar
objetos de clases distintas e incompatibles. Pues bien, las conversiones definidas vienen a
abundar un poco más sobre estos conceptos, permitiendo hacer compatibles tipos que antes
no lo eran.
Decíamos antes que el fragmento de código que pongo a continuación seguía siendo
un tanto extraño por culpa de tener que usar constructores y propiedades:
Metros SumaMetros=m+c;
Centimetros SumaCentimetros=c+m;
Console.WriteLine(SumaMetros.Cantidad);
Console.WriteLine(SumaCentimetros.Cantidad);
Las conversiones definidas nos van a permitir manejar todo esto de un modo mucho
más natural, como veréis en este código equivalente:
Metros SumaMetros=m+c;
Centimetros SumaCentimetros=c+m;
Console.WriteLine((double) SumaMetros);
Console.WriteLine((double) SumaCentimetros);
Ahora todo parece mucho más claro, ¿no es cierto? Como veis, ya no tenemos que
complicarnos en usar constructores y tampoco tenemos que usar la propiedad Cantidad para
escribir su valor con WriteLine. Esto es así porque, tanto para la clase Metros como para la
clase Centimetros, hemos creado conversiones definidas que nos han hecho compatibles esas
clases con el tipo double. Gracias a esto podemos convertir a metros un número (como en la
primera línea) y también podemos convertir a double un objeto de la clase Metros (dentro de
WriteLine). Veamos el código de estas conversiones definidas en la clase Metros:
public static explicit operator Metros(double cant)
{
Metros retValue=new Metros(cant);
return retValue;
}
return retValue;
}
Con lo que el resultado todavía es mucho más manejable. Fijaos en cómo podríamos
dejar finalmente el código cliente de las clases Metros y Centimetros:
Metros m=10;
Centimetros c=10;
Metros SumaMetros=m+c;
Centimetros SumaCentimetros=c+m;
Console.WriteLine(SumaMetros);
Console.WriteLine(SumaCentimetros);
Ahora veis que ni siquiera hemos necesitado hacer las conversiones explícitas, ya que
las conversiones definidas en ambas clases las hemos hecho implícitas con la palabra clave
implicit en lugar de explicit. ¿Que por qué se puede asignar 10, que es de tipo int al no tener el
sufijo D, si la conversión definida está creada solamente para el tipo double? Pues,
sencillamente, porque el tipo double es compatible con el tipo int, de modo que basta con hacer
la conversión definida con el tipo double para que sirva con todos los tipos numéricos.
Antes de terminar aún nos queda una cosilla más. ¿qué pasaría si intentamos asignar
un objeto de la clase centímetros a otro de la clase metros y viceversa?
Metros cEnMetros=c;
Centimetros mEnCentimetros=m;
Pues, nuevamente, el compilador generaría otro error. Antes de poder hacer eso
deberíamos crear también las conversiones definidas pertinentes en Metros y Centímetros para
hacerlos compatibles y, también, para que se asignen los valores adecuadamente. Vamos a
verlo:
return retValue;
}
return retValue;
}
Como veis, no basta con escribir las conversiones para hacerlos compatibles, sino que
también hay que escribirlas correctamente para que se asignen valores adecuados, es decir, si
a "m" (que es de tipo Metros) le asignamos 10 centímetros, "m" tiene que valer,
necesariamente, 0.1 metros.
Por último, sería bueno que no os olvidarais de otro pequeño detalle, y es reescribir el
método ToString (recordad que este método se hereda siempre de System.Object). Soy
consciente de que aún no hemos visto la herencia, pero bueno, dicho método tendría que ir así
tanto en la clase Metros como en la clase Centímetros:
Por otra parte tienes que pensar en un detalle que es muy importante: si por medio de
la sobrecarga de operadores y las conversiones definidas haces que dos tipos sean
compatibles, tienes que intentar que sean compatibles a todos los niveles posibles. Por
ejemplo, con las clases Metros y Centimetros, habría que sobrecargar también los
operadores /, *, %, >, >=, <, <=, == y != para hacer posibles las divisiones, multiplicaciones, el
cálculo de restos y las comparaciones (es decir, la comparación 1 metro == 100 centímetros
debería retornar true).
Te voy a proponer un par de ejercicios para esta entrega. Es probable que con ello te
ponga en algún apuro, pero recuerda que el método prueba-error es el mejor modo de
aprender.
EJERCICIO 4
Una anotación para aquellos que seguís el curso desde fuera de Europa: En las pistas
de este ejercicio explico algunos conceptos con los que quizá no estéis familiarizados en
vuestro país.
Necesitamos una clase para almacenar los datos de una factura. Dichos datos son:
Nombre del cliente, teléfono, dirección, población, provincia, código postal, NIF o CIF y
porcentaje de IVA. Por otra parte tienes que tener presente que en una misma factura puede
haber una o varias líneas de detalle con los siguientes datos: Cantidad, descripción, precio
unitario e importe. Usa un indizador para acceder a cada una de estas líneas de detalle. Esta
clase debe ofrecer, además, propiedades que devuelvan la base imponible, la cuota de IVA y el
total a pagar. Escribid también un método Main cliente de esta clase que demuestre que
funciona correctamente.
Supongo que ya habrás deducido que para que la clase Factura cumpla los requisitos
que te pido tendrás que construir también una clase Detalle. Pues bien, te propongo también
que sobrecargues el operador + para que puedas sumar objetos de la clase Detalle a objetos
de la clase Factura. Ojo, en este caso solamente queremos hacer posible la suma
Factura+Detalle, nada más.
EJERCICIO 5
Intenta construir dos clases: la clase Euro y la clase Peseta (la peseta era la antigua
moneda oficial de España antes de ser reemplazada por el Euro). Tienes que hacer que los
objetos de estas clases se puedan sumar, restar, comparar, incrementar y disminuir con total
normalidad como si fueran tipos numéricos, teniendo presente que 1 Euro + 166.386 pesetas=2
euros. Además, tienen que ser compatibles entre sí y también con el tipo double. Recuerda que
1 Euro = 166.386 pesetas. Para este ejercicio no hay pistas.