Herencia en Java

Herencia en Java

1. Introducción

Uno de los principios fundamentales de la Programación Orientada a Objetos: la herencia, nos permite reutilizar código existente o extender un tipo de clase existente.

En pocas palabras, en Java, una clase puede heredar de otra clase y múltiples interfaces, mientras que una interfaz puede heredar de otras interfaces.

En este artículo, comenzaremos con la necesidad de la herencia, pasando a cómo funciona la herencia con clases e interfaces.

Luego, cubriremos cómo los nombres de variables/métodos y los modificadores de acceso afectan a los miembros que se heredan.

Y al final, veremos qué significa heredar un tipo.

2. La Necesidad de la Herencia

Imagina, como fabricante de coches, que ofreces múltiples modelos de coches a tus clientes. Aunque diferentes modelos de coches pueden ofrecer diferentes características como un techo corredizo o ventanas a prueba de balas, todos incluirían componentes y características comunes, como el motor y las ruedas.

Tiene sentido crear un diseño básico y extenderlo para crear sus versiones especializadas, en lugar de diseñar cada modelo de coche por separado, desde cero.

De manera similar, con la herencia, podemos crear una clase con características y comportamientos básicos y crear sus versiones especializadas mediante la creación de clases que heredan esta clase base. De la misma manera, las interfaces pueden extender interfaces existentes.

Notaremos el uso de varios términos para referirse a un tipo que es heredado por otro tipo, específicamente:

  • un tipo base también se llama tipo super o padre
  • un tipo derivado se denomina tipo extendido, subtipo o hijo

3. Herencia de Clase

3.1. Extender una Clase

Una clase puede heredar de otra clase y definir miembros adicionales.

Comencemos definiendo una clase base Coche:

public class Coche {
    int ruedas;
    String modelo;
    void arrancar() {
        // Verificar partes esenciales
    }
}

La clase CocheBlindado puede heredar los miembros de la clase Coche usando la palabra clave extends en su declaración:

public class CocheBlindado extends Coche {
    int ventanasAntibalas;
    void arrancarConControlRemoto() {
	// este vehículo se puede arrancar con un control remoto
    }
}

Ahora podemos decir que la clase CocheBlindado es una subclase de Coche, y esta última es una superclase de CocheBlindado.

Las clases en Java admiten herencia simple; la clase CocheBlindado no puede extender múltiples clases.

Además, ten en cuenta que en ausencia de una palabra clave extends, una clase hereda implícitamente la clase java.lang.Object.

Una subclase hereda los miembros protected y public no estáticos de la superclase. Además, los miembros con acceso default (package-private) se heredan si las dos clases están en el mismo paquete.

Por otro lado, los miembros private y static de una clase no se heredan.

3.2. Acceso a Miembros Padre desde una Clase Hija

Para acceder a propiedades o métodos heredados, simplemente podemos usarlos directamente:

public class CocheBlindado extends Coche {
    public String registrarModelo() {
        return modelo;
    }

Ten en cuenta que no necesitamos una referencia a la superclase para acceder a sus miembros.

4. Herencia de Interfaz

4.1. Implementar Múltiples Interfaces

Aunque las clases pueden heredar solo de una clase, pueden implementar múltiples interfaces.

Imagina que el CocheBlindado que definimos en la sección anterior es necesario para un superespía. Entonces, la compañía fabricante de coches pensó en agregar funcionalidades de vuelo y flotación:

public interface Flotable {
    void flotarEnAgua();
}

public interface Volable {
    void volar();
}

public class CocheBlindado extends Coche implements Flotable, Volable {
    public void flotarEnAgua() {
        System.out.println("¡Puedo flotar!");
    }

    public void volar() {
        System.out.println("¡Puedo volar!");
    }
}

En el ejemplo anterior, notamos el uso de la palabra clave implements para heredar de una interfaz.

4.2. Problemas con la Herencia Múltiple

Java permite la herencia múltiple mediante interfaces.

Hasta Java 7, esto no era un problema. Las interfaces solo podían definir métodos abstract, es decir, métodos sin ninguna implementación. Entonces, si una clase implementaba múltiples interfaces con la misma firma de método, no era un problema. La clase que implementaba finalmente solo tenía un método que implementar.

Veamos cómo esta ecuación simple cambió con la introducción de métodos default en las interfaces con Java 8.

A partir de Java 8, las interfaces podían optar por definir implementaciones default para sus métodos (una interfaz todavía puede definir métodos abstract). Esto significa que si una clase implementa múltiples interfaces que definen métodos con la misma firma, la clase hija heredaría implementaciones separadas. Esto suena complejo y no está permitido.

Java no permite la herencia de múltiples implementaciones de los mismos métodos definidos en interfaces separadas.

Aquí tienes un ejemplo:

public interface Flotable {
    default void reparar() {
    	System.out.println("Reparando objeto flotante");	
    }
}

public interface Volable {
    default void reparar() {
    	System.out.println("Reparando objeto volador");	
    }
}

public class CocheBlindado extends Coche implements Flotable, Volable {
    // esto no se compilará
}

Si deseamos implementar ambas interfaces, tendremos que sobrescribir el método reparar.

Si las interfaces en los ejemplos anteriores definen variables con el mismo nombre, por ejemplo, duracion, no podemos acceder a ellas sin preceder el nombre de la variable con el nombre de la interfaz:

public interface Flotable {
    int duracion = 10;
}

public interface Volable {
    int duracion = 20;
}

public class CocheBlindado extends Coche implements Flotable, Volable {
 
    public void unMetodo() {
    	System.out.println(duracion); // no se compilará
    	System.out.println(Flotable.duracion); // muestra 10
    	System.out.println(Volable.duracion); // muestra 20
    }
}

4.3. Interfaces que Extienden Otras Interfaces

Una interfaz puede extender múltiples interfaces. Aquí tienes un ejemplo:

public interface Flotable {
    void flotarEnAgua();
}

interface Volable {
    void volar();
}

public interface NaveEspacial extends Flotable, Volable {
    void controlRemoto();
}

Una interfaz hereda otras interfaces usando la palabra clave extends. Las clases usan la palabra clave implements para heredar una interfaz.

5. Heredar Tipo

Cuando una clase hereda de otra clase o interfaces, además de heredar sus miembros, también hereda su tipo. Esto también se aplica a una interfaz que hereda otras interfaces.

Este es un concepto muy poderoso que permite a los desarrolladores programar a una interfaz (clase base o interfaz), en lugar de programar a sus implementaciones.

Por ejemplo, imagina una situación en la que una organización mantiene una lista de los coches propiedad de sus empleados. Por supuesto, todos los empleados pueden poseer diferentes modelos de coches. Entonces, ¿cómo podemos referirnos a diferentes instancias de coches? Aquí está la solución:

public class Empleado {
    private String nombre;
    private Coche coche;
    
    // constructor estándar
}

Dado que todas las clases derivadas de Coche heredan el tipo Coche, las instancias de clases derivadas se pueden referir mediante una variable de tipo Coche:

Empleado e1 = new Empleado("Toni", new CocheBlindado());
Empleado e2 = new Empleado("Paco", new CocheDeCarreras());
Empleado e3 = new Empleado("Rubén", new Ferrari());

6. Miembros Ocultos de la Clase

6.1. Miembros de Instancia Ocultos

¿Qué sucede si tanto la superclase como la subclase definen una variable o método con el mismo nombre? No te preocupes, aún podemos acceder a ambos. Sin embargo, debemos hacer nuestro intento claro para Java, prefijando la variable o el método con las palabras clave this o super.

La palabra clave this se refiere a la instancia en la que se utiliza. La palabra clave super se refiere a la instancia de la clase padre:

public class CocheBlindado extends Coche {
    private String modelo;
    public String obtenerUnValor() {
    	return super.modelo;   // devuelve el valor de modelo definido en la clase base Coche
    	// return this.modelo;   // devolverá el valor de modelo definido en CocheBlindado
    	// return modelo;   // devolverá el valor de modelo definido en CocheBlindado
    }
}

Muchos desarrolladores usan las palabras clave this y super para indicar explícitamente a qué variable o método se refieren. Sin embargo, usarlas con todos los miembros puede hacer que nuestro código parezca desordenado.

6.2. Miembros static Ocultos

¿Qué sucede cuando nuestra clase base y las subclases definen variables y métodos estáticos con el mismo nombre? ¿Podemos acceder a un miembro static desde la clase base en la clase derivada, de la misma manera en que lo hacemos para las variables de instancia?

Averigüémoslo usando un ejemplo:

public class Coche {
    public static String mensaje() {
        return "Coche";
    }
}

public class CocheBlindado extends Coche {
    public static String mensaje() {
        return super.mensaje(); // esto no se compilará.
    }
}

No, no podemos. Los miembros estáticos pertenecen a una clase y no a las instancias. Entonces no podemos usar la palabra clave super no estática en mensaje().

Dado que los miembros estáticos pertenecen a una clase, podemos modificar la llamada anterior de la siguiente manera:

return Coche.mensaje();

Considera el siguiente ejemplo en el que tanto la clase base como la clase derivada definen un método static mensaje() con la misma firma:

public class Coche {
    public static String mensaje() {
        return "Coche";
    }
}

public class CocheBlindado extends Coche {
    public static String mensaje() {
        return "CocheBlindado";
    }
}

Así es como podemos llamarlos:

Coche primero = new CocheBlindado();
CocheBlindado segundo = new CocheBlindado();

Para el código anterior, primero.mensaje() mostrará Coche y segundo.mensaje() mostrará CocheBlindado. El mensaje estático que se llama depende del tipo de variable utilizada para referirse a la instancia de CocheBlindado.

7. Conclusión

En este artículo, cubrimos un aspecto fundamental del lenguaje Java: la herencia.

Vimos cómo Java admite la herencia simple con clases y la herencia múltiple con interfaces, y discutimos las complejidades de cómo funciona este mecanismo en el lenguaje.