title |
---|
24 Days of Hackage: contravariant |
Today and tomorrow we take a brief excursion to the boundary of abstract nonsense with a pair of guest posts from Tom Ellis. Tom, over to you!
In today and tommorow's posts I'm going to introduce the Contravariant
and
Profunctor
type classes, which are found in the
contravariant
and
profunctors
packages,
respectively. They are closely related to the familiar Functor
type class, so
let's refresh our understanding by first reviewing its definition:
class Functor f where
fmap :: (a -> b) -> f a -> f b
We will use the intuition that a Functor
is a sort of "producer of output"
that can have its type adapted. If f
is a functor, then f a
represents a
"producer" of type a
. Using a normal Haskell function of type a -> b
we can
adapt the output to the type b
by using fmap
. Here are three examples of the
"producer of output" intuition for a Functor
:
-
Maybe a
represents an output of typea
which may be either present (Just a
) or absent (Nothing
). Usingfmap f :: Maybe a -> Maybe b
we can adapt that potentially absenta
into a potentially absentb
. -
[a]
represents a sequence of output values of typea
, and with theFunctor
instance for[]
we can adapt them to a sequence of output values of typeb
. -
A value of type
r -> a
is a function taking an input of typer
and giving an output of typea
. TheFunctor
instance for(->) r
- which is the type of functions takingr
as input - allows us to convert this output to typeb
, by composition:
instance Functor ((->) r) where
fmap f g = f . g
Incidentally, this is also the underlying Functor
instance of the reader
monad.
While fmap
allowed us to change the result type of the function, notice how we
are unable to change the input type. The Functor
type class does not allow to
change the input, and that's where Contravariant
comes in. The intuition
behind a Contravariant
is that it reflects a sort of "consumer of input" that
can have its type adapted. For a contravariant functor f
, f a
represents
some sort of input of type a
. As with fmap
, we can use contramap
with a
function of type a -> b
to change, we can adapt the input to the type b
.
class Contravariant f where
contramap :: (b -> a) -> f a -> f b
The function arrow is the prototypical example of something contravariant,
although because of the ordering of the type parameters, we have to give it a
newtype
, which is commonly called Op
(for Op
posite):
newtype Op z a = Op (a -> z)
instance Contravariant (Op z) where
contramap h (Op g) = Op (g . h)
For example, we can use contramap
to map over the input of a function that
calculates the length of a list, to calculate the length of a Set
:
lengther :: Op Int [a]
lengther = Op Prelude.length
setLengther :: Op Int (Set a)
setLengther = contramap Set.toList lengther
In Haskell, we like to be able to reason about our code, so most type classes
come with corresponding laws. The laws that the instance is required to
satisfy are dual to the Functor
laws. First, let's recall what the Functor
laws are:
fmap id = id
fmap (f . g) = fmap f . fmap g
The idea of "dual" laws here means that the order of the f
and g
switches
over, whereas in the functor law they keep their relative ordering. Therefore,
the laws for contravariant functors are:
contramap id = id
contramap (f . g) = contramap g . contramap f
A more interesting example of a contravariant functor is an actor that receives
messages of type a
, performs some action and then listens for further messages
of type a
, repeating this process indefinitely. (You'll find a similar
definition to this in the
simple-actors
package).
data Behavior a = Behavior (a -> IO (Behavior a))
instance Contravariant Behavior where
contramap f (Behavior r) = Behavior (\a -> fmap (contramap f) (r (f a)))
runBehavior :: Behavior a -> [a] -> IO ()
runBehavior _ [] = return ()
runBehavior (Behavior f) (a:as) = do
newBehavior <- f a
runBehavior newBehavior as
We can make a Behavior
that just prints the strings it receives:
printer :: Behavior String
printer = Behavior (\s -> putStrLn s >> return printer)
messages :: [String]
messages = [ "Hello", "world", "Haskell", "is", "great" ]
testPrinter = runBehavior printer messages
We run our printer
with testPrinter :: IO ()
, which gives the following
output:
> testPrinter
Hello
world
Haskell
is
great
Using contramap
we can adapt it to a Behaviour
which shouts:
shoutyPrinter :: Behavior String
shoutyPrinter = contramap (\s -> (map toUpper s ++ "!")) printer
testShoutyPrinter = runBehavior shoutyPrinter messages
Who's output is now a bit louder:
> testShoutyPrinter
HELLO!
WORLD!
HASKELL!
IS!
GREAT!
A more complicated example would be a "mailbox" that listens for messages and
prints out the messages it has received so far. These mailboxes will have a
limit on the amount of messages they can hold, and if attempt to push messages
into a full mailbox, then the message is dropped. We can model this as another
Behaviour
:
makeMailbox :: [(String, String)] -> Behavior (String, String)
makeMailbox messages = Behavior $ \message ->
if length messages < 3
then let newMessages = messages ++ [message]
in do putStrLn "I contain messages:"
mapM_ printMessage newMessages
return (makeMailbox newMessages)
else do putStrLn "I am full."
putStrLn "I contain messages:"
mapM_ printMessage messages
return (makeMailbox messages)
where
printMessage (from, message) =
putStrLn ("From " ++ from ++ ": " ++ message)
mailbox :: Behavior (String, String)
mailbox = makeMailbox []
Now we test the mailbox. There are some messages we don't want to receive, but luckily the mailbox gets full before they arrive!
testMailbox = runBehavior mailbox messages
where messages = [ ("ocharles", "hackage is great")
, ("edwardk", "I love Simon Peyton Jones")
, ("spj", "We all love lazy evaluation")
, ("Your spouse", "Make me a cup of tea")
, ("Your employer", "You must program in Scala") ]
If we run this, we'll see that the first 3 messages are delivered, but after that the messages are dropped:
> testMailbox
I contain messages:
From ocharles: hackage is great
I contain messages:
From ocharles: hackage is great
From edwardk: I love Simon Peyton Jones
I contain messages:
From ocharles: hackage is great
From edwardk: I love Simon Peyton Jones
From spj: We all love lazy evaluation
I am full.
I contain messages:
From ocharles: hackage is great
From edwardk: I love Simon Peyton Jones
From spj: We all love lazy evaluation
I am full.
I contain messages:
From ocharles: hackage is great
From edwardk: I love Simon Peyton Jones
From spj: We all love lazy evaluation
A mailbox expects to receive a message consisting of a tuple of (String, String)
containing the "sender name" and "message body". As long as we can
provide a function a -> (String, String)
we can use contramap
to make a
Behavior a
from our mailbox. Here's an example of using the mailbox as a
logger. A log entry is either success carrying an integer, or a failure
message. We can turn this into a message for a mailbox with a simple logMsg
function:
logMsg :: Either String Int -> (String, String)
logMsg (Right x) = ("Logger daemon", "Everything is OK: " ++ show x)
logMsg (Left s) = ("Logger daemon", "FAILURE: " ++ s)
Now we can contramap
our previous mailbox into a mailbox that accepts log
entries as input:
loggerMailbox :: Behavior (Either String Int)
loggerMailbox = contramap logMsg mailbox
testLogger = runBehavior loggerMailbox messages
where messages = [ Right 24
, Left "Oops, there was an error"
, Right 1
, Right 2 ]
A final run of this testLogger
shows:
> testLogger
I contain messages:
From Logger daemon: Everything is OK: 24
I contain messages:
From Logger daemon: Everything is OK: 24
From Logger daemon: FAILURE: Oops, there was an error
I contain messages:
From Logger daemon: Everything is OK: 24
From Logger daemon: FAILURE: Oops, there was an error
From Logger daemon: Everything is OK: 1
I am full.
I contain messages:
From Logger daemon: Everything is OK: 24
From Logger daemon: FAILURE: Oops, there was an error
From Logger daemon: Everything is OK: 1
There are not many instances of Contravariant
on Hackage, although there are
surely many contravariant type parameters, and perhaps this type class could be
used more widely.