Tratamento de exceção de estilo funcional

24

Foi-me dito que na programação funcional não se deve lançar e / ou observar exceções. Em vez disso, um cálculo incorreto deve ser avaliado como um valor inferior. No Python (ou em outras linguagens que não incentivam totalmente a programação funcional), pode-se retornar None(ou outra alternativa tratada como o valor mais baixo, embora Nonenão esteja estritamente de acordo com a definição) sempre que algo der errado para "permanecer puro", mas fazer então é preciso observar um erro em primeiro lugar, ou seja,

def fn(*args):
    try:
        ... do something
    except SomeException:
        return None

Isso viola a pureza? E se sim, isso significa que é impossível lidar com erros puramente em Python?

Atualizar

Em seu comentário, Eric Lippert me lembrou outra maneira de tratar exceções no PF. Embora eu nunca tenha visto isso na prática em Python, eu brinquei com ela quando estudei FP há um ano. Aqui, qualquer optionalfunção -decorada retorna Optionalvalores, que podem estar vazios, para saídas normais e para uma lista especificada de exceções (exceções não especificadas ainda podem terminar a execução). Carrycria uma avaliação atrasada, em que cada etapa (chamada de função atrasada) obtém uma Optionalsaída não vazia da etapa anterior e simplesmente a transmite, ou avalia a si mesma passando uma nova Optional. No final, o valor final é normal ou Empty. Aqui o try/exceptbloco fica oculto atrás de um decorador, portanto as exceções especificadas podem ser consideradas como parte da assinatura do tipo de retorno.

class Empty:
    def __repr__(self):
        return "Empty"


class Optional:
    def __init__(self, value=Empty):
        self._value = value

    @property
    def value(self):
        return Empty if self.isempty else self._value

    @property
    def isempty(self):
        return isinstance(self._value, BaseException) or self._value is Empty

    def __bool__(self):
        raise TypeError("Optional has no boolean value")


def optional(*exception_types):
    def build_wrapper(func):
        def wrapper(*args, **kwargs):
            try:
                return Optional(func(*args, **kwargs))
            except exception_types as e:
                return Optional(e)
        wrapper.__isoptional__ = True
        return wrapper
    return build_wrapper


class Carry:
    """
    >>> from functools import partial
    >>> @optional(ArithmeticError)
    ... def rdiv(a, b):
    ...     return b // a
    >>> (Carry() >> (rdiv, 0) >> (rdiv, 0) >> partial(rdiv, 1))(1)
    1
    >>> (Carry() >> (rdiv, 0) >> (rdiv, 1))(1)
    1
    >>> (Carry() >> rdiv >> rdiv)(0, 1) is Empty
    True
    """
    def __init__(self, steps=None):
        self._steps = tuple(steps) if steps is not None else ()

    def _add_step(self, step):
        fn, *step_args = step if isinstance(step, Sequence) else (step, )
        return type(self)(steps=self._steps + ((fn, step_args), ))

    def __rshift__(self, step) -> "Carry":
        return self._add_step(step)

    def _evaluate(self, *args) -> Optional:
        def caller(carried: Optional, step):
            fn, step_args = step
            return fn(*(*step_args, *args)) if carried.isempty else carried
        return reduce(caller, self._steps, Optional())

    def __call__(self, *args):
        return self._evaluate(*args).value
