Blog post: Contexts and capabilities in Rust

Comment thread for my post, Context and capabilities in Rust.

What if Rust code could instead declare the context it needs, and the compiler took care of making sure that context was provided?

This general concept is addressed by a few different approaches in other languages:

  • Dependency injection
  • Implicit arguments
  • Effects

The approach proposed here works like implicit arguments, with some syntactic and conceptual similarities to effects in other languages that make it both more convenient and integrate better into Rust’s type system. It can also be used to implement dependency injection. This approach:

  • Has zero runtime overhead; it compiles down to the code you would write anyway
  • Statically guarantees that any context you need is available
  • Allows passing references to objects on the stack
  • Integrates with the trait system
  • Follows simple rules
20 Likes

How do you handle creating fn pointers to associated functions that are implemented requiring some with clauses? For example what if you did let deserialize_fn: fn(&mut Deserializer) -> Result<T, Error> = T::deserialize;. How is the compiler supposed to pass the context to that function? This is quite similar to a problem I found in another recent proposal of with clauses `with` clauses - #8 by SkiFire13

Ah I missed that thread, thanks. It's based on an earlier version of the proposal Niko and I put up on the async fundamentals repo.

The answer might be similar to how I was planning to deal with dyn, which is to qualify each dyn with a with clause. I don't see why we couldn't do the same for function pointers. But this naturally leads to the question of whether we need the ability to qualify all types like this. (EDIT: On second thought I don't think that's true, we only need to qualify dyn and function pointers. You couldn't construct a struct that had an unqualified fn pointer with a qualified fn pointer.)

Actually calling it would look similar to the solution for dyn, which could be to collapse all context into a single parameter that allows name-based lookup, or use a compiler-managed thread local. We can also use the approach used to implement #[track_caller] here to have reified functions with different ABIs depending on whether the caller is expecting to pass the specific context a function is expecting or just a "generic" context map.

1 Like

Another option is to disallow capturing and expressing function pointers that depend on context. (That's what I'm planning to do by default for dyn, much like dyn Trait is 'static by default, but I don't want to leave it at that.) Function pointers are generally "less fancy" than dyn so it might be simpler just to sidestep the issue altogether.

1 Like

Hmm.. and how would the invoker know that this parameter needs to be supplied at all? The invoker would have no idea if the fn being invoked takes any with parameters at all or not wouldn't it?

Yeah, that's what I was referring to when I was talking about qualifying with with clauses. The function pointer type would become something like fn(i32) -> String with(allocator = BasicArena).

1 Like

Also consider

struct Foo { .. }
impl Bar for Foo with e : Effect { ... }

let vec : Vec<Bar> = Vec::new();

// we require with e =  ... { here, don't we?
    vec.push(Foo::new());
}

// but we may actually invoke methods of trait Bar on
// elements of vec 30 minutes later in a different thread
// but oops.. we can no longer pass the
// effect from there because it's been baked in here, right?
1 Like

I guess, we just make <Foo as Bar> to have lifetime of enclosing with clause. This way we effectively get vec's content to be inacessible from without appropriate with clause.

...but we wanted effects to be pervasive, we wanted to be able to build such a vec at app startup and use it on all those other wonderful threads which would be providing locally-defined effects?

1 Like

I see, now I'm a bit confused: which lifetime obligations our vec's content gets?

In my example above? None at all.

I am saying the caller of Bar methods on elements of vec has to provide effect and that's all.

But the effect may not be static, right?

Then we introduce it somehow => assuming we don't move it(?), we borrow it, but for how long we do, and for how long we can borrow at all? Hence lifetime.

Imagine

  • Bar is Future trait
  • Foo is some specific future implementation
  • Effect is Context
  • vec is some sort of task queue

We don't want to bake Context in when we build our future. We want each executor thread to provide it's own Context for the duration of poll method, right? I think the example above is pretty clean.

How do we re-implement async infrastructure using the new mechanism?

1 Like

Thx, I got it.

Then Future gets capability \ with bound requiring Context. Perhaps we would add an executor effect later.

Suppose crate zzz you can't change goes like this

trait ZzzProblem { .. }
fn zzzSolve(Vec<dyn ZzzProblem> problems) { ... }

and you want to mimic ThreadLocal via an effect:

// your crate
trait ThreadLocalImitator { ... }
struct A { ... }
struct B { ... }

