Saltearse al contenido

Herencia

Herencia

El paradigma orientado a objetos plantea el concepto de la herencia como una poderosa característica que otorga la capacidad de crear nuevas clases partiendo de otra definida previamente. Esto permite que la nueva clase herede sus atributos y métodos.

Esta característica nos permite reutilizar código, pero sobre todo, nos permite especializar las clases. Es decir, en lugar de crear una clase desde cero o modificar una clase existente, podemos crear una nueva clase que extienda las características y/o comportamientos de otra.

  • La clase de la cual se hereda se conoce como clase padre, clase base o superclase.
  • La clase que hereda se conoce como clase hija, clase derivada o subclase.

En Java, la herencia se define mediante la palabra clave extends empleando la siguiente sintaxis:

1
class <Clase Hija> extends <Clase Padre> {
2
// ...
3
}

Como vemos, al definir una clase, podemos indicar que esta hereda de otra. Con lo cual todos los atributos y métodos de la clase padre, serán heredados por la clase hija también.

Para ver este concepto de manera más clara, estableceremos la superclase Vehicle, de la cual heredaremos luego.

1
class Vehicle {
2
private String brand;
3
private String model;
4
private int year;
5
6
public Vehicle(String brand, String model, int year) {
7
this.brand = brand;
8
this.model = model;
9
this.year = year;
10
}
11
12
public String getBrand() {
13
return brand;
14
}
15
16
public String getModel() {
17
return model;
18
}
19
20
public int getYear() {
21
return year;
22
}
23
}

No obstante, podríamos tener la necesidad de crear nuevas clases que tienen en común los atributos y métodos de la clase Vehicle, pero que además tienen atributos y/o métodos propios. Por ejemplo, podríamos tener la necesidad de crear una clase Car.

1
class Car extends Vehicle {
2
private int doors;
3
4
public Car(String brand, String model, int year, int doors) {
5
super(brand, model, year);
6
this.doors = doors;
7
}
8
9
public int getDoors() {
10
return doors;
11
}
12
}

En este ejemplo podemos notar, que al momento de definir el constructor de la clase Car, estamos utilizando la palabra reservada super.

En este caso, empleamos super para acceder al constructor de la clase Vehicle. De esta forma, delegamos la tarea de inicializar los atributos brand, model y year a la clase padre, y nos concentramos solo en inicializar los atributos propios de la clase Car.

De igual manera, podríamos usar super para acceder otros métodos de la clase padre. Por ejemplo, dentro de la clase Car, podríamos invocar el método getBrand() de la clase Vehicle de la siguiente forma:

1
super.getBrand();

Así también, podríamos definir tantas clases hijas como necesitemos. Por ejemplo, podríamos definir la clase Motorcycle de la siguiente forma:

1
class Motorcycle extends Vehicle {
2
private int cc;
3
4
public Motorcycle(String brand, String model, int year, int cc) {
5
super(brand, model, year);
6
this.cc = cc;
7
}
8
9
public int getCc() {
10
return cc;
11
}
12
}

Sobrecarga y sobreescritura de métodos

La sobrecarga de métodos es una característica de la que hemos hablado anteriormente, y que nos permite definir varios métodos con el mismo nombre pero con diferentes firmas.

Por otro lado, en el contexto de la herencia, podemos encontrarnos con un concepto similar, conocido como sobreescritura de métodos.

A diferencia de la sobrecarga de métodos, la sobreescritura de métodos nos permite redefinir un método heredado de la clase padre. Es decir, estamos modificando un método heredado, para adaptarlo a las necesidades de la clase hija.

Establezcamos un nuevo ejemplo para ver este concepto de manera más clara. En este caso, definiremos la clase Character, la cual representa un personaje de un videojuego.

1
class Character {
2
private String name;
3
private int level;
4
private int health;
5
6
public Character(String name, int level, int health) {
7
this.name = name;
8
this.level = level;
9
this.health = health;
10
}
11
12
public String getName() {
13
return name;
14
}
15
16
public int getLevel() {
17
return level;
18
}
19
20
public int getHealth() {
21
return health;
22
}
23
24
public void attack() {
25
System.out.println("Atacando...");
26
System.out.println("Daño: 10");
27
}
28
}

Ahora, definiremos la clase Warrior, la cual representa un guerrero. Es decir, un personaje especializado en el combate cuerpo a cuerpo. Con esto en mente, podemos pensar que el método attack() de la clase Character no es suficiente para representar el ataque de un guerrero y nos gustaría que el daño que este realiza dependa de su nivel y de un nuevo atributo que definiremos, su fuerza.

