O que são exceções? Pelo nome, é claro - eles surgem quando ocorre uma exceção no programa. Você pode perguntar por que as exceções são um antipadrão e como elas se relacionam com a digitação? Tentei
descobrir e agora quero discutir isso com você, harazhiteli.
Problemas de exceção
É difícil encontrar falhas no que você enfrenta todos os dias. Hábito e cegueira transformam insetos em características, mas vamos tentar examinar as exceções com a mente aberta.
Exceções são difíceis de detectar
Existem dois tipos de exceções: "explícito" é criado chamando
raise
diretamente no código que você está lendo; "Oculto" estão ocultos nas funções, classes e métodos utilizados.
O problema é que as exceções "ocultas" são realmente difíceis de perceber. Deixe-me mostrar um exemplo de uma função pura:
def divide(first: float, second: float) -> float: return first / second
A função simplesmente divide um número por outro, retornando um número
float
. Os tipos são verificados e você pode executar algo como isto:
result = divide(1, 0) print('x / y = ', result)
Você já reparou? De fato, a execução do programa nunca chegará à
print
, porque dividir 1 por 0 é uma operação impossível, pois
ZeroDivisionError
um
ZeroDivisionError
. Sim, esse código é do tipo seguro, mas não pode ser usado de qualquer maneira.
Para perceber um problema em potencial, mesmo em um código tão simples e legível, é preciso ter experiência. Qualquer coisa no Python pode parar de funcionar com diferentes tipos de exceções: divisão, chamadas de função,
int
,
str
, geradores, iteradores em loops, acesso a atributos ou chaves. Até
raise something()
pode causar um acidente. Além disso, eu nem menciono operações de entrada e saída. E
as exceções verificadas não serão mais suportadas no futuro próximo.
Não é possível restaurar o comportamento normal no local
Mas precisamente para esse caso, temos exceções. Vamos apenas lidar com
ZeroDivisionError
e o código se tornará seguro para o tipo.
def divide(first: float, second: float) -> float: try: return first / second except ZeroDivisionError: return 0.0
Agora está tudo bem. Mas por que retornamos 0? Por que não 1 ou
None
? Obviamente, na maioria dos casos, obter
None
quase tão ruim (se não pior) como uma exceção, mas você ainda precisa confiar na lógica de negócios e nas opções para usar a função.
O que exatamente estamos compartilhando? Números arbitrários, alguma unidade específica ou dinheiro? Nem todas as opções são fáceis de prever e restaurar. Pode acontecer que da próxima vez que você use uma função, você precise de uma lógica de recuperação diferente.
A triste conclusão: a solução para cada problema é individual, dependendo do contexto específico de uso.
Não existe uma bala de prata para lidar com o
ZeroDivisionError
uma vez por todas. E não estamos falando sobre a possibilidade de E / S complexa com solicitações e tempos limite repetidos.
Talvez não seja necessário lidar com exceções exatamente onde elas surgem? Talvez apenas jogue no processo de execução de código - alguém descobrirá mais tarde. E então somos forçados a voltar ao estado atual.
Processo de execução pouco claro
Bem, vamos esperar que outra pessoa pegue a exceção e talvez lide com isso. Por exemplo, o sistema pode solicitar ao usuário que altere o valor inserido, porque não pode ser dividido por 0. E a função de
divide
não deve ser explicitamente responsável pela recuperação do erro.
Nesse caso, você precisa verificar onde capturamos a exceção. A propósito, como determinar exatamente onde será processado? É possível ir ao lugar certo no código? Acontece que não,
é impossível .
Não é possível determinar qual linha de código será executada após a exceção ser lançada. Diferentes tipos de exceções podem ser manipulados com diferentes opções
except
, e algumas exceções podem ser
ignoradas . E você pode lançar exceções adicionais em outros módulos que serão executados anteriormente e, em geral, quebrarão toda a lógica.
Suponha que haja dois encadeamentos independentes em um aplicativo: um encadeamento regular que é executado de cima para baixo e um encadeamento de exceção que é executado como desejar. Como ler e entender esse código?
Somente com o depurador ativado no modo "capturar todas as exceções".
Exceções, como o notório
goto
, rasgam a estrutura do programa.
Exceções não são exclusivas
Vejamos outro exemplo: o código de acesso à API HTTP usual:
import requests def fetch_user_profile(user_id: int) -> 'UserProfile': """Fetches UserProfile dict from foreign API.""" response = requests.get('/api/users/{0}'.format(user_id)) response.raise_for_status() return response.json()
Neste exemplo, literalmente tudo pode dar errado. Aqui está uma lista parcial de possíveis erros:
- A rede pode não estar disponível e a solicitação não será executada.
- O servidor pode não funcionar.
- O servidor pode estar muito ocupado, o tempo limite ocorrerá.
- O servidor pode exigir autenticação.
- Uma API pode não ter esse URL.
- Um usuário inexistente pode ser transferido.
- Pode não haver direitos suficientes.
- O servidor pode falhar devido a um erro interno ao processar sua solicitação
- O servidor pode retornar uma resposta inválida ou danificada.
- O servidor pode retornar JSON inválido que não pode ser analisado.
A lista continua, e há tantos problemas em potencial no código das três linhas infelizes. Podemos dizer que geralmente funciona apenas por uma chance de sorte e é muito mais provável que caia com uma exceção.
Como se proteger?
Agora que garantimos que as exceções podem ser prejudiciais ao código, vamos descobrir como se livrar delas. Para escrever código sem exceção, existem padrões diferentes.
- Em todos os lugares,
except Exception: pass
. Beco sem saída. Não faça isso. - Retorno
None
. Muito mal. Como resultado, você terá que iniciar quase todas as linhas if something is not None:
e toda a lógica será perdida por trás do lixo das verificações de limpeza, ou você sofrerá TypeError
o tempo todo. Não é uma boa escolha. - Escreva aulas para casos de uso especiais. Por exemplo, a classe base
User
com subclasses para erros como UserNotFound
e MissingUser
. Essa abordagem pode ser usada em algumas situações específicas, como AnonymousUser
no Django, mas agrupar todos os erros possíveis nas classes não é realista. Isso exigirá muito trabalho e o modelo de domínio se tornará inimaginavelmente complexo. - Use contêineres para agrupar a variável resultante ou o valor do erro em um invólucro e continue trabalhando com o valor do contêiner. Por isso, criamos o projeto
@dry-python/return
. Portanto, essas funções retornam algo significativo, digitado e seguro.
Vamos retornar ao exemplo de divisão, que retorna 0. Quando ocorre um erro.Podemos indicar explicitamente que a função não teve êxito sem retornar um valor numérico específico?
from returns.result import Result, Success, Failure def divide(first: float, second: float) -> Result[float, ZeroDivisionError]: try: return Success(first / second) except ZeroDivisionError as exc: return Failure(exc)
Colocamos os valores em um dos dois invólucros:
Success
ou
Failure
. Essas classes são herdadas da classe base
Result
. Tipos de valores compactados podem ser especificados na anotação com a função retornada, por exemplo,
Result[float, ZeroDivisionError]
retorna
Success[float]
ou
Failure[ZeroDivisionError]
.
O que isso nos dá? Mais
exceções não são excepcionais, mas são problemas esperados . Além disso, agrupar uma exceção em
Failure
resolve um segundo problema: a complexidade de identificar possíveis exceções.
1 + divide(1, 0)
Agora eles são fáceis de detectar. Se você vir
Result
no código, a função poderá gerar uma exceção. E você até conhece o tipo dele com antecedência.
Além disso, a biblioteca é totalmente digitada e
compatível com o PEP561 . Ou seja, mypy irá avisá-lo se você tentar retornar algo que não corresponde ao tipo declarado.
from returns.result import Result, Success, Failure def divide(first: float, second: float) -> Result[float, ZeroDivisionError]: try: return Success('Done')
Como trabalhar com contêineres?
Existem dois
métodos :
map
para funções que retornam valores normais;bind
para funções que retornam outros contêineres.
Success(4).bind(lambda number: Success(number / 2))
A
.bind
é que esse código o protegerá de scripts sem êxito, pois
.bind
e
.map
não serão executados para contêineres com
Failure
:
Failure(4).bind(lambda number: Success(number / 2))
Agora você pode apenas se concentrar no processo de execução correto e garantir que o estado errado não interrompa o programa em um local inesperado. E sempre há a oportunidade de
determinar o estado errado, corrigi-lo e retornar ao caminho concebido do processo.
Failure(4).rescue(lambda number: Success(number + 1))
Em nossa abordagem, "todos os problemas são resolvidos individualmente" e "o processo de execução agora é transparente". Aproveite a programação que monta nos trilhos!
Mas como expandir os valores dos contêineres?
De fato, se você trabalha com funções que não sabem nada sobre contêineres, precisa dos próprios valores. Então você pode usar os
.unwrap()
ou
.value_or()
:
Success(1).unwrap()
Espere, tivemos que nos livrar das exceções e agora acontece que todas as chamadas
.unwrap()
podem levar a outra exceção?
Como não pensar em UnwrapFailedErrors?
Ok, vamos ver como conviver com as novas exceções. Considere este exemplo: você precisa verificar a entrada do usuário e criar dois modelos no banco de dados. Cada etapa pode terminar com uma exceção, e é por isso que todos os métodos são agrupados em
Result
:
from returns.result import Result, Success, Failure class CreateAccountAndUser(object): """Creates new Account-User pair."""
Primeiro, você não precisa expandir os valores em sua própria lógica de negócios:
class CreateAccountAndUser(object): """Creates new Account-User pair.""" def __call__(self, username: str, email: str) -> Result['User', str]: """Can return a Success(user) or Failure(str_reason).""" return self._validate_user( username, email, ).bind( self._create_account, ).bind( self._create_user, )
Tudo funcionará sem problemas, sem exceções, porque
.unwrap()
não
.unwrap()
usado. Mas é fácil ler esse código? Não. E qual é a alternativa?
@pipeline
:
from result.functions import pipeline class CreateAccountAndUser(object): """Creates new Account-User pair.""" @pipeline def __call__(self, username: str, email: str) -> Result['User', str]: """Can return a Success(user) or Failure(str_reason).""" user_schema = self._validate_user(username, email).unwrap() account = self._create_account(user_schema).unwrap() return self._create_user(account)
Agora este código está bem lido. Veja como
.unwrap()
e
@pipeline
trabalham juntos: sempre que um método
.unwrap()
falha e
Failure[str]
, o decorador
@pipeline
captura e retorna
Failure[str]
como o valor resultante. É assim que proponho remover todas as exceções do código e torná-lo realmente seguro e digitado.
Enrole tudo junto
Bem, agora aplicaremos as novas ferramentas, por exemplo, com uma solicitação à API HTTP. Lembre-se que cada linha pode lançar uma exceção? E não há como fazê-los retornar o contêiner com
Result
. Mas você pode usar o
decorador @safe para
agrupar funções não seguras e torná-las seguras. Abaixo estão duas opções de código que fazem a mesma coisa:
from returns.functions import safe @safe def divide(first: float, second: float) -> float: return first / second
O primeiro, com
@safe
, é mais fácil e melhor de ler.
A última coisa a fazer no exemplo de solicitação da API é adicionar o decorador
@safe
. O resultado é o seguinte código:
import requests from returns.functions import pipeline, safe from returns.result import Result class FetchUserProfile(object): """Single responsibility callable object that fetches user profile."""
Para resumir como se livrar das exceções e proteger o código :
- Use o wrapper
@safe
para todos os métodos que podem lançar uma exceção. Ele mudará o tipo de retorno da função para Result[OldReturnType, Exception]
. - Use
Result
como um contêiner para transferir valores e erros em uma abstração simples. - Use
.unwrap()
para expandir o valor do contêiner. - Use
@pipeline
para facilitar a leitura das .unwrap
chamadas .unwrap
.
Ao observar essas regras, podemos fazer exatamente a mesma coisa - apenas segura e bem legível. Todos os problemas que estavam com exceções foram resolvidos:
- "Exceções são difíceis de detectar . " Agora eles estão envolvidos em um contêiner de
Result
digitado, o que os torna completamente transparentes. - "Restaurar o comportamento normal é impossível . " Agora você pode delegar com segurança o processo de recuperação ao chamador.
.fix()
caso, existem .fix()
e .rescue()
. - "A sequência de execução não é clara . " Agora eles são um com o fluxo comercial usual. Do início ao fim.
- "Exceções não são excepcionais . " Nós sabemos! E esperamos que algo dê errado e esteja pronto para qualquer coisa.
Casos de uso e limitações
Obviamente, você não pode usar essa abordagem em todo o seu código. Será
muito seguro para a maioria das situações cotidianas e é incompatível com outras bibliotecas ou estruturas. Mas você deve escrever as partes mais importantes da lógica de negócios exatamente como mostrei, para garantir a operação correta do seu sistema e facilitar o suporte futuro.
O tópico faz você pensar ou até parecer holivarny? Venha para Moscou Python Conf ++ em 5 de abril, discutiremos! Além de mim, Artyom Malyshev, o fundador do projeto dry-python e desenvolvedor principal do Django Channels, estará lá. Ele falará mais sobre python seco e lógica de negócios.