Herança Múltipla em Java

Hoje vamos explorar a Herança Múltipla em Java. Algum tempo atrás, escrevi algumas postagens sobre herança, interface e composição em Java. Nesta postagem, vamos abordar a herança múltipla em Java e, em seguida, comparar com composição e herança.

Herança Múltipla em Java

A herança múltipla em Java é a capacidade de criar uma única classe com várias superclasses. Ao contrário de algumas outras linguagens de programação orientadas a objetos populares, como C++, o Java não oferece suporte à herança múltipla em classes. O Java não suporta heranças múltiplas em classes porque pode levar ao problema do diamante e, em vez de fornecer uma maneira complexa de resolvê-lo, existem maneiras melhores de alcançar o mesmo resultado que a herança múltipla.

Problema do Diamante em Java

Para entender o problema do diamante facilmente, vamos assumir que múltiplas heranças eram suportadas em Java. Nesse caso, poderíamos ter uma hierarquia de classes como na imagem abaixo. Digamos que SuperClasse seja uma classe abstrata declarando alguns métodos e ClasseA, ClasseB são classes concretas. SuperClasse.java

package com.journaldev.inheritance;

public abstract class SuperClass {

	public abstract void doSomething();
}

ClasseA.java

package com.journaldev.inheritance;

public class ClassA extends SuperClass{
	
	@Override
	public void doSomething(){
		System.out.println("doSomething implementation of A");
	}
	
	//Método próprio da ClasseA
	public void methodA(){
		
	}
}

ClasseB.java

package com.journaldev.inheritance;

public class ClassB extends SuperClass{

	@Override
	public void doSomething(){
		System.out.println("doSomething implementation of B");
	}
	
	//Método específico da ClasseB
	public void methodB(){
		
	}
}

Agora vamos dizer que a implementação da ClasseC seria algo assim e ela está estendendo tanto a ClasseA quanto a ClasseB. ClasseC.java

package com.journaldev.inheritance;

// isso é apenas uma suposição para explicar o problema do diamante
//este código não compilará
public class ClassC extends ClassA, ClassB{

	public void test(){
		//chamando método da superclasse
		doSomething();
	}

}

Observe que o método test() está fazendo uma chamada ao método doSomething() da superclasse. Isso leva à ambiguidade, pois o compilador não sabe qual método da superclasse executar. Devido ao diagrama de classe em forma de diamante, isso é chamado de Problema do Diamante em Java. O problema do diamante em Java é a principal razão pela qual Java não suporta heranças múltiplas em classes. Observe que o problema acima com herança de classe múltipla também pode ocorrer com apenas três classes, onde todas elas têm pelo menos um método comum.

Herança Múltipla em Interfaces Java

Você pode ter notado que estou sempre dizendo que heranças múltiplas não são suportadas em classes, mas são suportadas em interfaces. Uma única interface pode estender várias interfaces, abaixo está um exemplo simples. InterfaceA.java

package com.journaldev.inheritance;

public interface InterfaceA {

	public void doSomething();
}

InterfaceB.java

package com.journaldev.inheritance;

public interface InterfaceB {

	public void doSomething();
}

Observe que ambas as interfaces estão declarando o mesmo método, agora podemos ter uma interface estendendo ambas essas interfaces como abaixo. InterfaceC.java

package com.journaldev.inheritance;

public interface InterfaceC extends InterfaceA, InterfaceB {

	//o mesmo método é declarado em InterfaceA e InterfaceB
	public void doSomething();
	
}

Isso é perfeitamente aceitável porque as interfaces apenas declaram os métodos e a implementação real será feita por classes concretas que implementam as interfaces. Portanto, não há possibilidade de qualquer tipo de ambiguidade em heranças múltiplas em interfaces Java. É por isso que uma classe Java pode implementar múltiplas interfaces, algo como no exemplo abaixo. InterfacesImpl.java

package com.journaldev.inheritance;

public class InterfacesImpl implements InterfaceA, InterfaceB, InterfaceC {

	@Override
	public void doSomething() {
		System.out.println("doSomething implementation of concrete class");
	}

	public static void main(String[] args) {
		InterfaceA objA = new InterfacesImpl();
		InterfaceB objB = new InterfacesImpl();
		InterfaceC objC = new InterfacesImpl();
		
		//todas as chamadas de método abaixo vão para a mesma implementação concreta
		objA.doSomething();
		objB.doSomething();
		objC.doSomething();
	}

}

