Por que usar a exceção Mais de (marcada)?

17

Há pouco tempo, comecei a usar o Scala em vez do Java. Parte do processo de "conversão" entre os idiomas para mim foi aprender a usar Eithers em vez de (marcado) Exceptions. Eu tenho codificado dessa maneira por um tempo, mas recentemente comecei a me perguntar se esse é realmente o melhor caminho a percorrer.

Uma grande vantagem Eithertem mais Exceptioné melhor desempenho; um Exceptionprecisa criar um rastreamento de pilha e está sendo lançado. Até onde eu entendo, jogar a Exceptionparte não é a parte mais exigente, mas construir o rastreamento da pilha é.

Mas então, sempre é possível construir / herdar Exceptions com scala.util.control.NoStackTrace, e mais ainda, vejo muitos casos em que o lado esquerdo de um Eitheré de fato um Exception(renunciando ao aumento de desempenho).

Uma outra vantagem Eitheré a segurança do compilador; o compilador Scala não se queixará de Exceptions não manipulados (ao contrário do compilador do Java). Mas se não me engano, essa decisão é fundamentada com o mesmo raciocínio discutido neste tópico, então ...

Em termos de sintaxe, sinto que o Exceptionestilo é muito mais claro. Examine os seguintes blocos de código (ambos obtendo a mesma funcionalidade):

Either estilo:

def compute(): Either[String, Int] = {

    val aEither: Either[String, String] = if (someCondition) Right("good") else Left("bad")

    val bEithers: Iterable[Either[String, Int]] = someSeq.map {
        item => if (someCondition(item)) Right(item.toInt) else Left("bad")
    }

    for {
        a <- aEither.right
        bs <- reduce(bEithers).right
        ignore <- validate(bs).right
    } yield compute(a, bs)
}

def reduce[A,B](eithers: Iterable[Either[A,B]]): Either[A, Iterable[B]] = ??? // utility code

def validate(bs: Iterable[Int]): Either[String, Unit] = if (bs.sum > 22) Left("bad") else Right()

def compute(a: String, bs: Iterable[Int]): Int = ???

Exception estilo:

@throws(classOf[ComputationException])
def compute(): Int = {

    val a = if (someCondition) "good" else throw new ComputationException("bad")
    val bs = someSeq.map {
        item => if (someCondition(item)) item.toInt else throw new ComputationException("bad")
    }

    if (bs.sum > 22) throw new ComputationException("bad")

    compute(a, bs)
}

def compute(a: String, bs: Iterable[Int]): Int = ???

O último parece muito mais limpo para mim, e o código que trata da falha (com correspondência de padrões Eitherou try-catch) é bastante claro nos dois casos.

Então, minha pergunta é - por que usar Eitherover (marcado) Exception?

Atualizar

Depois de ler as respostas, percebi que poderia não ter apresentado o cerne do meu dilema. Minha preocupação não é com a falta de try-catch; pode-se "pegar" um Exceptioncom Tryou usar o catchpara quebrar a exceção com Left.

Meu principal problema com Either/ Trysurge quando escrevo código que pode falhar em muitos pontos ao longo do caminho; nesses cenários, ao encontrar uma falha, tenho que propagar essa falha por todo o meu código, tornando o código muito mais complicado (como mostra os exemplos mencionados acima).

Na verdade, existe outra maneira de quebrar o código sem Exceptions usando return(que na verdade é outro "tabu" no Scala). O código ainda seria mais claro que a Eitherabordagem e, embora um pouco menos limpo que o Exceptionestilo, não haveria medo de Exceptions não capturados .

def compute(): Either[String, Int] = {

  val a = if (someCondition) "good" else return Left("bad")

  val bs: Iterable[Int] = someSeq.map {
    item => if (someCondition(item)) item.toInt else return Left("bad")
  }

  if (bs.sum > 22) return Left("bad")

  val c = computeC(bs).rightOrReturn(return _)

  Right(computeAll(a, bs, c))
}

def computeC(bs: Iterable[Int]): Either[String, Int] = ???

def computeAll(a: String, bs: Iterable[Int], c: Int): Int = ???

implicit class ConvertEither[L, R](either: Either[L, R]) {

  def rightOrReturn(f: (Left[L, R]) => R): R = either match {
    case Right(r) => r
    case Left(l) => f(Left(l))
  }
}

