Futuristic Rust: context emulation
Following recent discussion of contexts and capabilities, let us explore a potential design within current reach of Rust.
Exploring concrete desugaring can help us find the limits and answer some of those difficult questions that were raised. This isn't a concrete proposal, rather an attempt to emulate contexts in today's stable Rust.
A couple of notes
-
Here's the playground which I used for exploration (warning: very NSFW).
-
The following approach can be summarized as "capability is a variable living on the stack and passed to you by the caller as an extra parameter". This is not the only possible interpretation. (Update 28.12.2021: clarification)
-
We will use global allocator as a strawman for typical use. Allocation is one of the more pervasive ambient capabilities in every program, so it makes for perfect target to test the design. Especially, we would like to consider repercussions of switching from "old" global-
static
-based implementation to new shinyalloc
capability.
Representation
Capabilities
In order to encode presence of capability we need to lift it into type system. The easiest way to achieve that is newtype wrapper:
capability alloc: GlobalAllocator;
expands into
struct __alloc(GlobalAllocator);
Very straightforward.
Traits: context
We should be able to call a function from different places with potentially different contexts,
so it makes sense for context to be generic.
On the other hand, we also need to be able to extract capabilities from it - that's the whole point.
We can achieve this with Get
trait:
trait Get<'a, T> {
fn get(&self) -> &'a T;
}
It is important to bundle lifetime along the reference - we don't want to be bound to lifetime of context itself.
You may ask, what about mutable references or taking by value? It seems like very natural extensions and indeed are already proposed in this post, but both introduce a big complication: we need to be able to move out of context. This is not easy to achieve while keeping all the machinery working and probably warrants a separate exploration post. For now, let's swipe it under the rug.
It is probably far from final design, but this simple trait should be able to get us started.
Combining with previous section we can already express capability requirements as normal where
blocks:
fn f<Cx>(args: (), cx: Cx)
where
for<'a> Cx: Get<'a, __interner>,
{
let alloc: &__interner = cx.get();
// We can now use `alloc` to allocate, nice!
}
Traits: CxFn*
family
In Rust functions and operators are expressed as traits, so we will probably need traits for our new functions too.
Let's call new traits CxFn
, CxFnMut
and CxFnOnce
(or CxFn*
family) standing for "contextual function".
I can already sense bikeshedding team mobilize.
They closely follow Fn*
family for obvious reason and defined like following:
trait CxFnOnce<Args, Cx> {
type Output;
fn cx_call_once(self, args: Args, cx: Cx) -> Self::Output;
}
trait CxFnMut<Args, Cx>: CxFnOnce<Args, Cx> {
fn cx_call_mut(&mut self, args: Args, cx: Cx) -> Self::Output;
}
trait CxFn<Args, Cx>: CxFnMut<Args, Cx> {
fn cx_call(&self, args: Args, cx: Cx) -> Self::Output;
}
This looks quite natural: we preserve existing relationship within Fn*
trait family and traits look very much alike
just with an addition of extra Cx
parameter.
There are probably a few points which capture your attention.
First, it may feel more appropriate to put Cx
as generic bound on method instead of trait,
but that gives us wrong semantics.
Cx
param on method says "function is callable with every possible Cx
as long as trait is implemented",
however this is wrong.
What we want to say "function is callable only with good Cx
",
i.e. it needs to conditionally implement trait depending on context.
Second, we take Cx
by value.
This is analogous to normal arguments, any kind of more sophisticated ownership control will come from individual
fields inside the context.
Desugaring
Concrete functions
Let's put it all together:
fn foo()
with &alloc
{
let mem = alloc.allocate()
}
becomes
struct __foo;
impl<Cx> CxFn<(), Cx> for __foo
where
for<'a> Cx: Get<'a, __alloc>,
{
fn cx_call(&self, (): (), cx: Cx) {
let __a: &__alloc = cx.get();
let alloc = &__a.0;
let mem = alloc.allocate();
}
}
I omitted implementations of CxFnOnce
and CxFnMut
as they are analogous.
I will do so for future examples too to keep it brief.
Observations:
-
All functions taking contexts are generic by necessity, but we already implied that.
-
Capabilities translate into trait bounds, so you must specify capability if you use it directly. This is very much like Rust, it forces you to explicitly state your intentions.
-
You must explicitly specify desired ownership for every capability. I use fictional
&alloc
syntax (by analogy withself
; you can imagine how&mut alloc
andalloc
would work). This is again analogous to normal parameters. -
I sneaked an extra feature into the example and the expansion should give you a clue. This is Rust, so what's your first thought when you see a reference? Right, where's the lifetime?! We are pampered by compiler who elides them in so many places, but this is new territory.
In this case, there is no need to trace lifetime of
alloc
throughfoo
(it returns a unit type) which is expressed in HRTB bound:alloc
's reference can have any lifetime, we don't care. -
However, if
foo
returns anything referring toalloc
it must be generic overalloc
's lifetime:fn foo<'a>() -> &'a [u8] with &'a alloc { // ... }
This is part of the reason why explicitly specifying desired ownership is so important: compiler needs to know this information in order to properly calculate lifetimes.
Constraint propagation
The interesting part is how we propagate constraints up. Let's take a look:
fn bar() {
foo()
}
translates into
struct __bar;
impl<Cx> CxFn<(), Cx> for __bar
where
__foo: CxFn<(), Cx>
{
__foo.cx_call((), cx)
}
The magic happens in where
clause.
Normally in generic code if a function has Cx: Trait
bound you replicate it on caller,
but that states constraint explicitly as opposed to smuggling it through.
Instead, we do something rather roundabout: we just require that foo
is callable with current context.
Compiler is smart and will deduce required bound on bar
from bound on foo
.
There is a peculiar thought to be had here.
Unlike with previous case this extra bound on bar
is added silently which can be upsetting to explicit gang.
We can consider to explicitly write it in bar
's signature
fn bar()
with foo // some arbitrary syntax
{
foo()
}
but this will probably take it to untenable level of verbosity besides being a SemVer hazard.
It will also look somewhat weird to read, we write: foo
must be callable to call bar
.
Newcomers without knowledge of contexts will likely get greatly confused.
Traits
Traits are difficult. Major part of this difficulty is that we don't have a natural syntax to express methods as function objects.
We can surmise that applying with
on trait definition simply reapplies bound to all methods -
there is no shared Cx
type at trait level, so this is the only possible behaviour.
But with
on trait implementation looks like a huge can of worms,
so let's swipe it under that rug too for the time being.
Context creation
One last bit to look at is context creation.
with alloc = GlobalAllocator::new() {
foo()
}
becomes
// This is the context from outer scope.
let __outer_cx: __OuterCx;
{
let alloc = __alloc(GlobalAllocator::new());
struct __ScopedContext<'a, 'outer, OuterCx> {
__outer: &'outer OuterCx,
alloc: &'a __alloc,
}
impl<'a, 'outer, OuterCx> Get<'a, __alloc> for __ScopedContext<'a, 'outer, OuterCx> {
fn get(&self) -> &'a __alloc {
self.alloc
}
}
impl<'a, 'outer, OuterCx> Deref for __ScopedContext<'a, 'outer, OuterCx> {
type Target = OuterCx;
fn deref(&self) -> &Self::Target {
self.__outer
}
}
let __cx = __ScopedContext {
__outer: &__outer_cx,
alloc: &alloc,
};
__foo.cx_call((), __cx)
}
Quite straightforward, again.
Deref
impl allows us to "inherit" getters from outer context.
Don't worry about overlapping Get
implementations between two contexts: requested method will be tried against
current context first before dereferencing.
The mechanism at play here is resemblant of
autoref specialization.
Interaction with other language parts
The fun part.
Fn*
traits
Because Fn*
traits don't accept context parameter, we can think of them as contextual functions callable
with empty context.
This brings us to redefinition:
trait FnOnce<Args>: CxFnOnce<Args, ()> {
fn call_once(self, args: Args) -> Self::Output;
}
trait FnMut<Args>: FnOnce<Args> + CxFnMut<Args, ()> {
fn call_mut(&mut self, args: Args) -> Self::Output;
}
trait Fn<Args>: FnMut<Args> + CxFn<Args, ()> {
fn call(&self, args: Args) -> Self::Output;
}
impl<Args, T> FnOnce<Args> for T
where T: CxFnOnce<Args, ()>,
{
fn call_once(self, args: Args) -> Self::Output {
self.cx_call_once(args, ())
}
}
impl<Args, T> FnMut<Args> for T
where T: CxFnMut<Args, ()>,
{
fn call_mut(&mut self, args: Args) -> Self::Output {
self.cx_call_mut(args, ())
}
}
impl<Args, T> Fn<Args> for T
where T: CxFn<Args, ()>,
{
fn call(&self, args: Args) -> Self::Output {
self.cx_call(args, ())
}
}
Even blanket impls are possible!
We expect implementation of CxFn*
to be generic over all possible Cx
it can accept,
so it is only required to test one.
Unit type is perfect.
Observations:
- Every function now implements
CxFn* + ?Fn*
bound instead ofFn*
. - Which means
CxFn*
trait is now defining characteristic of being a function, notFn*
. I guess it's a good thingunboxed_closures
andfn_traits
are not stabilized? Also writing function objects just became even harder. - Most functions just stopped being
Fn*
too: our global allocator capability made sure of that. We will look at repercussions in a second.
Closures
The defining mechanic of closures is capture of local variables. Context is also desugared into a local variable, so it should be able to hack into existing capture mechanism. This feels like most natural behaviour, and it is likely to align with user expectations.
This brings important implications:
- Closures always implement
Fn*
. This is a very important property to preserve (at least by default) otherwise all callback-based code will probably break. - Therefore, closures is the primary mechanism for constructing
Fn*
functions. - Calling a closure represents discontinuity in context propagation.
- Closures have to rely on compiler magic to capture minimal context, but that is already expected for normal captures. Still it potentially presents a bit more work for compiler due to context bundling/unbundling.
- Context is part of closure capture, which means automatic propagation of lifetimes and auto-traits.
Latter is really cool to get out of the box. Imagine writing this code:
fn foo() -> impl Fn() {
with cx = MyContext::new() {
|| cx.do_something()
}
}
Compiler can immediately flag this using already existing error:
"[E0373]:
closure may outlive the current function, but it borrows cx
...",
and suggest you explicitly move context into closure instead.
But what about contextual closures?
Surely, there is some madman out there already contemplating a use for them.
Unfortunately it implies we are able to construct a closure generic over Cx
type,
but we will need to figure out how to make generic closures first.
I haven't seen any RFC on the subject, but it may be in the works somewhere.
Callbacks
Callbacks can choose whether they want to propagate their own context or not.
Because whole ecosystem currently lives with Fn*
, this will probably become the golden standard.
Thanks to alloc
, it is also almost impossible to use normal functions as callbacks, which can break existing code.
Mitigation is easy: just wrap your callback in closure so it captures all necessary context.
Contextual callbacks is a potential direction for future, but without contextual closures such API is hard to use on caller side.
Multithreading and async
Now, what about sending functions to other threads?
Currently thread::spawn
is defined as
pub fn spawn<F, T>(f: F) -> JoinHandle<T>
where
F: FnOnce() -> T,
F: Send + 'static,
T: Send + 'static,
{
//...
}
and I think it doesn't have to change.
You can think of contexts as extra information necessary for computation which is passed to a function by its parent.
But by sending f
to another thread it itself becomes the root of call tree and single source of truth;
it doesn't have a parent to ask about (and no, main
doesn't count - it may have terminated long ago!).
It should have all necessary information baked in by the time we send it over.
And this is exactly what FnOnce
bound implies: function is self-sufficient.
Scoped threads can provide a parent function to ask for context,
but conceptually it at most lifts 'static
bound on f
.
Context still needs to be shared and passed over somehow,
so it makes sense to bake it into function call or function object itself,
which leads to FnOnce
bound once more.
By extension, main
should also implement Fn
but that can be argued, we will come back to this point in a moment.
What about Send
(or 'static
)?
Send
on the closure translates naturally onto Send
on captures, which includes Cx
type -
and compiler can take it from there.
On the other hand, function can directly set a capability instead of capture, leading to a thread-local value.
This is a very nice result, programmer is still in control of what is happening:
every capability can be shared or thread-local depending on how you use it.
Situation with async is mostly identical.
Visibility
Visibility leans into already existing visibility for types and works just like you expect. This snippet
mod private {
// not pub
capability interner: Interner;
pub fn foo()
with &interner
{}
}
causes public function foo
to have where Cx: Get<__interner>
block on CxFn
impl.
But that is already flagged by compiler:
"[E0446]: private type __interner
in public interface".
Which implies that any public functions have to take care and properly set every private capability in case they want to use it or call any other function that do. Great!
Defaults
What about global defaults?
Most programs are probably content having a global allocator set for it by std
.
Can we do this?
Sure, there are two simple steps:
- Provide initializer value.
It must be const evaluatable, remember
rule 1.4:
no life before
main
. - Write
with interner = Capability::default()
on top of your function.
Wait, what? Where did my default go? Isn't setting it by hand defeats the point?
To understand the problem, consider this example:
mod private {
capability interner: Interner = Interner::new();
pub fn foo() {
interner.intern("s");
}
pub fn bar() {
with interner = Interner::other() {
foo()
}
}
}
fn main() {
bar()
}
Alright, let's unpack.
First, interner
is private to module.
As we already deduced, it cannot appear in public APIs.
Second, compiler sees public foo
which has unsatisfied capability interner
, so it must be set before the call.
However, when foo
is called from outside, interner
cannot be part of accepted context due to visibility,
so interner
can only be set inside foo
.
Third, interner
has a default, so user expects this to be automatically set.
Following this logic, compiler silently adds with interner = Capability::default()
to the top of foo
as the only way to provide the default.
Now, when we call bar
it sets interner
but that gets immediately overridden inside foo
.
What an elegant footgun, activated by simply adding a pub
!
All of this happened because compiler tried to second-guess what we mean.
Let's keep it explicit, shall we?
Should default be represented as static
/const
variable?
It doesn't really matter for private capabilities, but for public ones it depends on interaction with main
.
Defaults and main
If capability is reachable from main
and have a default we call it ambient capability.
Naming is analogous to ambient authority, it represents a capability which is always naturally available.
This is one case where this explicitness certainly hurts.
In case we guarantee that main
requires no context,
every ambient capability provided by any of your dependencies must be set by hand.
Ambient capabilities are extremely pervasive, so it is preferable to set them on top of main
.
But many applications don't even have access to the function!
And then, just imagine starting every doc-test with
fn main() {
// We probably need allocations, right?
with alloc = Capability::default() {
//...
}
}
Options are not very abundant:
- We can rely on compiler to enumerate all existing ambient capabilities and silently add them on top of
main
- for everyone's sanity. - Alternatively, we can drop
Fn
requirement onmain
and pass in some prebaked global context (which should be acceptable if all capability defaults are const-evaluatable).
Personally I don't like the second option: it doesn't provide a natural way to leave undesired capabilities unset, so some other use cases may suffer. For example, having compiler automatically set default allocator in a kernel sounds like a terrible idea.
Object safety
Contextual functions are not object safe.
Shouldn't be a surprise - we cannot predict with which concrete type of Cx
it will be called while being type-erased.
The only way to make them object safe is to turn Cx
into trait object:
struct __foo;
trait __MyContext: Get<__alloc> + Get<__interner> {}
impl CxFn<(), Box<dyn __MyContext>> for __foo {
fn cx_call(&self, (): (), cx: Box<dyn __MyContext>) {
//...
}
}
But this looks like a lot of extra magic and a lot of extra discussions.
fn
pointers
ZST Fn
objects can be converted to fn
pointers.
Since almost no normal functions implement Fn
now, they are not convertible to fn
either.
This is OK with me, but not so OK with FFI gang.
FFI
FFI requires function pointers, function pointers require Fn
.
I offhandedly mentioned that most normal functions don't implement Fn
, but it doesn't mean we cannot craft one.
We just need to make sure it is callable with empty context, so it is enough to set all required capabilities
at the beginning of the function to achieve that:
fn ffi_safe() {
with
alloc = GlobalAllocator::new(),
interner = Interner::new()
{
// we can now call contextual functions which use `alloc` or `interner`
}
}
In other words, it means all exported functions must properly set all required capabilities on Rust side. This makes a lot of sense: even if we allow other language to do it, how do they know how to do it correctly?
The problem here is already existing FFI-exported functions - every single one will get broken as soon as some of the
more contagious capabilities get introduced (like alloc
).
Some cases may require creating extra wrappers as well.
There is another interesting implication: Rust-to-Rust communication through FFI naturally does not share global context - each side is required to set its own when being called by the other. This, surprisingly, also makes a lot of sense. Anyone who happened to debug global statics in dynlibs in C++ will understand. This also begets a thought, that if direct Rust-to-Rust communication ever happens it probably should follow the suit.
Potential breakage
So, what happens if we suddenly move std
to use alloc
capability?
Lots of stuff breaks:
- All of exported FFI
- Normal functions as callbacks - auto-fixable.
- Certain closure uses - sometimes auto-fixable.
Because closures now silently capture more things, it can lead to breakage
when multiple closures are converted to fn
pointers:
let f: fn() -> Vec<_> = if cond {
// Doesn't allocate, ok!
|| Vec::new()
} else {
// Oops, this now have to capture `alloc` capability
|| vec![0_usize, 1, 2, 3]
};
Capturing any context is enough to prevent it from being convertible to a function pointer. This example is stupid, but I actually used this approach a couple of times in my own projects, it simplifies functional-style processing.
Fixing this in general case is hard.
You can try to define closures outside of if
/match
and convert them to dyn Fn
instead:
let first = || Vec::new();
let second = || vec![0_usize, 1, 2, 3];
let f: &dyn Fn() -> Vec<_> = if cond {
&first
} else {
&second
};
but capturing variable local to branches makes boxing hard to avoid.
Case study: global allocator
On the closing note, let's try to actually implement global allocator as capability.
Global allocator is determined by GlobalAlloc
trait,
so global allocator itself is just a vtable (+possibly some data).
To allow overriding we need type erasure;
the textbook solution is to use Box<dyn GlobalAlloc>
trait object, but we can't, creating box requires allocation!
Instead, we have to keep it as a reference only:
pub capability alloc: &dyn GlobalAlloc;
However, there is something our pedantic compiler will not be happy with. If this is a reference, how long does it live?
Well... For how long referenced object lives. Proper solution (and we are getting way ahead of current discussion!) is to make it generic over lifetime:
pub capability alloc<'a>: &'a dyn GlobalAlloc;
It would allow you to optionally override global allocator with locally-scoped variant (even using stack memory!),
but for now let's set it to 'static
.
This will provide us with the same overall semantics as #[global_allocator]
.
pub capability alloc: &'static dyn GlobalAlloc;
Setting default is the easy part: std
's default allocator is ZST, so we can just put it in:
pub capability alloc: &'static dyn GlobalAlloc = &std::alloc::alloc::Global;
Conclusions
This looks fun and way more tractable that it looked at first. Rust still keeps surprising me in how powerful it is!
There is certainly a lot more to investigate (interaction with lifetimes? generic code? constraining as part of trait? mutable/by-value contexts?), but this post is already way too long.
Hope you had fun too, see you next time!