Você notou que toda vez que estou substituindo algum método da superclasse ou implementando algum método da interface, estou usando a anotação @Override? A anotação Override é uma das três anotações integradas do Java e devemos sempre usar a anotação override ao substituir qualquer método.

Composição para o resgate

Então, o que fazer se quisermos utilizar a função methodA() da ClassA e a função methodB() da ClassB na ClassC? A solução reside em usar composição. Aqui está uma versão refatorada da ClassC que está usando composição para utilizar os métodos de ambas as classes e também usando o método doSomething() de um dos objetos. ClassC.java

package com.journaldev.inheritance;

public class ClassC{

	ClassA objA = new ClassA();
	ClassB objB = new ClassB();
	
	public void test(){
		objA.doSomething();
	}
	
	public void methodA(){
		objA.methodA();
	}
	
	public void methodB(){
		objB.methodB();
	}
}

Composição vs Herança

Uma das melhores práticas de programação em Java é “favorecer a composição sobre a herança”. Vamos analisar alguns dos aspectos que favorecem essa abordagem.

  1. Suponha que tenhamos uma superclasse e uma subclasse como segue: ClassC.java

    package com.journaldev.inheritance;
    
    public class ClassC{
    
    	public void methodC(){
    	}
    }
    

    ClassD.java

    package com.journaldev.inheritance;
    
    public class ClassD extends ClassC{
    
    	public int test(){
    		return 0;
    	}
    }
    

    O código acima compila e funciona bem, mas e se a implementação da ClasseC for alterada como abaixo: ClassC.java

    package com.journaldev.inheritance;
    
    public class ClassC{
    
    	public void methodC(){
    	}
    
    	public void test(){
    	}
    }
    

    Observe que o método test() já existe na subclasse, mas o tipo de retorno é diferente. Agora, a ClasseD não compilará e se você estiver usando qualquer IDE, ela sugerirá que você altere o tipo de retorno na superclasse ou na subclasse. Agora, imagine a situação em que temos múltiplos níveis de herança de classes e a superclasse não é controlada por nós. Não teremos escolha senão alterar a assinatura do método da nossa subclasse ou seu nome para remover o erro de compilação. Além disso, teremos que fazer uma alteração em todos os lugares onde o método da nossa subclasse estava sendo invocado, então a herança torna nosso código frágil. O problema acima nunca ocorrerá com composição e isso a torna mais favorável sobre a herança.

  2. Outro problema com a herança é que estamos expondo todos os métodos da superclasse ao cliente. Se nossa superclasse não estiver adequadamente projetada e houver brechas de segurança, mesmo que tomemos todo cuidado na implementação da nossa classe, seremos afetados pela má implementação da superclasse. A composição ajuda a fornecer acesso controlado aos métodos da superclasse, ao contrário da herança, que não oferece controle sobre os métodos da superclasse. Essa é também uma das principais vantagens da composição sobre a herança.

  3. Outro benefício da composição é que ela fornece flexibilidade na invocação de métodos. Nossa implementação acima da ClassC não é ótima e fornece ligação em tempo de compilação com o método que será invocado, com uma mudança mínima podemos tornar a invocação do método flexível e dinâmica. ClassC.java

    package com.journaldev.inheritance;
    
    public class ClassC{
    
    	SuperClass obj = null;
    
    	public ClassC(SuperClass o){
    		this.obj = o;
    	}
    	public void test(){
    		obj.doSomething();
    	}
    	
    	public static void main(String args[]){
    		ClassC obj1 = new ClassC(new ClassA());
    		ClassC obj2 = new ClassC(new ClassB());
    		
    		obj1.test();
    		obj2.test();
    	}
    }
    

    A saída do programa acima é:

    implementação de doSomething de A
    implementação de doSomething de B
    

    Essa flexibilidade na invocação de método não está disponível na herança e promove a melhor prática de favorecer a composição sobre a herança.

  4. Testar unidades é fácil na composição, porque sabemos quais métodos estamos usando da superclasse e podemos simulá-los para teste, enquanto na herança dependemos fortemente da superclasse e não sabemos quais métodos da superclasse serão usados, então precisamos testar todos os métodos da superclasse, isso é um trabalho extra e precisamos fazê-lo desnecessariamente por causa da herança.

Isso é tudo para heranças múltiplas em Java e uma breve visão sobre composição.

Source:
https://www.digitalocean.com/community/tutorials/multiple-inheritance-in-java