Eli Korvigo
fonte
11
Sua pergunta já foi respondida, portanto, apenas um comentário: você entende por que a exceção de sua função gera uma exceção na programação funcional? Não é um capricho arbitrário :)
Andres F.
6
Há outra alternativa para retornar um valor indicando o erro. Lembre-se, o tratamento de exceções é o fluxo de controle e temos mecanismos funcionais para reificar os fluxos de controle. Você pode emular o tratamento de exceções em linguagens funcionais, escrevendo seu método para executar duas funções: a continuação de sucesso e a continuação de erro . A última coisa que sua função faz é chamar a continuação de sucesso, passando o "resultado", ou a continuação de erro, passando a "exceção". O lado ruim é que você precisa escrever seu programa dessa maneira de dentro para fora.
Eric Lippert
3
Não, qual seria o estado nesse caso? Existem vários problemas, mas aqui estão alguns: 1- existe um fluxo possível que não é codificado no tipo, ou seja, você não pode saber se uma função lançará uma exceção apenas olhando para o tipo (a menos que você tenha verificado apenas exceções, é claro, mas eu não conheço nenhum idioma que as tenha apenas ). Você está efetivamente trabalhando "fora" do sistema de tipos. 2 programadores funcionais se esforçam para escrever funções "totais" sempre que possível, ou seja, funções que retornam um valor para cada entrada (exceto a não terminação). Exceções funcionam contra isso.
Andres F.
3
3- quando você trabalha com funções totais, você pode compor com outras funções e usá-las em funções de ordem superior, sem se preocupar com resultados de erro "não codificados no tipo", ou seja, exceções.
Andres F.
11
@EliKorvigo A definição de uma variável introduz o estado, por definição, ponto final. O objetivo inteiro das variáveis ​​é que elas mantenham estado. Isso não tem nada a ver com exceções. Observar o valor de retorno de uma função e definir o valor de uma variável com base na observação introduz o estado na avaliação, não é?
user253751

Respostas:

20

Primeiro de tudo, vamos esclarecer alguns equívocos. Não há "valor inferior". O tipo inferior é definido como um tipo que é um subtipo de qualquer outro tipo no idioma. A partir disso, pode-se provar (pelo menos em qualquer sistema de tipos interessantes), que o tipo inferior não tem valores - está vazio . Portanto, não existe um valor inferior.

Por que o tipo inferior é útil? Bem, sabendo que está vazio, vamos fazer algumas deduções no comportamento do programa. Por exemplo, se tivermos a função:

def do_thing(a: int) -> Bottom: ...

sabemos que do_thingnunca pode voltar, uma vez que teria de retornar um valor do tipo Bottom. Assim, existem apenas duas possibilidades:

  1. do_thing não para
  2. do_thing lança uma exceção (em idiomas com um mecanismo de exceção)

Observe que eu criei um tipo Bottomque realmente não existe na linguagem Python. Noneé um nome impróprio; na verdade, é o valor da unidade , o único valor do tipo de unidade , chamado NoneTypeem Python (faça type(None)para confirmar por si mesmo).

Agora, outro equívoco é que as linguagens funcionais não têm exceção. Isso também não é verdade. O SML, por exemplo, possui um mecanismo de exceção muito bom. No entanto, as exceções são usadas com muito mais moderação no SML do que no Python, por exemplo. Como você disse, a maneira comum de indicar algum tipo de falha nas linguagens funcionais é retornando um Optiontipo. Por exemplo, criaríamos uma função de divisão segura da seguinte maneira:

def safe_div(num: int, den: int) -> Option[int]:
  return Some(num/den) if den != 0 else None

Infelizmente, como o Python não possui realmente tipos de soma, essa não é uma abordagem viável. Você pode retornar Nonecomo um tipo de opção de pobre para significar falha, mas isso não é nada melhor do que retornar Null. Não há segurança de tipo.

Então, eu recomendaria seguir as convenções do idioma neste caso. O Python usa exceções linguisticamente para lidar com o fluxo de controle (que é um design ruim, IMO, mas é padrão), portanto, a menos que você esteja trabalhando apenas com o código que você mesmo escreveu, recomendo seguir a prática padrão. Se isso é "puro" ou não, é irrelevante.

