Como a definição de que um método pode ser substituído é um compromisso mais forte do que a definição de que um método pode ser chamado?

36

De: http://www.artima.com/lejava/articles/designprinciples4.html

Erich Gamma: Eu ainda acho que é verdade mesmo depois de dez anos. A herança é uma maneira legal de mudar o comportamento. Mas sabemos que é frágil, porque a subclasse pode facilmente fazer suposições sobre o contexto em que um método que substitui está sendo chamado. Existe um forte acoplamento entre a classe base e a subclasse, devido ao contexto implícito no qual o código da subclasse que eu inserir será chamado. A composição tem uma propriedade melhor. O acoplamento é reduzido apenas com algumas coisas menores que você conecta em algo maior, e o objeto maior apenas chama o objeto menor de volta. Do ponto de vista da API, definir que um método possa ser substituído é um compromisso mais forte do que definir que um método pode ser chamado.

Eu não entendo o que ele quis dizer. Alguém poderia explicar isso?

q126y
fonte

Respostas:

63

Um compromisso é algo que reduz suas opções futuras. A publicação de um método implica que os usuários o chamarão; portanto, você não pode remover esse método sem quebrar a compatibilidade. Se você o mantivesse private, eles não poderiam (diretamente) chamá-lo, e você poderia refatorá-lo um dia sem problemas. Portanto, publicar um método é um compromisso mais forte do que não publicá-lo. A publicação de um método substituível é um compromisso ainda mais forte. Seus usuários podem chamá-lo e podem criar novas classes onde o método não faz o que você pensa!

Por exemplo, se você publicar um método de limpeza, pode garantir que os recursos sejam desalocados adequadamente, desde que os usuários lembrem de chamar esse método como a última coisa que fazem. Mas se o método for substituível, alguém poderá substituí-lo em uma subclasse e não chamar super. Como resultado, um terceiro usuário pode usar essa classe e causar um vazamento de recursos, mesmo que eles tenham chamado cleanup()no final ! Isso significa que você não pode mais garantir a semântica do seu código, o que é uma coisa muito ruim.

Essencialmente, você não pode mais confiar em nenhum código executado em métodos substituíveis pelo usuário, porque algum intermediário pode substituí-lo. Isso significa que você precisa implementar sua rotina de limpeza inteiramente em privatemétodos, sem a ajuda do usuário. Portanto, geralmente é uma boa ideia publicar apenas finalelementos, a menos que sejam explicitamente destinados a serem substituídos pelos usuários da API.

Kilian Foth
fonte
11
Este é possivelmente o melhor argumento contra a herança que eu já li. De todas as razões contra as quais encontrei, nunca me deparei com esses dois argumentos antes (acoplando e quebrando a funcionalidade através da substituição), mas ambos são argumentos muito poderosos contra a herança.
David Arno
5
@DavidArno Eu não acho que seja um argumento contra a herança. Eu acho que é um argumento contra "tornar tudo substituível por padrão". A herança não é perigosa por si só, usá-la sem pensar.
Sv16
15
Embora isso pareça um bom argumento, não consigo entender como "um usuário pode adicionar seu próprio código de buggy" é um argumento. A ativação da herança permite que os usuários adicionem funcionalidades ausentes, sem perder a capacidade de atualização, uma medida que pode impedir e corrigir bugs. Se um código de usuário no topo da sua API estiver quebrado, não será uma falha na API.
Sebb 04/12/2015
4
Você pode facilmente transformar esse argumento em: o primeiro codificador cria um argumento de limpeza, mas comete erros e não limpa tudo. O segundo codificador substitui o método de limpeza e faz um bom trabalho, e o codificador nº 3 usa a classe e não possui nenhum vazamento de recurso, embora o codificador nº 1 tenha feito uma bagunça.
Pieter B
6
@Doval De fato. É por isso que é uma farsa que a herança seja a lição número um em quase todos os livros e aulas introdutórias de OOP.
Kevin Krumwiede
30

Se você publica uma função normal, fornece um contrato unilateral:
O que a função faz se chamada?

Se você publicar um retorno de chamada, também concederá um contrato unilateral:
quando e como será chamado?

E se você publica uma função substituível, é ao mesmo tempo, portanto, você concede um contrato bilateral:
quando será chamado e o que deve fazer se for chamado?

Mesmo que seus usuários não estejam abusando da sua API (quebrando a parte do contrato, que pode ser proibitivamente cara de detectar), você pode ver facilmente que o último precisa de muito mais documentação, e tudo o que você documenta é um compromisso, o que limita suas outras escolhas.

Um exemplo de renúncia a um contrato frente e verso é a mudança de showe hidepara setVisible(boolean)em java.awt.Component .

