Liskov, retângulos, quadrados e objetos nulos

5
Continuo pensando que tenho minha cabeça envolvida no Princípio da Substituição de Liskov, e então percebo que não. Aqui é onde eu juntei StackExchange:

  1. Subclassing Square from Rectangle viola o LSP porque, se você alterar a largura de um Square, a altura também será alterada.
  2. Objetos nulos não violam o LSP.

Esses parecem se contradizer para mim. Se isso é ruim:

square.setWidth(40);
square.setHeight(50);
width = square.getWidth(); // Returns 50 instead of 40. Liskov hates that.

Então por que isso não é ruim?

nullRectangle.setWidth(40);
nullRectangle.setHeight(50);
width = nullRectangle.getWidth(); // Returns 0 or null. Isn't that worse?

O que estou perdendo aqui?

    
por M. Christian 12.09.2017 / 19:54
fonte

5 respostas

4

O que você encontrou é simplesmente um caso extremo em que o objeto Nulo pode não ser uma boa solução.

O ponto de null object é tornar o comportamento igual ao de se você já havia marcado null .

No seu caso, se esse código for um comportamento esperado:

Rectangle nullRectangle = null;
int width = 0;
if (nullRectangle != null)
{
    nullRectangle.setWidth(40);
    nullRectangle.setHeight(50);
    width = nullRectangle.getWidth();
}
// width is 0 if nullRectangle is null

Dez null object , conforme descrito em seu código, é um substituto lógico.

Mas a idéia de ter "retângulo nulo" não faz muito sentido a partir da perspectiva de modelagem. O objeto nulo é realmente utilizável apenas como implementação de abstrações, em que chamar um método não significa que o chamador esteja sempre esperando algo. Se o chamador de qualquer método da abstração vir "sem operação" ou "nenhum dado retornado" como resultado válido, então null object poderá ser usado. Se, em vez disso, um valor diferente de zero for esperado como retorno, o objeto nulo realmente violará o LSP.

    
por 12.09.2017 / 20:03
fonte
2

Acho que o problema Rectangle / Square é resultado de uma análise de domínio equivocada.

Retângulos / quadrados matemáticos

Da matemática, todos sabemos que os quadrados são casos especiais de retângulos, que traduzimos como Square como uma subclasse de Rectangle , e isso é bom, desde que permaneçamos com o conceito matemático de retângulo e quadrado que não inclui alterar a largura ou a altura do objeto . Para formas imutáveis, o modelo de herança está em conformidade com o princípio de Liskov.

Objetos de programas mutáveis

Assim que introduzirmos um setter, por exemplo setHeight() , a analogia matemática não mais se sustenta, e precisamos fazer uma nova análise.

O que significa setHeight(50) ? Quando vemos isso em uma classe Rectangle , todos supomos que a largura permanece a mesma, por isso modifica a proporção da forma. E essa é uma operação que, quando aplicada a um quadrado, resulta em um retângulo não-quadrado. Portanto, um método Square.setHeight() não pode estar em conformidade com o contrato Rectangle -inspired, pois ele deve modificar a classe do objeto de Square para Rectangle (o que é impossível em todas as linguagens de programação que conheço).

Portanto, ter uma classe Square mutável herdada de Rectangle viola Liskov.

A fonte do mal aqui é a mutabilidade. Se setHeight() retornasse uma nova instância em vez de alterar o estado da instância original, poderíamos resolver a situação. Em seguida, chamar setHeight() em um Square poderia retornar facilmente um Rectangle com as novas dimensões. Pode até mesmo herdar a implementação de Rectangle .

    
por 05.11.2017 / 20:19
fonte
1

Já existe uma resposta aceita, mas eu queria apontar algumas coisas.

Os objetos devem ter um comportamento que eles executam. Caso contrário, há poucas razões para usar um sobre uma estrutura. Seus objetos de exemplo não têm comportamento algum, apenas os dados que você tornou mais complicado para acessar via getters e setters. Seu exemplo viola Liskov principalmente porque não é orientado a objetos de qualquer maneira. É processualmente lendo e escrevendo dados com uma estrutura específica.

Também quero mencionar que "Objetos Nulos" não significam null ponteiros. Eles significam uma implementação da classe com um comportamento padrão. Veja este vídeo .

Então, como poderia ser o LSP?

Se eu pensar em Shape2D em termos de qual comportamento eu quero fazer para mim, uma coisa que vem à mente é o cálculo de área. Porque isso será diferente nas formas.

public class Shape2D
{
    public virtual double GetArea() { return 0.0; }
}

Aqui, estou usando o Shape2D como seu próprio Objeto Nulo - uma forma que não tem área. Pode ser estendido pela subclasse e sobrescrevendo GetArea() .

Returning double as the area is a questionable choice. Because there are other implications with area, like the unit of measure or that it can be infinite. Maybe an Area class should be created too if this were production code.

Então, vamos criar algumas implementações de coisas de área bidimensional.

public class Square : Shape2D
{
    private double _sideLength;
    public Square(double sideLength) { _sideLength = sideLength; }
    public override double GetArea()
        { return _sideLength ^ 2 }
}

public class Rectangle : Shape2D
{
    private double _length;
    private double _height;
    public Rectangle(double length, double height) { ... }
    public override double GetArea()
        { return _length * _height; }
}

public class Circle : Shape2D
{
    private double _radius;
    public Circle(double radius) { _radius = radius; }
    public override double GetArea()
        { return Math.Pi * (_radius ^ 2); }
}

