Pular para o conteúdo
← Voltar para o Skalablog

Artigo publicado

Guia do Princípio de Substituição de Liskov para POO

Engenharia de SoftwareNotion

O princípio de substituição de Liskov determina que objetos de uma superclasse devem poder ser trocados por objetos de uma subclasse sem alterar o comportamento esperado do programa. A regra vale para herança e para interfaces. Quando a subclasse muda a semântica de um método, a quebra aparece em tempo de execução, não na compilação.

O que é o princípio de substituição de Liskov?

O princípio de substituição de Liskov diz que objetos de uma superclasse devem poder ser substituídos por objetos de uma subclasse sem alterar o comportamento esperado do programa. É a letra L do acrônimo SOLID, um conjunto de cinco princípios de design orientado a objetos. A formulação original vem da cientista da computação Barbara Liskov, que descreveu a ideia em termos de subtipos: se S é um subtipo de T, objetos do tipo T podem ser trocados por objetos do tipo S sem quebrar as propriedades desejáveis do programa.

Subtipo aqui cobre os dois caminhos do polimorfismo: herança de uma superclasse e implementação de uma interface. Toda vez que uma classe está hierarquicamente abaixo de outra, herdando ou implementando, o princípio se aplica.

A parte crítica é o trecho final: sem alterar o comportamento esperado. Compiladores modernos já forçam a mesma assinatura, o mesmo número e ordem de parâmetros e a mesma estrutura de retorno. Isso resolveu o problema estrutural de décadas passadas, quando sobrescrever um método podia mudar parâmetros livremente. O princípio continua válido porque trata de outra camada: o comportamento, que o compilador não verifica.

Quem é Barbara Liskov e por que ela dá nome ao princípio?

Barbara Liskov é uma das cientistas da computação mais reconhecidas da área, professora do MIT e referência em técnicas de orientação a objetos. A ideia foi apresentada pela primeira vez em 1993, numa conferência, e formalizada por ela com Jeannette Wing em 1994, no artigo "A Behavioral Notion of Subtyping". Robert C. Martin, o Uncle Bob, popularizou o princípio na comunidade de software ao integrá-lo ao SOLID. A definição canônica do princípio preserva exatamente essa relação entre subtipo e substituibilidade.

Na época da formulação, linguagens e compiladores tinham capacidades bem menores que as de hoje. Uma superclasse definia a assinatura, mas quem sobrescrevia podia alterar ordem de parâmetros ou estrutura de retorno. O princípio nasceu para estabelecer uma padronização mínima de contrato. As linguagens depois absorveram a parte sintática; a parte semântica, de comportamento, continua responsabilidade do designer do código.

Buscar a trajetória dela vale a pena: Liskov contribuiu para abstrações de dados, tolerância a falhas e linguagens como a CLU, e continua formando engenheiros no MIT.

Como o exemplo do retângulo e do quadrado viola o LSP?

O exemplo clássico usa um software de desenho, algo como o Microsoft Paint, com duas classes: Rectangle, com largura e altura, e Square. Por uma decisão de design didática, o quadrado é implementado como subclasse do retângulo. Visualmente faz sentido: um quadrado é um retângulo com todos os lados iguais. Em código, a decisão cria conflito.

O retângulo permite mudar largura e altura de forma independente. O quadrado não: para continuar sendo quadrado, alterar a largura precisa alterar a altura também, e vice-versa. A subclasse então sobrescreve os setters e cria um acoplamento entre propriedades que a superclasse nunca prometeu.

ts class Rectangle { constructor(width, height) { this.width = width; this.height = height; } setWidth(w) { this.width = w; } setHeight(h) { this.height = h; } getArea() { return this.width * this.height; } }

class Square extends Rectangle { constructor(side) { super(side, side); } setWidth(w) { this.width = w; this.height = w; } setHeight(h) { this.width = h; this.height = h; } }

Note que o construtor do quadrado recebe um único valor, o lado, enquanto o do retângulo recebe dois. Já é um mau cheiro, um code smell, indicando que a hierarquia merece revisão.

ClasseContrato de mutaçãoCálculo de área
Rectanglelargura e altura mudam de forma independentelargura × altura
Squaremudar um lado muda todos os ladoslado × lado
Shape (proposta)nenhum contrato de mutação, apenas getArea()definido por cada subclasse