Desduplicador
fonte
+1. Não sei por que a outra resposta foi aceita; faz alguns pontos interessantes, mas definitivamente não é a resposta certa para essa pergunta, pois definitivamente não é o que se entende por passagem citada.
Ruakh
Esta é a resposta correta, mas não entendo o exemplo. Substituir show e hide por setVisible (boolean) parece quebrar o código que também não usa herança. Estou esquecendo de algo?
eigensheep
3
@eigensheep: showe hideainda existem, são apenas @Deprecated. Portanto, a mudança não quebra nenhum código que apenas os invoque. Mas se você os tiver substituído, suas substituições não serão chamadas pelos clientes que migrarem para o novo 'setVisible'. (Eu nunca usei o Swing, por isso não sei como é comum substituí-los; mas, como aconteceu há muito tempo, imagino o motivo pelo qual o Deduplicator se lembra é que ele o mordeu dolorosamente.)
ruakh 01/01
12

A resposta de Kilian Foth é excelente. Gostaria apenas de adicionar o exemplo canônico * de por que isso é um problema. Imagine uma classe Point inteira:

class Point2D {
    public int x;
    public int y;

    // constructor
    public Point2D(int theX, int theY) { x = theX; y = theY; }

    public int hashCode() { return x + y; }

    public boolean equals(Object o) {
        if (this == o) { return true; }
        if ( !(o instanceof Point2D) ) { return false; }

        Point2D that = (Point2D) o;

        return (x == that.x) &&
               (y == that.y);
    }
}

Agora vamos subclassificar que seja um ponto 3D.

class Point3D extends Point2D {
    public int z;

    // constructor
    public Point3D(int theX, int theY, int theZ) {
        super(x, y); z = theZ;
    }

    public int hashCode() { return super.hashCode() + z; }

    public boolean equals(Object o) {
        if (this == o) { return true; }
        if ( !(o instanceof Point3D) ) { return false; }

        Point3D that = (Point3D) o;

        return super.equals(that) &&
               (z == that.z);
    }
}

Super simples! Vamos usar nossos pontos:

Point2D p2a = new Point2D(3, 5);
Point2D p2b = new Point2D(3, 5);
Point2D p2c = new Point2D(3, 7);

p2a.equals(p2b); // true
p2b.equals(p2a); // true
p2a.equals(p2c); // false

Point3D p3a = new Point3D(3, 5, 7);
Point3D p3b = new Point3D(3, 5, 7);
Point3D p3c = new Point3D(3, 7, 11);

p3a.equals(p3b); // true
p3b.equals(p3a); // true
p3a.equals(p3c); // false

Você provavelmente está se perguntando por que estou postando um exemplo tão fácil. Aqui está o problema:

p2a.equals(p3a); // true
p3a.equals(p2a); // FALSE!

Quando comparamos o ponto 2D com o ponto 3D equivalente, nos tornamos verdadeiros, mas quando invertemos a comparação, nos tornamos falsos (porque p2a falha instanceof Point3D).

Conclusão

  1. Geralmente, é possível implementar um método em uma subclasse de forma que não seja mais compatível com o modo como a superclasse espera que ele funcione.

  2. Geralmente é impossível implementar equals () em uma subclasse significativamente diferente de uma maneira que seja compatível com a classe pai.

Quando você escreve uma classe que pretende permitir que as pessoas subclasses, é uma boa idéia escrever uma contrato sobre como cada método deve se comportar. Melhor ainda seria um conjunto de testes de unidade que as pessoas poderiam executar contra suas implementações de métodos substituídos para provar que não violam o contrato. Quase ninguém faz isso porque é muito trabalho. Mas se você se importa, é o que deve ser feito.

Um ótimo exemplo de um contrato bem escrito é o Comparator . Apenas ignore o que diz .equals()pelas razões descritas acima. Aqui está um exemplo de como o Comparator pode fazer as coisas .equals()não .

Notas

  1. O item 8 "Java efetivo" de Josh Bloch foi a fonte desse exemplo, mas Bloch usa um ColorPoint que adiciona uma cor ao invés de um terceiro eixo e usa duplas ao invés de ints. O exemplo de Java de Bloch é basicamente duplicado por Odersky / Spoon / Venners, que disponibilizou seu exemplo online.

  2. Várias pessoas se opuseram a este exemplo porque, se você informar a classe pai sobre a subclasse, poderá corrigir esse problema. Isso é verdade se houver um número suficientemente pequeno de subclasses e se os pais souberem de todas elas. Mas a pergunta original era sobre como criar uma API para a qual alguém escreverá subclasses. Nesse caso, você geralmente não pode atualizar a implementação pai para ser compatível com as subclasses.

Bônus

O comparador também é interessante, pois funciona em torno da questão da implementação correta de equals (). Melhor ainda, segue um padrão para corrigir esse tipo de problema de herança: o padrão de design da Estratégia. As Typeclasses com as quais Haskell e Scala se empolgam também são o padrão da Estratégia. A herança não é ruim ou errada, é apenas complicada. Para uma leitura mais aprofundada, consulte o artigo de Philip Wadler Como tornar o polimorfismo ad-hoc menos ad hoc

