What is the difference between ST effect / Reader / Writer ?

I finally have managed to go through complete PureScritpt ebook.

After having put the language aside for a couple of days, I’ve discovered, there are multiple ways to have mutable state.

  1. Chapter 8 talks about the ST effect with STRef cells, allowing mutable read and write state
  2. Chapter 11 introduces the Writer and Reader monads, which either provide write or read. The RWS can provide both, similar to ST effect.
  3. Last,The Reader monad also offers local, which can modify the current configuration for further steps. What is unclear to me (might be crystal clear for FP experts): Isn’t that already some kind of Writer itself - writing changed values to current config?

When do I use what kind of state? Thanks.

Welcome @librarian!
I’ll even add one to your list: the State monad!
I think the short answer is they all do very similar things or potentially the same thing, but they just communicate intent slightly differently. Imagine how in C# you might have a class property with a get and set operation, or you might have a “getter” method and a “setter” method, which do the same thing, but communicate a little differently to the consumer about things like expected runtime and usage. Or in JavaScript/TypeScript how you can have a Map with string keys or an Object, which pretty much do the same thing, but communicate a little differently to the consumer about what kind of contents they might expect.

I think the different stateful monads can be summed up as follows

  1. State is your go-to for most stateful computations, unless you have a reason to prefer some other monad.
  2. Reader is for (generally) read-only states, like application configs, where you set a value at the beginning and then don’t mutate it further. There’s the local function like you mentioned, which kind of lets you write to it, but you can’t return a modified value to the caller of a Reader, so it still kind of preserves its read-only status. You could replace a Reader with State and it just wouldn’t be read-only anymore.
  3. Writer is for (generally) write-only states, like logs, where you consume a value only at the end but don’t inspect the value anywhere while running. You could replace a Writer with State and it just wouldn’t be write-only anymore.
  4. RWS is for situations where you have different things that are readable, or writable, or otherwise stateful all at the same time. So like if you have a config, a log, and some application state that changes as the program executes. It’s really just a helpful alias for having a Reader, a Writer, and a State all together as part of the same stack. You could actually replace RWS with three State monads, or with a single State of a record that looked like
    { config :: r, logs :: w, appState :: s }.
  5. ST is very similar to State, but with performance optimizations at the cost of added complexity (you have to understand and manage the “Region” of mutation). This is generally something you use as a performance optimization in some algorithm when mutating a value in a tight loop is significantly faster than “pure FP” approaches. You wouldn’t often use ST as just a general approach for managing some state, since it’s harder to use than just the State monad.

Hope that’s helpful! Please ask if there’s further questions or other ways to clear things up.

4 Likes

I think the answer here is very good and I don’t want to take away from it.

But just for reference: If you’ll use any of these will probably depend on what you are going to write.
For example: most of the time I write PS apps with Halogen. This framework comes with it’s own state-handling and it’s disencouraged to use State with it (I’ll use Reader to provide “configuration” and access to more global state - for example with Refs).

Thanks @ntwilson for your detailed answer!

This makes sense.

I see. It was great for my understanding, that you highlighted the word “different”.
Some thought experiment: Is it possible to have a single shared state (read + write), but allow some client A only reading and another caller B only writing to this one state?

In OOP you probably would create an interface (pseudo code):

interface State<T> extends Readable<T>, Writable<T> {}
interface Readable<T> { read(): T }
interface Writeable<T> { write(t:T): void }

and pass Readable sub interface to A and Writeable to B.

This is good to hear. I already had heard from Reader and Writer before and this whole concept of ST, StRef confused me. I asked myself, why there needs to be something special just for Effect (as it’s listed in the Effect chapter). Looking over it again, ST just seems to be a different general alternative, with performance improvements, as you say.

Without reading the above answers (so, I’m not sure if I’m just repeating what was said):

  • ST is mutable state in a specific isolated context, such that the mutation doesn’t escape that context.
  • everything else mentioned is immutable state, but the values are passed around, so that it feels “stateful” (i.e. the State monad just passes around a Tuple monadOutput stateValue implicitly by hiding the boilerplate in its “do notation”. See GitHub - JordanMartinez/pure-conf-talk: Notes and files for my talk for PureConf 2022)
    • ReaderT provides a read-only value that allows top-down mutation of that value: I can only change the value in nested computations via local.
    • WriterT provides a write-only value that allows bottom-up mutation of that value: I can only change what the parent computations receive via tell, pass, or censor
    • StateT is a combination of the capabilities of ReaderT/WriterT, so it allows both read/write in top-down/bottom-up directions, which makes it feel like “real” stateful computations.
    • RWS combines all three of the types above into a tighter type. I’ve never fully understood why, but I think it’s performance-related (i.e. using RWS rather than a combination of the three individually is faster).
