haskell phantom-types

haskell - ¿Motivación detrás de los tipos de fantasmas?



phantom-types (3)

La motivación detrás del uso de los tipos fantasma es especializar el tipo de retorno de los constructores de datos. Por ejemplo, considere:

data List a = Nil | Cons a (List a)

El tipo de retorno de Nil y Cons es List a por defecto (que está generalizado para todas las listas de tipo a ).

Nil :: List a Cons :: a -> List a -> List a |____| | -- return type is generalized

También tenga en cuenta que Nil es un constructor fantasma (es decir, su tipo de retorno no depende de sus argumentos, en este caso, de manera imprecisa, pero de todos modos es el mismo).

Como Nil es un constructor fantasma, podemos especializar Nil en cualquier tipo que deseemos (por ejemplo, Nil :: List Int o Nil :: List Char ).

Los tipos de datos algebraicos normales en Haskell le permiten elegir el tipo de argumentos de un constructor de datos. Por ejemplo, elegimos el tipo de argumentos para los Cons anteriores ( a y List a ).

Sin embargo, no le permite elegir el tipo de devolución de un constructor de datos. El tipo de devolución siempre es generalizado. Esto está bien para la mayoría de los casos. Sin embargo, hay excepciones. Por ejemplo:

data Expr a = Number Int | Boolean Bool | Increment (Expr Int) | Not (Expr Bool)

El tipo de constructores de datos son:

Number :: Int -> Expr a Boolean :: Bool -> Expr a Increment :: Expr Int -> Expr a Not :: Expr Bool -> Expr a

Como puede ver, el tipo de devolución de todos los constructores de datos está generalizado. Esto es problemático porque sabemos que Number e Increment siempre deben devolver un Expr Int y Boolean y Not siempre debe devolver un Expr Bool .

Los tipos de devolución de los constructores de datos son incorrectos porque son demasiado generales. Por ejemplo, Number no puede devolver un Expr a pero lo hace. Esto le permite escribir expresiones incorrectas que el verificador de tipos no captará. Por ejemplo:

Increment (Boolean False) -- you shouldn''t be able to increment a boolean Not (Number 0) -- you shouldn''t be able to negate a number

El problema es que no podemos especificar el tipo de devolución de los constructores de datos.

Observe que todos los constructores de datos de Expr son constructores fantasmas (es decir, su tipo de devolución no depende de sus argumentos). Un tipo de datos cuyos constructores son todos constructores fantasmas se llama tipo fantasma.

Recuerde que el tipo de devolución de constructores fantasmas como Nil puede estar especializado en cualquier tipo que deseemos. Por lo tanto, podemos crear constructores inteligentes para Expr siguiente manera:

number :: Int -> Expr Int boolean :: Bool -> Expr Bool increment :: Expr Int -> Expr Int not :: Expr Bool -> Expr Bool number = Number boolean = Boolean increment = Increment not = Not

Ahora podemos usar los constructores inteligentes en lugar de los constructores normales y nuestro problema está resuelto:

increment (boolean False) -- error not (number 0) -- error

Así que los constructores fantasmas son útiles cuando se quiere especializar el tipo de retorno de un constructor de datos y los tipos fantasmas son tipos de datos cuyos constructores son todos constructores fantasmas.

Tenga en cuenta que los constructores de datos como Left and Right también son constructores fantasmas:

data Either a b = Left a | Right b Left :: a -> Either a b Right :: b -> Either a b

La razón es que, aunque el tipo de devolución de estos constructores de datos depende de sus argumentos, aún se generalizan porque solo dependen parcialmente de sus argumentos.

Manera simple de saber si un constructor de datos es un constructor fantasma:

¿Aparecen también todas las variables de tipo que aparecen en el tipo de retorno del constructor de datos en los argumentos del constructor de datos? Si es así, no es un constructor fantasma.

Espero que ayude.

Haskell de Don Stewart en la presentación de Large mencionó los Phantom Types :

data Ratio n = Ratio Double 1.234 :: Ratio D3 data Ask ccy = Ask Double Ask 1.5123 :: Ask GBP

Leí sus puntos sobre ellos, pero no los entendí. Además, leí el Wiki de Haskell sobre el tema. Sin embargo, todavía estoy perdiendo su punto.

¿Cuál es la motivación para usar un tipo fantasma?


Para la Ratio D3 específicamente, utilizamos tipos ricos como ese para dirigir código dirigido por tipo, por ejemplo, si tiene un campo en algún lugar en tipo Ratio D3 , su editor se envía a un campo de texto que acepta entradas numéricas solamente y muestra una precisión de 3 dígitos . Esto está en contraste, por ejemplo, con newtype Amount = Amount Double donde no mostramos dígitos decimales, pero usamos mil comas y analizamos la entrada como ''10m'' como ''10, 000,000''.

