Herança e composição são duas técnicas de programação usadas pelos desenvolvedores para estabelecer o relacionamento entre classes e objetos. Enquanto a herança deriva uma classe de outra, a composição define uma classe como a soma de suas partes.
Classes e objetos criados por herança são fortemente acoplado porque alterar o pai ou a superclasse em um relacionamento de herança corre o risco de quebrar seu código. Classes e objetos criados por meio de composição são fracamente acopladao que significa que você pode alterar mais facilmente as partes dos componentes sem quebrar o código.
Como o código fracamente acoplado oferece mais flexibilidade, muitos desenvolvedores acreditam que a composição é uma técnica melhor do que a herança, mas a verdade é mais complexa. Escolher uma ferramenta de programação é semelhante a escolher o utensílio de cozinha correto: você não usaria uma faca de manteiga para cortar vegetais e, da mesma forma, não deveria escolher a composição para cada cenário de programação.
Herança e composição em Java
Neste artigo, você aprenderá as diferenças entre herança e composição e como decidir qual é a correta para seus programas Java:
- Quando usar herança em Java
- Quando usar composição em Java
- Diferenças entre herança e composição
- Substituição de método com herança Java
- Usando construtores com herança
- Transmissão de tipo e ClassCastException
- Erros comuns com herança em Java
- O que lembrar sobre herança em Java
Quando usar herança em Java
Na programação orientada a objetos, podemos usar herança quando sabemos que existe um relacionamento “é um” entre um filho e sua classe pai. Alguns exemplos seriam:
- Uma pessoa é um humano.
- Um gato é um animal.
- Um carro é um veículo.
Em cada caso, o filho ou subclasse é um especializado versão do pai ou superclasse. Herdar da superclasse é um exemplo de reutilização de código. Para entender melhor essa relação, reserve um momento para estudar o Car
classe, que herda de Vehicle
:
class Vehicle {
String brand;
String color;
double weight;
double speed;
void move() {
System.out.println("The vehicle is moving");
}
}
public class Car extends Vehicle {
String licensePlateNumber;
String owner;
String bodyStyle;
public static void main(String... inheritanceExample) {
System.out.println(new Vehicle().brand);
System.out.println(new Car().brand);
new Car().move();
}
}
Ao considerar o uso de herança, pergunte-se se a subclasse é realmente uma versão mais especializada da superclasse. Nesse caso, carro é um tipo de veículo, então a relação de herança faz sentido.
Quando usar composição em Java
Na programação orientada a objetos, podemos usar composição nos casos em que um objeto “tem” (ou faz parte de) outro objeto. Alguns exemplos seriam:
- Um carro tem um bateria (uma bateria é parte de um carro).
- Uma pessoa tem um coração (um coração é parte de uma pessoa).
- Uma casa tem um sala de estar (uma sala de estar é parte de uma casa).
Para entender melhor esse tipo de relacionamento, considere a composição de um House
:
public class CompositionExample {
public static void main(String... houseComposition) {
new House(new Bedroom(), new LivingRoom());
// The house now is composed with a Bedroom and a LivingRoom
}
static class House {
Bedroom bedroom;
LivingRoom livingRoom;
House(Bedroom bedroom, LivingRoom livingRoom) {
this.bedroom = bedroom;
this.livingRoom = livingRoom;
}
}
static class Bedroom { }
static class LivingRoom { }
}
Neste caso, sabemos que uma casa possui uma sala e um quarto, então podemos utilizar o Bedroom
e LivingRoom
objetos na composição de um House
.
Diferenças entre herança e composição
Agora vamos comparar herança e composição em Java. O seguinte é um bom exemplo de herança?
import java.util.HashSet;
public class CharacterBadExampleInheritance extends HashSet
Neste caso, a resposta é não. A classe filha herda muitos métodos que nunca usará, resultando em um código fortemente acoplado que é confuso e difícil de manter. Se você olhar com atenção, também ficará claro que esse código não passa no teste “é um”.
Agora vamos tentar o mesmo exemplo usando composição:
import java.util.HashSet;
import java.util.Set;
public class CharacterCompositionExample {
static Set set = new HashSet<>();
public static void main(String... goodExampleOfComposition) {
set.add("Homer");
set.forEach(System.out::println);
}
Usar a composição para este cenário permite que o CharacterCompositionExample
classe para usar apenas dois dos HashSet
métodos, sem herdar todos eles. Acabamos com um código mais simples, menos acoplado e mais fácil de entender e manter.
Substituição de método com herança Java
A herança nos permite reutilizar métodos e outros atributos de uma classe em uma nova classe, o que é muito conveniente. Mas para que a herança realmente funcione, também precisamos ser capazes de alterar alguns dos comportamentos herdados em nossa nova subclasse. Por exemplo, podemos querer especializar o som de um Cat
faz:
class Animal {
void emitSound() {
System.out.println("The animal emitted a sound");
}
}
class Cat extends Animal {
@Override
void emitSound() {
System.out.println("Meow");
}
}
class Dog extends Animal {
}
public class Main {
public static void main(String... doYourBest) {
Animal cat = new Cat(); // Meow
Animal dog = new Dog(); // The animal emitted a sound
Animal animal = new Animal(); // The animal emitted a sound
cat.emitSound();
dog.emitSound();
animal.emitSound();
}
}
Este é um exemplo de herança Java com substituição de método. Nós primeiro ampliar o Animal
classe para criar um novo Cat
aula. Em seguida nós sobrepor o Animal
aula emitSound()
método para obter o som específico do Cat
faz. Mesmo que tenhamos declarado o tipo de classe como Animal
quando instanciamos isso como Cat
vamos ouvir o miado do gato.
Java tem herança múltipla?
Ao contrário de algumas linguagens, como C++, Java não permite herança múltipla com classes. Entretanto, você pode usar herança múltipla com interfaces. A diferença entre uma classe e uma interface, neste caso, é que as interfaces não mantêm o estado.
Se você tentar herança múltipla como fiz abaixo, o código não será compilado:
class Animal {}
class Mammal {}
class Dog extends Animal, Mammal {}
Uma solução usando classes seria herdar uma por uma:
class Animal {}
class Mammal extends Animal {}
class Dog extends Mammal {}
Outra solução é substituir as classes por interfaces:
interface Animal {}
interface Mammal {}
class Dog implements Animal, Mammal {}
Usando 'super' para acessar os métodos de uma classe pai
Quando duas classes estão relacionadas por herança, a classe filha deve ser capaz de acessar todos os campos, métodos ou construtores acessíveis de sua classe pai. Em Java, usamos a palavra reservada super
para garantir que a classe filha ainda possa acessar o método substituído do pai:
public class SuperWordExample {
class Character {
Character() {
System.out.println("A Character has been created");
}
void move() {
System.out.println("Character walking...");
}
}
class Moe extends Character {
Moe() {
super();
}
void giveBeer() {
super.move();
System.out.println("Give beer");
}
}
}
Neste exemplo, Character
é a classe pai de Moe. Usando super
podemos acessar Character
de move()
método para dar uma cerveja a Moe.
Usando construtores com herança
Quando uma classe herda de outra, o construtor da superclasse será carregado primeiro, antes de carregar sua subclasse. Na maioria dos casos, a palavra reservada super
é adicionado automaticamente ao construtor. Entretanto, se a superclasse tiver um parâmetro em seu construtor, devemos invocar deliberadamente o parâmetro super
construtor, conforme mostrado abaixo:
public class ConstructorSuper {
class Character {
Character() {
System.out.println("The super constructor was invoked");
}
}
class Barney extends Character {
// No need to declare the constructor or to invoke the super constructor
// The JVM will to that
}
}
Se a classe pai tiver um construtor com pelo menos um parâmetro, então devemos declarar o construtor na subclasse e usar super
para invocar explicitamente o construtor pai. O super
a palavra reservada não será adicionada automaticamente e o código não será compilado sem ela. Por exemplo:
public class CustomizedConstructorSuper {
class Character {
Character(String name) {
System.out.println(name + "was invoked");
}
}
class Barney extends Character {
// We will have compilation error if we don't invoke the constructor explicitly
// We need to add it
Barney() {
super("Barney Gumble");
}
}
}
Transmissão de tipo e ClassCastExceptions
Casting é uma forma de comunicar explicitamente ao compilador que você realmente pretende converter um determinado tipo. É como dizer: “Ei, JVM, eu sei o que estou fazendo, então, por favor, lance esta classe com este tipo”. Se uma classe que você lançou não for compatível com o tipo de classe que você declarou, você receberá um ClassCastException
.
Na herança, podemos atribuir a classe filha à classe pai sem lançar, mas não podemos atribuir uma classe pai à classe filha sem usar a conversão.
Considere o seguinte exemplo:
public class CastingExample {
public static void main(String... castingExample) {
Animal animal = new Animal();
Dog dogAnimal = (Dog) animal; // We will get ClassCastException
Dog dog = new Dog();
Animal dogWithAnimalType = new Dog();
Dog specificDog = (Dog) dogWithAnimalType;
specificDog.bark();
Animal anotherDog = dog; // It's fine here, no need for casting
System.out.println(((Dog)anotherDog)); // This is another way to cast the object
}
}
class Animal { }
class Dog extends Animal { void bark() { System.out.println("Au au"); } }
Quando tentamos lançar um Animal
instância para um Dog
obtemos uma exceção. Isso ocorre porque o Animal
não sabe nada sobre seu filho. Pode ser um gato, um pássaro, um lagarto, etc. Não há informações sobre o animal específico.
O problema neste caso é que instanciamos Animal
assim:
Animal animal = new Animal();
Então tentei lançar assim:
Dog dogAnimal = (Dog) animal;
Porque não temos um Dog
por exemplo, é impossível atribuir um Animal
para o Dog
. Se tentarmos, conseguiremos ClassCastException
.
Para evitar a exceção, devemos instanciar o Dog
assim:
Dog dog = new Dog();
então atribua-o a Animal
:
Animal anotherDog = dog;
Neste caso, porque estendemos o Animal
classe, o Dog
a instância nem precisa ser lançada; o Animal
o tipo de classe pai simplesmente aceita a atribuição.
Casting com supertipos
É possível declarar um Dog
com o supertipo Animal
mas se quisermos invocar um método específico de Dog
, precisaremos lançá-lo. Por exemplo, e se quiséssemos invocar o bark()
método? O Animal
supertype não tem como saber exatamente qual instância de animal estamos invocando, então temos que lançar Dog
manualmente antes de podermos invocar o bark()
método:
Animal dogWithAnimalType = new Dog();
Dog specificDog = (Dog) dogWithAnimalType;
specificDog.bark();
Você também pode usar a conversão sem atribuir o objeto a um tipo de classe. Esta abordagem é útil quando você não deseja declarar outra variável:
System.out.println(((Dog)anotherDog)); // This is another way to cast the object
Aceite o desafio da herança Java!
Você aprendeu alguns conceitos importantes de herança, então agora é hora de experimentar um desafio de herança. Para começar, estude o seguinte código:
public class InheritanceCompositionChallenge {
private static int result;
public static void main(String... doYourBest) {
Character homer = new Homer();
homer.drink();
new Character().drink();
((Homer)homer).strangleBart();
Character character = new Character();
System.out.println(result);
((Homer)character).strangleBart();
}
static class Character {
Character() {
result++;
}
void drink() {
System.out.println("Drink");
}
}
static class Homer extends Character {
Lung lung = new Lung();
void strangleBart() {
System.out.println("Why you little!");
}
void drink() {
System.out.println("Drink beer");
lung.damageLungs();
}
}
static class Lung {
void damageLungs() {
System.out.println("Soon you will need a transplant");
}
}
}
Qual destes é o resultado após executar o método principal?
A)