Mais uma vez sobre o princípio da substituição de Lisk, ou a semântica da herança no POO

A herança é um dos pilares da OOP. A herança é usada para reutilizar o código comum. Porém, nem sempre é necessário reutilizar o código comum, e a herança nem sempre é a melhor maneira de reutilizar o código. Muitas vezes acontece que, para que haja um código semelhante em duas partes diferentes de código (classes), mas os requisitos para elas são diferentes, ou seja, as classes realmente herdam umas das outras e podem não valer a pena.

Geralmente, para ilustrar esse problema, use o exemplo sobre a herança da classe Square da classe Rectangle ou vice-versa.

Vamos ter uma classe de retângulo:

class Rectangle: def __init__(self, width, height): self._width = width self._height = height def set_width(self, width): self._width = width def set_height(self, height): self._height = height def get_area(self): return self._width * self._height ... 

Agora queríamos escrever a classe Square, mas, para reutilizar o código de cálculo da área, parece lógico herdar o Square do Rectangle:

 class Square(Rectangle): def set_width(self, width): self._width = width self._height = width def set_height(self, height): self._width = height self._height = height 

Parece que o código das classes Square e Rectangle é consistente. Parece que o quadrado preserva as propriedades matemáticas do quadrado, ou seja, e um retângulo Isso significa que podemos passar objetos quadrados em vez de retângulo.

Mas se fizermos isso, podemos violar o comportamento da classe Rectangle:

Por exemplo, há um código de cliente:

 def client_code(rect): rect.set_height(10) rect.set_width(20) assert rect.get_area() == 200 

Se você passar uma instância da classe Square como argumento para essa função, a função se comportará de maneira diferente. O que é uma violação do contrato para o comportamento da classe Rectangle, porque as ações com um objeto da classe base devem fornecer exatamente o mesmo resultado que o objeto da classe descendente.

Se a classe quadrada é descendente da classe retângulo, trabalhando com o quadrado e executando os métodos do retângulo, não devemos nem perceber que não é um retângulo.

Você pode corrigir esse problema, por exemplo, assim:

  1. faça uma declaração para corresponder exatamente à classe ou faça um se isso funcionar de maneira diferente para classes diferentes
  2. no Square, crie o método set_size () e substitua os métodos set_height, set_width para que eles lançem exceções
    etc etc

Esse código e essas classes funcionarão, no sentido de que o código estará funcionando.

Outra questão é que o código do cliente que usa a classe Square ou Rectangle precisará saber sobre a classe base e seu comportamento, ou sobre a classe descendente e seu comportamento.

Com o tempo, conseguimos:

  • a classe descendente substituirá a maioria dos métodos
  • refatorar ou adicionar métodos à classe base quebrará o código usando descendentes
  • no código que usa os objetos da classe base, haverá ifs, verificando a classe do objeto e o comportamento dos descendentes e da classe base é diferente

Acontece que o código do cliente gravado para a classe base fica dependente da implementação da classe base e da classe descendente. O que complica muito o desenvolvimento ao longo do tempo. E OOP foi criado apenas para que você pudesse editar a classe base e a classe descendente independentemente uma da outra.

Nos anos 80 do século passado, percebemos que, para que a herança de classe funcionasse bem para a reutilização de código, precisamos ter certeza de que a classe descendente pode ser usada em vez da classe base. I.e. semântica de herança - isso deve ser não apenas e não tanto dados como comportamento. Os herdeiros não devem "quebrar" o comportamento da classe base.

Na verdade, esse é o princípio da substituição de Lisk ou o princípio de determinar um subtipo com base no comportamento de tipagem comportamental forte de classes: se você puder escrever pelo menos algum código significativo no qual substituir o objeto de classe base pelo objeto de classe descendente, ele será quebrado, não valerá a pena. herdá-los um do outro. Devemos estender o comportamento da classe base nos descendentes, e não alterá-lo significativamente. As funções que usam a classe base devem poder usar objetos de subclasse sem conhecê-lo. De fato, essa é a semântica da herança no POO.

E no código industrial real, é altamente recomendável que esse princípio seja seguido e respeite a semântica descrita da herança. E com esse princípio, existem várias sutilezas.

O princípio deve ser satisfeito não com abstrações no nível do domínio, mas com abstrações de código - classes. Do ponto de vista geométrico, um quadrado é um retângulo. Do ponto de vista da hierarquia de herança de classe, se a classe de um quadrado será o herdeiro do retângulo da classe depende do comportamento que exigimos dessas classes. Depende de como e em que situações usamos esse código.

Se a classe Rectangle tiver apenas dois métodos - calcular a área e renderizar, sem a possibilidade de redesenhar e redimensionar, nesse caso, o Square com um construtor substituído atenderá ao princípio de substituição de Lisky.

I.e. essas classes satisfazem o princípio da substituição:

 class Rectangle: def draw(): ... def get_area(): ... class Square(Rectangle): pass 

Embora, obviamente, este não seja um código muito bom, e mesmo, provavelmente, o antipadrão do design de classes, mas do ponto de vista formal, ele satisfaz o princípio de Liskov.

