Pregunta Derivar instancias para datos de mayor nivel


Esta pregunta se basa en el patrón de datos de mayor nivel, descrito en esta publicación de blog Razonablemente polimórfica.

En el siguiente bloque de código, defino una familia de tipos HKD y un tipo de datos Persondonde los campos pueden ser Maybe o Identity.

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE TypeFamilies  #-}

import           Data.Aeson

-- "Higher Kinded Data" (http://reasonablypolymorphic.com//blog/higher-kinded-data)
type family HKD f a where
    HKD Identity a = a
    HKD Maybe a = Maybe a

data Person f = Person
  { pName :: HKD f String
  , pAge  :: HKD f Int
  } deriving (Generic)

Entonces trato de derivar un ToJSON instancia para este tipo.

-- Already provided in imports:
-- instance ToJSON String
-- instance ToJSON Int
-- instance ToJSON a => ToJSON (Maybe a)

instance ToJSON (Person f) where
  toJSON = genericToJSON defaultOptions

Lamentablemente me sale el siguiente error:

No instancia para (ToJSON (HKD f Int)) que surge de un uso de genericToJSON.

Dado que ya tengo ToJSON Int y ToJSON (Maybe Int), ¿no debería GHC ser capaz de derivar una instancia? ToJSON (HKD f Int)? Según entiendo, las familias de tipo actúan como alias de tipo con respecto a las instancias. Si ese es el caso, entonces no puedo definir mis propias instancias para ello, pero debería recibir instancias de sus definiciones, en este caso Int y Maybe Int. Lamentablemente, el error parece contradecir eso.

¿Cómo puedo definir un ToJSON instancia para mi tipo?


5
2018-04-02 20:55


origen


Respuestas:


El tipo de familia HKD debe aplicarse a un conocido Identity o Maybe para reducir. De otra manera, HKD f Int con un desconocido f Simplemente está atascado, y no podemos resolver HKD f a restricciones para todos los tipos de campo a, excepto enumerándolos en el contexto, por ejemplo (ToJSON (HKD f Int), ToJSON (HKD f String)), que es una posible solución, pero no se adapta bien a un gran número de campos.

Solución 1: Derive el contexto

El principal problema es la tediosidad de escribir y mantener la lista de restricciones de campo, esto se resuelve al señalar que realmente es una función del tipo de registro, y que podemos definirlo en Haskell usando GHC Generics.

type GToJSONFields a = GFields' ToJSON (Rep a)

-- Every field satisfies constraint c
type family GFields' (c :: * -> Constraint) (f :: * -> *) :: Constraint
type instance GFields' c (M1 i d f) = GFields' c f
type instance GFields' c (f :+: g) = (GFields' c f, GFields' c g)
type instance GFields' c (f :*: g) = (GFields' c f, GFields' c g)
type instance GFields' c U1 = ()
type instance GFields' c (K1 i a) = c a

instance (GToJSONFields (Person f)) => ToJSON (Person f) where
  toJSON = genericToJSON defaultOptions

Sin embargo, esta instancia no es modular ni es ineficaz, porque aún expone la estructura interna del registro (sus tipos de campo) y las restricciones para cada campo deben resolverse cada vez. ToJSON (Person f) es usado.

La esencia de la solución 1

Solución 2: Generalizar el contexto.

Lo que realmente queremos escribir como ejemplo es esto

instance (forall a. ToJSON a => ToJSON (HKD f a)) => ToJSON (Person f) where
  -- ...

que usa una restricción cuantificada, una nueva característica que se implementa actualmente en GHC; Esperemos que la sintaxis sea autodescriptiva. Pero dado que aún no se ha lanzado, ¿qué podemos hacer mientras tanto?

Una restricción cuantificada actualmente se puede codificar utilizando una clase de tipo.

class ToJSON_HKD f where
  toJSON_HKD :: ToJSON a => f a -> Value  -- AllowAmbiguousTypes, or wrap this in a newtype (which we will define next anyway)

instance ToJSON_HKD Identity where
  toJSON_HKD = toJSON

instance ToJSON_HKD Maybe where
  toJSON_HKD = toJSON

Pero genericToJSON usaría ToJSON en los campos, no ToJSON_HKD. Podemos envolver los campos en una newtype que despacha ToJSON restricciones con un ToJSON_HKD restricción.

newtype Apply f a = Apply (HKD f a)

instance ToJSON_HKD f => ToJSON (Apply f a) where
  toJSON (Apply x) = toJSON_HKD @f @a x

Los campos de Person solo puede ser envuelto en HKD Identity o HKD Maybe. Deberíamos agregar un caso más para HKD. De hecho, hagámoslo abierto y refaccionaremos el caso para los constructores de tipos. Nosotros escribimos HKD (Tc Maybe) a en lugar de HKD Maybe a; esto es mas largo, pero el Tc la etiqueta se puede reutilizar para cualquier otro constructor de tipo, por ejemplo, HKD (Tc (Apply f)) a.

-- Redefining HKD
type family HKD f a
type instance HKD Identity a = a
type instance HKD (Tc f) a = f a

data Tc (f :: * -> *)  -- Type-level tag for type constructors

aeson tiene un ToJSON1 tipo de clase cuyo papel es bastante similar a ToJSON_HKD, como una codificación de forall a. ToJSON a => ToJSON (f a). Casualmente, Tc es el tipo correcto para conectar esas clases.

instance ToJSON1 f => ToJSON_HKD (Tc f) where
  toJSON1_HKD = toJSON1

El siguiente paso es el envoltorio mismo.

wrapApply :: Person f -> Person (Tc (Apply f))
wrapApply = gcoerce

Todo lo que estamos haciendo es envolver los campos en un newtype(de HKD f a a HKD (Tc (Apply f)) a, que es igual a Apply f a y representativamente equivalente a HKD f a) Entonces esto es realmente una coacción. Desafortunadamente, coerce no va a teclear aquí, como Person f tiene un parámetro de tipo nominal (porque usa HKD, que coincide en el nombre  f para reducir). Sin embargo, Person es un Generic tipo, y las representaciones genéricas de la entrada y el resultado esperado de wrapApply son de hecho coercibles. Esto da lugar a la siguiente "coerción genérica", que hace wrapApply superfluo:

gcoerce :: forall a b
        .  (Generic a, Generic b, Coercible (Rep a ()) (Rep b ()))
        => a -> b
gcoerce = to . (coerce :: Rep a () -> Rep b ()) . from

Concluimos: envuelva los campos en Apply, y use genericToJSON.

instance ToJSON_HKD f => ToJSON (Person f) where
  toJSON = genericToJSON defaultOptions . gcoerce @_ @(Person (Tc (Apply f)))

La esencia de la solución 2.

Nota sobre la esencia: HKD fue renombrado a (@@), un nombre prestado de singletonsy HKD Identity a se reescribe como HKD Id a, haciendo una distinción explícita entre el constructor de tipos. Identityy el símbolo desfuncionalizado Id para la función de identidad. Me parece más limpio.

Solución 3: Sin familias de tipos

La publicación del blog HKD combina dos ideas:

  1. Parametrización de registros sobre un constructor de tipo. f (también llamado "patrón de functor functor");

  2. Generalizando f para ser una función de tipo, que es posible, aunque Haskell no tiene funciones de primera clase a nivel de tipo, gracias a la técnica de desfuncionalización.

El objetivo principal de la segunda idea es poder reutilizar el registro Person Con campos sin envolver. Parece una preocupación bastante cosmética por la cantidad de complejidad que introducen las familias.

Mirando más de cerca, podría argumentarse que realmente no existe mucha complejidad adicional. ¿Vale la pena al final? Todavía no tengo una buena respuesta.

Solo como referencia, aquí está el resultado de aplicar las técnicas anteriores a un registro más simple sin el HKD escriba familia

data Person f = Person
  { name :: f String
  , age :: f Int
  }

Podemos eliminar dos definiciones: ToJSON_HKD (ToJSON1 es suficiente), y gcoerce (coerce basta). Reemplazamos Apply con este otro nuevo tipo de conexión ToJSON y ToJSON1:

newtype Apply' f a = Apply' (f a)  -- no HKD

instance (ToJSON1 f, ToJSON a) => ToJSON (Apply' f a) where
  toJSON (Apply' x) = toJSON1 x

Y derivamos ToJSON como sigue:

instance ToJSON1 f => ToJSON (Person f) where
  toJSON = genericToJSON defaultOptions . coerce @_ @(Person (Apply' f))

Advertencia: tipos de campo especiales

aeson tiene una opción para hacer Maybe campos opcionales, por lo que se les permite faltar en el objeto JSON correspondiente. Bueno, esa opción no funciona con los métodos descritos anteriormente. Solo afecta a los campos que se sabe que son. Maybe en la definición de la instancia, de modo que falla para las soluciones 2 y 3 debido a los nuevos tipos en todos los campos.

Además, para la solución 1, esto

instance {-# OVERLAPPING #-} ToJSON (Person Maybe) where
  toJSON = genericToJSON defaultOptions{omitNothingFields=True}

Se comportaría de manera diferente de especializarse en esta otra instancia. después del hecho a Person Maybe:

instance ... => ToJSON (Person f) where
  toJSON = genericToJSON defaultOptions{omitNothingFields=True}

6
2018-04-03 00:52



autor de la publicación del blog aquí. Probablemente la solución más fácil aquí es simplemente monomorfizar su f parámetro:

instance ToJSON (Person Identity) where
  toJSON = genericToJSON defaultOptions

instance ToJSON (Person Maybe) where
  toJSON = genericToJSON defaultOptions

Es algo feo, pero ciertamente se puede enviar. Actualmente estoy en el laboratorio tratando de encontrar mejores soluciones generales para este problema, y ​​le haré saber si se me ocurre algo.


3
2018-04-03 01:46