1 Like

Some thought experiment: Is it possible to have a single shared state (read + write), but allow some client A only reading and another caller B only writing to this one state?

Yes, this sort of sub-typing can only be done in PureScript via type classes. So for the Reader Monad, this is the MonadAsk type class.

You can then write a function that requires a type which satisfies the MonadAsk type class constraints. You can pass this function a Reader or a RWS monad, but if you pass it the RWS, that function will still only have access to the ask part of of the Monad.

So you can think of Reader, RWS, and any other monad/stack that implements MonadAsk as sub-types of MonadAsk. This is similar to implementing an interface in OOP.

An quick aside to the already great answers here:

There’s a confusion I ran into while learning that I’ve seen pop up a few times on discord and elsewhere. Don’t look to the PureScript library implementations for these Monads to study/better understand them. The prelude defines these monads via their monad transformers. For example, the Reader monad is a synonym for the ReaderT monad transformer, applied to the Identity monad.

It’s easier to first learn how a monad works and leave learning about monad transformers and MTL until later. There was a great blog article that implemented a bunch of monads in PureScript. You can’t use them for MTL (expect as the base monad), but it’s a good way to learn :slight_smile: . I can not seem to find it just now, but I’ll update here if I can find that article.

It is. The standard equivalents for Readable and Writable are the MonadAsk and MonadTell classes, and it is easy to write a wrapper type for State (or StateT) with instances for those classes that reduce to get and flip append >>> modify_ respectively.

This isn’t often done, though. Not because being granular with the effects you allow your ‘clients’ to use isn’t a good idea (it is!), but because once the part of your program that uses state gets complex enough to want this level of encapsulation, you probably should have already moved away from generic state transformer classes to application-specific classes. In OOP, this would be like using

interface FrobConsumer {
  readFrob(): Frob
  logFrobWarning(fw: FrobWarning): void
}

instead of Readable<Frob> & Writable<FrobWarning>. This allows you to bundle concerns by how your application logic is structured instead of how your data is structured, which is usually more readable and easier to refactor, provided the volume of code that consumes Frobs is large enough to justify the abstraction. The same holds in FP; you want to use

class MonadFrobConsumer m where
  readFrob :: m Frob
  logFrobWarning :: FrobWarning -> m Unit

instead of futzing around with generic readers and writers, under the same conditions and for the same reasons as in the OOP case.

(Of course, it still may be a good idea to implement the instances of your application-specific classes with the generic MTL-like classes. And then if, for performance reasons, you want to switch to ST or Refs in Effect, the amount of code you need to change to do that is much smaller.)

2 Likes

You’re totally right, this is just for educational purposes. I basically just want a simple noob example to understand PS state management alternatives a bit better, possibly using OOP analogies.

Mind giving an example?

Tried your proposed solution and setup some code, which 1.) reads shared state 2.) changes its value 3.) and reads again - all actions logging to Writer:

main :: Effect Unit   
main =
  logShow $
  unwrap $
  execWriterT $
  execStateT (do
    read
    write
    read) "foo"
                                              
read :: MyLogState Unit
read = do
  s <- get
  -- put "Hello world" -- should not work here
  tell ["Current state is " <> s]
                                              
write :: MyLogState Unit
write = do
  tell ["Put new state bar"]
  -- s <- get -- should not work here
  put "bar"

type Log = Array String
type MyLogState = StateT String (Writer Log)        

Output:

[“Current state is foo”,“Put new state bar”,“Current state is bar”]

Limitation: You can write in read function and vice versa.

Now what probably should happen:

  • use MonadAsk and MonadTell to narrow down MyLogState type
  • Since these are type classes, I need to add a newtype wrapper(s) and define MonadAsk/MonadTell instances (?)
  • newtype types can then be used in read / write functions to narrow down possible actions

Having recapped type class chapter, I tried to derive some instance for my state

newtype MyLogStateNew = MyLogStateNew String
derive newtype instance monadAskMyStateInt :: MonadAsk MyLogStateNew ...

, but here ends. Not sure, how to type MonadAsk with r and Monad m

Also question is, if using State monad is optimal here or if it would be more easy with ST, STRef.

Mind giving an example?

How’s this:

main :: Effect Unit   
main =
  logShow $
  flip execMyLogState "foo" $ do
    read
    write
    read
                                              
read :: ∀ m. MonadAsk String m => MonadTell Log m => m Unit
read = do
  s <- ask
  -- put "Hello world" -- does not work here
  tell ["Current state is " <> s]
                                              
write :: MyLogState Unit
write = do
  tell ["Put new state bar"]
  put "bar"

type Log = Array String
newtype MyLogState a = MyLogState (StateT String (Writer Log) a)

derive newtype instance Functor MyLogState
derive newtype instance Apply MyLogState
derive newtype instance Bind MyLogState
derive newtype instance Applicative MyLogState
derive newtype instance Monad MyLogState
derive newtype instance MonadState String MyLogState
derive newtype instance MonadTell Log MyLogState
instance MonadAsk String MyLogState where
  ask = state \s -> Tuple s s

execMyLogState :: ∀ a. MyLogState a -> String -> Array String
execMyLogState (MyLogState x) s = execWriter $ execStateT x s

(I blatantly stole the implementation of ask from the implementation of get, since they’re doing the same thing. Maybe you could even define ask in terms of get).

Note that while there’s ask (part of MonadAsk) which accesses some read-only state, there’s no such type class allowing you to put but not get. The only write-only state with a builtin type class is through the MonadTell class. So you can still call get in your write function, but you can’t call put in your read function. Or you could write your own type class for MonadPut and define your own instances for it.

I’m not saying I endorse the code snippet I’m posting, but for educational purposes, it hopefully gives you a clue how to make a monad that supports read/write state, but then go through a read-only type class (MonadAsk) to use that monad in a read-only context.

1 Like

The wrapper type I was talking about would be defined and used like this:

module Main where

import Prelude

import Control.Monad.Reader.Class (class MonadAsk, ask)
import Control.Monad.State (State, runState)
import Control.Monad.State.Trans (get, modify_)
import Control.Monad.Writer.Trans (class MonadTell, tell)
import Data.Array (length)
import Data.Tuple (Tuple(..))
import Effect (Effect)
import Effect.Console (logShow)

-- Define a generic wrapper around State that will provide separate
-- MonadAsk and MonadTell interfaces
newtype SeparableState s a = SeparableState (State s a)

-- A bunch of boilerplate to establish that SeparableState is a monad
-- because State is
derive newtype instance Functor (SeparableState s)
derive newtype instance Apply (SeparableState s)
derive newtype instance Applicative (SeparableState s)
derive newtype instance Bind (SeparableState s)
derive newtype instance Monad (SeparableState s)

-- The implementation of MonadAsk delegates to get
instance MonadAsk s (SeparableState s) where
  ask = SeparableState get

-- The implementation of MonadTell delegates to modify_
instance Semigroup s => MonadTell s (SeparableState s) where
  tell = SeparableState <<< modify_ <<< flip append

-- Every practical monad needs a way to run it
runSeparableState :: forall s a. SeparableState s a -> s -> Tuple a s
runSeparableState (SeparableState inner) = runState inner

-- Now you have your read-only function...
canOnlyRead :: forall m. MonadAsk (Array String) m => m Int
canOnlyRead = do
  s <- ask
  -- tell doesn't work here
  pure $ length s

-- ...and your write-only function
canOnlyWrite :: forall m. MonadTell (Array String) m => String -> m Unit
canOnlyWrite msg = do
  tell ["Hello there"]
  tell ["I was given:", msg]
  -- ask doesn't work here

program :: SeparableState (Array String) Unit
program = do
  len <- canOnlyRead
  canOnlyWrite (show len)

main :: Effect Unit
main = do
  let Tuple _ s = runSeparableState program []
  logShow s

However, this approach wouldn’t work for the toy example you posted. To use both canOnlyRead and canOnlyWrite in program, they need to read and write to the same type (Array String). Your example tracks two different types of state, which the MonadAsk/MonadTell pattern isn’t flexible enough to differentiate between. If you tried that by putting both your state variable and your log variable inside a record, you would have a function that only writes both types and a function that only reads both types. But your read example wants to read from one side and write to the other. This is a great example of why I said that application-specific classes are likely a better choice then the generic MonadAsk and MonadTell interfaces for even moderately complex application logic.

To explain another way: in OOP, you might be inclined to solve this problem by passing around two objects—a logger that might implement Writable<Log>, and a state manager that might implement both Writable<String> and Readable<String>. In FP, you’re only programming in one monad at a time. We have different tricks to make that one monad flexible enough to handle multiple logical domains, but most of them boil down to the OOP equivalent of having a single object that implements every interface—if you have one interface that you want to implement two different ways, it doesn’t work. So the usual approach is to define distinct interfaces instead.

Here’s how I’d design your toy example. I’m not using the generic SeparableState wrapper anymore; I’m using an application-specific monad type and application-specific classes that are instantiated for that type. But you’ll still notice similarities between this example and the SeparableState example—namely, how ‘most’ of the program (read and write, which in this example is actually a very small fraction of the program but stands in for much larger units) is kept in the dark about what the application-specific monad is exactly, and is only told specific things about what that monad can do.

module Main where

import Prelude

import Control.Monad.Reader.Trans (ReaderT, ask, runReaderT)
import Control.Monad.State (put)
import Control.Monad.State.Trans (StateT, get, runStateT)
import Control.Monad.ST (ST)
import Control.Monad.ST.Ref (STRef)
import Control.Monad.ST.Ref as STRef
import Control.Monad.ST.Global (Global, toEffect)
import Control.Monad.Writer (Writer, tell, runWriter)
import Control.Monad.Trans.Class (lift)
import Data.Array.ST (STArray)
import Data.Array.ST as STArray
import Data.Tuple (Tuple(..))
import Effect (Effect)
import Effect.Console (logShow)

class Monad m <= MonadAppLog m where
  appLog :: String -> m Unit

class Monad m <= MonadAppStateRead m where
  getAppState :: m String

class Monad m <= MonadAppStateWrite m where
  putAppState :: String -> m Unit

-- Implementation 1:

newtype ApplicationState a = ApplicationState (StateT String (Writer (Array String)) a)

instance MonadAppLog ApplicationState where
  appLog msg = ApplicationState (tell [msg])

instance MonadAppStateRead ApplicationState where
  getAppState = ApplicationState get

instance MonadAppStateWrite ApplicationState where
  putAppState = ApplicationState <<< put

runApplicationState :: forall a. ApplicationState a -> { finalState :: String, log :: Array String, result :: a }
runApplicationState (ApplicationState st) = { finalState, log, result }
  where
  Tuple (Tuple result finalState) log = runWriter (runStateT st "foo")

-- Implementation 2:
{-
newtype ApplicationState a = ApplicationState
  (ReaderT { stateRef :: STRef Global String, logRef :: STArray Global String }
    (ST Global) a)

instance MonadAppLog ApplicationState where
  appLog msg = ApplicationState do
    { logRef } <- ask
    lift $ void $ STArray.push msg logRef

instance MonadAppStateRead ApplicationState where
  getAppState = ApplicationState do
    { stateRef } <- ask
    lift $ STRef.read stateRef

instance MonadAppStateWrite ApplicationState where
  putAppState st = ApplicationState do
    { stateRef } <- ask
    lift $ void $ STRef.write st stateRef

-- Note that this type changed because everything is happening in Effect.
-- You could get around this by parameterizing ApplicationState by a non-Global
-- region, but this would mean changing more types.
runApplicationState :: forall a. ApplicationState a -> Effect { finalState :: String, log :: Array String, result :: a }
runApplicationState (ApplicationState st) = toEffect do
  stateRef <- STRef.new "foo"
  logRef <- STArray.new
  result <- runReaderT st { stateRef, logRef }
  finalState <- STRef.read stateRef
  log <- STArray.freeze logRef
  pure { finalState, log, result }
-}

derive newtype instance Functor ApplicationState
derive newtype instance Apply ApplicationState
derive newtype instance Applicative ApplicationState
derive newtype instance Bind ApplicationState
derive newtype instance Monad ApplicationState

read :: forall m. MonadAppLog m => MonadAppStateRead m => m Unit
read = do
  s <- getAppState
  appLog $ "Current state is " <> s
                                              
write :: forall m. MonadAppLog m => MonadAppStateWrite m => m Unit
write = do
  appLog "Put new state bar"
  putAppState "bar"

program :: ApplicationState Unit
program = do
  read
  write
  read

main :: Effect Unit
main = do
  let { log } = runApplicationState program
  -- or, if runApplicationState returns an Effect:
  -- { log } <- runApplicationState program
  logShow log
3 Likes

@ntwilson @rhendric Thanks very much to both of you, the code examples are fantastic!

While it would be a tad too much to code this myself currently, this gave me the exact clues I needed - learned a lot.

2 Likes