🍰 – Effects for Lua
Cake is a simple library for using one-shot effects in lua.
local cake = require"cake"
local http = require"socket.http"
-- first define all the effects you want to use...
local HttpRequest = cake.effects {
get = true
}
-- Now write some effectful code
local function size_example()
-- Ask your caller to perform the `HttpRequest.get` effect.
local content = coroutine.yield(HttpRequest.get"https://example.com")
return ("https://example.com is %d bytes"):format(#content)
end
-- To run it we need to handle all the effects:
local function handler(effect)
-- The (~) operator on effects matches them to their constructor
if effect ~ HttpRequest.get then
local url = effect:args() -- any arguments passed into the constructor are available here!
return http.request(url)
end
end
local function main()
local result = cake.run(handler, size_example)
print(result)
end
Reference
cake.run(handler, f, ...)
anything Runs an effectful function f
against the effect handler handler
, returning the final results of f
.
-
function(effect, cont)
handler
See the notes on effect handlers for more information on this parameter. -
function
f
An effectful function. -
anything
...
Arguments tof
.
cake.effects(t)
table (effect constructors) Accepts a specification of effects and returns a table with the same structure, where all leaves are effect constructors.
- table
t
A table whose structure describes the effects you want to implement.
E = cake.effects{
foo = {bar = {baz = 1}}, -----> foo = {bar = {baz = effect"foo.bar.baz"}}
qux = true, -----> qux = effect"qux"
quux = "something" -----> quux = effect"quux"
}
Effect constructors can be called to create an effect, and this value can then be yielded to request the handler performs it.
coroutine.yield(E.qux(1,2,3))
cake.effects(name) (t)
table (effect constructors) If called with a string as the first argument, returns a namespaced function for creating effect constructors:
local Delay = cake.effects "Delay" {
wait = true,
cancel = true
}
cake.simple
table (effects) This table can be used to construct simple (i.e valueless) effects.
local A = cake.simple.A
function handler(effect)
if effect == A --[[effect ~ A]] then
return "effect a"
end
end
cake.run(handler, function() print(coroutine.yield(A)) end)
-- "effect a"
constructor(...)
table (effect) These objects represent effects to be performed by handlers, and are created by effect constructors. They are simply tagged tables with array content that can be unpacked via a method.
local Delay = cake.effects "Delay" {
wait = true,
cancel = true
}
Delay.wait(5.0) -- constructs an instance of the "wait" effect.
effect ~ Constructor
table (effect) | nil Matches an effect with a constructor; returning the effect if it was created by that constructor.
effect.name
string This value is equal to the name of the effect; and can also be used for matching.
effect:args()
anything Unpacks an effect's captured arguments.
local function handler(effect)
if effect ~ E.qux then
local a,b,c = effect:args()
return a + b + c
end
end
handler(effect, cont, abort)
anything... An effect handler is a function of three parameters: an effect, an effect continuation, and an abort function. If you want to handle an effect, then you do so within this function body
and should return any results of the effect's computation so that they can be fed back
to the requestee. You may also use the cont
callback to respond instead of returning,
but if you want to respond asynchronously you must return the wait
sentinel value.
If you return additional arguments after cake.wait
, they will be propagated to the place where control resumes if possible, for example if you return cake.wait, "foo"
for MyAsyncEffect
then cake.perform
will return "foo"
when MyAsyncEffect
is handled.
If this function returns nothing then effect resolution is passed on to an outer coroutine. The abort function can be used to cancel the effectful computation:
local MyEffect = cake.simple.MyEffect
local function handler(effect, _, abort)
if effect ~ MyEffect then
abort("aborted")
end
end
cake.run(handler, function() coroutine.yield(MyEffect) return 5 end)
--- the run call returns "aborted"
When a nested effect is aborted the inner computation is affected, not its parent:
local x, y
x = cake.run(handler, function()
-- cake.pass is just a predefined noop function; forcing resolution to defer to the outer parent.
y = cake.run(cake.pass, function() coroutine.yield(MyEffect) return 5 end)
-- control flow resumes here after the handler aborts the 'Test' effect.
return 6
end)
print(x, y) -- 6 "aborted"
Notes on nested effects
When managing the control flow of effectful computations you may
find that nesting cake.run
calls is required. There are multiple helpers
defined which make the various operations on effects easier to manage.
cake.perform(f)
anything Launches a new cake.run
call in a coroutine, using the currently applied handler.
This is useful if you need to prevent effects from blocking till resolved:
local cake = require"cake"
local copas = require"copas"
local Delay = cake.effects "Delay" {_wait = true, cancel = true}
function Delay.wait(sec) return Delay._wait( sec, (coroutine.running()) ) end
local waiting = {}
local function handler(effect, cont, abort)
if effect ~ Delay._wait then
local sec, thread = effect:args()
local t = copas.timer.new {delay = sec, callback = function(cont) return cont(true) end, params = cont}
waiting[thread] = {t, abort}
return cake.wait, thread -- returned by the .perform call
elseif effect ~ Delay.cancel then
local thread = effect:args()
if waiting[thread] then
local t, abort = table.unpack(waiting[thread])
waiting[thread] = nil
t:cancel()
abort(true)
return true
else
return false
end
end
end
local function main()
local thread = cake.perform(function() -- inherits our handler
coroutine.yield(Delay.wait(5.0))
print("5 seconds elapsed")
end)
coroutine.yield(Delay.wait(3.0))
print("Waited 3 seconds instead!")
coroutine.yield(Delay.cancel(thread))
end
cake.start(handler, main)
copas()
Notes on handling effects
When handling effects using cake.wait
you must take care with how you use the cont
and abort
functions; they're not actually functions, but instead are callable values.
This may cause a problem when passing the cont
"function" into a system callback directly, for example into an event loop written in C. I apologize for this inconvenience.
Value propagation can get confusing when using cake.wait
, because control has to switch contexts so frequently. I recommend creating some simple effects and observing how the values are returned at various points (i.e what is returned from cont
after you've cake.wait
-ed? What about synchronous abort
).
If you decide to call cont
while control is still held by the handler the computation
is processed normally so that a value can be returned for cont and whatever you return from the handler is therefore ignored. You cannot call cont
multiple times synchronously because the computation was completed by the first call.