Skip to content

Commit b0b3a30

Browse files
committed
Add dynamic reader
1 parent be82748 commit b0b3a30

2 files changed

Lines changed: 116 additions & 1 deletion

File tree

src/Theseus/Effect/Reader.hs

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,29 @@ module Theseus.Effect.Reader (
44
local,
55
asks,
66
runReader,
7+
DReader (DAsk, DLocal),
8+
dask,
9+
dlocal,
10+
dasks,
11+
ReadStack,
12+
runDReader,
713
) where
814

15+
import Data.Foldable
16+
import Data.Monoid
917
import Theseus.Eff
18+
import Theseus.Effect.State
1019

1120
-- # Reader
1221

1322
-- The Reader effect is a good example of a higher order effect because it's
14-
-- otherwise very simple.
23+
-- otherwise very simple. There are two readers in this module. The first is
24+
-- a lexically scoped reader. The second is a dynamically scoped reader. The
25+
-- first is simpler and more naturally fits into Theseus' architecture. The
26+
-- second is more complicated. Coming from first order effect systems like
27+
-- `freer-simple`, the lexical scoping is what you'd expect. Coming from IO
28+
-- based higher order effect systems like effectful, dynamic scoping is what
29+
-- you'd expect.
1530

1631
-- | Computations that use `Reader r` can ask the environment for some `r`.
1732
-- They cannot change the `r`, but that does not necessarily mean that two
@@ -58,3 +73,80 @@ localReader :: forall r lb ub es a. (lb Identity, Reader r :> es) => (r -> r) ->
5873
localReader f = interpret \sender -> \case
5974
Ask -> pure . f <$> ask
6075
Local newF m -> pure $ sender @(Reader r) $ localReader (newF . f) m
76+
77+
-- ## Dynamic Reader
78+
79+
-- The default (and simpler) version of Reader is lexically scoped. Code
80+
-- directly inside of a `local` see the modified value, but anything not
81+
-- "syntactically" inside the `local` will not. For example, imagine you wanted
82+
-- to keep track of a stack of locations while logging. For example, you might
83+
-- use some code like `withCtx "doingThing" do action` and any logs emitted within
84+
-- `...` should include the extra context. You might expect the normal `Reader`
85+
-- effect to work for this. If `action = do callDb; log` then it the `log`
86+
-- after `callDb` will work correctly. If `callDb` is an effect and the
87+
-- implementation of `CallDb` calls `log`, it won't see the local context. It
88+
-- would only see the context where `CallDb` is being handled, not the context
89+
-- where `CallDb` is being sent.
90+
--
91+
-- In cases like that one, having a dynamically scoped Reader is extremely
92+
-- helpful. Theseus includes that as a separate `DReader` type
93+
94+
-- | The dynamically scoped Reader has the same laws as `Reader`, but
95+
-- modifications created by `DLocal` are visible within effect interpretations
96+
-- instead of only being visible at the outermost level.
97+
data DReader r m a where
98+
DAsk :: DReader r m r
99+
DLocal :: lb Identity => (r -> r) -> Eff lb ub es a -> DReader r (Eff lb ub es) a
100+
101+
-- | Returns the `DReader`'s constant value.
102+
dask :: DReader r :> es => Eff lb ub es r
103+
dask = send DAsk
104+
105+
-- | Modifies the `DReader`'s value within some limited scope.
106+
dlocal :: (lb Identity, DReader r :> es) => (r -> r) -> Eff lb ub es a -> Eff lb ub es a
107+
dlocal f action = send $ DLocal f action
108+
109+
-- | A convenience function equivalent to `fmap f dask`.
110+
dasks :: DReader r :> es => (r -> a) -> Eff lb ub es a
111+
dasks f = fmap f dask
112+
113+
-- The implementation of `DReader` uses `State` to keep track of a stack of
114+
-- local modifications. It looks a lot like the implementation of Readers in IO
115+
-- based effect systems. It relies on Theseus' `finally` function to ensure the
116+
-- stack is modified correctly.
117+
118+
-- | Runs a `DReader r` effect and consumes the `State ReadStack` that it
119+
-- depends on. The `State` is not a private effect because it needs to be
120+
-- sendable by any functions that want to send `DAsk`. That means you should
121+
-- never use raise to hide the `State ReadStack` unless you are also hiding the
122+
-- `DReader` associated with it. Getting this wrong will trigger `error` calls.
123+
--
124+
-- Although it's public, you should not modify the `State ReadStack`. Because
125+
-- `ReadStack` doesn't export its constructor, you shouldn't be able to do much
126+
-- with it anyway.
127+
runDReader ::
128+
forall r lb ub es a.
129+
(lb Identity, lb (StateResult (ReadStack r))) =>
130+
r ->
131+
Eff lb ub (DReader r : State (ReadStack r) : es) a ->
132+
Eff lb ub es a
133+
runDReader r =
134+
evalState mempty . interpret \sender -> \case
135+
DAsk -> pure . apply r <$> get
136+
DLocal f m -> do
137+
modify (push f)
138+
pure $ sender @(State (ReadStack r)) $ m `finally` modify (pop @r)
139+
140+
-- | An opaque type holding the stack of local modifications.
141+
newtype ReadStack r = ReadStack [Endo r]
142+
deriving (Semigroup, Monoid)
143+
144+
push :: (r -> r) -> ReadStack r -> ReadStack r
145+
push f (ReadStack rs) = ReadStack (Endo f : rs)
146+
147+
pop :: ReadStack r -> ReadStack r
148+
pop (ReadStack []) = error "The read stack was empty. It might have been replaced with an incompatible one."
149+
pop (ReadStack (_ : rs)) = ReadStack rs
150+
151+
apply :: r -> ReadStack r -> r
152+
apply r (ReadStack rs) = fold rs `appEndo` r