Basicamente, as return Leftsubstituições throw new Exceptione o método implícito em qualquer uma delas rightOrReturnsão um complemento para a propagação automática de exceções na pilha.

Eyal Roth
fonte
Leia isto: mauricio.github.io/2014/02/17/…
Robert Harvey
@RobertHarvey Parece que a maior parte do artigo discute Try. A parte sobre Eithervs Exceptionsimplesmente afirma que Eithers deve ser usado quando o outro caso do método for "não excepcional". Primeiro, essa é uma definição muito, muito vaga. Segundo, vale mesmo a pena a sintaxe? Quero dizer, eu realmente não me importaria de usar Eithers se não fosse pela sobrecarga de sintaxe que eles apresentam.
Eyal Roth 8/16
Eitherparece uma mônada para mim. Use-o quando precisar dos benefícios de composição funcional que as mônadas fornecem. Ou talvez não .
Robert Harvey
2
@RobertHarvey: Tecnicamente falando, Eitherpor si só não é uma mônada. A projeção para o lado esquerdo ou direito é uma mônada, mas Eitherpor si só não é. Você pode torná- la uma mônada, "influenciando" para o lado esquerdo ou o lado direito. No entanto, você transmite uma certa semântica nos dois lados de um Either. O Scala's Eitherera originalmente imparcial, mas era tendencioso recentemente, de modo que atualmente é de fato uma mônada, mas a "monadness" não é uma propriedade inerente, Eithermas sim um resultado de ser tendenciosa.
Jörg W Mittag
@ JörgWMittag: Bem, bom. Isso significa que você pode usá-lo para toda a sua bondade monádica e não se incomodar particularmente com o quão "limpo" o código resultante é (embora eu suspeite que o código de continuação no estilo funcional seja realmente mais limpo que a versão Exception).
Robert Harvey

Respostas:

13

Se você usar apenas um bloco Eitherexatamente como um imperativo try-catch, é claro que parecerá uma sintaxe diferente para fazer exatamente a mesma coisa. Eitherssão um valor . Eles não têm as mesmas limitações. Você pode:

  • Pare com o hábito de manter seus blocos de tentativa pequenos e contíguos. A parte "catch" de Eithersnão precisa estar ao lado da parte "try". Pode até ser compartilhado em uma função comum. Isso torna muito mais fácil separar as preocupações adequadamente.
  • Armazenar Eithersem estruturas de dados. Você pode percorrê-las e consolidar mensagens de erro, encontrar a primeira que não falhou, avaliá-las preguiçosamente ou semelhante.
  • Estenda e personalize-os. Não gosta de algum tipo de clichê com o qual você é forçado a escrever Eithers? Você pode escrever funções auxiliares para facilitar o trabalho para seus casos de uso específicos. Ao contrário do try-catch, você não fica permanentemente preso apenas à interface padrão criada pelos designers de idiomas.

Observe que na maioria dos casos de uso, a Trypossui uma interface mais simples, possui a maioria dos benefícios acima e geralmente também atende às suas necessidades. Um Optioné ainda mais simples se você só se importa com sucesso ou falha e não precisa de nenhuma informação de estado, como mensagens de erro sobre o caso de falha.

Na minha experiência, Eithersnão são realmente preferidos, a Trysmenos que Leftseja algo diferente de uma Exception, talvez uma mensagem de erro de validação, e você queira agregar os Leftvalores. Por exemplo, você está processando alguma entrada e deseja coletar todas as mensagens de erro, e não apenas erros na primeira. Essas situações não surgem com muita frequência na prática, pelo menos para mim.

Olhe além do modelo try-catch e você encontrará Eithersmuito mais agradável para trabalhar.

Atualização: esta pergunta ainda está recebendo atenção e eu não tinha visto a atualização do OP esclarecendo a questão da sintaxe, então pensei em adicionar mais.

Como um exemplo de quebra da mentalidade de exceção, é assim que eu normalmente escreveria seu código de exemplo:

def compute(): Either[String, Int] = {
  Right(someSeq)
    .filterOrElse(someACondition,            "someACondition failed")
    .filterOrElse(_ forall someSeqCondition, "someSeqCondition failed")
    .map(_ map {_.toInt})
    .filterOrElse(_.sum <= 22, "Sum is greater than 22")
    .map(compute("good",_))
}

