Anulación de métodos en Java

Con la anulación de métodos, las clases heredadas pueden modificar cómo esperamos que se comporte una clase. En este artículo, exploraremos uno de los conceptos centrales de OOP: el polimorfismo, a través de la anulación de métodos.

Introducción

La Programación Orientada a Objetos (OOP) nos anima a modelar objetos del mundo real en código. Y lo que pasa con los objetos es que algunos comparten apariencias externas. Además, un grupo de ellos puede mostrar un comportamiento similar.

Java es un lenguaje excelente para atender a OOP. Permite que los objetos hereden las características comunes de un grupo. Les permite ofrecer sus atributos únicos también. Esto no solo lo convierte en un dominio rico, sino que también puede evolucionar con las necesidades comerciales.

Cuando una clase Java extiende a otra, la llamamos subclase. El que se extiende desde se convierte en una superclase. Ahora, la razón principal de esto es que la subclase puede usar las rutinas de la superclase. Sin embargo, en otros casos, la subclase puede querer agregar una funcionalidad adicional a la que ya tiene la superclase.

Con la anulación de métodos, las clases heredadas pueden modificar cómo esperamos que se comporte un tipo de clase. Y como mostrará este artículo, esa es la base de uno de los mecanismos más poderosos e importantes de OOP. Es la base del polimorfismo.

¿Qué es la anulación de métodos?

Generalmente, cuando una subclase extiende otra clase, hereda el comportamiento de la superclase. La subclase también tiene la oportunidad de cambiar las capacidades de la superclase según sea necesario.

Pero para ser precisos, llamamos a un método anular si comparte estas características con uno de los métodos de su superclase:

  1. El mismo nombre
  2. El mismo número de parámetros
  3. El mismo tipo de parámetros
  4. El mismo tipo de retorno o covariante

Para comprender mejor estas condiciones, tome una clase Forma. Esta es una figura geométrica, que tiene un área calculable:

1
2
3
abstract class Shape {
    abstract Number calculateArea();
}

Entonces, ampliemos esta clase base a un par de clases concretas — un ‘Triángulo’ y un ‘Cuadrado’:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class Triangle extends Shape {
    private final double base;
    private final double height;

    Triangle(double base, double height) {
        this.base = base;
        this.height = height;
    }

    @Override
    Double calculateArea() {
        return (base / 2) * height;
    }

    @Override
    public String toString() {
        return String.format(
                "Triangle with a base of %s and height of %s",
                new Object[]{base, height});
    }
}

class Square extends Shape {
    private final double side;

    Square(double side) {
        this.side = side;
    }

    @Override
    Double calculateArea() {
        return side * side;
    }

    @Override
    public String toString() {
        return String.format("Square with a side length of %s units", side);
    }
}

Además de anular el método calculateArea(), las dos clases también anulan toString() de Object. También tenga en cuenta que los dos anotan los métodos anulados con @Override.

Debido a que Shape es abstracto, las clases Triangle y Square deben anular calculateArea(), ya que el método abstracto no ofrece implementación.

Sin embargo, también agregamos una anulación toString(). El método está disponible para todos los objetos. Y dado que las dos formas son objetos, pueden anular toString(). Aunque no es obligatorio, hace que la impresión de los detalles de una clase sea amigable para los humanos.

Y esto es útil cuando queremos iniciar sesión o imprimir la descripción de una clase durante la prueba, por ejemplo:

1
2
3
4
5
6
7
void printAreaDetails(Shape shape) {
    var description = shape.toString();
    var area = shape.calculateArea();

    // Print out the area details to console
    LOG.log(Level.INFO, "Area of {0} = {1}", new Object[]{description, area});
}

Entonces, cuando ejecuta una prueba como:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
void calculateAreaTest() {
    // Declare the side of a square
    var side = 5;

    // Declare a square shape
    Shape shape = new Square(side);

    // Print out the square's details
    printAreaDetails(shape);

    // Declare the base and height of a triangle
    var base = 10;
    var height = 6.5;

    // Reuse the shape variable
    // By assigning a triangle as the new shape
    shape = new Triangle(base, height);

    // Then print out the triangle's details
    printAreaDetails(shape);
}

Obtendrá esta salida:

1
2
INFO: Area of Square with a side length of 5.0 units = 25
INFO: Area of Triangle with a base of 10.0 and height of 6.5 = 32.5

Como muestra el código, es recomendable incluir la notación @Override al sobrescribir. Y como explica Oráculo, esto es importante porque:

...instruye al compilador que intentas anular un método en la superclase. Si, por alguna razón, el compilador detecta que el método no existe en una de las superclases, generará un error.

Cómo y cuándo anular

