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:
1 2 3 4 5 6 7 |
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.
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
1 2 3 4 5 |
André Celestino | Urânia | SP Beatriz Makiyama | Florianópolis | SC LetÃcia Carolina | Maringá | PR 05/02/2018 19:30 |
- CSV
1 2 3 4 |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
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; |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
{ "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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
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:
Para corrigi-la, podemos criar um novo método na classe TJSONFile
para importar este formato em particular:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
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:
1 2 3 4 5 6 7 8 9 |
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:
1 2 3 4 5 6 7 8 9 |
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.
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
:
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
:
1 2 3 4 |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
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!
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. 😉
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!
Ótima série e didática! Ansiosa pela sequência!
Olá, Elaine!
Muito obrigado pelo feedback! Vou publicar a sequência logo, logo!
Abraço!
O senhor pode passar a função que usou para preencher o dbgrid?
Olá, Antonio, tudo bem?
Para preencher a DBGrid (para os formatos TXT e CSV), basta atribuir cada linha do arquivo à propriedade DelimitedText de um objeto da classe TStringList. Com isso, os valores serão “quebrados” em posições no objeto, e você poderá acessá-los dessa forma:
Abraço!
Parabéns, Muito esclarecedor. Espero que Continue com a série Padrões de Projetos ( SOLID ) / GOF
Olá, Antonio, boa noite!
As séries sobre Design Patterns GOF e SOLID já estão concluÃdas. Clique aqui para acessar a página de artigos.
Em breve, iniciarei uma nova série sobre os padrões de projeto GRASP.
Abraço!