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
expto_02
. A funçãosave_pai()
depende desses métodos ou atributos para funcionar corretamente.
- A classe pai define as propriedades
-
Comportamento da Classe Filha:
- A classe filha herda
xpto_01
expto_02
e adiciona xpto_03. Se xpto_03 não for chamado pela funçãosave_pai()
(ou seja, a função só usaxpto_01
expto_02
), a substituição da classe pai pela filha não deve causar problemas, desde que a implementação dexpto_01
expto_02
na filha seja consistente com a do pai.
- A classe filha herda
-
Substituição na Função:
- Se
save_pai()
for projetada para trabalhar apenas com os atributos ou métodos da classe pai (xpto_01
expto_02
), e a classe filha os implementa da mesma forma, a substituição funciona. A adição dexpto_03
não afeta o contrato, pois a função não depende dele.
- Se
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
}
saída esperada:
Processando Usuario:
Salvando usuario: Nome=João, Idade=30
Processando UsuarioPremium:
Salvando usuario premium: Nome=Maria, Idade=25, NivelPremium=3
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"
}
saída esperada:
Processando Usuario:
Salvando usuario: Nome=João, Idade=30
Processando UsuarioPremium:
Erro: NivelPremium inválido
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 queSalvar()
sempre retorne uma string de sucesso (com dados salvos), masUsuarioPremium
pode retornar um erro, quebrando o contrato implícito. - Isso viola o LSP porque a substituição de
Salvavel
porUsuarioPremium
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!
- Exemplos em Elixir: solid_elixir_examples
- Exemplos em Go: solid-go-examples