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:
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
:
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!
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!