Qual seria a desvantagem de definir uma classe como uma subclasse de uma lista de si mesma?

48

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

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

No entanto, depois de conversar com meu professor de CS, ele afirmou que a aula seria "horrível para a memória" e "má prática". Eu não encontrei o primeiro a ser particularmente verdadeiro, e o segundo a ser subjetivo.

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

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

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

¹ Se eu precisar explicar isso mais adiante, eu ficaria feliz em saber; Estou apenas tentando manter essa questão concisa.

    
por Addison Crump 02.11.2016 / 21:27
fonte

6 respostas

108

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

Se esta é apenas uma estrutura de dados recursiva, basta escrever algo como:

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

Que faz faz sentido; O nó tem uma lista de filhos ou nós descendentes.

    
por 02.11.2016 / 21:39
fonte
57

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

A sigla SOLID fornece algumas heurísticas para um bom design orientado a objetos. Aqui, o I Princípio de Segregação da Interface (ISP) e o L iskov Substituição do Pricinple (LSP) tem algo a dizer.

O ISP nos diz para manter nossas interfaces o menor possível. Mas, herdando 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 a ensureCapacity() da lista subjacente? O que significa sort() a Node? Os usuários da sua turma realmente deveriam receber um subList() ? Como não é possível ocultar os métodos que você 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 quiser todos os métodos que você vê na documentação, podemos passar para o LSP. O LSP nos diz que devemos poder usar a subclasse sempre que a classe pai for esperada. Se uma função usar um parâmetro ArrayList como e passarmos o nosso Node , nada deve mudar.

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

Mas o LSP é muito mais profundo: temos que manter a compatibilidade com tudo o que é prometido pela documentação de todas as classes e interfaces pai. Na sua resposta , Lynn encontrou um desses casos em que a interface List (que você herdou via ArrayList ) garante como os métodos equals() e hashCode() devem funcionar. Para hashCode() , você recebe até mesmo um algoritmo específico que deve ser implementado exatamente. Vamos supor que você tenha escrito este 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 exige que value não contribua para o hashCode() e não possa influenciar equals() . A interface List - que você promete honrar herdando dela - exige que new Node(0).equals(new Node(123)) seja verdadeiro.

Como a herança de classes torna muito fácil quebrar acidentalmente uma promessa feita por uma classe pai e, como ela geralmente expõe mais métodos do que você pretendia, geralmente é sugerido que você prefira composição sobre herança . Se você precisar herdar algo, é recomendável herdar somente interfaces. Se você quiser reutilizar o comportamento de uma determinada classe, poderá mantê-lo como um objeto separado em uma variável de instância, dessa forma, todas as promessas e requisitos não serão impostos a você.

Às vezes, nossa linguagem natural sugere um relacionamento de herança: um carro é um veículo. Uma motocicleta é um veículo. Devo definir classes Car e Motorcycle que herdam de uma classe Vehicle ? O design orientado a objetos não se refere ao espelhamento do mundo real exatamente em nosso código. Não podemos codificar facilmente as taxonomias ricas do mundo real em nosso código-fonte.

Um exemplo é o problema de modelagem empregado-chefe. Temos vários Person s, cada um com um nome e endereço. Um Employee é um Person e tem um Boss . Um Boss também é um Person . Então, devo criar uma classe Person que é herdada por Boss e Employee ? Agora eu tenho um problema: o chefe também é funcionário e tem outro superior. Portanto, parece que Boss deve estender Employee . Mas o CompanyOwner é um Boss , mas não é um Employee ? Qualquer tipo de gráfico de herança irá de alguma forma quebrar aqui.

OOP não é sobre hierarquias, herança e reutilização de classes existentes, trata-se de generalizar o comportamento . OOP é sobre "Eu tenho um monte de objetos e quero que eles façam uma coisa em particular - e eu não me importo como." Isso é o que interfaces são para. Se você implementar a Iterable interface para o seu Node , porque você quer torná-lo iterável, está tudo bem. Se você implementar a interface Collection porque deseja adicionar / remover nós filhos etc., tudo bem. Mas herdar de outra classe, porque acontece de lhe dar tudo o que não é, ou pelo menos não, a menos que você tenha dado um pensamento cuidadoso, conforme descrito acima.

    
por 03.11.2016 / 15:51
fonte
17

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

    
por 02.11.2016 / 21:43
fonte
14

Somando-se ao que foi dito, há uma razão específica do Java para evitar esse tipo de estrutura.

O contrato do método equals para listas requer que uma lista seja considerada igual a outro objeto

if and only if the specified object is also a list, both lists have the same size, and all corresponding pairs of elements in the two lists are equal.

Fonte: link

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

    
por 03.11.2016 / 12:26
fonte
3

Em relação à memória:

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

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 a construção de uma Lista unida com seu código. Você acabou de inchar com sucesso o seu consumo de memória pelo fator 10x (para ser preciso, mesmo que marginalmente maior). Para as árvores isso pode facilmente ficar muito ruim, bem como sem quaisquer requisitos especiais sobre a estrutura da árvore. Usar um LinkedList ou outra classe, como alternativa, deve resolver esse problema.

Para ser honesto, na maioria dos casos isso não é nada além de mero perfeccionismo, mesmo se ignorarmos a quantidade de memória disponível. Um LinkedList desaceleraria o código um pouco como uma alternativa, portanto, é uma troca entre desempenho e consumo de memória de qualquer forma. Ainda assim, minha opinião pessoal sobre isso seria não desperdiçar tanta memória de maneira que possa ser facilmente contornada como esta.

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

Sobre "prática ruim":

Definitivamente. Esta não é a forma padrão de implementar um gráfico por um motivo simples: um nó de gráfico tem nós-filhos, não é como uma lista de nós-filhos. Exprimir precisamente o que você quer dizer com código é uma grande habilidade. Seja em nomes de variáveis ou expressando estruturas como esta. Próximo ponto: mantendo as interfaces mínimas: Você fez todos os métodos de ArrayList disponíveis para o usuário da classe via herança. Não há como alterar isso sem quebrar toda a estrutura do código. Em vez disso, armazene a List como variável 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 turma sem estragar tudo.

    
por 04.11.2016 / 00:00
fonte
-2

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

    
por 02.11.2016 / 22:07
fonte