Herramientas de usuario

Herramientas del sitio


clase:daw:prog:2eval:herencia_composicion

09 - POO IV: Herencia y composición

Herencia

La herencia es un mecanismo que permite la definición de una clase a partir de la definición de otra ya existente. Es decir, una clase hija extiende la funcionalidad de una clase padre.

Por ejemplo, supongamos que tenemos una clase Coche con el siguiente código:

import java.util.Arrays;

public class Coche {
    
    private String marca, modelo;
    private int velocidad;
    private float[] presionRuedas = new float[4];
    private boolean ruedaRepuesto;

    public Coche(String marca, String modelo, int velocidad, boolean ruedaRepuesto, float[] presionRuedas) {
        this.marca = marca;
        this.modelo = modelo;
        this.velocidad = velocidad;
        this.ruedaRepuesto = ruedaRepuesto;
        this.presionRuedas = presionRuedas;
    }

    public void acelerar(int incremento) {
        this.velocidad += incremento;
    }

    public void frenar(int decremento) {
        this.velocidad -= decremento;
    }

    public void cambiarRueda(){
        if(this.ruedaRepuesto) {
            this.ruedaRepuesto = false;
        } else {
            System.out.println("No existe rueda de repuesto");
        }
    }

    public void aumentarPresion(int rueda, float presion){
        presionRuedas[rueda] = Math.round((presionRuedas[rueda] + presion) * 10.0) / 10.0f;
    }

    public void reducirPresion(int rueda, float presion) {
        presionRuedas[rueda] = Math.round((presionRuedas[rueda] - presion) * 10.0) / 10.0f;
    }

    @Override
    public String toString(){
        return "Marca: " + this.marca + 
               "\nModelo: " + this.modelo +
               "\nVelocidad: " + this.velocidad + 
               "\nRueda repuesto: " + this.ruedaRepuesto + 
               "\nPresión ruedas: " + Arrays.toString(this.presionRuedas);
    }
}

Nuestra clase tiene los atributos marca, modelo y velocidad. Además, tiene un atributo que indica si existe o no rueda de repuesto y un array con la presión de las ruedas. Los métodos de la clase son acerlerar() y frenar(), que aumenta o disminuye la velocidad, cambiarRueda(), que, si existe la rueda de repuesto, pone el atributo a False para indicar que se ha sustituido una rueda y aumentarPresion() y reducirPresion(), que aumenta o reduce la presión de una de las ruedas.

Además, tiene un último método toString() que muestra por pantalla el estado del vehículo.

Fíjate en la forma de redondear la presión de las ruedas cuando utilizamos floats. Existen muchas maneras de hacerlo. Lo que hacemos aquí es multiplicar por 10 (al querer tener solo un decimal), redondear al entero más cercano y dividir el resultado por 10.0f para que solo muestre un decimal.
El método toString() es un método de la clase Object. Como todos los objetos en Java heredan de dicha clase (precisamente en este tema entenderás el concepto de herencia), todos ellos tienen acceso a este método. Lo habitual es sobreescribir (de nuevo, en este tema aprenderás el concepto de sobreescritura) el método para mostrar el estado del objeto como nosotros queramos (por eso tiene el decorador @Override antes de la cabecera del método).

Vamos a crear un nuevo objeto de tipo Coche en nuestra clase principal, ejecutar algunos de sus métodos e ir sacando por pantalla el estado del objeto para ver como cambia:

public class Main {
    
    public static void main(String[] args) {
        Coche coche1 = new Coche(
                            "Kia", 
                            "Niro", 
                            0, 
                            true, 
                            new float[] {2.4f, 2.4f, 2.4f, 2.4f}
                        );
        System.out.println(coche1.toString());
        coche1.acelerar(10);
        coche1.cambiarRueda();
        coche1.reducirPresion(0, 0.5f);
        coche1.aumentarPresion(1, 0.3f);
        coche1.frenar(3);
        coche1.cambiarRueda();
        System.out.println(coche1.toString());

}

Marca: Kia
Modelo: Niro
Velocidad: 0
Rueda repuesto: true
Presión ruedas: [2.4, 2.4, 2.4, 2.4]
No existe rueda de repuesto
Marca: Kia
Modelo: Niro
Velocidad: 7
Rueda repuesto: false
Presión ruedas: [1.9, 2.7, 2.4, 2.4]

Por ahora, nuestros métodos funcionan como toca. Supongamos ahora que queremos tener una nueva clase Moto. Esta clase tendrá también los atributos marca, modelo, velocidad y presionRuedas (aunque esta vez, tendrá solo 2 ruedas, en lugar de 4). Tendrá, además, los métodos acelerar(), frenar(), aumentarPresion(), reducirPresion() y toString():

import java.util.Arrays;

public class Moto {

    private String marca, modelo;
    private int velocidad;
    private float[] presionRuedas = new float[2];

    public Moto(String marca, String modelo, int velocidad, float[] presionRuedas) {
        this.marca = marca;
        this.modelo = modelo;
        this.velocidad = velocidad;
        this.presionRuedas = presionRuedas;
    }

    public void acelerar(int incremento) {
        this.velocidad += incremento;
    }

    public void frenar(int decremento) {
        this.velocidad -= decremento;
    }

    public void aumentarPresion(int rueda, float presion){
        presionRuedas[rueda] = Math.round((presionRuedas[rueda] + presion) * 10.0) / 10.0f;
    }

