Oi Habr.
Hoje consideraremos um padrão FP como Has
-class. Isso é interessante por várias razões: em primeiro lugar, garantiremos mais uma vez que existem padrões no PF. Em segundo lugar, verifica-se que a implementação desse padrão pode ser confiada à máquina, o que acabou sendo um truque bastante interessante com as classes de tipos (e a biblioteca Hackage), que mais uma vez demonstra a utilidade prática das extensões de sistemas de tipos fora do Haskell 2010 e IMHO é muito mais interessante do que esse próprio padrão. Em terceiro lugar, uma ocasião para gatos.

No entanto, talvez valha a pena começar com uma descrição do que é uma classe Has
, especialmente porque não havia uma descrição curta (e, principalmente, uma em russo).
Então, como o Haskell resolve o problema de gerenciar um ambiente global somente leitura que várias funções diferentes precisam? Como, por exemplo, é expressa a configuração global do aplicativo?
A solução mais óbvia e direta é que, se uma função precisar de um valor do tipo Env
, você poderá simplesmente passar um valor do tipo Env
para essa função!
iNeedEnv :: Env -> Foo iNeedEnv env =
No entanto, infelizmente, essa função não é muito passível de composição, principalmente se comparada a outros objetos com os quais estamos acostumados no Haskell. Por exemplo, comparado com mônadas.
Na verdade, uma solução mais generalizada é agrupar funções que precisam acessar o ambiente Env
na mônada Reader Env
:
import Control.Monad.Reader data Env = Env { someConfigVariable :: Int , otherConfigVariable :: [String] } iNeedEnv :: Reader Env Foo iNeedEnv = do
Isso pode ser generalizado ainda mais, para o qual basta usar a classe MonadReader
e apenas alterar o tipo de função:
iNeedEnv :: MonadReader Env m => m Foo iNeedEnv =
Agora, não nos importa exatamente em que pilha monádica estamos, desde que possamos obter o valor do tipo Env
partir dela (e expressamos isso explicitamente no tipo de nossa função). Não nos importamos se a pilha inteira possui outros recursos, como IO
/ IO
ou tratamento de erros através do MonadError
:
someCaller :: (MonadIO m, MonadReader Env m, MonadError Err m) => m Bar someCaller = do theFoo <- iNeedEnv ...
E, a propósito, um pouco mais alto, eu realmente menti quando disse que a abordagem de passar explicitamente um argumento para uma função não é tão compostável quanto as mônadas: o tipo funcional "parcialmente aplicado" r ->
é uma mônada e, além disso, é bastante uma instância legítima da classe MonadReader r
. O desenvolvimento de intuição apropriada é oferecido ao leitor como um exercício.
De qualquer forma, este é um bom passo para a modularidade. Vamos ver aonde ele nos leva.
Por que
Vamos trabalhar em algum tipo de serviço da web que, entre outras coisas, pode ter os seguintes componentes:
- Camada de acesso ao banco de dados
- servidor web
- temporizador ativado módulo cron.
Cada um desses módulos pode ter sua própria configuração:
- detalhes de acesso ao banco de dados,
- host e porta para o servidor web,
- intervalo de operação do temporizador.
Podemos dizer que a configuração geral de todo o aplicativo é uma combinação de todas essas configurações (e, provavelmente, outra coisa).
Para simplificar, suponha que a API de cada módulo consista em apenas uma função:
setupDatabase
startServer
runCronJobs
Cada um desses recursos requer uma configuração apropriada. Nós já aprendemos que o MonadReader
é uma boa prática, mas qual será o tipo de ambiente?
A solução mais óbvia seria algo como
data AppConfig = AppConfig { dbCredentials :: DbCredentials , serverAddress :: (Host, Port) , cronPeriodicity :: Ratio Int } setupDatabase :: MonadReader AppConfig m => m Db startServer :: MonadReader AppConfig m => m Server runCronJobs :: MonadReader AppConfig m => m ()
Muito provavelmente, esses recursos exigirão o MonadIO
e, possivelmente, algo mais, mas isso não é tão importante para a nossa discussão.
De fato, fizemos uma coisa terrível. Porque Bem, de imediato:
- Adicionamos uma conexão desnecessária entre componentes completamente diferentes. Idealmente, a camada DB não deve saber nada sobre algum tipo de servidor web. E, é claro, não devemos recompilar o módulo para trabalhar com o banco de dados ao alterar a lista de opções de configuração para o servidor da web.
- Isso não funcionará se não pudermos editar o código fonte de alguns dos módulos. Por exemplo, o que devo fazer se o módulo cron for implementado em alguma biblioteca de terceiros que não saiba nada sobre o nosso caso de usuário específico?
- Adicionamos oportunidades para cometer um erro. Por exemplo, o que é
serverAddress
? Esse é o endereço que o servidor da Web deve escutar ou é o endereço do servidor de banco de dados? O uso de um tipo grande para todas as opções aumenta a chance de tais colisões. - Não podemos mais concluir, de relance, nas assinaturas das funções que módulos usam qual parte da configuração. Tudo tem acesso a tudo!
Então, qual é a solução para tudo isso? Como você pode imaginar, no título do artigo, este
Has
padrão
De fato, cada módulo não se importa com o tipo de ambiente inteiro, desde que esse tipo possua os dados necessários para o módulo. Isso é mais fácil de mostrar com um exemplo.
Considere um módulo para trabalhar com um banco de dados e suponha que ele defina um tipo que contenha toda a configuração que o módulo precisa:
data DbConfig = DbConfig { dbCredentials :: DbCredentials , ... }
Has
-padrão é representado como a seguinte classe de tipo:
class HasDbConfig rec where getDbConfig :: rec -> DbConfig
Em seguida, o tipo setupDatabase
parecerá
setupDatabase :: (MonadReader rm, HasDbConfig r) => m Db
e no corpo da função, apenas precisamos usar o asks $ foo . getDbConfig
asks $ foo . getDbConfig
onde usamos o asks foo
antes, devido à camada de abstração extra que acabamos de adicionar.
Da mesma forma, teremos as HasWebServerConfig
HasCronConfig
e HasCronConfig
.
E se alguma função usar dois módulos diferentes? Apenas construções compatíveis!
doSmthWithDbAndCron :: (MonadReader rm, HasDbConfig r, HasCronConfig r) => ...
E as implementações dessas classes de tipo?
Ainda temos o AppConfig
no nível mais alto de nosso aplicativo (agora os módulos ainda não sabem disso) e, para isso, podemos escrever:
data AppConfig = AppConfig { dbConfig :: DbConfig , webServerConfig :: WebServerConfig , cronConfig :: CronConfig } instance HasDbConfig AppConfig where getDbConfig = dbConfig instance HasWebServerConfig AppConfig where getWebServerConfig = webServerCOnfig instance HasCronConfig AppConfig where getCronConfig = cronConfig
Parece bom até agora. No entanto, essa abordagem tem um problema - muita escrita , e a examinaremos em mais detalhes no próximo post.