Esse site utiliza cookies
Nós armazenamos dados temporariamente para melhorar a sua experiência de navegação e recomendar conteúdo do seu interesse.
Ao utilizar os nossos serviços, você concorda com as nossas políticas de privacidade.
Esse site utiliza cookies
Nós armazenamos dados temporariamente para melhorar a sua experiência de navegação e recomendar conteúdo do seu interesse.
Ao utilizar os nossos serviços, você concorda com as nossas políticas de privacidade.
Categoria de Programação
Postado em 21 abril 2022
Atualizado em 05 junho 2023
Palavras-chave: arquitetura,liskov,substitution,principle,principio,substituição,programação,programming
Visualizações: 2230
A programação orientada a objetos trouxe uma nova forma de escrever código. Uma das principais vantagens foi a abstração. Com a abstração podemos definir a estrutura de uma classe antecipadamente. A classe que implementa essa abstração é obrigada a obedecer essa estrutura pré-definida.
Além disso, existe a herança. Classes podem estender outras classes já implementadas e realizar o polimorfismo (subtipagem). O polimorfismo permite a alteração de variáveis e métodos da classe parente pela classe derivada. Quando alteramos um método da classe parente, estamos mudando o seu comportamento. Essa alteração de comportamento deve honrar a classe parente. É isso que diz Bertrand Meyer em seu artigo “Design por contrato”. O contrato da classe base deve ser honrada pela classe derivada. O princípio de substituição de Liskov (Liskov Substitution Principle) reafirma esse princípio.
O LSP é o terceiro princípio SOLID abordado na arquitetura limpa. Esse princípio diz que uma classe derivada deve ser substituível pela sua classe base. Isso significa que uma classe que estende outra classe, não deve apresentar comportamentos inesperados.
Através do polimorfismo, é possível alterar o funcionamento dos métodos da classe parente. Alterar os métodos em si, não se torna um problema entre a classe parente e a classe derivada. O problema acontece com a classe que depende desse funcionamento.
O usuário depende do “Cadastro manual”. A classe “Cadastro online” estende o “Cadastro manual” para herdar suas funcionalidades e aplica o polimorfismo em alguns métodos, alterando um pouco o comportamento da classe derivada em relação a classe parente.
Quando o usuário usa a classe derivada, ele espera no mínimo conseguir fazer o cadastro. Além disso, o usuário também espera conseguir fazer o contrato online com menos dificuldade e obter resultados com mais eficiência. “Menos dificuldade” representa uma pré-condição e “obter resultados com mais eficiência” representa uma pós-condição. Uma classe derivada deve honrar a sua classe parente. Quando dizemos que devemos honrar a classe base, nos referimos ao contrato.
O contrato entre a classe base e a classe derivada se chama Design por contrato. Esse conceito foi criado por Bertrand Meyer e diz o seguinte:
Ao redefinir uma rotina em uma classe derivada, você deve apenas repor a sua pré-condição por uma mais fraca e repor a sua pós-condição por uma mais forte
“Redefinir uma rotina” indica o polimorfismo. Ao sobrepor um método de uma classe parente, podemos apenas repor a pré-condição com uma mais fraca e a pós-condição com uma mais forte. As palavras “fraco” e “forte” se referem a restrição dessas condições. A classe que depende da classe parente, não poderá usar a classe derivada com os mesmos argumentos se a classe derivada tiver pré-condições mais fortes que a classe parente. O mesmo acontece com a pós-condição, pois a classe dependente espera um resultado mínimo para operar corretamente.
Quando uma classe derivada não honra o contrato com a classe parente, mecanismos extras devem ser adicionados ao sistema. Adicionar mecanismos extras por violações de princípios só polui o código com condições que além de serem difíceis de localizar, poderiam ter sido evitados.
if (instance instanceof SuperClass) {
instance.setSomeVar(10);
} else {
instance.setSomeAnotherVar(0);
}
O exemplo acima é um mecanismo extra. Isso é um resultado de uma classe derivada que não honrou o contrato com a classe base e consequentemente violou o princípio de substituição de Liskov, pois a classe derivada não pode ser substituída pela sua classe parente.
Geralmente, a violação do princípio de substituição de Liskov acontece devido a intuição do ser humano. Invés de fazer a herança de classes por comportamento, fazemos por semântica. Imagine o seguinte cenário:
No exemplo acima, o gato e o cachorro estendem a classe “Mamífero”. É certo afirmar que os dois são mamíferos, pois é assim na vida real. Intuitivamente, compartilhamos a mesma classe “Mamífero” entre os dois animais. Se esse tipo de extensão é certo ou errado, isso vai depender do contexto desse sistema.
Se a classe “Mamífero” tivesse apenas os métodos Comer() e Dormir(), não teríamos problemas em estende-lo com as classes cachorro e gato. Mas o que aconteceria se a classe “Mamífero” tivesse um método chamado LevarParaPassear()? Nesse caso, o cachorro iria atender as expectativas da sua classe parente honrando o contrato. Porém, o gato provavelmente não honraria o contrato, pois normalmente os gatos não são levados para passear. O gato provavelmente retornaria uma excessão ou um resultado fraco. A classe “Dono” que depende da classe “Mamífero” espera no mínimo que ele possa levar o mamífero para passear. Porém, o gato não atende ao requerimento mínimo do dono. Nessa ocasião, adiciona-se mecanismos extras para evitar comportamentos estranhos.
Dono dono = new Dono();
Mamifero[] mamiferos = new Mamifero[2];
mamiferos[0] = new Cachorro();
mamiferos[1] = new Gato();
for (Mamifero mamifero : mamiferos) {
// Mecanismo extra (ignora se for gato)
if (mamifero instanceof Gato) {
continue;
}
dono.levar(mamifero.passear()):
}
Sabemos que o gato não pode passear, por isso adicionamos uma condição para ignorar o mamífero caso ele seja um gato. Quando isso acontece, quer dizer que o contrato entre a classe “Mamifero” e a classe “Gato” não foi obedecido, violando os princípios de substituição de Liskov.
Em resumo, é certo afirmar que o gato e o cachorro são mamíferos. Porém, o problema acontece quando a classe parente começa a impor comportamentos a suas classes derivadas. As classes derivadas não devem ser obrigadas a adotar comportamentos que elas não suportam.
A melhor forma saber se estamos violando ou não o LSP é substituindo a classe derivada pela sua classe base. Se as duas classes atenderem aos requerimentos mínimos da classe que dependem delas, provavelmente não há violação.
Entretanto, há casos em que classes violam o LSP sem apresentar os sintomas. Quando não há sintomas aparentes de violação de princípio, a classe violadora pode passar despercebida. A violação ao longo prazo pode ser destrutiva, porque os desenvolvedores continuam realizando acoplamento entre a classe violadora com as outras. Quando a violação ao longo prazo é descoberta, ela é difícil de ser desfeita, pois as classes estão projetadas para funcionar umas com as outras. Mudar a classe violadora afetaria todas as suas dependências, exigindo uma modificação em cadeia nas dependências transitivas, demandando muito mais tempo. Por isso, muitos programadores preferem usar mecanismos extras do que consertar as irregularidades causadas pela classe violadora.
Se analisarmos o princípio de modo detalhado, vemos que Liskov propôs requerimentos padrões para realizar o polimorfismo entre a classe parente e a classe derivada (subtipo):
É importante destacar que os requerimentos acima se referem ao tipo do argumento. Se uma classe que herda um método de uma superclasse, sobrepor esse método, ela se torna um subtipo e a superclasse um supertipo. Esses requerimentos já são adotados na maioria das linguagens orientadas a objetos recentes.
No exemplo acima, os subtipos substituem o método getName() do supertipo “Mamifero”. Porém, essa substituição deve atender aos requerimentos padrões definidos acima.
A covariância e a contravariância se referem a restrição do tipo do valor atribuído. Em outras palavras, a covariância se refere a capacidade de usar um tipo mais derivado e a contravariância se refere a capacidade de usar um tipo menos derivado. Um valor com tipo mais derivado pode ser mais específico e o menos derivado deve ser menos específico (mais genérico).
Em outras palavras, a covariância permite um tipo mais específico (O gato e o cachorro são covariantes do mamífero). Por outro lado, contravariância permite um tipo mais genérico (O mamífero é um contravariante do gato e do cachorro).
O exemplo abaixo foi elaborado com base no gráfico acima. O subtipo “Gato” é um subtipo do “Mamífero”.
class Mamifero {
public String getName(int id) {}
}
class Gato extends Mamifero {
public String getName(int id) {}
}
No algoritmo abaixo, o valor passado ao método gerenciar() é aceito porque o parâmetro definido (Mamifero) é uma contravariante (genérico) do valor de tipo “Gato”.
class GerenciadorDeGatos {
public void gerenciar(Mamifero mamifero) { //... }
public void main() {
Gato gato = new Gato();
gerenciar(gato); // OK
}
}
Se fosse ao contrário, provavelmente teríamos um erro de compilação:
class GerenciadorDeMamiferos {
public void gerenciar(Gato gato) { //... }
public void main() {
Mamifero mamifero = new Mamifero();
gerenciar(mamifero); // ERROR
}
}
No exemplo acima, é retornado um erro porque o tipo “Mamifero” é uma contravariante do tipo “Gato”. Não podemos passar um valor genérico para uma função que espera um valor específico. Quando Liskov diz que o tipo do parâmetro de um método de um subtipo deve ser contravariante, ela diz que a classe derivada não pode fazer o polimorfismo de um método usando parâmetros mais específicos do que os da superclasse. O exemplo acima lança uma excessão (erro) porque há uma violação do princípio.
Um método de um subtipo deve retornar um valor covariante. Isso quer dizer que o valor retornado deve ser igual ou mais específico do que o supertipo.
class Mamifero {
public static Mamifero criar() {
return new Mamifero();
}
}
class Gato extends Mamifero {
public static Mamifero criar() {
return new Gato();
}
}
Perceba que o método criar() da classe “Gato” retorna um tipo mais específico do que o tipo mamífero.
class Main {
public static void main(String[] args) {
Mamifero mamifero = Gato.criar(); // OK
}
}
Isso é possível porque o gato é um covariante do supertipo “Mamifero”. Logo, podemos usar essa prática normalmente na maioria das linguagens orientadas a objetos.
Quando precisamos lançar excessões no método do subtipo, devemos lançar apenas excessões que estendem a excessão do supertipo.
class CustomNegativeException : NegativeException {
public CustomNegativeException(String error) { //... }
}
class Mamifero {
public String getName(int id) {
if (id < 0) {
throw new NegativeException("ID nao pode ser negativo");
}
...
}
}
class Gato extends Mamifero {
public String getName(int id) {
if (id < 0) {
throw new CustomNegativeException("ID do subtipo tambem nao pode ser negativo");
}
...
}
}
No exemplo acima, CustomNegativeException é um subtipo de NegativeException. Logo, ele pode ser lançado em um subtipo como o Gato.
Esses três requerimentos já são padrões em muitas linguagens, por isso quando violamos esses requerimentos, obtemos erros ao execução do código.
Porém, ainda há mais três regras para proteger a superclasse das suas derivadas:
Robert Martin postou em seu artigo “Princípios de Design e Padrões de Projeto” sobre os itens 1 e 2 da lista acima. Ele conclui que os métodos derivados não devem esperar mais e não fornecer menos. “Esperar” se refere a pré-condição e “Fornecer” se refere a pós-condição.
Diferente dos três primeiros requerimentos, essas três regras não se referem ao tipo de uma classe, mas sim, ao valor dela. As pré-condições são realizadas antes da execução do método e as pós-condições são realizadas após a execução do método. Quando uma das duas condições não for satisfeita, excessões devem ser lançadas.
Quando dizemos que uma condição deve ser mais forte ou mais fraca, estamos nos referindo a sua restrição.
class Mamifero {
public String getName(int id) {
if (id < 0 || id > 9999) {
throw new Exception("ID invalido");
}
...
}
}
A pré-condição do mamífero é que o parâmetro “id” seja maior do que zero. Caso contrário, uma excessão é lançada. Uma classe derivada da classe “Mamifero” não deve ter uma pré-condição mais forte do que a classe base, pois a classe que depende da classe tipo “Mamifero” não espera que uma classe derivada espere mais, pois isso seria uma violação do princípio de Liskov.
class Gato extends Mamifero {
public String getName(int id) {
if (id < 0 && id > 100) {
throw new NegativeException("ID nao pode ser negativo e nem maior que cem");
}
...
}
}
O polimorfismo acima é uma clara violação de LSP, pois possui uma pré-condição mais forte do que a sua classe base. A condição acima é mais restrita (portanto mais forte) do que a da superclasse. Essa prática impossibilitaria a classe base de substituir a classe derivada quando o parâmetro “id” fosse maior do que cem. Invés de fortalecer a pré-condição, poderíamos enfraquecer ela.
class Gato extends Mamifero {
public String getName(int id) {
if (id < 0) {
throw new CustomNegativeException("ID deve maior que zero");
}
...
}
}
Quanto mais restrita uma condição for, mais forte ela é.
A pós-condição é realizada após a execução do método, geralmente, logo antes de retornar o resultado. A pós-condição do método da classe derivada não deve ser menos restrito (menos fraco) do que a classe base.
class Mamifero {
public String getName(int id) {
...
if (name.length() > 12 && !name.isEmpty()) {
throw new Exception("Nome e invalido");
}
return name;
}
}
class Gato extends Mamifero {
public String getName(int id) {
...
if (name.length() > 12) {
throw new Exception("Nome nao deve ter mais do que 12 caracteres");
}
return name;
}
}
A classe “Gato” sobrepõe a pós-condição do método da classe “Mamifero” enfraquecendo-a. Isso é uma violação de LSP. Pós-condições em classes derivadas só podem ser fortalecidas. Veja abaixo um exemplo de fortalecimento que poderia ser feito na pós-condição:
class Gato extends Mamifero {
public String getName(int id) {
...
if (name.length() > 12 && !name.isEmpty() && name.length() < 4) {
throw new Exception("Nome invalido");
}
return name;
}
}
Mais uma pequena restrição foi adicionada na pós-condição da classe derivada, tornando-a mais forte.
Uma invariante não é nem uma covariante e nem uma contravariante. Uma invariante é uma propriedade que deve ser preservada na mesma condição que ela foi deixada no supertipo, por isso um subtipo não pode alterar o seu valor.
Uma invariante é uma variável (mais especificamente uma propriedade) que contém um valor importante para que uma classe funcione corretamente. Ao alterar esse valor, a classe deixa de operar corretamente, apresentando valores ou comportamentos inesperados.
Um grande exemplo é quando estendemos uma classe derivada chamada “Quadrado” da superclasse “Retângulo”. Pelo fato do quadrado ser um retângulo, intuitivamente fazemos a herança entre essas duas classes. O retângulo possui altura e comprimento. Porém, o quadrado não precisa de duas medidas, sendo que a sua altura é igual ao seu comprimento. Sendo assim, alteramos a altura ou o comprimento da classe “Retangulo” na classe derivada “Quadrado” para tornar todos os lados do quadrado iguais, comprometendo uma invariável importante para o funcionamento do retângulo. Essa ocasião também é uma violação de LSP, pois não podemos substituir mais a classe derivada pela sua classe base sem obter comportamentos inesperados.
O princípio de substituição de Liskov é o terceiro princípio dos princípios SOLID. Esse princípio protege o funcionamento da classe base impondo regras de polimorfismo. Esse princípio aborda sobre como o tipo e o valor da classe derivada devem ser aplicados.
Projetos práticos
Jogo simples de guerra espacial desenvolvido em javascript. Esse jogo usa cálculos de física para simular efeitos de atrito e inércia.
Programando um jogo clássico de arcade usando javascript e p5.js. O usuário deve quebrar os blocos utilizando uma bola ao mesmo tempo que evita que a bola saia pela parte inferior da tela
Desenvolvimento dos conceitos mais básicos do clássico pacman, como: mapa, animação, deslocamento e detector de colisões.
Projeto de comunicação entre dois dispositivos ESP8266 e Raspberrypi4. Laravel irá funcionar como servidor e receptor de dados de temperatura e umidade coletados com o DHT11.
Programando o clássico jogo da serpente usando o framework p5.js. Tutorial indicado para iniciantes da programação que querem aprender os conceitos básico da área criando jogos.
Já não é mais novidade saber que os robôs não precisam mais da orientação de um humano para aprender. Além disso, os robôs já superam os humanos em muitas áreas...
As criptomoedas mudaram totalmente o modo das pessoas pensarem. Usar robôs para autentificar transações online, custa muito menos comparado com os bancos em relação às taxas...
O algoritmo é um conjunto de instruções escritas por um programador com intuito de solucionar um problema ou obter um resultado previsto.
Pilha e fila são tipos de estrutura de dados que contribuem para um gerenciamento de dados mais inteligente e eficaz na programação
Eficiente quando aplicada em softwares de grande porte que necessitam de manutenção ao longo prazo. Criada em 2012 por Robert Martin.
Ao ser aplicado permite que os detalhes passem a depender de abstrações, respeitando a direção da regra de dependências.