Sobrecarga y Sobrescritura de Métodos en Java

Sobrecarga y Sobrescritura de Métodos en Java

1. Introducción

La sobrecarga y sobrescritura de métodos son conceptos clave del lenguaje de programación Java, y como tales, merecen un análisis en profundidad.

En este artículo, aprenderemos los conceptos básicos de estos conceptos y veremos en qué situaciones pueden ser útiles.

2. Sobrecarga de Métodos

La sobrecarga de métodos (overload) es un mecanismo poderoso que nos permite definir API de clases cohesivas. Para comprender mejor por qué la sobrecarga de métodos es una característica tan valiosa, veamos un ejemplo simple.

Supongamos que hemos escrito una clase de utilidad ingenua que implementa diferentes métodos para multiplicar dos números, tres números, y así sucesivamente.

Si hemos dado a los métodos nombres confusos o ambiguos, como multiplicar2(), multiplicar3(), multiplicar4(), entonces esa sería una API de clase mal diseñada. Aquí es donde entra en juego la sobrecarga de métodos.

En pocas palabras, podemos implementar la sobrecarga de métodos de dos maneras diferentes:

  • implementando dos o más métodos que tienen el mismo nombre pero toman un número diferente de argumentos
  • implementando dos o más métodos que tienen el mismo nombre pero toman argumentos de diferentes tipos

2.1. Número de Argumentos Diferentes

La clase Multiplicador muestra, en pocas palabras, cómo sobrecargar el método multiplicar() simplemente definiendo dos implementaciones que toman un número diferente de argumentos:

public class Multiplicador {
    
    public int multiplicar(int a, int b) {
        return a * b;
    }
    
    public int multiplicar(int a, int b, int c) {
        return a * b * c;
    }
}

2.2. Argumentos de Tipos Diferentes

De manera similar, podemos sobrecargar el método multiplicar() haciendo que acepte argumentos de diferentes tipos:

public class Multiplicador {
    
    public int multiplicar(int a, int b) {
        return a * b;
    }
    
    public double multiplicar(double a, double b) {
        return a * b;
    }
}

Además, es legítimo definir la clase Multiplicador con ambos tipos de sobrecarga de métodos:

public class Multiplicador {
    
    public int multiplicar(int a, int b) {
        return a * b;
    }
    
    public int multiplicar(int a, int b, int c) {
        return a * b * c;
    }
    
