Anulación de método en Java

    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 la programación orientada a objetos. Permite que los objetos hereden las características comunes de un grupo. También les permite ofrecer sus atributos únicos. Esto no solo lo convierte en un dominio rico, sino también en uno que puede evolucionar con las necesidades comerciales.

    Cuando una clase Java extiende 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 funcionalidad adicional a lo que ya tiene la superclase.

    Con la invalidació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 la programación orientada a objetos. Es la base del polimorfismo.

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

    Generalmente, cuando una subclase extiende a 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 como primordial si comparte estas características con uno de los métodos de su superclase:

    • El mismo nombre
    • El mismo número de parámetros
    • El mismo tipo de parámetros
    • El mismo tipo de retorno o covariante

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

    abstract class Shape {
        abstract Number calculateArea();
    }
    

    Luego, extendamos esta clase base a un par de clases concretas: una Triangle y un Square:

    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 calculateArea() método, las dos clases anulan Objectes toString() también. También tenga en cuenta que los dos anotan los métodos anulados con @Override.

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

    Sin embargo, también agregamos un toString() anular. 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 imprimir 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 al probar, por ejemplo:

    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:

    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:

    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 aconsejable incluir el @Override notación al anular. Y como Oráculo explica, esto es importante porque:

    … indica al compilador que tiene la intención de 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.

    Tomemos un escenario en el que se 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

    Toma una interfaz Identifiable, que define la id campo:

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

    T representa el tipo de clase que se utilizará 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.

    Entonces, digamos que creamos una clase, PrimaryKey, que implementa Identifiable:

    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() desde Identifiable. Esto significa que PrimaryKey tiene las características de Identifiable. 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 denomina relación “tiene-a” en las jerarquías de clases.

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

    abstract class Person {
        abstract String getName();
        abstract int getAge();
    }
    

    Entonces, si desea aprovechar algunas rutinas que solo funcionan en Person tipos, tendrías que extender la clase. Toma esto Customer clase, por ejemplo:

    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;
        }
    }
    

    Extendiendo Person utilizando Customer, está obligado a aplicar anulaciones. Sin embargo, solo significa que ha introducido una clase, que es del tipo Person. Por tanto, ha introducido una relación “es-un”. Y cuanto más lo miras, más sentido tienen esas 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 de las que podríamos hacer un buen uso. Supongamos que está diseñando un programa que modela un juego de cricket, por ejemplo.

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

    class Coach {
        void motivateTeam() {
            throw new UnsupportedOperationException();
        }
    }
    

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

    class CricketCoach extends Coach {
        String analyzeGame() {
            throw new UnsupportedOperationException();
        }
    
        @Override
        void motivateTeam() {
            throw new UnsupportedOperationException();
        }
    }
    

    Ampliando una clase final

    Finalmente, ¿qué pasaría si extendiéramos un final ¿clase?

    final class CEO {
        void leadCompany() {
            throw new UnsupportedOperationException();
        }
    }
    

    Y si intentáramos replicar un CEOs funcionalidad a través de otra clase, digamos, SoftwareEngineer:

    class SoftwareEngineer extends CEO {}
    

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

    No puedes extender un final clase.

    Normalmente, si una clase no está destinada a ampliarse, se marca como final, lo mismo que las variables. Sin embargo, existe una solución 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 del final class, que le proporciona métodos que pueden cambiar el estado del objeto. Sin embargo, esto solo funciona si la clase que se está empaquetando implementa una interfaz, lo que significa que podemos suministrar la empaquetadura en lugar del final clase en su lugar.

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

    Un ejemplo popular de final la clase es la String clase. Es final y por tanto inmutable. Cuando realiza “cambios” en una cadena con cualquiera de los métodos integrados, un nuevo String se crea y se devuelve, dando la ilusión de cambio:

    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étodo y polimorfismo

    los Merriam Webster El diccionario define polimorfismo como:

    La calidad o estado de existir o asumir diferentes formas.

    La invalidación de métodos nos permite crear una característica de este tipo en Java. Como el Shape En el ejemplo mostrado, 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 calculateArea() método en cualquier forma. Depende de la clase de forma de hormigón determinar qué área proporcionará, según su fórmula única.

    El polimorfismo resuelve las muchas trampas que vienen con los diseños OOP inadecuados. Por ejemplo, podemos curar antipatrones como condicionales excesivos, clases etiquetadas y clases de utilidad. Al crear jerarquías polimórficas, podemos reducir la necesidad de estos antipatrones.

    Condicionales

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

    Considere las dos clases siguientes, que describen los sonidos que un Dog y un Cat hacer:

    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:

    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:

    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 como resultado:

    INFO: Bark!
    INFO: Meow!
    

    Si bien el código anterior funciona como se esperaba, no obstante muestra un diseño OOP deficiente. Por tanto, deberíamos refactorizarlo para introducir un resumen Animal clase. Esto luego asignará la creación de sonido a sus clases concretas:

    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 prueba a continuación muestra lo simple que se ha vuelto usar la clase:

    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 separado makeSound método como antes para determinar cómo extraer un sonido de un animal. En cambio, cada concreto Animal la clase ha anulado makeSound para introducir polimorfismo. Como resultado, el código es legible y breve.

    Si desea leer más sobre las expresiones Lambda y las referencias de métodos que se muestran en los ejemplos de código anteriores, ¡lo tenemos cubierto!

    Clases de servicios públicos

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

    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 los valores necesarios o de uso frecuente. El problema con 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 la programación procedimental en un ecosistema de programación orientada a objetos.

    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.

    Toma el min() método en el Math clase de utilidad, por ejemplo. Esta rutina busca devolver un int valor. También acepta dos int valores 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, nombrado Minimum.

    En Java, el Number la clase es abstracta. Y eso es 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, floato un double. Como resultado, el Minimum la clase podría verse así:

    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 Mathes min y Minimum es considerable:

    // 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 podemos haber expandido el método de utilidad min() en gran parte. ¡Lo hemos convertido en una clase en toda regla, de hecho!

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

    Anulación vs sobrecarga

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

    Solo que en su caso, no implicamos ninguna herencia. Mira, siempre encontrarás métodos sobrecargados con nombres similares en una clase. Por el contrario, cuando anula, se ocupa de 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 los métodos anulados en tiempo de ejecución. Es por eso que la sobrecarga también se conoce como polimorfismo en tiempo de compilación. 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 prevalencia obliga a uno a adoptar jerarquías de clases. Son especialmente útiles porque obligan a los programadores a diseñar para POO.

    En resumen, la sobrecarga y la anulación difieren de estas formas:

    Método de sobrecarga Anulación de método

    No requiere herencia. Los métodos sobrecargados ocurren en una sola clase.Funciona en todas 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 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 devuelva 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 compartan un tipo.
    El tipo de excepciones que arrojan los métodos sobrecargados no conciernen al compiladorLos métodos anulados siempre deben presentar el mismo número de excepciones que la superclase o menos

    Conclusión

    La anulación del método 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 característica 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 anima a anular la toString() método de la clase Object. Y este artículo mostró tal práctica cuando anuló toString() Para el Shape tipos – Triangle y Square.

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

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

    Etiquetas:

    Deja una respuesta

    Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *