¿Puedo hacer? Golpeado por la programación de tipo genérico

Hola Habr


La última vez, describimos Has patrón, describimos los problemas que resuelve y escribimos algunas instancias específicas:


 instance HasDbConfig AppConfig where getDbConfig = dbConfig instance HasWebServerConfig AppConfig where getWebServerConfig = webServerConfig instance HasCronConfig AppConfig where getCronConfig = cronConfig 

Se ve bien ¿Qué dificultades pueden surgir aquí?


imagen


Bueno, pensemos en qué otras instancias podríamos necesitar. En primer lugar, los tipos concretos con una configuración en sí mismos son buenos candidatos para la implementación (trivial) de estas clases de tipos, lo que nos da tres instancias más donde cada método se implementa a través de id


 instance HasDbConfig DbConfig where getDbConfig = id 

Nos permiten escribir fácilmente pruebas individuales o utilidades auxiliares que son independientes de toda la AppConfig .


Esto ya es aburrido, pero aún continúa. Es fácil imaginar que algunas pruebas de integración verifican la interacción de un par de módulos, y todavía no queremos depender de la configuración de toda la aplicación, por lo que ahora necesitamos escribir seis instancias (dos por tipo), cada una de las cuales se reducirá a fst o snd . Por ejemplo, para DbConfig :


 instance HasDbConfig (DbConfig, b) where getDbConfig = fst instance HasDbConfig (a, DbConfig) where getDbConfig = snd 

Horror Se espera que nunca necesitemos probar el funcionamiento de tres módulos al mismo tiempo; de lo contrario, tendrá que escribir nueve instancias aburridas. En cualquier caso, personalmente ya estoy muy incómodo, y preferiría pasar varias horas automatizando este asunto que un par de minutos escribiendo una docena de líneas adicionales de código.


Si está interesado en cómo resolver este problema de manera general, además, son tipos dependientes y cómo terminará pareciéndose a un gato Haskell - Welkom.


Resumiendo la clase Has


Primero, tenga en cuenta que tenemos diferentes clases para diferentes entornos. Esto puede interferir con la creación de una solución universal, por lo que eliminamos el entorno en un parámetro separado:


 class Has part record where extract :: record -> part 

Podemos decir que Has part record significa que se puede extraer algún valor del tipo de part del valor del record de tipo. En estos términos, nuestro viejo HasDbConfig convierte en Has DbConfig , y de manera similar para otras clases de tipos que escribimos anteriormente. Resulta casi un cambio puramente sintáctico y, por ejemplo, el tipo de una de las funciones de nuestra publicación anterior se convierte en


 doSmthWithDbAndCron :: (MonadReader rm, HasDbConfig r, HasCronConfig r) => ... 

en


 doSmthWithDbAndCron :: (MonadReader rm, Has DbConfig r, Has CronConfig r) => ... 

El único cambio es un par de espacios en los lugares correctos.


Además, no perdimos mucho en la inferencia de tipos: un temporizador aún puede generar el valor de retorno necesario de extract en el contexto circundante en la gran mayoría de los casos encontrados en la práctica.


Ahora que no nos importa el tipo específico de entorno, veamos qué registros pueden implementar la clase de Has part record para la part fija. Esta tarea tiene una buena estructura inductiva:


  1. Cada tipo tiene sí mismo: Has record record se implementa de forma trivial ( extract = id ).
  2. Si el record es un producto de los tipos rec1 y rec2 , entonces el Has part record se implementa si y solo si se implementa la Has part rec1 o Has part rec2 .
  3. Si record es la suma de los tipos rec1 y rec2 , entonces el Has part record se implementa si y solo si se implementan la Has part rec1 y Has part rec2 . Aunque la prevalencia práctica de este caso en este contexto no es obvia, aún vale la pena mencionarla por su integridad.

Por lo tanto, parece que hemos formulado un boceto de un algoritmo para determinar automáticamente si se Has part record implementado el Has part record de part para los datos de part y record .


Afortunadamente, dicho razonamiento inductivo sobre los tipos se ajusta muy bien al mecanismo de Haskell Generics . Brevemente y simplificando, Generics es uno de los métodos de metaprogramación generalizada en Haskell, como resultado de la observación de que cada tipo es un tipo de suma, un tipo de producto o un tipo básico de construcción simple con un campo.


No escribiré otro tutorial sobre genéricos, así que simplemente pase al código.


Primer intento


Usaremos el método clásico de implementación Generic de nuestro Has través de la clase auxiliar GHas :


 class GHas part grecord where gextract :: grecord p -> part 

Aquí grecord es una representación Generic de nuestro tipo de record .


GHas implementaciones de GHas siguen la estructura inductiva que notamos anteriormente:


 instance GHas record (K1 i record) where gextract (K1 x) = x instance GHas part record => GHas part (M1 it record) where gextract (M1 x) = gextract x instance GHas part l => GHas part (l :*: r) where gextract (l :*: _) = gextract l instance GHas part r => GHas part (l :*: r) where gextract (_ :*: r) = gextract r 

  1. K1 corresponde al caso base.
  2. M1 : metadatos específicos de genéricos que no necesitamos en nuestra tarea, por lo que simplemente los ignoramos y los revisamos.
  3. La primera instancia para el tipo de producto l :*: r corresponde al caso en que la parte "izquierda" del producto tiene el valor de la part de tipo que necesitamos (posiblemente, recursivamente).
  4. De manera similar, la segunda instancia para el tipo de producto l :*: r corresponde al caso en que la parte "correcta" del producto tiene el valor deseado de la part tipo (naturalmente, también, posiblemente, recursivamente).

Solo admitimos tipos de productos aquí. Mi impresión subjetiva es que las cantidades no se usan con tanta frecuencia en contextos para MonadReader y clases similares, por lo que se pueden descuidar para simplificar la consideración.


Además, es útil tener en cuenta que cada n-ary type-product (a1, ..., an) se puede representar como una composición n1pares (a1, (a2, (a3, (..., an)))) , así que me permito asociar tipos de productos con pares.


Con nuestros GHas , puede escribir una implementación predeterminada para Has que use genéricos:


 class Has part record where extract :: record -> part default extract :: Generic record => record -> part extract = gextract . from 

Listo


O no?


El problema


Si intentamos compilar este código, veremos que no taypechaetsya, incluso sin ningún intento de utilizar esta implementación de forma predeterminada, informando algunas instancias superpuestas allí. Peor aún, estas instancias son iguales en algunos aspectos. Parece que es hora de descubrir cómo funciona el mecanismo para resolver instancias en Haskell.


Podemos tener


 instance context => Foo barPattern bazPattern where ... 

(Por cierto, esta cosa después de => se llama cabeza de instancia).


Parece natural leer esto como


Necesitemos elegir una instancia para Foo bar baz . Si context satisface el context , puede seleccionar esta instancia siempre que bar y baz correspondan a barPattern y bazPattern .

Sin embargo, esta es una mala interpretación, y todo lo contrario:


Necesitemos elegir una instancia para Foo bar baz . Si bar y baz corresponden a barPattern y bazPattern , entonces seleccionamos esta instancia y agregamos context a la lista de constantes que deben resolverse.

Ahora es obvio cuál es el problema. Echemos un vistazo más de cerca al siguiente par de instancias:


 instance GHas part l => GHas part (l :*: r) where gextract (l :*: _) = gextract l instance GHas part r => GHas part (l :*: r) where gextract (_ :*: r) = gextract r 

Tienen las mismas cabezas de instancia, ¡así que no es de extrañar que se crucen! Además, ninguno de ellos es más específico que el otro.


Además, no hay forma de refinar de alguna manera estas instancias para que dejen de superponerse. Bueno, además de agregar más parámetros de GHas .


¡Los tipos expresivos se apresuran al rescate!


La solución al problema es calcular previamente la "ruta" hacia el valor que nos interesa y utilizar esta ruta para guiar la elección de las instancias.


Dado que acordamos no admitir los tipos de suma, una ruta es, en sentido literal, una secuencia de giros a la izquierda o derecha en los tipos de productos (es decir, elecciones del primer o segundo componente de un par), que termina con un gran puntero "AQUÍ", tan pronto como encontramos el tipo deseado . Escribimos esto:


 data Path = L Path | R Path | Here deriving (Show) 

Por ejemplo

Considere los siguientes tipos:


 data DbConfig = DbConfig { dbAddress :: DbAddress , dbUsername :: Username , dbPassword :: Password } data AppConfig = AppConfig { dbConfig :: DbConfig , webServerConfig :: WebServerConfig , cronConfig :: CronConfig } 

¿Cuáles son algunos ejemplos de rutas de AppConfig ?


  1. A DbConfigL Here .
  2. Para WebServerConfigR (L Here) .
  3. Para CronConfigR (R Here) .
  4. A DbAddressL (L Here) .

¿Cuál podría ser el resultado de una búsqueda de un valor del tipo deseado? Dos opciones son obvias: podemos encontrarlo o no. Pero, de hecho, todo es un poco más complicado: podemos encontrar más de un valor de este tipo. Aparentemente, el comportamiento más sensato en este controvertido caso también sería un mensaje de error. Cualquier elección de un valor particular tendrá una cierta cantidad de aleatoriedad.