    public double multiplicar(double a, double b) {
        return a * b;
    }

Es importante destacar, sin embargo, que no es posible tener dos implementaciones de métodos que difieran solo en sus tipos de retorno.

Para entender por qué, consideremos el siguiente ejemplo:

public int multiplicar(int a, int b) { 
    return a * b; 
}
 
public double multiplicar(int a, int b) { 
    return a * b; 
}

En este caso, el código simplemente no se compilaría debido a la ambigüedad en la llamada al método: el compilador no sabría qué implementación de multiplicar() llamar.

2.3. Promoción de Tipos

Una característica interesante proporcionada por la sobrecarga de métodos es la llamada promoción de tipos.

En términos simples, un tipo dado se promociona implícitamente a otro cuando no hay coincidencia entre los tipos de los argumentos pasados al método sobrecargado y una implementación específica del método.

Para comprender con más claridad cómo funciona la promoción de tipos, consideremos las siguientes implementaciones del método multiplicar():

public double multiplicar(int a, long b) {
    return a * b;
}

public int multiplicar(int a, int b, int c) {
    return a * b * c;

Ahora, llamar al método con dos argumentos de tipo int resultará en que el segundo argumento se promocione a long, ya que en este caso no hay una implementación coincidente del método con dos argumentos de tipo int.

Veamos una prueba unitaria rápida para demostrar la promoción de tipos:

@Test
public void cuandoLlamamosMultiplicarSinCoincidir_entoncesPromocionamosTipo() {
    assertThat(multiplicador.multiplicar(10, 10)).isEqualTo(100.0);
}

Por otro lado, si llamamos al método con una implementación coincidente, la promoción de tipos simplemente no tiene lugar:

@Test
public void cuandoLlamamosMultiplicarCoincidiendo_entoncesNoPromocionamosTipo() {
    assertThat(multiplicador.multiplicar(10, 10, 10)).isEqualTo(1000);
}

Aquí tienes un resumen de las reglas de promoción de tipos que se aplican a la sobrecarga de métodos:

  • byte se puede promocionar a short, int, long, float o double
  • short se puede promocionar a int, long, float o double
  • char se puede promocionar a int, long, float o double
  • int se puede promocionar a long, float o double
  • long se puede promocionar a float o double
  • float se puede promocionar a double

2.4. Vinculación Estática

La capacidad de asociar una llamada de método específica al cuerpo del método se conoce como vinculación.

En el caso de la sobrecarga de métodos, la vinculación se realiza estáticamente en tiempo de compilación, por lo que se llama vinculación estática.

El compilador puede establecer eficazmente la vinculación en tiempo de compilación simplemente comprobando las firmas de los métodos.

3. Sobrescritura de Métodos

La sobrescritura de métodos (override) nos permite proporcionar implementaciones detalladas en las subclases para los métodos definidos en una clase base.

Si bien la sobrescritura de métodos es una característica poderosa, considerando que es una consecuencia lógica del uso de la herencia, uno de los pilares más importantes de la POO, cuándo y dónde utilizarla debe analizarse cuidadosamente, caso por caso.

Veamos ahora cómo utilizar la sobrescritura de métodos creando una relación simple de herencia ("es-un").

Aquí está la clase base:

public class Vehiculo {
    
    public String acelerar(long mph) {
        return "El vehículo acelera a: " + mph + " MPH.";
    }
    
    public String detener() {
        return "El vehículo se ha detenido.";
    }
    
    public String correr() {
        return "El vehículo está en movimiento.";
    }
}

Y aquí hay una subclase creada de manera forzada:

public class Coche extends Vehiculo {

    @Override
    public String acelerar(long mph) {
        return "El coche acelera a: " + mph + " MPH.";
    }
}

En la jerarquía anterior, simplemente hemos sobrescrito el método acelerar() para proporcionar una implementación más refinada para el subtipo Coche.

Aquí, queda claro que si una aplicación utiliza instancias de la clase Vehículo, también puede trabajar con instancias de Coche, ya que ambas implementaciones del método acelerar() tienen la misma firma y el mismo tipo de retorno.

Escribamos algunas pruebas unitarias para comprobar las clases Vehículo y Coche:

@Test
public void cuandoLlamamosAcelerar_entoncesComprobamos() {
    assertThat(vehiculo.acelerar(100))
      .isEqualTo("El vehículo acelera a: 100 MPH.");
}
    
@Test
public void cuandoLlamamosCorrer_entoncesComprobamos() {
    assertThat(vehiculo.correr())
      .isEqualTo("El vehículo está en movimiento.");
}
    
@Test
public void cuandoLlamamosDetener_entoncesComprobamos() {
    assertThat(vehiculo.detener())
      .isEqualTo("El vehículo se ha detenido.");
}

@Test
public void cuandoLlamamosAcelerar_entoncesComprobamos() {
    assertThat(coche.acelerar(80))
      .isEqualTo("El coche acelera a: 80 MPH.");
}
    
@Test
public void cuandoLlamamosCorrer_entoncesComprobamos() {
    assertThat(coche.correr())
      .isEqualTo("El vehículo está en movimiento.");
}
    
@Test
public void cuandoLlamamosDetener_entoncesComprobamos() {
    assertThat(coche.detener())
      .isEqualTo("El vehículo se ha detenido.");
}

Ahora, veamos algunas pruebas unitarias que muestran cómo los métodos correr() y detener(), que no se han sobrescrito, devuelven valores iguales tanto para Coche como para Vehículo:

@Test
public void dadasInstanciasVehiculoCoche_cuandoLlamamosCorrer_entoncesSonIguales() {
    assertThat(vehiculo.correr()).isEqualTo(coche.correr());
}
 
@Test
public void dadasInstanciasVehiculoCoche_cuandoLlamamosDetener_entoncesSonIguales() {
   assertThat(vehiculo.detener()).isEqualTo(coche.detener());
}

En nuestro caso, tenemos acceso al código fuente de ambas clases, por lo que podemos ver claramente que llamar al método acelerar() en una instancia base de Vehículo y llamar a acelerar() en una instancia de Coche devolverá valores diferentes para el mismo argumento.

Por lo tanto, la siguiente prueba demuestra que el método sobrescrito se invoca para una instancia de Coche:

@Test
public void cuandoLlamamosAcelerarConElMismoArgumento_entoncesNoSonIguales() {
    assertThat(vehiculo.acelerar(100))
      .isNotEqualTo(coche.acelerar(100));
}

3.1. Substitución de Tipos

Un principio fundamental en la POO es el de la substitución de tipos, que está estrechamente relacionado con el Principio de Substitución de Liskov (LSP).

En pocas palabras, el LSP establece que si una aplicación trabaja con un tipo base dado, también debería funcionar con cualquiera de sus subtipos. De esta manera, se conserva adecuadamente la substitución de tipos.

El mayor problema con la sobrescritura de métodos es que algunas implementaciones específicas de métodos en las clases derivadas podrían no adherirse completamente al LSP y, por lo tanto, no conservar la substitución de tipos.

Por supuesto, es válido hacer que un método sobrescrito acepte argumentos de diferentes tipos y devuelva un tipo diferente, pero con plena adherencia a estas reglas:

  • Si un método en la clase base toma argumento(s) de un tipo dado, el método sobrescrito debería tomar el mismo tipo o un supertipo (también conocido como argumentos contravariantes).
  • Si un método en la clase base devuelve void, el método sobrescrito debería devolver void.
  • Si un método en la clase base devuelve un primitivo, el método sobrescrito debería devolver el mismo primitivo.
  • Si un método en la clase base devuelve un cierto tipo, el método sobrescrito debería devolver el mismo tipo o un subtipo (también conocido como tipo de retorno covariante).
  • Si un método en la clase base lanza una excepción, el método sobrescrito debe lanzar la misma excepción o un subtipo de la excepción de la clase base.

3.2. Vinculación Dinámica

Dado que la sobrescritura de métodos solo se puede implementar con la herencia, donde hay una jerarquía de un tipo base y subtipos, el compilador no puede determinar en tiempo de compilación qué método llamar, ya que tanto la clase base como las subclases definen los mismos métodos.

Como consecuencia, el compilador necesita verificar el tipo de objeto para saber qué método debe ser invocado.

Dado que esta verificación ocurre en tiempo de ejecución, la sobrescritura de métodos es un ejemplo típico de vinculación dinámica.

4. Conclusión

En este tutorial, aprendimos cómo implementar la sobrecarga de métodos y la sobrescritura de métodos, y exploramos algunas situaciones típicas en las que son útiles.