X verificado x Desmarcado x Sem exceção… Uma melhor prática de crenças contrárias

10

Existem muitos requisitos necessários para um sistema transmitir e tratar adequadamente exceções. Também existem muitas opções para escolher um idioma para implementar o conceito.

Requisitos para exceções (em nenhuma ordem específica):

  1. Documentação : um idioma deve ter o significado de documentar exceções que uma API pode lançar. Idealmente, este meio de documentação deve ser utilizável em máquina para permitir que compiladores e IDEs forneçam suporte ao programador.

  2. Transmitir situações excepcionais : Essa é óbvia, para permitir que uma função transmita situações que impedem a funcionalidade chamada de executar a ação esperada. Na minha opinião, existem três grandes categorias de tais situações:

    2.1 Erros no código que fazem com que alguns dados sejam inválidos.

    2.2 Problemas na configuração ou outros recursos externos.

    2.3 Recursos inerentemente não confiáveis ​​(rede, sistemas de arquivos, bancos de dados, usuários finais, etc.). Esse é um caso esquecido, uma vez que sua natureza não confiável deve nos fazer esperar suas falhas esporádicas. Nesse caso, essas situações devem ser consideradas excepcionais?

  3. Forneça informações suficientes para o código lidar com isso : As exceções devem fornecer informações suficientes para o receptor, para que ele possa reagir e possivelmente lidar com a situação. as informações também devem ser suficientes para que, quando registradas, essas exceções forneçam contexto suficiente para um programador identificar e isolar as instruções incorretas e fornecer uma solução.

  4. Forneça confiança ao programador sobre o status atual do estado de execução de seu código : Os recursos de tratamento de exceções de um sistema de software devem estar presentes o suficiente para fornecer as salvaguardas necessárias, mantendo-se fora do caminho do programador, para que ele possa se concentrar na tarefa em mão.

Para cobrir estes, os seguintes métodos foram implementados em vários idiomas:

  1. Exceções verificadas Fornecem uma ótima maneira de documentar exceções e, teoricamente, quando implementadas corretamente, devem fornecer ampla garantia de que tudo está bom. No entanto, o custo é tal que muitos sentem mais produtivo simplesmente ignorar ou engolir exceções ou repeti-las como exceções não verificadas. Quando usadas exceções verificadas de maneira inadequada, perdem praticamente toda a sua utilidade. Além disso, as exceções verificadas dificultam a criação de uma API estável no tempo. As implementações de um sistema genérico em um domínio específico trarão uma carga de situação excepcional que seria difícil de manter usando apenas exceções verificadas.

  2. Exceções não verificadas - muito mais versáteis que as exceções verificadas, elas não documentam adequadamente as possíveis situações excepcionais de uma determinada implementação. Eles contam com documentação ad-hoc, se houver. Isso cria situações em que a natureza não confiável de uma mídia é mascarada por uma API que dá a aparência de confiabilidade. Além disso, quando lançadas, essas exceções perdem o significado à medida que retornam pelas camadas de abstração. Como eles são mal documentados, um programador não pode direcioná-los especificamente e muitas vezes precisa lançar uma rede muito maior do que o necessário para garantir que os sistemas secundários, caso falhem, não derrubem todo o sistema. O que nos leva de volta ao problema da deglutição, com exceção das exceções fornecidas.

  3. Tipos de retorno com várias etapas Aqui, você deve confiar em um conjunto separado, em uma tupla ou em outro conceito semelhante para retornar o resultado esperado ou um objeto que representa a exceção. Aqui, não há desenrolamento de pilha, nenhum código de corte, tudo é executado normalmente, mas o valor de retorno deve ser validado por erro antes de continuar. Eu realmente não trabalhei com isso ainda, portanto não posso comentar por experiência própria. Reconheço que resolve algumas exceções de problemas ignorando o fluxo normal, mas ainda assim sofrerá os mesmos problemas das exceções verificadas como cansativo e constantemente "na sua cara".

Então a questão é:

