Skip to content

Instantly share code, notes, and snippets.

@italoag
Last active August 15, 2019 19:55
Show Gist options
  • Save italoag/e2f2721df938edbcb8f2111c21d6456b to your computer and use it in GitHub Desktop.
Save italoag/e2f2721df938edbcb8f2111c21d6456b to your computer and use it in GitHub Desktop.
Error Handling

Error Handling

Erro ao lidar com armadilhas no Scala

Existem várias estratégias para manipulação de erros no Scala.

Erros podem ser representados como exceções, o que é uma maneira comum de lidar com erros em linguagens como Java. No entanto, as exceções são invisíveis para o sistema de tipos, o que pode dificultar o seu tratamento. É fácil deixar de fora o tratamento de erros necessário, o que pode resultar em erros infelizes de tempo de execução.

No Scala, geralmente é recomendado representar erros como parte do tipo de retorno. Os tipos de biblioteca padrão do Scala, como Either e Try, podem ser usados ​​para capturar erros em operações síncronas, enquanto Future pode ser usado para representar operações assíncronas. Além disso, os programadores do Scala também podem representar diferentes resultados como um tipo personalizado usando recursos de traços selados.

Formas de ocultar acidentalmente erros

A menos que você estiver usando tipos personalizados para representar resultados diferentes, o tipo de retorno que você usa para capturar erros mais provável vem com métodos de composição: map, flatMap, e withFilter. Com esses métodos implementados, podemos aproveitar as abrangências do Scala ao criar programas que podem produzir erros.

// Exemplo #1
def foo(i: Int): Try[String] = ???

def bar(): Try[String] =
  for { // equivalent to foo(1).flatMap(x => foo(2).map(y => x + y))
    x <- foo(1)
    y <- foo(2)
  } yield x + y

