haskell unsafe-perform-io

haskell - ¿Estoy abusando de unsafePerformIO?



unsafe-perform-io (4)

El propósito de unsafePerformIO es cuando su función realiza alguna acción interna, pero no tiene efectos secundarios que un observador notaría. Por ejemplo, una función que toma un vector, lo copia, ordena rápidamente la copia en el lugar y luego devuelve la copia. (ver comentarios) Cada una de estas operaciones tiene efectos secundarios, y también lo está en IO , pero el resultado general no.

newUnique debe ser una acción IO porque genera algo diferente cada vez. Esta es básicamente la definición de IO , significa un verbo , a diferencia de las funciones que son adjetivos . Una función siempre devolverá el mismo resultado para los mismos argumentos. Esto se llama transparencia referencial.

Para usos válidos de unsafePerformIO , vea esta pregunta .

Para familiarizarse con unsafePerformIO (cómo usarlo y cuándo usarlo), he implementado un módulo para generar valores únicos.

Esto es lo que tengo:

module Unique (newUnique) where import Data.IORef import System.IO.Unsafe (unsafePerformIO) -- Type to represent a unique thing. -- Show is derived just for testing purposes. newtype Unique = U Integer deriving Show -- I believe this is the Haskell''98 derived instance, but -- I want to be explicit, since its Eq instance is the most -- important part of Unique. instance Eq Unique where (U x) == (U y) = x == y counter :: IORef Integer counter = unsafePerformIO $ newIORef 0 updateCounter :: IO () updateCounter = do x <- readIORef counter writeIORef counter (x+1) readCounter :: IO Integer readCounter = readIORef counter newUnique'' :: IO Unique newUnique'' = do { x <- readIORef counter ; writeIORef counter (x+1) ; return $ U x } newUnique :: () -> Unique newUnique () = unsafePerformIO newUnique''

Para mi deleite, el package llamado Data.Unique eligió el mismo tipo de datos que yo; por otro lado, eligieron el tipo newUnique :: IO Unique , pero quiero permanecer fuera de IO si es posible.

¿Es esta implementación peligrosa? ¿Podría posiblemente llevar a GHC a cambiar la semántica de un programa que lo usa?


Sí, tu módulo es peligroso. Considera este ejemplo:

module Main where import Unique main = do print $ newUnique () print $ newUnique ()

Compilar y ejecutar:

$ ghc Main.hs $ ./Main U 0 U 1

Compilar con optimización y ejecutar:

$ /rm *.{hi,o} $ ghc -O Main.hs $ ./Main U 0 U 0

¡UH oh!

