Ir al contenido principal

Polimorfismo y sobrecarga de métodos

Introducción

El polimorfismo es uno de los conceptos fundamentales de la programación orientada a objetos que permite a objetos de diferentes clases ser tratados de manera uniforme. Su nombre proviene del griego y significa "muchas formas", reflejando precisamente cómo un mismo método puede comportarse de distintas maneras según el tipo de objeto que lo invoca. Por otro lado, la sobrecarga de métodos es una técnica que permite definir varios métodos con el mismo nombre pero diferentes parámetros. En este artículo exploraremos ambos conceptos, su implementación en Java y las ventajas que aportan al desarrollo de software.

Polimorfismo en Java

El polimorfismo es la capacidad de un objeto para tomar diferentes formas o ser tratado como un objeto de otro tipo. En Java, podemos distinguir dos tipos principales de polimorfismo:

Polimorfismo en tiempo de ejecución

También conocido como polimorfismo dinámico, ocurre cuando una referencia a una superclase se utiliza para referirse a un objeto de una subclase. Se basa en la sobrescritura de métodos (que vimos en el artículo anterior sobre herencia).

// Definimos una superclase
public class Animal {
    public void emitirSonido() {
        System.out.println("El animal emite un sonido");
    }
}

// Definimos subclases que sobreescriben el método
public class Perro extends Animal {
    @Override
    public void emitirSonido() {
        System.out.println("El perro ladra: Guau guau");
    }
}

public class Gato extends Animal {
    @Override
    public void emitirSonido() {
        System.out.println("El gato maúlla: Miau");
    }
}

Lo interesante del polimorfismo es que podemos usar una referencia de tipo Animal para trabajar con objetos de tipo Perro o Gato:

public class EjemploPolimorfismo {
    public static void main(String[] args) {
        // Creamos un array de tipo Animal
        Animal[] animales = new Animal[3];
        
        // Llenamos el array con diferentes tipos de animales
        animales[0] = new Animal();
        animales[1] = new Perro();    // Un Perro es un Animal
        animales[2] = new Gato();     // Un Gato es un Animal
        
        // Llamamos al mismo método en todos los elementos
        for (Animal animal : animales) {
            animal.emitirSonido();  // Se ejecutará la versión adecuada según el tipo real
        }
    }
}

La salida sería:

El animal emite un sonido
El perro ladra: Guau guau
El gato maúlla: Miau

Este ejemplo demuestra cómo cada objeto ejecuta su propia versión del método emitirSonido(), a pesar de que todos se manipulan a través de referencias de tipo Animal. Java determina en tiempo de ejecución qué versión del método debe llamar basándose en el tipo real del objeto, no en el tipo de la referencia.

Polimorfismo en tiempo de compilación

También conocido como polimorfismo estático, se implementa mediante la sobrecarga de métodos (que veremos con detalle más adelante). A diferencia del polimorfismo dinámico, el compilador de Java determina qué método llamar basándose en los parámetros durante la compilación.

Ventajas del polimorfismo

  • Flexibilidad: Permite escribir código que funciona con clases que aún no se han creado
  • Extensibilidad: Facilita añadir nuevas subclases sin modificar el código existente
  • Reutilización: Permite desarrollar código genérico que funciona con la superclase y todas sus subclases
  • Mantenibilidad: Simplifica el código al reducir estructuras condicionales complejas

Sobrecarga de métodos

La sobrecarga de métodos (method overloading) es una característica que permite definir varios métodos con el mismo nombre pero diferentes parámetros en la misma clase. El compilador de Java determina qué versión del método debe llamarse basándose en los argumentos proporcionados.

Reglas para la sobrecarga de métodos

  1. El nombre del método debe ser el mismo
  2. La lista de parámetros debe ser diferente (por tipo, número o ambos)
  3. El tipo de retorno puede ser diferente, pero no es suficiente por sí solo para distinguir métodos sobrecargados
public class Calculadora {
    // Método para sumar dos enteros
    public int sumar(int a, int b) {
        return a + b;
    }
    
