Hoje vamos falar sobre o Liskov Substitution Principle. Para ver os artigos anteriores da série acesse:
- Princípios SOLID em GoLang - Single Responsability Principle (SRP)
- Princípios SOLID em GoLang - Open/Closed Principle (OCP)
Dentre os princípios SOLID, o LSP é o que tem a definição formal mais complicada. Por outro lado, é o de mais simples execução, porque o conceito é intuitivo e você acaba aplicando sem muito esforço cognitivo na maioria das vezes.
O princípio foi definido por Barbara Liskov da seguinte forma:
"Se q(x) é uma propriedade demonstrável dos objetos x de tipo T. Então q(y) deve ser verdadeiro para objetos y de tipo S onde S é um subtipo de T."
Complicado, né?
Mas, no fim das contas, essa definição matemática pode ser traduzida na seguinte sentença:
"Se um ObjetoX é uma instância da ClasseX, e um ObjetoY é uma instância da ClasseY, e a ClasseY herda da ClasseX— se usarmos ObjetoY em vez de ObjetoX em algum lugar do código, a funcionalidade não deve ser interrompida."
Como podemos ver, esse é um princípio que parece estar diretamente ligado aos conceitos de classe e herança, e nenhum dos dois está presente no Go. Então como podemos aplicar?
Já vi alguns artigos explicando o LSP em GoLang utilizando os recursos de composição (embedding) da linguagem, mas, do meu ponto de vista, não é uma boa abordagem, pois a composição não permite substituir a estrutura pai pela estrutura filha. Observem:
type Employee struct { | |
Name string | |
Position string | |
Salary float64 | |
} | |
func (e *Employee) CalculateBonus() float64 { | |
return e.Salary * 0.1 // Bônus de 10% do salário | |
} | |
type Manager struct { | |
Employee // Employee compõe Manager | |
TeamSize int | |
} | |
func (m *Manager) CalculateBonus() float64 { | |
return m.Salary * 0.2 // Bônus de 20% do salário | |
} | |
func PrintBonus(e Employee) { | |
fmt.Printf("Bonus: $%.2f\n", e.CalculateBonus()) | |
} | |
func main() { | |
employee := Employee{Name: "John Doe", Position: "Employee", Salary: 5000} | |
manager := Manager{Employee: Employee{Name: "Jane Smith", Position: "Manager", Salary: 8000}, TeamSize: 10} | |
PrintBonus(employee) | |
PrintBonus(manager) // Error: cannot use manager (variable of type Manager) as Employee value in argument to PrintBonus compiler(IncompatibleAssign) | |
} |
A definição do princípio leva muitos (eu incluso por bastante tempo) a pensar que se trata de somente de herança, mas na verdade trata-se de subtipagem. Logo, em Go, o LSP é melhor expresso através do uso de interfaces e polimorfismo. Vamos ao próximo exemplo:
type Vehicle interface { | |
Start() string | |
Stop() string | |
Refuel() string | |
} | |
type Car struct { | |
Model string | |
} | |
func (c Car) Start() string { | |
return "The car is starting." | |
} | |
func (c Car) Stop() string { | |
return "The car is stopping." | |
} | |
func (c Car) Refuel() string { | |
return "The car is refueling with gasoline." | |
} | |
type ElectricCar struct { | |
Model string | |
BatteryLevel float64 | |
} | |
func (ec ElectricCar) Start() string { | |
return "The electric car is starting." | |
} | |
func (ec ElectricCar) Stop() string { | |
return "The electric car is stopping." | |
} | |
func (ec ElectricCar) Refuel() string { | |
panic("Electric cars cannot be refueled with gasoline.") | |
} | |
func PerformVehicleActions(vehicle Vehicle) { | |
startMessage := vehicle.Start() | |
stopMessage := vehicle.Stop() | |
refuelMessage := vehicle.Refuel() | |
fmt.Println(startMessage) | |
fmt.Println(stopMessage) | |
fmt.Println(refuelMessage) | |
} | |
func main() { | |
car := Car{Model: "Sedan"} | |
electricCar := ElectricCar{Model: "Tesla", BatteryLevel: 80.5} | |
PerformVehicleActions(car) | |
PerformVehicleActions(electricCar) | |
} |
Neste cenário, fizemos o método Refuel() na struct ElectricCar lançar um panic indicando que carros elétricos não podem ser abastecidos com gasolina.
Ao chamar a função PerformVehicleActions() com uma instância de ElectricCar, ocorre uma quebra óbvia do princípio de substituição de Liskov. Embora ElectricCar implemente o método Refuel() definido pela interface Vehicle, a implementação específica do carro elétrico interrompe a execução do programa.
No exemplo, vimos a quebra da funcionalidade da interface em vez de seguir a expectativa. E essa é a sacada do LSP em Go: Uma struct não deve violar o propósito da interface.
Poderíamos alterar o design de modo que o cliente do método Refuel() tenha que estar ciente de um possível erro ao chamá-lo. No entanto, isso significaria que os clientes teriam que ter conhecimento especial do comportamento inesperado do subtipo. Isso começa a quebrar o Open/Closed Principle.
func PerformVehicleActions(vehicle Vehicle) { | |
startMessage := vehicle.Start() | |
stopMessage := vehicle.Stop() | |
var refuelMessage string | |
// Esse if viola o Open/Closed Principle | |
if _, ok := vehicle.(ElectricCar); ok { | |
refuelMessage = "Refueling is not supported for electric cars." | |
} else { | |
refuelMessage = vehicle.Refuel() | |
} | |
fmt.Println(startMessage) | |
fmt.Println(stopMessage) | |
fmt.Println(refuelMessage) | |
} |
Em resumo, toda violação do LSP se torna uma violação do OCP. Então, como corrigir?
Uma possível modificação para respeitar o princípio seria mudar a interface Vehicle para ter um método mais genérico, como Recharge, em vez de Refuel. Assim, cada subtipo pode implementar esse método de acordo com a sua fonte de energia, seja gasolina ou eletricidade.
Outra possível modificação seria criar uma interface separada para os veículos elétricos, como ElectricVehicle, que tenha um método específico para recarregar a bateria, como RechargeBattery. Assim, o ElectricCar implementaria essa interface e não haveria conflito com o método Refuel.
Aqui está um exemplo de código usando a segunda modificação:
type Vehicle interface { | |
Start() string | |
Stop() string | |
} | |
type RegularVehicle interface { | |
Vehicle | |
Refuel() string // método específico para veículos a gasolina | |
} | |
type ElectricVehicle interface { | |
Vehicle | |
RechargeBattery() string // método específico para veículos elétricos | |
} | |
type Car struct { | |
Model string | |
} | |
func (c Car) Start() string { | |
return "The car is starting." | |
} | |
func (c Car) Stop() string { | |
return "The car is stopping." | |
} | |
func (c Car) Refuel() string { | |
return "The car is refueling with gasoline." | |
} | |
type ElectricCar struct { | |
Model string | |
BatteryLevel float64 | |
} | |
func (ec ElectricCar) Start() string { | |
return "The electric car is starting." | |
} | |
func (ec ElectricCar) Stop() string { | |
return "The electric car is stopping." | |
} | |
func (ec ElectricCar) RechargeBattery() string { | |
return "The electric car is recharging its battery." | |
} | |
func PerformRegularVehicleActions(regularVehicle RegularVehicle) { | |
startMessage := regularVehicle.Start() | |
stopMessage := regularVehicle.Stop() | |
refuelMessage := regularVehicle.Refuel() | |
fmt.Println(startMessage) | |
fmt.Println(stopMessage) | |
fmt.Println(refuelMessage) | |
} | |
func PerformElectricVehicleActions(electricVehicle ElectricVehicle) { | |
startMessage := electricVehicle.Start() | |
stopMessage := electricVehicle.Stop() | |
rechargeBatteryMessage := electricVehicle.RechargeBattery() | |
fmt.Println(startMessage) | |
fmt.Println(stopMessage) | |
fmt.Println(rechargeBatteryMessage) | |
} | |
func main() { | |
car := Car{Model: "Sedan"} | |
electricCar := ElectricCar{Model: "Tesla", BatteryLevel: 80.5} | |
PerformRegularVehicleActions(car) | |
PerformElectricVehicleActions(electricCar) | |
} |
Depois disso tudo vocês podem estar pensando: "Ah, Wali, com código de mentirinha é tudo muito fácil, quero ver no mundo real." Então vejamos um exemplo de aplicação do LSP no mudo real:
type Cache interface { | |
Get(ctx context.Context, key string, dest any) error | |
Set(ctx context.Context, key string, item any) error | |
Delete(ctx context.Context, key string) error | |
} | |
type MemoryCache struct { | |
items map[string][]byte | |
mu *sync.RWMutex | |
} | |
func NewMemoryCache() Cache { | |
return &MemoryCache{ | |
items: make(map[string][]byte), | |
mu: &sync.RWMutex{}, | |
} | |
} | |
func (c *MemoryCache) Get(ctx context.Context, key string, dest any) error { | |
c.mu.RLock() | |
defer c.mu.RUnlock() | |
if _, ok := c.items[key]; !ok { | |
return fmt.Errorf("key '%s' not found", key) | |
} | |
return json.Unmarshal(c.items[key], dest) | |
} | |
func (c *MemoryCache) Set(ctx context.Context, key string, item any) error { | |
c.mu.Lock() | |
defer c.mu.Unlock() | |
itemSerialized, err := json.Marshal(item) | |
if err != nil { | |
return err | |
} | |
c.items[key] = itemSerialized | |
return nil | |
} | |
func (c *MemoryCache) Delete(ctx context.Context, key string) error { | |
c.mu.Lock() | |
defer c.mu.Unlock() | |
delete(c.items, key) | |
return nil | |
} | |
type RedisCache struct { | |
client *redis.Client | |
} | |
func NewRedisCache(client *redis.Client) *RedisCache { | |
return &RedisCache{client: client} | |
} | |
func (c *RedisCache) Get(ctx context.Context, key string, dest interface{}) error { | |
val, err := c.client.Get(ctx, key).Result() | |
if err != nil { | |
if errors.Is(err, redis.Nil) { | |
return fmt.Errorf("key '%s' not found", key) | |
} | |
return err | |
} | |
err = json.Unmarshal([]byte(val), dest) | |
if err != nil { | |
return fmt.Errorf("failed to unmarshal cache value: %w", err) | |
} | |
return nil | |
} | |
func (c *RedisCache) Set(ctx context.Context, key string, item interface{}) error { | |
data, err := json.Marshal(item) | |
if err != nil { | |
return fmt.Errorf("failed to marshal cache value: %w", err) | |
} | |
err = c.client.Set(ctx, key, data, 0).Err() | |
if err != nil { | |
return fmt.Errorf("failed to set cache value: %w", err) | |
} | |
return nil | |
} | |
func (c *RedisCache) Delete(ctx context.Context, key string) error { | |
err := c.client.Del(ctx, key).Err() | |
if err != nil { | |
return fmt.Errorf("failed to delete cache value: %w", err) | |
} | |
return nil | |
} | |
type User struct { | |
ID int | |
Name string | |
} | |
type UserService struct { | |
cache Cache | |
} | |
func NewUserService(cache Cache) *UserService { | |
return &UserService{ | |
cache: cache, | |
} | |
} | |
func (s *UserService) GetUserByID(ctx context.Context, userID int) (*User, error) { | |
key := fmt.Sprintf("user:%d", userID) | |
var user User | |
err := s.cache.Get(ctx, key, &user) | |
if err == nil { | |
return &user, nil | |
} | |
// Simula a busca do usuário no banco de dados | |
user = User{ | |
ID: userID, | |
Name: "John Doe", | |
} | |
err = s.cache.Set(ctx, key, &user) | |
if err != nil { | |
// Não é um erro fatal, apenas loga o erro e continua | |
fmt.Println("Failed to set user in cache:", err) | |
} | |
return &user, nil | |
} | |
func main() { | |
client := redis.NewClient(&redis.Options{ | |
Addr: "localhost:6379", | |
Password: "", // senha, se necessário | |
DB: 0, // número do banco de dados | |
}) | |
redisCache := NewRedisCache(client) | |
memoryCache := NewMemoryCache() | |
userService := NewUserService(redisCache) | |
user, err := userService.GetUserByID(context.Background(), 123) | |
if err != nil { | |
fmt.Println("Error:", err) | |
} else { | |
fmt.Println("User:", user) | |
} | |
userService = NewUserService(memoryCache) | |
user, err = userService.GetUserByID(context.Background(), 456) | |
if err != nil { | |
fmt.Println("Error:", err) | |
} else { | |
fmt.Println("User:", user) | |
} | |
} |
Neste exemplo, temos a definição da interface Cache, com os métodos Get, Set e Delete, e as implementações RedisCache e MemoryCache.
O UserService utiliza a interface Cache como dependência, permitindo a troca entre o RedisCache e o MemoryCache sem problemas.
No cenário apresentado, o LSP poderia ser quebrado se a implementação específica de algum método de Cache na struct derivada não cumprisse as mesmas garantias e pré-condições definidas pela interface.
Por exemplo, se a implementação do método Get em MemoryCache lançasse um erro diferente ou não respeitasse a garantia de retornar um erro quando a chave não é encontrada, isso quebraria o LSP. Da mesma forma, se a implementação do método Set em RedisCache não armazenasse corretamente os valores no Redis, ou a implementação do método Delete não excluísse corretamente as chaves, isso também violaria o LSP.
Isso é tudo, pessoal! Até a próxima!
Referências:
Top comments (0)