Manejo de excepciones de estilo funcional

24

Me han dicho que en la programación funcional no se debe lanzar y / u observar excepciones. En cambio, un cálculo erróneo debe evaluarse como un valor inferior. En Python (u otros lenguajes que no fomentan completamente la programación funcional) uno puede regresar None(u otra alternativa tratada como el valor inferior, aunque Noneno cumple estrictamente con la definición) cuando algo sale mal para "permanecer puro", pero para hacerlo así que uno debe observar un error en primer lugar, es decir

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

¿Esto viola la pureza? Y si es así, ¿significa que es imposible manejar los errores únicamente en Python?

Actualizar

En su comentario, Eric Lippert me recordó otra forma de tratar las excepciones en FP. Aunque nunca lo había visto en Python en la práctica, jugué con él cuando estudié FP hace un año. Aquí, cualquier optionalfunción decorada devuelve Optionalvalores, que pueden estar vacíos, para salidas normales así como para una lista específica de excepciones (las excepciones no especificadas aún pueden terminar la ejecución). Carrycrea una evaluación retrasada, donde cada paso (llamada de función retrasada) obtiene un Optionalresultado no vacío del paso anterior y simplemente lo pasa, o de lo contrario se evalúa pasando un nuevo Optional. Al final, el valor final es normal o Empty. Aquí el try/exceptbloque está oculto detrás de un decorador, por lo que las excepciones especificadas se pueden considerar como parte de la firma de 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
fuente
1
Su pregunta ya ha sido respondida, así que solo un comentario: ¿comprende por qué su función arroja una excepción está mal vista en la programación funcional? No es un capricho arbitrario :)
Andres F.
66
Hay otra alternativa para devolver un valor que indica el error. Recuerde, el manejo de excepciones es flujo de control y tenemos mecanismos funcionales para reificar los flujos de control. Puede emular el manejo de excepciones en lenguajes funcionales escribiendo su método para tomar dos funciones: la continuación del éxito y la continuación del error . Lo último que hace su función es llamar a la continuación del éxito, pasando el "resultado", o la continuación del error, pasando la "excepción". La desventaja es que tienes que escribir tu programa de esta manera.
Eric Lippert
3
No, ¿cuál sería el estado en este caso? Hay varios problemas, pero aquí hay algunos: 1- hay un flujo posible que no está codificado en el tipo, es decir, no puede saber si una función arrojará una excepción simplemente mirando su tipo (a menos que solo haya marcado excepciones, por supuesto, pero no conozco ningún idioma que solo las tenga). Está trabajando efectivamente "fuera" del sistema de tipos, los programadores de 2 funciones se esfuerzan por escribir funciones "totales" siempre que sea posible, es decir, funciones que devuelven un valor para cada entrada (salvo la no terminación). Las excepciones van en contra de esto.
Andres F.
3
3- cuando trabaja con funciones totales, puede componerlas con otras funciones y usarlas en funciones de orden superior sin preocuparse por los resultados de error "no codificados en el tipo", es decir, excepciones.
Andres F.
1
@EliKorvigo Establecer una variable introduce el estado, por definición, punto final. El propósito completo de las variables es que mantengan el estado. Eso no tiene nada que ver con excepciones. Observar el valor de retorno de una función y establecer el valor de una variable basado en la observación introduce el estado en la evaluación, ¿no es así?
usuario253751

Respuestas:

20

En primer lugar, aclaremos algunos conceptos erróneos. No hay "valor de fondo". El tipo inferior se define como un tipo que es un subtipo de cualquier otro tipo en el idioma. A partir de esto, se puede demostrar (al menos en cualquier sistema de tipos interesante), que el tipo inferior no tiene valores , está vacío . Por lo tanto, no existe un valor inferior.

¿Por qué es útil el tipo de fondo? Bueno, sabiendo que está vacío, hagamos algunas deducciones sobre el comportamiento del programa. Por ejemplo, si tenemos la función:

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

sabemos que do_thingnunca puede regresar, ya que tendría que devolver un valor de tipo Bottom. Por lo tanto, solo hay dos posibilidades:

  1. do_thing no se detiene
  2. do_thing lanza una excepción (en idiomas con un mecanismo de excepción)

Tenga en cuenta que creé un tipo Bottomque en realidad no existe en el lenguaje Python. Nonees un nombre inapropiado; en realidad es el valor de la unidad , el único valor del tipo de unidad , que se llama NoneTypeen Python (hazlo type(None)por ti mismo).