Gardenhead
fonte
Por "valor inferior", eu realmente quis dizer "tipo inferior", foi por isso que escrevi que Nonenão estava de acordo com a definição. De qualquer forma, obrigado por me corrigir. Você não acha que usar exceção apenas para interromper completamente a execução ou retornar um valor opcional está de acordo com os princípios do Python? Quero dizer, por que é ruim não usar exceção para controle complicado?
Eli Korvigo 27/10/16
@EliKorvigo Isso é o que mais ou menos o que eu disse, certo? Exceções são Python idiomático.
gardenhead
11
Por exemplo, eu desencorajo meus alunos de graduação a usarem try/except/finallycomo outra alternativa if/else, ou seja try: var = expession1; except ...: var = expression 2; except ...: var = expression 3..., embora seja algo comum em qualquer linguagem imperativa (btw, eu desencorajo fortemente o uso de if/elseblocos para isso também). Você quer dizer que eu estou sendo irracional e deveria permitir esses padrões, já que "this is Python"?
Eli Korvigo 27/10/16
@EliKorvigo Eu concordo com você em geral (btw, você é professor?). nãotry... catch... deve ser usado para controle de fluxo. Por alguma razão, foi assim que a comunidade Python decidiu fazer as coisas. Por exemplo, a função que escrevi acima normalmente seria escrita . Portanto, se você está ensinando a eles os princípios gerais de engenharia de software, você definitivamente deve desencorajar isso. também é um cheiro de código, mas é muito tempo para entrar aqui. safe_divtry: result = num / div: except ArithmeticError: result = Noneif ... else...
gardenhead
2
Existe um "valor inferior" (é usado para falar sobre a semântica de Haskell, por exemplo), e tem pouco a ver com o tipo inferior. Portanto, isso não é realmente um equívoco do OP, apenas falando de uma coisa diferente.
Ben
11

Como houve muito interesse pela pureza nos últimos dias, por que não examinamos como é uma função pura?

Uma função pura:

  • É referencialmente transparente; isto é, para uma determinada entrada, ela sempre produzirá a mesma saída.

  • Não produz efeitos colaterais; não altera as entradas, saídas ou qualquer outra coisa em seu ambiente externo. Só produz um valor de retorno.

Então pergunte a si mesmo. Sua função faz alguma coisa além de aceitar uma entrada e retornar uma saída?

