laria
laria copied to clipboard
Investigate effects/context system?
Not sure if it'd have any application for my current use case (game scripting language), but this might be cool for using Laria as a general purpose language. I've run into a few situations where effects/contexts might be useful in the Triplicata game engine (a bump allocator, a virtual filesystem abstraction, maaayyybe passing important values through to Laria bindings?), so this might be worthwhile.
Prior art
- Context and capabilities in Rust proposes a context system for Rust (later discussion suggested that
contextshould be used instead ofcapabilitysince capabilities are a separate concept, referring to things likecap_std::AmbientAuthority).- Dyon has a similar concept, current objects. Current objects differ from
contexts in syntax (~overwith), form (any type can be a current object, butcontexts must be explicitly declared), and usage (current object bindings seem to last until the end of the scope they're declared in rather than creating a new scope (similar to Koka, discussed below)).
- Dyon has a similar concept, current objects. Current objects differ from
- Koka is a research language that provides polymorphic effects; see the Koka book for details.
Contexts and effects seem to have somewhat different semantics. A context/current object is sort of like a hidden argument that's automatically plumbed through your code (using a straw syntax here):
// still hashing out the details on the module system;
// bear with me...
mod dat;
mod vfs {
pub enum Filesystem { /* ... */ }
pub struct Vfs { /* ... */ }
pub context vfs = &mut Vfs;
}
use root::{
dat::Dat,
vfs::{Filesystem, Vfs, vfs},
};
vfs fn get_text() -> string {
vfs.read_to_string("foo.txt").unwrap()
}
vfs fn hello() {
println!("{}", get_text());
}
fn main() {
let mut vfs = Vfs::new();
vfs.mount_root(FileSystem::Physical("."));
let resources = Dat::load_from_compressed(...);
vfs.mount(Filesystem::dat(resources), "resources");
// can't do this yet since `main` doesn't have the `vfs` context
// hello();
// contexts don't live in the value namespace.
// not certain if `with` blocks (create a new scope)
// or `install` statements (affect the scope that contains them)
// would be better
install vfs = &mut vfs;
// past this point, we now have the `vfs` context!
// now we can call `vfs fn`s, such as `hello`
hello();
}
Meanwhile, effects are meant to describe what a function is allowed to do. Examples of popular effects include async (allows await) and throw (unwind to raise exception). Unfortunately, it seems like effects tend to be implemented via stack unwinding. Effects might have some of the same problems as throw/try/catch, as effects are a generalization of them (but they can also resume the function that called the effect handler). It'd help if every function that uses an effect must explicitly declare that effect, though it still might cause confusion about where, exactly, effect calls originate from/are handled:
effect throws {
fn throw[E: Error](error: E) -> never;
}
throws fn foo() -> string {
throws::throw("the monitored program dumped her girlfriend");
// don't have to do anything else since `throw(...)` returns `never`
}
throws fn bar() {
println!("{}", foo());
}
fn main() {
install throws::throw = fn(error) {
println!("{e}");
abort();
};
bar();
}
Interestingly, I think contexts might be sort of equivalent to single-handler effects:
// *casually introduces a higher ranked trait bound containing a type*
context throw = for[E: Error] fn(E) -> never;
throw fn foo() -> string {
throw("OOPSY I done it");
}
throw fn bar() {
println!("{}", foo());
}
fn main() {
install throw = fn(err) {
println!("{err}");
abort();
// you could imagine that this handler could return
// something that could be used by the caller
};
bar();
}
Incidentally, here's a certain famous concept from Rust partially implemented using contexts:
// Perhaps contexts that provide nothing could be useful?
context unsafe;
unsafe fn do_something_unsafe() {
println!("fooing the bar");
}
fn main() {
// can't do this here since `main` is not `unsafe fn`
// do_something_unsafe();
// this is a case that benefits from using `with` blocks
// instead of `install`, so i've done that here
with unsafe {
do_something_unsafe();
// one could imagine that you might have
// `impl[T] Deref for ConstPtr[T]` with `unsafe fn deref`,
// which would provide one of Rust-`unsafe`'s superpowers
}
}
I'm currently leaning towards the context design with with blocks, though I'm not sure if context is the right keyword.
One feature which I'm not too sure about is generic context/effect parameters. Koka supports this already, and there's an MCP for a similar feature in Rust. I'm not entirely certain this would be useful for Laria, though functions being generic over & and &mut might be desirable.
This blog post reflecting on "Context and capabilities in Rust" points out that using non-Copy owned values for contexts is kinda weird and suggests treating them like closure captures. Not too sure about this one since with blocks look more like bindings than captures, but I'm not too sure how else to deal with this. Perhaps moved types get plumbed through like usual, but can't be used in multiple callers:
struct NonCopy;
context foo = NonCopy;
foo fn reimu() {}
foo fn tsubakura() {}
fn main() {
with foo = NonCopy::new() {
reimu();
tsubakura();
}
}
error: use of moved context: `foo`
--> src/main.rs:10:9
|
2 | context foo = NonCopy;
| --- move occurs because `foo` has type `NonCopy`, which does not implement the `Copy` trait
...
9 | reimu();
| ------- context moved into `foo fn reimu` here
10 | tsubakura();
| ^^^^^^^^^^^ value used here after move
Or perhaps they're just not allowed in the first place:
struct NonCopy;
context foo = NonCopy; //~ ERROR context types must either be `&mut T` or implement `Copy`
Declaring effects before fn might cause parsing issues: just how far should we look ahead to determine if something's a function?
a b c d e f g h i j k l m n o p q r s t u v w x y z fn foo() {}
Allowing ident to introduce a function might inhibit the introduction of new items after Laria is stabilized:
effect union;
union fn x() {}
// Look at our new untagged unions!
union U {}
//^ ERROR expected keyword `fn`, got `U`
So where should effects be declared? Koka and this rejected RFC for Rust place them alongside return value:
effect union;
effect throw = for[E: Error] fn(E) -> never;
// Koka
fn foo() -> (union, throw) () {}
// RFC 73
fn foo() effect(union, throw) {}
// This feels a bit too close to C++'s `T _() const` syntax
fn foo() (union, throw) {}
// From "Context and capabilities in Rust".
// Return type specification is optional for these two
fn foo() -> () with union, throw {}
fn foo() with union, throw -> () {}
They could also be placed after the fn keyword:
fn(union, throw) foo() {}
// this might be hard to read
fn union throw foo() {}
It seems like it'd be awkward to use with blocks with effects whose handlers are functions, such as throw. Defining the handler inline might be difficult to read:
effect throw = for[E: Error] fn(E) -> never;
fn main() {
// the lambda syntax is still in flux; there'll probably be a
// single-line shorthand, but braces are used to demonstrate
// the issue
with throw = fn(err) {
panic!("{err}");
} {
throw("too many braces");
}
}
Instead, the handler should be defined in a separate variable:
effect throw = for[E: Error] fn(E) -> never;
fn main() {
let handler = fn(err) {
panic!("{err}");
};
with throw = handler {
throw("too many lines");
}
}
Is this acceptable? It should be noted that despite using the with keyword, Koka essentially uses what I referred to as install statements for effect handlers, sidestepping this problem. With install, the above example might look like this:
effect throw = for[E: Error] fn(E) -> never;
fn main() {
install throw = fn(err) {
panic!("{err}");
};
throw("not enough braces");
}