Outro exemplo Um conjunto é um subtipo de um multiset. Essa é a proporção de abstrações de domínio. Mas o código pode ser escrito para que herdemos a classe Set de Bag e o princípio da substituição seja violado, ou podemos escrever para que o princípio seja respeitado. Com a mesma semântica de domínio de assunto.

Em geral, a herança de classes pode ser considerada como a implementação do relacionamento “IS”, mas não entre as entidades da área de assunto, mas entre classes. E se a classe descendente é um subtipo da classe base é determinada pelas restrições e comportamento da classe que o código do cliente utiliza (e, em princípio, pode usar).

Restrições, invariantes, um contrato de classe base não são fixos no código, mas sim nas cabeças dos desenvolvedores que editam e leem o código. O que é "quebrar", o que está quebrando o "contrato" é determinado não pelo código, mas pela semântica da classe na cabeça do desenvolvedor.

Qualquer código que seja significativo para um objeto de uma classe base não deve ser interrompido se o substituirmos por um objeto de uma classe descendente. Código significativo é qualquer código de cliente que usa um objeto de uma classe base (e seus descendentes) dentro da estrutura da semântica e restrições da classe base.

O que é extremamente importante entender é que as limitações da abstração implementada na classe base geralmente não estão contidas no código do programa. Essas restrições são entendidas, conhecidas e suportadas pelo desenvolvedor. Ele monitora a consistência da abstração e do código. Para o código expressar o que significa.

Por exemplo, um retângulo possui outro método que retorna uma visualização em json

 class Rectangle: def to_dict(self): return {"height": self.height, "width": self.width} 

E na Square redefinimos:

 class Square: def to_dict(self): return {"size": self.height} 

Se considerarmos que o contrato básico para o comportamento da classe Rectangle to_json tenha altura e largura, o código

 r = rect.to_dict() log(r['height'], r['width']) 

será significativo para um objeto da classe base Rectangle. Ao substituir um objeto de uma classe base por uma classe, o código herdeiro de Square altera seu comportamento e viola o contrato, violando o princípio da substituição de Lisk.

Se acreditamos que o contrato básico para o comportamento da classe Rectangle é que to_dict retorna um dicionário que pode ser serializado sem colocar campos específicos, então esse método to_dict estará ok.

A propósito, este é um bom exemplo, destruindo o mito de que a imutabilidade evita a violação do princípio.

Formalmente, qualquer substituição de um método em uma classe descendente é perigosa, bem como alterações na lógica na classe base. Por exemplo, muitas vezes as classes descendentes se adaptam ao comportamento "incorreto" da classe base e, quando o bug é corrigido na classe base, elas quebram.

É possível transferir todas as condições do contrato e invariantes para o código o máximo possível, mas, no caso geral, a semântica do comportamento permanece a mesma fora do código - na área do problema e é suportada pelo desenvolvedor. O exemplo sobre to_dict é um exemplo em que o contrato pode ser descrito no código, mas, por exemplo, verificar se o método get_hash realmente retorna um hash com todas as propriedades do hash, e não apenas uma linha, é impossível.

Quando um desenvolvedor usa código escrito por outros desenvolvedores, ele pode entender quais são as semânticas de uma classe apenas diretamente por código, nomes de métodos, documentação e comentários. Mas, em qualquer caso, a semântica é frequentemente um domínio humano e, portanto, incorreto. A consequência mais importante: somente pelo código - sintaticamente - é impossível verificar a conformidade com o princípio de Liskov, e você precisa confiar na semântica (muitas vezes) vaga. Não há meios formais (matemáticos) de uma maneira verificável e garantida para verificar uma forte tipagem comportamental.

Portanto, muitas vezes, em vez do princípio de Liskov, são usadas regras formais para pré-condições e pós-condições da programação de contratos:

  • as pré-condições em uma subclasse não podem ser reforçadas - uma subclasse não deve exigir mais do que a classe base
  • as pós-condições da subclasse não podem ser relaxadas - a subclasse não deve fornecer (prometer) menos que a classe base
  • invariantes da classe base devem ser preservados na classe descendente.

Por exemplo, em um método de classe descendente, não podemos adicionar um parâmetro necessário que não estava na classe base - porque é assim que fortalecemos as pré-condições. Ou não podemos lançar exceções no método substituído, porque violar os invariantes da classe base. Etc.

O que importa não é o comportamento atual da classe, mas o que a classe muda implica em responsabilidade ou semântica da classe.

O código é constantemente corrigido e alterado. Portanto, se neste momento o código atender ao princípio da substituição, isso não significa que as alterações no código não irão mudar isso.

Digamos que exista um desenvolvedor da classe de biblioteca Rectangle e um desenvolvedor de aplicativos que herda Square do Rectangle. No momento em que o desenvolvedor do aplicativo herdou o Square da Rectangle - tudo estava bem, as classes atendiam ao princípio da substituição.

E, em algum momento, o desenvolvedor responsável pela biblioteca adicionou um método de remodelagem ou set_width / set_height à classe base Rectangle. Do seu ponto de vista, uma extensão da classe base acabou de acontecer. Mas, de fato, houve uma mudança na semântica e nos contratos nos quais a classe descendente dependia. Agora as classes não satisfazem mais o princípio.