Qual é a sua experiência nesse assunto e qual, segundo você, é o melhor candidato para criar um bom sistema de tratamento de exceções para um idioma?


Edição: Poucos minutos depois de escrever esta pergunta me deparei com este post , assustador!

Newtopian
fonte
2
"Ele sofrerá dos mesmos problemas que as exceções verificadas e é cansativo e constantemente na sua cara": Na verdade: com o suporte adequado à linguagem, você só precisa programar o "caminho do sucesso", com o mecanismo de linguagem subjacente cuidando da propagação erros.
Giorgio
"Uma linguagem deve ter um meio para documentar exceções que uma API pode lançar." - weeeel. Em C ++, "aprendemos" que isso realmente não funciona. Tudo o que você pode realmente fazer é indicar se uma API pode gerar alguma exceção. (Isso realmente cortar uma longa história curta, mas acho que olhando para a noexcepthistória em C ++ pode render muito bons insights para EH em C # e Java também.)
Martin Ba

Respostas:

10

Nos primeiros dias do C ++, descobrimos que, sem algum tipo de programação genérica, linguagens fortemente tipadas eram extremamente difíceis de manejar. Também descobrimos que as exceções verificadas e a programação genérica não funcionavam bem juntas, e as exceções verificadas foram essencialmente abandonadas.

Os tipos de retorno multiset são ótimos, mas não substituem as exceções. Sem exceções, o código está cheio de ruído na verificação de erros.

O outro problema com exceções verificadas é que uma alteração nas exceções geradas por uma função de baixo nível força uma cascata de alterações em todos os chamadores e seus chamadores, e assim por diante. A única maneira de evitar isso é que cada nível de código capture quaisquer exceções geradas por níveis inferiores e envolva-as em uma nova exceção. Novamente, você acaba com um código muito barulhento.

Kevin Cline
fonte
2
Os genéricos ajudam a resolver toda uma classe de erros que se devem principalmente a uma limitação do suporte da linguagem ao paradigma OO. Ainda assim, as alternativas parecem ser o código que realiza a verificação de erros ou que é executado na esperança de que nada dê errado. Você tem situações excepcionais constantemente em seu rosto ou vive em uma terra de sonhos de coelhinhos brancos e fofos que ficam realmente feios quando você deixa cair um lobo mau no meio!
Newtopian
3
+1 para o problema em cascata. Qualquer sistema / arquitetura que dificulte a mudança leva apenas a sistemas de correção e desorganização de macacos, por mais bem projetados que os autores pensassem que eram.
Matthieu M.
2
@ Newtopian: modelos fazem coisas que não podem ser feitas com orientação estrita a objetos, como fornecer segurança de tipo estático para contêineres genéricos.
David Thornley
2
Eu gostaria de ver um sistema de exceção com um conceito de "exceções verificadas", mas muito diferente do Java. A verificação não deve ser um atributo de um tipo de exceção , mas sim lançar sites, sites de captura e instâncias de exceção; se um método é anunciado como lançando uma exceção verificada, isso deve ter dois efeitos: (1) a função deve lidar com um "lançamento" da exceção verificada, fazendo algo especial no retorno (por exemplo, definindo o sinalizador de transporte, etc., dependendo do plataforma exata) para a qual o código de chamada deveria estar preparado.
Supercat
7
"Sem exceções, o código está cheio de ruído na verificação de erros.": Não tenho certeza sobre isso: no Haskell você pode usar mônadas para isso e todo o ruído na verificação de erros desapareceu. O ruído introduzido pelos "tipos de retorno de vários estados" é mais uma limitação da linguagem de programação do que da solução em si.
Giorgio
9

Por um longo tempo nas linguagens OO, o uso de exceções tem sido o padrão de fato para erros de comunicação. Mas linguagens de programação funcionais oferecem a possibilidade de uma abordagem diferente, por exemplo, usando mônadas (que eu não uso) ou a "Programação Orientada a Ferrovias", mais leve, como descrito por Scott Wlaschin.

É realmente uma variante do tipo de resultado com várias etapas.

  • Uma função retorna um sucesso ou um erro. Ele não pode retornar os dois (como é o caso de uma tupla).
  • Todos os erros possíveis foram documentados de forma sucinta (pelo menos em F # com tipos de resultado como uniões discriminadas).
  • O chamador não pode usar o resultado sem levar em consideração se o resultado foi um sucesso ou uma falha.

O tipo de resultado pode ser declarado assim

type Result<'TSuccess,'TFail> =
| Success of 'TSuccess
| Fail of 'TFail

Portanto, o resultado de uma função que retorna esse tipo seria um Successou um Failtipo. Não pode ser os dois.

Em linguagens de programação orientadas mais imperativas, esse tipo de estilo pode exigir uma grande quantidade de código no site do chamador. Mas a programação funcional permite que você construa funções de ligação ou operadores para unir várias funções, para que a verificação de erros não ocupe metade do código. Como um exemplo:

// Create an updateUser function that takes an id, and new state
// as input, and updates an existing user.
let updateUser id input =
    validateInput input
    >>= loadUser id
    >>= updateUser input
    >>= saveUser id
    >>= notifyAboutUserUpdated

A updateUserfunção chama cada uma dessas funções em sucessão e cada uma delas pode falhar. Se todos tiverem êxito, o resultado da última função chamada será retornado. Se uma das funções falhar, o resultado dessa função será o resultado da updateUserfunção geral . Tudo isso é tratado pelo operador >> = personalizado.

No exemplo acima, os tipos de erro podem ser

type UserValidationErrorType =
| InvalidEmail of string
| MissingFirstName of string
... etc

type DbErrorType =
| RecordNotFound of int
| ConcurrencyError of int

type UpdateUserErrorType =
| InvalidInput of UserValidationErrorType
| DbError of DbErrorType

Se o chamador de updateUsernão manipular explicitamente todos os erros possíveis da função, o compilador emitirá um aviso. Então você tem tudo documentado.

No Haskell, existe uma donotação que pode tornar o código ainda mais limpo.

Pete
fonte
2
Ótimas respostas e referências (programação orientada a ferrovias), +1. Você pode mencionar a donotação de Haskell , que torna o código resultante ainda mais limpo.
Giorgio
11
@Giorgio - eu trabalhei agora, mas não trabalhei com Haskell, apenas com F #, então não pude escrever muito sobre isso. Mas você pode adicionar à resposta, se quiser.
Pete
Obrigado, escrevi um pequeno exemplo, mas como não era pequeno o suficiente para ser adicionado à sua resposta, escrevi uma resposta completa (com algumas informações adicionais).
Giorgio
2
O Railway Oriented Programmingcomportamento é exatamente monádico.
Daenyth
5

Acho a resposta de Pete muito boa e gostaria de acrescentar alguma consideração e um exemplo. Uma discussão muito interessante sobre o uso de exceções versus o retorno de valores de erro especiais pode ser encontrada em Programação no padrão ML, por Robert Harper , no final da Seção 29.3, página 243, 244.

O problema é implementar uma função parcial fretornando um valor de algum tipo t. Uma solução é ter a função tipo

f : ... -> t

e lance uma exceção quando não houver resultado possível. A segunda solução é implementar uma função com o tipo

f : ... -> t option

e retorno SOME vsobre o sucesso e NONEsobre o fracasso.

Aqui está o texto do livro, com pequena adaptação feita por mim para tornar o texto mais geral (o livro se refere a um exemplo específico). O texto modificado é escrito em itálico .

Quais são os trade-offs entre as duas soluções?

  1. A solução baseada nos tipos de opção torna explícita no tipo da função fa possibilidade de falha. Isso força o programador a testar explicitamente a falha usando uma análise de caso no resultado da chamada. O verificador de tipos garantirá que não seja possível usar t optionondet é esperado. A solução baseada em exceções não indica explicitamente falha em seu tipo. No entanto, o programador é forçado a lidar com a falha, pois, caso contrário, um erro de exceção não detectado seria gerado no tempo de execução, em vez do tempo de compilação.
  2. A solução baseada nos tipos de opção requer uma análise explícita de caso no resultado de cada chamada. Se "a maioria" dos resultados for bem-sucedida, a verificação é redundante e, portanto, excessivamente cara. A solução baseada em exceções está isenta dessa sobrecarga: é direcionada para o caso "normal" de retornar a t, e não para o caso de "falha" de não retornar um resultado . A implementação de exceções garante que o uso de um manipulador seja mais eficiente do que uma análise de caso explícita, caso a falha seja rara em comparação ao sucesso.

[cut] Em geral, se a eficiência é primordial, tendemos a preferir exceções se a falha for uma raridade e a preferir opções se a falha for relativamente comum. Se, por outro lado, a verificação estática for primordial, é vantajoso usar as opções, pois o verificador de tipos reforçará o requisito de que o programador verifique a falha, em vez de o erro ocorrer apenas no tempo de execução.

Isso diz respeito à escolha entre exceções e tipos de retorno de opção.

Com relação à ideia de que representar um erro no tipo de retorno leva a verificações de erro espalhadas por todo o código: esse não precisa ser o caso. Aqui está um pequeno exemplo em Haskell que ilustra isso.

Suponha que desejemos analisar dois números e depois dividir o primeiro pelo segundo. Portanto, pode haver um erro ao analisar cada número ou ao dividir (divisão por zero). Portanto, temos que verificar se há um erro após cada etapa.

import Text.Read

parseInt :: String -> Maybe Int
parseInt s = readMaybe s :: Maybe Int

safeDiv :: Int -> Int -> Maybe Int
safeDiv n d = if d /= 0 then Just (n `div` d) else Nothing

toString :: Maybe Int -> String
toString (Just i) = show i
toString Nothing  = "error"

main = do
         -- Get two lines from the terminal.
         nStr <- getLine
         dStr <- getLine

         -- Parse each string and divide.
         let r = do n <- parseInt nStr
                    d <- parseInt dStr
                    safeDiv n d

         -- Print the result.
         putStrLn $ toString r

A análise e a divisão são realizadas no let ...bloco. Observe que, usando a Maybemônada e a donotação, apenas o caminho de sucesso é especificado: a semântica da Maybemônada propaga implicitamente o valor do erro ( Nothing). Nenhuma sobrecarga para o programador.

Giorgio
fonte
2
Penso que em casos como este em que você deseja imprimir algum tipo de mensagem de erro útil, o Eithertipo seria mais adequado. O que você faz se receberNothing aqui? Você acabou de receber a mensagem "erro". Não é muito útil para depuração.
Sara
1

Tornei-me um grande fã das exceções verificadas e gostaria de compartilhar minha regra geral sobre quando usá-las.

Cheguei à conclusão de que existem basicamente dois tipos de erros com os quais meu código precisa lidar. Há erros testáveis ​​antes da execução do código e erros não testáveis ​​antes da execução do código. Um exemplo simples para um erro que pode ser testado antes da execução do código em uma NullPointerException.

//... bad code below.  the runnable variable
// tries to call the run() method before the variable
// is instantiated.  Running the code below will cause
// a NullPointerException.
Runnable runnable = null;
runnable.run();

Um teste simples poderia ter evitado o erro, como ...

Runnable runnable = null;
...
if (runnable != null)
{   runnable.run(); }

Há momentos na computação em que você pode executar 1 ou mais testes antes de executar o código para garantir sua segurança e você ainda terá uma exceção. Por exemplo, você pode testar um sistema de arquivos para garantir que haja espaço em disco suficiente no disco rígido antes de gravar seus dados na unidade. Em um sistema operacional de multiprocessamento, como os usados ​​hoje, seu processo pode testar o espaço em disco e o sistema de arquivos retornará um valor dizendo que há espaço suficiente; uma alternância de contexto para outro processo poderá gravar os bytes restantes disponíveis para o sistema operacional. sistema. Quando o contexto do sistema operacional voltar ao processo em que você grava o conteúdo no disco, ocorrerá uma exceção simplesmente porque não há espaço em disco suficiente no sistema de arquivos.

Considero o cenário acima como um caso perfeito para uma exceção verificada. É uma exceção no código que força você a lidar com algo ruim, mesmo que seu código possa ser perfeitamente escrito. Se você optar por fazer coisas ruins como 'engolir a exceção', você é o mau programador. A propósito, eu encontrei casos em que é razoável engolir a exceção, mas por favor deixe um comentário no código sobre o motivo pelo qual a exceção foi engolida. O mecanismo de manipulação de exceção não é o culpado. Costumo brincar que prefiro que meu marcapasso cardíaco seja escrito com uma linguagem que tenha exceções verificadas.

Há momentos em que fica difícil decidir se o código é testável ou não. Por exemplo, se você estiver escrevendo um intérprete e uma SyntaxException for lançada quando o código falhar na execução por algum motivo sintático, a SyntaxException deve ser uma exceção verificada ou (em Java) uma RuntimeException? Eu responderia se o intérprete verificar a sintaxe do código antes que o código seja executado, a exceção deve ser uma RuntimeException. Se o intérprete simplesmente executar o código 'hot' e simplesmente encontrar um erro de sintaxe, eu diria que a exceção deve ser uma exceção verificada.

Admito que nem sempre estou feliz em ter que capturar ou lançar uma exceção verificada, porque há momentos em que não tenho certeza do que fazer. Exceções verificadas são uma maneira de forçar um programador a estar atento ao possível problema que pode ocorrer. Uma das razões pelas quais eu programa em Java é porque ele tem exceções verificadas.

James Moliere
fonte
11
Prefiro que meu marcapasso cardíaco tenha sido escrito em um idioma que não tivesse exceções, e todas as linhas de código tratassem erros por meio de códigos de retorno. Quando você lança uma exceção, está dizendo "tudo deu errado" e a única maneira segura de continuar o processamento é parar e reiniciar. Um programa que tão facilmente acaba em um estado inválido não é algo que você quer para o software crítico (e Java proíbe explicitamente o seu uso para software crítico no EULA)
gbjbaanb
Usando exceção e não verificando-os vs usando código de retorno e não verificando-os no final, tudo gera a mesma parada cardíaca.
Newtopian
-1

Atualmente, estou no meio de um grande projeto / API baseado em OOP e usei esse layout das exceções. Mas tudo realmente depende de quão profundo você deseja ir com o tratamento de exceções e coisas do gênero.

ExpectedException
- AuthorisedException
- EmptySetException
- NoRemainingException
- NoRowsException
- NotFoundException
- ValidationException

UnexpectedException
- ConnectivityException
- EnvironmentException
- ProgrammerException
- SQLException

EXEMPLO

   $valid_types = array('mysql', 'oracle', 'sqlite');
       if (!in_array($type, $valid_types)) {
           throw new ecProgrammerException(
        'The database type specified, %1$s, is invalid. Must be one of: %2$s.',
    $type,
    join(', ', $valid_types)
    );
}
MattyD
fonte
11
Se a exceção é esperada, não é realmente uma exceção. "NoRowsException"? Parece que o controle flui para mim e, portanto, um mau uso de uma exceção.
Quentin-starin
11
@qes: faz sentido gerar uma exceção sempre que uma função não puder calcular um valor, por exemplo, double Math.sqrt (double v) ou User findUser (long id). Isso dá ao chamador a liberdade de capturar e manipular erros quando for conveniente, em vez de verificar após cada chamada.
Kevin cline
11
Esperado = fluxo de controle = antipadrão de exceção. A exceção não deve ser usada para o fluxo de controle. Se é esperado que produza erro para uma entrada específica, basta passar como parte do valor de retorno. Então nós temos NANou NULL.
Eonil
11
@Eonil ... ou Opção <T>
Maarten Bodewes