SOLID – Liskov Substitution Principle (LSP)

Saudações, leitores!
Já temos conhecimento de que abstração, no contexto da programação, é o ato de identificar características em comum nas entidades do sistema de forma que seja possível reaproveitar comportamentos e atributos por meio de heranças. A questão é que, algumas vezes, cometemos algumas falhas no processo de abstração, levando à violação do Liskov Substitution Principle.

Introdução

Abstração e Herança representam dois dos quatro pilares da Orientação a Objetos. Estes dois conceitos devem ser muito bem trabalhados para definir uma arquitetura leve e flexível para o projeto de software. No entanto, a busca pelo “nível perfeito” de abstração pode trazer incorreções na arquitetura. Neste artigo, identificaremos um equívoco de abstração que promove comportamentos inesperados em uma rotina de leitura de arquivos.

O Liskov Substitution Principle – ou LSP – está bastante relacionado com Abstração e Herança e estabelece que uma classe base deve poder ser substituída pela sua classe derivada. Sendo assim, o código abaixo, por exemplo, deve funcionar normalmente:

Se o método ExecutarAcao devolver uma exceção, talvez por ser um método abstrato não implementado na classe derivada, podemos assumir que houve um erro de abstração.

Além disso, o LSP também define que classes filhas nunca devem infringir as definições de tipo (leia-se “comportamentos”) da classe base. No exemplo acima, o método ExecutarAcao, ao ser sobrescrito nas classes derivadas, deve manter o possível comportamento da classe base (no Delphi, utiliza-se a palavra reservada inherited).

O grande risco de violar o LSP é a imprevisibilidade do comportamento do software. Ao herdar novas classes com falhas de abstração e utilizá-las no código, é bem provável que algumas rotinas gerem exceções ou apresentem informações errôneas ao usuário. Nessa situação, se a abstração não for “corrigida”, a manutenção do software será cada vez mais custosa.

Cenário de exemplo

O exemplo prático desse artigo envolve uma simples rotina de importação de arquivos de diferentes formatos – TXT, CSV e JSON.

Neste cenário, os arquivos TXT e CSV são delimitados por um pipeline (“|”) e por ponto-e-vírgula (“;”), respectivamente. O primeiro possui duas linhas no final do arquivo referente à data e hora, portanto, não são considerados como registros a serem importados. O segundo possui um cabeçalho que se encaixa na mesma característica:

  • TXT

  • CSV

Modelaremos uma classe base, chamada TFile, que executa a operação principal de importação utilizando dois objetos da classe TStringList: um para ler todo o conteúdo do arquivo e outro para ler cada linha como texto delimitado:

Observe que existe um método protegido para configurar o delimitador, no qual será chamado pelas classes derivadas. Há também dois métodos públicos: um para executar a importação e o outro para retornar a quantidade de registros que serão importados, apenas para efeito de informação.

A classe referente à importação de arquivos TXT traz a codificação abaixo. No método GetRecordCount, invocamos a ação da classe base, que conta a quantidade de linhas no objeto TStringList, e decrementamos o resultado em 2, já que as duas últimas linhas do arquivo TXT não são consideradas.

A classe para importação de CSV, por sua vez, é bem semelhante. Configuramos o delimitador como ponto-e-vírgula e decrementamos a contagem da quantidade de linhas em 1 em função do cabeçalho, no qual também é desconsiderado:

Um novo requisito: JSON

Até o momento, tudo funciona perfeitamente bem. A situação começa a mudar quando o formato JSON surge no cenário. Como sabemos, o seu conteúdo não é delimitado e consiste em dados estruturados:

Mesmo assim, criaremos a classe TJSONFile herdada de TFile como proposta de solução, definido o delimitador com um espaço em branco:

Para testar a aplicação, colocaremos as três classes em prática em uma rotina de importação de múltiplos arquivos:

Consegue prever o que acontecerá? Os arquivos TXT e CSV serão corretamente importados, porém, os dados provenientes dos arquivos JSON ficarão incorretos. A primeira linha do JSON, por exemplo, consiste apenas em uma chave de abertura (“{“), e como estamos utilizando a classe TStringList para varrer o conteúdo de forma linear,  algumas colunas serão gravadas em branco. Veja um exemplo dessa falha:

Exemplo de falha na importação de dados

Para corrigi-la, podemos criar um novo método na classe TJSONFile para importar este formato em particular:

Na rotina principal, portanto, basta tratar este caso com uma condição IF dentro do loop:

Neste exato momento, violamos o Liskov Substituion Principle. Há indícios de um erro de abstração, visto que a classe TJSONFile se torna um caso específico e deve ser tratado em todas as referências no código. Geralmente essas classes recebem o nome de caso especial (special case) em função da necessidade de uma condição IF para utilizá-las em conjunto com outras classes da mesma abstração.

No entanto, o problema não é só esse. O método GetRecordCount da classe TJSONFile retornará o valor 19 referente à quantidade de registros (equivalente à quantidade de linhas do arquivo), porém, há somente 3.
Na tentativa de corrigir essa nova falha, podemos remover a palavra inherited da função e contar os registros de uma forma diferente:

Oras, se a solução é remover o inherited para anular o comportamento da classe base, então há definitivamente um erro de abstração. Outro detalhe é que, para que este método funcione, o objeto FContent da classe base teria que ser movida para a visibilidade protected, violando o OCP, visto no artigo anterior.

Aplicando o LSP

A solução de tudo isso é corrigir a abstração. O arquivo JSON – embora seja um arquivo – não faz parte da mesma abstração dos arquivos TXT e CSV.
Na verdade, podemos usar duas abordagens para resolver este problema. Na primeira, a classe TFile é elevada na hierarquia e duas novas classes derivadas surgiriam: TDelimitedFile e TStructuredFile:

Proposta de arquitetura para eliminar a violação do LSP

Neste caso, o método AddRecords seria abstrato no primeiro nível da hierarquia e sobrescrito no segundo nível.

Na rotina principal, a estrutura condicional que compara o tipo da classe com TJSONFile seria removida, solucionando a nossa irregularidade arquitetural.

A segunda abordagem consiste no uso de Interfaces. Todas as classes que possuem o comportamento de importação de arquivos (TXT, CSV, JSON e qualquer outro formato) implementaria uma Interface que declara a assinatura AddRecords:

Como trata-se de um contrato, todas as classes são obrigadas a implementar este método. Logo, na rotina principal, é seguro utilizar uma lista deste tipo de Interface e chamá-lo em um loop:

A desvantagem dessa segunda abordagem é a duplicação de código para classes que possuem comportamentos semelhantes, como a importação de arquivos TXT e CSV.

A solução que recomendo, por fim, é uma “mesclagem” da primeira com a segunda abordagem! Utilize Interfaces para declarar comportamentos comuns e heranças para reaproveitar o código! 🙂

 

Essa é a letra “L”, pessoal! Volto em breve com a letra “I”.
Grande abraço!


 

André Celestino