Dominando Exceptions em Ruby: Guia Completo para Tratamento de Erros

Até agora, desenvolvemos código em um mundo perfeito onde nada jamais dá errado. Toda chamada de biblioteca funciona, usuários nunca inserem dados incorretos e os recursos são abundantes. Bem, isso está prestes a mudar. No mundo real, erros acontecem, e bons programas (e programadores) os antecipam e se organizam para tratá-los elegantemente.

O Problema dos Códigos de Retorno

Uma abordagem tradicional é usar códigos de retorno para sinalizar erros. Por exemplo, o método File.open poderia retornar algum valor específico para indicar falha. O problema dessa abordagem é que gerenciar todos esses códigos de erro pode ser uma dor de cabeça. Se uma função chama open, depois read e finalmente close, e cada uma pode retornar uma indicação de erro, como a função pode distinguir esses códigos de erro no valor que retorna ao seu chamador?

Ruby usa exceptions para ajudar a resolver o problema de responder a erros. Exceptions permitem que você empacote informações sobre um erro em um objeto. Esse objeto de exception é então propagado automaticamente de volta pela pilha de chamadas até que o sistema de runtime encontre código que declare explicitamente que sabe como lidar com esse tipo de exception.

A Classe Exception e sua Hierarquia

Informações sobre uma exception são encapsuladas em um objeto da classe Exception ou em uma de suas classes filhas. Ruby predefine uma hierarquia organizada de exceptions. A subclasse mais importante de Exception é StandardError. A exception StandardError e suas subclasses representam as condições excepcionais que você vai querer capturar em seu código.

Quando você precisa criar uma exception personalizada, suas classes de exception devem ser subclasses de StandardError ou uma de suas filhas. Frequentemente, o único dado novo associado a uma exception personalizada é que ela é uma exception personalizada, então você pode declará-la em uma linha:

class UsuarioAusenteError < StandardError; end

Ponto e vírgula, que são raros em Ruby, são usados para separar expressões quando você coloca mais de uma em uma linha. Por convenção, nomes de classes de exception personalizadas terminam com Error.

Tratando Exceptions

Vamos ver um exemplo de código que baixa o conteúdo de uma página web e o escreve em um arquivo, linha por linha:

require "open-uri"

URI.open("https://pragprog.com/news/index.html") do |pagina_web|
  saida = File.open("index.html", "w")
  while (linha = pagina_web.gets)
    saida.puts linha
  end
  saida.close
end

O que acontece se ocorrer um erro fatal no meio do processo? Certamente não queremos armazenar uma página incompleta no arquivo de saída. Vamos adicionar código de tratamento de exceptions:

require "open-uri"

nome_arquivo = "index.html"
URI.open("https://pragprog.com/news/#{nome_arquivo}") do |pagina_web|
  saida = File.open(nome_arquivo, "w")
  begin
    while (linha = pagina_web.gets)
      saida.puts linha
    end
    saida.close
  rescue StandardError
    $stderr.warn "Falha ao baixar #{nome_arquivo}: #{$!}"
    saida.close
    File.delete(nome_arquivo)
    raise
  end
end

Múltiplas Cláusulas Rescue

Você pode ter múltiplas cláusulas rescue em um método ou bloco begin, e cada cláusula rescue pode especificar múltiplas exceptions para capturar. No final de cada cláusula rescue, você pode dar ao Ruby o nome de uma variável local para receber a exception correspondida:

begin
  eval string_codigo
rescue SyntaxError, NameError => e
  print "String não compila: " + e.to_s
rescue StandardError => e
  print "Erro executando script: " + e.to_s
end

Garantindo Limpeza com Ensure

Às vezes você precisa garantir que um processamento específico seja feito no final de um bloco de código, independentemente de uma exception ter sido levantada. A cláusula ensure faz exatamente isso. Uma cláusula ensure vai após a última cláusula rescue e sempre será executada:

arquivo = File.open("arquivo_teste")
begin
  # .. processar
rescue
  # .. tratar erro
ensure
  arquivo.close
end

A cláusula else é uma construção similar, embora menos útil. Se presente, vai após as cláusulas rescue e antes de qualquer ensure. O corpo de uma cláusula else é executado apenas se nenhuma exception for levantada pelo corpo principal do código:

arquivo = File.open("arquivo_teste")
begin
  # .. processar
rescue
  # .. tratar erro
else
  puts "Parabéns-- nenhum erro!"
ensure
  arquivo.close
end

Tentando Novamente com Retry