Aqui você pode ver que a semântica de filterOrElsecapturar perfeitamente o que estamos fazendo principalmente nesta função: verificar condições e associar mensagens de erro às falhas especificadas. Como esse é um caso de uso muito comum, filterOrElseestá na biblioteca padrão, mas se não fosse, você poderia criá-lo.

Este é o ponto em que alguém cria um contra-exemplo que não pode ser resolvido exatamente da mesma maneira que o meu, mas o que estou tentando destacar é que não há apenas uma maneira de usar Eithers, ao contrário das exceções. Existem maneiras de simplificar o código para quase todas as situações de tratamento de erros, se você explorar todas as funções disponíveis para uso Eitherse estiver aberto à experimentação.

Karl Bielefeldt
fonte
1. Ser capaz de separar o argumento trye catché um argumento justo, mas isso não poderia ser facilmente alcançado com Try? 2. O armazenamento de Eithers nas estruturas de dados pode ser feito juntamente com o throwsmecanismo; não é simplesmente uma camada extra além das falhas de manipulação? 3. Você pode definitivamente estender Exceptions. Claro, você não pode mudar a try-catchestrutura, mas lidar com falhas é tão comum que eu definitivamente quero que o idioma me forneça um padrão para isso (que não sou obrigado a usar). É como dizer que a compreensão é ruim porque é um clichê para operações monádicas.
Eyal Roth
Acabei de perceber que você disse que Eithers podem ser estendidos, onde claramente eles não podem (sendo um sealed traitcom apenas duas implementações, ambas final). Você pode elaborar sobre isso? Estou assumindo que você não quis dizer o significado literal de estender.
Eyal Roth
1
Eu quis dizer estendido como na palavra em inglês, não na palavra-chave de programação. Incluindo funcionalidade criando funções para lidar com padrões comuns.
Karl Bielefeldt
8

Os dois são realmente muito diferentes. EitherA semântica é muito mais geral do que apenas representar cálculos com potencial de falha.

Eitherpermite retornar um dos dois tipos diferentes. É isso aí.

Agora, um desses dois tipos pode ou não representar um erro e o outro pode ou não representar sucesso, mas esse é apenas um possível caso de uso de muitos. Eitheré muito, muito mais geral que isso. Você também pode ter um método que leia a entrada do usuário que tem permissão para inserir um nome ou retornar uma data Either[String, Date].

