Arturo Parra
Arturo Parra
Ingeniero de Software
Mar 27, 2023 12 min read

Herencia en Java

thumbnail for this post

La herencia en Java permite que ciertas propiedades y/o métodos puedan ser pasados de una clase (llamada clase padre) a otra clase (llamada clase hija), para que esta última pueda utilizar dichas propiedades y métodos sin tener que implementarlos nuevamente.

En este artículo vamos a discutir todo sobre la herencia en Java, desde cómo se implementa, pasando por algunos aspectos importantes que debes tomar en cuenta, hasta los beneficios que se pueden obtener de esta, y algunos ejemplos.

Para obtener el máximo provecho de este artículo, es importante que tengas bien dominado el concepto de clases en Java. Para saber más sobre las clases en Java, o si solo necesitas un recordatorio de cómo funcionan, puedes leer este artículo sobre clases en Java.

¿Cómo hacer herencia en Java?

En Java se utiliza la palabra reservada extends para poder implementar la herencia, con lo cual se pueden pasar propiedades y métodos de una clase a otra, y se usa de la siguiente manera,

public class ClasePadre {
    public String propiedadP = "Soy una propiedad de la clase padre.";

    public void metodoP() {
        System.out.println("Soy un método de la clase padre");
    }
}

public class ClaseHija extends ClasePadre {
    public String propiedadH = "Soy una propiedad de la clase hija.";

    public void metodoH() {
        System.out.println("Soy un método de la clase hija"); 
    }
}

Donde,

  1. ClaseHija - Es la clase que obtendrá las propiedades y métodos de la clase padre.
  2. ClasePadre - Es la clase que implementa ciertas propiedades y métodos que se pasarán a la clase hija.
  3. extends - Es la palabra reservada utilizada por Java para poder implementar la herencia.

Como se puede observar en el ejemplo anterior, la clase hija puede implementar propiedades y métodos adicionales a los que heredó, observa la propiedad propiedadH y el método metodoH, que no existen en la clase padre.

Una vez creada la jerarquía entre una clase padre y una clase hija, un objeto de la clase hija puede hacer uso de las propiedades y métodos públicos que heredó de la clase padre, como lo puedes ver en el siguiente código de ejemplo,

ClaseHija objetoHijo = new ClaseHija();

// Esto imprime "Soy una propiedad de la clase padre."
System.out.println(objetoHijo.propiedadP); 

// Esto imprime "Soy un método de la clase padre."
objetoHijo.metodoH();

Por otro lado, las clases padre no tienen acceso a los métodos y propiedades de las clases hijas, el siguiente código arrojaría errores de compilación,

ClasePadre objetoPadre = new ClasePadre();

// Intentando acceder a una propiedad de la clase hija 
// desde un objeto de la clase padre.
System.out.println(objetoPadre.propiedadH); // Error de compilación

// Intentando acceder a un método de la clase hija
// desde un objeto de la clase padre.
objetoPadre.metodoH(); // Error de compilación

Esta es una de las formas más sencillas de herencia en Java, pero hay algunas reglas y consideraciones que debes tomar en cuenta cuando utilices la herencia en Java, por ejemplo, las clases padre pueden seguir siendo utilizadas independientemente de las clases hijas, pero las clases hijas de alguna manera se vuelven dependientes de las clases padre, por lo que ciertas modificaciones a las clases padre pueden impactar el funcionamiento de las clases hijas, o incluso pueden causar errores de compilación.

Para ilustrar lo anterior, sigamos con nuestro ejemplo de ClasePadre y ClaseHija, y digamos que, después de haber creado la jerarquía de clases, decidimos modificar el método de la clase padre, como se muestra a continuación,

public ClasePadre {
    public String propiedadP = "Soy una propiedad de la clase padre.";

    public void metodoP() {
    
        // Se realiza una modificación al contenido de la siguiente línea
        System.out.println("Ahora quiero imprimir otra cosa");
    }
}

A primera vista podría parecer que la modificación es inofensiva, sin embargo, si construyes tu software de tal manera que los objetos de la clase hija dependan de que el valor que se imprime en el método padre siempre sea el mismo, podrías enfrentarte a errores de lógica que son difíciles de rastrear.

