DEV Community

Wali Queiroz
Wali Queiroz

Posted on

Princípios SOLID em GoLang - Liskov Substitution Principle (LSP)

Hoje vamos falar sobre o Liskov Substitution Principle. Para ver os artigos anteriores da série acesse:

  1. Princípios SOLID em GoLang - Single Responsability Principle (SRP)
  2. Princípios SOLID em GoLang - Open/Closed Principle (OCP)

Gopher - Liskov Substitution Principle

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:

Retry later

Top comments (0)

Retry later