Ou, pense nos literais lambda em C♯: eles avaliam para um Funcou um Expression, ou seja, avaliam para uma função anônima ou avaliam para um AST. Em C♯, isso acontece "magicamente", mas se você quisesse dar um tipo adequado, seria Either<Func<T1, T2, …, R>, Expression<T1, T2, …, R>>. No entanto, nenhuma das duas opções é um erro e nenhuma das duas é "normal" ou "excepcional". Eles são apenas dois tipos diferentes que podem ser retornados da mesma função (ou, neste caso, atribuídos ao mesmo literal). [Nota: na verdade, Funcdeve ser dividido em outros dois casos, porque C♯ não possui um Unittipo e, portanto, algo que não retorna nada não pode ser representado da mesma maneira que algo que retorna algo,Either<Either<Func<T1, T2, …, R>, Action<T1, T2, …>>, Expression<T1, T2, …, R>>

Agora, Either pode ser usado para representar um tipo de sucesso e um tipo de falha. E se você concordar com uma ordenação consistente dos dois tipos, você pode até mesmo viés Either a preferir um dos dois tipos, tornando assim possível para propagar falhas através de encadeamento monádico. Mas nesse caso, você está dando uma certa semântica Eitherque ela não possui necessariamente por si só.

Um Eitherque tem um de seus dois tipos corrigidos para ser um tipo de erro e é inclinado para um lado para propagar erros, geralmente é chamado de Errormônada e seria uma opção melhor para representar uma computação com potencial de falha. Em Scala, esse tipo de tipo é chamado Try.

Faz muito mais sentido comparar o uso de exceções do Tryque com Either.

Portanto, esta é a razão nº 1 porque Eitherpode ser usada sobre exceções verificadas: Eitheré muito mais geral do que exceções. Pode ser usado nos casos em que as exceções nem se aplicam.

No entanto, você provavelmente estava perguntando sobre o caso restrito em que apenas usa Either(ou mais provável Try) como um substituto para exceções. Nesse caso, ainda existem algumas vantagens, ou melhor, diferentes abordagens possíveis.

A principal vantagem é que você pode adiar a manipulação do erro. Você apenas armazena o valor de retorno, sem sequer examinar se é um sucesso ou um erro. (Lide com isso mais tarde.) Ou passe para outro lugar. (Deixe alguém se preocupar com isso.) Colete-os em uma lista. (Manipule todos eles de uma vez.) Você pode usar o encadeamento monádico para propagá-los ao longo de um pipeline de processamento.

A semântica das exceções é conectada à linguagem. No Scala, as exceções desenrolam a pilha, por exemplo. Se você permitir que eles atinjam um manipulador de exceção, ele não poderá voltar ao local onde ocorreu e corrigi-lo. OTOH, se você o pegar mais baixo na pilha antes de desenrolá-lo e destruir o contexto necessário para corrigi-lo, poderá estar em um componente de nível muito baixo para tomar uma decisão informada sobre como proceder.

Este é diferente em Smalltalk, por exemplo, onde exceções não desanuviar a pilha e são resumable, e você pode pegar o alto exceção para cima na pilha (possivelmente tão alto quanto o depurador), arrumar o erro waaaaayyyyy para baixo na empilhar e retomar a execução a partir daí. Ou as condições CommonLisp em que aumentar, capturar e manipular são três coisas diferentes em vez de duas (manipular e capturar e manipular), como as exceções.

O uso de um tipo de retorno de erro real em vez de uma exceção permite que você faça coisas semelhantes e muito mais, sem precisar modificar o idioma.

E depois há o elefante óbvio na sala: exceções são um efeito colateral. Os efeitos colaterais são maus. Nota: não estou dizendo que isso seja necessariamente verdade. Mas é um ponto de vista que algumas pessoas têm e, nesse caso, a vantagem de um tipo de erro sobre exceções é óbvia. Portanto, se você quiser fazer programação funcional, poderá usar tipos de erro pelo único motivo de que eles são a abordagem funcional. (Observe que Haskell tem exceções. Observe também que ninguém gosta de usá-las.)

Jörg W Mittag
fonte
Ame a resposta, embora ainda não veja por que devo renunciar Exception. Eitherparece ser uma ótima ferramenta, mas sim, estou perguntando especificamente sobre como lidar com falhas e prefiro usar uma ferramenta muito mais específica para minha tarefa do que uma ferramenta totalmente poderosa. Adiar o tratamento de falhas é um argumento justo, mas pode-se simplesmente usar Tryum método lançando um Exceptionse ele não quiser lidar com isso no momento. O desenrolamento de rastreamento de pilha não afeta Either/ Trytambém? Você pode repetir os mesmos erros ao propagá-los incorretamente; saber quando lidar com uma falha não é trivial nos dois casos.
Eyal Roth
E eu realmente não posso argumentar com raciocínio "puramente funcional", não posso?
Eyal Roth
0

O bom das exceções é que o código de origem já classificou a circunstância excepcional para você. A diferença entre an IllegalStateExceptione a BadParameterExceptioné muito mais fácil de diferenciar em idiomas tipificados mais fortes. Se você tentar analisar "bom" e "ruim", não estará aproveitando a ferramenta extremamente poderosa à sua disposição, a saber, o compilador Scala. Suas próprias extensões Throwablepodem incluir o máximo de informações necessárias, além da sequência de mensagens de texto.

Essa é a verdadeira utilidade do Tryambiente Scala, Throwablepois atua como o invólucro dos itens à esquerda para facilitar a vida.

BobDalgleish
fonte
2
Eu não entendo o seu ponto.
Eyal Roth
Eithertem problemas de estilo porque não é uma mônada verdadeira (?); a arbitrariedade do leftvalor se afasta do maior valor agregado quando você coloca adequadamente informações sobre condições excepcionais em uma estrutura adequada. Portanto, comparar o uso de Eitherum caso retornando cadeias no lado esquerdo, try/catchem comparação com o outro caso, usando o tipo digitado corretamente Throwables não é adequado. Por causa do valor de Throwablespelo fornecimento de informações, ea forma concisa que Tryalças Throwables, Tryé uma aposta melhor do que qualquer um ... Eitheroutry/catch
BobDalgleish
Na verdade, não estou preocupado com o try-catch. Como dito na pergunta, o último parece muito mais limpo para mim, e o código que lida com a falha (correspondência de padrão no Either ou try-catch) é bastante claro nos dois casos.
Eyal Roth