De hecho, considere nuestro ejemplo de servicio web estándar. Si alguien quiere obtener un valor del tipo (Host, Port) , ¿debería ser la dirección del servidor de la base de datos o la dirección del servidor web? Es mejor no arriesgarse.


En cualquier caso, expresemos esto en código:


 data MaybePath = NotFound | Conflict | Found Path deriving (Show) 

NotFound y Conflict , ya que el manejo de estos casos es fundamentalmente diferente: si obtenemos NotFound en una de las ramas de nuestro tipo de producto, entonces no estará de más encontrar el valor deseado en alguna otra rama, mientras que Conflict en cualquier rama significa inmediatamente completo un fracaso


Ahora consideramos un caso especial de tipos de productos (que, como acordamos, los consideramos como pares). ¿Cómo encontrar el valor del tipo deseado en ellos? Puede ejecutar una búsqueda recursivamente en cada componente de un par, obtener los resultados p1 y p2 respectivamente, y luego combinarlos de alguna manera.


Como estamos hablando de la elección de instancias de clases de tiempo que ocurren durante la compilación, en realidad necesitamos cálculos de tiempo de compilación, que se expresan en Haskell a través de cálculos de tipos (incluso si los tipos se representan a través de términos generados en el universo usando DataKinds ). En consecuencia, dicha función en los tipos se representa como familia de tipos:


 type family Combine p1 p2 where Combine ('Found path) 'NotFound = 'Found ('L path) Combine 'NotFound ('Found path) = 'Found ('R path) Combine 'NotFound 'NotFound = 'NotFound Combine _ _ = 'Conflict 

Esta función representa varios casos:


  1. Si una de las búsquedas recursivas es exitosa y la otra NotFound a NotFound , entonces tomamos el camino de la búsqueda exitosa y agregamos el giro en la dirección correcta.
  2. Si ambas búsquedas recursivas terminan con NotFound , entonces, obviamente, la búsqueda completa termina con NotFound .
  3. En cualquier otro caso, tenemos Conflict .

Ahora escribiremos una función de nivel de tipe que tome la part que se va a encontrar, y una representación Generic del tipo en el que se encuentra la part , y busca:


 type family Search part (grecord :: k -> *) :: MaybePath where Search part (K1 _ part) = 'Found 'Here Search part (K1 _ other) = 'NotFound Search part (M1 _ _ x) = Search part x Search part (l :*: r) = Combine (Search part l) (Search part r) Search _ _ = 'NotFound 

Tenga en cuenta que obtuvimos algo muy similar en significado a nuestro intento anterior con GHas . Esto es de esperarse, ya que en realidad estamos reproduciendo el algoritmo que intentamos expresar a través de las clases de tiempo.


GHas , todo lo que nos queda es agregar un parámetro adicional a esta clase, que es responsable de la ruta encontrada anteriormente y que servirá para seleccionar instancias específicas:


 class GHas (path :: Path) part grecord where gextract :: Proxy path -> grecord p -> part 

También agregamos un argumento adicional para gextract para que el compilador pueda seleccionar la instancia correcta para la ruta dada (que debe mencionarse en la firma de la función para esto).