Robert Harvey
fonte
3
Então, não importa, quão feio é escrito por dentro, desde que se comporte funcionalmente?
Eli Korvigo 27/10/16
8
Correto, se você está apenas interessado em pureza. É claro que pode haver outras coisas a considerar.
Robert Harvey
3
Para interpretar o advogado do diabo, eu argumentaria que lançar uma exceção é apenas uma forma de saída e as funções que lançam são puras. Isso é um problema?
Bergi 27/10/16
11
@ Bergi, não sei se você está bancando o advogado do diabo, já que é exatamente isso que essa resposta implica :) O problema é que existem outras considerações além da pureza. Se você permitir exceções não verificadas (que por definição não fazem parte da assinatura da função), o tipo de retorno de cada função se tornará efetivamente T + { Exception }(onde Té o tipo de retorno declarado explicitamente), o que é problemático. Você não pode saber se uma função lançará uma exceção sem examinar seu código-fonte, o que também torna problemática a gravação de funções de ordem superior.
Andrés F.
2
@Begri ao lançar pode ser indiscutivelmente puro, o IMO usando exceções complica mais do que apenas complicar o tipo de retorno de cada função. Prejudica a composição de funções. Considere a implementação de map : (A->B)->List A ->List Bwhere A->Bcan error. Se permitirmos que f para lançar uma exceção map f L retorne algo do tipo Exception + List<B>. Se, em vez disso, permitirmos que ele retorne um optionaltipo de estilo map f L, retornará List <Optional <B>> `. Esta segunda opção parece mais funcional para mim.
Michael Anderson
7

A semântica de Haskell usa um "valor inferior" para analisar o significado do código Haskell. Não é algo que você realmente use diretamente na programação do Haskell, e retornar Nonenão é o mesmo tipo de coisa.

O valor inferior é o valor atribuído pela semântica de Haskell a qualquer cálculo que falha ao avaliar normalmente um valor. Uma dessas maneiras que uma computação Haskell pode fazer é lançando uma exceção! Portanto, se você estava tentando usar esse estilo no Python, deveria lançar exceções normalmente.

A semântica de Haskell usa o valor mais baixo porque Haskell é preguiçoso; você pode manipular "valores" retornados por cálculos que ainda não foram executados. Você pode passá-los para funções, colá-los em estruturas de dados, etc. Essa computação não avaliada pode gerar uma exceção ou um loop para sempre, mas se nunca precisarmos realmente examinar o valor, a computação nuncaexecute e encontre o erro, e nosso programa geral poderá fazer algo bem definido e finalizado. Portanto, sem querer explicar o que significa o código Haskell, especificando o comportamento operacional exato do programa em tempo de execução, declaramos que tais cálculos errôneos produzem o valor mais baixo e explicamos o que esse valor se comporta; basicamente, qualquer expressão que precise depender de todas as propriedades do valor inferior (além da existente) também resultará no valor inferior.

Para permanecer "puro", todas as formas possíveis de gerar o valor mais baixo devem ser tratadas como equivalentes. Isso inclui o "valor inferior" que representa um loop infinito. Como não há como saber que alguns loops infinitos são realmente infinitos (eles podem terminar se você os executar por mais algum tempo), não é possível examinar nenhuma propriedade de valor inferior. Você não pode testar se algo está embaixo, não pode compará-lo a qualquer outra coisa, não pode convertê-lo em uma string, nada. Tudo o que você pode fazer com um é colocar os lugares (parâmetros de função, parte de uma estrutura de dados, etc.) intocados e não examinados.

Python já tem esse tipo de fundo; é o "valor" que você obtém de uma expressão que gera uma exceção ou não termina. Como o Python é mais rigoroso do que preguiçoso, esses "fundos" não podem ser armazenados em nenhum lugar e potencialmente deixados sem serem examinados. Portanto, não há necessidade real de usar o conceito de valor inferior para explicar como os cálculos que não retornam um valor ainda podem ser tratados como se tivessem um valor. Mas também não há razão para que você não pense dessa maneira sobre exceções, se quiser.

Lançar exceções é realmente considerado "puro". É a captura de exceções que quebra a pureza - precisamente porque permite que você inspecione algo sobre certos valores inferiores, em vez de tratá-los todos de forma intercambiável. No Haskell, você só pode capturar exceções no IOque permite interfaces impuras (o que geralmente acontece em uma camada bastante externa). O Python não impõe pureza, mas você ainda pode decidir por si mesmo quais funções fazem parte da sua "camada impura externa" em vez de funções puras, e apenas se permite capturar exceções lá.

Retornar Noneé completamente diferente. Noneé um valor não inferior; você pode testar se algo é igual a ele e o chamador da função que retornou Nonecontinuará sendo executado, possivelmente usando o Noneinapropriadamente.

Portanto, se você estava pensando em lançar uma exceção e deseja "voltar ao fundo" para imitar a abordagem de Haskell, você simplesmente não faz nada. Deixe a exceção se propagar. É exatamente isso que os programadores Haskell querem dizer quando falam sobre uma função retornando um valor mais baixo.

Mas não é isso que os programadores funcionais querem dizer quando dizem para evitar exceções. Programadores funcionais preferem "funções totais". Eles sempre retornam um valor não inferior válido de seu tipo de retorno para todas as entradas possíveis. Portanto, qualquer função que possa gerar uma exceção não é uma função total.

A razão pela qual gostamos de funções totais é que elas são muito mais fáceis de tratar como "caixas pretas" quando as combinamos e manipulamos. Se eu tiver uma função total retornando algo do tipo A e uma função total que aceite algo do tipo A, então eu posso chamar o segundo na saída do primeiro, sem saber nada sobre a implementação de qualquer um; Eu sei que vou obter um resultado válido, não importa como o código de uma das funções seja atualizado no futuro (desde que a totalidade seja mantida e enquanto eles mantêm a mesma assinatura de tipo). Essa separação de preocupações pode ser uma ajuda extremamente poderosa para a refatoração.

Também é um pouco necessário para funções confiáveis ​​de ordem superior (funções que manipulam outras funções). Se eu quiser escrever código que recebe uma função completamente arbitrário (com uma interface conhecida) como um parâmetro que eu tenho de tratá-lo como uma caixa preta, porque eu não tenho nenhuma maneira de saber quais entradas podem desencadear um erro. Se eu receber uma função total, nenhuma entrada causará um erro. Da mesma forma, o responsável pela chamada da minha função de ordem superior não saberá exatamente quais argumentos eu uso para chamar a função que eles passam por mim (a menos que desejem depender dos detalhes da minha implementação). Portanto, passar uma função total significa que eles não precisam se preocupar com o que eu faço com isso.

Portanto, um programador funcional que o aconselha a evitar exceções prefere que você retorne um valor que codifique o erro ou um valor válido e exija que, para usá-lo, esteja preparado para lidar com as duas possibilidades. Coisas como Eithertypes ou Maybe/ Optiontypes são algumas das abordagens mais simples para fazer isso em linguagens mais fortemente tipadas (geralmente usadas com sintaxe especial ou funções de ordem superior para ajudar a colar coisas que precisam de um Acom coisas que produzem a Maybe<A>).

Uma função que retorna None(se ocorreu um erro) ou algum valor (se não houve erro) está seguindo nenhuma das estratégias acima.

No Python com duck digitando, o estilo Either / Maybe não é muito usado, ao invés disso, permite que exceções sejam lançadas, com testes para validar que o código funciona, em vez de confiar que as funções sejam totais e combináveis ​​automaticamente com base em seus tipos. O Python não tem nenhuma facilidade para impor que o código use coisas como Talvez tipos corretamente; mesmo se você o estivesse usando por uma questão de disciplina, precisará de testes para realmente exercitar seu código para validar isso. Portanto, a abordagem de exceção / parte inferior é provavelmente mais adequada à programação funcional pura em Python.

Ben
fonte
11
+1 resposta impressionante! E muito abrangente também.
Andres F.
6

Enquanto não houver efeitos colaterais visíveis externamente e o valor de retorno depender exclusivamente das entradas, a função será pura, mesmo que faça algumas coisas impuras internamente.

Portanto, depende realmente do que exatamente pode causar exceções. Se você está tentando abrir um arquivo com um caminho, não, não é puro, porque o arquivo pode ou não existir, o que faria com que o valor de retorno variasse para a mesma entrada.

Por outro lado, se você estiver tentando analisar um número inteiro de uma determinada string e lançar uma exceção se ela falhar, isso pode ser puro, desde que nenhuma exceção possa sair de sua função.

Em uma nota lateral, os idiomas funcionais tendem a retornar o tipo de unidade apenas se houver apenas uma única condição de erro possível. Se houver vários erros possíveis, eles tendem a retornar um tipo de erro com informações sobre o erro.

8bittree
fonte
Você não pode retornar o tipo de fundo.
gardenhead
11
@ Gardenhead Você está certo, eu estava pensando no tipo de unidade. Fixo.
8bittree
No caso do seu primeiro exemplo, o sistema de arquivos e quais arquivos existem nele simplesmente não são uma das entradas para a função?
Validade 27/10
2
@Vaility Na realidade, você provavelmente tem um identificador ou um caminho para o arquivo. Se a função ffor pura, você esperaria f("/path/to/file")sempre retornar o mesmo valor. O que acontece se o arquivo real for excluído ou alterado entre duas invocações para f?
27716 Andres F.
2
@Vaility A função pode ser pura se, em vez de um identificador para o arquivo, você transmitiu o conteúdo real (ou seja, o instantâneo exato de bytes) do arquivo, nesse caso, você pode garantir que, se o mesmo conteúdo for inserido, a mesma saída apaga-se. Mas acessar um arquivo não é assim :)
Andres F.