GlenPeterson
fonte
1
No entanto, SortedMap e SortedSet não alteram as definições de equalscomo Map e Set o definem. A igualdade ignora completamente a ordem, com o efeito de que, por exemplo, dois SortedSets com os mesmos elementos, mas diferentes ordens de classificação ainda comparam iguais.
User2357112 suporta Monica
1
@ user2357112 Você está certo e removi esse exemplo. SortedMap.equals () sendo compatível com o Map é uma questão separada da qual passarei a reclamar. SortedMap é geralmente O (log2n) e HashMap (a implementação canônica de Map) é O (1). Portanto, você só usaria um SortedMap se realmente se preocupasse em fazer pedidos. Por esse motivo, acredito que a ordem é importante o suficiente para ser um componente crítico do teste equals () nas implementações do SortedMap. Eles não devem compartilhar uma implementação equals () com o Map (como fazem no AbstractMap em Java).
GlenPeterson
3
"A herança não é ruim ou errada, é apenas complicada." Entendo o que você está dizendo, mas coisas complicadas geralmente levam a erros, bugs e problemas. Quando você pode realizar as mesmas coisas (ou quase todas as mesmas coisas) de uma maneira mais confiável, a maneira mais complicada é ruim.
precisa saber é
7
Este é um exemplo terrível , Glen. Você acabou de usar a herança de uma maneira que não deve ser usada, não admira que as classes não funcionem da maneira que você pretendia. Você violou o princípio de substituição de Liskov fornecendo uma abstração errada (o ponto 2D), mas apenas porque a herança é ruim no seu exemplo errado não significa que ela é ruim em geral. Embora essa resposta possa parecer razoável, apenas confundirá as pessoas que não percebem que infringem a regra mais básica de herança.
Andy
3
O ELI5 do Princípio Substituton de Liskov diz: Se uma classe Bé filha de uma classe Ae você deve instanciar um objeto de uma classe B, deve poder converter o Bobjeto de classe para seu pai e usar a API da variável castada sem perder nenhum detalhe de implementação de a criança Você violou a regra ao fornecer a terceira propriedade. Como você planeja acessar a zcoordenada depois de converter a Point3Dvariável Point2D, quando a classe base não faz ideia de que essa propriedade existe? Se, ao converter uma classe filho em sua base, você quebrar a API pública, sua abstração estará errada.
Andy
4

Herança enfraquece o encapsulamento

Ao publicar uma interface com herança permitida, você aumenta substancialmente o tamanho da sua interface. Cada método substituível pode ser substituído e, portanto, deve ser considerado como retorno de chamada fornecido ao construtor. A implementação fornecida por sua classe é apenas o valor padrão do retorno de chamada. Assim, algum tipo de contrato deve ser fornecido, indicando quais são as expectativas sobre o método. Isso raramente acontece e é uma das principais razões pelas quais o código orientado a objetos é chamado de quebradiço.

Abaixo está um exemplo real (simplificado) da estrutura de coleções java, cortesia de Peter Norvig ( http://norvig.com/java-iaq.html ).

Public Class HashTable{
    ...
    Public Object put(K key, V value){
        try{
            //add object to table;
        }catch(TableFullException e){
            increaseTableSize();
            put(key,value);
        }
    }
}

Então, o que acontece se subclassificarmos isso?

/** A version of Hashtable that lets you do
 * table.put("dog", "canine");, and then have
 * table.get("dogs") return "canine". **/

public class HashtableWithPlurals extends Hashtable {

    /** Make the table map both key and key + "s" to value. **/
    public Object put(Object key, Object value) {
        super.put(key + "s", value);
        return super.put(key, value);
    }
}

Temos um bug: Ocasionalmente, adicionamos "dog" e a hashtable recebe uma entrada para "dogss". A causa foi alguém fornecendo uma implementação do put que a pessoa que projetava a classe Hashtable não esperava.

A herança quebra a extensibilidade

Se você permitir que sua classe seja subclassificada, estará comprometendo-se a não adicionar métodos à sua classe. Caso contrário, isso poderia ser feito sem quebrar nada.

Quando você adiciona novos métodos a uma interface, qualquer pessoa que tenha herdado da sua classe precisará implementar esses métodos.

eigensheep
fonte
3

Se um método deve ser chamado, você só precisa garantir que ele funcione corretamente. É isso aí. Feito.

Se um método foi projetado para ser substituído, você também deve pensar cuidadosamente sobre o escopo do método: se o escopo for muito grande, a classe filho frequentemente precisará incluir código copiado e colado do método pai; se for muito pequeno, muitos métodos precisarão ser substituídos para ter a nova funcionalidade desejada - isso adiciona complexidade e contagem de linhas desnecessárias.

Portanto, o criador do método pai precisa fazer suposições sobre como a classe e seus métodos poderão ser substituídos no futuro.

No entanto, o autor está falando sobre uma questão diferente no texto citado:

Mas sabemos que é frágil, porque a subclasse pode facilmente fazer suposições sobre o contexto em que um método que substitui está sendo chamado.

Considere o método aque normalmente está sendo chamado do método b, mas em alguns casos raros e não óbvios do método c. Se o autor do método de substituição ignorar oc método e suas expectativas a, é óbvio como as coisas podem dar errado.

Portanto, é mais importante que aseja definido de forma clara e inequívoca, bem documentado, "faça uma coisa e faça bem" - mais do que se fosse um método projetado apenas para ser chamado.

Chuvoso
fonte