In most functional programming languages, a signal or event type is defined as such:
newtype Event a = Event ((a -> Effect Unit) -> Effect (Effect Unit))
The contract is saying: "If you give me a way to report events a -> Effect Unit
, I'll give you a way to turn on and off the event stream Effect (Effect Unit)
. The outer effect turns on the stream, and the inner one turns it off.
This definition has been used to build many functional frameworks, including purescript-hyrule
, purescript-halogen-subscriptions
and almost all of the Rx implementations.
The definition has also given rise to a distinction between hot and cold events. A "hot" event is one through which information is already flowing when you subscribe to it, whereas a "cold" event starts the flow of information upon subscription. The Rx documentation does a good job describing this in the “Hot” and “Cold” Observables section.
In this article, I'll show some examples of hot and cold events. I'll then argue that cold events are evil and represent 99% of the problems folks have with event-based libraries. Lastly, I'll propose a definition of event that only admits hot events.
Events hot and cold
In this section, I'll present two common examples of hot and cold events as well as an example of an event that straddles this divide, much to the chagrin of many a developer.
Some like it hot
In PureScript, a hot event is created with the create
function.
create :: forall a. Effect { event :: Event a, push :: a -> Effect Unit
When using create
, you get an event and a pusher. This event is hot, meaning that anything that subscribes to it only gets things pushed to push
after the subscription is created.
Hot events are useful when, for example, you're building an event bus. You send the pusher to whatever corner of your application needs to push information, and you subscribe wherever you need to receive it.
Purity is cold
The most common example of a cold event is pure
via its applicative instance.
instance Applicative Event where
pure a = Event \k -> k a *> pure (pure unit)
Here, every time someone subscribes to an event by giving it a k
, it immediately emits an a
. It is the epitome of a cold event, replaying the exact same sequence for each subscription.
Intervals
Whenever a programmer encounters one of PureScript's event libraries for the first time, if they spend enough time working with it, they eventually encounter:
interval :: Int -> Event Instant
This emits the current time at intervals of Int
milliseconds.
interval
is cold, meaning every time you subscribe to it, a new interval starts. And, given the finickiness of most programming languages, even if you do traverse interval [5, 5, 5, 5]
, which in theory should create four perfectly aligned events that go off every 5 milliseconds, the start times will be slightly offset and the gaps will only get worse as time goes on. This is because, instead of using the same interval 5 times, we are creating four different intervals.
Cold events are evil
Robert Frost said it best:
Some say the world will end in fire,
Some say in ice.
From what I’ve tasted of desire
I hold with those who favor fire.
But if it had to perish twice,
I think I know enough of hate
To say that for destruction ice
Is also great
And would suffice.
Cold events destroy programs. They potentially tuck away all sorts of side effects in the subscription phase, which happens during the first Effect
of Effect (Effect Unit)
in the definition of Event
. While these side effects are cleaned up on unsubscription, they encourage composing together objects in a pure way that, at scale, dangerously masks what side effects are actually going on.
To drive the point home, consider a "today's news" event that, on subscription, pays 5 cents to your local newsmonger and starts the flow of headlines. On unsubscribe, it stops the flow of headlines.
Can you spot what's different and what's the same between these two programs?
Program 1
void $ subscribe (sequence (repeat 100_000 today'sNews) log)
Program 2
do
{ event, push } <- create
void $ subscribe today'sNews push
subscribe (sequence (repeat 100_000 event) log)
In both programs, we'll have each headline from today's news spammed to the log 100_000 times. But in the second program, we only pay 5 cents for today's news, whereas in the first one, we pay 5,000 dollars.
Now, imagine that you're working on a large team and you're working on an API that takes an event
as a parameter. Is it safe to subscribe to? Will doing so incur a cost? Scratch your car? Kick your dog? This is why cold events, like Robert Frost's ice, are the great destroyer. They are footguns that do nothing that a hot event can't do with a bit of proper planning.
We can do better!
So why did the magnanimous inventors of events create cold events? Let's look at the signature of Event
again:
newtype Event a = Event ((a -> Effect Unit) -> Effect (Effect Unit))
The thing that makes cold
events so dangerous is the Effect (Effect Unit)
, where we can introduce an arbitrary side effect upon subscription and another one upon unsubscription. But we do need side effects on subscription and unsubscription, even for hot events. Let's look at the implementation of a function like create
, which as we remember from above is how we make a hot event.
create
:: forall a
. Event { push :: a -> Effect Unit, event :: Event a }
create_ = do
-- here, FOST is Foreign.Object.ST
-- which allows you to work with mutable objects
subscribers <- liftST FOST.new
idx <- liftST $ STRef.new 0
pure
{ event:
Event \k -> liftST do
rk <- STRef.new k
ix <- STRef.read idx
void $ FOST.poke (show ix) rk subscribers
void $ STRef.modify (_ + 1) idx
pure $ liftST do
void $ STRef.write mempty rk
void $ FOST.delete (show ix) subscribers
, push:
\a -> do
o <- liftST $ FOST.unfreeze subscribers
for_ subscribers \rk -> do
k <- liftST $ STRef.read rk
k a
}
In event
, we are adding a listener to a subscriber cache on subscribe and removing it on unsubscribe. Then, on push
, we iterate over the subscriber cache and push our value to each subscriber.
It's clear, then, that even in hot
events, we need effects on subscription and unsubscription. But wait a second, do we really need Effect
as our effect? Looking at the code, it looks like both the subscriber and unsubscriber are lifted from ST Global
to Effect
. So what if we dispensed with Effect
and instead made the signature of Event
.
newtype Event a = Event ((a -> Effect Unit) -> ST Global (ST Global Unit))
We still have an Effect
in the first argument to Event
because push
in the example above needs to be effectful and it can contain arbitrary side effects (like, for example, pushing the result of an API call). However, the subscription and unsubscription mechanism is entirely achievable in the ST Global
monad because it is only ever creating and manipulating references to stateful objects.
As promised, this is the new definition of Event
that I offer for folks's humble consideration. In this setup, anything of type Event a
can only ever be hot because it's impossible to trigger any effect on subscription on unsubscription. The worst conundrum we can get in if we do something exotic like roll our own create
is to forget either subscribing or unsubscribing, but this is far more anodine than the bomb detonators and Rick-and-Morty-esque "one is now zero" mechanisms one can implement using Effect
.
So if we're bidding a farewell to cold events, what are we losing. The most bittersweet farewell is to Event
's Applicative
instance because we no longer can trigger an event upon subscription. But did we ever really need applicative? Applicative
only makes sense insofar as we have some computation where we need to "prime the pump" of an event so that there's something there when another event goes off. For example, let's look at the definition of fold
, which folds over an event in time to create an accumulator.
-- | Fold over values received from some `Event`, creating a new `Event`.
fold :: foral a b. (b -> a -> b) -> b -> Event a -> Event b
fold f b e =
fix \i -> sampleOnRight (i <|> pure b) ((flip f) <$> e)
Here, we use pure
in so that the following sequence of events (ha!) occurs:
-
pure b
goes off. -
e
goes off. -
sampleOnRight
responds toe
going off as it drives the sampling, and picks up thatpure b
has already gone off, so it emits. - This emission causes
i
(the fixed point) to emit, as the input is the output. - When
i
emits, ase
hasn't emitted again, we wait for the nexte
. - Now when
e
emits again, it will combine with its previous incarnation (akai
). Thepure b
will never go off again: it just primed the pump so that the loop could start.
But do we really need pure
here? We don't need an event to trigger immediately upon subscription, we just need it to trigger sometime before e
. Or in other words, if 0
is the moment a system is subscribed to and t
is the moment e
fires, for the six-part sequence above to hold, we need pure b
to fire anytime between 0
and t - ϵ
, where ϵ
is arbitrarily small. So what can get us an event at t - ϵ
? e
can!
"Woah woah waoh," you may protest, "Hold your horses. If e
happens at time t
, how the heck can it happen at t - ϵ
? Are you a time traveler?" Yes, I am. The future sent me to your era to teach you about FRP. But no, I didn't need to use that superpower to conjure an event at time t - ϵ
from an event at time t
. Because events are read from left to right, any event that fires on the left side of an expression will, fire before the right side. This is because, at the end of the day, events just translate to imperative-looking effectful code, and two bits of effectful code that execute "simultaneously" still have to run before another. To reinforce that, let's look at the definition of sampleOnRight
.
sampleOnRight :: forall a b. Event a -> Event (a -> b) -> Event b
sampleOnRight (Event e1) (Event e2) =
Event $ \k -> do
latest <- Ref.new Nothing
c1 <-
e1 \a -> do
Ref.write (Just a) latest
c2 <-
e2 \f -> do
o <- Ref.read latest
for_ o (\a -> runEffectFn1 k (f a))
pure do
c1
c2
If e1
and e2
get an emission at the same time, the top of the do
block will run before the bottom of the do
block. That's the nature of imperative code.
So we almost have our Applicative
instance back, but not quite. Instead of pure b
, we could write:
fold :: foral a b. (b -> a -> b) -> b -> Event a -> Event b
fold f b e =
fix \i -> sampleOnRight (i <|> (e $> b)) ((flip f) <$> e)
Now the e
on the left will fire slightly before the e
on the right and we have our event at time t - ϵ
. But we have another problem now. This does much more than pure
because it will keep firing every time e
fires. We only want it to fire once. Drats. So how do we pull that off? Again, ST
to the rescue!
once :: forall a. Event a -> Event a
once (Event e) =
Event $\k -> do
latest <- STRef.new Nothing
u <- STRef.new $ pure unit
c <-
e \a -> do
o <- liftST $ STRef.read latest
case o of
Nothing -> do
void $ liftST $ STRef.write (Just a) latest
k a
liftST $ join (STRef.read u)
Just _ -> pure unit
void $ STRef.write c u
o <- liftST $ STRef.read latest
case o of
Just _ -> c
_ -> pure unit
pure do
c
We can use ST
to monitor how many times an event is invoked and unsubscribe it automatically after the first emission. With this, we finally have something like pure
back:
fold :: foral a b. (b -> a -> b) -> b -> Event a -> Event b
fold f b e =
fix \i -> sampleOnRight (i <|> once (e $> b)) ((flip f) <$> e)
So the most important cold
event, pure
, is achievable with respect to any arbitrary event e
because we can always emit an event slightly before e
using once
that will achieve the same effect as pure
did.
As for other cold events like interval
or today'sNews
, we can always create something akin to a subscription or unsubscription in an Effect
block. Now you may protest: "I'm working with Event
s and I'm not in the Effect
monad. How am I gonna do something like interval
if I need to be in Effect
. The answer is that, if you are working with events, you'll have to subscribe to them at some point, and when you subscribe to them, it will be in an Effect
monad. So long as you can pipe information through your system from this Effect
monad, for example by using a Reader
over Event
, you're fine.
So, in conclusion, there is nothing you can achieve with cold events that you can't achieve in a hot world with a bit of elbow grease. And, for the price of that grease, you will avoid a plethora of pitfalls plus have a more consistent and manageable API.
Top comments (0)