Agregar {-# NOINLINE counter #-} y {-# NOINLINE newUnique #-} no ayuda, así que no estoy realmente seguro de lo que está pasando aquí ...

1ª ACTUALIZACIÓN

Mirando el núcleo de GHC, veo que @LambdaFairy estaba en lo cierto en que la eliminación constante de subexpresiones (CSE) hizo que mis expresiones newUnique () se levantaran. Sin embargo, prevenir CSE con -fno-cse y agregar {-# NOINLINE counter #-} a Unique.hs no es suficiente para que el programa optimizado se imprima igual que el programa no optimizado. En particular, parece que el counter está en línea incluso con el pragma Unique.hs en Unique.hs . ¿Alguien entiende por qué?

He cargado las versiones completas de los siguientes archivos principales en https://gist.github.com/ntc2/6986500 .

El núcleo (relevante) para main al compilar con -O :

main3 :: Unique.Unique [GblId, Unf=Unf{Src=<vanilla>, TopLvl=True, Arity=0, Value=False, ConLike=False, Cheap=False, Expandable=False, Guidance=IF_ARGS [] 20 0}] main3 = Unique.newUnique () main2 :: [Char] [GblId, Unf=Unf{Src=<vanilla>, TopLvl=True, Arity=0, Value=False, ConLike=False, Cheap=False, Expandable=False, Guidance=IF_ARGS [] 40 0}] main2 = Unique.$w$cshowsPrec 0 main3 ([] @ Char) main4 :: [Char] [GblId, Unf=Unf{Src=<vanilla>, TopLvl=True, Arity=0, Value=False, ConLike=False, Cheap=False, Expandable=False, Guidance=IF_ARGS [] 40 0}] main4 = Unique.$w$cshowsPrec 0 main3 ([] @ Char) main1 :: State# RealWorld -> (# State# RealWorld, () #) [GblId, Arity=1, Unf=Unf{Src=<vanilla>, TopLvl=True, Arity=1, Value=True, ConLike=True, Cheap=True, Expandable=True, Guidance=IF_ARGS [0] 110 0}] main1 = / (eta_B1 :: State# RealWorld) -> case Handle.Text.hPutStr2 Handle.FD.stdout main4 True eta_B1 of _ { (# new_s_atQ, _ #) -> Handle.Text.hPutStr2 Handle.FD.stdout main2 True new_s_atQ }

Tenga en cuenta que las llamadas newUnique () se han levantado y enlazado a main3 .

Y ahora al compilar con -O -fno-cse :

main3 :: Unique.Unique [GblId, Unf=Unf{Src=<vanilla>, TopLvl=True, Arity=0, Value=False, ConLike=False, Cheap=False, Expandable=False, Guidance=IF_ARGS [] 20 0}] main3 = Unique.newUnique () main2 :: [Char] [GblId, Unf=Unf{Src=<vanilla>, TopLvl=True, Arity=0, Value=False, ConLike=False, Cheap=False, Expandable=False, Guidance=IF_ARGS [] 40 0}] main2 = Unique.$w$cshowsPrec 0 main3 ([] @ Char) main5 :: Unique.Unique [GblId, Unf=Unf{Src=<vanilla>, TopLvl=True, Arity=0, Value=False, ConLike=False, Cheap=False, Expandable=False, Guidance=IF_ARGS [] 20 0}] main5 = Unique.newUnique () main4 :: [Char] [GblId, Unf=Unf{Src=<vanilla>, TopLvl=True, Arity=0, Value=False, ConLike=False, Cheap=False, Expandable=False, Guidance=IF_ARGS [] 40 0}] main4 = Unique.$w$cshowsPrec 0 main5 ([] @ Char) main1 :: State# RealWorld -> (# State# RealWorld, () #) [GblId, Arity=1, Unf=Unf{Src=<vanilla>, TopLvl=True, Arity=1, Value=True, ConLike=True, Cheap=True, Expandable=True, Guidance=IF_ARGS [0] 110 0}] main1 = / (eta_B1 :: State# RealWorld) -> case Handle.Text.hPutStr2 Handle.FD.stdout main4 True eta_B1 of _ { (# new_s_atV, _ #) -> Handle.Text.hPutStr2 Handle.FD.stdout main2 True new_s_atV }

Tenga en cuenta que main3 y main5 son las dos llamadas newUnique () separadas.

Sin embargo:

rm *.hi *o Main ghc -O -fno-cse Main.hs && ./Main U 0 U 0

Mirando el núcleo de este Unique.hs modificado:

module Unique (newUnique) where import Data.IORef import System.IO.Unsafe (unsafePerformIO) -- Type to represent a unique thing. -- Show is derived just for testing purposes. newtype Unique = U Integer deriving Show {-# NOINLINE counter #-} counter :: IORef Integer counter = unsafePerformIO $ newIORef 0 newUnique'' :: IO Unique newUnique'' = do { x <- readIORef counter ; writeIORef counter (x+1) ; return $ U x } {-# NOINLINE newUnique #-} newUnique :: () -> Unique newUnique () = unsafePerformIO newUnique''

Parece que el counter está en línea como counter_rag , a pesar del pragma NOINLINE (2da actualización: ¡incorrecto! counter_rag no está marcado con [InlPrag=NOINLINE] , pero eso no significa que esté en línea; más bien, counter_rag es simplemente el nombre combinado del counter ); el NOINLINE para newUnique es respetado aunque:

counter_rag :: IORef Type.Integer counter_rag = unsafeDupablePerformIO @ (IORef Type.Integer) (lvl1_rvg `cast` (Sym (NTCo:IO <IORef Type.Integer>) :: (State# RealWorld -> (# State# RealWorld, IORef Type.Integer #)) ~# IO (IORef Type.Integer))) [...] lvl3_rvi :: State# RealWorld -> (# State# RealWorld, Unique.Unique #) [GblId, Arity=1] lvl3_rvi = / (s_aqi :: State# RealWorld) -> case noDuplicate# s_aqi of s''_aqj { __DEFAULT -> case counter_rag `cast` (NTCo:IORef <Type.Integer> :: IORef Type.Integer ~# STRef RealWorld Type.Integer) of _ { STRef var#_au4 -> case readMutVar# @ RealWorld @ Type.Integer var#_au4 s''_aqj of _ { (# new_s_atV, a_atW #) -> case writeMutVar# @ RealWorld @ Type.Integer var#_au4 (Type.plusInteger a_atW lvl2_rvh) new_s_atV of s2#_auo { __DEFAULT -> (# s2#_auo, a_atW `cast` (Sym (Unique.NTCo:Unique) :: Type.Integer ~# Unique.Unique) #) } } } } lvl4_rvj :: Unique.Unique lvl4_rvj = unsafeDupablePerformIO @ Unique.Unique (lvl3_rvi `cast` (Sym (NTCo:IO <Unique.Unique>) :: (State# RealWorld -> (# State# RealWorld, Unique.Unique #)) ~# IO Unique.Unique)) Unique.newUnique [InlPrag=NOINLINE] :: () -> Unique.Unique Unique.newUnique = / (ds_dq8 :: ()) -> case ds_dq8 of _ { () -> lvl4_rvj }

¿Que está pasando aqui?

2da ACTUALIZACIÓN

El usuario @errge lo descubrió . Al observar con más cuidado que la última salida del núcleo pegada anteriormente, vemos que la mayor parte del cuerpo de Unique.newUnique se ha flotado al nivel superior como lvl4_rvj . Sin embargo, lvl4_rvj es una expresión constante , no una función, por lo que solo se evalúa una vez, explicando la salida repetida de U 0 de main .

En efecto:

rm *.hi *o Main ghc -O -fno-cse -fno-full-laziness Main.hs && ./Main U 0 U 1

No entiendo exactamente lo que hace la -ffull-laziness ", los documentos de GHC hablan sobre los enlaces flotantes de let, pero el cuerpo de lvl4_rvj no parece haber sido un enlace let - pero al menos podemos comparar el núcleo anterior con el núcleo generado con -fno-full-laziness y ver que ahora el cuerpo no se levanta:

Unique.newUnique [InlPrag=NOINLINE] :: () -> Unique.Unique Unique.newUnique = / (ds_drR :: ()) -> case ds_drR of _ { () -> unsafeDupablePerformIO @ Unique.Unique ((/ (s_as1 :: State# RealWorld) -> case noDuplicate# s_as1 of s''_as2 { __DEFAULT -> case counter_rfj `cast` (<NTCo:IORef> <Type.Integer> :: IORef Type.Integer ~# STRef RealWorld Type.Integer) of _ { STRef var#_avI -> case readMutVar# @ RealWorld @ Type.Integer var#_avI s''_as2 of _ { (# ipv_avz, ipv1_avA #) -> case writeMutVar# @ RealWorld @ Type.Integer var#_avI (Type.plusInteger ipv1_avA (__integer 1)) ipv_avz of s2#_aw2 { __DEFAULT -> (# s2#_aw2, ipv1_avA `cast` (Sym <(Unique.NTCo:Unique)> :: Type.Integer ~# Unique.Unique) #) } } } }) `cast` (Sym <(NTCo:IO <Unique.Unique>)> :: (State# RealWorld -> (# State# RealWorld, Unique.Unique #)) ~# IO Unique.Unique)) }

Aquí counter_rfj corresponde a counter nuevo, y vemos que la diferencia es que el cuerpo de Unique.newUnique no se ha levantado, por lo que el código de actualización de referencia ( readMutVar , writeMutVar ) se ejecutará cada vez que se Unique.newUnique .

He actualizado https://gist.github.com/ntc2/6986500 para incluir el nuevo archivo central -fno-full-laziness . Los archivos principales anteriores se generaron en una computadora diferente, por lo que algunas diferencias menores aquí no están relacionadas con la -fno-full-laziness .


Trate unsafePerformIO como una promesa para el compilador. Dice: "Prometo que puede tratar esta acción IO como si fuera un valor puro y que nada saldrá mal". Es útil porque hay ocasiones en que puede construir una interfaz pura para un cálculo implementado con operaciones impuras, pero es imposible para el compilador verificar cuándo es este el caso; en unsafePerformIO lugar, unsafePerformIO permite poner su mano sobre su corazón y jurar que ha verificado que el cálculo impuro es realmente puro, por lo que el compilador simplemente puede confiar en que lo es.

En este caso esa promesa es falsa. Si newUnique fuera una función pura, entonces let x = newUnique () in (x, x) y (newUnique (), newUnique ()) serían expresiones equivalentes. Pero querrías que estas dos expresiones tuvieran resultados diferentes; un par de duplicados del mismo valor Unique en un caso, y un par de dos valores Unique diferentes en el otro. Con tu código, realmente no hay manera de decir lo que significa cualquiera de las expresiones. Solo se pueden entender considerando la secuencia real de operaciones que el programa llevará a cabo en tiempo de ejecución, y el control sobre eso es exactamente lo que está renunciando cuando usa unsafePerformIO . unsafePerformIO dice que no importa si cualquiera de las expresiones se compila como una ejecución de newUnique o dos, y cualquier implementación de Haskell es libre de elegir lo que quiera cada vez que encuentre ese código.


Ver otro ejemplo de cómo esto falla:

module Main where import Unique helper :: Int -> Unique -- noinline pragma here doesn''t matter helper x = newUnique () main = do print $ helper 3 print $ helper 4

Con este código, el efecto es el mismo que en el ejemplo de ntc2: correcto con -O0, pero incorrecto con -O. Pero en este código no hay "subexpresión común para eliminar".

Lo que realmente sucede aquí es que la expresión newUnique () está "flotando" al nivel superior, porque no depende de los parámetros de la función. En GHC, esto es -ffull-laziness ( -ffull-laziness de forma predeterminada con -O , se puede desactivar con -O -fno-full-laziness ).

Entonces el código se convierte efectivamente en esto:

helperworker = newUnique () helper x = helperworker

Y aquí, helperworker es un procesador que solo se puede evaluar una vez.

Con los pragmas NOINLINE ya recomendados si agrega -fno-full-laziness a la línea de comando, entonces funciona como se espera.