    public void reducirPresion(int rueda, float presion) {
        System.out.println(presion);
        presionRuedas[rueda] = Math.round((presionRuedas[rueda] - presion) * 10.0) / 10.0f;
    }

    @Override
    public String toString(){
        return "Marca: " + this.marca + 
               "\nModelo: " + this.modelo +
               "\nVelocidad: " + this.velocidad + 
               "\nPresión ruedas: " + Arrays.toString(this.presionRuedas);
    }

}

Si te fijas, la mayoría de atributos y métodos son iguales (o muy similares) en ambas clases. Con la herencia, podemos reutilizar parte del código de ambas clases creando una clase padre que contendrá ese código repetido. Por ejemplo, crea una clase Vehiculo con el siguiente código:

import java.util.Arrays;

public class Vehiculo {
    private String marca, modelo;
    private int velocidad;
    private float[] presionRuedas;

    public Vehiculo(String marca, String modelo, int velocidad, float[] presionRuedas) {
        this.marca = marca;
        this.modelo = modelo;
        this.velocidad = velocidad;
        this.presionRuedas = presionRuedas;
    }

    public void acelerar(int incremento) {
        this.velocidad += incremento;
    }

    public void frenar(int decremento) {
        this.velocidad -= decremento;
    }

    public void aumentarPresion(int rueda, float presion){
        presionRuedas[rueda] = Math.round((presionRuedas[rueda] + presion) * 10.0) / 10.0f;
    }

    public void reducirPresion(int rueda, float presion) {
        presionRuedas[rueda] = Math.round((presionRuedas[rueda] - presion) * 10.0) / 10.0f;
    }

    @Override
    public String toString(){
        return "Marca: " + this.marca + 
               "\nModelo: " + this.modelo +
               "\nVelocidad: " + this.velocidad + 
               "\nPresión ruedas: " + Arrays.toString(this.presionRuedas);
    }    
}

Hemos creado una clase Vehiculo con el código común a ambas clases (incluso hemos eliminado la restricción del número de ruedas en el atributo presionRuedas). Lo que deberíamos hacer ahora, es crear dos clases hijas Coche y Moto que hereden de Vehiculo para que compartan sus métodos y atributos. Para crear clases que hereden de otra utilizamos la palabra reserveda extends seguido de la clase padre en la definición de la clase hija.

Vamos a crear primero la clase Coche:

public class Coche extends Vehiculo{
    
}

Si creamos la clase Coche como el ejemplo anterior, nos dará un error con el mensaje “Implicit super constructor Vehiculo() is undefined for default constructor. Must define an explicit constructorJava(134217868)”. Esto ocurre porque nuestra clase padre Vehiculo tiene definido un constructor, con lo que cuando creemos un objeto de la clase hija Coche tenemos que ejecutar obligatoriamente ese constructor. Para solucionarlo, tenemos que crear un constructor en la clase Coche.

¿Y que contendrá ese constructor de la clase hija? Ese constructor tiene que ejecutar primero el constructor de la clase padre, y después el código que queramos añadir en la clase hija. En nuestro caso, deberá asignar valor a la marca, modelo, velocidad y la presión de las ruedas desde el constructor de la clase padre (ya que son atributos comunes que hemos definido en la clase Vehiculo) y, además, asignarle un valor inicial al atributo ruedaRepuesto, propia de la clase Coche. Para ello, tenemos que ser capaces de ejecutar métodos de la clase padre desde la clase hija. Ésto se puede hacer con la palabra reservada super().

super()

Como hemos dicho, para ejecutar métodos de la clase padre tenemos que usar la palabra reservada super. Podemos usarla de dos formas:

  • Si estamos en un constructor o en un método que sobreescribamos de la clase padre, la referencia a super() ejecutará el método padre que estemos sobreescribiendo (o el constructor del padre).
  • Podemos utilizar super para hacer referencia a la clase padre, con lo que podemos ejecutar métodos o acceder a propiedades de la clase padre.

Vamos a ver un par de ejemplos del uso de super. Crea un constructor en la clase Coche con el siguiente contenido:

public class Coche extends Vehiculo{

    public Coche(String marca, String modelo, int velocidad, float[] presionRuedas) {
        super(marca, modelo, velocidad, presionRuedas);
    }
    
}

Si te fijas, el constructor de la clase Coche hace referencia al constructor de la clase Vehiculo mediante super(). Obviamente, al método le tenemos que pasar las variables que espera el constructor de la clase padre (Vehiculo). En este caso, marca, modelo, velocidad y presionRuedas.

Vamos a crear un nuevo objeto de la clase Coche en nuestra clase principal y comprobar que todo funciona como toca:

public class Main {
    
    public static void main(String[] args) {
        Coche coche1 = new Coche(
                            "Kia", 
                            "Niro", 
                            0,
                            new float[] {2.4f, 2.4f, 2.4f, 2.4f} 
                        );
        
        System.out.println(coche1.toString());

    }   
}

Marca: Kia
Modelo: Niro
Velocidad: 0
Presión ruedas: [2.4, 2.4, 2.4, 2.4]

Si te fijas, hemos creado un objeto de la clase Coche y ejecutado uno de los métodos de la clase Vehiculo (toString). En realidad, podemos ejecutar cualquier método public o protected de la clase padre desde las clases hijas:

    public static void main(String[] args) {
        Coche coche1 = new Coche(
                            "Kia", 
                            "Niro", 
                            0,
                            new float[] {2.4f, 2.4f, 2.4f, 2.4f} 
                        );
        
        System.out.println(coche1.toString());
        coche1.acelerar(10);
        coche1.reducirPresion(0, 0.5f);
        coche1.aumentarPresion(1, 0.3f);
        coche1.frenar(3);
        System.out.println(coche1.toString());
    }   

Marca: Kia
Modelo: Niro
Velocidad: 0
Presión ruedas: [2.4, 2.4, 2.4, 2.4]
Marca: Kia
Modelo: Niro
Velocidad: 7
Presión ruedas: [1.9, 2.7, 2.4, 2.4]

Vamos a ver otro ejemplo de uso de super como acceso a métodos o propiedades de la clase padre. Añade lo siguiente al constructor de la clase hija (Coche):

public class Coche extends Vehiculo{

    public Coche(String marca, String modelo, int velocidad, float[] presionRuedas) {
        super(marca, modelo, velocidad, presionRuedas);
        super.acelerar(10);
    }
}

Si creamos ahora un objeto de la clase Coche y mostramos su estado (mediante el método toString()), veremos como su velocidad inicial ya no es 0, si no 10, ya que en el constructor ejecutamos el método acelerar() de la clase padre mediante super.acelerar():

    public static void main(String[] args) {
        Coche coche1 = new Coche(
                            "Kia", 
                            "Niro", 
                            0,
                            new float[] {2.4f, 2.4f, 2.4f, 2.4f} 
                        );
        
        System.out.println(coche1.toString());
    }   

Marca: Kia
Modelo: Niro
Velocidad: 10
Presión ruedas: [2.4, 2.4, 2.4, 2.4]

Añadiendo propiedades y métodos propios a las clases hijas

Ya tenemos la clase Coche con los atributos y métodos comunes. Ahora nos faltaría añadir la propiedad ruedaRepuesto y el método cambiarRueda():

public class Coche extends Vehiculo{
    private boolean ruedaRepuesto;

    public Coche(String marca, String modelo, int velocidad, float[] presionRuedas) {
        super(marca, modelo, velocidad, presionRuedas);
    }

    public void cambiarRueda(){
        if(this.ruedaRepuesto) {
            this.ruedaRepuesto = false;
        } else {
            System.out.println("No existe rueda de repuesto");
        }
    }
}

Vamos a crear un objeto Coche, utilizar sus funciones y mostrar el estado por pantalla:

    public static void main(String[] args) {
        Coche coche1 = new Coche(
                            "Kia", 
                            "Niro", 
                            0,
                            new float[] {2.4f, 2.4f, 2.4f, 2.4f} 
                        );
        
        System.out.println(coche1.toString());
        coche1.acelerar(10);
        coche1.reducirPresion(0, 0.5f);
        coche1.aumentarPresion(1, 0.3f);
        coche1.frenar(3);
        coche1.cambiarRueda();
        System.out.println(coche1.toString());
    }   
}

Marca: Kia
Modelo: Niro
Velocidad: 0
Presión ruedas: [2.4, 2.4, 2.4, 2.4]
No existe rueda de repuesto
Marca: Kia
Modelo: Niro
Velocidad: 7
Presión ruedas: [1.9, 2.7, 2.4, 2.4]

¿Por qué nos dice que no hay rueda de repuesto? Es lógico, ya que no le hemos pasado el valor inicial en el constructor. Para inicializar el atributo, debemos pasarle el valor inicial al constructor:

    public Coche(String marca, String modelo, int velocidad, float[] presionRuedas, boolean ruedaRepuesto) {
        super(marca, modelo, velocidad, presionRuedas);
        this.ruedaRepuesto = ruedaRepuesto;
    }

Y añadir ese valor cuando creemos el objeto Coche desde nuestra clase inicial:

    public static void main(String[] args) {
        Coche coche1 = new Coche(
                            "Kia", 
                            "Niro", 
                            0,
                            new float[] {2.4f, 2.4f, 2.4f, 2.4f},
                            true
                        );
        
        System.out.println(coche1.toString());
        coche1.acelerar(10);
        coche1.reducirPresion(0, 0.5f);
        coche1.aumentarPresion(1, 0.3f);
        coche1.frenar(3);
        coche1.cambiarRueda();
        System.out.println(coche1.toString());
    }  

Marca: Kia
Modelo: Niro
Velocidad: 0
Presión ruedas: [2.4, 2.4, 2.4, 2.4]
Marca: Kia
Modelo: Niro
Velocidad: 7
Presión ruedas: [1.9, 2.7, 2.4, 2.4]

Ya no nos muestra el error anterior, pero sigue sin aparecernos la propiedad ruedaRepuesto en el método toString(). Para eso, tenemos que modificar el método toString() en la clase hija mediante la sobreescritura de métodos.

Sobreescribir métodos

A menudo necesitamos modificar métodos de la clase padre desde las clases hijas. Por ejemplo, en nuestro caso, el método toString() de la clase Vehiculo no muestra la propiedad ruedaRepuesto en la clase Coche. Para que se muestre, tenemos que cambiar el código y añadirle el atributo. Este proceso se llama sobreescritura de métodos (Override).

En Java, si queremos sobreescribir un método tenemos que añadir el decorador @Override antes de la cabecera del método que queremos sobreescribir.

En realidad, la anotación @Override sirve para comprobar en tiempo de compilación si estás cumpliendo con los requisitos para sobrescribir un método. Si no usamos la anotación, el código funcionaría igual, pero Java no nos avisaría de posibles errores.

