box

A profunctor effect system.
What is all this stuff around me; this stream of experiences that I seem to be having all the time? Throughout history there have been people who say it is all illusion. ~ S Blackmore
Usage
:set -XOverloadedStrings import Box import Prelude import Data.Function import Data.Bool
Standard IO echoing:
echoC = Committer (\s -> putStrLn ("echo: " <> s) >> pure True) echoE = Emitter (getLine & fmap (\x -> bool (Just x) Nothing (x =="quit"))) glue echoC echoE hello echo: hello echo echo: echo quit
Committing to a list:
> toListM echoE hello echo quit ["hello","echo"]
Emitting from a list:
> glue echoC <$|> witherE (\x -> bool (pure (Just x)) (pure Nothing) (x=="quit")) <$> (qList ["hello", "echo", "quit"]) echo: hello echo: echo
Library Design
Resource Coinduction
Haskell has an affinity with coinductive functions; functions should expose destructors and allow for infinite data.
The key text, Why Functional Programming Matters, details how producers and consumers can be separated by exploiting laziness, creating a speration of concern not available in other technologies. Utilising laziness, we can peel off (destruct) the next element of a list to be consumed without disturbing the pipeline of computations that is still to occur, for the cost of a thunk.
So how do you apply this to resources and their effects? One answer is that you destruct a (potentially long-lived) resource simply by using it. For example, reading and writing lines to standard IO:
:t getLine :t putStrLn getLine :: IO String putStrLn :: String -> IO ()
These are the destructors that need to be transparently exposed if effects are to be good citizens in Haskell.
What is a Box?
A Box is simply the product of a consumer destructor and a producer destructor.
data Box m c e = Box { committer :: Committer m c, emitter :: Emitter m e }
Committer
The library denotes a consumer by wrapping a consumption destructor and calling it a Committer. Like much of base, there is failure hidden in the getLine example type. A better approach, for a consumer, is to signal whether consumption actually occurred.
newtype Committer m a = Committer { commit :: a -> m Bool }
You give a Committer an ’a’, and the destructor tells you whether the consumption of the ’a’ was successful or not. A standard output committer is then:
stdC :: Committer IO String stdC = Committer (\s -> putStrLn s >> pure True) <interactive>:19:1-4: warning: [GHC-63397] [-Wname-shadowing] This binding for ‘stdC’ shadows the existing binding defined at <interactive>:16:1
A Committer is a contravariant functor, so contramap can be used to modify this:
import Data.Text as Text import Data.Functor.Contravariant echoC :: Committer IO Text echoC = contramap (Text.unpack . ("echo: "<>)) stdC
Emitter
The library denotes a producer by wrapping a production destructor and calling it an Emitter.
newtype Emitter m a = Emitter { emit :: m (Maybe a) }
An emitter returns an ’a’ on demand or not.
stdE :: Emitter IO String stdE = Emitter (Just <$> getLine)
As a functor instance, an Emitter can be modified with fmap. Several library functions, such as witherE and filterE can also be used to stop emits or add effects.
echoE :: Emitter IO Text echoE = witherE (\x -> bool (pure (Just x)) (putStrLn "quitting" *> pure Nothing) (x == "quit")) (fmap Text.pack stdE) <interactive>:52:1-5: warning: [GHC-63397] [-Wname-shadowing] This binding for ‘echoE’ shadows the existing binding defined at <interactive>:49:1
Box duality
A Box represents a duality in two ways:
-
As the consumer and producer sides of a resource. The complete interface to standard IO, for example, could be:
stdIO :: Box IO String String stdIO = Box (Committer (\s -> putStrLn s >> pure True)) (Emitter (Just <$> getLine))
-
As two ends of a computation.
This is how we can use a profunctor to glue together two categories ~ Milewski Promonads, Arrows, and Einstein Notation for Profunctors
glue is the primitive with which we connect a Committer and Emitter.
> glue echoC echoE hello echo: hello echo echo: echo quit quitting
Effectively the same computation, for a Box, is:
fuse (pure . pure) stdIO
Continuation
As with many operators in the library, qList is actually a continuation:
:t qList qList :: Control.Monad.Conc.Class.MonadConc m => [a] -> CoEmitter m a type CoEmitter m a = Codensity m (Emitter m a)
Effectively being a newtype wrapper around:
forall x. (Emitter m a -> m x) -> m x
A good background on call-back style programming in Haskell is in the managed library, which is a specialised version of Codensity.
Codensity has an Applicative instance, and lends itself to applicative-style coding. To send a (queued) list to stdout, for example, you could say:
:t glue <$> pure toStdout <*> qList ["a", "b", "c"] glue <$> pure toStdout <*> qList ["a", "b", "c"] :: Codensity IO (IO ())
and then escape the continuation with:
runCodensity (glue <$> pure toStdout <*> (qList ["a", "b", "c"])) id a b c
This closes the continuation. The following code is equivalent:
close $ glue <$> pure toStdout <*> qList ["a", "b", "c"] a b c close $ glue toStdout <$> qList ["a", "b", "c"] a b c
Given the ubiquity of this method, the library supplies two applicative style operators that combine application and closure.
-
(<$|>) fmap and close over a Codensity:
glue toStdout <$|> qList ["a", "b", "c"]
a b c
-
(<*|>) Apply and close over Codensity
glue <$> pure toStdout <*|> qList ["a", "b", "c"]
a b c
Explicit Continuation
Yield-style streaming libraries are coroutines, sum types that embed and mix continuation logic in with other stuff like effect decontruction. box sticks to a corner case of a product type representing a consumer and producer. The major drawback of eschewing coroutines is that continuations become explicit and difficult to hide. One example; taking the first n elements of an Emitter:
:t takeE takeE :: Monad m => Int -> Emitter m a -> Emitter (StateT Int m) a
A disappointing type. The state monad can not be hidden, the running count has to sit somewhere, and so different glueing functions are needed:
-- | Connect a Stateful emitter to a (non-stateful) committer of the same type, supplying initial state. -- -- >>> glueES 0 (showStdout) <$|> (takeE 2 <$> qList [1..3]) -- 1 -- 2 glueES :: (Monad m) => s -> Committer m a -> Emitter (StateT s m) a -> m () glueES s c e = flip evalStateT s $ glue (foist lift c) e
Future directions
The design and concepts contained within the box library is a hodge-podge, but an interesting mess, being at quite a busy confluence of recent developments.
Optics
A Box is an adapter in the language of optics and the relationship between a resource’s committer and emitter could be modelled by other optics.
Categorical Profunctor
The deprecation of Box.Functor awaits the development of categorical functors. Similarly to Filterable the type of a Box could be something like FunctorOf Op(Kleisli Maybe) (Kleisli Maybe) (->). Or it could be something like the SISO type in Programming with Monoidal Profunctors and Semiarrows.
Wider Types
Alternatively, the types could be widened:
newtype Committer f a = Committer { commit :: a -> f () } instance Contravariant (Committer f) where contramap f (Committer a) = Committer (a . f) newtype Emitter f a = Emitter { emit :: f a } instance (Functor f) => Functor (Emitter f) where fmap f (Emitter a) = Emitter (fmap f a) data Box f g b a = Box { committer :: Committer g b, emitter :: Emitter f a } instance (Functor f) => Functor (Box f g b) where fmap f (Box c e) = Box c (fmap f e) instance (Functor f, Contravariant g) => Profunctor (Box f g) where dimap f g (Box c e) = Box (contramap f c) (fmap g e)
.. with the existing computations recovered with:
type CommitterB m a = Committer (MaybeT m) a type EmitterB m a = Emitter (MaybeT m) a type BoxB m b a = Box (MaybeT m) (MaybeT m) b a
Alternative to both of these, the Monad constraint could be rethought. There are the ends of the computational pipeline, but there is also the gluing/fusion/middle bit.
connect :: (f a -> b) -> Committer g b -> Emitter f a -> g () connect w c e = emit e & w & commit c glue :: Box f g (f a) a -> g () glue (Box c e) = connect id c e nucleate :: Functor f => (f a -> f b) -> Committer g b -> Emitter f a -> f (g ()) nucleate n c e = emit e & n & fmap (commit c)
This has the nice property that the closure is not hidden (as is usually the case for a Monad constraint) so that, for instance, fusion along longer chains becomes possible.