Skip to content

Commit

Permalink
add "Capability constraints" chapter
Browse files Browse the repository at this point in the history
  • Loading branch information
GoNZooo committed Nov 21, 2021
1 parent 98de713 commit e8d641b
Show file tree
Hide file tree
Showing 2 changed files with 203 additions and 0 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ are readable from top to bottom in terms of the assumptions they make about know
- [The Reader Monad](./basics/07-reader.md)
- [The ReaderT Monad Transformer](./basics/08-readert.md)
- [`Has` constraints](./basics/09-has-constraints.md)
- [Capability constraints](./basics/10-capability-constraints.md)

There is a series of extra materials that can be read to gain some familiarity with
libraries/aspects of solving problems in Haskell:
Expand Down
202 changes: 202 additions & 0 deletions basics/10-capability-constraints.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
# Capability constraints

- [Capability constraints](#capability-constraints)
- [Tagless final / `MonadX` constraints](#tagless-final--monadx-constraints)
- [Where we left off](#where-we-left-off)
- [More constraints on what we can do in functions](#more-constraints-on-what-we-can-do-in-functions)
- [Where do we go with this?](#where-do-we-go-with-this)

## Tagless final / `MonadX` constraints

Sometimes we'd like to be very specific about what can happen in a function. This can be
accomplished in many ways, one of which is called "Tagless final". We've seen glimpses of this in
previous chapters, with both `MonadReader` and `MonadIO` showing up. When we see these in the type
signature of a function we know that the function has a certain capability and can confidently say
the function also works with any monad transformer stack that has the signalled capability.

In this chapter we'll take a look at defining our own a'la carte type classes for signalling
behavior, to stand in as a general example of how we can be more specific about what can happen in
functions.

## Where we left off

We saw in our chapter on `Has` constraints that we could both generalize and constrain our functions
such that they can be used with any transformer stack but also only be usable if we have access to
a "log handle":

```haskell
import RIO
import System.IO (hPutStrLn, openFile, print)

-- type AppMonad a = ReaderT ApplicationState IO a
type AppMonad = ReaderT ApplicationState IO

data ApplicationState = ApplicationState
{ string :: String,
logHandle :: Handle
}

runMain :: IO ()
runMain = do
logHandle <- openFile "./run-log.txt" AppendMode
hSetBuffering logHandle LineBuffering
let initialState = ApplicationState {string = "", logHandle}
x <- runReaderT (canReadString 5) initialState {string = "Quanterall"}
y <- runReaderT (canReadString 5) initialState {string = "Quanteral"}
print x
print y

notPassingArguments :: AppMonad Int
notPassingArguments = do
ApplicationState {string} <- ask
-- We can use `logToFile` here and not be concerned with the file handle because we know it's in
-- the environment we're executing inside of already.
logToFile $ "We got '" <> string <> "' from the environment"
pure $ length string

canReadString :: Int -> AppMonad Int
canReadString added = do
logToFile "We're about to call `notPassingArguments`"
result <- notPassingArguments
pure $ added + result

class HasLogHandle e where
getLogHandle :: e -> Handle

instance HasLogHandle ApplicationState where
getLogHandle = logHandle

logToFile :: (HasLogHandle e, MonadReader e m, MonadIO m) => String -> m ()
logToFile logString = do
fileHandle <- asks getLogHandle
liftIO $ hPutStrLn fileHandle logString
```

## More constraints on what we can do in functions

We'd like to be even clearer about what is happening in our functions by way of one of these type
classes:

```haskell
import RIO
import System.IO (hPutStrLn, openFile, print)

-- type AppMonad a = ReaderT ApplicationState IO a
type AppMonad = ReaderT ApplicationState IO

data ApplicationState = ApplicationState
{ string :: String,
logHandle :: Handle
}

runMain :: IO ()
runMain = do
logHandle <- openFile "./run-log.txt" AppendMode
hSetBuffering logHandle LineBuffering
let initialState = ApplicationState {string = "", logHandle}
x <- runReaderT (canReadString 5) initialState {string = "Quanterall"}
y <- runReaderT (canReadString 5) initialState {string = "Quanteral"}
print x
print y

notPassingArguments :: AppMonad Int
notPassingArguments = do
ApplicationState {string} <- ask
-- We can use `logToFile` here and not be concerned with the file handle because we know it's in
-- the environment we're executing inside of already.
logToFile $ "We got '" <> string <> "' from the environment"
pure $ length string

canReadString :: Int -> AppMonad Int
canReadString added = do
logToFile "We're about to call `notPassingArguments`"
result <- notPassingArguments
pure $ added + result

class HasLogHandle environment where
getLogHandle :: environment -> Handle

instance HasLogHandle ApplicationState where
getLogHandle = logHandle

class MonadHandleLogging m where
logToHandle :: Handle -> String -> m ()

instance MonadHandleLogging AppMonad where
logToHandle h = hPutStrLn h >>> liftIO

logToFile :: (HasLogHandle e, MonadReader e m, MonadHandleLogging m) => String -> m ()
logToFile logString = do
fileHandle <- asks getLogHandle
logToHandle fileHandle logString
```

We could also imagine another capability type class, `LogToDefault` that does not take a handle to
write to:

```haskell
class LogToDefault m where
outputToLog :: String -> m ()

-- Since `outputToLog` doesn't take a handle but we have one in our environment, we use it together
-- with `logToHandle` to make the default logging go to the file handle.
instance LogToDefault AppMonad where
outputToLog s = do
h <- asks getLogHandle
logToHandle h s

-- `LogToDefault` is trivially implementable for just `IO` as well, meaning it would transparently
-- work in that context as well.
instance LogToDefault IO where
outputToLog = putStrLn
```

With this type class we are very free to implement entirely different behavior depending on the
context we are executing in. As an added example of that, let's imagine we had a custom testing
context set up and we wanted to implement `LogToDefault` for it in a way that let us capture the
output:

```haskell
type TestMonad = ReaderT TestingState IO

newtype TestingState = TestingState
{ output :: IORef [String]
}

instance LogToDefault TestMonad where
outputToLog s = do
outputReference <- asks output
modifyIORef' outputReference (s :)
```

## Where do we go with this?

It's hard to say where to draw the line with these type classes. In the end it ought to be up to the
team that is working on things to say whether or not their constraints make sense. If your
application deals a lot with sending and receiving from AWS SQS, it could be useful to have
`MonadSQS` or even `MonadReadSQS`/`MonadWriteSQS` constraints to make it clear where this particular
effect is actually needed and wanted. When people are modifying the code as per new requirements,
they'll naturally think twice about having to add these capabilities in certain functions and will
also have to deal with these constraints being put on the calling functions as well. This can lead
to more thoughtful use of effects and much clearer signalling of capabilities.

It's also important to note that with a certain level of granularity these kinds of constraints can
become overly tedious. A mostly reasonable way to remedy this is to make type classes that
themselves group up other capabilities:

```haskell
newtype QueueName = QueueName {unQueueName :: Text}

class MonadReadSQS m where
readFromQueue :: QueueName -> m (Maybe [Message])

class MonadWriteSQS m where
writeToQueue :: QueueName -> Text -> m ()

class (MonadReadSQS m, MonadWriteSQS m) => MonadSQS m
```

Since we have these superclass requirements for `MonadSQS` we are guaranteeing that if `MonadSQS m`
shows up in a type signature, it means the `m` in question has implementations of `MonadReadSQS` and
`MonadWriteSQS`. This type of consolidation of capabilities can make sense depending on your
particular needs in terms of code clarity.

0 comments on commit e8d641b

Please sign in to comment.