Vamos a modificar el método toString() en la clase Coche para que muestre si existe o no la rueda de repuesto:

    @Override
    public String toString(){
        return "Marca: " + this.marca + 
               "\nModelo: " + this.modelo +
               "\nVelocidad: " + this.velocidad + 
               "\nPresión ruedas: " + Arrays.toString(this.presionRuedas) + 
               "\nRueda de repuesto: " + this.ruedaRepuesto;
    }   

Si ejecutamos el código, vemos varios errores (en realidad, el compilador ya nos avisa de dichos errores antes de la ejecución):

The field Vehiculo.marca is not visible
The field Vehiculo.modelo is not visible
The field Vehiculo.velocidad is not visible
The field Vehiculo.presionRuedas is not visible

¿Recuerdas el tema de visibilidad? El error es, precisamente, ese. Los atributos de la clase Vehiculo los hemos definido como privados, con lo que no podemos acceder a ellos desde las clases hijas.

Podemos solucionarlos de varias formas. Por ejemplo, podemos cambiar la visibilidad de los atributos a protected:

public class Vehiculo {
    protected String marca, modelo;
    protected int velocidad;
    protected float[] presionRuedas;
    
    ...

Otra opción es mantener los atributos privados, crear getters públicos de los atributos y usarlos en el método toString() de las clases hijas:

public class Vehiculo {
    private String marca, modelo;
    private int velocidad;
    private float[] presionRuedas;

    public float[] getPresionRuedas() {
        return presionRuedas;
    }
    
    ...

public class Coche extends Vehiculo{
    private boolean ruedaRepuesto;

...

    @Override
    public String toString(){
        return "Marca: " + this.getMarca() + 
               "\nModelo: " + this.getModelo() +
               "\nVelocidad: " + this.getVelocidad() + 
               "\nPresión ruedas: " + Arrays.toString(this.getPresionRuedas()) + 
               "\nRueda de repuesto: " + this.ruedaRepuesto;
    }    

Una última opción sería usar super para recoger el String devuelto por el método toString() de la clase padre y añadirle el texto que falta:

public class Coche extends Vehiculo{
    private boolean ruedaRepuesto;

...

    @Override
    public String toString(){
        String respuesta = super.toString();
        respuesta += "\nRueda de repuesto: " + this.ruedaRepuesto;
        return respuesta;
    }    

public class Main {
    
    public static void main(String[] args) {
        Coche coche1 = new Coche(
                            "Kia", 
                            "Niro", 
                            0,
                            new float[] {2.4f, 2.4f, 2.4f, 2.4f},
                            true
                        );
        
        System.out.println(coche1.toString());
        coche1.acelerar(10);
        coche1.reducirPresion(0, 0.5f);
        coche1.aumentarPresion(1, 0.3f);
        coche1.frenar(3);
        coche1.cambiarRueda();
        System.out.println(coche1.toString());
    }   
}

Marca: Kia
Modelo: Niro
Velocidad: 0
Presión ruedas: [2.4, 2.4, 2.4, 2.4]
Rueda de repuesto: true
Marca: Kia
Modelo: Niro
Velocidad: 7
Presión ruedas: [1.9, 2.7, 2.4, 2.4]
Rueda de repuesto: false

Por último, nos quedaría crear la clase Moto, también hija de la clase Vehiculo. En este caso, al no tener atributos ni métodos propios, solo tendríamos que añadir el constructor:

public class Moto extends Vehiculo{

    public Moto(String marca, String modelo, int velocidad, float[] presionRuedas) {
        super(marca, modelo, velocidad, presionRuedas);
    }
    
}

public class Main {
    
    public static void main(String[] args) {
        Coche coche1 = new Coche(
                            "Kia", 
                            "Niro", 
                            0,
                            new float[] {2.4f, 2.4f, 2.4f, 2.4f},
                            true
                        );
        
        System.out.println(coche1.toString());
        coche1.acelerar(10);
        coche1.reducirPresion(0, 0.5f);
        coche1.aumentarPresion(1, 0.3f);
        coche1.frenar(3);
        coche1.cambiarRueda();
        System.out.println(coche1.toString());

        Moto moto1 = new Moto(
                        "Honda", 
                        "NC 750", 
                        0,
                        new float[] {2.5f, 2.5f});
        System.out.println(moto1.toString());
        moto1.acelerar(50);
        moto1.reducirPresion(1, 0.4f);
        System.out.println(moto1.toString());
    }   
}

Marca: Kia
Modelo: Niro
Velocidad: 0
Presión ruedas: [2.4, 2.4, 2.4, 2.4]
Rueda de repuesto: true
Marca: Kia
Modelo: Niro
Velocidad: 7
Presión ruedas: [1.9, 2.7, 2.4, 2.4]
Rueda de repuesto: false
Marca: Honda
Modelo: NC 750
Velocidad: 0
Presión ruedas: [2.5, 2.5]
Marca: Honda
Modelo: NC 750
Velocidad: 50
Presión ruedas: [2.5, 2.1]

Clases y métodos finales

¿Recuerdas como definíamos las constantes? Utilizamos final para indicar que el valor no podía cambiar. Podemos hacer algo similar con las clases y los métodos.

Si usamos la palabra final en una clase, estamos indicando que esa clase no puede tener herederos (clases hijas). Por ejemplo, si modificamos la clase Vehiculo añadiendo la palabra final, veremos como Java nos muestra un error diciendo que las clases Coche y Moto no pueden heredar de la clase Vehiculo:

public final class Vehiculo {
    private String marca, modelo;
    private int velocidad;
    private float[] presionRuedas;

...

The type Coche cannot subclass the final class Vehiculo

Algo parecido ocurre con los métodos. Si añadimos final a un método, le estamos indicando a Java que ese método no se puede cambiar. Es decir, no puede ser sobreescrito:

public final class Vehiculo {
    private String marca, modelo;
    private int velocidad;
    private float[] presionRuedas;

...

