Reflexões sobre SOLID - A Letra "L"
Published: 2024-05-18 20:33:02

Nesta quarta parte de minhas reflexões, seguimos com a letra "L", obedecendo a ordem proposta pelo acrônimo SOLID. Trabalharei com TypeScript nos exemplos.

---

## Neste artigo

[Liskov Substitution Principle](#liskov-substitution-principle)
[Exemplo abstrato](#exemplo-abstrato)
[Exemplo técnico (Front-End)](#exemplo-técnico-frontend)
[Exemplo técnico (Back-End)](#exemplo-técnico-backend)
[Exemplo pessoal](#exemplo-pessoal)
[Exemplo funcional](#exemplo-funcional)
[As aplicabilidades](#as-aplicabilidades)
[Reflexões finais](#reflexões-finais)

---

## Liskov Substitution Principle

O Liskov Substitution Principle (ou _Princípio da Substituição de Liskov_) possui esse nome por ter sido descrito originalmente por Barbara Liskov. Este princípio propõe que **uma classe pode ser substituída por suas subclasses sem comprometimento ao funcionamento do sistema**.

Inicialmente, esqueçamos um pouco o **motivo** pelo qual você teria que substituir uma classe por uma subclasse derivada, e vamos focar na aplicabilidade. Suponha o seguinte cenário:

- Eu tenho uma classe chamada `Class`;
- Eu tenho uma subclasse chamada `Subclass` que estende `Class`;
- Eu tenho um objeto `const obj = new Class()`;
- Se eu quiser trocar para `const obj = new Subclass()`, tem que continuar funcionando;

Esse é o resumo prático do princípio. Vamos agora para alguns exemplos.

---

## Exemplo abstrato

Imagine que, estendendo da nossa classe de **livro**, identificamos livros que **não estão completos**. Contudo, a classe superiora espera que exista uma quantidade de livros vendidos - funcionalidade que foi "perdida" na subclasse.

**🔴 Implementação Incorreta**

```typescript
// Temos a nossa classe para a entidade de livro.
class Book {
  private sales: number;

  constructor(private title: string, private author: string) {}

  // Podemos determinar quantas vendas o livro tem.
  public setSales(sales: number) {
    this.sales = sales;
  }

  // E podemos checar quantas vendas o livro tem.
  public checkSales() {
    console.log(
      `O livro "${this.title}" de "${this.author}" vendeu ${this.sales} exemplates!`
    );
  }
}

class IncompleteBook extends Book {
  // VIOLAÇÃO DO PRINCÍPIO: Como um livro incompleto não tem vendas, aqui é retornado um erro.
  // Ou seja, não seria possível substituir um livro instanciado como Book por um IncompleteBook sem
  // causar algum efeito colateral inesperado.
  public checkSales() {
    // Não vamos nos apegar no fato de que aqui estamos literalmente causando um erro.
    // É um exemplo abstrato.
    throw new Error(
      "Este livro ainda não foi concluído, portanto, não possui vendas!"
    );
  }
}

const book = new Book("A Máquina do Tempo", "H. G. Wells");
book.setSales(50000000);
book.checkSales();

const incompleteBook = new IncompleteBook("A Máquina do Tempo", "H. G. Wells");
incompleteBook.setSales(50000000);
incompleteBook.checkSales(); // Aqui, estourará um erro. Literalmente, claro, pelo que fizemos.
```

**🟢 Implementação Correta**

A parte interessante do princípio é que alterar a subclasse nem sempre será a solução. Às vezes, o problema está no fato da subclasse existir: talvez a implementação devesse estar na classe superiora?

De qualquer forma, abaixo imagino uma forma um pouco diferente de resolver o problema.

```typescript
class Book {
  private sales: number;

  constructor(private title: string, private author: string) {}

  public setSales(sales: number) {
    this.sales = sales;
  }

  public checkSales() {
    console.log(
      `O livro "${this.title}" de "${this.author}" vendeu ${this.sales} exemplares!`
    );
  }
}

// Draft seria um nome mais adequado, pois pode se tornar um livro posteriormente.
class Draft extends Book {
  // Criamos uma nova propriedade para controlar o estado.
  private isComplete: boolean = false;

  public setComplete(isComplete: boolean) {
    this.isComplete = isComplete;
  }

  // Apenas retornará erro se o status estiver explícito.
  public checkSales(): void {
    if (this.isComplete) super.checkSales();
  }
}

// Agora, ambos os casos funcionam normalmente.

const book = new Book("A Máquina do Tempo", "H. G. Wells");
book.setSales(50000000);
book.checkSales();

const incompleteBook = new IncompleteBook("A Máquina do Tempo", "H. G. Wells");
incompleteBook.setComplete(true);
incompleteBook.setSales(50000000);
incompleteBook.checkSales();
```

---

## Exemplo técnico (Front-End)

Para a abstração do Front-End, vamos imaginar uma entidade **Button** que foi estendida para a subclasse **DisabledButton**. Se eu substituir uma pela outra, que comportamentos inesperados eu poderia ter?

**🔴 Implementação Incorreta**

```typescript
// Classe para nossa entidade de botão.
class Button {
  // Iniciamos com o texto do botão.
  constructor(public label: string, public event: () => void) {}

  // Temos a lógica genérica de evento de pressionar.
  public onPress() {
    console.log("O botão foi pressionado! Executando evento...");
    this.event();
  }
}

// Contudo, um botão desabilitado não será capaz de executar o método corretamente.
class DisabledButton extends Button {
  public onPress() {
    console.log("O botão está desabilitado e não poderá ser pressionado.");
  }
}

// Ao instanciarmos o botão como Button, temos o comportamento esperado.
const myButton = new Button("Clique aqui", () => console.log("Cliquei!"));
myButton.onPress();
// OUTPUT:
// Cliquei!

// VIOLAÇÃO DO PRINCÍPIO: Se substituirmos por DisabledButton, o comportamento muda.
// Se isso não era o esperado, o princípio foi violado. Ou seja, para ser caracterizado como violação,
// não precisa necessariamente retornar um erro, mas precisa exibir um comportamento anormal inesperado.
const myButton2 = new DisabledButton("Clique aqui", () => console.log("Cliquei!"));
myButton2.onPress();
// OUTPUT:
// O botão está desabilitado e não poderá ser pressionado.
```

**🟢 Implementação Correta**

Aqui, pessoalmente, eu diria que o ideal seria não utilizar uma subclasse chamada **DisabledButton**, mas transformar sua implementação em uma propriedade **isDisabled** dentro de **Button**. Algumas perguntas podem surgir:

- **Isso não viola o princípio do Single Responsibility?** Na minha visão, não. Estar habilitado ou não ainda faz parte do contexto de um botão.
- **Como eu consigo saber quando devo ou não descartar a subclasse?** Claro, tome cuidado com a refatoração descontrolada. Analise com calma e avalie os riscos.

---

## Exemplo técnico (Back-End)

**🔴 Implementação Incorreta**

```typescript
// Aqui temos uma classe ABSTRATA para um banco de dados.
abstract class DatabaseConnection {
  abstract connect(): void;
  abstract query(query: string): void;
  abstract disconnect(): void;
}

// Então podemos especificar a implementação CONCRETA para cada tipo de banco de dados abaixo.

class MySQLConnection extends DatabaseConnection {
  connect(): void {
    console.log("Conectando ao MySQL.");
    // Implementação específica para o MySQL.
  }

  query(query: string): void {
    console.log(`Executando consulta MySQL: ${query}`);
    // Implementação específica para o MySQL.
  }

  disconnect(): void {
    console.log("Desconectando do MySQL.");
    // Implementação específica para o MySQL.
  }
}

class MongoDBConnection extends DatabaseConnection {
  connect(): void {
    console.log("Conectando ao MongoDB.");
    // Implementação específica para o MongoDB.
  }

  // VIOLAÇÃO DO PRINCÍPIO: Nenhuma implementação foi realmente desenvolvida para esse tipo de banco de dados.
  query(query: string): void {
    // Nenhuma implementação foi feita.
  }

  disconnect(): void {
    console.log("Desconectando do MongoDB.");
    // Implementação específica para o MongoDB.
  }
}

// Isso funcionará normalmente.
const mysqlConnection = new MySQLConnection();
mysqlConnection.connect();
mysqlConnection.query('SELECT * FROM MINHA_TABELA');
mysqlConnection.disconnect();

// Se tentarmos mudar para o MongoDB, falhará.
const mongodbConnection = new MongoDBConnection();
mongodbConnection.connect();
mongodbConnection.query('SELECT * FROM MINHA_TABELA'); // Erro: Cannot read property 'query' of undefined
mongodbConnection.disconnect(); // Isso nem mesmo será executado.
```

**🟢 Implementação Correta**

A solução é simples: **a subclasse deve obedecer às limitações impostas pela classe superiora**. Se a classe está exigindo a implementação do método `query`, então ele obrigatoriamente precisa ser implementado.

---

## Exemplo pessoal

**🔴 Implementação Incorreta**

```typescript
// Imagine que temos uma classe Kong para cada personagem.
abstract class Kong {
  abstract attack(): void;
  abstract specialAbility(): void;
}

class DonkeyKong extends Kong {
  public attack() {
    console.log('Donkey Kong está atacando!')
  }

  public specialAbility() {
    console.log('Donkey Kong está usando uma habilidade especial!')
  }
}

class DiddyKong extends Kong {
  public attack() {
    console.log('Diddy Kong está atacando!')
  }

  // VIOLAÇÃO DO PRINCÍPIO: Este método não foi implementado para Diddy Kong.
  // Agora, imagine que trocamos de personagem no meio do jogo. O jogo
  // teria travado porque este método não foi implementado.
  public specialAbility() {
    // Nenhuma implementação foi realmente feita.
  }
}
```

**🟢 Implementação Correta**

Bom, novamente, a solução é visualmente simples: **Diddy Kong** deve, obrigatoriamente, implementar os métodos propostos por **Kong**.

---

## Exemplo funcional

Vamos manter nossos exemplos funcionais falando sobre **leitura de arquivos**. Suponhamos que temos uma interface, um contrato, para um **FileReader** para nossa aplicação. Portanto, posso criar vários fileReaders, cada um para uma situação distinta, desde que cumpram nosso contrato.

No entanto, o que acontece se a implementação concreta dessas interfaces não for feita corretamente?

**🔴 Implementação Incorreta**

```typescript
// Aqui temos uma interface que nos permite criar
// quantos File Readers (leitores de arquivos) personalizados quisermos.
type FileReaderFunction = (file: string) => { result: string };

// Este funciona corretamente.
const myFileReader: FileReaderFunction = (file) => {
  const fileContent = fileSystem.read(file);
  return { result: "Aqui está o conteúdo do arquivo: " + fileContent };
};

// VIOLAÇÃO DO PRINCÍPIO: Se você vai implementar uma
// interface, é melhor honrar o que ela propõe. Aqui,
// o leitor de arquivos não foi implementado corretamente.
const anotherFileReader: FileReaderFunction = (file) => {
  throw new Error("Não implementado!");
};
```

**🟢 Implementação Correta**

Você adivinhou: **anotherFileReader** deve aderir ao Princípio da Substituição de Liskov, cumprindo e respeitando o contrato de **FileReaderFunction**.

---

## As aplicabilidades

O Princípio da Substituição de Liskov é muito interessante, pois propõe uma **hierarquia consistente** entre os membros de um mesmo sistema e, na minha opinião, propõe mais um trabalho reflexivo do que refatoração de código propriamente dita. Como o próprio Martin comenta, costumeiramente, identifica-se os problemas de violação deste princípio quando já é tarde demais, e a solução acaba sendo um **if-else**.

Contudo, sua aplicabilidade pode muito bem ser vista em tempo de **concepção**. Um desenvolvedor que é responsável por implementar uma nova funcionalidade estendendo de uma classe pré-existente passará a considerar o quão substituível ela seria, ou se realmente valeria a pena aplicar essa extensão.

---

## Reflexões finais

De todos os princípios que abordei até o momento, creio que esse seja um dos mais contextuais. Na minha humilde opinião, nem sempre ficar "batendo a cabeça" para fazer esse princípio funcionar seja a solução correta - talvez uma simples unificação de contextos faça muito mais sentido.

Aqui neste artigo, abordei o exemplo da entidade **Book**, e ele estar completo ou não poderia muito bem fazer parte da mesma entidade; mas, por vias didáticas, optei por refatorar considerando o princípio em questão. Independentemente do caminho seguido, o melhor fruto colhido deste princípio é, com certeza, **o olhar crítico para as extensões realizadas**.