Delphi/Object Pascal – Exceções vs. funções com retorno de erro

Existem muitas debates na internet sobre como tratar no código-fonte situações excepcionais que ocorrem no aplicativo. O Delphi possui uma variedade de exceções conhecidas: EAccessViolation, EDivByZero, EConvertError etc. Uma violação de acesso, por exemplo, pode ocorrer quando tentamos acessar uma propriedade de um objeto que ainda não foi instanciado. Ou seja, algo inesperado ou que foge à regra ocorreu na aplicação. A esta situação damos o nome de exceção. Ela tem o objetivo de interromper o fluxo da aplicação, para que algo seja corrigido ou, se não houver o tratamento adequado, a aplicação seja encerrada. Os debates que existem ao redor deste assunto normalmente questionam qual técnica devemos utilizar quando estas situações inesperadas ocorrem. Devemos lançar uma exceção ou fazer com que a função retorne um valor quando uma falha for detectada? O objetivo deste artigo é justamente demonstrar as vantagens e desvantagens de cada abordagem, com a finalidade de ajudar a tomar a decisão mais adequada para o projeto.

Um pouco sobre exceções

Toda exceção no Delphi herda direta ou indiretamente da classe Exception. Exemplo:
unit DivisaoPorZeroException;
interface
uses
  System.SysUtils;
type
  EDivisaoPorZero = class(Exception)
  public
    constructor Create();
  end;
implementation
constructor EDivisaoPorZero.Create();
begin
  inherited Create('Não é permitido dividir por zero');
end;
end.
Um típico código que utilizaria esta exceção seria o seguinte:
 procedure TfrmPrincipal.btnExcecaoClick(Sender: TObject);
var
  lQuociente: Double;
begin
  try
    lQuociente := Calculo.Dividir(5, 0);
  except
    on E: EDivisaoPorZero do
    begin
      ShowMessage(E.Message);
      Exit;
    end;
  end;

  ShowMessage(Format('O resultado da divisão é: %f', [lQuociente]));
end;
Segue o código da função Dividir(), utilizada no exemplo anterior:
unit Calculo;

interface

function Dividir(const pDividendo: Double; const pDivisor: Double): Double;

implementation

uses
  DivisaoPorZeroException;

function Dividir(const pDividendo: Double; const pDivisor: Double): Double;
begin
  if (pDivisor = 0) then
    raise EDivisaoPorZero.Create();

  Result := pDividendo / pDivisor;
end;

end.
Podemos entender que o aplicativo acima “não sabe” resolver a divisão por zero por conta própria, exigindo que o usuário tome as providências antes de prosseguir. Chegamos a esta conclusão observando que, após exibir a mensagem no bloco try..except, o código-fonte simplesmente sai da função utilizando o procedimento Exit. Este é o cenário preferencial para o lançamento de exceções. A aplicação precisa chegar a um estado de “aguardando solução” ou, dependendo do quão crítica for a rotina, deverá ser encerrada abruptamente; o máximo que pode acontecer nestas situações seria fazer uma rotina de auditoria, permitindo registrar o problema em algum lugar antes de encerrar a aplicação.

Um pouco sobre funções que retornam erro

A palavra “retornar” pode significar tanto o Result da função como um parâmetro out que o programador pode utilizar caso o retorno da função já esteja destinado a outra finalidade. Utilizando o exemplo do tópico anterior, iremos alterar um pouco a unit Calculo.pas, conforme o código abaixo:
unit Calculo;

interface

function Dividir(const pDividendo: Double; const pDivisor: Double; out pMensagemErro: string): Double;

implementation

uses
  System.Math;

function Dividir(const pDividendo: Double; const pDivisor: Double; out pMensagemErro: string): Double;
begin
  if (pDivisor = 0) then
  begin
    pMensagemErro := 'Não é permitido dividir por zero';
    Result := NaN;
    Exit;
  end;

  Result := pDividendo / pDivisor;
end;

end.
A lógica é bem semelhante ao último exemplo, mas ao invés de lançar a exceção, a função preenche o parâmetro “pMensagemErro” caso algum problema tenha ocorrido durante a rotina. O código que “chama” a função Dividir() ficaria da seguinte forma:
procedure TfrmPrincipal.btnFuncaoClick(Sender: TObject);
var
  lMensagemErro: string;
  lQuociente: Double;
begin
  lQuociente := Calculo.Dividir(5, 0, lMensagemErro);

  if (lMensagemErro <> '') then
  begin
    ShowMessage(lMensagemErro);
    Exit;
  end;

  ShowMessage(Format('O resultado da divisão é: %f', [lQuociente]));
