Posso haz? Atingido por programação de tipo genérico

Oi Habr.


Na última vez, descrevemos Has padrão, descrevemos os problemas que ele resolve e escrevemos algumas instâncias específicas:


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

Parece bom. Que dificuldades podem surgir aqui?


imagem


Bem, vamos pensar em quais outras instâncias podemos precisar. Em primeiro lugar, tipos concretos com uma configuração em si são bons candidatos para a implementação (trivial) dessas classes de tipo, o que nos dá mais três instâncias em que cada método é implementado via id , por exemplo


 instance HasDbConfig DbConfig where getDbConfig = id 

Eles nos permitem escrever facilmente testes individuais ou utilitários auxiliares independentes de todo o AppConfig .


Isso já é chato, mas ainda continua. É fácil imaginar que alguns testes de integração verifiquem a interação de um par de módulos, e ainda não queremos depender da configuração de todo o aplicativo, então agora precisamos escrever seis instâncias (duas por tipo), cada uma das quais será reduzida para fst ou snd . Por exemplo, para DbConfig :


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

Horror Espera-se que nunca seja necessário testar a operação de três módulos ao mesmo tempo - caso contrário, você precisará escrever nove instâncias chatas. De qualquer forma, eu pessoalmente já estou muito desconfortável e prefiro passar várias horas automatizando esse assunto do que alguns minutos escrevendo uma dúzia de linhas extras de código.


Além disso, se você estiver interessado em resolver esse problema de uma maneira geral, são tipos dependentes e como tudo acabará parecendo um gato Haskell - Welkom.


Resumindo a classe Has


Primeiro, observe que temos classes diferentes para ambientes diferentes. Isso pode interferir na criação de uma solução universal; portanto, removemos o ambiente em um parâmetro separado:


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

Podemos dizer que Has part record significa que algum valor do tipo part pode ser extraído do valor do record de tipo. Nesses termos, nosso bom e velho HasDbConfig se torna Has DbConfig e da mesma forma para outras classes de tipo que escrevemos anteriormente. Acontece quase uma mudança puramente sintática e, por exemplo, o tipo de uma das funções de nosso post anterior passa de


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

em


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

A única mudança é um par de espaços nos lugares certos.


Além disso, não perdemos muito na inferência de tipo: um timer ainda pode gerar o valor de retorno necessário de extract no contexto circundante na grande maioria dos casos encontrados na prática.


Agora que não nos preocupamos com o tipo específico de ambiente, vamos ver quais registros podem implementar a classe de Has part record para a part fixa. Esta tarefa possui uma boa estrutura indutiva:


  1. Cada tipo possui: O Has record record é implementado de maneira trivial ( extract = id ).
  2. Se o record for um produto dos tipos rec1 e rec2 , o campo Has part record será implementado se e somente se Has part rec1 ou Has part rec2 .
  3. Se record for a soma dos tipos rec1 e rec2 , o Has part record será implementado se e somente se a Has part rec1 e a Has part rec2 . Embora a prevalência prática deste caso neste contexto não seja óbvia, ainda vale a pena mencionar a sua integralidade.

Portanto, parece que formulamos um esboço de um algoritmo para determinar automaticamente se Has part record implementado para dados de part e record !


Felizmente, esse raciocínio indutivo sobre os tipos se encaixa muito bem no mecanismo de genéricos Haskell. Resumidamente e simplificando, o Generics é um dos métodos de metaprogramação generalizada no Haskell, resultante da observação de que cada tipo é um tipo de soma, um tipo de produto ou um tipo básico de construção única com um campo.


Não vou escrever outro tutorial sobre genéricos, portanto, apenas passe para o código.


Primeira tentativa


Usaremos o método clássico de implementação Generic de nossos Has através da classe auxiliar GHas :


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

Aqui grecord é uma representação Generic do nosso tipo de record .


GHas implementações seguem a estrutura indutiva que observamos acima:


 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 ao caso base.
  2. M1 - Metadados específicos de genéricos que não precisamos em nossa tarefa, portanto, simplesmente os ignoramos e os analisamos.
  3. A primeira instância do tipo de produto l :*: r corresponde ao caso em que a parte "esquerda" do produto tem o valor da part de tipo de que precisamos (possivelmente, recursivamente).
  4. Da mesma forma, a segunda instância para o tipo de produto l :*: r corresponde ao caso em que a parte "correta" do produto tem o valor desejado da part tipo (naturalmente, também, possivelmente, recursivamente).

Somente suportamos tipos de produtos aqui. Minha impressão subjetiva é que as quantias não são usadas com tanta frequência em contextos para o MonadReader e classes semelhantes, portanto podem ser negligenciadas para simplificar a consideração.