Para esto nos veremos en la necesidad de sobreescribir el método attack(), para adaptarlo a las necesidades de la clase Warrior. En Java, podemos sobreescribir un método de la clase padre mediante la anotación @Override.

1
class Warrior extends Character {
2
private int strength;
3
4
public Warrior(String name, int level, int health, int strength) {
5
super(name, level, health);
6
this.strength = strength;
7
}
8
9
public int getStrength() {
10
return strength;
11
}
12
13
@Override
14
public void attack() {
15
System.out.println("Atacando...");
16
System.out.println("Daño: " + (10 * getLevel() + getStrength()));
17
}
18
}

Como vemos, antes de definir el método attack(), hemos agregado la anotación @Override. Esta anotación nos permite indicarle al compilador que estamos sobreescribiendo un método de la clase padre. De esta forma, podremos realizar la misma operación tanto para objetos de la clase Character como para objetos de la clase Warrior.

De la misma manera, podemos sobreescibir métodos de la clase padre en cualquier otra clase hija. Por ejemplo, podríamos definir la clase Wizard, la cual representa un mago. Es decir, un personaje especializado en el combate a distancia. Con esto en mente, nos gustaría que el daño que este realiza dependa de su nivel y de un nuevo atributo que definiremos, su mana.

1
class Wizard extends Character {
2
private int mana;
3
4
public Wizard(String name, int level, int health, int mana) {
5
super(name, level, health);
6
this.mana = mana;
7
}
8
9
public int getMana() {
10
return mana;
11
}
12
13
public void setMana(int mana) {
14
this.mana = mana;
15
}
16
17
@Override
18
public void attack() {
19
System.out.println("Atacando...");
20
System.out.println("Daño: " + (10 * getLevel() + getMana()));
21
setMana(getMana() - 10);
22
}
23
}

Herencia múltiple

La herencia múltiple es una característica que permite que una clase herede de más de una clase padre. En Java, la herencia múltiple no es permitida, ya que puede generar conflictos en el código.

Sin embargo, existen alternativas para simular la herencia múltiple y, en la práctica, obtener el mismo resultado. Para ello se emplea el concepto de interfaces, el cual veremos más adelante. De momento, nos concentraremos en la herencia simple.

Jeraquía de clases

La herencia nos permite crear una jerarquía de clases, una estructura en la que una clase hija hereda de una clase padre, y esta a su vez hereda de otra clase padre, y así sucesivamente.

Siguiendo con el ejemplo planteado sobre los personajes de un videojuego, podríamos definir la clase Character como una clase padre, y definir las clases Warrior y Wizard como clases hijas.

Además, esta última podría ser padre de otra clase. Por ejemplo, podríamos definir la clase AncientWizard, la cual representa un mago mucho más poderoso que el mago común, y que agrega comportamientos y características propias. Por supuesto, esta clase herada de la clase Wizard.

1
class AncientWizard extends Wizard {
2
private int wisdom;
3
4
public AncientWizard(String name, int level, int health, int mana, int wisdom) {
5
super(name, level, health, mana);
6
this.wisdom = wisdom;
7
}
8
9
public int getWisdom() {
10
return wisdom;
11
}
12
13
@Override
14
public void attack() {
15
System.out.println("Atacando...");
16
System.out.println("Daño: " + (10 * getLevel() + getMana() + getWisdom()));
17
}
18
}

Gráficamente podemos representar esta jerarquía mediante un árbol de herencia, en el cual cada nodo representa una clase, y cada rama representa una relación de herencia.

Jerarquía de clases

En Java, veremos que existen muchos de estos árboles de herencia para clases que ya están definidas. Podemos entender mejor el comportamiento de las mismas si analizamos su jerarquía de clases.

La herencia puede se interpretada mediante la analogía de es un. Por ejemplo, en la naturaleza podríamos decir que un perro es un animal, y que un gato es un animal. De la misma manera, podríamos decir que un perro es un mamífero, y que un gato es un mamífero. Si abstrajéramos este tipo de entidades a componentes de software veríamos que estas realaciones se mantienen y que podemos aplicarlas a la herencia.

Jerarquía de clases

Igualmente con nuestro ejemplo de personajes de videojuegos, podemos decir que un Warrior es un Character, y que un Wizard es un Character. De la misma manera, podemos decir que un AncientWizard es un Wizard.

Así podríamos seguir atrás en la jerarquía de clases, y nos daríamos cuenta que todas las clases, ya sea las que definimos nosotros o las que nos provee Java, heredan de una clase padre común, la clase Object.

Es decir, cualquier instancia es un Object.