Tema 5: Clases
(C) 2001 José Antonio González Seco
Definición de
clases
Conceptos de clase y
objeto
C# es un lenguaje orientado a objetos puro, lo que significa que
todo con lo que vamos a trabajar en este lenguaje son objetos. Un
objeto es un agregado de datos y de métodos que
permiten manipular dichos datos, y un programa en C# no es
más que un conjunto de objetos que interaccionan unos con
otros a través de sus métodos.
Una clase es la definición de las
características concretas de un determinado tipo de objetos.
Es decir, de cuáles son los datos y los métodos de
los que van a disponer todos los objetos de ese tipo. Por esta
razón, se suele decir que el tipo de dato de un
objeto es la clase que define las características del
mismo.
Sintaxis de
definición de clases
La sintaxis básica para definir una clase es la que a
continuación se muestra:
class <nombreClase>
{
<miembros>
}
De este modo se definiría una clase de nombre
<nombreClase> cuyos miembros son los definidos en
<miembros> Los miembros de una clase son los datos y
métodos de los que van a disponer todos los objetos de la
misma. Un ejemplo de cómo declarar una clase de nombre A que
no tenga ningún miembro es la siguiente:
class A
{}
Una clase así declarada no dispondrá de ningún
miembro a excepción de los implícitamente definidos
de manera común para todos los objetos que creemos en C#.
Estos miembros los veremos dentro de poco en este mismo tema bajo
el epígrafe La clase primigenia: System.Object.
Aunque en C# hay muchos tipos de miembros distintos, por ahora
vamos a considerar que estos únicamente pueden ser campos o
métodos y vamos a hablar un poco acerca de ellos y de
cómo se definen:
-
Campos: Un campo es un dato común a
todos los objetos de una determinada clase. Para definir
cuáles son los campos de los que una clase dispone se usa
la siguiente sintaxis dentro de la zona señalada como
<miembros> en la definición de la misma:
<tipoCampo> <nombreCampo>;
El nombre que demos al campo puede ser cualquier identificador que
queramos siempre y cuando siga las reglas descritas en el Tema
4: Aspectos Léxicos para la escritura de identificadores
y no coincida con el nombre de ningún otro miembro
previamente definido en la definición de clase.
Los campos de un objeto son a su vez objetos, y en
<tipoCampo> hemos de indicar cuál es el tipo de dato
del objeto que vamos a crear. Éste tipo puede corresponderse
con cualquiera que los predefinidos en la BCL o con cualquier otro
que nosotros hallamos definido siguiendo la sintaxis arriba
mostrada. A continuación se muestra un ejemplo de
definición de una clase de nombre Persona que dispone
de tres campos:
class Persona
{
string Nombre; // Campo de cada objeto Persona que almacena su nombre
int Edad; // Campo de cada objeto Persona que almacena su edad
string NIF; // Campo de cada objeto Persona que almacena su NIF
}
Según esta definición, todos los objetos de clase
Persona incorporarán campos que almacenarán
cuál es el nombre de la persona que cada objeto representa,
cuál es su edad y cuál es su NIF. El tipo int
incluido en la definición del campo Edad es un tipo
predefinido en la BCL cuyos objetos son capaces de almacenar
números enteros con signo comprendidos entre -2.147.483.648
y 2.147.483.647 (32 bits), mientras que string es un
tipo predefinido que permite almacenar cadenas de texto que sigan
el formato de los literales de cadena visto en el Tema 4:
Aspectos Léxicos
Para acceder a un campo de un determinado objeto se usa la
sintaxis:
<objeto>.<campo>
Por ejemplo, para acceder al campo Edad de un objeto Persona
llamado p y cambiar su valor por 20 se haría:
p.Edad = 20;
En realidad lo marcado como <objeto> no tiene porqué
ser necesariamente el nombre de algún objeto, sino que puede
ser cualquier expresión que produzca como resultado una
referencia no nula a un objeto (si produjese null se
lanzaría una excepción del tipo predefinido
System.NullPointerException)
-
Métodos: Un método es un
conjunto de instrucciones a las que se les asocia un nombre de
modo que si se desea ejecutarlas basta referenciarlas a
través de dicho nombre en vez de tener que escribirlas.
Dentro de estas instrucciones es posible acceder con total
libertad a la información almacenada en los campos
pertenecientes a la clase dentro de la que el método se
ha definido, por lo que como al principio del tema se
indicó, los métodos permiten manipular los datos
almacenados en los objetos.
La sintaxis que se usa en C# para definir los métodos es la
siguiente:
<tipoDevuelto> <nombreMétodo> (<parametros>)
{
<instrucciones>
}
Todo método puede devolver un objeto como resultado de la
ejecución de las instrucciones que lo forman, y el tipo de
dato al que pertenece este objeto es lo que se indica en
<tipoDevuelto>. Si no devuelve nada se indica void, y
si devuelve algo es obligatorio finalizar la ejecución de
sus instrucciones con alguna instrucción return
<objeto>; que indique qué objeto ha de
devolverse.
Opcionalmente todo método puede recibir en cada llamada una
lista de objetos a los que
podrá acceder durante la ejecución de sus
instrucciones. En <parametros> se indica es
cuáles son los tipos de dato de estos objetos y cuál
es el nombre con el que harán referencia las instrucciones
del método a cada uno de ellos. Aunque los objetos que puede
recibir el método pueden ser diferentes cada vez que se
solicite su ejecución, siempre han de ser de los mismos
tipos y han de seguir el orden establecido en
<parametros>.
Un ejemplo de cómo declarar un método de nombre
Cumpleaños es la siguiente
modificación de la definición de la clase Persona
usada antes como ejemplo:
class Persona
{
string Nombre; // Campo de cada objeto Persona que almacena su nombre
int Edad; // Campo de cada objeto Persona que almacena su edad
string NIF; // Campo de cada objeto Persona que almacena su NIF
void Cumpleaños() // Incrementa en uno de la edad del objeto Persona
{
Edad++;
}
}
La sintaxis usada para llamar a los métodos de un objeto es
la misma que la usada para llamar a sus campos, sólo que
ahora tras el nombre del método al que se
desea llamar hay que indicar entre paréntesis cuáles
son los valores que se desea dar a los parámetros del
método al hacer la llamada. O sea, se escribe:
<objeto>.<método>(<parámetros>)
Como es lógico, si el método no tomase
parámetros se dejarían vacíos los
parámetros en la llamada al mismo.
Por ejemplo, para llamar al método
Cumpleaños() de un objeto Persona llamado p se haría:
p.Cumpleaños(); // El método no toma parámetros, luego no le pasamos ninguno
Es importante señalar que en una misma clase pueden
definirse varios métodos con el mismo nombre siempre y
cuando tomen diferente número o tipo de parámetros. A
esto se le conoce como sobrecargar de métodos,
y es posible ya que cuando se les llame el compilador sabrá
a cual llamar a partir de <parámetros> pasados
en la llamada.
Sin embargo, lo que no es permite es definir varios métodos
que sólo se diferencien en su valor de retorno, ya que como
éste no se tiene porqué indicar al llamarlos no
podría diferenciarse a que método en concreto se hace
referencia en cada llamada. Por ejemplo, a partir de la llamada:
p.Cumpleaños();
Si además de la versión de Cumpleaños() que no
retorna nada hubiese otra que retornase un int,
¿cómo sabría entonces el compilador a
cuál llamar?
Antes de continuar es preciso señalar que en C# todo,
incluido los literales, son objetos del tipo de cada literal y por
tanto pueden contar con miembros a los que se accedería tal
y como se ha explicado. Para entender esto no hay nada mejor que un
ejemplo:
string s = 12.ToString();
Este código almacena el literal de cadena "12"
en la variable s, pues 12 es un objeto de tipo int (tipo que
representa enteros) y cuenta cuenta con el método
común a todos los ints llamado ToString() que
lo que hace es devolver una cadena cuyos caracteres son los
dígitos que forman el entero representado por el int
sobre el que se aplica; y como la variable s es de tipo
string (tipo que representa cadenas) es perfectamente
posible almacenar dicha cadena en ella, que es lo que se hace en el
código anterior.
Creación de
objetos
Operador new
Ahora que ya sabemos cómo definir las clases de objetos que
podremos usar en nuestras aplicaciones ha llegado el momento de
explicar cómo crear objetos de una determinada clase. Algo
de ello ya se introdujo en el Tema 4: Aspectos
Léxicos cuando se comentó la utilidad del
operador new, que precisamente es crear objetos y cuya
sintaxis es:
new <nombreTipo>(<parametros>)
Este operador crea un nuevo objeto del tipo cuyo nombre se le
indica y llama durante su proceso de creación al constructor
del mismo apropiado según los valores que se le pasen en
<parametros>, devolviendo una referencia al objeto
recién creado. Hay que resaltar el hecho de que new
no devuelve el propio objeto creado, sino una referencia a la
dirección de memoria dinámica donde en realidad se ha
creado.
El antes comentado constructor de un objeto no es más
que un método definido en la definición de su tipo
que tiene el mismo nombre que la clase a la que pertenece el objeto
y no tiene valor de retorno. Como new siempre devuelve
una referencia a la dirección de memoria donde
se cree el objeto y los constructores sólo pueden usarse
como operandos de new, no tiene sentido que un
constructor devuelva objetos, por lo que no tiene sentido incluir
en su definición un campo <tipoDevuelto> y el
compilador considera erróneo hacerlo (aunque se indique
void)
El constructor recibe ese nombre debido a que su código
suele usarse precisamente para construir el objeto, para
inicializar sus miembros. Por ejemplo, a nuestra clase de ejemplo
Persona le podríamos añadir un constructor
dejándola así:
class Persona
{
string Nombre; // Campo de cada objeto Persona que almacena su nombre
int Edad; // Campo de cada objeto Persona que almacena su edad
string NIF; // Campo de cada objeto Persona que almacena su NIF
void Cumpleaños() // Incrementa en uno la edad del objeto Persona
{
Edad++;
}
Persona (string nombre, int edad, string nif) // Constructor
{
Nombre = nombre;
Edad = edad;
NIF = nif;
}
}
Como se ve en el código, el constructor toma como
parámetros los valores con los que deseemos inicializar el
objeto a crear. Gracias a él, podemos crear un objeto
Persona de nombre José, de 22 años de edad y NIF
12344321-A así:
new Persona("José", 22, "12344321-A")
Nótese que la forma en que se pasan parámetros al
constructor consiste en indicar los valores que se ha de dar a cada
uno de los parámetros indicados en la definición del
mismo separándolos por comas. Obviamente, si un
parámetro se definió como de tipo
string habrá que pasarle una cadena, si se
definió de tipo int habrá que pasarle un
entero y, en general, ha todo parámetro habrá que
pasarle un valor de su mismo tipo (o de alguno convertible al
mismo), produciéndose un error al compilar si no se hace
así.
En realidad un objeto puede tener múltiples constructores,
aunque para diferenciar a unos de otros es obligatorio que se
diferencien en el número u orden de los parámetros
que aceptan, ya que el nombre de todos ellos ha de coincidir con el
nombre de la clase de la que son miembros. De ese modo, cuando
creemos el objeto el compilador podrá inteligentemente
determinar cuál de los constructores ha de ejecutarse en
función de los valores que le pasemos al new.
Una vez creado un objeto lo más normal es almacenar la
dirección devuelta por new en una variable del tipo
apropiado para el objeto creado. El siguiente ejemplo -que como es
lógico irá dentro de la definición de
algún método- muestra cómo crear una variable
de tipo Persona llamada p y cómo almacenar en ella la
dirección del objeto que devolvería la anterior
aplicación del operador new:
Persona p; // Creamos variable p
p = new Persona("Jose", 22, "12344321-A");
// Almacenamos en p el objeto creado con new
A partir de este momento la variable p contendrá una
referencia a un objeto de clase Persona que representará a
una persona llamada José de 22 años y NIF 12344321-A.
O lo que prácticamente es lo mismo y suele ser la forma
comúnmente usada para decirlo: la variable p representa a
una persona llamada José de 22 años y NIF 12344321-A.
Como lo más normal suele ser crear variables donde almacenar
referencias a objetos que creemos, las instrucciones anteriores
pueden compactarse en una sola así:
Persona p = new Persona("José", 22, "12344321-A");
De hecho, una sintaxis más general para la definición
de variables es la siguiente:
<tipoDato> <nombreVariable> = <valorInicial>;
La parte = <valorInicial> de esta sintaxis es en realidad
opcional, y si no se incluye la variable declarada pasará a
almacenar una referencia nula (contendrá el literal
null)
Constructor por
defecto
No es obligatorio definir un constructor para cada clase, y en caso
de que no definamos ninguno el compilador creará uno por
nosotros sin parámetros ni instrucciones. Es decir, como si
se hubiese definido de esta forma:
<nombreTipo>()
{
}
Gracias a este constructor introducido automáticamente por
el compilador, si Coche es una clase en cuya definición no
se ha incluido ningún constructor, siempre será
posible crear uno nuevo usando el operador
new así:
Coche c = new Coche();
// Crea coche c llamando al constructor por defecto de Coche
Hay que tener en cuenta una cosa: el constructor por defecto es
sólo incluido por el compilador si no hemos definido
ningún otro constructor. Por tanto, si tenemos una clase en
la que hayamos definido algún constructor con
parámetros pero ninguno sin parámetros no será
válido crear objetos de la misma llamando al constructor sin
parámetros, pues el compilador no lo habrá definido
automáticamente. Por ejemplo, con la última
versión de la clase de ejemplo Persona es inválido
hacer:
Persona p = new Persona();
// ERROR: El único constructor de persona toma 3 parámetros
Referencia al objeto
actual con this
Dentro del código de cualquier método de un objeto
siempre es posible hacer referencia al propio objeto usando la
palabra reservada this. Esto puede venir bien a la
hora de escribir constructores de objetos debido a que permite que
los nombres que demos a los parámetros del constructor
puedan coincidir nombres de los campos del objeto sin que haya
ningún problema. Por ejemplo, el constructor de la clase
Persona escrito anteriormente se puede reescribir así usando
this:
Persona (string Nombre, int Edad, string NIF)
{
this.Nombre = Nombre;
this.Edad = Edad;
this.NIF = NIF;
}
Es decir, dentro de un método con parámetros cuyos
nombres coincidan con campos, se da preferencia a los
parámetros y para hacer referencia a los campos hay que
prefijarlos con el this tal y como se muestra en el ejemplo.
El ejemplo anterior puede que no resulte muy interesante debido a
que para evitar tener que usar this podría haberse
escrito el constructor tal y como se mostró en la primera
versión del mismo: dando nombres que empiecen en
minúscula a los parámetros y nombres que empiecen con
mayúsculas a los campos. De hecho, ese es el convenio que
Microsoft recomienda usar. Sin embargo, como más adelante se
verá sí que puede ser útil this cuando
los campos a inicializar a sean privados, ya que el convenio de
escritura de identificadores para campos privados recomendado por
Microsoft coincide con el usado para dar identificadores a
parámetros (obviamente otra solución sería dar
cualquier otro nombre a los parámetros del constructor o los
campos afectados, aunque así el código
perdería algo legibilidad)
Un uso más frecuente de this en C# es el de permitir
realizar llamadas a un método de un objeto desde
código ubicado en métodos del mismo objeto. Es decir,
en C# siempre es necesario que cuando llamemos a algún
método de un objeto precedamos al operador . de
alguna expresión que indique cuál es el objeto a cuyo
método se desea llamar, y si éste método
pertenece al mismo objeto que hace la llamada la única forma
de conseguir indicarlo en C# es usando this.
Finalmente, una tercera utilidad de this es permitir
escribir métodos que puedan devolver como objeto el propio
objeto sobre el que el método es aplicado. Para ello
bastaría usar una instrucción return this; al
indicar el objeto a devolver
Herencia y
métodos virtuales
Concepto de herencia
El mecanismo de herencia es uno de los pilares fundamentales
en los que se basa la programación orientada a
objetos. Es un mecanismo que permite definir nuevas clases a partir
de otras ya definidas de modo que si en la definición de una
clase indicamos que ésta deriva de otra, entonces la primera
-a la que se le suele llamar clase hija- será tratada
por el compilador automáticamente como si su
definición incluyese la definición de la segunda
-a la que se le suele llamar clase padre o clase
base. Las clases que derivan de otras se definen usando la
siguiente sintaxis:
class <nombreHija>:<nombrePadre>
{
<miembrosHija>
}
A los miembros definidos en <miembrosHijas> se le
añadirán los que hubiésemos definido en la
clase padre. Por ejemplo, a partir de la clase Persona puede
crearse una clase Trabajador así:
class Trabajador:Persona
{
public int Sueldo;
public Trabajador(string nombre, int edad, string nif, int sueldo)
: base(nombre, edad, nif)
{
Sueldo = sueldo;
}
}
Los objetos de esta clase Trabajador contarán con los mismos
miembros que los objetos Persona y además
incorporarán un nuevo campo llamado Sueldo que
almacenará el dinero que cada trabajador gane. Nótese
además que a la hora de escribir el constructor de esta
clase ha sido necesario escribirlo con una sintaxis especial
consistente en preceder la llave de apertura del cuerpo del
método de una estructura de la forma:
: base(<parametrosBase>)
A esta estructura se le llama inicializador base y se
utiliza para indicar cómo deseamos inicializar los campos
heredados de la clase padre. No es más que una llamada al
constructor de la misma con los parámetros adecuados, y si
no se incluye el compilador consideraría por defecto que
vale :base(), lo que sería incorrecto en este ejemplo
debido a que Persona carece de constructor sin parámetros.
Un ejemplo que pone de manifiesto cómo funciona la herencia
es el siguiente:
using System;
class Persona
{
public string Nombre; // Campo de cada objeto Persona que almacena su nombre
public int Edad; // Campo de cada objeto Persona que almacena su edad
public string NIF; // Campo de cada objeto Persona que almacena su NIF
void Cumpleaños() // Incrementa en uno de edad del objeto Persona
{
Edad++;
}
public Persona (string nombre, int edad, string nif) // Constructor de Persona
{
Nombre = nombre;
Edad = edad;
NIF = nif;
}
}
class Trabajador: Persona
{
public int Sueldo; // Campo de cada objeto Trabajador que almacena cuánto gana
Trabajador(string nombre, int edad, string nif, int sueldo):
base(nombre, edad, nif)
{ // Inicializamos cada Trabajador en base al constructor de Persona
Sueldo = sueldo;
}
public static void Main()
{
Trabajador p = new Trabajador("Josan", 22, "77588260-Z", 100000);
Console.WriteLine ("Nombre="+p.Nombre);
Console.WriteLine ("Edad="+p.Edad);
Console.WriteLine ("NIF="+p.NIF);
Console.WriteLine ("Sueldo="+p.Sueldo);
}
}
Nótese que ha sido necesario prefijar la definición
de los miembros de Persona del palabra reservada public.
Esto se debe a que por defecto los miembros de una tipo sólo
son accesibles desde código incluido dentro de la
definición de dicho tipo, e incluyendo public
conseguimos que sean accesibles desde cualquier código, como
el método Main() definido en Trabajador. public es lo
que se denomina un modificador de acceso, concepto que se
tratará más adelante en este mismo tema bajo el
epígrafe titulado Modificadores de acceso.
Llamadas por defecto al
constructor base
Si en la definición del constructor de alguna clase que
derive de otra no incluimos inicializador base el compilador
considerará que éste es :base() Por ello hay
que estar seguros de que si no se incluye base en la
definición de algún constructor, el tipo padre del
tipo al que pertenezca disponga de constructor sin
parámetros.
Es especialmente significativo reseñar el caso de que no
demos la definición de ningún constructor en la clase
hija, ya que en estos casos la definición del constructor
que por defecto introducirá el compilador será en
realidad de la forma:
<nombreClase>(): base()
{}
Es decir, este constructor siempre llama al constructor sin
parámetros del padre del tipo que estemos definiendo, y si
ése no dispone de alguno se producirá un error al
compilar.
Métodos
virtuales
Ya hemos visto que es posible definir tipos cuyos
métodos se hereden de definiciones de otros tipos. Lo
que ahora vamos a ver es que además es posible cambiar
dicha definición en la clase hija, para lo que
habría que haber precedido con la palabra reservada
virtual la definición de dicho método en la
clase padre. A este tipo de métodos se les llama
métodos virtuales, y la sintaxis que se usa para
definirlos es la siguiente:
virtual <tipoDevuelto> <nombreMétodo>(<parámetros>)
{
<código>
}
Si en alguna clase hija quisiésemos dar una nueva
definición del <código> del
método, simplemente lo volveríamos a definir en la
misma pero sustituyendo en su definición la palabra
reservada virtual por override. Es decir,
usaríamos esta sintaxis:
override <tipoDevuelto> <nombreMétodo>(<parámetros>)
{
<nuevoCódigo>
}
Nótese que esta posibilidad de cambiar el código de
un método en su clase hija sólo se da si en la clase
padre el método fue definido como virtual. En caso
contrario, el compilador considerará un error intentar
redefinirlo.
El lenguaje C# impone la restricción de que toda
redefinición de método que queramos realizar
incorpore la partícula override para forzar a que el
programador esté seguro de que verdaderamente lo que quiere
hacer es cambiar el significado de un método heredado.
Así se evita que por accidente defina un método del
que ya exista una definición en una clase padre.
Además, C# no permite definir un método como
override y virtual a la vez, ya que ello
tendría un significado absurdo: estaríamos dando una
redefinición de un método que vamos a definir.
Por otro lado, cuando definamos un método como
override ha de cumplirse que en alguna clase antecesora (su
clase padre, su clase abuela, etc.) de la clase en la que se ha
realizado la definición del mismo exista un método
virtual con el mismo nombre que el redefinido. Si no, el compilador
informará de error por intento de redefinición de
método no existente o no virtual. Así se evita que
por accidente un programador crea que está redefiniendo un
método del que no exista definición previa o que
redefina un método que el creador de la clase base no desee
que se pueda redefinir.
Para aclarar mejor el concepto de método virtual, vamos a
mostrar un ejemplo en el que cambiaremos la definición del
método Cumpleaños() en los objetos Persona por una
nueva versión en la que se muestre un mensaje cada vez que
se ejecute, y redefiniremos dicha nueva versión para los
objetos Trabajador de modo que el mensaje mostrado sea otro. El
código de este ejemplo es el que se muestra a
continuación:
using System;
class Persona
{
public string Nombre; // Campo de cada objeto Persona que almacena su nombre
public int Edad; // Campo de cada objeto Persona que almacena su edad
public string NIF; // Campo de cada objeto Persona que almacena su NIF
public virtual void Cumpleaños() // Incrementa en uno de la edad del objeto Persona
{
Console.WriteLine("Incrementada edad de persona");
}
public Persona (string nombre, int edad, string nif) // Constructor de Persona
{
Nombre = nombre;
Edad = edad;
NIF = nif;
}
}
class Trabajador: Persona
{
public int Sueldo; // Campo de cada objeto Trabajador que almacena cuánto gana
Trabajador(string nombre, int edad, string nif, int sueldo): base(nombre, edad, nif)
{ // Inicializamos cada Trabajador en base al constructor de Persona
Sueldo = sueldo;
}
public override Cumpleaños()
{
Edad++;
Console.WriteLine("Incrementada edad de persona");
}
public static void Main()
{
Persona p = new Persona("Carlos", 22, "77588261-Z", 100000);
Trabajador t = new Trabajador("Josan", 22, "77588260-Z", 100000);
t.Cumpleaños();
p.Cumpleaños();
}
}
Nótese cómo se ha añadido el modificador
virtual en la definición de Cumpleaños() en la
clase Persona para habilitar la posibilidad de que dicho
método puede ser redefinido en clase hijas de Persona y
cómo se ha añado override en la
redefinición del mismo dentro de la clase Trabajador para
indicar que la nueva definición del método es una
redefinición del heredado de la clase. La salida de este
programa confirma que la implementación de
Cumpleaños() es distinta en cada clase, pues es de la
forma:
Incrementada edad de trabajador
Incrementada edad de persona
También es importante señalar que para que la
redefinición sea válida ha sido necesario
añadir la partícula public a la
definición del método original, pues si no se
incluyese se consideraría que el método sólo
es accesible desde dentro de la clase donde se ha definido, lo que
no tiene sentido en métodos virtuales ya que entonces
nunca podría ser redefinido. De hecho, si se excluyese
el modificador public el compilador informaría de un
error ante este absurdo. Además, este modificador
también se ha mantenido en la redefinición de
Cumpleaños() porque toda redefinición de un
método virtual ha de mantener los mismos modificadores de
acceso que el método original para ser válida.
Clases abstractas
Una clase abstracta es aquella que forzosamente se ha de
derivar si se desea que se puedan crear objetos de la misma o
acceder a sus miembros estáticos (esto último se
verá más adelante en este mismo tema) Para definir
una clase abstracta se antepone abstract a su
definición, como se muestra en el siguiente ejemplo:
public abstract class A
{
public abstract void F();
}
abstract public class B: A
{
public void G() {}
}
class C: B
{
public override void F()
{}
}
Las clases A y B del ejemplo son abstractas, y como puede verse es
posible combinar en cualquier orden el modificador
abstract con modificadores de acceso.
La utilidad de las clases abstractas es que pueden contener
métodos para los que no se dé directamente una
implementación sino que se deje en manos de sus clases hijas
darla. No es obligatorio que las clases abstractas contengan
métodos de este tipo, pero sí lo es marcar como
abstracta a toda la que tenga alguno. Estos métodos se
definen precediendo su definición del modificador
abstract y sustituyendo su código por un punto y coma
(;), como se muestra en el método F() de la clase A
del ejemplo (nótese que B también ha de definirse
como abstracta porque tampoco implementa el método F() que
hereda de A)
Obviamente, como un método abstracto no tiene código
no es posible llamarlo. Hay que tener especial cuidado con esto a
la hora de utilizar this para llamar a otros métodos
de un mismo objeto, ya que llamar a los abstractos provoca un error
al compilar.
Véase que todo método definido como abstracto es
implícitamente virtual, pues si no sería imposible
redefinirlo para darle una implementación en las clases
hijas de la clase abstracta donde esté definido. Por ello es
necesario incluir el modificador override a la hora de
darle implementación y es redundante marcar un método
como abstract y virtual a la vez (de hecho, hacerlo
provoca un error al compilar)
Es posible marcar un método como abstract y
override a la vez, lo que convertiría al
método en abstracto para sus clases hijas y forzaría
a que éstas lo tuviesen que reimplementar si no se quisiese
que fuesen clases abstractas.
La clase primigenia:
System.Object
Ahora que sabemos lo que es la herencia es el momento apropiado
para explicar que en .NET todos los tipos que se definan heredan
implícitamente de la clase System.Object predefinida
en la BCL, por lo que dispondrán de todos los miembros de
ésta. Por esta razón se dice que
System.Object es la raíz de la jerarquía
de objetos de .NET.
A continuación vamos a explicar cuáles son estos
métodos comunes a todos los objetos:
-
public virtual bool Equals(object o): Se usa para
comparar el objeto sobre el que se aplica con cualquier otro que
se le pase como parámetro. Devuelve true si ambos
objetos son iguales y false en caso contrario.
La implementación que por defecto se ha dado a este
método consiste en usar igualdad por referencia para los
tipos por referencia e igualdad por valor para los
tipos por valor. Es decir, si los objetos
a comparar son de tipos por referencia sólo se devuelve
true si ambos objetos apuntan a la misma referencia en
memoria dinámica, y si los tipos a comparar son tipos por
valor sólo se devuelve true si todos los bits de
ambos objetos son iguales, aunque se almacenen en posiciones
diferentes de memoria.
Como se ve, el método ha sido definido como virtual,
lo que permite que los programadores puedan redefinirlo para
indicar cuándo ha de considerarse que son iguales dos
objetos de tipos definidos por ellos. De hecho, muchos de los tipos
incluidos en la BCL cuentan con redefiniciones de este tipo, como
es el caso de string, quien aún siendo un tipo por
referencia, sus objetos se consideran iguales si apuntan a
cadenas que sean iguales carácter a carácter (aunque
referencien a distintas direcciones de memoria dinámica)
El siguiente ejemplo muestra cómo hacer una
redefinición de Equals() de manera que aunque los
objetos Persona sean de tipos por referencia, se considere que dos
Personas son iguales si tienen el mismo NIF:
public override bool Equals(object o)
{
if (o==null)
return this==null;
else
return (o is Persona) && (this.NIF == ((Persona) o).NIF);
}
Hay que tener en cuenta que es conveniente que toda
redefinición del método Equals() que hagamos
cumpla con
una serie de propiedades que muchos de los
métodos incluidos en las distintas clases de la BCL esperan
que se cumplan. Estas propiedades son:
-
Reflexividad: Todo objeto ha de ser igual a
sí mismo. Es decir, x.Equals(x) siempre ha de devolver
true.
-
Simetría: Ha de dar igual el orden en
que se haga la comparación. Es decir, x.Equals(y) ha de
devolver lo mismo que y.Equals(x) .
-
Transitividad: Si dos objetos son iguales y
uno de ellos es igual a otro, entonces el primero también ha
de ser igual a ese otro objeto. Es decir, si x.Equals(y) e
y.Equals(z) entonces x.Equals(z) .
-
Consistencia: Siempre que el método se aplique
sobre los mismos objetos ha de devolver el mismo resultado.
-
Tratamiento de objetos nulos: Si uno de los objetos
comparados es nulo (null), sólo se ha de devolver
true si el otro también lo es.
-
Hay que recalcar que el hecho de que redefinir Equals() no implica
que el operador de igualdad (==) quede también
redefinido. Ello habría que hacerlo de independientemente
como se indica en el Tema 11: Redefinición de
operadores.
-
public virtual int GetHashCode(): Devuelve un
código de dispersión (hash) que representa de
forma numérica al objeto sobre el que el método es
aplicado. GetHashCode() suele usarse para trabajar con
tablas de dispersión, y se cumple que si dos objetos son
iguales sus códigos de dispersión serán
iguales, mientras que si son distintos la probabilidad de que
sean iguales es ínfima.
En tanto que la búsqueda de objetos en tablas de
dispersión no se realiza únicamente usando la
igualdad de objetos (método Equals()) sino usando
también la igualdad de códigos de dispersión,
suele ser conveniente redefinir GetHashCode() siempre que se
redefina Equals() De hecho, si no se hace el compilador informa de
la situación con un mensaje de aviso.
-
public virtual string ToString(): Devuelve una
representación en forma de cadena del objeto sobre el que
se el método es aplicado, lo que es muy útil para
depurar aplicaciones ya que permite mostrar con facilidad el
estado de los objetos.
La implementación por defecto de este método
simplemente devuelve una cadena de texto con el nombre de la clase
a la que pertenece
el objeto sobre el que es aplicado. Sin embargo, como lo habitual
suele ser implementar ToString() en cada nueva clase que se defina,
a continuación mostraremos un ejemplo de cómo
redefinirlo en la clase Persona para que muestre los valores de
todos los campos de los objetos Persona:
public override string ToString()
{
string cadena = "";
cadena += "DNI = " + this.DNI + "\n";
cadena +="Nombre = " + this.Nombre + "\n";
cadena +="Edad = " + this.Edad + "\n";
return cadena;
}
Es de reseñar el hecho de que en realidad los que hace el
operador de concatenación de cadenas (+) para
concatenar una cadena con un objeto cualquiera es convertirlo
primero en cadena llamando a su método ToString() y
luego realizar la concatenación de ambas cadenas.
Del mismo modo, cuando a Console.WriteLine() y
Console.Write() se les pasa como parámetro un objeto
lo que hacen es mostrar por la salida estándar el resultado
de convertirlo en cadena llamando a su método
ToString(); y si se les pasa como parámetros una
cadena seguida de varios objetos lo muestran por la salida
estándar esa cadena pero sustituyendo en ella toda subcadena
de la forma {<número>} por el resultado de convertir
en cadena el parámetro que ocupe la posición
<número>+2 en la lista de valores de llamada al
método.
-
protected object MemberWiseClone(): Devuelve una copia
shallow copy del objeto sobre el que se aplica. Esta
copia es una copia bit a bit del mismo, por lo que el objeto
resultante de la copia mantendrá las mismas referencias a
otros que tuviese el objeto copiado y toda modificación
que se haga a estos objetos a través de la copia
afectará al objeto copiado y viceversa.
Si lo que interesa es disponer de una copia más normal, en
la que por cada objeto referenciado se crease una copia del mismo a
la que referenciase el objeto clonado, entonces el programador ha
de escribir su propio método clonador pero puede servirse de
MemberwiseClone() como base con la que copiar los campos que no
sean de tipos referencia.
-
public System.Type GetType(): Devuelve un objeto de clase
System.Type que representa al tipo de dato del objeto
sobre el que el método es aplicado. A través de
los métodos ofrecidos por este objeto se puede acceder a
metadatos sobre el mismo como su nombre, su clase padre,
sus miembros, etc. La explicación de cómo usar los
miembros de este objeto para obtener dicha información
queda fuera del alcance de este documento ya que es muy larga y
puede ser fácilmente consultada en la
documentación que acompaña al .NET SDK.
Aparte de los métodos ya comentados que todos los objetos
heredan, la clase System.Object también incluye en su
definición los siguientes métodos de tipo:
-
public static bool ReferenceEquals(object objeto1, object
objeto2): Indica si los dos objetos que se le pasan
como parámetro se almacenan en la misma posición de
memoria dinámica. A través de este método,
aunque se hayan redefinido Equals() y el operador de igualdad
(==) para un cierto tipo por referencia, se podrán
seguir realizando comparaciones por referencia entre objetos de ese
tipo en tanto que redefinir de Equals() no afecta a este
método. Por ejemplo, dada la anterior
redefinición de Equals() para objetos Persona:
Persona p = new Persona("José", 22, "83721654-W");
Persona q = new Persona("Antonio", 23, "83721654-W");
Console.WriteLine(p.Equals(q));
Console.WriteLine(Object.Equals(p, q));
Console.WriteLine(Object.ReferenceEquals(p, q));
Console.WriteLine(p == q);
La salida que por pantalla mostrará el código
anterior es:
True
True
False
False
En los primeros casos se devuelve true porque según
la redefinición de Equals() dos personas son iguales si
tienen el mismo DNI, como pasa con los objetos p y q. Sin embargo,
en los últimos casos se devuelve false porque aunque
ambos objetos tienen el mismo DNI cada uno se almacena en la
memoria dinámica en una posición distinta, que es lo
que comparan ReferenceEquals() y el operador ==
(éste último sólo por defecto)
Polimorfismo
Concepto de
polimorfismo
El polimorfismo es otro de los pilares fundamentales de la
programación orientada a objetos. Es la capacidad de
almacenar objetos de un determinado tipo en variables de tipos
antecesores del primero a costa, claro está, de sólo
poderse acceder a través de dicha variable a los miembros
comunes a ambos tipos. Sin embargo, las versiones de los
métodos virtuales a las que se llamaría a
través de esas variables no serían las definidas como
miembros del tipo de dichas variables, sino las definidas en el
verdadero tipo de los objetos que almacenan.
A continuación se muestra un ejemplo de cómo una
variable de tipo Persona puede usarse para almacenar objetos de
tipo Trabajador. En esos casos el campo Sueldo del objeto
referenciado por la variable no será accesible, y la
versión del método Cumpleaños() a la que se
podría llamar a través de la variable de tipo Persona
sería la definida en la clase Trabajador, y no la definida
en Persona:
using System;
class Persona
{
public string Nombre; // Campo de cada objeto Persona que almacena su nombre
public int Edad; // Campo de cada objeto Persona que almacena su edad
public string NIF; // Campo de cada objeto Persona que almacena su NIF
// Incrementa en uno la edad del objeto Persona
public virtual void Cumpleaños()
{
Console.WriteLine("Incrementada edad de persona");
}
// Constructor de Persona
public Persona (string nombre, int edad, string nif)
{
Nombre = nombre;
Edad = edad;
NIF = nif;
}
}
class Trabajador: Persona
{
int Sueldo; // Campo de cada objeto Trabajador que almacena cuánto gana
Trabajador(string nombre, int edad, string nif, int sueldo):
base(nombre, edad, nif)
{ // Inicializamos cada Trabajador en base al constructor de Persona
Sueldo = sueldo;
}
public override Cumpleaños()
{
Edad++;
Console.WriteLine("Incrementada edad de trabajador");
}
public static void Main()
{
Persona p = new Trabajador("Josan", 22, "77588260-Z", 100000);
p.Cumpleaños();
// p.Sueldo++; //ERROR: Sueldo no es miembro de Persona
}
}
El mensaje mostrado por pantalla al ejecutar este método
confirma lo antes dicho respecto a que la versión de
Cumpleaños() a la que se llama, ya que es:
Incrementada edad de trabajador
Métodos
genéricos
El polimorfismo es muy útil ya que permite escribir
métodos genéricos que puedan recibir
parámetros que sean de un determinado tipo o de
cualquiera de sus tipos hijos. Es más, en tanto que
cómo se verá en el epígrafe siguiente, en C#
todos los tipos derivan implícitamente del tipo
System.Object, podemos escribir métodos que admitan
parámetros de cualquier tipo sin más que definirlos
como métodos que tomen parámetros de tipo
System.Object. Por ejemplo:
public void MétodoGenérico(object o)
{
// Código del método
}
Nótese que en vez de System.Object se ha
escrito object, que es el nombre abreviado incluido en C#
para hacer referencia de manera compacta a un tipo tan
frecuentemente usado como System.Object.
Determinación de
tipo. Operador is
Dentro de una rutina polimórifica que, como la del ejemplo
anterior, admita parámetros que puedan ser de cualquier
tipo, muchas veces es conveniente poder consultar en el
código de la misma cuál es el tipo en concreto del
parámetro que se haya pasado al método en cada
llamada al mismo. Para ello C# ofrece el operador is, cuya
forma sintaxis de uso es:
<expresión> is <nombreTipo>
Este operador devuelve true en caso de que el resultado de
evaluar <expresión> sea del tipo cuyo nombre es
<nombreTipo> y false en caso contrario. Gracias a ellas
podemos escribir métodos genéricos que puedan
determinar cuál es el tipo que tienen los parámetros
que en cada llamada en concreto se les pasen. O sea, métodos
como:
public void MétodoGenérico(object o)
{
if (o is int) // Si o es de tipo int (entero)...
// ...Código a ejecutar si el objeto o es de tipo int
else if (o is string) // Si no, si o es de tipo string (cadena)...
// ...Código a ejecutar si o es de tipo string
//... Idem para otros tipos
}
El bloque if...else es una instrucción
condicional que permite ejecutar un código u otro en
función de si la condición indicada entre
paréntesis tras el if es cierta (true) o no
(false) Esta instrucción se explicará
más detalladamente en el Tema 16: Instrucciones
Acceso a la clase
base
Hay determinadas circunstancias en las que cuando redefinamos un
determinado método nos interese poder acceder al
código de la versión original. Por ejemplo, porque el
código redefinido que vayamos a escribir haga lo mismo que
el original y además algunas cosas extras. En estos casos se
podría pensar que una forma de conseguir esto sería
convirtiendo el objeto actual al tipo del método a redefinir
y entonces llamar así a ese método, como por ejemplo
en el siguiente código:
using System;
class A
{
public virtual void F()
{
Console.WriteLine("A");
}
}
class B:A
{
public override void F()
{
Console.WriteLine("Antes");
((A) this).F(); // (2)
Console.WriteLine("Después");
}
public static void Main()
{
B b = new B();
b.F();
}
}
Pues bien, si ejecutamos el código anterior veremos que la
aplicación nunca termina de ejecutarse y está
constantemente mostrando el mensaje Antes por pantalla. Esto se
debe a que debido al polimorfismo se ha entrado en un bucle
infinito: aunque usemos el operador de conversión para
tratar el objeto como si fuese de tipo A, su verdadero tipo sigue
siendo B, por lo que la versión de F() a la que se
llamará en (2) es a la de B de nuevo, que volverá a
llamarse así misma una y otra vez de manera indefinida.
Para solucionar esto, los diseñadores de C# han incluido una
palabra reservada llamada base que devuelve una referencia
al objeto actual semejante a this pero con la peculiaridad
de que los accesos a ella son tratados como si el verdadero tipo
fuese el de su clase base. Usando base, podríamos
reemplazar el código de la redefinición de F() de
ejemplo anterior por:
public override void F()
{
Console.WriteLine("Antes");
base.F();
Console.WriteLine("Después");
}
Si ahora ejecutamos el programa veremos que ahora sí que la
versión de F() en B llama a la versión de F()
en A, resultando la siguiente salida por pantalla:
Antes
A
Después
A la hora de redefinir métodos abstractos hay que tener
cuidado con una cosa: desde el método redefinidor no es
posible usar base para hacer referencia a
métodos abstractos de la clase padre, aunque sí para
hacer referencia a los no abstractos. Por ejemplo:
abstract class A
{
public abstract void F();
public void G()
{}
}
class B: A
{
public override void F()
{
base.G(); // Correcto
base.F(); // Error, base.F() es abstracto
}
}
Downcasting
Dado que una variable de un determinado tipo puede estar en
realidad almacenando un objeto que sea de algún tipo hijo
del tipo de la variable y en ese caso a través de la
variable sólo puede accederse a aquellos miembros del
verdadero tipo del objeto que sean comunes con miembros del tipo de
la variable que referencia al objeto, muchas veces nos va a
interesar que una vez que dentro de un método
genérico hayamos determinado cuál es el verdadero
tipo de un objeto (por ejemplo, con el operador is) podamos
tratarlo como tal. En estos casos lo que hay es que hacer una
conversión del tipo padre al verdadero tipo del objeto, y a
esto se le llama downcasting
Para realizar un downcasting una primera posibilidad es indicar
preceder la expresión a convertir del tipo en el que se la
desea convertir indicado entre paréntesis. Es decir,
siguiendo la siguiente sintaxis:
(<tipoDestino>) <expresiónAConvertir>
El resultado de este tipo de expresión es el objeto
resultante de convertir el resultado de
<expresiónAConvertir> a <tipoDestino>. En caso
de que la conversión no se pudiese realizar se
lanzaría una excepción del tipo predefinido
System.InvalidCastException
Otra forma de realizar el downcasting es usando el operador
as, que se usa así:
<expresiónAConvertir> as <tipoDestino>
La principal diferencia de este operador con el anterior es que si
ahora la conversión no se pudiese realizar se
devolvería null en lugar de lanzarse una
excepción. La otra diferencia es que as sólo
es aplicable a tipos referencia y sólo a conversiones entre
tipos de una misma jerarquía (de padres a hijos o viceversa)
Los errores al realizar conversiones de este tipo en métodos
genéricos se producen cuando el valor pasado a la variable
genérica no es ni del tipo indicado en <tipoDestino>
ni existe ninguna definición de cómo realizar la
conversión a ese tipo (cómo definirla se verá
en el Tema 11: Redefinición de operadores).
Clases y métodos
sellados
Una clase sellada es una clase que no puede tener clases
hijas, y para definirla basta anteponer el modificador
sealed a la definición de una clase normal. Por
ejemplo:
sealed class ClaseSellada
{
}
Una utilidad de definir una clase como sellada es que permite que
las llamadas a sus métodos virtuales heredados se realicen
tan eficientemente como si fuesen no virtuales, pues al no poder
existir clases hijas que los redefinan no puede haber polimorfismo
y no hay que determinar cuál es la versión correcta
del método a la que se ha de llamar. Nótese que
se ha dicho métodos virtuales heredados, pues lo que no se
permite es definir miembros virtuales dentrode este tipo de clases,
ya que al no poderse heredarse de ellas es algo sin sentido en
tanto que nunca podráin redefinirse.
Ahora bien, hay que tener en cuenta que sellar reduce enormemente
su capacidad de reutilización, y eso es algo que el aumento
de eficiencia obtenido en las llamadas a sus métodos
virtuales no suele compensar. En realidad la principal causa de la
inclusión de estas clases en C# es que permiten asegurar que
ciertas clases críticas nunca podrán tener clases
hijas. Por ejemplo, para simplificar el funcionamiento del CLR y
los compiladores se ha optado porque todos los tipos de datos
básicos excepto System.Object estén sellados,
pues así las operaciones con ellos siempre se
realizarán de la misma forma al no influirles el
polimorfismo.
Téngase en cuenta que es absurdo definir
simultáneamente una clase como abstract
y sealed, pues nunca podría accederse a la misma al no
poderse crear clases hijas suyas que definan sus métodos
abstractos. Por esta razón, el compilador considera
erróneo definir una clase con ambos modificadores a la vez.
Aparte de para sellar clases, también se puede usar
sealed como modificador en la redefinición de
un método para conseguir que la nueva versión del
mismo que se defina deje de ser virtual y se le puedan aplicar las
optimizaciones arriba comentadas. Un ejemplo de esto es el
siguiente:
class A
{
public abstract F();
}
class B:A
{
public sealed override F() // F() deja de ser redefinible
{}
}
Ocultación de
miembros
Hay ocasiones en las que puede resultar interesante usar la
herencia únicamente como mecanismo de reutilización
de código pero no necesariamente para reutilizar miembros.
Es decir, puede que interese heredar de una clase sin que ello
implique que su clase hija herede sus miembros tal cual sino con
ligeras modificaciones.
Esto puede muy útil al usar la herencia para definir
versiones especializadas de clases de uso genérico. Por
ejemplo, los objetos de la clase
System.Collections.ArrayList incluida en la BCL pueden
almacenar cualquier número de objetos System.Object,
que al ser la clase primigenia ello significa que pueden almacenar
objetos de cualquier tipo. Sin embargo, al recuperarlos de este
almacén genérico se tiene el problema de que
los métodos que para ello se ofrecen devuelven objetos
System.Object, lo que implicará que muchas veces haya
luego que reconvertirlos a su tipo original mediante downcasting
para poder así usar sus métodos específicos.
En su lugar, si sólo se va a usar un ArrayList para
almacenar objetos de un cierto tipo puede resultar más
cómodo usar un objeto de alguna clase derivada de
ArrayList cuyo método extractor de objetos oculte al
heredado de ArrayList y devuelva directamente objetos de ese
tipo.
Para ver más claramente cómo hacer la
ocultación, vamos a tomar el siguiente ejemplo donde se
deriva de una clase con un método void F() pero se desea que
en la clase hija el método que se tenga sea de la forma int
F():
class Padre
{
public void F()
{}
}
class Hija:Padre
{
public int F()
{return 1;}
}
Como en C# no se admite que en una misma clase hayan dos
métodos que sólo se diferencien en sus valores de
retorno, puede pensarse que el código anterior
producirá un error de compilación. Sin embargo, esto
no es así sino que el compilador lo que hará
será quedarse únicamente con la versión
definida en la clase hija y desechar la heredada de la clase padre.
A esto se le conoce como ocultación de miembro ya que
hace desparacer en la clase hija el miembro heredado, y cuando al
compilar se detecte se generará el siguiente de aviso (se
supone que clases.cs almacena el código anteiror):
clases.cs(9,15): warning CS0108: The keyword new is required on
'Hija.F()' because it hides inherited member 'Padre.F()';
Como generalmente cuando se hereda interesa que la clase hija
comparta los mismos miembros que la clase padre (y si acaso que
añada miembros extra), el compilador emite el aviso
anterior para indicar que no se está haciendo lo
habitual. Si queremos evitarlo hemos de preceder la
definición del método ocultador de la palabra
reservada new para así indicar explíctamente
que lo que queremos hacer es ocultar elF() heredado:
class Padre
{
public void F()
{}
}
class Hija:Padre
{
new public int F()
{return 1;}
}
En realidad la ocultación de miembros no implica los
miembros ocultados tengan que ser métodos, sino que
también pueden ser campos o cualquiera de los demás
tipos de miembro que en temas posteriores se verán. Por
ejemplo, puede que se desee que un campo X de tipo int
esté disponible en la clase hija como si fuese de tipo
string.
Tampoco implica que los miembros métodos ocultados tengan
que diferenciarse de los métodos ocultadores en su tipo de
retorno, sino que pueden tener exáctamente su mismo tipo de
retorno, parámetros y nombre. Hacer esto puede dar lugar a
errores muy sutiles como el incluido en la siguiente variante de la
clase Trabajador donde en vez de redefinirse Cumpleaños() lo
que se hace es ocultarlo al olvidar incluir el override:
using System;
class Persona
{
public string Nombre; // Campo de cada objeto Persona que almacena su nombre
public int Edad; // Campo de cada objeto Persona que almacena su edad
public string NIF; // Campo de cada objeto Persona que almacena su NIF
// Incrementa en uno la edad del objeto Persona
public virtual void Cumpleaños()
{
Console.WriteLine("Incrementada edad de persona");
}
// Constructor de Persona
public Persona (string nombre, int edad, string nif)
{
Nombre = nombre;
Edad = edad;
NIF = nif;
}
}
class Trabajador: Persona
{
int Sueldo; // Campo de cada objeto Trabajador que almacena cuánto gana
Trabajador(string nombre, int edad, string nif, int sueldo):
base(nombre, edad, nif)
{ // Inicializamos cada Trabajador en base al constructor de Persona
Sueldo = sueldo;
}
public Cumpleaños()
{
Edad++;
Console.WriteLine("Incrementada edad de trabajador");
}
public static void Main()
{
Persona p = new Trabajador("Josan", 22, "77588260-Z", 100000);
p.Cumpleaños();
// p.Sueldo++; //ERROR: Sueldo no es miembro de Persona
}
}
Al no incluirse override se ha perdido la capacidad de
polimorifsmo, y ello puede verse en que la salida que ahora
mostrara por pantalla el código:
Incrementada edad de persona
Errores de este tipo son muy sutiles y podrían ser
difíciles de detectar. Sin embargo, en C# es
fácil hacerlo gracias a que el compilador
emitirá el mensaje de aviso ya visto por haber hecho la
ocultación sin new. Cuando el programador lo vea
podrá añadir new para suprimirlo si realmente
lo que quería hacer era ocultar, pero si esa no era su
intención así sabrá que tiene que corregir el
código (por ejemplo, añadiendo el
override olvidado)
Como su propio nombre indica, cuando se redefine un método
se cambia su definición original y por ello las llamadas al
mismo ejecutaran dicha versión aunque se hagan a
través de variables de la clase padre que almacenen objetos
de la clase hija donde se redefinió. Sin embargo,
cuando se oculta un método no se cambia su
definición en la clase padre sino sólo en la clase
hija, por lo que las llamadas al mismo realizadas a través
de variables de la clase padre ejecutarán la versión
de dicha clase padre y las realizadas mediante variables de la
clase hija ejecutarán la versión de la clase hija.
En realidad el polimorfismo y la ocultación no son conceptos
totalmente antagónicos, y aunque no es válido definir
métodos que simultánemente cuenten con los
modificadores override y new ya que un método
ocultador es como si fuese la primera versión que se hace
del mismo (luego no puede redefinirse algo no definido), sí
que es posible combinar new y virtual para definir
métodos ocultadores redefinibles. Por ejemplo:
using System;
class A
{
public virtual void F() { Console.WriteLine("A.F"); }
}
class B: A
{
public override void F() { Console.WriteLine("B.F"); }
}
class C: B
{
new public virtual void F() { Console.WriteLine("C.F"); }
}
class D: C
{
public override void F() { Console.WriteLine("D.F"); }
}
class Ocultación
{
public static void Main()
{
A a = new D();
B b = new D();
C c = new D();
D d = new D();
a.F();
b.F();
c.F();
d.F();
}
}
La salida por pantalla de este programa es:
B.F
B.F
D.F
D.F
Aunque el verdadero tipo de los objetos a cuyo método se
llama en Main() es D, en las dos primeras llamadas se llama al F()
de B. Esto se debe a que la redefinición dada en B cambia la
versión de F() en A por la suya propia, pero la
ocultación dada en C hace que para la redefinición
que posteriormente se da en D se considere que la versión
original de F() es la dada en C y ello provoca que no modifique la
versiones de dicho método dadas en A y B (que, por la
redefinición dada en B, en ambos casos son la versión
de B)
Un truco nemotécnico que puede ser útil para
determinar a qué versión del método se
llamará en casos complejos como el anterior consiste en
considerar que el mecanismo de polimorfismo funciona como si
buscase el verdadero tipo del objeto a cuyo método se llama
descendiendo en la jerarquía de tipos desde el tipo de la
variable sobre la que se aplica el método y de manera que si
durante dicho recorrido se llega a alguna versión del
método con new se para la búsqueda y se queda
con la versión del mismo incluida en el tipo recorrido justo
antes del que tenía el método ocultador.
Hay que tener en cuenta que el grado de ocultación que
proporcione new depende del nivel de accesibilidad del
método ocultador, de modo que si es privado sólo
ocultará dentro de la clase donde esté definido. Por
ejemplo, dado:
using System;
class A
{
public virtual void F() // F() es un método redefinible
{
Console.WriteLine("F() de A");
}
}
class B: A
{
new private void F() {} // Oculta la versión de F() de A sólo dentro de B
}
class C: B
{
public override void F() // Válido, pues aquí sólo se ve el F() de A
{
base.F();
Console.WriteLine("F() de B");
}
public static void Main()
{
C obj = new C();
obj.F();
}
}
La salida de este programa por pantalla será:
F() de A
F() de B
Pese a todo lo comentado, hay que resaltar que la principal
utilidad de poder indicar explícitamente si se desea
redefinir u ocultar cada miembro es que facilita enormemente la
resolución de problemas de versionado de tipos que
puedan surgir si al derivar una nueva clase de otra y
añadirle miembros adicionales, posteriormente se la desea
actualizar con una nueva versión de su clase padre pero
ésta contiene miembros que entran en conflictos con los
añadidos previamente a la clase hija cuando aún no
existían en la clase padre. En lenguajes como Java donde
todos los miembros son implícitamente virtuales estos da
lugar a problemas muy graves debidos sobre todo a:
-
Que por sus nombres los nuevos miembros de la clase padre entre en
conflictos con los añadidos a la clase hija cuando no
exisitían. Por ejemplo, si la versión inicial
de de la clase padre no contiene ningún método de
nombre F(), a la clase hija se le añade void F() y luego en
la nueva versión de la clase padre se incorporado int F(),
se producirá un error por tenerse en la clase hija dos
métodos F()
En Java para resolver este problema una posibilidad sería
pedir al creador de la clase padre que cambiase el nombre o
parámetros de su método, lo cual no es siempre
posible ni conveniente en tanto que ello podría trasladar el
problema a que hubiesen derivado de dicha clase antes de volverla a
modificar. Otra posibilidad sería modificar el nombre o
parámetros del método en la clase hija, lo que
nuevamente puede llevar a incompatibilidades si también se
hubiese derivado de dicha clase hija.
-
Que los nuevos miembros tengan los mismos nombres y tipos de
parámetros que los incluidos en las clases hijas y sea
obligatorio que toda redefinición que se haga de ellos siga
un cierto esquema.
Esto es muy problemático en lenguajes como Java donde toda
definición de método con igual nombre y
parámetros que alguno de su clase padre es considerado
implícitamente redefinición de éste, ya que
difícilmente en una clase hija escrita con anterioridad a la
nueva versión de la clase padre se habrá seguido el
esquema necesario. Por ello, para resolverlo habrá que
actualizar la clase hija para que lo siga y de tal manera que los
cambios que se le hagan no afecten a sus subclases, lo que ello
puede ser más o menos difícil según las
características del esquema a seguir.
Otra posibilidad sería sellar el método en la clase
hija, pero ello recorta la capacidad de reutilización de
dicha clase y sólo tiene sentido si no fue redefinido en
ninguna subclase suya.
En C# todos estos problemas son de fácil solución ya
que pueden resolverse con sólo ocultar los nuevos miembros
en la clase hija y seguir trabajando como si no existiesen.
Miembros de tipo
En realidad, dentro la definición de un tipo de dato no
tiene porqué incluirse sólo definiciones de miembros
comunes a todos sus objetos, sino también pueden definirse
miembros ligados al tipo como tal y no a los objetos del mismo.
Para ello basta preceder la definición de ese miembro de la
palabra reservada static, como muestra este ejemplo:
class A
{
int x;
static int y;
}
Los objetos de clase A sólo van a disponer del campo x,
mientras que el campo y va a pertenecer a la clase A. Por esta
razón se dice que los miembros con modificador
static son miembros de tipo y que los no lo
tienen son miembros de objeto.
Para acceder a un miembro de clase ya no es válida la
sintaxis hasta ahora vista de
<objeto>.<miembro>, pues al no estar estos
miembros ligados a ningún objeto no podría ponerse
nada en el campo <objeto>. La sintaxis a usar para acceder a
estos miembros será
<nombreClase>.<miembro>, como muestra ejemplo
donde se asigna el valor 2 al miembro y de la clase A definida
más arriba:
A.y = 2;
Nótese que la inclusión de miembros de clase rompe
con la afirmación indicada al principio del tema en la que
se decía que C# es un lenguaje orientado a objetos puro en
el que todo con lo que se trabaja son objetos, ya que a los
miembros de tipo no se les accede a través de objetos sino
nombres de tipos.
Es importante matizar que si definimos una función como
static, entonces el código de la misma sólo
podrá acceder implícitamente (sin sintaxis
<objeto>.<miembro>) a otros miembros static del
tipo de dato al que pertenezca. O sea, no se podrá acceder a
ni a los miembros de objeto del tipo en que esté definido ni
se podrá usar this ya que el método no
está asociado a ningún objeto. O sea, este
código sería inválido:
int x;
static void Incrementa()
{
x++; //ERROR: x es miembro de objeto e Incrementa() lo es de clase.
}
También hay que señalar que los métodos
estáticos no entran dentro del mecanismo de redefiniciones
descrito en este mismo tema. Dicho mecanismo sólo es
aplicable a métodos de objetos, que son de quienes puede
declararse variables y por tanto puede actuar el polimorifsmo. Por
ello, incluir los modificadores virtual,
override o abstract al definir un
método static es considerado erróneo por
el compilador; aunque ello no significan que los miembros
static no se hereden, sino que sólo tiene sentido
redefinirlos.
Encapsulación
Ya hemos visto que la herencia y el polimorfismo eran dos de los
pilares fundamentales en los que es apoya la programación
orientada a objetos. Pues bien, el tercero y último es la
encapsulación, que es un mecanismo que permite a los
diseñadores de tipos de datos determinar qué miembros
de los tipos creen pueden ser utilizados por otros programadores y
cuáles no. Las principales ventajas que ello aporta son:
-
Se facilita a los programadores que vaya a usar el tipo de dato
(programadores clientes) el aprendizaje de cómo trabajar con
él, pues se le pueden ocultar todos los detalles relativos a
su implementación interna y sólo dejarle visibles
aquellos que puedan usar con seguridad. Además, así
se les evita que cometan errores por manipular inadecuadamente
miembros que no deberían tocar.
-
Se facilita al creador del tipo la posterior modificación
del mismo, pues si los programadores clientes no pueden acceder a
los miembros no visibles, sus aplicaciones no se verán
afectadas si éstos cambian o se eliminan. Gracias a esto es
posible crear inicialmente tipos de datos con un diseño
sencillo aunque poco eficiente, y si posteriormente es
necesariomodificarlos para aumentar su eficiencia, ello puede
hacerse sin afectar al código escrito en base a la no
mejorada de tipo.
La encapsulación se consigue añadiendo
modificadores de acceso en las definiciones de miembros y
tipos de datos. Estos modificadores son partículas que se
les colocan delante para indicar desde qué códigos
puede accederse a ellos, entendiéndose por acceder el hecho
de usar su nombre para cualquier cosa que no sea definirlo, como
llamarlo si es una función, leer o escribir su valor si es
un campo, crear objetos o heredar de él si es una clase,
etc.
Por defecto se considera que los miembros de un tipo de dato
sólo son accesibles desde código situado dentro de la
definición del mismo, aunque esto puede cambiarse
precediendolos de uno los siguientes modificadores (aunque algunos
de ellos ya se han explicado a lo largo del tema, aquí se
recogen todos de manera detallada) al definirlos:
public: Puede ser accedido desde cualquier
código.
protected: Desde una clase sólo puede
accederse a miembros protected de objetos de esa misma clase
o de subclases suyas. Así, en el siguiente código las
instrucciones comentadas con // Error no son válidas por lo
escrito junto a ellas:
public class A
{
protected int x;
static void F(A a, B b, C c)
{
a.x = 1; // Ok
b.x = 1; // Ok
c.x = 1; // OK
}
}
public class B: A
{
static void F(A a, B b, C c)
{
//a.x = 1; // Error, ha de accederse a traves de objetos tipo B o C
b.x = 1; // Ok
c.x = 1; // Ok
}
}
public class C: B
{
static void F(A a, B b, C c)
{
//a.x = 1; // Error, ha de accederse a traves de objetos tipo C
//b.x = 1; // Error, ha de accederse a traves de objetos tipo C
c.x = 1; // Ok
}
}
Obviamente siempre que se herede de una clase se tendrá
total acceso en la clase hija -e implícitamente sin
necesidad de usar la sintaxis
<objeto>.<miembro>- a los miembros que
ésta herede de su clase padre, como muestra el siguiente
ejemplo:
using System;
class A
{
protected int x=5;
}
class B:A
{
B()
{
Console.WriteLine("Heredado x={0} de clase A", x);
}
public static void Main()
{
new B();
}
}
Como es de esperar, la salida por pantalla del programa de ejemplo
será:
Heredado x=5 de clase A
private: Sólo puede ser accedido desde el
código de la clase a la que
pertenece. Es lo considerado por defecto.
internal: Sólo puede ser accedido desde
código perteneciente al ensamblado en que se ha definido.
protected internal: Sólo puede ser accedido
desde código perteneciente al ensamblado en que se ha
definido o desde
clases que deriven de la clase donde se ha definido.
Es importante recordar que toda redefinición de un
método virtual o abstracto ha de realizarse manteniendo los
mismos modificadores que tuviese el método original. Es
decir, no podemos redefinir un método protegido cambiando su
accesibilidad por pública, pues si el creador de la clase
base lo definió así por algo sería.
Respecto a los tipos de datos, por defecto se considera que son
accesibles sólo desde el mismo ensamblado en que ha sido
definidos, aunque también es posible modificar esta
consideración anteponiendo uno de los siguientes
modificadores a su definición:
public: Es posible acceder a la clase desde cualquier
ensamblado.
internal: Sólo es posible acceder a la clase
desde el ensamblado donde se declaró. Es lo considerado por
defecto.
También pueden definirse tipos dentro de otros (tipos
internos) En ese caso serán considerados miembros del
tipo contenedor dentro de la que se hayan definido, por lo que les
serán aplicables todos los modificadores válidos para
miembros y por defecto se considerará que, como con
cualquier miembro, son privados. Para acceder a estos tipos desde
código externo a su tipo contenedor (ya sea para heredar de
ellos, crear objetos suyos o acceder a sus miembros
estáticos), además de necesitarse los permisos de
acceso necesarios según el modificador de accesibilidad al
definirlos, hay que usar la notación
<nombreTipoContendera>.<nombreTipoInterno> como
muestra este ejemplo:
class A // No lleva modificador, luego se considera que es internal
{
public class AInterna {} // Si ahora no se pusiese public se consideraría private
}
class B:A.AInterna // B deriva de la clase interna AInterna definida dentro de A. Es
{} // válido porque A.AInterna es pública