box: A profunctor effect system?

[ bsd3, control, library ] [ Propose Tags ] [ Report a vulnerability ]

This might be a profunctor effect system, but is unlike all the others, so it's hard to say for sure.


[Skip to Readme]

Downloads

Maintainer's Corner

Package maintainers

For package maintainers and hackage trustees

Candidates

  • No Candidates
Versions [RSS] 0.0.1.1, 0.0.1.2, 0.0.1.4, 0.0.1.5, 0.1.0, 0.2.0, 0.3.0, 0.4.0, 0.5.0, 0.6.0, 0.6.1, 0.6.2, 0.6.3, 0.7.0, 0.8.0, 0.8.1, 0.9.0, 0.9.1, 0.9.2.0, 0.9.2.1, 0.9.3.0, 0.9.3.1, 0.9.3.2
Change log ChangeLog.md
Dependencies async (>=2.2 && <2.3), base (>=4.7 && <5), bytestring (>=0.11.3 && <0.13), containers (>=0.6 && <0.8), contravariant (>=1.5 && <1.6), dlist (>=1.0 && <1.1), exceptions (>=0.10 && <0.11), kan-extensions (>=5.2 && <5.3), mtl (>=2.2.2 && <2.4), profunctors (>=5.6.2 && <5.7), semigroupoids (>=5.3 && <6.1), stm (>=2.5.1 && <2.6), text (>=1.2 && <2.2), time (>=1.10 && <1.15) [details]
Tested with ghc ==9.10.1, ghc ==9.8.2, ghc ==9.6.5
License BSD-3-Clause
Copyright Tony Day (c) 2017
Author Tony Day
Maintainer tonyday567@gmail.com
Category control
Home page https://github.com/tonyday567/box#readme
Bug tracker https://github.com/tonyday567/box/issues
Source repo head: git clone https://github.com/tonyday567/box
Uploaded by tonyday567 at 2024-10-13T02:03:34Z
Distributions LTSHaskell:0.9.3.2, NixOS:0.9.3.2, Stackage:0.9.3.2
Reverse Dependencies 8 direct, 4 indirect [details]
Downloads 4517 total (56 in the last 30 days)
Rating (no votes yet) [estimated by Bayesian average]
Your Rating
  • λ
  • λ
  • λ
Status Docs available [build log]
Last success reported on 2024-10-20 [all 1 reports]

Readme for box-0.9.3.2

[back to package description]

box

img img

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

Introduce a nucleus

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.