    // Sobrecarga para sumar tres enteros
    public int sumar(int a, int b, int c) {
        return a + b + c;
    }
    
    // Sobrecarga para sumar dos números decimales
    public double sumar(double a, double b) {
        return a + b;
    }
    
    // Sobrecarga para concatenar dos cadenas
    public String sumar(String a, String b) {
        return a + b;
    }
}

Podemos utilizar esta clase de la siguiente manera:

public class EjemploSobrecarga {
    public static void main(String[] args) {
        Calculadora calc = new Calculadora();
        
        // Java llama al método adecuado según los argumentos
        System.out.println(calc.sumar(5, 3));           // Llama a sumar(int, int)
        System.out.println(calc.sumar(5, 3, 2));        // Llama a sumar(int, int, int)
        System.out.println(calc.sumar(5.5, 3.2));       // Llama a sumar(double, double)
        System.out.println(calc.sumar("Hola ", "mundo")); // Llama a sumar(String, String)
    }
}

La salida sería:

8
10
8.7
Hola mundo

Sobrecarga de constructores

Los constructores también pueden sobrecargarse, permitiendo crear objetos de diferentes maneras:

public class Persona {
    private String nombre;
    private String apellido;
    private int edad;
    
    // Constructor completo
    public Persona(String nombre, String apellido, int edad) {
        this.nombre = nombre;
        this.apellido = apellido;
        this.edad = edad;
    }
    
    // Constructor solo con nombre y apellido
    public Persona(String nombre, String apellido) {
        this(nombre, apellido, 0); // Llamada al constructor completo
    }
    
    // Constructor sin parámetros
    public Persona() {
        this("Desconocido", "Desconocido", 0);
    }
    
    public String getInformacion() {
        return nombre + " " + apellido + ", " + edad + " años";
    }
}

Uso de los constructores sobrecargados:

Persona p1 = new Persona("Juan", "Pérez", 30);
Persona p2 = new Persona("María", "García");
Persona p3 = new Persona();

System.out.println(p1.getInformacion()); // Juan Pérez, 30 años
System.out.println(p2.getInformacion()); // María García, 0 años
System.out.println(p3.getInformacion()); // Desconocido Desconocido, 0 años

Diferencias entre sobrecarga y sobreescritura

Es importante entender las diferencias entre la sobrecarga (overloading) de métodos y la sobrescritura (overriding) de métodos:

Característica Sobrecarga Sobrescritura
Parámetros Deben ser diferentes Deben ser iguales
Tipo de retorno Puede ser diferente Debe ser igual o subtipo
Ámbito Misma clase Relación de herencia
Tiempo de resolución Compilación (estático) Ejecución (dinámico)
Objetivo Múltiples comportamientos con el mismo nombre Cambiar implementación heredada

Aplicaciones prácticas del polimorfismo

El polimorfismo es especialmente útil cuando queremos desarrollar sistemas extensibles. Veamos un ejemplo práctico con formas geométricas:

// Clase base
public abstract class Forma {
    // Método abstracto que las subclases deben implementar
    public abstract double calcularArea();
    
    // Método común para todas las formas
    public void mostrarArea() {
        System.out.println("El área es: " + calcularArea());
    }
}

// Subclases concretas
public class Circulo extends Forma {
    private double radio;
    
    public Circulo(double radio) {
        this.radio = radio;
    }
    
    @Override
    public double calcularArea() {
        return Math.PI * radio * radio;
    }
}

public class Rectangulo extends Forma {
    private double ancho;
    private double alto;
    
    public Rectangulo(double ancho, double alto) {
        this.ancho = ancho;
        this.alto = alto;
    }
    
    @Override
    public double calcularArea() {
        return ancho * alto;
    }
}

public class Triangulo extends Forma {
    private double base;
    private double altura;
    
    public Triangulo(double base, double altura) {
        this.base = base;
        this.altura = altura;
    }
    
