haskell - todas - ¿Por qué la captura de una excepción no es pura, pero lanzar una excepción es puro?
que es throws en java (2)
En Haskell, puede lanzar una excepción desde el código puramente funcional, pero solo puede capturar el código IO.
- ¿Por qué?
- ¿Se puede atrapar en otros contextos o solo en la mónada IO?
- ¿Cómo lo manejan otros lenguajes puramente funcionales?
El documento que Delnan menciona en los comentarios, y las respuestas a esta pregunta anterior , ciertamente proporcionan razones suficientes para detectar solo excepciones en IO
.
Sin embargo, también puedo ver por qué razones como observar el orden de evaluación o romper la monotonicidad pueden no ser persuasivas en un nivel intuitivo; es difícil imaginar cómo cualquiera de las dos podría causar mucho daño en la gran mayoría de los códigos. Como tal, podría ayudar a recordar que el manejo de excepciones es una estructura de flujo de control de una variedad claramente no local, y ser capaz de capturar excepciones en código puro permitiría (mal) usarlas para ese propósito.
Permítame ilustrar exactamente qué horror conlleva esto.
Primero, definimos un tipo de excepción para usar, y una versión de catch
que se puede usar en código puro:
newtype Exit a = Exit { getExit :: a } deriving (Typeable)
instance Show (Exit a) where show _ = "EXIT"
instance (Typeable a) => Exception (Exit a)
unsafeCatch :: (Exception e) => a -> (e -> a) -> a
unsafeCatch x f = unsafePerformIO $ catch (seq x $ return x) (return . f)
Esto nos permitirá lanzar cualquier valor Typeable
y luego capturarlo desde algún ámbito externo, sin el consentimiento de ninguna expresión intermedia. Por ejemplo, podríamos ocultar un lanzamiento de Exit
dentro de algo que pasamos a una función de orden superior para escapar con algún valor intermedio generado por su evaluación. Es posible que los lectores astutos hayan descubierto a dónde va esto:
callCC :: (Typeable a) => ((a -> b) -> a) -> a
callCC f = unsafeCatch (f (throw . Exit)) (/(Exit e) -> e)
Sí, eso realmente funciona, con la advertencia de que requiere cualquier uso de la continuación para ser evaluado siempre que la expresión sea completa. Ten esto en cuenta si lo intentas, o simplemente usas deepseq
si deepseq
desde la órbita es más tu velocidad.
Mirad:
-- This will clearly never terminate, no matter what k is
foo k = fix (/f x -> if x > 100 then f (k x) else f (x + 1)) 0
Pero:
∀x. x ⊢ callCC foo
101
Escapando desde dentro de un map
:
seqs :: [a] -> [a]
seqs xs = foldr (/h t -> h `seq` t `seq` (h:t)) [] xs
bar n k = map (/x -> if x > 10 then k [x] else x) [0..n]
Tenga en cuenta la necesidad de forzar la evaluación.
∀x. x ⊢ callCC (seqs . bar 9)
[0,1,2,3,4,5,6,7,8,9]
∀x. x ⊢ callCC (seqs . bar 11)
[11]
... ugh
Ahora, nunca volvamos a hablar de esto.
Porque lanzar una excepción dentro de una función no hace que el resultado de esa función dependa de nada más que de los valores de los argumentos y la definición de la función; La función permanece pura. La OTOH que detecta una excepción dentro de una función hace (o al menos puede) hacer que esa función ya no sea una función pura.
Voy a ver dos tipos de excepción. El primero no es determinista; tales excepciones surgen de manera impredecible en el tiempo de ejecución, e incluyen cosas como errores de memoria insuficiente. La existencia de estas excepciones no se incluye en el significado de las funciones que podrían generarlas. Es solo un hecho desagradable de la vida con el que tenemos que lidiar porque tenemos máquinas físicas reales en el mundo real, que no siempre coinciden con las abstracciones que utilizamos para ayudarnos a programarlas.
Si una función lanza tal excepción, significa que ese intento en particular para evaluar la función no pudo producir un valor. No significa necesariamente que el resultado de la función no esté definido (en los argumentos en que se invocó en este momento), pero el sistema no pudo producir el resultado.
Si pudiera detectar una excepción de este tipo dentro de un llamador puro, podría hacer cosas como tener una función que devuelva un valor (no inferior) cuando un cálculo secundario se complete con éxito y otro cuando se quede sin memoria. Esto no tiene sentido como una función pura; el valor calculado por una llamada de función debe ser determinado únicamente por los valores de sus argumentos y la definición de la función. El hecho de poder devolver algo diferente dependiendo de si el cálculo secundario se quedó sin memoria hace que el valor de retorno dependa de otra cosa (cuánta memoria está disponible en la máquina física, qué otros programas se están ejecutando, el sistema operativo y sus políticas, etc. etc); por definición, una función que puede comportarse de esta manera no es pura y no puede (normalmente) existir en Haskell.
Debido a fallas puramente operativas, tenemos que permitir que la evaluación de una función pueda producir el fondo en lugar del valor que "debería" haber producido. Eso no arruina completamente nuestra interpretación semántica de los programas de Haskell, porque sabemos que la parte inferior hará que todas las personas que llaman también produzcan la parte inferior (a menos que no necesitaran el valor que se suponía que debía calcularse, pero en ese caso no La evaluación estricta implica que el sistema nunca habría intentado evaluar esta función y falló. Eso suena mal, pero cuando llegamos a un cálculo en la mónada IO
, podemos detectar dichas excepciones de forma segura. Los valores en la mónada IO
pueden depender de cosas "fuera" del programa; de hecho, pueden cambiar su valor dependiendo de cualquier cosa en el mundo (esta es la razón por la cual una interpretación común de los valores de IO
es que son como si se les hubiera pasado una representación de todo el universo). Por lo tanto, es perfectamente correcto que un valor de IO
tenga un resultado si un sub-cálculo puro se queda sin memoria y otro resultado si no lo hace.
Pero ¿qué pasa con las excepciones deterministas ? Aquí estoy hablando de excepciones que siempre se lanzan cuando se evalúa una función particular en un conjunto particular de argumentos. Tales excepciones incluyen errores de división por cero, así como cualquier excepción lanzada explícitamente desde una función pura (ya que su resultado solo puede depender de sus argumentos y su definición, si se evalúa como un lanzamiento una vez que siempre se evaluará el mismo lanzamiento). para los mismos argumentos [1]).
Puede parecer que esta clase de excepciones debería ser detectable en código puro. Después de todo, el valor de 1/0 es un error de división por cero. Si una función puede tener un resultado diferente dependiendo de si un sub-cálculo se evalúa como un error de división por cero verificando si está pasando un cero, ¿por qué no puede hacerlo comprobando si el resultado es una división por -zero error?
Aquí volvemos al punto larsmans hecho en un comentario. Si una función pura puede observar qué excepción obtiene de la throw ex1 + throw ex2
, entonces su resultado se vuelve dependiente del orden de ejecución. Pero eso depende del sistema de tiempo de ejecución, y posiblemente podría incluso cambiar entre dos ejecuciones diferentes del mismo sistema. Tal vez tengamos alguna implementación avanzada de paralelización automática que pruebe diferentes estrategias de paralelización en cada ejecución para intentar converger en la mejor estrategia en múltiples ejecuciones. Esto haría que el resultado de la función de captura de excepciones dependiera de la estrategia utilizada, la cantidad de CPU en la máquina, la carga en la máquina, el sistema operativo y sus políticas de programación, etc.
De nuevo, la definición de una función pura es que solo la información que entra en una función a través de sus argumentos (y su definición) debe afectar su resultado. En el caso de las funciones que no son de IO
, la información que afecta a la excepción que se produce no entra en la función a través de sus argumentos o definición, por lo que no puede afectar el resultado. Pero los cálculos en la mónada IO
implícitamente pueden depender de cualquier detalle de todo el universo, por lo que capturar tales excepciones está bien ahí.
En cuanto a su segundo punto, no, otras mónadas no funcionarían para atrapar excepciones. Se aplican todos los mismos argumentos; los cálculos que producen Maybe x
o [y]
no deben depender de nada fuera de sus argumentos, y la captura de cualquier tipo de excepción "filtra" todo tipo de detalles sobre cosas que no están incluidas en esos argumentos de funciones.
Recuerda, no hay nada particularmente especial en las mónadas. No funcionan de manera diferente a otras partes de Haskell. La clase de mónada se define en el código Haskell normal, al igual que casi todas las implementaciones de mónada. Todas las mismas reglas que se aplican al código Haskell ordinario se aplican a todas las mónadas. Es IO
lo que es especial, no el hecho de que sea una mónada.
En cuanto a cómo otros idiomas puros manejan la captura de excepciones, el único otro idioma con pureza forzada con el que tengo experiencia es Mercury. [2] Mercury lo hace un poco diferente de Haskell, y puede detectar excepciones en código puro.
Mercury es un lenguaje de programación lógico, por lo que, en lugar de desarrollarse en funciones, los programas de Mercury se crean a partir de predicados ; una llamada a un predicado puede tener cero, una o más soluciones (si está familiarizado con la programación en la mónada de la lista para obtener el no determinismo, es como si todo el idioma estuviera en la mónada de la lista). Operacionalmente, la ejecución de Mercury utiliza el retroceso para enumerar recursivamente todas las soluciones posibles para un predicado, pero la semántica de un predicado no determinista es que simplemente tiene un conjunto de soluciones para cada conjunto de sus argumentos de entrada, en oposición a una función de Haskell que calcula una sola valor de resultado para cada conjunto de sus argumentos de entrada. Al igual que Haskell, Mercury es puro (incluida la E / S, aunque utiliza un mecanismo ligeramente diferente), por lo que cada llamada a un predicado debe determinar de forma única un único conjunto de soluciones , que depende solo de los argumentos y la definición del predicado.
Mercurio rastrea el "determinismo" de cada predicado. Los predicados que siempre dan como resultado exactamente una solución se llaman det
(abreviatura de determinista). Aquellos que generan al menos una solución se llaman multi
. También hay algunas otras clases de determinismo, pero no son relevantes aquí.
La captura de una excepción con un bloque try
(o llamando explícitamente a los predicados de orden superior que lo implementan) tiene determinismo cc_multi
. El cc significa "elección comprometida". Significa que "este cálculo tiene al menos una solución y, operativamente, el programa solo obtendrá una de ellas". Esto se debe a que ejecutar el sub-cálculo y ver si produjo una excepción tiene un conjunto de soluciones que es la unión de las soluciones "normales" del sub-cálculo más el conjunto de todas las posibles excepciones que podría lanzar. Dado que "todas las posibles excepciones" incluyen todas las posibles fallas en el tiempo de ejecución, la mayoría de las cuales nunca ocurrirán, este conjunto de soluciones no se puede realizar completamente. No hay forma posible de que el motor de ejecución realmente retroceda a través de todas las soluciones posibles para el bloque try
, así que en vez de eso solo le da una solución (ya sea una normal o una indicación de que se exploraron todas las posibilidades y no hubo ninguna solución o excepción). La primera excepción que ocurrió surgió).
Debido a que el compilador realiza un seguimiento del determinismo, no le permitirá llamar a try
en un contexto donde el conjunto completo de soluciones sea importante. No puede usarlo para generar todas las soluciones que no encuentran una excepción, por ejemplo, porque el compilador se quejará de que necesita todas las soluciones para una llamada cc_multi
, que solo producirá una. Sin embargo, tampoco puede llamarlo desde un predicado det
, porque el compilador se quejará de que un predicado det
(que se supone que tiene exactamente una solución) está haciendo una llamada cc_multi
, que tendrá múltiples soluciones (solo vamos a saber cuál es uno de ellos).
Entonces, ¿cómo en la tierra es útil? Bueno, puedes tener main
(y otras cosas a las que llama, si eso es útil) declaradas como cc_multi
, y pueden llamar a try
sin problemas. Esto significa que todo el programa tiene múltiples "soluciones" en teoría, pero su ejecución generará una solución. Esto le permite escribir un programa que se comporta de manera diferente cuando se queda sin memoria en algún momento. Pero no estropea la semántica declarativa porque el resultado "real" que habría computado con más memoria disponible todavía se encuentra en el conjunto de soluciones (al igual que la excepción de falta de memoria todavía está en el conjunto de soluciones cuando el programa realmente lo hace). calcular un valor), es solo que solo terminamos con una solución arbitraria.
Es importante que det
(hay exactamente una solución) se trate de manera diferente a cc_multi
(hay varias soluciones, pero solo puedes tener una). De manera similar al razonamiento sobre la captura de excepciones en Haskell, no se puede permitir que la captura de excepciones ocurra en un contexto que no sea de "elección comprometida", o puede obtener predicados puros que producen diferentes conjuntos de soluciones dependiendo de la información del mundo real que no deberían " t tiene acceso a El determinismo cc_multi
de try
nos permite escribir programas como si produjeran un conjunto de soluciones infinito (en su mayoría lleno de variantes menores de excepciones improbables), y nos impide escribir programas que realmente necesiten más de una solución del conjunto. [3]
[1] A menos que su evaluación encuentre un error no determinista primero. La vida real es un dolor.
[2] Los lenguajes que simplemente alientan al programador a usar la pureza sin imponerla (como Scala) tienden a permitirle detectar excepciones donde quiera, igual que le permiten hacer I / O donde quiera.
[3] Tenga en cuenta que el concepto de "elección comprometida" no es la forma en que Mercury maneja la E / S pura. Para eso, Mercury usa tipos únicos, que son ortogonales a la clase de determinismo de "elección comprometida".