test/Reader.hs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
module Reader where
22

33
import Control.Applicative
4+
import Theseus.Constraints
45
import Theseus.Eff
6+
import Theseus.Effect.Choice
7+
import Theseus.Effect.Error
58
import Theseus.Effect.Reader
69
import Utils
710

@@ -56,13 +59,33 @@ testReader = do
5659
third <- local (++ " never") ask
5760
pure (first, second, third)
5861
runReaderNoLocal "second" $ doneCoroutine rest
62+
describe "DReader" do
63+
describe "Local" do
64+
it "is dynamically scoped" do
65+
"local" === runEff $ runDReader "initial" $ runDA $ dlocal (const "local") $ send A
66+
it "is limited to its scope" do
67+
"initial" === runEff $ runDReader "initial" $ runDA $ dlocal (const "local") (pure ()) >> send A
68+
it "handles throw" do
69+
"initial" === runEff $ runDReader "initial" $ runDA $ do
70+
-- Without the `finally` in the implementation, this would forget to pop
71+
_ <- runThrow @() $ dlocal (const "local") (throw ())
72+
send A
73+
it "handles choice" do
74+
"initial" === runEff $ runDReader "initial" $ runDA $ do
75+
_ <- unrestrict @Traversable @Traversable idImply $ runChoice $ dlocal (const "local") do
76+
-- Without the `finally` in the implementation, this would try to pop twice
77+
pure () <|> pure ()
78+
send A
5979

6080
data A :: Effect where
6181
A :: A m String
6282

6383
runA :: (Reader String :> es, lb Identity) => Eff lb ub (A : es) a -> Eff lb ub es a
6484
runA = interpret_ \A -> ask
6585

86+
runDA :: (DReader String :> es, lb Identity) => Eff lb ub (A : es) a -> Eff lb ub es a
87+
runDA = interpret_ \A -> dask
88+
6689
-- | This is a version of Reader which completely ignores the function passed
6790
-- to local. It's unlawful and you should never use it, but it's convenient
6891
-- for some `Coroutine` tests.

0 commit comments

Comments
 (0)