ClaseHija objetoHijo = new ClaseHija();

// Código que tal vez está esperando que imprima
// "Soy un método de la clase padre."
objetoHijo.metodoP(); // Oh no! esto ahora imprime algo distinto.

Para entender otras reglas y consideraciones importantes en la herencia en Java, vamos a discutirlas en las siguientes secciones.

Los modificadores de acceso y la herencia en Java

El que una propiedad o método pueda ser accedido ya sea desde una clase hija, o desde un objeto de la clase hija, depende de cuál sea el modificador de acceso de la propiedad o método.

Por ejemplo, digamos que tenemos la siguiente jerarquía de clases,

//En el archivo Burra.java
public class Burra {
    private void rebusnar() {
        System.out.println("Rebusnando...");
    }
}

//En el archivo Burdegano.java 
public final class Burdegano extends Burra {
    private void relinchar() {
        System.out.println("Relinchando...");
    }
}

Dado el código anterior, ¿Qué esperas del siguiente código?

Burdegano burdegano = new Burdegano();
burdegano.rebusnar();

Si pensaste que va a imprimir “Rebusnando…”, piensa dos veces, porque en realidad ¡esto daría un error de compilación!, ya que con la herencia en Java, aunque las propiedades y métodos privados sí se heredan de una clase padre a una clase hija, estos no son accesibles directamente desde las clases hijas.

Para poder acceder a propiedades y/o métodos privados de una clase padre desde la clase hija, debe hacerse de forma indirecta, es decir, llamar a un método público de la clase padre, que a su vez acceda a las propiedades y/o métodos privados, como una especie de puerta trasera.


// En Auto.java
public class Auto {
    private String color;
    
    public void setColor(String color) {
        this.color = color;
    }
    
    public String getColor() {
        return this.color;
    }
}

// En TeslaModelo3.java
public class TeslaModelo3 extends Auto {
    public void imprimeColor() {
        System.out.println( this.getColor() );
    }
}

Dado el código anterior, en alguna otra parte de nuestra aplicación podríamos hacer lo siguiente,

TeslaModelo3 miTesla = new TeslaModelo3();

miTesla.setColor("Rojo");

miTesla.imprimeColor();

Y aquí sí veríamos el texto “Rojo” en la pantalla, incluso si la propiedad color es de tipo privado en la clase padre. La razón por la que podemos tener acceso a ella, es debido a que estamos invocando el método público setColor(...), que a su vez accede a la propiedad privada color, y lo mismo ocurre con los métodos privados.

Herencia y constructores

Como es sabido, toda clase tiene al menos un constructor, un constructor sin argumentos si el programador no implementa ningún constructor manualmente, o uno o más constructores que hayan sido especificados por el programador.

Los constructores no se heredan, pero sí se puede tener acceso a los constructores de las clases padre desde las clases hijas, esto se logra por medio del comando super(). Solo se puede llamar a los constructores de las clases padre directas, es decir, no se puede invocar a los constructores de las clases “abuelas”.

A continuación un ejemplo de cómo invocar el constructor de una clase padre, desde la clase hija,

// En SoyTuPadre.java
public class SoyTuPadre {
    public SoyTuPadre() {
        System.out.println("Constructor de la clase padre.");
    }
}

// En SoyTuHijo.java
public class SoyTuHijo extends SoyTuPadre {
    public SoyTuHijo() {
        super();
    }
}

En el código de arriba, se invoca el comando super() desde el constructor de la clase SoyTuHijo, lo cual va a resultar que cuando se cree una instancia (objeto) de la clase SoyTuHijo, el comando super() invocará al constructor de la clase padre SoyTuPadre. El siguiente código imprimiría en pantalla el texto “Constructor de la clase padre.”,

// Este código imprime el texto "Constructor de la clase padre."
SoyTuHijo hijo = new SoyTuHijo();

Un dato importante con respecto al comando super(), es que cuando este sea utilizado, debe ser la primera línea que exista en el constructor desde el que se invoca.