impl ZzzProblem for A {...}
impl<'a> ZzzProblem for B with threadLocalImitator : &'a ThreadLocalImitator {...}

let vec : Vec<ZzzProblem> = Vec::new();
vec.push(A{..});
vec.push(B{..});

Now you need to hand over vec to a different thread. That thread will create ThreadLocalImitator and invoke zzzSolve, but zzzSolve knows nothing of ThreadLocalImitator. How does it get passed into ZzzProblem methods implemented for B ?

Extra question: zzzSolve will be invoking methods on A and B the same way. How does it work? One needs &ThreadLocalImitator the other doesn't?

(First off: you can't have Vec<dyn Trait> in the first place. I'll assume you meant Vec<Box<dyn Trait>> or similar.)

(Second off: this post does not express approval of this idea in any way, just seeks to clarify it.)

The concept is that B is not Trait. If you write

let mut vec: Vec<Box<dyn Trait>> = Vec::new();
vec.push(Box::new(A::new()));
vec.push(Box::new(B::new()));

you get an error, saying that B does not implement Trait, because the required with context is not provided. If you write

let mut vec: Vec<Box<dyn Trait>> = Vec::new();
with(ctx = &mut ctx) {
    vec.push(Box::new(A::new()));
    vec.push(Box::new(B::new()));
}
drop(vec); // for clarity

you get an error, saying that B does not implement Trait long enough. The with block ends here (points at close bracket) and the value is later dropped here (points at drop).

If you wanted this to work, you could instead write

let mut vec: Vec<Box<dyn (Trait with(ctx: &mut Ctx))>> = Vec::new();
vec.push(Box::new(A::new()));
vec.push(Box::new(B::new()));

(or equivalent) to defer providing of the with context to later, when the trait is used. A monomorphizaton is generated to dispatch and discard any extraneous with context.

The general idea is that any time some T: Trait with(context) type is used for a bound that requires just Trait, it is either a) a hard error if it's + 'static, because that could be leaked after the with context; or b) shimmed into an opaque (T, context): Trait type with an additional lifetime that ends when the with block ends.


(Personally, I'm not a fan because of all of the knock on effects and limitations that this has.)

1 Like

Thx, it's making more sense now. One thing still not clear:

How exactly would it work? Box<dyn Trait> is a fat pointer with two parts: vtable and data. Wouldn't the shim need to be a fat pointer with 3 parts? vtable, data and context?

What’s the story for coherence here? Presumably, this opens us up for the “hashmap problem” — if impl Hash has a with clause, than we can insert into hashmap with one context, and do lookup with another, different context, which subtly breaks the semantics of Hash, while the types continue to align.

4 Likes

... at the end of the day with is tautonymous to an extra explicit parameter. You can write HashMap badly today.. HashMap utilising with parameter that way would be badly written.

Imagine you

  • have a functional language
  • all functions are pure
  • you want to add disk IO

One way to do it is to introduce an IO effect

trait IO {
    fn readByte(&self) -> u8;
    fn writeByte(&self, u8);
}

Now your pure functions can do IO. Can you write a HashMap that instead of doing the right thing is reading from disk? Yes you can. Is this a bad enough footgun to abandon effects? Don't think so.

2 Likes

Good point, I think it's a footgun worth considering. So the first way you might think to write this wouldn't compile:

let mut map = HashMap::<Key, Val>::new();

with hash_context = HashContext::new() {
  map.insert(k, v);
}

with hash_context = HashContext::new() {
  assert!(map.get(k).is_some());
}

with something like

ERROR: `Key` is required to implement `Hash` while `map` exists
note: but only implements `Hash` within this scope
LL|> with hash_context = HashContext::new() {
LL|    map.insert(k, v);
LL|> }
note: `map` is used again here
LL| > assert!(map.get(k).is_some());

In other words it goes like this: the lifetime of the hashmap is linked to the lifetime of the K: Hash where clause, which itself is linked to the scope of the first with expression. Providing another with expression later on doesn't make the hashmap valid again.

But this doesn't prevent you from doing something nested like this (which could happen by accident across function calls):

let mut map = HashMap::<Key, Val>::new();

with hash_context = HashContext::new() {
  map.insert(k, v);
  with hash_context = HashContext::new() {
    assert!(map.get(k).is_some());
  }
}

One way we can defend against this is to allow declaring non-overridable capabilities with a final keyword or similar. I think this is enforceable for any approach we'd want to take for overriding, but I could be wrong.

3 Likes