Além disso, é útil observar que cada nO produto secundário (a1, ..., an) pode ser representado como uma composição n1pares (a1, (a2, (a3, (..., an)))) , por isso me permito associar tipos de produtos a pares.


Com nosso GHas , você pode escrever uma implementação padrão para Has que usa genéricos:


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

Feito.


Ou não?


O problema


Se tentarmos compilar esse código, veremos que ele não taypechaetsya, mesmo sem qualquer tentativa de usar essa implementação por padrão, relatando algumas instâncias sobrepostas lá. Pior, essas instâncias são iguais em alguns aspectos. Parece que é hora de descobrir como o mecanismo para resolver instâncias no Haskell funciona.


Que possamos ter


 instance context => Foo barPattern bazPattern where ... 

(A propósito, essa coisa depois de => é chamada de instance head.)


Parece natural ler isso como


Vamos precisar escolher uma instância para Foo bar baz . Se o context satisfeito, você poderá selecionar esta instância, desde que bar e baz correspondam a barPattern e bazPattern .

No entanto, isso é uma má interpretação e exatamente o oposto:


Vamos precisar escolher uma instância para Foo bar baz . Se bar e baz corresponderem a barPattern e bazPattern , selecionaremos essa instância e adicionaremos context à lista de constantes que devem ser resolvidas.

Agora é óbvio qual é o problema. Vamos dar uma olhada no seguinte par de instâncias:


 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 

Eles têm as mesmas cabeças de instância, portanto, não é de admirar que se cruzem! Além disso, nenhum deles é mais específico que o outro.


Além disso, não há como refinar de alguma forma essas instâncias para que elas deixem de se sobrepor. Bem, além de adicionar mais parâmetros GHas .


Tipos expressivos correm para o resgate!


A solução para o problema é pré-calcular o "caminho" para o valor de seu interesse e usar esse caminho para orientar a escolha das instâncias.


Como concordamos em não oferecer suporte a tipos de soma, um caminho é, literalmente, uma sequência de curvas à esquerda ou à direita nos tipos de produto (ou seja, escolhas do primeiro ou segundo componente de um par), terminando com um grande ponteiro "AQUI", assim que encontrarmos o tipo desejado . Nós escrevemos isso:


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

Por exemplo

Considere os seguintes tipos:


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

Quais são alguns exemplos de caminhos do AppConfig ?


  1. Para DbConfigL Here .
  2. Para WebServerConfig ® R (L Here) .
  3. Para CronConfig ® R (R Here) .
  4. Para DbAddressL (L Here) .

Qual poderia ser o resultado de uma pesquisa por um valor do tipo desejado? Duas opções são óbvias: podemos encontrá-lo ou não. Mas, de fato, tudo é um pouco mais complicado: podemos encontrar mais de um valor desse tipo. Aparentemente, o comportamento mais sensato nesse caso controverso também seria uma mensagem de erro. Qualquer escolha de um valor específico terá uma certa quantidade de aleatoriedade.


De fato, considere nosso exemplo padrão de serviço da web. Se alguém quiser obter um valor do tipo (Host, Port) , deve ser o endereço do servidor de banco de dados ou o endereço do servidor da web? É melhor não arriscar.


De qualquer forma, vamos expressar isso no código:


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

Separamos NotFound e Conflict , pois o tratamento desses casos é fundamentalmente diferente: se NotFound em uma das ramificações do nosso tipo de produto, não será prejudicial encontrar o valor desejado em outra ramificação, enquanto Conflict em qualquer ramificação significa imediatamente completo. um fracasso.


Agora, consideramos um caso especial de tipos de produto (que, como concordamos, consideramos como pares). Como encontrar o valor do tipo desejado neles? Você pode executar uma pesquisa recursivamente em cada componente de um par, obter os resultados p1 e p2 respectivamente, e combiná-los de alguma forma.


Como estamos falando sobre a escolha de instâncias de classes de tempo que ocorrem durante a compilação, na verdade precisamos de cálculos em tempo de compilação, que são expressos no Haskell por meio de cálculos em tipos (mesmo que os tipos sejam representados por termos criados no universo usando DataKinds ). Por conseguinte, essa função nos tipos é representada como família 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 função representa vários casos:


  1. Se uma das pesquisas recursivas for bem-sucedida e a outra NotFound ao NotFound , seguiremos o caminho da pesquisa bem-sucedida e anexaremos a curva na direção certa.
  2. Se ambas as pesquisas recursivas terminarem com NotFound , obviamente toda a pesquisa terminará com NotFound .
  3. Em qualquer outro caso, temos Conflict .