El siguiente código arrojaría un error de compilación,

// En Auto.java
public class Automobil {
    public Auto() {
        System.out.println("Constructor de Automobil.");
    }
}

// En Sedan.java
public class Sedan extends Automobil {
    public Sedan() {
        System.out.println("Constructor de Sedan.");
        super(); // ERROR DE COMPILACIÓN, el comando `super()` debe ser la primera línea
    }
}

Si no se utiliza el comando super(), Java insertará el comando de forma automática, en tiempo de compilación.

Por ejemplo, cuando escribimos el siguiente código,

// En Automobil.java
public class Auto {
    public Auto() {
        System.out.println("Auto");
    }
}

// En Sedan.java
public class Sedan extends Auto {
}

Lo que Java hace, es insertar un constructor por defecto sin argumentos en la clase Sedan, y dentro de dicho constructor, incluye una llamada al comando super(), de la siguiente manera,

// El código resultante de la clase Sedan, después de ser compilado
public class Sedan extends Auto {
    public Sedan() {
        super();
    }
}

Por lo que si creamos un objeto de la clase Sedan, obtendremos el mensaje “Auto” en el momento en el que se construye la instancia, aunque no hayamos escrito ningún constructor, como se puede ver en el siguiente ejemplo,

// Imprime "Auto"
Sedan sedan = new Sedan();

Finalmente, cabe mencionar que también se puede utilizar el comando super(), para invocar a los constructores de las clases padre que llevan argumentos, como en el siguiente ejemplo,

// En Padre.java
public class Padre {
    
    public Padre(String nombre) {
        System.out.println(nombre);
    }
}

// En Hijo.java
public class Hijo extends Padre {
    public Hijo() {
        super("Santiago");
    }
}

Ahora, ¿puedes suponer lo que imprime el siguiente código?

// En Padre.java
public class Padre {
    
    public Padre(String nombre) {
        System.out.println(nombre);
    }
}

// En Hijo.java
public class Hijo extends Padre {
    public Hijo(String nombre) {
        System.out.println("El nombre del hijo es: " + nombre);
    }
}

// En alguna otra parte de la aplicación

// ¿Qué imprime la siguiente línea?
Hijo hijo = new Hijo("Roberto");

Tal vez podrías suponer que el código anterior imprimiría “Roberto”, porque Java inserta automáticamente una llamada al comando super(), aunque la verdad es que ¡el código no compila!, y la razón de ello es porque Java inserta una llamada al comando super() sin argumentos, y al existir un constructor con argumentos en la clase padre, Java no insertó el constructor por defecto sin argumentos en dicha clase.

Una forma de corregir lo anterior, puede ser agregando un constructor sin argumentos a la clase padre, como se demuestra a continuación,

// En Padre.java
public class Padre {
    public Padre() {
        System.out.println("Constructor sin argumentos.")
    }
    
    public Padre(String nombre) {
        System.out.println(nombre);
    }
}

// En Hijo.java
public class Hijo extends Padre {
    public Hijo(String nombre) {
        System.out.println("El nombre del hijo es: " + nombre);
    }
}

// En alguna otra parte de la aplicación

// ¿Qué imprime la siguiente línea?
Hijo hijo = new Hijo("Roberto");

En este caso ya no habría un error de compilación, sin embargo, lo que imprime el código es: “Constructor sin argumentos.", seguido de “El nombre del hijo es: Roberto”.

La otra forma de corregir el error de compilación, en lugar de agregar un constructor sin argumentos, es invocando manualmente al comando super(), y pasando el argumento del constructor de la clase padre, como se muestra en el código siguiente,

// En Padre.java
public class Padre {
    
    public Padre(String nombre) {
        System.out.println(nombre);
    }
}

// En Hijo.java
public class Hijo extends Padre {
    public Hijo(String nombre) {
        super("Santiago");
        System.out.println("El nombre del hijo es: " + nombre);
    }
}

// En alguna otra parte de la aplicación

// ¿Qué imprime la siguiente línea?
Hijo hijo = new Hijo("Roberto");