Ahora, otro concepto erróneo es que los lenguajes funcionales no tienen excepción. Esto tampoco es cierto. SML, por ejemplo, tiene un mecanismo de excepción muy agradable. Sin embargo, las excepciones se usan mucho más moderadamente en SML que, por ejemplo, en Python. Como ha dicho, la forma común de indicar algún tipo de falla en los lenguajes funcionales es devolviendo un Optiontipo. Por ejemplo, crearíamos una función de división segura de la siguiente manera:

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

Desafortunadamente, dado que Python no tiene tipos de suma, este no es un enfoque viable. Usted podría regresar Nonecomo un hombre pobre tipo de opción al fracaso significan, pero esto no es realmente mejor que volver Null. No hay tipo de seguridad.

Por lo tanto, recomendaría seguir las convenciones del lenguaje en este caso. Python usa excepciones idiomáticamente para manejar el flujo de control (que es un mal diseño, IMO, pero es estándar de todos modos), así que a menos que solo esté trabajando con el código que escribió usted mismo, recomendaría seguir la práctica estándar. Si esto es "puro" o no es irrelevante.

cabeza de jardín
fuente
Por "valor de fondo" en realidad me refería al "tipo de fondo", por eso escribí que Noneno cumplía con la definición. De todos modos, gracias por corregirme. ¿No cree que usar la excepción solo para detener la ejecución por completo o devolver un valor opcional está bien con los principios de Python? Quiero decir, ¿por qué es malo evitar usar excepciones para un control complicado?
Eli Korvigo
@EliKorvigo Eso es más o menos lo que dije, ¿verdad? Las excepciones son Python idiomático.
cabeza de jardín
1
Por ejemplo, desaconsejo que mis estudiantes de pregrado utilicen try/except/finallycomo otra alternativa if/else, es decir try: var = expession1; except ...: var = expression 2; except ...: var = expression 3..., aunque es algo común en cualquier idioma imperativo (por cierto, desaconsejo usar if/elsebloques también para esto). ¿Quiere decir que no estoy siendo razonable y debería permitir tales patrones ya que "esto es Python"?
Eli Korvigo
@EliKorvigo Estoy de acuerdo contigo en general (por cierto, ¿eres profesor?). try... catch...debe no ser utilizado para el flujo de control. Por alguna razón, así es como la comunidad Python decidió hacer las cosas. Por ejemplo, la safe_divfunción que escribí arriba generalmente se escribiría try: result = num / div: except ArithmeticError: result = None. Entonces, si les está enseñando principios generales de ingeniería de software, definitivamente debería desalentar esto. if ... else...también es un olor a código, pero eso es demasiado largo para entrar aquí.
cabeza de jardín
2
Hay un "valor de fondo" (se usa para hablar sobre la semántica de Haskell, por ejemplo), y tiene poco que ver con el tipo de fondo. Entonces, eso no es realmente una idea errónea del OP, solo hablar de algo diferente.
Ben
11

Dado que ha habido tanto interés en la pureza en los últimos días, ¿por qué no examinamos cómo es una función pura?

Una función pura:

  • Es referencialmente transparente; es decir, para una entrada dada, siempre producirá la misma salida.

  • No produce efectos secundarios; no cambia las entradas, salidas o cualquier otra cosa en su entorno externo. Solo produce un valor de retorno.

Entonces pregúntate a ti mismo. ¿Su función hace algo más que aceptar una entrada y devolver una salida?