As duas primeiras linhas mostram o problema: contratos de mutação incompatíveis não podem compartilhar a mesma hierarquia de subtipo.

Onde a quebra do princípio se materializa: tempo de execução

A violação do LSP aparece quando o programa roda, não na estrutura do código. Diferente do princípio da responsabilidade única e do open/closed, que são problemas visíveis na organização das classes, o LSP se materializa na execução, com valores errados ou erros inesperados.

Considere uma função que recebe qualquer Rectangle, altera a largura para 5, a altura para 4 e calcula a área:

ts function useRectangle(rect: Rectangle) { rect.setWidth(5); rect.setHeight(4); return rect.getArea(); // esperado: 20 }

Com um Rectangle(2, 3), o resultado é 5 × 4 = 20, exatamente o esperado. Com um Square(2), o resultado é 16. Ao alterar a largura, o setter do quadrado mudou a altura junto; ao alterar a altura, mudou a largura de novo. A área guarda sempre o último valor, 4 × 4.

A quebra é exatamente essa: o comportamento garantido pela superclasse não foi preservado na subclasse. E não se trata só de valores errados; o mesmo mecanismo produz exceções inesperadas e mudanças de comportamento que só aparecem com objetos de tipos diferentes em tempo de execução.

O teste do "é um" funciona para validar herança?

O teste do "é um" ajuda, mas só quando a afirmação é 100% verdadeira em todas as situações. Ao aprender orientação a objetos, você provavelmente encontrou a regra: se cachorro é um animal vertebrado, então cachorro herda de animal vertebrado.

Aplicando ao exemplo: um quadrado é sempre um retângulo? Não. Se você precisa mudar a largura de forma independente, o quadrado deixa de ser quadrado. A afirmação não é 100% verdadeira, então a herança não se sustenta. Nesse caso, manter o quadrado como subclasse do retângulo quebra o comportamento esperado da classe base e viola o princípio de substituição de Liskov.

Use o teste como alerta inicial, nunca como prova final. A verificação decisiva é comportamental: a subclasse cumpre todas as promessas da superclasse em qualquer cenário de uso?

Como corrigir a hierarquia sem quebrar o LSP?

A correção proposta no vídeo sobe um nível na abstração: uma classe abstrata Shape, cuja única responsabilidade é representar uma forma geométrica qualquer e declarar o método getArea(). A classe abstrata não define comportamento, apenas o contrato de que cada subclasse retorna um número representando sua área. A refatoração segue quatro passos:

  1. Crie a classe abstrata Shape declarando apenas getArea(): number, sem implementação.
  2. Faça Rectangle estender Shape, mantendo largura e altura independentes.
  3. Faça Square estender Shape diretamente, expondo só setSide(), que garante todos os lados iguais.
  4. Mantenha useRectangle recebendo retângulos e crie useShape para aceitar qualquer subtipo de Shape.

ts abstract class Shape { abstract getArea(): number; }

class Rectangle extends Shape { / setters independentes, getArea() = largura × altura / } class Square extends Shape { / apenas setSide(), getArea() = lado × lado / }

Cada forma agora garante suas próprias regras de existência. O retângulo mantém largura e altura independentes; o quadrado só expõe a mudança de lado, o que respeita sua regra de todos os lados iguais.

Com isso, useRectangle continua recebendo retângulos e funcionando. O quadrado deixa de ser subtipo de retângulo, então o editor, o VS Code no exemplo, acusa que Square não é assignable ao tipo Rectangle. Tentar passar um quadrado para essa função vira erro de compilação, e não bug silencioso.

O quadrado continua utilizável pela nova função useShape, que aceita qualquer subtipo de Shape e chama getArea(). Como o único contrato da superclasse é retornar a área, tanto retângulo quanto quadrado cumprem o esperado: retângulo 5 × 4 = 20, quadrado 2 × 2 = 4. Polimorfismo preservado, princípio respeitado.

A lição de design: quando duas classes compartilham cálculo mas têm regras de mutação incompatíveis, extraia o que é comum para um tipo mais alto e não as force numa hierarquia que não é verdadeira.

Quais sinais práticos indicam violação do princípio de substituição de Liskov?

Você pode caçar violações de LSP procurando padrões concretos no dia a dia. O mais comum é a subclasse que precisa de condicionais para "se encaixar": código que checa if (shape instanceof Square) antes de agir é um sinal claro de que o contrato da superclasse não é universal.

  • Subclasse lança exceção que a superclasse nunca lança, ou retorna tipos diferentes do prometido.
  • Sobrescrita de método ignora ou anula parte do comportamento da classe base, como setters que alteram propriedades extras.
  • Condicionais por tipo espalhadas pelo código para lidar com diferenças entre subclasses.
  • Testes que passam para a superclasse e falham quando o mesmo fluxo roda com a subclasse.
  • Método sobrescrito que precisa ser "desligado" na filha, por exemplo lançando NotImplementedError.

Todos esses sinais apontam para o mesmo problema: o contrato implícito da superclasse é mais estreito do que a hierarquia sugere. Corrigir significa reorganizar a abstração, geralmente extraindo uma base menor e mais honesta, como a classe Shape do exemplo anterior.

Por que o LSP é considerado o princípio mais crítico do SOLID?

O LSP é tratado como o mais crítico porque a violação não aparece na leitura do código. SRP e open/closed são problemas estruturais: você abre a classe e vê a bagunça. O LSP quebra silenciosamente, na execução, com valores errados ou comportamentos inesperados.

Essa característica muda a estratégia de detecção. Revisão de código sozinha raramente pega a quebra; você precisa de testes que exercitem subclasses nos mesmos fluxos da superclasse e de atenção aos sinais da seção anterior. Em linguagens tipadas como TypeScript, o erro de assignable é o primeiro alerta. Em linguagens de tipagem dinâmica, produção costuma ser o primeiro.

FAQ sobre o princípio de substituição de Liskov

O princípio de substituição de Liskov vale só para herança?

Não. Ele vale para qualquer relação de subtipo, incluindo herança de classes e implementação de interfaces. Sempre que um objeto pode aparecer no lugar de outro pelo sistema de tipos, o contrato de comportamento se aplica.

Compiladores modernos já resolvem o LSP automaticamente?

Eles resolvem apenas a parte estrutural: número de parâmetros, ordem e tipo de retorno. A parte de comportamento, como efeitos colaterais e semântica dos métodos, continua sendo responsabilidade do designer do código.

Por que o exemplo do quadrado e do retângulo é usado tanto?

Porque é o exemplo mais clássico, presente nos materiais do Uncle Bob e em praticamente toda literatura sobre o tema. Ele mostra de forma simples que a relação matemática "é um" nem sempre sobrevive às regras de mutação do código.

O LSP se aplica também em linguagens com tipagem dinâmica?

Aplica, e com mais risco. Sem verificação de tipos em compilação, a quebra só aparece em tempo de execução, geralmente em produção, então disciplina de design e testes se tornam ainda mais importantes.

Qual a diferença entre subtipo e subclasse no contexto do LSP?

Subclasse é um mecanismo de linguagem: uma classe que estende outra. Subtipo é uma garantia de comportamento: a filha pode aparecer em qualquer lugar onde a mãe era esperada. Toda subclasse deveria ser um subtipo; quando não é, você tem a violação clássica do LSP.

O que fazer quando já identifiquei uma hierarquia quebrada?

Procure o contrato mínimo comum entre as classes envolvidas e extraia-o para uma base menor, como Shape no exemplo. Depois, corte a herança direta entre as classes com regras de mutação conflitantes e confie no compilador para apontar os pontos de uso que precisam de ajuste.

Do conceito à prática: leve o LSP para o seu código hoje

O que o exemplo do retângulo e do quadrado deixa de lição é simples: herança é contrato de comportamento, e o compilador não fiscaliza contrato, só sintaxe. Para aprofundar nesse e em outros princípios SOLID com TypeScript, materiais como o Crazystack Typescript e o Bootcamp do Dev Doido, do criador Gustavo Dev Doido, percorrem o mesmo caminho de refatorar hierarquias quebradas em abstrações honestas.

Transforme suas aulas sobre LSP em artigos

Se você aprendeu LSP assistindo a um vídeo, provavelmente também ensina ou estuda por esse formato. Aula boa gravada merece texto bom: escrito, o conteúdo vira referência pesquisável em vez de link perdido na playlist.

No Skala Blog, você cola o link de um vídeo do YouTube, o áudio é transcrito e o texto vira um artigo estruturado, pronto para revisar e publicar.

Source video