inko-lang / inko

A language for building concurrent software with confidence
http://inko-lang.org/
Mozilla Public License 2.0
907 stars 41 forks source link

(Re)introduce one-shot closures #347

Closed yorickpeterse closed 3 months ago

yorickpeterse commented 2 years ago

During the work on https://gitlab.com/inko-lang/inko/-/merge_requests/120, Inko briefly supported closures that could be called at most once, using the syntax fn once { ... }. This was later removed as I wasn't sure about their value.

Recently I've been spending time writing a small parsing combinator framework, mostly as a way of testing Inko's type system and to see what functionality might be missing. Several times I ran into cases where one-shot closures would be necessary. The simplest example is when you have Option A and Option B, and you want to map them into (A, B) like so:

a.then fn (lhs) { b.then fn (rhs) { Option.Some((lhs, rhs)) } }

This won't work because then the b.then closure captures lhs, it exposes it as mut A, meaning we end up with an Option[(mut A, B)] instead of Option[(A, B)].

There are probably other cases where one-shot closures are useful. In fact, I think that if we were to reintroduce them, we should recommend using them by default unless you want to call a closure multiple times.

Syntax

I prefer not to introduce the once keyword for just this case, but it is the most obvious/clear syntax:

fn once (arg) { ... }

I'm also not sure yet how to combine this with fn move: do we use fn once move or fn move once? Or does fn once simply imply fn move?

Another option is to use fn! instead of just fn, but this syntax isn't self-explanatory.

Semantics

A one-shot closure's call method is generated as fn move, instead of fn mut. The captured variables are exposed using their original types (i.e. a captured T is exposed as T, not mut T). An fn isn't compatible with fn once and vice-versa.

Tasks

yorickpeterse commented 3 months ago

Moving closures introduce a caveat: if you want to move an owned value out of a closure, you need to use fn move to capture by moving. In practice, this means that every time you want to capture an owned value and move it out of the closure, you have to use fn move, only using a bare fn if you're moving borrows at which point you might as well use a normal closure.

From the perspective of somebody calling a closure, there's also not a clear benefit to only being able to call the closure once. That is, methods such as Option.then work just fine without a moving closure (as in, it doesn't matter to them how many times you can call the closure).

yorickpeterse commented 3 months ago

Another note: fn move currently results in all captures being captured by moving. But what happens if we want to borrow captures A and B, and only move C? Right now there's no way of doing that.

yorickpeterse commented 3 months ago

One option is to make the list of captured variables explicit, i.e. something like this:

fn (arg1, arg2) move (capture1) { capture1.do_something(capture2) }

Here capture1 would be captured by moving, while any other captures are captured by borrowing. This removes the need for doing this instead (a pattern not uncommon in Rust):

let capture2 = ref capture2

fn move (arg1, arg2) { capture1.do_something(capture2) }
yorickpeterse commented 3 months ago

Another option is this: if a closure moves some variable A, it's inferred as a moving closure and captures the variable as an owned value instead of a borrow. The problem now is that if we want to actually borrow it (e.g. pass a borrow of it to a generic argument T), we need a way to explicitly signal that.

To put it differently, detecting whether a variable should be captured as a borrow or owned value is going to be tricky/prone to error.

yorickpeterse commented 3 months ago

I'm going to leave this be for the time being. Introducing one-shot closures introduces several challenges/complications, both for the compiler and the user. For example, for a one-shot closure to work it must capture variables by moving them. This means that a one-shot closure is always fn move but an fn move isn't necessarily a one-shot closure, which is confusing.

In addition, our current "all or nothing" approach to capturing means that you can't have a one-shot closure that borrows certain values while taking ownership of others, instead they're either all borrowed or moved. This will make working with one-shot closures more difficult, resulting in a pattern similar to Rust as described in https://github.com/inko-lang/inko/issues/347#issuecomment-2263515315.

For cases where we have nested closures, such as the Option.then example, I think we're better off with dedicated methods to solve those issues, rather than introducing more complexity in the type system. One can also use a regular match expression if necessary.

If at some point in the future it's deemed necessary to have one-shot closures, we can revisit this.