Robert Harvey
fuente
3
Entonces, no importa, ¿qué tan feo está escrito en su interior siempre que se comporte funcionalmente?
Eli Korvigo
8
Correcto, si simplemente estás interesado en la pureza. Puede haber, por supuesto, otras cosas a considerar.
Robert Harvey
3
Para jugar al abogado de los demonios, diría que lanzar una excepción es solo una forma de salida y las funciones que arrojan son puras. ¿Es eso un problema?
Bergi
1
@ Bergi No sé si estás jugando al abogado del diablo, ya que eso es precisamente lo que esta respuesta implica :) El problema es que hay otras consideraciones además de la pureza. Si permite excepciones no verificadas (que, por definición, no son parte de la firma de la función), el tipo de retorno de cada función se convierte efectivamente T + { Exception }(donde Tes el tipo de retorno declarado explícitamente), lo cual es problemático. No puede saber si una función arrojará una excepción sin mirar su código fuente, lo que hace que escribir funciones de orden superior también sea problemático.
Andres F.
2
@Begri mientras lanza podría ser posiblemente puro, la OMI que usa excepciones hace más que solo complicar el tipo de retorno de cada función. Daña la composibilidad de las funciones. Considere la implementación de map : (A->B)->List A ->List Bdónde A->Bpuede el error. Si permitimos que f arroje una excepción map f L , devolverá algo de tipo Exception + List<B>. Si, en cambio, permitimos que devuelva un optionaltipo de estilo map f L, en su lugar devolverá Lista <Opcional <B>> `. Esta segunda opción me parece más funcional.
Michael Anderson
7

La semántica de Haskell usa un "valor inferior" para analizar el significado del código de Haskell. No es algo que realmente uses directamente en la programación de Haskell, y regresar Noneno es para nada el mismo tipo de cosas.

El valor inferior es el valor atribuido por la semántica de Haskell a cualquier cálculo que no puede evaluar un valor normalmente. ¡Una de las formas en que un cálculo de Haskell puede hacer eso es en realidad lanzando una excepción! Entonces, si intentabas usar este estilo en Python, en realidad solo deberías lanzar excepciones de manera normal.

La semántica de Haskell usa el valor inferior porque Haskell es vago; puede manipular los "valores" que devuelven los cálculos que aún no se han ejecutado. Puede pasarlos a funciones, pegarlos en estructuras de datos, etc. Tal cálculo no evaluado podría generar una excepción o bucle para siempre, pero si nunca necesitamos examinar el valor, entonces el cálculo nuncaejecutar y encontrar el error, y nuestro programa en general podría lograr hacer algo bien definido y finalizar. Entonces, sin querer explicar qué significa el código de Haskell al especificar el comportamiento operativo exacto del programa en tiempo de ejecución, en su lugar declaramos que estos cálculos erróneos producen el valor inferior y explicamos qué se comporta ese valor; Básicamente, cualquier expresión que deba depender de cualquier propiedad del valor inferior (aparte de la existente) también dará como resultado el valor inferior.

Para permanecer "puro", todas las formas posibles de generar el valor inferior deben tratarse como equivalentes. Eso incluye el "valor inferior" que representa un bucle infinito. Como no hay forma de saber que algunos bucles infinitos en realidad son infinitos (podrían terminar si los ejecuta un poco más), no puede examinar ninguna propiedad de un valor inferior. No puede probar si algo está en la parte inferior, no puede compararlo con nada más, no puede convertirlo en una cadena, nada. Todo lo que puede hacer con uno es colocarlo (parámetros de función, parte de una estructura de datos, etc.) intactos y sin examinar.

Python ya tiene este tipo de fondo; Es el "valor" que se obtiene de una expresión que arroja una excepción o no termina. Debido a que Python es estricto en lugar de perezoso, tales "fondos" no se pueden almacenar en ningún lugar y, potencialmente, no se pueden examinar. Por lo tanto, no hay necesidad real de utilizar el concepto del valor inferior para explicar cómo los cálculos que no devuelven un valor aún pueden tratarse como si tuvieran un valor. Pero tampoco hay ninguna razón por la que no pueda pensar de esta manera acerca de las excepciones si lo desea.

Lanzar excepciones se considera realmente "puro". Es la captura de excepciones que se rompe la pureza - precisamente porque le permite inspeccionar algo acerca de ciertos valores inferiores, en lugar de tratar a todos indistintamente. En Haskell solo puede detectar excepciones IOque permiten una interfaz impura (por lo que generalmente ocurre en una capa bastante externa). Python no exige la pureza, pero aún puede decidir por sí mismo qué funciones forman parte de su "capa impura externa" en lugar de las funciones puras, y solo se permite detectar excepciones allí.

Volver en su Nonelugar es completamente diferente. Nonees un valor no inferior; puede probar si algo es igual a él, y la persona que llama de la función que regresó Nonecontinuará ejecutándose, posiblemente usando el Noneinapropiado.

Entonces, si estaba pensando en lanzar una excepción y quiere "volver al fondo" para emular el enfoque de Haskell, simplemente no haga nada en absoluto. Deje que la excepción se propague. Eso es exactamente lo que los programadores de Haskell quieren decir cuando hablan de una función que devuelve un valor inferior.

Pero eso no es lo que los programadores funcionales quieren decir cuando dicen evitar las excepciones. Los programadores funcionales prefieren "funciones totales". Estos siempre devuelven un valor no inferior válido de su tipo de retorno para cada entrada posible. Entonces, cualquier función que pueda generar una excepción no es una función total.

La razón por la que nos gustan las funciones totales es que son mucho más fáciles de tratar como "cajas negras" cuando las combinamos y manipulamos. Si tengo una función total que devuelve algo de tipo A y una función total que acepta algo de tipo A, entonces puedo llamar a la segunda en la salida de la primera, sin saber nada sobre la implementación de cualquiera; Sé que obtendré un resultado válido, sin importar cómo se actualice el código de cualquiera de las funciones en el futuro (siempre que se mantenga su totalidad y siempre que conserven la misma firma de tipo). Esta separación de preocupaciones puede ser una ayuda extremadamente poderosa para la refactorización.

También es algo necesario para funciones confiables de orden superior (funciones que manipulan otras funciones). Si quiero escribir código que recibe una función completamente arbitraria (con una interfaz conocida) como un parámetro que tengo que tratarlo como un cuadro negro porque no tengo manera de saber qué entradas podrían dar lugar a un error. Si me dan una función total, ninguna entrada causará un error. Del mismo modo, la persona que llama de mi función de orden superior no sabrá exactamente qué argumentos uso para llamar a la función que me pasan (a menos que quieran depender de los detalles de mi implementación), por lo que pasar una función total significa que no tienen que preocuparse lo que hago con eso

Por lo tanto, un programador funcional que le aconseje que evite las excepciones preferiría que en su lugar devuelva un valor que codifique el error o un valor válido, y que requiera que para usarlo esté preparado para manejar ambas posibilidades. Cosas como Eithertipos o Maybe/ Optiontipos son algunos de los más sencillos se aproxima a ello en los idiomas más inflexible de tipos (se usa generalmente sintaxis especial o funciones de orden superior a las cosas juntos ayuda de pegamento que necesitan una Ade las cosas que producen una Maybe<A>).

Una función que devuelve None(si ocurrió un error) o algún valor (si no hubo error) no está siguiendo ninguna de las estrategias anteriores.

En Python con pato escribiendo, el estilo Either / Maybe no se usa mucho, en lugar de permitir que se generen excepciones, con pruebas para validar que el código funciona en lugar de confiar en que las funciones sean totales y combinables automáticamente según sus tipos. Python no tiene facilidad para hacer cumplir ese código que usa cosas como los tipos tal vez correctamente; incluso si lo estaba utilizando como una cuestión de disciplina, necesita pruebas para ejercer realmente su código para validar eso. Por lo tanto, el enfoque de excepción / fondo probablemente sea más adecuado para la programación funcional pura en Python.

Ben
fuente
1
+1 respuesta impresionante! Y muy completo también.
Andres F.
6

Mientras no haya efectos secundarios visibles externamente y el valor de retorno dependa exclusivamente de las entradas, entonces la función es pura, incluso si hace algunas cosas impuras internamente.

Por lo tanto, realmente depende de qué puede causar excepciones. Si está tratando de abrir un archivo dado una ruta, entonces no, no es puro, porque el archivo puede o no existir, lo que causaría que el valor de retorno varíe para la misma entrada.

Por otro lado, si está tratando de analizar un número entero de una cadena dada y arroja una excepción si falla, eso podría ser puro, siempre que no haya excepciones que salgan de su función.

En una nota al margen, los lenguajes funcionales tienden a devolver el tipo de unidad solo si hay una sola condición de error posible. Si hay varios errores posibles, tienden a devolver un tipo de error con información sobre el error.

8bittree
fuente
No puede devolver el tipo de fondo.
cabeza de jardín
1
@gardenhead Tienes razón, estaba pensando en el tipo de unidad. Fijo.
8bittree
En el caso de su primer ejemplo, ¿no es el sistema de archivos y qué archivos existen dentro de él simplemente una de las entradas a la función?
Vality
2
@Vability En realidad, probablemente tenga un identificador o una ruta al archivo. Si la función fes pura, esperaría f("/path/to/file")devolver siempre el mismo valor. ¿Qué sucede si el archivo real se elimina o cambia entre dos invocaciones f?
Andres F.
2
@Vaility La función podría ser pura si, en lugar de un identificador del archivo, le pasara el contenido real (es decir, la instantánea exacta de los bytes) del archivo, en cuyo caso puede garantizar que si entra el mismo contenido, el mismo resultado sale. Pero acceder a un archivo no es así :)
Andres F.