En la representación subyacente, ambos siguen siendo solo Double .


Para responder al "¿cuál es la motivación para usar un tipo fantasma"? Hay dos puntos:

  • para hacer que los estados inválidos sean irrepresentables, lo cual está bien explicado en la respuesta de Aadit
  • Llevar parte de la información sobre el nivel de tipo

Por ejemplo, puede tener distancias etiquetadas por la unidad de longitud:

{-# LANGUAGE GeneralizedNewtypeDeriving #-} newtype Distance a = Distance Double deriving (Num, Show) data Kilometer data Mile marathonDistance :: Distance Kilometer marathonDistance = Distance 42.195 distanceKmToMiles :: Distance Kilometer -> Distance Mile distanceKmToMiles (Distance km) = Distance (0.621371 * km) marathonDistanceInMiles :: Distance Mile marathonDistanceInMiles = distanceKmToMiles marathonDistance

Y puedes evitar el desastre de Mars Climate Orbiter :

>>> marathonDistanceInMiles Distance 26.218749345 >>> marathonDistanceInMiles + marathonDistance <interactive>:10:27: Couldn''t match type ‘Kilometer’ with ‘Mile’ Expected type: Distance Mile Actual type: Distance Kilometer In the second argument of ‘(+)’, namely ‘marathonDistance’ In the expression: marathonDistanceInMiles + marathonDistance

Hay ligeras varitions para este "patrón". Puede usar DataKinds para tener un conjunto cerrado de unidades:

{-# LANGUAGE GeneralizedNewtypeDeriving #-} {-# LANGUAGE KindSignatures #-} {-# LANGUAGE DataKinds #-} data LengthUnit = Kilometer | Mile newtype Distance (a :: LengthUnit) = Distance Double deriving (Num, Show) marathonDistance :: Distance ''Kilometer marathonDistance = Distance 42.195 distanceKmToMiles :: Distance ''Kilometer -> Distance ''Mile distanceKmToMiles (Distance km) = Distance (0.621371 * km) marathonDistanceInMiles :: Distance ''Mile marathonDistanceInMiles = distanceKmToMiles marathonDistance

Y funcionará de manera similar:

>>> marathonDistanceInMiles Distance 26.218749345 >>> marathonDistance + marathonDistance Distance 84.39 >>> marathonDistanceInMiles + marathonDistance <interactive>:28:27: Couldn''t match type ‘''Kilometer’ with ‘''Mile’ Expected type: Distance ''Mile Actual type: Distance ''Kilometer In the second argument of ‘(+)’, namely ‘marathonDistance’ In the expression: marathonDistanceInMiles + marathonDistance

Pero ahora la Distance puede ser solo en kilómetros o millas, no podemos agregar más unidades más tarde. Eso podría ser útil en algunos casos de uso.

También podríamos hacer:

data Distance = Distance { distanceUnit :: LengthUnit, distanceValue :: Double } deriving (Show)

En el caso de distancia, podemos calcular la suma, por ejemplo, traducir a kilómetros si se trata de unidades diferentes. Pero esto no funciona bien para las monedas cuya relación no es constante a lo largo del tiempo, etc.

Y es posible utilizar GADT para eso, lo que puede ser un enfoque más simple en algunas situaciones:

{-# LANGUAGE GeneralizedNewtypeDeriving #-} {-# LANGUAGE KindSignatures #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE StandaloneDeriving #-} data Kilometer data Mile data Distance a where KilometerDistance :: Double -> Distance Kilometer MileDistance :: Double -> Distance Mile deriving instance Show (Distance a) marathonDistance :: Distance Kilometer marathonDistance = KilometerDistance 42.195 distanceKmToMiles :: Distance Kilometer -> Distance Mile distanceKmToMiles (KilometerDistance km) = MileDistance (0.621371 * km) marathonDistanceInMiles :: Distance Mile marathonDistanceInMiles = distanceKmToMiles marathonDistance

Ahora sabemos la unidad también en el nivel de valor:

>>> marathonDistanceInMiles MileDistance 26.218749345

Este enfoque simplifica Expr a ejemplo, a partir de la respuesta de Aadit :

{-# LANGUAGE GADTs #-} data Expr a where Number :: Int -> Expr Int Boolean :: Bool -> Expr Bool Increment :: Expr Int -> Expr Int Not :: Expr Bool -> Expr Bool

Vale la pena señalar que las últimas variaciones requieren extensiones de lenguaje no triviales ( GADTs , DataKinds , KindSignatures ), que pueden no ser compatibles con su compilador. Ese podría ser el caso con el compilador de Mu que Don menciona.