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í?

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:
- Cada tipo tiene sí mismo:
Has record record
se implementa de forma trivial ( extract = id
). - 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
. - 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
K1
corresponde al caso base.M1
: metadatos específicos de genéricos que no necesitamos en nuestra tarea, por lo que simplemente los ignoramos y los revisamos.- 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). - 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 -ary type-product (a1, ..., an)
se puede representar como una composición pares (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 ejemploConsidere 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
?
- A
DbConfig
⟶ L Here
. - Para
WebServerConfig
⟶ R (L Here)
. - Para
CronConfig
⟶ R (R Here)
. - A
DbAddress
⟶ L (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:
- 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. - Si ambas búsquedas recursivas terminan con
NotFound
, entonces, obviamente, la búsqueda completa termina con NotFound
. - 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:
record
debe implementar Generic
para poder aplicar el mecanismo genérico, de modo que obtengamos un Generic record
.- 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). - 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:
- Codificamos reglas recursivas en forma de datos inductivos tipo
T
- 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. - 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.
- Comenzó con
Env -> Foo
. - No es lo suficientemente general, termine en la mónada
Reader Env
. - No es lo suficientemente general, reescriba con
MonadReader Env m
. - No es lo suficientemente general, reescriba
MonadReader rm, HasEnv r
. - No es lo suficientemente general, escribamos
MonadReader rm, Has Env r
y agreguemos genéricos para que el compilador haga todo allí. - Ahora la norma.