SO[L]ID – Liskov Substitution Principle (LSP)

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.

 

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:

var
  Objeto: TClasseBase; // declaração como classe base
begin
  Objeto := TClasseDerivada.Create; // instanciação como classe derivada
  Objeto.ExecutarAcao;
  ...
end;

 

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.

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
André Celestino  | Urânia        | SP
Beatriz Makiyama | Florianópolis | SC
Letícia Carolina | Maringá       | PR
05/02/2018
19:30

 

  • CSV
Nome;Cidade;UF
André Celestino;Urânia;SP
Beatriz Makiyama;Florianópolis;SC
Letícia Carolina;Maringá;PR

 

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:

type
  TFile = class
  private
    FContent: TStringList;
    FLine: TStringList;
  protected
    procedure SetDelimiter(const Delimiter: char);
  public
    procedure AddRecords; virtual;
    function GetRecordCount: integer; virtual;
 
    constructor Create(const FileName: string);
    destructor Destroy; override;
  end;
 
implementation
 
{ TFile }
 
constructor TFile.Create(const FileName: string);
begin
  FContent := TStringList.Create;
  FLine := TStringList.Create;
  FContent.LoadFromFile(FileName);
end;
 
destructor TFile.Destroy;
begin
  FContent.Free;
  FLine.Free;
  inherited;
end;
 
procedure TFile.SetDelimiter(const Delimiter: char);
begin
  FLine.Delimiter := Delimiter;
end;
 
procedure TFile.AddRecords;
var
  Line, Value: string;
begin
  for Line in FContent do
  begin
    FLine.DelimitedText := Line;
 
    for Value in FLine do
      // Aqui, por exemplo, adiciona os valores a um DataSet
  end;
end;
 
function TFile.GetRecordCount: integer;
begin
  result := FContent.Count;
end;

 

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.

type
  TTXTFile = class(TFile)
  public
    procedure AddRecords; override;
    function GetRecordCount: integer; override;
  end;
 
implementation
 
{ TTXTFile }
 
procedure TTXTFile.AddRecords;
begin
  SetDelimiter('|');
  inherited;
end;
 
function TTXTFile.GetRecordCount: integer;
begin
  result := inherited GetRecordCount;
  Dec(result, 2);
end;

 

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:

type
  TCSVFile = class(TFile)
  public
    procedure AddRecords; override;
    function GetRecordCount: integer; override;
  end;
 
implementation
 
{ TCSVFile }
 
procedure TCSVFile.AddRecords;
begin
  SetDelimiter(';');
  inherited;
end;
 
function TCSVFile.GetRecordCount: integer;
begin
  result := inherited GetRecordCount;
  Dec(result, 1);
end;

 

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:

{  
   "dados":[  
      {  
         "nome": "André Celestino",
         "cidade": "Urânia",
         "uf": "SP"
      },
      {  
         "nome": "Beatriz Makiyama",
         "cidade": "Florianópolis",
         "uf": "SC"
      },
            {  
         "nome": "Letícia Carolina",
         "cidade": "Maringá",
         "uf": "PR"
      }
   ]
}

 

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

type
  TJSONFile = class(TFile)
  public
    procedure AddRecords; override;
    function GetRecordCount: integer; override;
  end;
 
implementation
 
{ TJSONFile }
 
procedure TJSONFile.AddRecords;
begin
  SetDelimiter(' ');
  inherited;
end;
 
function TJSONFile.GetRecordCount: integer;
begin
  result := inherited GetRecordCount;
end;

 

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

var
  FileList: TObjectList<TFile>;
  ItemFile: TFile;
begin
  FileList := TObjectList<TFile>.Create;
 
  FileList.Add(TTXTFile.Create('C:\Importacao\Arquivo1.txt'));
  FileList.Add(TTXTFile.Create('C:\Importacao\Arquivo2.txt'));
  FileList.Add(TCSVFile.Create('C:\Importacao\Arquivo3.csv'));
  FileList.Add(TCSVFile.Create('C:\Importacao\Arquivo4.csv'));
  FileList.Add(TJSONFile.Create('C:\Importacao\Arquivo5.json'));
  FileList.Add(TJSONFile.Create('C:\Importacao\Arquivo6.json'));
 
  for ItemFile in FileList do
  begin
    ShowMessage('Qtde de registros: ' + ItemFile.GetRecordCount.ToString);
    ItemFile.AddRecords;
  end;
end;

 

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:

type
  TJSONFile = class(TFile)
  public
    ...
    procedure AddRecordsFromJSON;
  end;
 
...
 
procedure TJSONFile.AddRecordsFromJSON;
begin
  // Utiliza TJSONObject para importar os dados
end;

 

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

for ItemFile in FileList do
begin
  ShowMessage('Qtde de importações: ' + ItemFile.GetRecordCount.ToString);
 
  if ItemFile.ClassType = TJSONFile then
    (ItemFile as TJSONFile).AddRecordsFromJSON
  else
    ItemFile.AddRecords;
end;

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:

function TJSONFile.GetRecordCount: integer;
var
  JSON: TJSONObject;
begin
  JSON := TJSONObject.Create;
  JSON.Parse(TEncoding.ASCII.GetBytes(FContent.Text), 0);
  result := (JSON.GetValue('dados') as TJSONArray).Count;
  JSON.Free;
end;

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.

 

E qual é a solução disso tudo?
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:

type
  IFileImport = interface
    procedure AddRecords;
  end;

 

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:

var
  FileList: TList<IFileImport>;
  ItemFile: IFileImport;
begin
  FileList := TList<IFileImport>.Create;
 
  FileList.Add(TTXTFile.Create('C:\Importacao\Arquivo1.txt'));
  FileList.Add(TTXTFile.Create('C:\Importacao\Arquivo2.txt'));
  FileList.Add(TCSVFile.Create('C:\Importacao\Arquivo3.csv'));
  FileList.Add(TCSVFile.Create('C:\Importacao\Arquivo4.csv'));
  FileList.Add(TJSONFile.Create('C:\Importacao\Arquivo5.json'));
  FileList.Add(TJSONFile.Create('C:\Importacao\Arquivo6.json'));
 
  for ItemFile in FileList do
      ItemFile.AddRecords;
end;

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!


 

Compartilhe!
Share on FacebookTweet about this on TwitterShare on LinkedInShare on Google+Pin on PinterestEmail this to someone

2 comentários

  1. Muito bacana esse princípio né cara? Eu estou fazendo integração com o Magento nas versões 1.5 e 1.9 e como vi extrema similaridade entre os objetos SOAP, eu usei esse princípio para abstrair o básico e implementar os derivados. Ficou show de bola.
    Ah, parabéns pelo artigo. 😉

    1. Boa, Marcos!
      A Abstração, em cenários como este que você mencionou, faz toda a diferença na sustentabilidade do projeto!
      Obrigado pelo comentário, Marcão! Abraço!

Deixe uma resposta

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *

Preencha o campo abaixo * Time limit is exhausted. Please reload CAPTCHA.