Entendendo SOLID de uma vez por todas | Parte 03 - (LSP)
Rafael Honório

Rafael Honório @rafahs

About: Senior Software Engineer, currently I'm working with Elixir and Golang.

Location:
Brazil
Joined:
Aug 8, 2021

Entendendo SOLID de uma vez por todas | Parte 03 - (LSP)

Publish Date: Jul 21
2 0

Motivação

Fala pessoal de boas? esse é o terceito texto da série que estou compartilhando minha experiência com SOLID. neste artigo, vamos entender um pouco melhor Liskov Substitution Principle (LSP) e ver alguns exemplos.

Talvez esse seja o conceito mais difícil de entender, mas vou tentar exemplificar da forma mais simples possível para não deixar dúvidas de quanto a aplicação.


Breve resumo

Esse conceito foi cunhado pela Barbara Liskov e Jeannette Wing em 94 em um artigo chamado "A Behavioral Notion of Subtyping" e a definição diz:

Subtipos devem poder ser usados no lugar de seus tipos base, sem alterar a correção do programa.

Em tese, você deve conseguir substituir um tipo base pelo subtipo dado que, o subtipo contêm todas as caracteristicas do tipo base, e isso não deve quebrar o fluxo atual.


Exemplo

Imagine a seguinte situção, existem duas classes, uma classe pai que tem propriedades e metódos, a classe filha implementa a classe pai, se caso a gente fosse substituir a classe pai pela filha em alguma implementacão, nosso código não deveria quebrar, segue o exemplo em imagem:

No exemplo mostra uma função que salva essas informações e em tese, a função funcionaria por que a substituição dos tipos não influenciaria no comportamento interno visto que respeita o contrato se considerar a herança ou interface.

O LSP exige que objetos da classe filha possam substituir objetos da classe pai sem alterar o comportamento correto do programa. Vamos verificar se isso é válido no exemplo:

  • Contrato da Classe Pai:
    • A classe pai define as propriedades xpto_01 e xpto_02. A função save_pai() depende desses métodos ou atributos para funcionar corretamente.
  • Comportamento da Classe Filha:
    • A classe filha herda xpto_01 e xpto_02 e adiciona xpto_03. Se xpto_03 não for chamado pela função save_pai() (ou seja, a função só usa xpto_01 e xpto_02), a substituição da classe pai pela filha não deve causar problemas, desde que a implementação de xpto_01 e xpto_02 na filha seja consistente com a do pai.
  • Substituição na Função:
    • Se save_pai() for projetada para trabalhar apenas com os atributos ou métodos da classe pai (xpto_01 e xpto_02), e a classe filha os implementa da mesma forma, a substituição funciona. A adição de xpto_03 não afeta o contrato, pois a função não depende dele.

LSP na prática

Vamos seguir para um exemplo em Golang seguindo a mesma linha de raciocinio:

Neste exemplo, temos umas interface Salvavel que define um contrato para salvar dados. Umas struct Usuario (equivalente à classe pai) e uma struct UsuarioPremium (equivalente à classe filha) implementam essa interface, adicionando uma propriedade extra na filha.

package main

import "fmt"

// Interface Salvavel define o contrato base
type Salvavel interface {
    Salvar() string
}

type Usuario struct {
    Nome string
    Idade int
}

// Usuario implementa a interface Salvavel
func (u *Usuario) Salvar() string {
    return fmt.Sprintf("Salvando usuario: Nome=%s, Idade=%d", u.Nome, u.Idade)
}

// UsuarioPremium estende o comportamento do Usuario
type UsuarioPremium struct {
    Usuario
    NivelPremium int
}

func (up *UsuarioPremium) Salvar() string {
    // Mantém o contrato da interface, adicionando informações extras
    return fmt.Sprintf("Salvando usuario premium: Nome=%s, Idade=%d, NivelPremium=%d", up.Nome, up.Idade, up.NivelPremium)
}

