Artigos

Programação orientada a objetos: Herança e polimorfismo – Parte 1

Quando cursei a disciplina de orientação a objetos na universidade já havia estudado alguma coisa em livros de C++ e Java. Tanto os livros quanto o professor da disciplina escolheram a abordagem clássica de ensino de programação orientada a objetos: a do reino animal. Esta abordagem é limitada, do meu ponto de vista, pelo fato de que não confronta o aluno com os problemas do mundo real, os quais este paradigma se predispõe a resolver. Ao aprender POO desta forma, o aluno não aprende a discernir as fronteiras de um sistema e não entende o propósito real de conceitos como a abstração e o encapsulamento. Existe o argumento de que a intenção é ser didático, e que para o iniciante é muito mais fácil aprender utilizando esta analogia. Porém, acredito que este argumento não seja válido uma vez que a disciplina de programação é pré-requisito para se aprender POO, e um aluno com este conhecimento tem capacidade de abstração suficiente para pensar em problemas na forma de entidades lógicas (variáveis, estruturas de dados e etc), sem precisar de exemplos lúdicos para entender os conceitos do paradigma.

É o meu ponto de vista. Muitos concordam e muitos discordam, mas todos concordam que falta uma ponte entre a teoria e a prática. Um dos pedaços dessa ponte veio para mim em um livro chamado Aprenda C++ em 21 dias de Jesse Liberty. Neste livro, o autor utiliza o exemplo de uma lista encadeada para apresentar os conceitos de herança e polimorfismo, bem como a delegação de responsabilidades entre os objetos, de uma forma muito concreta e aplicada a um problema de computação.

A partir desta reflexão, e da sugestão de colegas com os quais discuti sobre este assunto, resolvi compartilhar algumas idéias que talvez possam ajudar a construir essa ponte teoria-prática. Dividi este artigo em duas partes. Na primeira parte vou mostrar o exemplo do livro de Jesse Liberty e na segunda parte mostrarei uma implementação do emblemático jogo da cobrinha utilizando POO (texto repleto de duplos sentidos).

Este não é um artigo introdutório de POO. Aqui na comunidade é possível achar artigos que abordam os conceitos básicos do paradigma. Este artigo é voltado para aquele leitor que já conhece os conceitos básicos mas lhe falta a demonstração destes conceitos através de exemplos práticos. Adicionei links no final do artigo para baixar o código fonte dos exemplos.

Lista encadeada

Uma lista encadeada é uma estrutura de dados que consiste numa cadeia de elementos, chamados nós, ligados em sucessão. Cada contém um valor (que pode ser um objeto de negócio) e um relacionamento que indica quem é o seu sucessor na cadeia. Ao primeiro elemento da lista damos o nome de cabeça da lista, e ao último elemento damos o nome de cauda da lista.

A implementação que aprendi consistia em:

  • Um nó cabeça cujo trabalho é gerenciar o início da lista;
  • Um nó cauda cujo trabalho é gerenciar o fim da lista;
  • Zero ou mais nós internos que vão conter os dados reais da lista.
Esquema de lista encadeada
Esquema de lista encadeada

Sendo assim, quando um novo dado precisa ser inserido na lista o seguinte fluxo acontece:

  1. A lista encadeada pede para que sua cabeça adicione o dado na lista;
  2. A cabeça por sua vez pede para que seu sucessor adicione o dado na lista;
  3. Sendo o sucessor um nó interno e o dado a ser inserido menor que o dado contido neste nó interno:
    1. cria um nó interno novo para armazenar o dado e coloca este a sua frente;
    2. senão pede para que seu sucessor adicione o dado;
  4. Sendo o sucessor a cauda, cria um nó interno novo para armazenar o dado e coloca este a sua frente, já que este é o final da lista.

Analisando este fluxo é possível perceber três coisas importantes:

  • Todos os componentes na lista colaboram entre si com o propósito comum de adicionar um nó na lista;
  • Cada componente contribui de maneira muito particular para este propósito;
  • Nenhum componente em particular precisar saber detalhes do seu sucessor para executar sua tarefa. Este apenas sabe que seu sucessor sabe como adicionar um nó.

Com base nestas considerações podemos modelar as classes da nossa lista da seguinte forma.

Diagrama de classes de lista encadeada
Diagrama de classes de lista encadeada

Seguindo um raciocínio análogo ao fluxo da adição de nós podemos implementar também a listagem, que vai permitir exibir o conteúdo da lista. A seguir a implementação da lista em Java.

[sourcecode language=”java”]
public abstract class No<T> {
public abstract No<T> adicionar(T dado);
public abstract String listar();
}
[/sourcecode]

Como todos os componentes colaboram entre si para realizar a atividade “adicionar dado” então a idéia é que todos os componentes herdem da superclasse abstrata No que possui a operação “adicionar”.

Vimos também que cada componente realiza a tarefa de adicionar um nó seguindo um determinado fluxo em função do seu tipo. Este cenário é ideal para aplicarmos o conceito de polimorfismo. Polimorfismo é quando subclasses de uma classe definem um comportamento particular ainda que compartilhem de algumas de outras funcionalidades de sua superclasse. No caso, todos os componentes são nós, porém cada um realiza a tarefa de adicionar um dado de maneira diferente.

[sourcecode language=”java”]
public class Cabeca<T extends Comparable<? super T>> extends No<T> {
private No<T> sucessor;

public Cabeca() {
sucessor = new Cauda<T>();
}

@Override
public No<T> adicionar(T dado) {
sucessor = sucessor.adicionar(dado);
return this;
}

@Override
public String listar() {
return sucessor.listar();
}
}
[/sourcecode]

A cabeça da lista realiza seu papel apenas fazendo o repasse do dado para seu sucessor.

[sourcecode language=”java”]
public class Cauda<T extends Comparable<? super T>> extends No<T> {

@Override
public No<T> adicionar(T dado) {
return new Interno<T>(dado, this);
}

@Override
public String listar() {
return "";
}
}
[/sourcecode]

Como a cauda é o fim da lista, ela realiza seu papel criando um nó interno para armazenar este dado e garante que ela seja a sucessora deste novo nó.

[sourcecode language=”java”]
public class Interno<T extends Comparable<? super T>> extends No<T> {

private T dado;
private No<T> sucessor;

public Interno(T dado, No<T> sucessor) {
this.dado = dado;
this.sucessor = sucessor;
}

@Override
public No<T> adicionar(T dado) {
if (dado.compareTo(this.dado) < 0) {
return new Interno<T>(dado, this);
} else {
sucessor = sucessor.adicionar(dado);
return this;
}
}

@Override
public String listar() {
return "[" + dado + "]" + sucessor.listar();
}
}
[/sourcecode]

O papel do nó interno é decidir, com base em uma comparação, se o dado deve precedê-lo ou sucedê-lo. No primeiro caso, o nó interno cria outro nó para armazenar o dado e garante que este novo nó tenha o nó corrente como sucessor. No segundo caso, o nó interno apenas delega para o seu sucessor a responsabilidade de adicionar o dado.

[sourcecode language=”java”]
public class ListaEncadeada<T extends Comparable<? super T>> {

private Cabeca<T> cabeca;

public ListaEncadeada() {
cabeca = new Cabeca<T>();
}

public void adicionar(T dado) {
cabeca.adicionar(dado);
}

public String listar() {
return cabeca.listar();
}
}
[/sourcecode]

E finalmente, a classe ListaEncadeada realiza o seu papel repassando o dado a ser adicionado para a cabeça, dando início a cadeia de execução. A seguir o teste da classe ListaEncadeada.

[sourcecode language=”java”]
public class ListaEncadeadaTest {

private ListaEncadeada<String> lista;

@Before
public void iniciar() {
lista = new ListaEncadeada<String>();
}

@Test
public void quando_inserir_um_unico_elemento_na_lista_listar_unico_elemento() {
lista.adicionar("elemento");

String resultado = lista.listar();

Assert.assertEquals("[elemento]", resultado);
}

@Test
public void quando_lista_for_vazia_nao_listar_nada() {
String resultado = lista.listar();
Assert.assertEquals("", resultado);
}

@Test
public void quando_inserir_varios_elementos_listar_elementos_separados_por_virgula_e_ordenados_em_ordem_alfabetica() {
lista.adicionar("elemento2");
lista.adicionar("elemento1");
lista.adicionar("elemento3");

String resultado = lista.listar();

Assert.assertEquals("[elemento1][elemento2][elemento3]", resultado);
}
}
[/sourcecode]

Em resumo, em um programa orientado a objetos bem projetado, ninguém está no comando. Cada objeto faz sua parte e no final tudo funciona como uma cadeia. Para exemplificar, mostro abaixo uma possível implementação do método adicionar para a classe Cabeça.

[sourcecode language=”java”]
@Override
public No<T> adicionar(T dado) {
if (sucessor instanceof Cauda) {
sucessor = ((Cauda<T>)sucessor).adicionar(dado);
} else if (sucessor instanceof Interno) {
sucessor = ((Interno<T>)sucessor).adicionar(dado);
}

return this;
}
[/sourcecode]

Nesta implementação o método está assumindo mais responsabilidades que o necessário. Isto diminui o grau de coesão da implementação. Além disto, como agora o método tem que decidir o que fazer em função do tipo do seu sucessor, aumentamos o acoplamento entre as classes Cabeça, Cauda e Interno. Fazendo o uso do polimorfismo, como na implementação original, deixamos que a máquina virtual se encarregue de escolher qual implementação chamar em função do tipo do sucessor.

Conclusão

É isso, pessoal. O objetivo aqui foi mostrar algumas aplicações interessantes de herança e polimorfismo. São dois conceitos muito poderosos da POO. Tão poderosos que quando descobrimos queremos usar em todo lugar. Até mesmo em situações que não deveríamos. Mas isto é um assunto pra outro artigo.

De qualquer forma, para aquele leitor que conhece os conceitos, mas tem pouca prática com a POO, espero que este artigo faça pipocar algumas idéias e opiniões na sua cabeça. Na parte 2 mostrarei mais herança e polimorfismo com a implementação do jogo da cobrinha.

Um grande abraço!

Código-fonte

https://github.com/marceloandradep/tasafo-oop

Deixe uma resposta

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *