Hacker News new | ask | show | jobs
by fwip 748 days ago
You can think of algebraic effects kind of like:

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):

    define_handler onErrorResumeNext {
      throw(msg, resume_func): { 
        println(msg)
        // YOLO, call it anyways
        withHandlers onErrorResumeNext {
          resume_func()
        }
      }
    }
    withHandlers onErrorResumeNext {
      page = getPage("https://cheese.com")
      println(page.text) // Probably uninitialized - a better  :P
    }
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.

Exceptions/hurl = exactly 0 resumptions toss = exactly 1 resumption effects = 0..many resumptions

[1]: https://share.unison-lang.org/@unison/base/code/releases/3.5...

1 comments

I see. Many thanks for this! Sounds a bit like a mixture of coroutines and dynamic scoping. Very interesting!