En este caso, lo que veríamos impreso sería: “Santiago”, seguido de “El nombre del hijo es: Roberto”.

Sobrecarga y sobreescritura de métodos

Es importante entender los conceptos de sobrecarga y sobreescritura de métodos en el contexto de la herencia en Java, puesto que se vuelven importantes cuando tienes métodos implementados con el mismo nombre entre clases padre y clases hijas.

Como vimos en la primera sección de este artículo, en la herencia se pasan los métodos de la clase padre a la clase hija, ¿pero qué sucede cuando queremos implementar un método en la clase hija, con el mismo nombre que uno de los métodos heredados?, hay dos posibles escenarios, si el método de la clase hija lleva la misma firma que el método de la clase padre (o sea, el mismo nombre y los mismos parámetros), entonces estamos hablando de una sobreescritura de método, es decir, el método hijo sobreescribirá al método de la clase padre.

Si el método de la clase hija se llama igual al de la clase padre, pero recibe diferentes parámetros, entonces estamos hablando de sobrecarga de métodos, por lo que ambos métodos podrán ser llamados normalmente desde una instancia de la clase hija.

Sobreescribir un método en Java

Veamos la sobreescritura de métodos con un ejemplo,

// En Padre.java
public class Saludador {
    public void saludar() {
        System.out.println("Hola");
    }
}

// En .java
public class SaludadorMejorado extends Saludador {
    public void saludar() {
        System.out.println("¡Hola!, ¿Cómo estás?");
    }
}

Dado el código anterior, cuando se invoca al método saludar() desde una instancia de SaludadorMejorado, se imprimirá "¡Hola!, ¿Cómo estás?", habiendo efectivamente sobreescrito el método de la clase padre, esto es porque tanto el método de la clase padre como el método de la clase hija tienen la misma firma.

var saludadorMejorado = new SaludadorMejorado();

// Esta línea imprime "¡Hola!, ¿Cómo estás?"
saludadorMejorado.saludar();

El acceso al método de la clase padre no se ha perdido, sin embargo, para poder ejecutarlo desde la clase hija, se debe utilizar la palabra reservada super, luego el operador referencial (un punto), seguido del nombre del método.

public class SaludadorMejorado extends Saludador {
    public void saludar() {
        super.saludar();
        System.out.println("¿Cómo estás?");
    }
}

En este caso, el programa imprimiría “Hola”, y en otra línea "¿Cómo estás?".

Métodos finales

Cuando no quieres que un método se herede de una clase padre a una clase hija, lo puedes hacer con el uso de la palabra reservada final,

public class Padre {
    public final void miMetodo() {
        System.out.println("Método en la clase padre.");
    }
}

Al utilizar la palabra reservada final después del modificador de acceso public, le estamos indicando a Java que no queremos que el método se sobreescriba en clases hijas. Si intentamos sobreescribir el método en una clase hija, obtendríamos un error de compilación.

Conclusión

Como has leído en este artículo, podemos aprovechar la herencia en Java para evitar repetir código en clases que están relacionadas, permitiéndonos de esta forma mantener el contenido de las clases en un mínimo necesario, y delegamos responsabilidades a cada clase según corresponda.

Hay varias cosas a tomar en cuenta cuando se utiliza la herencia en Java, como los modificadores de acceso y la sobreescritura de métodos, así como el comportamiento de los constructores entre clases padre e hijas.

Advertencia sobre el uso de la herencia

Aunque la herencia puede ser muy útil en ciertas aplicaciones, como la construcción de frameworks, es importante mencionar que en la mayoría de los casos se debe utilizar la composición de clases, en lugar de la herencia.

Los desarrolladores experimentados encontramos que el sobreuso de la herencia causa muchos dolores de cabeza a la hora de depurar el código de errores, y también puede causar inflexibilidad para mantener y escalar los programas cuando la herencia se emplea de forma inadecuada, lo cual es muy común.

Por eso debes tener cuidado cuando pienses en utilizar la herencia en Java, piénsalo dos, o tres veces antes de decidir hacerlo, y considera si la composición es una mejor alternativa.