    @Override
    public final String toString(){
        return "Marca: " + this.marca + 
               "\nModelo: " + this.modelo +
               "\nVelocidad: " + this.velocidad + 
               "\nPresión ruedas: " + Arrays.toString(this.presionRuedas);
    }    

Cannot override the final method from Vehiculo

El uso de final es un método para asegurarnos que esa clase no puede tener clases hijas o ese método no puede ser sobreescrito.

Polimorfismo

Uno de los mecanismos más útiles de la herencia es el polimorfismo. El polimorfismo es una relajación del sistema de tipos, de tal manera que una referencia a una clase acepta direcciones de objetos de dicha clase y de sus clases derivadas (hijas, nietas, …).

Aunque pueda parecer un concepto complejo, en realidad es más sencillo de lo que parece. Vamos a ver un ejemplo para comprenderlo. Vamos a crear una clase Parking en nuestra aplicación que tendrá un array con las plazas del parking. Por ahora, solo vamos a permitir aparcar coches en nuestro parking:

public class Parking {

    private Coche[] plazas = new Coche[150];

    public void aparcarCoche(int plaza, Coche coche) {
        if(this.plazas[plaza] == null) {
            this.plazas[plaza] = coche;
        } else {
            System.out.println("La plaza está ocupada");
        }
    }

    @Override
    public String toString(){
        int count = 0;
        for (Coche coche : plazas) {
            if(coche != null) {
                count++;
            }
        }
        return "Hay " + count + " coches aparcados en el parking";
    }

}

El código anterior es muy simple. Creamos un array de objetos de tipo Coche de tamaño 150 (serán nuestras plazas del parking). Como no inicializamos el array, cuando se cree un objeto de tipo Praking todas las plazas estarán vacías (su valor será null). El método aparcarCoche() aceptará un número de plaza (posición en el array) y un objeto de tipo Coche. Si la plaza está libre (el valor de la posición en el array es distinto de null) almacenamos en esa posición el coche que le hayamos pasado al método.

Por último, tenemos el clásico método toString(), que lo único que nos mostrará será el número de coches que hay aparcados en nuestro parking.

Si creamos dos coches en nuestra clase principal y los aparcamos en nuestro parking, vemos que todo funciona como toca:

public class Main {
    
    public static void main(String[] args) {
        Coche coche1 = new Coche(
                            "Kia", 
                            "Niro", 
                            0,
                            new float[] {2.4f, 2.4f, 2.4f, 2.4f},
                            true
                        );
        Coche coche2 = new Coche(
                        "Seat", 
                        "Ibiza", 
                        0,
                        new float[] {2.5f, 2.5f, 2.5f, 2.5f},
                        true
        );
        
        Parking parking = new Parking();
        parking.aparcarCoche(0, coche1);
        parking.aparcarCoche(50, coche2);
        System.out.println(parking.toString());
    }   
}

Hay 2 coches aparcados en el parking

Supongamos ahora que queremos permitir aparcar motos también en nuestro parking (y que no hay distinción entre las plazas de coche y motos). Lo primero que vemos es que nuestras plazas de parking (el array de 150 elementos de la clase Parking) son de tipo Coche. Debemos cambiar eso para permitir aparcar también motos, pero ¿Cómo podemos hacerlo?.

Acuérdate que Java es de tipado estático, con lo que siempre tenemos que indicar de que tipo es la variable que estamos creando. Una posible solución sería crear otro array con elementos de tipo Moto, pero complicaría mucho la aplicación, ya que, primero, tendríamos que mantener dos listas para indicar lo mismo (las plazas libres y ocupadas) y, además, para insertar un coche, por ejemplo, deberíamos comprobar que esa posición está libre en ambas listas (los array de Coches y Motos).

Aquí es donde entra en juego el polimorfismo. Podemos tomarlo como una relajación de tipos. La solución es cambiar el tipo de elementos del array plazas por Vehiculo:

public class Parking {

    private Vehiculo[] plazas = new Vehiculo[150];

    public void aparcarCoche(int plaza, Coche coche) {
        if(this.plazas[plaza] == null) {
            this.plazas[plaza] = coche;
        } else {
            System.out.println("La plaza está ocupada");
        }
    }

    @Override
    public String toString(){
        int count = 0;
        for (Vehiculo vehiculo : plazas) {
            if(vehiculo != null) {
                count++;
            }
        }
        return "Hay " + count + " vehículos aparcados en el parking";
    }
}

Si ejecutamos la aplicación (sin modificar nada de Main), vemos que sigue funcionando como toca:

Hay 2 coches aparcados en el parking

Fíjate en el método aparcarCoche() de la clase Parking:

    public void aparcarCoche(int plaza, Coche coche) {
        if(this.plazas[plaza] == null) {
            this.plazas[plaza] = coche;
        } else {
            System.out.println("La plaza está ocupada");
        }
    }

Aunque nuestro array plazas espera objetos de tipo Vehiculo, le estamos pasando un objeto de tipo Coche y no da ningún error. Eso es precisamente uno de los tipos de polimorfismo que existen. Podemos usar objetos de clases derivadas como si fuesen la clase principal.

En realidad, ya habíamos usado polimorfismo antes sin saberlo. Cuando hablamos de colecciones, vimos que era habitual definir el tipo de datos como la interfaz genérica, por ejemplo:

Set<Integer> conjunto = new HashSet<>();

Si te das cuenta, la variable conjunto es de tipo Set, aunque después le asignamos un tipo distinto (HashSet).

En el caso anterior, estamos trabajando con interfaces (Set) en lugar de con herencia, pero el concepto es similar. Cuando veamos interfaces volveremos al tema del polimorfismo con interfaces.

Ya tenemos preparado nuestro parking para poder aparcar coches y motos. El problema, es que el método aparcarCoche() espera un objeto de tipo Coche, con lo que no lo podemos utilizar para aparcar motos. Una posible solución sería crear otro método aparcarMoto() donde le pasásemos una variable de tipo Moto, pero el código sería casi el mismo, con lo que estaríamos repitiendo código. Otra solución más eficiente sería usar polimorfismo también en el método. En lugar de pasarle una variable de tipo Coche, podríamos modificar el método y pasarle una variable de tipo Vehiculo:

    public void aparcar(int plaza, Vehiculo vehiculo) {
        if(this.plazas[plaza] == null) {
            this.plazas[plaza] = vehiculo;
        } else {
            System.out.println("La plaza está ocupada");
        }
    }

Ahora ya podemos pasarle coches y motos a nuestro método aparcar:

    public static void main(String[] args) {
        Coche coche1 = new Coche(
                            "Kia", 
                            "Niro", 
                            0,
                            new float[] {2.4f, 2.4f, 2.4f, 2.4f},
                            true
                        );
        Coche coche2 = new Coche(
                        "Seat", 
                        "Ibiza", 
                        0,
                        new float[] {2.5f, 2.5f, 2.5f, 2.5f},
                        true
        );
        Moto moto1 = new Moto(
                        "Honda", 
                        "NC 750", 
                        0,
                        new float[] {2.5f, 2.5f}
        );

        Parking parking = new Parking();
        parking.aparcar(0, coche1);
        parking.aparcar(50, coche2);
        parking.aparcar(30, moto1);
        System.out.println(parking.toString());
    }   

Hay 3 vehículos aparcados en el parking

Ventajas y desventajas de la herencia

Como hemos visto, la herencia es un mecanismo de la POO que nos ofrece una serie de ventajas muy útiles, entre ellas:

  • Nos permite estructura nuestra aplicación como una jerarquía de clases.
  • Favorece la reutilización de código, implementando los métodos comunes en las clases superiores.
  • Como consecuencia de lo anterior, reduce mucho el código de nuestra aplicación.
  • Cuenta con un mecanismo muy potente como es el polimorfismo, que nos facilita mucho las cosas en según que situaciones.

Aunque pueda parecer que es la solución a casi todos nuestros problemas, existen una serie de desventajas que desaconsejan muchas veces su uso:

  • Una clase no puede heredar de 2 o más clases a la vez. Solo puede tener una clase padre.
  • Es una relación de tipo es un/a, con lo que obliga a una clase hija a ser siempre del tipo clase padre.
  • Podemos tener problemas si ampliamos la aplicación con situaciones que no hayamos previsto al comienzo del diseño.

Las dos últimas desventajas pueden hacer que nuestra aplicación sea difícil de escalar y mantener, haciendo nuestro código cada vez más complicado y sin sentido. Por ejemplo, supongamos que añadimos la clase Barco a nuestra aplicación. Tal y como la hemos diseñado, si hacemos que Barco herede de la clase Vehiculo tendremos dos métodos inservibles como son aumentarPresion() y reducirPresion(). Además, no tendría mucho sentido aparcar un barco en un parking, aunque según nuestra aplicación se podría hacer sin problemas.

Puedes pensar que podríamos hacer la clase Barco de forma independiente, sin heredar de la clase Vehiculo para solucionar los problemas anteriores, pero, entonces, si queremos tener un listado de los vehículos que tiene una persona (incluyendo coches, motos, barcos…) se nos complicaría el código bastante.

También podríamos aumentar la jerarquía de clases, creando dos clases nuevas VehiculoTerrestre y VehiculoAcuatico que heredasen de Vehiculo. Nuestras clases Coche y Moto heredarían, a su vez, de VehiculoTerrestre y Barco de VehiculoAcuatico. Esto podría solucionar el tema del parking, ya que podríamos hacer que solo se pudiesen aparcar vehículos terrestres. El problema es que estamos aumentando la complejidad de nuestras clases, pudiendo darse el caso que las clases superiores tengan poco o nada de código. Además, si añadimos las clases MotoAgua o MotoNieve, al solo poder heredar de una clase padre, no podríamos reutilizar métodos y propiedades comunes que puedan compartir con la clase Moto.

Existen una serie de síntomas que indican que no estamos utilizando la herencia de forma correcta:

  • Clases hijas que sobreescriben varios métodos de las clases padres.
  • Clases hijas con métodos o propiedades inservibles o sin sentido.
  • Clases padres que agrupan clases que no tienen relación entre sí, aunque tengan algún método común.

Aunque la herencia puede ser muy útil en según que situaciones, normalmente es preferible favorecer la composición sobre la herencia.

Que la herencia tenga una serie de desventajas, no quiere decir que no sea útil. Hay situaciones donde nos viene muy bien (como la herencia de interfaces). El problema suele ser el mal uso de la herencia. Cuando aprendemos a programar OO, tendemos a tratar de meter herencia en casi todas nuestras clases, aunque no tenga sentido. Como siempre, un buen diseño reduce mucho los problemas futuros de nuestra aplicación.

Composición

La composición consisten en declarar clases que contienen otras clases. Es decir, es una relación de tipo tiene un/a, en lugar de es un/a como lo era la herencia. La idea general es tener instancias de una clase que contiene instancias de otras clases que implementan la funcionalidad deseada.

Por ejemplo, en nuestro ejemplo de los vehículos, en lugar de crear una clase padre Vehiculo de la cual heredarán el resto, podríamos crear las clases Coche y Moto y añadirle las partes (clases) que necesiten (en nuestro caso, la clase Rueda).

Para ver como quedaría, vamos a crear primero la clase Rueda:

public class Rueda {

    String marca;
    float presion;

    public Rueda(String marca, float presion) {
        this.marca = marca;
        this.presion = presion;
    }

    public void aumentarPresion(float incremento) {
        this.presion = Math.round((this.presion + presion) * 10.0) / 10.0f;
    }

    public void reducirPresion(float incremento) {
        this.presion = Math.round((this.presion - presion) * 10.0) / 10.0f;
    }

    @Override
    public String toString(){
        return "[Marca:" + this.marca + ", Presión:" + this.presion + "]";
    }

}

La clase solo tiene la marca de la rueda y la presión. Además, tiene los métodos aumentarPresion() y reducirPresion() que ya vimos anteriormente, y el método toString() para mostrar el estado de la rueda por pantalla. De esta forma, nuestra clase Coche quedaría de la siguiente forma:

public class Coche{
    private String marca, modelo;
    private int velocidad;
    private Rueda[] ruedas;
    private Rueda ruedaRepuesto;


    public Coche(String marca, String modelo, int velocidad, Rueda[] ruedas, Rueda ruedaRepuesto) {
        this.marca = marca;
        this.modelo = modelo;
        this.velocidad = velocidad;
        this.ruedas = ruedas;
        this.ruedaRepuesto = ruedaRepuesto;
    }

    public void acelerar(int incremento) {
        this.velocidad += incremento;
    }

    public void frenar(int decremento) {
        this.velocidad -= decremento;
    }

    public void cambiarRueda(int posicionRueda){
        if(this.ruedaRepuesto != null) {
            this.ruedas[posicionRueda] = ruedaRepuesto;
            this.ruedaRepuesto = null;
        } else {
            System.out.println("No existe rueda de repuesto");
        }
    }

    @Override
    public String toString(){
        String respuesta;
        respuesta = "Marca: " + this.marca + 
        "\nModelo: " + this.modelo +
        "\nVelocidad: " + this.velocidad + 
        "\nRuedas: ";
        for (Rueda rueda : ruedas) {
            respuesta += rueda.toString() + " ";
        }
        respuesta += "\nRueda de repuesto: ";
        if(this.ruedaRepuesto != null) {
            respuesta += ruedaRepuesto.toString();
        } else {
            respuesta += "Sin rueda de repuesto";
        }        return respuesta;
    }  
}

La clase sigue teniendo los atributos marca, modelo y velocidad, además de los métodos acelerar() y frenar(). Lo que ha cambiado, es que ahora tiene 2 atributos nuevos llamados ruedas, que es un array de objetos de tipo Rueda, y ruedaRepuesto, que es una instancia de Rueda.

De esta forma, podemos aumentar fácilmente nuestra funcionalidad. Fíjate, por ejemplo, en el método cambiarRueda(). Antes, lo único que hacíamos era poner el atributo ruedaRepuesto a null. Ahora, podemos pasarle la posición de la rueda que queremos cambiar, y sustituir esa rueda.

Usando solo herencia también podríamos aumentar la funcionalidad igual que hemos hecho en el ejemplo anterior, pero, según sea el problema, podrías ser bastante más complicado de implementar.

El último método (toString) muestra, como siempre, la situación del coche, pero ahora, podemos añadir más información, ya que podemos utilizar el método toString() de la clase Rueda. Si creamos un coche en nuestra clase principal y ejecutamos su método toString(), vemos que todo funciona correctamente:

public class Main {
    
    public static void main(String[] args) {
        Coche coche1 = new Coche(
                            "Kia", 
                            "Niro", 
                            0,
                            new Rueda[] {
                                new Rueda("Pirelli", 2.5f),
                                new Rueda("Pirelli", 2.5f),
                                new Rueda("Pirelli", 2.5f),
                                new Rueda("Pirelli", 2.5f),
                            },                            
                            new Rueda("Michelline", 2.0f)
                        );
        System.out.println(coche1.toString());

    }   
}

Marca: Kia
Modelo: Niro
Velocidad: 0
Ruedas: [Marca:Pirelli, Presión:2.5] [Marca:Pirelli, Presión:2.5] [Marca:Pirelli, Presión:2.5] [Marca:Pirelli, Presión:2.5] 
Rueda de repuesto: [Marca:Michelline, Presión:2.0]

Vamos a probar a cambiar una rueda y ver como queda:

    public static void main(String[] args) {
        Coche coche1 = new Coche(
                            "Kia", 
                            "Niro", 
                            0,
                            new Rueda[] {
                                new Rueda("Pirelli", 2.5f),
                                new Rueda("Pirelli", 2.5f),
                                new Rueda("Pirelli", 2.5f),
                                new Rueda("Pirelli", 2.5f),
                            },                            
                            new Rueda("Michelline", 2.0f)
                        );
        coche1.cambiarRueda(2);
        System.out.println(coche1.toString());
    }   

Marca: Kia
Modelo: Niro
Velocidad: 0
Ruedas: [Marca:Pirelli, Presión:2.5] [Marca:Pirelli, Presión:2.5] [Marca:Michelline, Presión:2.0] [Marca:Pirelli, Presión:2.5] 
Rueda de repuesto: Sin rueda de repuesto

Nuestra clase Moto, quedaría de la siguiente forma:

public class Moto{

    private String marca, modelo;
    private int velocidad;
    private Rueda[] ruedas;

    public Moto(String marca, String modelo, int velocidad, Rueda[] ruedas) {
        this.marca = marca;
        this.modelo = modelo;
        this.velocidad = velocidad;
        this.ruedas = ruedas;
    }
        
    public void acelerar(int incremento) {
        this.velocidad += incremento;
    }

    public void frenar(int decremento) {
        this.velocidad -= decremento;
    }

    @Override
    public String toString(){
        String respuesta;
        respuesta = "Marca: " + this.marca + 
        "\nModelo: " + this.modelo +
        "\nVelocidad: " + this.velocidad + 
        "\nRuedas: ";
        for (Rueda rueda : ruedas) {
            respuesta += rueda.toString() + " ";
        }
        return respuesta;
    }  
}

Creamos una moto para comprobar que todo funciona bien:

        Moto moto1 = new Moto(
                        "Honda", 
                        "NC 750", 
                        0,
                        new Rueda[] {
                            new Rueda("Pirelli", 2.8f),
                            new Rueda("Pirelli", 2.8f),
                        }
        );
        System.out.println(moto1.toString());

Marca: Honda
Modelo: NC 750
Velocidad: 0
Ruedas: [Marca:Pirelli, Presión:2.8] [Marca:Pirelli, Presión:2.8] 

De esta forma, podríamos añadir todas las clases que necesitaran nuestros vehículos (motor, carrocería…) sin obligar a que todos tuvieran las mismas piezas.

Hay varias consideraciones que tenemos que tener en cuenta cuando usamos composición:

  • No estamos reutilizando código, ya que ambas clases (Coche y Moto) tienen métodos similares que estamos codificando 2 veces
  • No podemos usar polimorfismo, con lo que si creamos la clase Parking sería complicado añadir vehículos a las plazas

Para el primer problema, tenemos varias soluciones. Una posible solución, sería combinar la herencia con la composición. Podríamos crear la clase Vehiculo que contiene objetos de la clase Rueda con los métodos comunes, y las clases hijas Coche y Moto. El problema, es que tendríamos los mismos problemas que usando solo herencia.

Otra solución bastante habitual es usar una clase aparte con los métodos comunes. Estas clases suelen llamarse helpers y sus métodos suelen ser estáticos. Por ejemplo, podríamos crear la clase VehiculoHelper con los métodos acelerar() y frenar(). Esos métodos recibirían, por ejemplo, la velocidad actual del vehículo (coche o moto) y el incremento/decremento (obviamente, con métodos tan sencillos como los que estamos haciendo no valdría la pena, al fin y al cabo lo único que hacemos es sumar/restar un número).

El segundo problema (no poder usar polimorfismo, con lo útil que es) tiene una solución muy sencilla como veremos en el siguiente tema: el uso de interfaces.

Aunque uno de los consejos más comunes de la POO es favorecer la composición sobre la herencia, ambas técnicas no son incompatibles. En nuestro ejemplo de los vehículos, podría ser una buena idea crear una clase padre Vehículo añadiendo partes con composición (ruedas, motor…) y crear clases hijas, según las necesidades de nuestra aplicación.

Ejercicios

Ejercicio 1

Crea la clase Product con los atributos id y price. Haz el constructor y los getters correspondientes, además del métodos toString(). Crea dos clases que hereden de Product: Clothes y Books.

Ejercicio 2

Añade los atributos type, size y colour a la clase Clothes, y author y title a la clase Books. Haz que los constructores de ambas clases reciban como parámetros todos los atributos (los de la clase padre y los suyos propios).

Ejercicio 3

En tu clase principal, crea varios productos de ambas clases (ropa y libros) y haz un método que muestre por pantalla los productos creados (almacena los productos en una lista de productos en tu clase principal).

Ejercicio 4

Sobreescribe el método toString() en ambas clases hijas para que se muestren los atributos propios de cada clase.

Ejercicio 5

Crea una constante en la clase Book llamada DISCOUNT con valor 0.8. Haz que el método getPrice() de esa clase devuelva el precio aplicándole el descuento correspondiente.

Ejercicio 6

Modifica el método de la clase principal que muestra por pantalla los productos para que se puede elegir los productos a mostrar (todos, la ropa o los libros).

Ejercicio 7

Crea 3 clases llamadas HD, Memory y CPU. La clase HD tendrá las propiedades type y capacity, la clase Memory la propiedad capacity y la clase CPU model y speed.

Haz los correspondientes getters, el método toString() y el constructor en cada una de las clases recién creadas.

Crea una nueva clase que herede de Product llamada Computer. Esta clase estará compuesta de una memoria, una CPU y un disco duro. En la clase principal, crea un nuevo ordenador y modifica el método que muestra por pantalla los productos para que se pueda elegir mostrar solo los ordenadores.

clase/daw/prog/2eval/herencia_composicion.txt · Última modificación: 2023/01/13 12:45 por cesguiro