Em geral, ao herdar no OOP, alterações na classe base que se parecerão com uma extensão da interface - outro método ou campo serão adicionados podem violar contratos “naturais” anteriores e, assim, alterar a semântica ou as responsabilidades. Portanto, adicionar qualquer método à classe base é perigoso. Você pode acidentalmente alterar o contrato acidentalmente.

E de um ponto de vista prático, no exemplo com um retângulo e uma classe, é importante se existe agora um método de remodelagem ou set_width / set_height. Do ponto de vista prático, é importante quão alta é a probabilidade de tais alterações no código da biblioteca. A semântica ou os limites da responsabilidade de classe implicam essas mudanças. Se implícita, a probabilidade de erro e / ou necessidade adicional de refatoração aumenta significativamente. E se houver uma pequena possibilidade, provavelmente é melhor não herdar essas classes umas das outras.

Manter definições de subtipo baseadas no comportamento é difícil, mesmo para classes simples com semântica clara , para não falar de empresas com lógica de negócios complexa. Apesar do fato de que a classe base e a classe herdeiro são diferentes partes do código, para elas você precisa pensar com cuidado e cuidado nas interfaces e na responsabilidade. E mesmo com uma ligeira mudança na semântica da classe - que não pode ser evitada de forma alguma, precisamos examinar o código das classes relacionadas, verificar se o novo contrato ou invariável viola o que já está escrito (!) E usado. Com quase qualquer alteração na hierarquia de classes ramificadas, precisamos procurar e verificar muitos outros códigos.

Essa é uma das razões pelas quais algumas pessoas não gostam muito da herança clássica no POO. E, portanto, geralmente preferem composição de classes, herança de interfaces, etc., etc. em vez da herança clássica do comportamento.

Para ser justo, há algumas regras que provavelmente não violam o princípio da substituição. Você pode se proteger o máximo possível se proibir todas as estruturas perigosas. Por exemplo, para C ++, Oleg escreveu sobre isso. Mas, em geral, essas regras não transformam classes em classes no sentido clássico.

Usando métodos administrativos, a tarefa também não está muito bem resolvida. Aqui você pode ler como o tio Martin se saiu em C ++ e como não funcionou.

Mas no código industrial real, muitas vezes, o princípio de Liskov é violado, e isso não é assustador . É difícil seguir o princípio, porque 1) a responsabilidade e a semântica de uma classe geralmente não são explícitas e não são expressas no código 2) a responsabilidade de uma classe pode mudar - tanto na classe base quanto na classe descendente. Mas isso nem sempre leva a consequências realmente terríveis. A violação mais comum, mais simples e mais básica é que um método substituído modifica o comportamento. Como por exemplo aqui:

 class Task: def close(self): self.status = CLOSED ... class ProjectTask(Task): def close(self): if status == STARTED: raise Exception("Cannot close a started Project Task") ... 

O método close do ProjectTask lançará uma exceção nos casos em que os objetos da classe Task funcionem bem. Em geral, a redefinição de métodos de uma classe base muitas vezes leva a uma violação do princípio da substituição, mas não se torna um problema.

De fato, nesse caso, o desenvolvedor percebe a herança NÃO como uma implementação do relacionamento "IS", mas simplesmente como uma maneira de reutilizar o código. I.e. uma subclasse é apenas uma subclasse, não um subtipo. Nesse caso, de um ponto de vista pragmático e prático, importa mais - mas qual é a probabilidade de existir ou já existir um código de cliente que notará semânticas diferentes dos métodos da classe descendente e da classe base?

Existe muito código que espera um objeto de uma classe base, mas para o qual passamos o objeto da classe descendente? Para muitas tarefas, esse código nunca existirá.

Quando uma violação do LSP leva a grandes problemas? Quando, devido a diferenças de comportamento, o código do cliente precisará ser reescrito com alterações na classe descendente e vice-versa. Isso se torna especialmente um problema se esse código de cliente é um código de biblioteca que não pode ser alterado. Se a reutilização do código não conseguir criar dependências entre o código do cliente e o código da classe no futuro, mesmo com a violação do princípio de substituição de Liskov, esse código poderá não causar grandes problemas.

Em geral, durante o desenvolvimento, a herança pode ser vista de duas perspectivas: subclasses são subtipos, com todas as limitações da programação de contratos e o princípio Lisk, e subclasses são uma maneira de reutilizar código, com todos os seus problemas em potencial. I.e. você pode pensar e projetar responsabilidades e contratos de classe e não se preocupar com o código do cliente. Pense sobre o que pode ser o código do cliente, como as classes serão usadas e esteja preparado para possíveis problemas, mas em menor grau se preocupa em observar o princípio da substituição. A decisão, como de costume, depende do desenvolvedor, o mais importante é que a escolha em uma situação específica seja consciente e que haja uma compreensão de quais prós, contras e armadilhas acompanham essa ou aquela solução.

Source: https://habr.com/ru/post/pt463385/


All Articles