I never understood "algebraic effects". But I understand the Hurl docs. Are "algebraic effects" basically the "toss" keyword from Hurl? If so, how are "algebraic effects" stronger than the "toss" keyword?
As mentioned in another comment here, the unwind is expensive. That is, searching back through the stack finding the original call site that triggered the exception. How do algebraic effects handle that performance hit?
From callsite Foo, I call out to a function with a known name, Bar, with one or more parameters. The runtime (or sufficiently smart compiler) searches upwards in scope, for a handler Baz that provides the function with that name. Baz's Bar is then called, with both the provided parameters, and, crucially, a function that is "the rest of Foo."
So, an implementation of Exception with effects would ignore the resume, and look something like:
define_effect Exception {
// only one function provided by Exception, but could have more
throw(String)
}
define_handler printExceptions {
throw(msg, resume_func): { println(msg) }
}
define_func getPage(url) {
request = http.get(url)
if not request.ok { throw("Could not download") }
return page
}
// main entrypoint
withHandlers printExceptions {
page = getPage("https://cheese.com")
println(page.text)
}
But you could also write an "on error resume next" handler for Exception. Exceptions thrown with this handler would be equivalent to toss. (in real life you'd probably write a different effect, rather than re-using the Exception/throw effect):
Breaking away from the Exception example, two cool examples are:
// Stream - potentially infinite and/or asynchronous iterables
// basically just like python generators
define_effect Stream {
emit(item)
}
define_handler toList(accumulator) {
emit(item, resume_func): {
if item == nil {
return accumulator
} else {
accumulator.append(item)
withHandlers toList { resume_func() }
}
}
}
define_handler take(how_many) {
emit(item, resume_func) {
if how_many == 0 {
emit nil
} else {
withHandlers take(how_many-1) { resume_func() }
}
}
}
define_func fib_stream() {
a, b = 0, 1
loop {
emit a
a, b = b, a+b
}
}
// Usage
first_five = withHandlers toList {
withHandlers take(5) {
fib()
}
}
println("The first five fibonacci numbers are", first_five)
And this one I'm just going to lift from the Unison documentation [1]:
Each.toList do
a = Each.range 0 5
-- beginning of resume_func f_A
b = each [1, 2, 3]
-- beginning of resume_func f_B
guard (a < b)
-- beginning of resume_func f_C
(a, b)
-- yields
[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]
Note that this has the same semantics as:
results = []
for (a = 0; a < 5 ; a++) { // for each a, call f_A(a)
foreach b in [1, 2, 3] { // for each b, call f_B(b)
if not a<b { continue } // if a<b, call f_C(a, b)
results.append((a, b))
}
}
And it is possible to do this, because these resumable functions can be resumed an arbitrary number of times - it's up to the handler. They also can pass parameters back to the resume_func.