Ahora escribir instancias es bastante fácil:


 instance GHas 'Here record (K1 i record) where gextract _ (K1 x) = x instance GHas path part record => GHas path part (M1 it record) where gextract proxy (M1 x) = gextract proxy x instance GHas path part l => GHas ('L path) part (l :*: r) where gextract _ (l :*: _) = gextract (Proxy :: Proxy path) l instance GHas path part r => GHas ('R path) part (l :*: r) where gextract _ (_ :*: r) = gextract (Proxy :: Proxy path) r 

De hecho, simplemente seleccionamos la instancia deseada en función de la ruta en la path que calculamos anteriormente.


¿Cómo escribir ahora nuestra implementación default de la función extract :: record -> part en la clase Has ? Tenemos varias condiciones:


  1. record debe implementar Generic para poder aplicar el mecanismo genérico, de modo que obtengamos un Generic record .
  2. La función de Search debe encontrar part en el record (o más bien, en la representación Generic del record , que se expresa como Rep record ). En el código, esto parece un poco más inusual: Search part (Rep record) ~ 'Found path . Este registro significa la restricción de que el resultado de la Search part (Rep record) debe ser igual a la 'Found path para alguna path (que, de hecho, es interesante para nosotros).
  3. Deberíamos poder usar GHas junto con part , la representación genérica de record y path del último paso, que se convierte en una GHas path part (Rep record) .

Nos encontraremos con las dos últimas constantes varias veces más, por lo que es útil ponerlas en un sinónimo const separado:


 type SuccessfulSearch part record path = (Search part (Rep record) ~ 'Found path, GHas path part (Rep record)) 

Dado este sinónimo, obtenemos


 class Has part record where extract :: record -> part default extract :: forall path. (Generic record, SuccessfulSearch part record path) => record -> part extract = gextract (Proxy :: Proxy path) . from 

Ahora todo!


Usando Has genéricos


Para ver todo esto en acción, escribiremos algunas instancias generales para tontos:


 instance SuccessfulSearch a (a0, a1) path => Has a (a0, a1) instance SuccessfulSearch a (a0, a1, a2) path => Has a (a0, a1, a2) instance SuccessfulSearch a (a0, a1, a2, a3) path => Has a (a0, a1, a2, a3) 

Aquí SuccessfulSearch a (a0, ..., an) path es responsable del hecho de que a ocurre entre a0, ..., an exactamente una vez.


Que ahora tengamos nuestro buen viejo


 data AppConfig = AppConfig { dbConfig :: DbConfig , webServerConfig :: WebServerConfig , cronConfig :: CronConfig } 

y queremos generar Has DbConfig , Has WebServerConfig y Has CronConfig . Es suficiente incluir las DeriveGeneric y DeriveAnyClass y agregar la declaración de deriving correcta:


 data AppConfig = AppConfig { dbConfig :: DbConfig , webServerConfig :: WebServerConfig , cronConfig :: CronConfig } deriving (Generic, Has DbConfig, Has WebServerConfig, Has CronConfig) 

Somos afortunados (o fuimos lo suficientemente perspicaces) para organizar los argumentos de Has para que el nombre del tipo anidado sea lo primero, de modo que podamos confiar en el mecanismo DeriveAnyClass para minimizar el garabato.


La seguridad es lo primero


¿Qué pasa si no tenemos ningún tipo?


 data AppConfig = AppConfig { dbConfig :: DbConfig , webServerConfig :: WebServerConfig } deriving (Generic, Has CronConfig) 

No, tenemos un error justo en el punto de definición de tipo:


 Spec.hs:35:24: error: • Couldn't match type ''NotFound' with ''Found path0' arising from the 'deriving' clause of a data type declaration • When deriving the instance for (Has CronConfig AppConfig) | 35 | } deriving (Generic, Has CronConfig) | ^^^^^^^^^^^^^^ 

No es el mensaje de error más amigable, pero aun así puede entender cuál es el problema: la frecuencia impar NotFound frecuencia impar CronConfig .


¿Qué pasa si tenemos varios campos con el mismo tipo?


 data AppConfig = AppConfig { prodDbConfig :: DbConfig , qaDbConfig :: DbConfig , webServerConfig :: WebServerConfig , cronConfig :: CronConfig } deriving (Generic, Has DbConfig) 

No, como se esperaba:


 Spec.hs:37:24: error: • Couldn't match type ''Conflict' with ''Found path0' arising from the 'deriving' clause of a data type declaration • When deriving the instance for (Has DbConfig AppConfig) | 37 | } deriving (Generic, Has DbConfig) | ^^^^^^^^^^^^ 

Todo parece estar realmente bien.


Para resumir


Por lo tanto, intentaremos formular brevemente el método propuesto.


Supongamos que tenemos algún tipo de Typklass, y queremos mostrar automáticamente sus instancias de acuerdo con algunas reglas recursivas. Entonces podemos evitar las ambigüedades (y generalmente expresamos estas reglas si no son triviales y no encajan en el mecanismo estándar para resolver instancias) de la siguiente manera:


  1. Codificamos reglas recursivas en forma de datos inductivos tipo T
  2. Escribiremos una función en los tipos (en forma de familia de tipos) para el cálculo preliminar del valor v este tipo T (o, en términos de Haskell, tipo v tipo T , donde están mis tipos dependientes), que describe la secuencia específica de pasos que deben tomarse.
  3. Use esta v como argumento adicional para el asistente Generic para determinar la secuencia específica de instancias que ahora coinciden con los valores de v .

Bueno, eso es todo!


En las siguientes publicaciones, veremos algunas extensiones elegantes (así como limitaciones elegantes) de este enfoque.


Ah y si. Es interesante seguir la secuencia de nuestras generalizaciones.


  1. Comenzó con Env -> Foo .
  2. No es lo suficientemente general, termine en la mónada Reader Env .
  3. No es lo suficientemente general, reescriba con MonadReader Env m .
  4. No es lo suficientemente general, reescriba MonadReader rm, HasEnv r .
  5. No es lo suficientemente general, escribamos MonadReader rm, Has Env r y agreguemos genéricos para que el compilador haga todo allí.
  6. Ahora la norma.

Source: https://habr.com/ru/post/471052/


All Articles