Design de software

[SOLID #1] As cinco letras de SOLID: entenda em vez de decorar (SRP · OCP · LSP)

Provavelmente você já ouviu pelo menos uma vez em uma entrevista: “Você pode explicar os princípios SOLID?”

4 min de leitura
Imagem de capa de [SOLID #1] As cinco letras de SOLID: entenda em vez de decorar (SRP · OCP · LSP)

Provavelmente você já ouviu pelo menos uma vez em uma entrevista: “Você pode explicar os princípios SOLID?”

Mas, falando a verdade, embora muita gente consiga recitar os cinco princípios, poucas pessoas chegam à pergunta: “Então, como aplico isso no meu código?”

Desta vez, vou explicar SOLID por meio de situações práticas, e não de definições de livro. Como há bastante conteúdo, vou dividir em duas partes. Hoje veremos as três primeiras letras: SRP, OCP e LSP.

SOLID é o acrônimo dos cinco princípios de design orientado a objetos organizados por Robert Martin (Uncle Bob).

  • SRP — Princípio da responsabilidade única
  • OCP — Princípio aberto/fechado
  • LSP — Princípio da substituição de Liskov
  • ISP — Princípio da segregação de interfaces
  • DIP — Princípio da inversão de dependência

Os cinco princípios parecem tratar de assuntos diferentes, mas têm uma mensagem em comum: reduzir o escopo do código que precisa ser alterado quando ocorre uma mudança.


SRP — Princípio da responsabilidade única

A definição de livro diz que “uma classe deve ter apenas uma responsabilidade”, mas o próprio Uncle Bob reformulou isso depois de uma maneira melhor.

Um módulo deve ser responsável por um único ator.

Aqui, ator é a pessoa ou o departamento que solicita mudanças nesse código. Vamos supor que exista uma classe assim.

class Employee {
    func calculatePay() { ... }   // O time de contabilidade pediu uma alteração
    func reportHours() { ... }    // O time de RH pediu uma alteração
    func save() { ... }           // O time de desenvolvimento(DBA)pediu uma alteração
}

Os três métodos têm responsáveis diferentes. Se, ao alterar calculatePaya pedido da contabilidade, você mexer na lógica compartilhada, o relatório de RH pode ficar silenciosamente errado. Quando isso acontece na prática, também é difícil encontrar a causa.

O SRP resume isso assim: “Se as pessoas que pedem alterações são diferentes, separe o código também”. Ao dividir cálculo de folha, relatório de trabalho e persistência em classes diferentes, uma solicitação da contabilidade não afeta a funcionalidade de RH.


OCP — Princípio aberto/fechado

A definição é: “aberto para extensão e fechado para modificação”. No começo parece um enigma, mas fica claro quando vemos o código.

// Se for necessário modificar esta função sempre que um meio de pagamento for adicionado
func pay(method: String, amount: Int) {
    if method == "card" { ... }
    else if method == "kakaopay" { ... }
    else if method == "naverpay" { ... }
    // Novo meio de pagamento = modificar else if = o código existente
}

Nessa estrutura, para adicionar o Toss Pay, é preciso abrir e modificar uma função que já funcionava. A cada alteração, você assume o risco de quebrar os meios de pagamento existentes.

// Uma estrutura em que basta “adicionar” um novo meio de pagamento
let payments: [String: Payment] = [
    "card": CardPayment(),
    "kakaopay": KakaoPayment(),
    "naverpay": NaverPayment(),
    // Adicionar Toss Pay = registrar uma nova classe em uma linha
]

func pay(method: String, amount: Int) {
    payments[method]?.process(amount: amount)
}

O código existente não é alterado (fechado para modificação), e a funcionalidade cresce apenas com a adição de uma nova classe (aberto para extensão). O segredo é aplicar essa estrutura somente nos pontos que mudam com frequência. Aplicá-la a todo o código violaria YAGNI, como vimos no artigo anterior.

Adicionar o Toss Pay termina com a inclusão de uma única classe
Adicionar o Toss Pay termina com a inclusão de uma única classe
OCP é responder com adições em vez de modificações
OCP é responder com adições em vez de modificações

LSP — Princípio da substituição de Liskov

O nome parece o mais assustador, mas o significado é simples.

Uma classe filha deve poder substituir a classe pai onde quer que ela seja usada sem quebrar o programa.

O contraexemplo famoso é o problema do retângulo e do quadrado. Se herdarmos seguindo o senso comum matemático de que “um quadrado é um retângulo”, isso acontece.

class Rectangle {
    var width = 0
    var height = 0
    func setWidth(_ w: Int) { width = w }
    func setHeight(_ h: Int) { height = h }
}

class Square: Rectangle {
    override func setWidth(_ w: Int) { width = w; height = w }  // Por ser um quadrado
    override func setHeight(_ h: Int) { width = h; height = h }
}

// Código que espera um retângulo
func test(_ rect: Rectangle) {
    rect.setWidth(5)
    rect.setHeight(4)
    print(rect.width * rect.height) // 20espera Squaremas, se for um quadrado 16
}

No momento em que você passa Square, quebra-se a expectativa óbvia de que “com largura 5 e altura 4, a área é 20”. Mesmo que a relação is-a pareça válida, LSP diz que não se deve usar herança quando o contrato de comportamento é quebrado.

O sinal prático é este: se uma classe filha sobrescreve um método do pai para lançar uma exceção ou o deixa vazio, é muito provável que essa herança viole o LSP.

Acontece que um quadrado não cabe no lugar de um retângulo
Acontece que um quadrado não cabe no lugar de um retângulo

Encerrando a primeira parte

Vamos deixar uma frase para cada um dos três princípios abordados hoje.

  • SRP — Se as pessoas que pedem alterações são diferentes, separe o código também.
  • OCP — Faça com que os pontos que mudam com frequência possam ser tratados com adições em vez de modificações.
  • LSP — A classe filha não deve quebrar os contratos da classe pai. Se precisar quebrá-los, a herança está errada.

No próximo artigo, vou abordar as duas letras restantes: ISP (segregação de interfaces) e DIP (inversão de dependência). O DIP será especialmente interessante porque se conecta diretamente ao motivo pelo qual os frameworks atuais usam injeção de dependência.