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
 {
    // Campo de cada objeto Persona que almacena su nombre

public string Nombre;
// Campo de cada objeto Persona que almacena su edad
public int Edad;     
// Campo de cada objeto Persona que almacena su NIF

    public string NIF;    

    void Cumpleaños()   // Incrementa en uno de edad del objeto Persona
    {
       Edad++;
    }
                                   
   // Constructor de Persona
    public Persona (string nombre, int edad, string nif)
    {
       Nombre = nombre;
       Edad = edad;
       NIF = nif;
    }
 }
 
 class Trabajador: Persona
 {
 
// Campo de cada objeto Trabajador que almacena cuánto gana
    public int Sueldo;

 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 dichar 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 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
  {
     // Campo de cada objeto Persona que almacena su nombre
     public string Nombre;     
     // Campo de cada objeto Persona que almacena su edad
     public int Edad;              
     // Campo de cada objeto Persona que almacena su NIF
     public string NIF;            
  // Incrementa en uno de la edad del objeto Persona
    

     public virtual void Cumpleaños()
{      
Edad++;
      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
  {
     // Campo de cada objeto Trabajador que almacena cuánto gana

public int Sueldo;     

     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 void Cumpleaños()
     {
     Edad++;
       Console.WriteLine("Incrementada edad de trabajador");
     }
             
     public static void Main()
     {
        Persona p = new Persona("Carlos", 22, "77588261-Z");
        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:


 ncrementada edad de trabajador

 ncrementada 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 primegenia: 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 es 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.

  • protected virtual void Finalize(): Contiene el código que se ejecutará siempre que vaya ha ser destruido algún objeto del tipo del que sea miembro. La implementación dada por defecto a Finalize() consiste en no hacer nada.

Aunque es un método virtual, en C# no se permite que el programador lo redefina explícitamente dado que hacerlo es peligroso por razones que se explicarán en el Tema 8: Métodos (otros lenguajes de .NET podrían permitirlo)

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 Equals(object objeto1, object objeto2) à Versión  estática del método Equals() ya visto. Indica si los objetos que se le pasan como parámetros son iguales, y para compararlos lo que hace es devolver el resultado de calcular objeto1.Equals(objeto2) comprobando antes si alguno de los objetos vale null (sólo se devolvería true sólo si el otro también lo es)

Obviamente si se da una redefinición al Equals() no estático, sus efectos también se verán cuando se llame al estático.

  • 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)

Herencia y métodos virtuales
José Antonio González Seco

José Antonio es experto en tecnologias Microsoft. Imparte cursos y conferencias en congresos sobre C# y .NET en Universidades de toda España (Sevilla, Barcelona, San Sebastián, Valencia, Oviedo, etc.) en representación de grandes empresas como Microsoft.
Fecha de alta:03/10/2006
Última actualizacion:03/10/2006
Visitas totales:124179
Valorar el contenido:
Últimas consultas realizadas en los foros
Últimas preguntas sin contestar en los foros de devjoker.com