Qual seria a desvantagem de definir uma classe como uma subclasse de uma lista própria?


48

Em um projeto recente, defini uma classe com o seguinte cabeçalho:

public class Node extends ArrayList<Node> {
    ...
}

No entanto, depois de discutir com meu professor de CS, ele afirmou que a classe seria "horrível para a memória" e "má prática". Não achei o primeiro particularmente verdadeiro e o segundo subjetivo.

Meu raciocínio para esse uso é que eu tinha uma ideia para um objeto que precisava ser definido como algo que pudesse ter profundidade arbitrária, em que o comportamento de uma instância pudesse ser definido por uma implementação customizada ou pelo comportamento de vários objetos semelhantes interagindo . Isso permitiria a abstração de objetos cuja implementação física seria composta de muitos subcomponentes interagindo.¹

Por outro lado, vejo como isso pode ser uma prática ruim. A idéia de definir algo como uma lista em si não é simples ou fisicamente implementável.

Existe algum motivo válido para eu não usar isso no meu código, considerando o meu uso?


¹ Se eu precisar explicar isso mais, ficaria feliz em; Estou apenas tentando manter essa pergunta concisa.



11
@gnat Isso tem mais a ver com definir estritamente uma classe como uma lista de si mesma do que com listas que contêm listas. Eu acho que é uma variante de algo semelhante, mas não inteiramente o mesmo. Essa pergunta se refere a algo na linha da resposta de Robert Harvey .
Addison Crump

16
Herdar de algo que é parametrizado por si só não é incomum, como mostra o idioma CRTP comumente usado em C ++. No caso do contêiner, a principal questão é justificar por que você deve usar herança em vez de composição.
Christophe

11
Eu gosto da ideia. Afinal, cada nó em uma árvore é uma (sub) árvore. Geralmente, a arvore de um nó é oculta de uma visualização de tipo (o nó clássico não é uma árvore, mas um único objeto) e, em vez disso, é expressa na visualização de dados (o nó único permite acesso a ramificações). Aqui é o contrário; não veremos dados da ramificação, estão no tipo Ironicamente, o layout real do objeto em C ++ provavelmente seria muito semelhante (a herança é implementada de maneira muito semelhante à contenção de dados), o que me faz pensar que estamos lidando com duas maneiras de expressar a mesma coisa.
Peter - Reintegrar Monica

11
Talvez esteja faltando um pouco, mas você realmente precisa estender ArrayList<Node> , em comparação com a implementação List<Node> ?
Matthias

Respostas:


107

Francamente, não vejo a necessidade de herança aqui. Isso não faz sentido; Node é um ArrayList dos Node?

Se isso é apenas uma estrutura de dados recursiva, você simplesmente escreveria algo como:

public class Node {
    public String item;
    public List<Node> children;
}

O que faz sentido; O nó possui uma lista de nós filhos ou descendentes.


34
Não é a mesma coisa. Uma lista de uma lista ad-infinitum não faz sentido, a menos que você esteja armazenando algo além de uma nova lista na lista. É para isso que Item serve e Itemopera independentemente das listas.
Robert Harvey

6
Oh, eu vejo agora. Eu tinha abordado Node é um ArrayList de Nodecomo Node contém 0 a muitos Node objetos. Sua função é a mesma, essencialmente, mas, em vez de ser uma lista de objetos semelhantes, ela possui uma lista de objetos semelhantes. O design original para a classe escrita era a crença de que qualquer um Nodepode ser expresso como um conjunto de sub Nodes, o que ambas as implementações fazem, mas esse certamente é menos confuso. +1
Addison Crump

11
Eu diria que a identidade entre um nó e uma lista tende a surgir "naturalmente" quando você escreve listas isoladas em C ou Lisp ou o que quer que seja: em certos projetos, o nó principal "é" a lista, no sentido de ser o único coisa já usada para representar uma lista. Portanto, não posso dissipar completamente a sensação de que, em alguns contextos, talvez um nó "seja" (não apenas "tenha") uma coleção de nós, mesmo que eu concorde que pareça EvilBadWrong em Java.
9605 Steve JobsMarcador de

12
@ nl-x O fato de a sintaxe ser mais curta não significa que seja melhor. Além disso, aliás, é muito raro acessar itens em uma árvore como essa. Normalmente, a estrutura não é conhecida no momento da compilação, portanto, você tende a não atravessar essas árvores por valores constantes. A profundidade também não é fixa, portanto você não pode nem repetir todos os valores usando essa sintaxe. Quando você adapta o código para usar um problema tradicional de travessia de árvore, acaba escrevendo Childrenapenas uma ou duas vezes, e geralmente é preferível escrevê-lo nesses casos por motivos de clareza do código.
Servy


57

O argumento “horrível para a memória” está totalmente errado, mas é objetivamente uma “má prática”. Quando você herda de uma classe, não herda apenas os campos e métodos nos quais está interessado. Em vez disso, herda tudo . Todo método declarado, mesmo que não seja útil para você. E o mais importante, você também herda todos os seus contratos e garante que a classe fornece.

O acrônimo SOLID fornece algumas heurísticas para um bom design orientado a objetos. Aqui, o I nterface Segregação Princípio (ISP) eo L iskov Substituição Pricinple (LSP) tem algo a dizer.

O ISP nos diz para manter nossas interfaces o menor possível. Mas, ao herdar de ArrayList, você obtém muitos, muitos métodos. É significativo para get(), remove(), set()(substituir), ou add()(inserir) um nó filho em um índice específico? É sensato à ensureCapacity()lista subjacente? O que isso significa para sort()um nó? Os usuários da sua classe realmente deveriam receber um subList()? Como você não pode ocultar os métodos que não deseja, a única solução é ter o ArrayList como uma variável de membro e encaminhar todos os métodos que você realmente deseja:

private final ArrayList<Node> children = new ArrayList();
public void add(Node child) { children.add(child); }
public Iterator<Node> iterator() { return children.iterator(); }

Se você realmente deseja todos os métodos que vê na documentação, podemos seguir para o LSP. O LSP nos diz que devemos poder usar a subclasse onde quer que a classe pai seja esperada. Se uma função usa um ArrayListparâmetro as e passamos o nosso Node, nada deve mudar.

A compatibilidade das subclasses começa com coisas simples, como assinaturas de tipo. Quando você substitui um método, não pode tornar os tipos de parâmetro mais rigorosos, pois isso pode excluir usos legais da classe pai. Mas isso é algo que o compilador verifica para nós em Java.

Mas o LSP é muito mais profundo: precisamos manter a compatibilidade com tudo o que é prometido pela documentação de todas as classes e interfaces-pai. Em sua resposta , Lynn encontrou um desses casos em que a Listinterface (que você herdou por ArrayList) garante como os métodos equals()e hashCode()devem funcionar. Pois hashCode()você recebe um algoritmo específico que deve ser implementado exatamente. Vamos supor que você tenha escrito isso Node:

public class Node extends ArrayList<Node> {
  public final int value;

  public Node(int value, Node... children) {
    this.value = Value;
    for (Node child : children)
      add(child);
  }

  ...

}

Isso requer que o valuenão possa contribuir para hashCode()e não possa influenciar equals(). A Listinterface - que você promete honrar herdando dela - precisa new Node(0).equals(new Node(123))ser verdadeira.


Como a herança de classes facilita demais a quebra acidental de uma promessa feita por uma classe pai e, como geralmente expõe mais métodos do que você pretendia, geralmente é sugerido que você prefira composição do que herança . Se você deve herdar algo, é recomendável herdar apenas interfaces. Se você deseja reutilizar o comportamento de uma classe específica, pode mantê-lo como um objeto separado em uma variável de instância, para que todas as suas promessas e requisitos não sejam impostos a você.

Às vezes, nossa linguagem natural sugere uma relação de herança: um carro é um veículo. Uma motocicleta é um veículo. Devo definir classes Care Motorcycleque herdam de uma Vehicleclasse? O design orientado a objetos não se trata de espelhar o mundo real exatamente em nosso código. Não podemos codificar facilmente as ricas taxonomias do mundo real em nosso código fonte.

Um exemplo é o problema de modelagem empregado-chefe. Temos vários Persons, cada um com um nome e endereço. Um Employeeé um Persone tem um Boss. A Bosstambém é a Person. Então, devo criar uma Personclasse que é herdada por Bosse Employee? Agora eu tenho um problema: o chefe também é funcionário e tem outro superior. Então parece que Bossdeveria se estender Employee. Mas o CompanyOwneré um Bossmas não é um Employee? De qualquer forma, qualquer tipo de gráfico de herança será dividido aqui.

OOP não é sobre hierarquias, herança e reutilização de classes existentes, é sobre generalizar o comportamento . OOP é sobre "Eu tenho vários objetos e quero que eles façam uma coisa em particular - e não me importo como." É para isso que servem as interfaces . Se você implementar a Iterableinterface para o seu computador Nodeporque deseja torná-lo iterável, tudo bem. Se você implementar a Collectioninterface porque deseja adicionar / remover nós filhos, etc., tudo bem. Mas herdar de outra classe, porque isso lhe dá tudo o que não é, ou pelo menos não, a menos que você tenha pensado cuidadosamente, conforme descrito acima.


10
Eu imprimi seus últimos 4 parágrafos, os intitulei como Sobre OOP e Herança e os coloquei na parede no trabalho. Alguns de meus colegas precisam ver que, como eu sei, eles apreciariam a maneira como você o coloca :) Obrigado.
YSC 3/16

17

Estender um contêiner por si só é geralmente aceito como uma má prática. Há realmente muito pouca razão para estender um contêiner em vez de apenas ter um. Estender um contêiner de você mesmo o torna ainda mais estranho.


14

Além do que foi dito, há um motivo específico de Java para evitar esse tipo de estrutura.

O contrato do equalsmétodo para listas exige que uma lista seja considerada igual a outro objeto

se e somente se o objeto especificado também for uma lista, ambas as listas terão o mesmo tamanho e todos os pares de elementos correspondentes nas duas listas serão iguais .

Fonte: https://docs.oracle.com/javase/7/docs/api/java/util/List.html#equals(java.lang.Object)

Especificamente, isso significa que ter uma classe projetada como uma lista em si mesma pode tornar caras as comparações de igualdade (e cálculos de hash também se as listas forem mutáveis) e, se a classe tiver alguns campos de instância, eles deverão ser ignorados na comparação de igualdade. .


11
Esse é um excelente motivo para remover isso do meu código, além de práticas inadequadas. : P
Addison Crump

3

Em relação à memória:

Eu diria que isso é uma questão de perfeccionismo. O construtor padrão de se ArrayListparece com isso:

public ArrayList(int initialCapacity) {
     super();

     if (initialCapacity < 0)
         throw new IllegalArgumentException("Illegal Capacity: "+ initialCapacity);

     this.elementData = new Object[initialCapacity];
 }

public ArrayList() {
     this(10);
}

Fonte . Este construtor também é usado no Oracle-JDK.

Agora considere construir uma lista vinculada individualmente com seu código. Você acabou de aumentar com sucesso o seu consumo de memória pelo fator 10x (para ser mais preciso, ainda que marginalmente mais alto). Para as árvores, isso pode facilmente ficar muito ruim sem requisitos especiais sobre a estrutura da árvore. Usar uma LinkedListou outra classe como alternativa deve resolver esse problema.

Para ser sincero, na maioria dos casos isso não passa de mero perfeccionismo, mesmo que ignoremos a quantidade de memória disponível. A LinkedListdesaceleraria um pouco o código como alternativa, por isso é uma troca entre desempenho e consumo de memória de qualquer maneira. Ainda assim, minha opinião pessoal sobre isso seria não desperdiçar tanta memória de uma maneira que possa ser tão facilmente contornada quanto esta.

EDIT: esclarecimentos sobre os comentários (@amon, para ser mais preciso). Esta seção da resposta não trata da questão da herança. A comparação do uso da memória é feita com uma Lista vinculada única e com o melhor uso da memória (em implementações reais, o fator pode mudar um pouco, mas ainda é suficientemente grande para resumir um pouco de memória desperdiçada).

Em relação às "más práticas":

Definitivamente. Esta não é a maneira padrão de implementar um gráfico por um motivo simples: um nó de gráfico possui nós filhos, não é como uma lista de nós filhos. Expressar com precisão o que você quer dizer com código é uma habilidade importante. Seja em nomes de variáveis ​​ou estruturas de expressão como esta. Próximo ponto: mantendo as interfaces mínimas: você disponibilizou todos os métodos ArrayListpara o usuário da classe por herança. Não há como alterar isso sem quebrar toda a estrutura do código. Em vez disso, armazene a Listvariável como interna e disponibilize os métodos necessários por meio de um método de adaptador. Dessa forma, você pode facilmente adicionar e remover funcionalidades da classe sem estragar tudo.


11
10x maior em comparação com o que ? Se eu tiver um ArrayList construído como padrão como um campo de instância (em vez de herdá-lo), na verdade estou usando um pouco mais de memória, pois preciso armazenar um ponteiro para ArrayList adicional no meu objeto. Mesmo se estivermos comparando maçãs com laranjas e comparar a herança de ArrayList a não usar nenhuma ArrayList, observe que em Java sempre lidamos com ponteiros para objetos, nunca objetos por valor como o C ++ faria. Supondo que um new Object[0]use 4 palavras no total, um new Object[10]usaria apenas 14 palavras de memória.
amon

@ amon Eu não afirmei explicitamente, sry por isso. Embora o contexto deva ser suficiente para ver que estou comparando a um LinkedList. E é chamado de referência, não ponteiro. E a questão da memória não era sobre se a classe é usada por herança ou não, mas simplesmente o fato de que (quase) ArrayLists não utilizados estão consumindo bastante memória.
Paulo

-2

Parece que parece a semântica do padrão composto . É usado para modelar estruturas de dados recursivas.


13
Eu não vou downvote isso, mas isso é porque eu realmente odeio o livro GoF. É uma árvore sangrando! Por que precisamos inventar o termo "padrão composto" para descrever uma árvore?
David Arno

3
@DavidArno Eu acredito que é todo o aspecto do nó polimórfico que o define como "padrão composto", o uso de uma estrutura de dados em árvore é apenas parte dela.
JAB

2
@RobertHarvey, eu discordo (com seus comentários à sua resposta e aqui). public class Node extends ArrayList<Node> { public string Item; }é a versão de herança da sua resposta. O seu é mais simples de raciocinar, mas ambos alcançam o seguinte: eles definem árvores não binárias.
David Arno

2
@ David David: Eu nunca disse que não iria funcionar, apenas disse que provavelmente não é o ideal.
Robert Harvey

11
@DavidArno não é sobre sentimentos e gostos aqui, mas é sobre fatos. Uma árvore é feita de nós e cada nó tem filhos em potencial. Um determinado nó é um nó folha apenas em um determinado momento no tempo. Em um composto, um nó folha é folha por construção, e sua natureza não muda.
Christophe
Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.