DEV Community

Cover image for Aplicando o "Liskov Substitution Principle" com Typescript e Java
Victor Lima Reboredo
Victor Lima Reboredo

Posted on

Aplicando o "Liskov Substitution Principle" com Typescript e Java

Conceitos

Interfaces

Uma interface define um contrato ou um conjunto de métodos e propriedades que uma classe deve implementar. Interfaces são usadas para garantir que uma classe siga um determinado formato, mas não fornecem implementação de métodos, apenas suas assinaturas.

Sempre que uma classe implementa uma interface, ela assina todos os contratos (métodos e atributos) da interface. Cada atributo e método é obrigatoriamente implementado.

SOLID

SOLID é um acrônimo que representa cinco princípios fundamentais da programação orientada a objetos, propostos por Robert C. Martin - o uncle Bob. Aqui você pode ler mais sobre o artigo dele.
Esses princípios têm como objetivo melhorar a estrutura e a manutenção do código, tornando-o mais flexível, escalável e fácil de entender. Tais princípios auxiliam o programador a criar códigos mais organizados, dividindo responsabilidades, reduzindo dependências, simplificando o processo de refatoração e promovendo a reutilização do código.

Sobre o LSP

O "L" do acrônimo significa "Liskov Substitution Principle". A frase que o uncle bob utilizou para definir esse princípio foi:

"Classes derivadas devem ser capazes de substituir totalmente as classes-bases"

Sugere-se portanto que a classe derivada deve estar o mais próximo possível da classe-base, de modo que a classe derivada possa substituir sua classe-base sem qualquer modificação no código.

Este princípio foi introduzido por Barbara Liskov em 1988, com base na teoria de abstração de dados e tipos. Derivado do conceito de Design by Contracts (DBC), popularizado por Bertrand Meyer em 1986.

Outra especificação desse princípio é:

O subtipo deve ser utilizado como seu tipo base sem nenhuma surpresa.

Na programação, mudanças e surpresas podem causar problemas. Se uma funcionalidade do sistema precisar ser substituída, a nova deve fornecer o mesmo tipo de informação, ou o sistema pode falhar. Para garantir que a classe S tenha o mesmo comportamento que a classe base T, é essencial usar um contrato (interface ou classe abstrata) que defina os métodos obrigatórios para implementação da funcionalidade nova, de modo a garantir a integridade da similaridade entre a classe S e classe T.

Aplicação prática

Considere uma classe base Bird com um método voar() que será usado em duas classes-filhas: Sparrow e Ostrich.

Arquivo: bird.java

class Bird {
    void fly() {
        System.out.println("I can fly!");
    }
}

class Sparrow extends Bird {
    // Herda o comportamento de 'fly' da classe 'Bird'
}

class Ostrich extends Bird {
    @Override
    void fly() {
        throw new UnsupportedOperationException("I cannot fly");
    }
}
Enter fullscreen mode Exit fullscreen mode

Arquivo: bird.ts

class Bird {
  fly() {
    console.log("I can fly!");
  }
}

class Sparrow extends Bird {}

class Ostrich extends Bird {
  fly() {
    throw new Error("I cannot fly");
  }
}
Enter fullscreen mode Exit fullscreen mode

Problemas encontrados

Aqui, a classe Sparrow adere ao LSP porque pardais de fato podem voar. No entanto, a classe Ostrich viola o LSP porque sobrescreve o método voar() de uma forma que altera fundamentalmente seu comportamento, quebrando as expectativas definidas pela classe Bird.

Como corrigir?

Precisaremos aplicar o LSP dividindo cada especificidade das classes Sparrow e Ostrich em contratos (interfaces ou classes abstratas, aqui usarei interfaces) que eles devem assinar para modular os comportamentos de cada:

Arquivo: bird.java

interface Bird {
    String getName();
    void makeSound();
}

interface FlyingBird extends Bird {
    void fly();
}

class Sparrow implements FlyingBird {
    private String name;