// Função que usa a interface Salvavel (equivalente à "save_pai")
func ProcessarSalvar(s Salvavel) {
    resultado := s.Salvar()
    fmt.Println(resultado)
}

func main() {
    // Instâncias
    usuario := &Usuario{Nome: "João", Idade: 30}
    usuarioPremium := &UsuarioPremium{Usuario: Usuario{Nome: "Maria", Idade: 25}, NivelPremium: 3}

    // Testando substituição
    fmt.Println("Processando Usuario:")
    ProcessarSalvar(usuario) // Funciona como esperado

    fmt.Println("Processando UsuarioPremium:")
    ProcessarSalvar(usuarioPremium) // Também funciona, respeitando o LSP
}
Enter fullscreen mode Exit fullscreen mode

saída esperada:

Processando Usuario:
Salvando usuario: Nome=João, Idade=30
Processando UsuarioPremium:
Salvando usuario premium: Nome=Maria, Idade=25, NivelPremium=3
Enter fullscreen mode Exit fullscreen mode

e se caso a gente violasse o LSP?

package main

import "fmt"

// Interface Salvavel define o contrato base
type Salvavel interface {
    Salvar() string
}

type Usuario struct {
    Nome string
    Idade int
}

// Usuario implementa a interface Salvavel
func (u *Usuario) Salvar() string {
    return fmt.Sprintf("Salvando usuario: Nome=%s, Idade=%d", u.Nome, u.Idade)
}

// UsuarioPremium com restrição
type UsuarioPremium struct {
    Usuario
    NivelPremium int
}

func (up *UsuarioPremium) Salvar() string {
    // Restrição: só salva se NivelPremium > 0
    if up.NivelPremium <= 0 {
        return "Erro: NivelPremium inválido"
    }
    return fmt.Sprintf("Salvando usuario premium: Nome=%s, Idade=%d, NivelPremium=%d", up.Nome, up.Idade, up.NivelPremium)
}

// Função que usa a interface Salvavel
func ProcessarSalvar(s Salvavel) {
    resultado := s.Salvar()
    fmt.Println(resultado)
}

func main() {
    usuario := &Usuario{Nome: "João", Idade: 30}
    usuarioPremium := &UsuarioPremium{Usuario: Usuario{Nome: "Maria", Idade: 25}, NivelPremium: 0}

    fmt.Println("Processando Usuario:")
    ProcessarSalvar(usuario) // Funciona: "Salvando usuario: Nome=João, Idade=30"

    fmt.Println("Processando UsuarioPremium:")
    ProcessarSalvar(usuarioPremium) // Quebra o comportamento esperado: "Erro: NivelPremium inválido"
}
Enter fullscreen mode Exit fullscreen mode

saída esperada:

Processando Usuario:
Salvando usuario: Nome=João, Idade=30
Processando UsuarioPremium:
Erro: NivelPremium inválido
Enter fullscreen mode Exit fullscreen mode

Nesse caso acontece algumas situações não tão legais:

  • A classe UsuarioPremium adiciona uma restrição (NivelPremium > 0) que não existe na interface Salvavel nem em um Usuario.
  • A função ProcessarSalvar espera que Salvar() sempre retorne uma string de sucesso (com dados salvos), mas UsuarioPremium pode retornar um erro, quebrando o contrato implícito.
  • Isso viola o LSP porque a substituição de Salvavel por UsuarioPremium altera o comportamento esperado, introduzindo uma pré-condição que a interface não define.

Conclusão

No geral, esse princípio nos faz refletir sobre como os módulos estão implementando comportamentos, como métodos, callbacks e behaviors. Se for preciso "forçar" alguma implementação apenas para cumprir um contrato, alterar o retorno do contrato no subtipo, ou se a substituição de tipos acaba quebrando o funcionamento do sistema, é um sinal claro de que o LSP está sendo violado.

No mais é isso, qualquer dúvida deixe nos comentários, nos vemos na próxima!

Comments 0 total

    Add comment