Hi, Habr.
Today we will consider such an FP pattern as Has
-class. This is a rather interesting thing for several reasons: firstly, we will once again make sure that there are patterns in the FP. Secondly, it turns out that the implementation of this pattern can be entrusted to the machine, which turned out to be a rather interesting trick with typeclasses (and the Hackage library), which once again demonstrates the practical usefulness of type system extensions outside of Haskell 2010 and IMHO is much more interesting than this pattern itself. Thirdly, an occasion for cats.
However, perhaps, it’s worthwhile to start with a description of what a Has
class is, all the more so since there wasn’t any short (and, especially, Russian-speaking) description.
So, how does the Haskell solve the problem of managing some global read-only environment that several different functions need? How, for example, is the global configuration of the application expressed?
The most obvious and direct solution is that if a function needs a value of type Env
, then you can simply pass a value of type Env
to this function!
iNeedEnv :: Env -> Foo iNeedEnv env = -- , env
However, unfortunately, such a function is not very composable, especially compared to some other objects that we are used to in the Haskell. For example, compared with monads.
Actually, a more generalized solution is to wrap functions that need access to the Env
environment in the Reader Env
monad:
import Control.Monad.Reader data Env = Env { someConfigVariable :: Int , otherConfigVariable :: [String] } iNeedEnv :: Reader Env Foo iNeedEnv = do -- : env <- ask -- c : theInt <- asks someConfigVariable ...
This can be generalized even more, for which it is enough to use the MonadReader MonadReader
and just change the type of function:
iNeedEnv :: MonadReader Env m => m Foo iNeedEnv = -- ,
Now it doesn’t matter to us exactly which monadic stack we are in, as long as we can get a value of type Env
from it (and we explicitly express this in the type of our function). We don’t care if the whole stack has any other features like IO
or error handling through MonadError
:
someCaller :: (MonadIO m, MonadReader Env m, MonadError Err m) => m Bar someCaller = do theFoo <- iNeedEnv ...
And, by the way, a little higher, I actually lied when I said that the approach of explicitly passing an argument to a function is not as composable as monads: the “partially applied” functional type r ->
is a monad, and, moreover, it is quite a legitimate instance of the MonadReader r
class. The development of appropriate intuition is offered to the reader as an exercise.
In any case, this is a good step towards modularity. Let's see where he leads us.
Let us work on some kind of web service, which, among other things, may have the following components:
Each of these modules can have its own configuration:
We can say that the overall configuration of the entire application is a combination of all these settings (and, probably, something else).
For simplicity, suppose that the API of each module consists of only one function:
setupDatabase
startServer
runCronJobs
Each of these features requires an appropriate configuration. We already learned that MonadReader
is a good practice, but what will be the type of environment?
The most obvious solution would be something like
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 ()
Most likely, these features will require MonadIO
and, possibly, something else, but this is not so important for our discussion.
In fact, we just did a terrible thing. Why? Well, offhand:
serverAddress
So what is the solution for this all? As you might guess from the title of the article, this
Has
In fact, each module does not care about the type of the entire environment, as long as this type has the data needed for the module. This is easiest to show with an example.
Consider a module for working with a database and suppose that it defines a type that contains all the configuration that a module needs:
data DbConfig = DbConfig { dbCredentials :: DbCredentials , ... }
Has
-pattern is represented as the following typeclass:
class HasDbConfig rec where getDbConfig :: rec -> DbConfig
Then the setupDatabase
type will look like
setupDatabase :: (MonadReader rm, HasDbConfig r) => m Db
and in the body of the function we just have to use asks $ foo . getDbConfig
asks $ foo . getDbConfig
where we used to use asks foo
because of the extra abstraction layer we just added.
Similarly, we will have the HasWebServerConfig
and HasCronConfig
.
What if some function uses two different modules? Just compatible constraints!
doSmthWithDbAndCron :: (MonadReader rm, HasDbConfig r, HasCronConfig r) => ...
What about the implementations of these typeclasses?
We still have AppConfig
at the highest level of our application (just now the modules do not know about it), and for it we can write:
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
It looks good so far. However, this approach has one problem - too much writing , and we will examine it in more detail in the next post.