    public Sparrow(String name) {
        this.name = name;
    }

    @Override
    public String getName() {
        return this.name;
    }

    @Override
    public void makeSound() {
        System.out.println("Chirp chirp!");
    }

    @Override
    public void fly() {
        System.out.println(this.name + " is flying!");
    }
}

class Ostrich implements Bird {
    private String name;

    public Ostrich(String name) {
        this.name = name;
    }

    @Override
    public String getName() {
        return this.name;
    }

    @Override
    public void makeSound() {
        System.out.println("Boom boom!");
    }
}

public class Main {
    public static void main(String[] args) {
        Sparrow sparrow = new Sparrow("Little Sparrow");
        sparrow.makeSound(); // Chirp chirp!
        sparrow.fly(); // Little Sparrow is flying!

        Ostrich ostrich = new Ostrich("Ostrich");
        ostrich.makeSound(); // Boom boom!
        ostrich.fly(); // Error: Method 'fly' does not exist on 'Ostrich'
    }
}
Enter fullscreen mode Exit fullscreen mode

Arquivo: bird.ts

interface Bird {
  name: string;
  makeSound(): void;
}

interface FlyingBird extends Bird {
  fly(): void;
}

class Sparrow implements FlyingBird {
  name: string;

  constructor(name: string) {
    this.name = name;
  }

  makeSound() {
    console.log("Chirp chirp!");
  }

  fly() {
    console.log(`${this.name} is flying!`);
  }
}

class Ostrich implements Bird {
  name: string;

  constructor(name: string) {
    this.name = name;
  }

  makeSound() {
    console.log("Boom boom!");
  }
}

const sparrow = new Sparrow("Little Sparrow");
sparrow.makeSound(); // Chirp chirp!
sparrow.fly(); // Little Sparrow is flying!

const ostrich = new Ostrich("Ostrich");
ostrich.makeSound(); // Boom boom!
ostrich.fly(); // Error: Method 'fly' does not exist on 'Ostrich'
Enter fullscreen mode Exit fullscreen mode

Análise

Explicação da Correção
Interface Bird: Define comportamentos comuns a todas as aves, como makeSound(). Todas as aves devem implementar essa interface.

Interface FlyingBird: Herda de Bird e adiciona o comportamento fly(), que é específico para aves que podem voar.

Classe Sparrow: Implementa a interface FlyingBird, já que pardais podem voar. Esta classe define o comportamento tanto de emitir som quanto de voar.

Classe Ostrich: Implementa apenas a interface Bird, pois avestruzes não podem voar. Essa classe não possui o método fly() e, portanto, não viola o LSP.

Conclusão

O LSP é crucial para garantir que o código seja modular, reutilizável e fácil de manter. Violações do LSP podem levar a código frágil que quebra quando novas subclasses são introduzidas ou quando subclasses existentes são modificadas, pois isso pode levar a comportamentos inesperados em partes do código que dependem da superclasse.

A substituição de subtipos permite que um módulo seja estendido sem modificação, essencial para a flexibilidade proporcionada pelo Princípio Aberto/Fechado (OCP), viabilizada pelo Princípio da Substituição de Liskov. Contratos (implementados através de interfaces ou classes abstratas) são cruciais para um design seguro, mas devem ser bem compreendidos pelos programadores, ajudando a evitar erros comuns em softwares legados. Eles também fornecem orientações valiosas sobre como implementar e utilizar o código, bastando observar o contrato em questão.

Implicações Práticas

  1. Ao projetar subclasses, certifique-se de que elas possam ser usadas onde quer que sua superclasse seja usada, sem introduzir bugs ou exigir tratamentos especiais.
  2. Evite criar subclasses que violem o comportamento esperado da superclasse, pois isso pode levar a problemas de manutenção e bugs inesperados.

Compreender e aplicar o Princípio da Substituição de Liskov ajuda os desenvolvedores a criar sistemas orientados a objetos mais previsíveis e estáveis.

Top comments (0)