    @Override
    public double calcularArea() {
        return (base * altura) / 2;
    }
}

Ahora podemos crear una aplicación que trabaje con cualquier tipo de forma:

public class AplicacionFormas {
    public static void main(String[] args) {
        // Creamos un array de formas
        Forma[] formas = new Forma[3];
        formas[0] = new Circulo(5);
        formas[1] = new Rectangulo(4, 6);
        formas[2] = new Triangulo(3, 8);
        
        // Calculamos y mostramos el área de cada forma
        for (Forma forma : formas) {
            System.out.print("Para la forma " + forma.getClass().getSimpleName() + ": ");
            forma.mostrarArea();
        }
        
        // Podemos pasar cualquier tipo de forma a un método
        calcularYMostrarArea(new Circulo(10));
        calcularYMostrarArea(new Rectangulo(2, 3));
    }
    
    // Este método acepta cualquier objeto que sea una Forma
    public static void calcularYMostrarArea(Forma forma) {
        System.out.println("Calculando área de " + forma.getClass().getSimpleName());
        forma.mostrarArea();
    }
}

La salida sería algo así:

Para la forma Circulo: El área es: 78.53981633974483
Para la forma Rectangulo: El área es: 24.0
Para la forma Triangulo: El área es: 12.0
Calculando área de Circulo
El área es: 314.1592653589793
Calculando área de Rectangulo
El área es: 6.0

Limitaciones y consideraciones

  • El polimorfismo en Java está limitado por la compatibilidad de tipos
  • Solo se puede usar polimorfismo con clases que tengan una relación de herencia
  • Para usar polimorfismo con clases no relacionadas, se pueden implementar interfaces comunes
  • La sobrecarga de métodos puede hacer el código más complejo si se abusa de ella

Ejemplo completo: Polimorfismo con interfaces

Las interfaces son una herramienta especialmente poderosa para implementar polimorfismo entre clases que no comparten una jerarquía de herencia:

// Definimos una interfaz para objetos que pueden reproducir sonido
public interface Sonoro {
    void reproducirSonido();
}

// Implementamos la interfaz en diferentes clases
public class Instrumento implements Sonoro {
    private String nombre;
    
    public Instrumento(String nombre) {
        this.nombre = nombre;
    }
    
    @Override
    public void reproducirSonido() {
        System.out.println("El " + nombre + " está sonando");
    }
}

public class DispositivoElectronico implements Sonoro {
    private String tipo;
    
    public DispositivoElectronico(String tipo) {
        this.tipo = tipo;
    }
    
    @Override
    public void reproducirSonido() {
        System.out.println("El " + tipo + " está reproduciendo audio");
    }
}

// Clase que usa el polimorfismo con la interfaz
public class ReproductorDeSonidos {
    public static void reproducirTodos(Sonoro[] elementos) {
        for (Sonoro elemento : elementos) {
            elemento.reproducirSonido();
        }
    }
    
    public static void main(String[] args) {
        Sonoro[] cosas = new Sonoro[4];
        cosas[0] = new Instrumento("piano");
        cosas[1] = new DispositivoElectronico("teléfono");
        cosas[2] = new Instrumento("guitarra");
        cosas[3] = new DispositivoElectronico("altavoz");
        
        reproducirTodos(cosas);
    }
}

La salida sería:

El piano está sonando
El teléfono está reproduciendo audio
El guitarra está sonando
El altavoz está reproduciendo audio

Resumen

El polimorfismo y la sobrecarga de métodos son conceptos fundamentales en Java que proporcionan flexibilidad y extensibilidad a nuestro código. El polimorfismo permite que objetos de diferentes clases sean tratados de manera uniforme, facilitando el diseño de sistemas extensibles y mantenibles. La sobrecarga de métodos nos permite usar el mismo nombre para diferentes operaciones, mejorando la claridad y legibilidad del código. Ambos conceptos son esenciales para aprovechar al máximo la programación orientada a objetos y crear aplicaciones robustas y bien estructuradas.