Como está definindo que um método pode ser substituído por um compromisso mais strong do que definir que um método pode ser chamado?

36

De: link

Erich Gamma: I still think it's true even after ten years. Inheritance is a cool way to change behavior. But we know that it's brittle, because the subclass can easily make assumptions about the context in which a method it overrides is getting called. There's a tight coupling between the base class and the subclass, because of the implicit context in which the subclass code I plug in will be called. Composition has a nicer property. The coupling is reduced by just having some smaller things you plug into something bigger, and the bigger object just calls the smaller object back. From an API point of view defining that a method can be overridden is a stronger commitment than defining that a method can be called.

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

    
por q126y 04.12.2015 / 16:26
fonte

5 respostas

63

Um compromisso é algo que reduz suas opções futuras. Publicar 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ê tivesse mantido private , eles não poderiam (diretamente) chamá-lo, e você poderia algum dia refatorá-lo sem problemas. Portanto, publicar um método é um compromisso mais strong do que não publicá-lo. A publicação de um método substituível é um compromisso ainda mais strong. Seus usuários podem chamá-lo, e eles podem criar novas classes onde o método não faz o que você pensa que faz!

Por exemplo, se você publicar um método de limpeza, poderá 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 pode 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, com razão, 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 depender de nenhum código em execução em métodos que possam ser substituídos pelo usuário, porque algum intermediário pode substituí-lo. Isso significa que você precisa implementar sua rotina de limpeza inteiramente em private métodos, sem ajuda do usuário. Portanto, geralmente é uma boa ideia publicar somente final elementos, a menos que eles sejam explicitamente destinados a serem substituídos pelos usuários da API.

    
por 04.12.2015 / 16:40
fonte
30

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

Se você publicar um retorno de chamada, também dará um contrato de um lado:
Quando e como ele será chamado?

E se você publicar uma função substituível, é ao mesmo tempo, então você dá um contrato de dois lados: Quando será chamado, e o que ele deve fazer se chamado?

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

Um exemplo de renegação em tal contrato bilateral é a mudança de show e hide para setVisible(boolean) em java.awt.Component .

    
por 04.12.2015 / 17:51
fonte
12

A resposta de Kilian Foth é excelente. Eu 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 classificá-lo como 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 simples. Aqui está o truque:

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

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

Conclusão

  1. Normalmente, é 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. Em geral, é impossível implementar equals () em uma subclasse significativamente diferente de forma compatível com sua classe pai.

Quando você escreve uma classe que você pretende permitir que as pessoas subclassifiquem, é realmente uma boa idéia escrever um 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 eles não violam o contrato. Quase ninguém faz isso porque é muito trabalho. Mas se você se importa, essa é a coisa a fazer.

Um ótimo exemplo de contrato bem escrito é Comparador . Apenas ignore o que diz sobre .equals() pelos motivos descritos acima. Veja um exemplo de como o Comparador pode fazer as coisas .equals() não pode .

Notas

  1. O item 8 de "Effective Java" de Josh Bloch foi a fonte deste exemplo, mas Bloch usa um ColorPoint que adiciona uma cor em vez de um terceiro eixo e usa duplas em vez de ints. O exemplo de Java de Bloch é basicamente duplicado por Odersky / Spoon / Venners que disponibilizou seu exemplo on-line.

  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 pequeno de subclasses e se o pai souber sobre todas elas. Mas a questão original era sobre fazer uma API para a qual outra pessoa escreveria 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 porque trabalha em torno do problema de implementar equals () corretamente. 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 que as pessoas de Haskell e Scala se empolgam também são o padrão de Estratégia. A herança não é ruim ou está errada, é apenas complicada. Para ler mais, confira o artigo de Philip Wadler Como tornar o polimorfismo ad-hoc menos ad hoc

    
por 04.12.2015 / 18:06
fonte
4

Herança enfraquece o encapsulamento

Quando você publica uma interface com herança permitida, aumenta substancialmente o tamanho da sua interface. Cada método overrideable pode ser substituído e, portanto, deve ser considerado como retorno de chamada fornecido ao construtor. A implementação fornecida por sua classe é meramente 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 é a principal razão pela qual o código orientado a objetos é chamado de frágil.

Abaixo está um exemplo real (simplificado) da estrutura de coleções java, cortesia de Peter Norvig ( link ).

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 o hashtable recebe uma entrada para "dogss". A causa foi alguém fornecendo uma implementação de put que a pessoa que projetava a classe Hashtable não esperava.

Extensibilidade de interrupções de herança

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

Quando você adiciona novos métodos a uma interface, qualquer pessoa herdada de sua turma precisará implementar esses métodos.

    
por 05.12.2015 / 01:52
fonte
3

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

Se um método é projetado para ser substituído, você também precisa pensar cuidadosamente sobre o escopo do método: se o escopo for muito grande, a classe-filha geralmente precisará incluir código copiado-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 desnecessária de linhas.

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

No entanto, o autor está falando sobre um assunto diferente no texto citado:

But we know that it's brittle, because the subclass can easily make assumptions about the context in which a method it overrides is getting called.

Considere o método a que está sendo normalmente chamado pelo 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 o método c e suas expectativas em a , é óbvio como as coisas podem dar errado.

Portanto, é mais importante que a seja 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.

    
por 05.12.2015 / 04:13
fonte