end;
Dependendo da situação, podem surgir diversas variações desta técnica: 1. Se a rotina é um procedimento (isto é, não possui retorno), ela pode ser transformada em função e utilizar o Result para retornar a mensagem de erro. 2. Em um cenário mais simples, o procedimento poderia ser transformado em uma função que retorna Boolean, apenas indicando sucesso (True) ou falha (False). 3. Independente de utilizarmos o Result da função ou um parâmetro out, ao invés de retornar um Boolean para indicar sucesso ou falha, também é possível preencher um enumerador. Exemplo:
unit Calculo;

interface

type
  TErroCalculo = (tcDividendoIgualAZero);

{ ... }
A abordagem #3 é bem flexível e pode se beneficiar de um dos recursos mais únicos do Delphi, que é o set de enumerador:
unit Calculo;

interface

type
  TErroCalculo = (tcDividendoIgualAZero);

  TErrosCalculo = set of TErroCalculo;

{ ... }
É claro que no momento temos apenas o valor “tcDividendoIgualAZero”, mas como a unit pode evoluir para realizar diferentes tipos de cálculo, é provável que a curto prazo passem a existir mais valores para o tipo “TErroCalculo”. Não iremos nos aprofundar na utilização de enumeradores e set de enumeradores, pois o objetivo do artigo é comparar a utilização de exceções com funções que retornam o erro, mas achamos interessante mencionar esta possibilidade por se tratar de uma técnica que normalmente é considerada nestas ocasiões.

Devo usar exceções ou funções que retornam o erro?

Um provérbio chinês muito popular diz que há mais de um caminho para o topo da montanha, mas no final a vista é sempre a mesma. Ambas as técnicas, lançar exceções ou utilizar funções que retornam o erro, irão atender perfeitamente seja qual for o código-fonte. Mas conceitualmente falando, há situações onde faz mais sentido utilizar exceções do que funções e vise-e-versa. Uma regra que pode ser utilizada para decidir qual técnica adotar é a seguinte: se a aplicação tiver que ser eventualmente interrompida, seja exibindo uma mensagem para o usuário ou simplesmente abortando com um erro, então usa-se objetos de exceções. Por outro lado, se a aplicação “souber” o que precisa ser feito para contornar o problema, então adota-se funções com o retorno do erro. Esta não é uma regra definida arbitrariamente. Na maioria das linguagens de programação, o lançamento de exceções é um recurso custoso, que se for chamado repetidas vezes, pode tornar a aplicação mais lenta. Contudo, se o problema encontrado exigir que a aplicação seja interrompida, a perda de desempenho é irrelevante quando comparada à legibilidade e clareza do código-fonte. No exemplo demonstrado anteriormente, a função Dividir() apenas considera um problema, que é o parâmetro “pDivisor” ser igual a zero. Mas, dependendo da rotina, há muito mais do que apenas uma situação para ser considerada. Vamos imaginar uma rotina de emissão de nota fiscal eletrônica. Dependendo da regra de negócio adotada, poderíamos criar diversas classes de exceções: ENotaFiscalSemItem, ENotaFiscalSemDestinatario, ENotaFiscalSemEnderecoDeEntrega etc. Ao lançar exceções, a rotina que envia a nota fiscal eletrônica poderá tratar diversas situações sem a necessidade de utilizar o result da função ou passar parâmetros out. O programador que chamar esta rotina utilizará a própria sintaxe da linguagem para tratar cada caso com blocos try..except. Utilizando ainda a emissão de nota fiscal eletrônica como exemplo, vamos supor que a rotina esteja em uma aplicação desktop. Certamente, quando o usuário tentar emitir uma nota que porventura não possua itens, a aplicação irá interromper seu fluxo avisando o usuário do problema. Ou seja, ainda que tenha sido “custoso” gerar uma exceção para exibir a mensagem, não há degradação visível no desempenho. E se a aplicação fosse um serviço em contínua execução, onde centenas ou milhares de notas fiscais precisassem ser emitidas consecutivamente? Será que nesta situação valeria a pena degradar a performance da aplicação com o uso de exceções tão somente para ganhar em legibilidade e clareza do código? Antes de responder, é importante ressaltar que os sistemas normalmente passam por análises minuciosas de requisito. Se existir o requisito não-funcional de que “a aplicação não pode parar”, então a resposta à pergunta é um ressonante “não às exceções”. Observe o código abaixo:
procedure TfrmPrincipal.btnVariasExcecoesClick(Sender: TObject);
var
  i: Integer;
  lQuociente: Double;
  lStopwatch: TStopwatch;