Às vezes você pode ser capaz de corrigir a causa de uma exception. Nesses casos, você pode usar a instrução retry dentro de uma cláusula rescue para repetir todo o bloco begin/end. Claramente, existe um tremendo potencial para loops infinitos aqui, então este é um recurso para usar com cuidado:

tentativas = 0
begin
  tentativas += 1
  @conexao = @servidor_remoto.ler_dados
rescue TimeOutError
  if @servidor_remoto && tentativas < 10 then
    sleep(tentativas ** 2)
    retry
  else
    raise
  end
end

Este código tenta ler dados do servidor_remoto. Se retornar um TimeOutError e se o servidor_remoto existir, o código dorme por um tempo e tenta novamente. Ele acompanha o número de tentativas, aumentando o timeout, até que eventualmente, se o número de tentativas ficar muito alto, pare de tentar se conectar e apenas levante o erro.

Levantando Exceptions

Você pode levantar exceptions em seu código com o método raise (ou seu sinônimo menos usado, fail). Existem várias formas de usar raise:

raise                                        # re-levanta a exception atual
raise "codificação mp3 ruim"                  # cria RuntimeError
raise InterfaceException, "Falha no teclado" # exception específica

Aqui estão alguns exemplos típicos de raise em ação:

raise
raise "Nome ausente" if nome.nil?
if i >= nomes.size
  raise IndexError, "#{i} >= tamanho (#{nomes.size})"
end
raise ArgumentError, "Nome muito grande", caller

Adicionando Informações às Exceptions

Você pode definir suas próprias exceptions para armazenar qualquer informação necessária para transmitir do local do erro. Por exemplo, certos tipos de erros de rede podem ser temporários dependendo das circunstâncias:

class TentarNovamenteException < RuntimeError
  attr_reader :ok_para_tentar_novamente
  
  def initialize(ok_para_tentar_novamente)
    @ok_para_tentar_novamente = ok_para_tentar_novamente
  end
end

# Uso da exception personalizada
def ler_dados(contador_tentativas)
  dados = @socket.read(512)
  if dados.nil?
    raise TentarNovamenteException.new(contador_tentativas < 10), 
          "erro temporário de leitura"
  end
  # .. processamento normal
end

# Tratamento da exception
tentativas = 0
begin
  tentativas += 1
  @conexao = @servidor_remoto.ler_dados(tentativas)
rescue TentarNovamenteException => e
  retry if e.ok_para_tentar_novamente
  raise
end

Usando Catch e Throw

Embora o mecanismo de exception de raise e rescue seja ótimo para abandonar a execução quando as coisas dão errado, às vezes é útil poder saltar de uma construção profundamente aninhada durante o processamento normal. É aqui que os raramente usados catch e throw são úteis.

lista_palavras = File.open("lista_palavras")
catch(:concluido) do
  resultado = []
  while (linha = lista_palavras.gets)
    palavra = linha.chomp
    throw :concluido unless /^\w+$/.match?(palavra)
    resultado << palavra
  end
  puts resultado.reverse
end

catch define um bloco rotulado com o nome dado. O bloco é executado normalmente até que um throw seja encontrado. Quando Ruby encontra um throw, ele volta pela pilha de chamadas procurando um bloco catch com um símbolo correspondente.

Se o throw for chamado com um segundo parâmetro opcional, esse valor é retornado como o valor do catch:

def perguntar_e_obter(pergunta)
  print pergunta
  resposta = readline.chomp
  throw :saida_solicitada if resposta == "!"
  resposta
end

catch :saida_solicitada do
  nome = perguntar_e_obter("Nome: ")
  idade = perguntar_e_obter("Idade: ")
  sexo = perguntar_e_obter("Sexo: ")
  # processar informações
end

Boas Práticas para Tratamento de Exceptions

Para finalizar este guia completo sobre exceptions em Ruby, aqui estão algumas boas práticas essenciais:

1. Sempre capture StandardError ou suas subclasses - Nunca capture Exception diretamente, pois isso pode mascarar erros críticos do sistema.

2. Use ensure para limpeza obrigatória - Recursos como arquivos e conexões de rede devem sempre ser fechados no bloco ensure.

3. Seja específico com rescue - Capture apenas as exceptions que você sabe como tratar adequadamente.

4. Crie exceptions personalizadas quando apropriado - Elas tornam seu código mais expressivo e facilitam o tratamento específico de erros.

5. Use retry com parcimônia - Sempre implemente um contador para evitar loops infinitos.

Com essas técnicas, você estará preparado para criar aplicações Ruby robustas e resilientes, capazes de lidar elegantemente com situações excepcionais que inevitavelmente surgem no desenvolvimento de software real.

Comentários (0)

Nenhum comentário:

Postar um comentário