En algunos casos, la anulación de métodos es obligatoria; si implementa una interfaz, por ejemplo, debe anular sus métodos. Sin embargo, en otros, generalmente depende del programador decidir si anularán algunos métodos dados o no.

Tome un escenario donde uno extiende una clase no abstracta, por ejemplo. El programador es libre (hasta cierto punto) de elegir métodos para anular de la superclase.

Métodos de interfaces y clases abstractas {#métodos de interfaces y clases abstractas}

Tome una interfaz, Identificable, que define el campo id de un objeto:

1
2
3
public interface Identifiable<T extends Serializable> {
    T getId();
}

T representa el tipo de clase que se usará para el id. Entonces, si usamos esta interfaz en una aplicación de base de datos, T puede tener el tipo Integer, por ejemplo. Otra cosa notable es que T es Serializable.

Entonces, podríamos almacenar en caché, persistir o hacer copias profundas de él.

Luego, supongamos que creamos una clase, PrimaryKey, que implementa Identifiable:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class PrimaryKey implements Identifiable<Integer> {
    private final int value;

    PrimaryKey(int value) {
        this.value = value;
    }

    @Override
    public Integer getId() {
        return value;
    }
}

PrimaryKey debe anular el método getId() de Identifiable. Significa que PrimaryKey tiene las características de Identificable. Y esto es importante porque PrimaryKey podría implementar varias interfaces.

En tal caso, tendría todas las capacidades de las interfaces que implementa. Es por eso que tal relación se llama una relación "has-a" en las jerarquías de clases.

Consideremos un escenario diferente. Tal vez tenga una API que proporcione una clase abstracta, Persona:

1
2
3
4
abstract class Person {
    abstract String getName();
    abstract int getAge();
}

Por lo tanto, si desea aprovechar algunas rutinas que solo funcionan en los tipos Person, tendrá que ampliar la clase. Tome esta clase Cliente, por ejemplo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
class Customer extends Person {
    private final String name;
    private final int age;

    Customer(String name, int age) {
        this.name = name;
        this.age = age;
    }

    @Override
    String getName() {
        return name;
    }

    @Override
    int getAge() {
        return age;
    }
}

Al extender Persona usando Cliente, se ve obligado a aplicar anulaciones. Sin embargo, solo significa que ha introducido una clase, que es del tipo Persona. Así ha introducido una relación "es-un". Y cuanto más lo miras, más sentido tienen tales declaraciones.

Porque, después de todo, un cliente es una persona.

Ampliación de una clase no final

A veces, encontramos clases que contienen capacidades que podríamos aprovechar. Digamos que está diseñando un programa que modela un juego de cricket, por ejemplo.

Has asignado al entrenador la tarea de analizar los partidos. Luego, después de hacer eso, te encuentras con una biblioteca, que contiene una clase Coach que motiva a un equipo:

1
2
3
4
5
class Coach {
    void motivateTeam() {
        throw new UnsupportedOperationException();
    }
}

Si Coach no se declara final, estás de suerte. Simplemente puede extenderlo para crear un CricketCoach que pueda analyzeGame() y motivateTeam():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class CricketCoach extends Coach {
    String analyzeGame() {
        throw new UnsupportedOperationException();
    }

    @Override
    void motivateTeam() {
        throw new UnsupportedOperationException();
    }
}

Ampliación de una clase final

Finalmente, ¿qué pasaría si tuviéramos que extender una clase final?

1
2
3
4
5
final class CEO {
    void leadCompany() {
        throw new UnsupportedOperationException();
    }
}

Y si tuviéramos que intentar replicar la funcionalidad de un ‘CEO’ a través de otra clase, por ejemplo, ‘SoftwareEngineer’:

1
class SoftwareEngineer extends CEO {}

Recibiríamos un desagradable error de compilación. Esto tiene sentido, ya que la palabra clave final en Java se usa para señalar cosas que no deberían cambiar.

No puedes extender una clase final.

Por lo general, si una clase no está destinada a extenderse, se marca como final, al igual que las variables. Sin embargo, hay una solución alternativa si debe ir en contra de la intención original de la clase y extenderla, hasta cierto punto.

Crear una clase contenedora que contenga una instancia de la clase final, que le proporciona métodos que pueden cambiar el estado del objeto. Sin embargo, esto solo funciona si la clase que se envuelve implementa una interfaz, lo que significa que podemos proporcionar el envoltorio en lugar de la clase “final”.

Finalmente, puede usar un Apoderado durante el tiempo de ejecución, aunque es un tema que amerita una artículo por sí mismo.

Un ejemplo popular de una clase final es la clase String. Es final y por lo tanto inmutable. Cuando realiza "cambios" a una Cadena con cualquiera de los métodos incorporados, se crea y devuelve una nueva Cadena, dando la ilusión de cambio:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public String concat(String str) {
    int otherLen = str.length();
    if (otherLen == 0) {
        return this;
    }

    int len = value.length;
    char buf[] = Arrays.copyOf(value, len + otherLen);
    str.getChars(buf, len);
    return new String(buf, true);
}

Anulación de métodos y polimorfismo

El diccionario Merriam Webster define el polimorfismo como:

La cualidad o estado de existir o asumir diferentes formas

La anulación de métodos nos permite crear una característica de este tipo en Java. Como mostró el ejemplo Shape, podemos programarlo para calcular áreas para diferentes tipos de formas.

Y más notablemente, ni siquiera nos importa cuáles son las implementaciones reales de las formas. Simplemente llamamos al método calculateArea() en cualquier forma. Depende de la clase de forma concreta determinar qué área proporcionará, según su fórmula única.

El polimorfismo resuelve los muchos escollos que surgen con los diseños de programación orientada a objetos inadecuados. Por ejemplo, podemos curar antipatrones como condicionales excesivos, clases etiquetadas y clases de utilidad. Mediante la creación de jerarquías polimórficas, podemos reducir la necesidad de estos antipatrones.

Condicionales

Es una mala práctica llenar el código con declaraciones condicionales y switch. La presencia de estos generalmente apunta al olor del código. Muestran que el programador se está entrometiendo con el flujo de control de un programa.

Considere las dos clases a continuación, que describen los sonidos que hacen un ‘Perro’ y un ‘Gato’:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class Dog {
    String bark() {
        return "Bark!";
    }

    @Override
    public String toString() {
        return "Dog";
    }
}

class Cat {
    String meow() {
        return "Meow!";
    }

    @Override
    public String toString() {
        return "Cat";
    }
}

Luego creamos un método makeSound() para hacer que estos animales produzcan sonidos:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
void makeSound(Object animal) {
    switch (animal.toString()) {
        case "Dog":
            LOG.log(Level.INFO, ((Dog) animal).bark());
            break;
        case "Cat":
            LOG.log(Level.INFO, ((Cat) animal).meow());
            break;
        default:
            throw new AssertionError(animal);
    }
}

Ahora, una prueba típica para makeSound() sería:

1
2
3
4
5
6
7
8
9
void makeSoundTest() {
    var dog = new Dog();
    var cat = new Cat();

    // Create a stream of the animals
    // Then call the method makeSound to extract
    // a sound out of each animal
    Stream.of(dog, cat).forEach(animal -> makeSound(animal));
}

Que luego da salida:

1
2
INFO: Bark!
INFO: Meow!

Si bien el código anterior funciona como se esperaba, muestra un diseño de programación orientada a objetos deficiente. Por lo tanto, deberíamos refactorizarlo para introducir una clase Animal abstracta. Esto luego asignará la producción de sonido a sus clases concretas:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
abstract class Animal {
    // Assign the sound-making
    // to the concrete implementation
    // of the Animal class
    abstract void makeSound();
}

class Dog extends Animal {
    @Override
    void makeSound() {
        LOG.log(Level.INFO, "Bark!");
    }
}

class Cat extends Animal {
    @Override
    void makeSound() {
        LOG.log(Level.INFO, "Meow!");
    }
}

La siguiente prueba muestra lo simple que se ha vuelto usar la clase:

1
2
3
4
5
6
7
8
9
void makeSoundTest() {
    var dog = new Dog();
    var cat = new Cat();

    // Create a stream of animals
    // Then call each animal's makeSound method
    // to produce each animal's unique sound
    Stream.of(dog, cat).forEach(Animal::makeSound);
}

Ya no tenemos un método makeSound separado como antes para determinar cómo extraer un sonido de un animal. En cambio, cada clase Animal concreta ha anulado makeSound para introducir polimorfismo. Como resultado, el código es legible y breve.

Si desea obtener más información sobre Expresiones lambda y Method References que se muestran en los ejemplos de código anteriores, ¡te tenemos cubierto!

Clases de utilidad

Las clases de utilidad son comunes en los proyectos de Java. Por lo general, se parecen al método min() de java.lang.matemáticas:

1
2
3
public static int min(int a, int b) {
    return (a <= b) ? a : b;
}

Proporcionan una ubicación central donde el código puede acceder a valores necesarios o de uso frecuente. El problema de estas utilidades es que no tienen las cualidades OOP recomendadas. En lugar de actuar como objetos independientes, se comportan como procedimientos. Por lo tanto, introducen programación procedimental en un ecosistema OOP.

Como en el escenario de los condicionales, deberíamos refactorizar las clases de utilidad para introducir polimorfismo. Y un excelente punto de partida sería encontrar un comportamiento común en los métodos de utilidad.

Tome el método min() en la clase de utilidad Math, por ejemplo. Esta rutina busca devolver un valor int. También acepta dos valores int como entrada. Luego compara los dos para encontrar el más pequeño.

Entonces, en esencia, min() nos muestra que necesitamos crear una clase de tipo Number - por conveniencia, llamada Minimum.

En Java, la clase Number es abstracta. Y eso es algo bueno. Porque nos permitirá anular los métodos que son relevantes solo para nuestro caso.

Por ejemplo, nos dará la oportunidad de presentar el número mínimo en varios formatos. Además de int, también podríamos ofrecer el mínimo como long, float o double. Como resultado, la clase Minimum podría verse así:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class Minimum extends Number {

    private final int first;
    private final int second;

    public Minimum(int first, int second) {
        super();
        this.first = first;
        this.second = second;
    }

    @Override
    public int intValue() {
        return (first <= second) ? first : second;
    }

    @Override
    public long longValue() {
        return Long.valueOf(intValue());
    }

    @Override
    public float floatValue() {
        return (float) intValue();
    }

    @Override
    public double doubleValue() {
        return (double) intValue();
    }
}

En el uso real, la diferencia de sintaxis entre Math's min y Minimum es considerable:

1
2
3
4
5
6
7
// Find the smallest number using
// Java's Math utility class
int min = Math.min(5, 40);

// Find the smallest number using
// our custom Number implementation
int minimumInt = new Minimum(5, 40).intValue();

Sin embargo, un argumento que se puede presentar en contra del enfoque anterior es que es más detallado. Es cierto que es posible que hayamos ampliado en gran medida el método de utilidad min(). ¡Lo hemos convertido en una clase completa, de hecho!

Algunos encontrarán esto más legible, mientras que otros encontrarán el enfoque anterior más legible.

Anulación frente a sobrecarga

En un Artículo anterior, exploramos qué es la sobrecarga de métodos y cómo funciona. Sobrecargar (como anular) es una técnica para perpetuar el polimorfismo.

Solo que en su caso, no nos implica ninguna herencia. Mira, siempre encontrarás métodos sobrecargados con nombres similares en una clase. Por el contrario, cuando anulas, tratas con los métodos que se encuentran en la jerarquía de un tipo de clase.

Otra diferencia distintiva entre los dos es cómo los tratan los compiladores. Los compiladores eligen entre métodos sobrecargados al compilar y resuelven métodos anulados en tiempo de ejecución. Es por eso que la sobrecarga también se conoce como polimorfismo compile-time. Y también podemos referirnos a la anulación como polimorfismo en tiempo de ejecución.

Aún así, anular es mejor que sobrecargar cuando se trata de realizar polimorfismo. Con la sobrecarga, corre el riesgo de crear API difíciles de leer. Por el contrario, la anulación obliga a adoptar jerarquías de clase. Estos son especialmente útiles porque obligan a los programadores a diseñar para programación orientada a objetos.

En resumen, la sobrecarga y la anulación difieren en estos aspectos:

Sobrecarga de métodos Anulación de métodos


No requiere ninguna herencia. Los métodos sobrecargados ocurren en una sola clase. Funciona a través de las jerarquías de clases. Por lo tanto, ocurre en varias clases relacionadas. Los métodos sobrecargados no comparten firmas de métodos. Mientras que los métodos sobrecargados deben compartir el mismo nombre, deben diferir en el número, tipo u orden de los parámetros. Los métodos anulados tienen la misma firma. Tienen el mismo número y orden de parámetros. No nos importa lo que devuelve un método sobrecargado. Por lo tanto, varios métodos sobrecargados pueden presentar valores de retorno muy diferentes. Los métodos anulados deben devolver valores que comparten un tipo. El tipo de excepciones que arrojan los métodos sobrecargados no concierne al compilador. Los métodos anulados siempre deben presentar la misma cantidad de excepciones que la superclase o menos.

Conclusión

La anulación de métodos es parte integral de la presentación del músculo OOP de Java. Cimenta las jerarquías de clases al permitir que las subclases posean e incluso amplíen las capacidades de sus superclases.

Aún así, la mayoría de los programadores encuentran la función solo cuando implementan interfaces o extienden clases abstractas. La anulación no obligatoria puede mejorar la legibilidad de una clase y la consiguiente usabilidad.

Por ejemplo, se le recomienda anular el método toString() de la clase Object. Y este artículo mostró tal práctica cuando anuló toString() para los tipos Shape - Triangle y Square.

Finalmente, debido a que la anulación de métodos combina la herencia y el polimorfismo, es una excelente herramienta para eliminar los olores comunes del código. Los problemas, como los condicionales excesivos y las clases de utilidad, podrían volverse menos frecuentes mediante el uso inteligente de la anulación.

Como siempre, puede encontrar el código completo en GitHub.

Licensed under CC BY-NC-SA 4.0