Agora, escreveremos uma função em nível de faixa que leva a part a ser encontrada, e uma representação Generic do tipo em que a part encontrada e as pesquisas:


 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 

Observe que obtivemos algo muito semelhante em significado à nossa tentativa anterior com o GHas . Isso é de se esperar, já que estamos realmente reproduzindo o algoritmo que tentamos expressar através das classes de tempo.


GHas , tudo o que resta para nós é adicionar um parâmetro adicional a essa classe, responsável pelo caminho encontrado anteriormente e que servirá para selecionar instâncias específicas:


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

Também adicionamos um argumento adicional para o gextract para que o compilador possa selecionar a instância correta para o caminho especificado (que deve ser mencionado na assinatura da função).


Agora, escrever instâncias é bem 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 fato, simplesmente selecionamos a instância desejada com base no path que calculamos anteriormente.


Como agora escrever nossa implementação default da função extract :: record -> part na classe Has ? Temos várias condições:


  1. record deve implementar o Generic para que o mecanismo genérico possa ser aplicado, para obter um Generic record .
  2. A função Search deve encontrar part no record (ou melhor, na representação Generic do record , que é expressa como Rep record ). No código, isso parece um pouco mais incomum: Search part (Rep record) ~ 'Found path . Esse registro significa a restrição de que o resultado da Search part (Rep record) deve ser igual ao 'Found path para algum path (que, de fato, é interessante para nós).
  3. Deveríamos poder usar GHas junto com a part , a representação genérica do record e path da última etapa, que se transforma em uma GHas path part (Rep record) .

Reuniremos as duas últimas constantes várias vezes mais, portanto, é útil colocá-las em um sinônimo const separado:


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

Dado esse sinônimo, obtemos


 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 

Agora tudo!


Usando genérico Has


Para analisar tudo isso em ação, escreveremos algumas instâncias gerais para manequins:


 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) 

Aqui, SuccessfulSearch a (a0, ..., an) path é responsável pelo fato de que a ocorre entre a0, ..., an exatamente uma vez.


Que possamos agora ter nosso bom e velho


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

e queremos Has DbConfig , Has WebServerConfig e Has CronConfig . Basta incluir as DeriveAnyClass e DeriveAnyClass e adicionar a declaração de deriving correta:


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

Temos a sorte (ou éramos suficientemente perspicazes) de organizar os argumentos para Has para que o nome do tipo aninhado seja o primeiro, para que possamos confiar no mecanismo DeriveAnyClass para minimizar os rabiscos.


Segurança vem em primeiro lugar


E se não tivermos nenhum tipo?


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

Não, obtemos um erro diretamente no ponto de definição do 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) | ^^^^^^^^^^^^^^ 

Não é a mensagem de erro mais amigável, mas mesmo assim você ainda pode entender qual é o problema: a frequência ímpar NotFound frequência ímpar CronConfig .


E se tivermos vários campos do mesmo tipo?


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

Não, como esperado:


 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) | ^^^^^^^^^^^^ 

Tudo parece estar realmente bom.


Resumir


Então, tentaremos formular brevemente o método proposto.


Suponha que tenhamos algum tipo de classe e queremos exibir automaticamente suas instâncias de acordo com algumas regras recursivas. Em seguida, podemos evitar as ambiguidades (e geralmente expressar essas regras se não forem triviais e não se ajustarem ao mecanismo padrão para resolver instâncias) da seguinte maneira:


  1. Codificamos regras recursivas na forma de um tipo de dados indutivo T
  2. Escreveremos uma função em tipos (na forma de família de tipos) para cálculo preliminar do valor v desse tipo T (ou, em termos de Haskell, tipo v tipo T - onde estão meus tipos dependentes), que descreve a sequência específica de etapas que precisam ser executadas.
  3. Use este v como um argumento adicional para o auxiliar Generic para determinar a sequência específica de instâncias que agora correspondem aos valores de v .

Bem, é isso!


Nas postagens a seguir, veremos algumas extensões elegantes (bem como limitações elegantes) dessa abordagem.


Ah e sim. É interessante rastrear a sequência de nossas generalizações.


  1. Iniciado com Env -> Foo .
  2. Não é geral o suficiente, encerre na mônada do Reader Env .
  3. Não suficientemente geral, reescreva com o MonadReader Env m .
  4. Não é geral o suficiente, reescreva MonadReader rm, HasEnv r .
  5. Não é suficientemente genérico, MonadReader rm, Has Env r escrever MonadReader rm, Has Env r e adicionar genéricos para que o compilador faça tudo lá.
  6. Agora a norma.

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


All Articles