As exceções do Python agora são consideradas anti-padrão

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) # => mypy error: Unsupported operand types for + ("int" and "Result[float, ZeroDivisionError]") 

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') # => error: incompatible type "str"; expected "float" except ZeroDivisionError as exc: return Failure(0) # => error: incompatible type "int"; expected "ZeroDivisionError" 

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)) # => Success(2) Success(4).map(lambda number: number + 1) # => Success(5) 

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)) # => Failure(4) Failure(4).map(lambda number: number / 2) # => Failure(4) 

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)) # => Success(5) Failure(4).fix(lambda number: number / 2) # => Success(2) 

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() # => 1 Success(0).value_or(None) # => 0 Failure(0).value_or(None) # => None Failure(1).unwrap() # => Raises UnwrapFailedError() 

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.""" # TODO: we need to create a pipeline of these methods somehow... def _validate_user( self, username: str, email: str, ) -> Result['UserSchema', str]: """Returns an UserSchema for valid input, otherwise a Failure.""" def _create_account( self, user_schema: 'UserSchema', ) -> Result['Account', str]: """Creates an Account for valid UserSchema's. Or returns a Failure.""" def _create_user( self, account: 'Account', ) -> Result['User', str]: """Create an User instance. If user already exists returns Failure.""" 

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 # is the same as: def divide(first: float, second: float) -> Result[float, ZeroDivisionError]: try: return Success(first / second) except ZeroDivisionError as exc: return Failure(exc) 

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.""" #: You can later use dependency injection to replace `requests` #: with any other http library (or even a custom service). _http = requests @pipeline def __call__(self, user_id: int) -> Result['UserProfile', Exception]: """Fetches UserProfile dict from foreign API.""" response = self._make_request(user_id).unwrap() return self._parse_json(response) @safe def _make_request(self, user_id: int) -> requests.Response: response = self._http.get('/api/users/{0}'.format(user_id)) response.raise_for_status() return response @safe def _parse_json(self, response: requests.Response) -> 'UserProfile': return response.json() 

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.

Source: https://habr.com/ru/post/pt445234/


All Articles