begin
  lStopwatch := TStopwatch.StartNew();

  for i := 0 to 10000 do
    try
      lQuociente := Calculo.Dividir(5, 0);
    except
      on E: EDivisaoPorZero do
        lQuociente := NaN;
    end;

  lStopwatch.Stop();

  ShowMessage(Format('Milissegundos transcorridos: %d', [lStopwatch.ElapsedMilliseconds]));
end;
Este código exibe quantos milissegundos foram necessários para lançar 10000 exceções. Nos testes realizados, a mensagem retornou aproximadamente 29700 milissegundos; ou seja, quase 30 segundos. O aplicativo foi compilado em 32-bits no Delphi 10.2 Tokyo, mas o tempo pode variar dependendo da configuração do computador. Observe que o código está “violando” a regra proposta anteriormente, onde a exceção deve ser utilizada nos casos em que a aplicação será eventualmente interrompida. Na verdade, o que está sendo feito é utilizar a exceção para fazer um “desvio” no código, que no caso foi atribuir NaN para a variável “lQuociente”. Vamos adaptar o código acima utilizando a versão da função Dividir() que retorna o erro através de um parâmetro out:
procedure TfrmPrincipal.btnVariasChamadasDeFunçãoClick(Sender: TObject);
var
  i: Integer;
  lMensagemErro: string;
  lQuociente: Double;
  lStopwatch: TStopwatch;
begin
  lStopwatch := TStopwatch.StartNew();

  for i := 0 to 10000 do
    lQuociente := Calculo.Dividir(5, 0, lMensagemErro);

  lStopwatch.Stop();

  ShowMessage(Format('Milissegundos transcorridos: %d', [lStopwatch.ElapsedMilliseconds]));
end;
Nos testes realizados, o loop com 10000 repetições é executado em apenas 1 milissegundo, aumentando drasticamente o desempenho da rotina. De um ponto de vista mais conceitual, se a minha aplicação “sabe” o que fazer em uma divisão por zero, então a própria função Dividir() pode retornar o valor desejado (no caso, NaN) e preencher a mensagem de erro, caso o programador queira utilizá-la para alguma coisa. A RTL (Run-Time Library) do Delphi parece adotar a regra que propomos antes, como podemos observar na implementação das funções StrToFloat(), StrToFloatDef() e TryStrToFloat(), por exemplo. Vejamos: 1. A função StrToFloat() converte uma string para Extended, lançando uma exceção do tipo EConvertError caso ocorra uma falha. 2. A função TryStrToFloat() “tenta” converter uma string para Extended, retornando o valor em um parâmetro out. Caso tenha conseguido converter, a função retorna True, do contrário retornará False. 3. Por último, a função StrToFloatDef(), converte uma string para Extended. Se a conversão for bem-sucedida, a função retorna o valor convertido; do contrário, retornará o valor default passado por parâmetro. Se o “correto” fosse sempre lançar exceção ou retornar o erro através de função ou parâmetro out, certamente a RTL do Delphi não teria três funções de conversão. O programador é quem decide qual utilizar de acordo com o conceito da aplicação. Voltando para o exemplo do serviço que emite notas fiscais eletrônicas, imagine que o usuário tenha indicado expressamente que a rotina de envio é tão crucial para o seu negócio que, em caso de falha, a aplicação realmente precise ser interrompida. Neste caso, sendo consoante à regra definida anteriormente, devemos fazer uso de lançamento de exceções. Ainda que o usuário da aplicação solicite que um e-mail seja enviado em caso de falha; eventualmente, após o envio do e-mail, a aplicação deverá ser finalizada de acordo com o requisito não-funcional previamente estabelecido. Desta forma, o sacrifício da performance acaba se tornando irrelevante, ao passo de que ganhamos legibilidade e toda clareza que o lançamento de exceções traz para o código.

Inscreva-se na nossa newsletter!

code, Delphi, Object Pascal, programação, Tecnologia

Deixe uma resposta

O seu endereço de e-mail não será publicado.

Sobre

Desde o início, sempre com soluções próprias, +400k sistemas em operação com facilidade, simplesmente porque acredita que ter bons produtos é fundamental, além do essencial, é ter um ótimo atendimento.

©1989 - 2019 - Alterdata Software - Direitos reservados.