Agora, neste ponto, você pode estar se perguntando. "Por que eu não apenas calculo a área no construtor?" ou "Por que não apenas criar um método estático para diferentes cálculos de área?" Bem, e quanto a formas 2D complexas? Como um octógono côncavo definido por vetores? Talvez a implementação particular da forma seja computacionalmente cara para calcular a área? Eu acho que, em última análise, requer alguma intuição para determinar qual caminho está correto. Mas no exemplo aqui, estamos calculando apenas quando solicitado.

Na pergunta original, você alterou a largura do retângulo alterando dados particulares sobre o retângulo. Mas aqui, você criaria um new Rectangle com dimensões diferentes.

Portanto, isso segue o LSP, pois para qualquer método que tenha um Shape2D , você poderia passar um Square , Shape2D (Null Object) ou qualquer forma que pudesse ser implementada no futuro como ConcaveOctagon sem o método sabendo disso.

    
por 12.09.2017 / 22:54
fonte
1

O "truque" do problema clássico do Square-Rectangle LSP é que se definirmos um retângulo como um objeto que deve satisfazer o contrato ...

rectangle.setWidth(40);
rectangle.setHeight(50);
assert(rectangle.getWidth() == 40);

... então estenda para um quadrado, depois violamos esse contrato. Isso ocorre porque estamos misturando uma definição personalizada de um retângulo e uma definição clássica. Como tal, estamos tentando fazer a definição clássica se encaixar em uma definição personalizada, o que não funciona. Em essência, é um truque de salão destinado a fazer você pensar sobre o comportamento do sub-tipo e as obrigações contratuais. Poderíamos facilmente mostrar que a afirmação acima de um objeto Rectangle falha mesmo para um retângulo com alguma reformulação astuta:

rectangle.setTopEdgeLength(40);
rectangle.setBottomEdgeLength(50);
assert(rectangle.getTopEdgeLength() == 40);

De qualquer forma, o suficiente dos enigmas, vamos dar uma olhada no ...

Definição clássica

Corretamente, quadrados e retângulos são ambos tipos de quadriláteros, sendo um quadrilátero um polígono com 4 arestas e 4 vértices. Classicamente, um quadrado é um retângulo, mas vamos dar uma olhada nas definições da Wikipédia de um retângulo e um quadrado:

  • Retângulo : um quadrilátero com quatro ângulos retos
  • Quadrado com quatro ângulos retos e quatro lados iguais

Observe algo lá? As únicas semelhanças entre um retângulo e um quadrado são que ambos são quadriláteros e ambos têm quatro ângulos retos. Nenhuma menção a todas as regras em torno do comprimento dos lados, como estes são derivados do contrato, não parte do contrato. Podemos demonstrar isso com algumas declarações lógicas simples:

  • se uma forma for um quadrilátero e a forma tiver quatro ângulos retos, então os lados opostos terão o mesmo comprimento
  • se uma forma é quadrilateral e a forma tem quatro ângulos retos e quatro lados iguais, então o comprimento de qualquer lado será igual ao comprimento de qualquer outro lado (este é na verdade uma tautologia)

Se quisermos manter a definição clássica de retângulos e quadrados e representá-los em código com os métodos setWidth() e setHeight() em vigor, o contrato precisa ser:

square.setWidth(40);
square.setHeight(50);
assert(square.isQuadrilateral());
assert(square.hasFourRightAngles());

Se a largura ainda é o que definimos na linha 1 é irrelevante, uma vez que essa regra não faz parte das obrigações contratuais do objeto Rectangle.

Resumindo, o problema inicial é uma das definições contratuais. Eu não estou dizendo que você não deve ter um objeto Rectangle que satisfaça rectangle.setWidth(40); rectangle.setHeight(50); assert(rectangle.getWidth() == 40); , mas se você definir seu objeto Rectangle dessa forma, quando você estendê-lo você precisa garantir que o contrato ainda é válido, caso contrário você violará o LSP .

    
por 13.09.2017 / 03:55
fonte
0

Acho que há duas armadilhas neste exemplo.

O primeiro já foi apontado nos comentários: a herança baseada na estrutura interna é usada, e não deveria ser : quebra o encapsulamento do objeto, é frágil, é processual, afinal.

Então a solução parece usar subtipagem, assim:

interface IRectangle
{
    public function setWidth($width);

    public function setHeight($height);
}

class Rectangle implements IRectangle
{
    private $width;
    private $height;

    public function setWidth($width)
    {
        $this->width = $width;
    }

    public function setHeight($height)
    {
        $this->height = $height;
    }
}

class Square implements IRectange
{
    private $side;

    public function setWidth($width)
    {
        $this->side = $width;
    }

    public function setHeight($height)
    {
        $this->side = $height;
    }
}

Mas há a mesma violação do contrato.

Então aqui está a segunda armadilha. Neste contexto , caracterizado pelas responsabilidades dos objetos, deve haver duas abstrações diferentes, representadas por duas interfaces diferentes: IRectangle e ISquare .

Assim, parece que esse exemplo notório é apenas um quebra-cabeça lógico, aquele em que há algum erro lógico, mas ninguém sabe onde está. Quais são os racionais por trás dizendo "quadrado é um caso especial de um retângulo"? Em que contexto é um caso especial? Qual é o caso de uso exato quando um quadrado age como um retângulo? Provavelmente há alguns, mas definitivamente não é aquele onde a largura do retângulo é alterada enquanto a altura permanece a mesma. Então, primeiro de tudo, esse exemplo estúpido viola o bom senso, depois a OOP, e só depois disso ele viola o LSP.

    
por 05.11.2017 / 16:52
fonte