No exemplo acima (exemplo #1), foo pode produzir um erro, que é representado no Try tipo de retorno. Quando compomos as operações juntas usando for / flatMap, estamos descrevendo como o programa deve funcionar no cenário do caminho feliz. Se, a qualquer momento, um valor de erro for atendido, as funções que o seguem não serão executadas e o valor do erro será escalado como o valor de retorno. Usando esses métodos de composição, podemos criar facilmente programas que tenham fluxos semelhantes ao tratamento de erros baseado em exceção mais tradicional e, ao mesmo tempo, podemos ter os erros representados no tipo de retorno. Esse estilo de composição sequencial é chamado de estilo monádico .

Como podemos ver no programa de exemplo, a composição é uma etapa manual que o desenvolvedor deve cumprir. O que aconteceria se partes do nosso programa não seguissem o fluxo da composição?

// Exemplo #2
def foo(i: Int): Try[String] = ???

def bar(): Try[String] = {
  val xf = foo(1)
  val yf = foo(2)
  val zf = foo(3) // unused

  for {
    x <- xf
    y <- yf
  } yield x + y
}

O exemplo acima (exemplo #2) é muito semelhante ao exemplo #1. Os dois exemplos compõem os mesmos resultados e o cenário do caminho feliz funciona da mesma maneira.

Existem algumas diferenças em como esses exemplos funcionam em tempo de execução. No exemplo 2, o resultado de foo(3) é atribuído o nome zf, mas seu resultado nunca é usado. Porque nunca é usado, também não afetará o resultado de bar. Portanto, qualquer erro que foo(3) possa produzir será completamente silencioso.

Outra propriedade do exemplo 2 é que fizemos várias chamadas foo, mas só capturamos no máximo um dos erros que podem ocorrer quando executamos o programa. Isso ocorre porque a composição sempre terminará quando encontrar o primeiro erro. Por exemplo, se ambos xfe yfconter um erro, então o resultado de bar vai ser o mesmo que xf.

Em muitos casos, a captura desses casos de erro é vital. Se foo não houver efeitos colaterais (por exemplo, foo para calcular a raiz quadrada de um número), a chamada de função simplesmente desperdiça alguns recursos de computação pela duração da chamada de função. No entanto, se houver efeitos colaterais em foo (por exemplo, foo gravar a entrada no banco de dados), é provável que, pelo menos, você queira capturar o erro que pode ter produzido em vez de permitir que ele falhe silenciosamente em segundo plano.

O que torna essas propriedades problemáticas é como é fácil se meter em problemas. Alterar levemente o posicionamento em que essas chamadas de função são feitas cria diferenças sutis em como (ou se) os casos de erro são manipulados. Muitas vezes, gostaríamos que os dois estilos apresentados nesses exemplos funcionassem da mesma forma em tempo de execução.

Essas propriedades não estão limitadas a apenas Try. Ambos Eithere e Future compartilham as mesmas propriedades.

Também é importante destacar que você também pode se meter em problemas usando alguns dos métodos desses tipos. Por exemplo, Try, Eithere e Future possuem o método foreach, que irá executar a função dada apenas para resultados bem sucedidos, mas vai ignorar completamente o cenário de falha:

def foo(i: Int): Try[String] = ???

def bar() = { // the type is Unit
  val x = foo(2)
  x.foreach(println(_))
}

Em busca de uma solução alternativa

O problema que estamos vendo com Either, Try e Future é que todos eles são avaliados ansiosamente e ao mesmo tempo eles podem causar efeitos colaterais.

Essas propriedades parecem problemáticas juntas. E se nossos cálculos tivessem apenas uma dessas propriedades? Se a computação puder ter efeitos colaterais, mas não for avaliada até que seja examinada, poderemos construir nossos cálculos para que os efeitos colaterais não sejam executados até que haja um erro no tratamento. Por outro lado, se o cálculo não pode ter efeitos colaterais, o pior que você faz é desperdiçar alguns ciclos de CPU e memória.

Explorando essas combinações temos algumas soluções que podem ser encontradas.

Efeitos colaterais com avaliação preguiçosa

Os valores Try do Scala são avaliados avidamente. Vamos criar nossa própria versão do Try que é avaliado com lazy. Quando o valor é examinado, ele produz um Scala Try como resultado.

import scala.util.{Try, Success, Failure}

// WARNING: This is only a demo!
final class Attempt[A](proc: => A) {

  def evaluate(): Try[A] = Try(proc)

  def evaluateUnsafe(): A = proc

  def map[B](f: A => B): Attempt[B] =
    new Attempt[B](f(proc))

  def flatMap[B](f: A => Attempt[B]): Attempt[B] =
    new Attempt[B](f(proc).evaluateUnsafe())

  def withFilter(f: A => Boolean): Attempt[A] =
    new Attempt[A]({
      val r = proc
      if (f(r)) r
      else throw new NoSuchElementException("filter == false")
    })
}

object Attempt {
  def apply[A](proc: => A): Attempt[A] = new Attempt(proc)
}

Na listagem de código acima, foi definida uma versão propria do Try chamado Attempt. Attempt é dado um procedimento como um parâmetro, que pode produzir um valor ou lançar uma exceção quando avaliado. O procedimento pode ser avaliado de duas maneiras: A avaliação com segurança produzirá o resultado do procedimento envolvido em uma Scala padrão Try, enquanto a avaliação insegura escalará quaisquer erros como exceções. A fim de fazer Attempt combináveis, ele também tem definições para os métodos map, flatMap e withFilter.

Vamos ver Attempt em ação. Vamos definir um programa com o Attempt qual executamos efeitos colaterais e verificar se apenas a parte de efeitos colaterais do fluxo será executada.

def attemptPrint(s: String): Attempt[Unit] = Attempt(println(s))

def attemptOkExample: Attempt[Int] = {
  val _ = attemptPrint("I won't be printed")
  for {
    x <- Attempt(1)
    _ <- attemptPrint("x = " + x)
    y <- Attempt(2)
    _ <- attemptPrint("y = " + y)
  } yield x + y
}

attemptOkExample.evaluate() match {
  case Success(i) => println("Got: " + i)
  case Failure(ex) => println("Failed: " + ex.getMessage)
}

No exemplo acima, o fluxo do programa imprime dois números e os soma. Como parte da mesma chamada de função e fora do fluxo, há outro comando de impressão envolto em um arquivo Attempt. Como Attempt é avaliado com preguiça, o comando de impressão não autorizado não será executado. Podemos verificar isso a partir da saída do programa:

x = 1
y = 2
Got: 3  

Poderíamos criar uma solução semelhante para o Scala Future, que nos forneceria cálculos de avaliação preguiçosa em um contexto assíncrono. Em vez de implementar tudo sozinhos, podemos aproveitar as soluções existentes de bibliotecas de terceiros:

ScalaZ fornece tarefa Catz fornece IO Monix fornece Tarefa e Coeval

Todos os três são mais preguiçosos do que ansiosos. O ScalaZ Task e o Catz IO são projetados para cálculos síncronos e assíncronos, enquanto o Monix Task é projetado apenas para cálculos assíncronos. Monix fornece um tipo separado, Coeval, para manipular cálculos síncronos.

Sem efeitos colaterais com avaliação ansiosa

Programas Scala podem ter efeitos colaterais em qualquer ponto do código. O programador pode seguir rigorosamente o idioma de limitar o uso de efeitos colaterais, mas não há muito que o compilador possa fazer para ajudar. No entanto, se alguém decidir seguir esse idioma, os tipos de biblioteca padrão do Scala serão suficientes para evitar os problemas apresentados anteriormente. A limitação aqui é, claro, que agora não temos como expressar efeitos dentro de nossos cálculos.

Sem efeitos colaterais com avaliação preguiçosa

Os efeitos são incrivelmente úteis e, geralmente, gostaríamos de ter pelo menos alguma maneira de expressá-los em nossos programas. No entanto, a combinação de ansiosas avaliações e efeitos nos leva de volta ao problema que apresentamos anteriormente. Podemos construir um sistema onde os efeitos são expressos como cálculos puros?

Anteriormente, exploramos algumas soluções para cálculos que são preguiçosos e têm efeitos colaterais. E se tivéssemos que eliminar os efeitos colaterais e a avaliação ansiosa?

Neste sistema, todos os efeitos seriam representados como valores em oposição aos efeitos colaterais. Esses valores podem ser compostos juntos para criar programas. Quando estamos prontos para executar nosso programa, nós o executamos através de um intérprete que traduz nossos valores em efeitos colaterais reais.

Uma maneira de implementar esse tipo de sistema é usar Free Monads.

Estou preso com Either/Try/Future! O que eu faço?

Nem todo projeto pode alternar para tipos de computação alternativos, como Task. Ainda menos projetos podem começar a usar Free Monads. O projeto pode estar fortemente ligado a tipos Scala existentes ou simplesmente não pode incluir outra dependência.

Qualquer que seja a razão, ainda seria bom se pudéssemos detectar alguns dos problemas apresentados neste artigo. Ter conhecimento do problema combinado com um processo de revisão de código adequado ajuda, mas seria ainda melhor ter algo que pudéssemos automatizar.

O compilador Scala fornece alguns sinalizadores de compilador úteis que podem ajudar a detectar possíveis problemas, produzindo avisos durante a compilação:

  • -Ywarn-dead-code ajuda a detectar qualquer código inacessível.
  • -Ywarn-unused:locals (2.12.x) e -Ywarn-unused (2.11.x) ajudam a detectar valores não utilizados em funções.
  • -Ywarn-value-discard ajuda a detectar quando os resultados não relacionados à unidade não são usados. Se você realmente não precisa do valor, você pode explicitamente atribuí-lo _para deixar claro que você quer descartar o valor. Tenha em mente que você não pode atribuir vários valores _devido ao SI-7691. Para contornar esse problema, você pode criar uma função ou um método que descarta o valor para você.
  • -Xfatal-warnings transforma todos os avisos em erros de compilação. Dessa forma, você pode impor conformidade a essas regras no momento da criação. As flags não detectarão todos os problemas por conta própria. Por exemplo, não há sinalização para detectar quando você compõe dois resultados já executados. No entanto, eles ainda podem ser úteis para destacar alguns dos problemas comuns.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment