Pre-RFC: typed context injection

Note: This pre-RFC was submitted as a full RFC. This RFC was closed, however, due to a lack of traction.

Building off a userland prototype of this concept, I wrote an RFC for a more practical and powerful version of the feature implemented as a compiler feature. Here is that RFC:

...and here is a permalink:

I'm still working on the sections after and including the drawbacks section of the RFC but I wanted to get feedback on the current design and help with the "How do we choose the strongest lifetime?" question before writing those.

I'm a bit worried about the quality of my motivation section since, although I think this is a good example of where typed context injection would be helpful, I also foresee seasoned Rust game developers complaining that these issues could be sidestepped with an ECS. Although I agree that this is likely not how a game should be made in Rust, I do still think that it provides an illustrative example of a scenario where context injection can be super helpful. Any ideas on how to avoid that confusion would be greatly appreciated.

I'm also a bit worried about the quality of these explanations, especially my reference-level explanation. Any pointers on how to write these in a more approachable and useful way would also be greatly appreciated.

Finally, I'd just like to point out that this is my first time contributing to the Rust compiler and, although I spent a bit of time toying around with the codebase to implement the feature myself, there are still many details of the Rust compiler of which I am still unaware.

Thanks,

Riley

1 Like

This sounds to me like a less general version of this older idea:

1 Like

There are a few advantages to the effect-based solution which my current proposal lacks. Here is my attempt at fixing that!

Capabilities

The effects-solution proposes using capabilities to "namespace" the components of interest rather than types. This gives two special superpowers:

  1. The ability to provide multiple instances of the same type but with different logical uses. (e.g. MyCoolType being both a logger and a tracer)
  2. The ability to accept an abstract component implementing a trait rather than a specific component (e.g. taking any logger: Logger rather than just MyLogger).

We can easily extend the current proposal to support these superpowers by defining special NamedRef<'a, N, T> and NamedMut<'a, N, T> structures. These are just newtypes around immutable and mutable references respectively and function in almost the same way as regular references except for their special coercion and deduplication rules.

Note: I later found the solution proposed in this comment to be unsound and also very overcomplicated. A better solution is proposed in this reply.

Specifically:

  • NamedX is directed to its destination by its namespace type N rather than its real pointee type T. This type must always be a concrete and 'static type.
  • If several NamedX instances with the same namespace are specified in the source or target CxRaw of a coercion, the first one is always used.
  • This is sound because it is illegal to construct a CxRaw instance with several NamedX instances from the same namespace. Unlike with regular references with potentially generic pointees, such a requirement can always be proven always provable since N is always a concrete 'static type.
  • NamedX structures do not need deduplication and, indeed, the fact that they do not rely on deduplication is important.
  • If several NamedX instances with the same namespace are present in a context but some of the pointee types are still inference holes, the inference holes are all subjected to a mutual equality constraint.

(I'll clarify these rules in a bit once I start redrafting the semantics section of the proposal—there's a few other holes I have to fix anyways)

Now, consider the following code:

// We begin by defining our capabilities.
#[non_exhaustive]
struct Logger;

#[non_exhaustive]
struct Tracing;

#[non_exhaustive]
struct Database;

// Child A context
type ChildACx<'a, B> = Cx<
    NamedMut<'a, Logger, <B as ChildACxBundle>::Logger>,
    NamedMut<'a, Tracing, <B as ChildACxBundle>::Tracing>,
>;

trait ChildACxBundle {
    type Logger: Logger;
    type Tracing: Tracing;
}

impl ChildACxBundle for PhantomData<(Self::Logger, Self::Tracing)> {}

// Child B context
type ChildBCx<'a, B> = Cx<
    NamedMut<'a, Logger, <B as ChildBCxBundle>::Logger>,
    NamedRef<'a, Database, <B as ChildBCxBundle>::Database>,
>;

trait ChildBCxBundle {
    type Logger: Logger;
    type Database: Database;
}

impl ChildBCxBundle for PhantomData<Self::Logger, Self::Database> {}

// Parent context
type ParentCx<'a, B> = Cx<
    ChildACx<'a, <B as ParentCxBundle>::ChildA>,
    ChildBCx<'a, <B as ParentCxBundle>::ChildB>,
>;

trait ParentCx<'a> {
    type ChildA: ChildACxBundle;
    type ChildB: ChildBCxBundle;
}

impl ParentCx for PhantomData<(Self::ChildA, Self::ChildB)> {}

// Usage demo
type CallerCx<'a> = Cx<
    NamedMut<'a, Logger, Either<Logger, Tracing>>,   // N.B. these still must reference distinct instances
    NamedMut<'a, Tracing, Either<Logger, Tracing>>,  // because their namespaces are distinct and are therefore
                                                     // assumed to be distinct.
    NamedRef<'a, Database, MyDatabase>,
>;

fn caller(cx: CallerCx<'_>) {
    parent(cx);
}

fn parent<B: ParentCxBundle>(cx: ParentCx<'a, B>) {
    let logger_a = child_a(cx);
    let logger_b = child_b(cx);

    // We cannot use both `B::ChildA::Logger` and `B::ChildB::Logger` at the same time here because, even though
    // they're possibly-different generic types, they are bound to the same namespace.
    // let _ = (logger_a, logger_b);
}

fn child_a<B: ChildACxBundle>(cx: ChildACx<'a, B>) -> &'a mut B::Logger {
    // ... do stuff here ...
    cx
}

fn child_b<B: ChildBCxBundle>(cx: ChildBCx<'a, B>) -> &'a mut B::Logger {
    // ... do stuff here ...
    cx
}

With this small extension to the proposal, have now the ability to:

  1. provide multiple instances of the same type but with different uses (notice how Either<Logger, Tracer> is used as the pointee for both the logger and the tracer?)
  2. accept an abstract component implementing a trait rather than a specific component

...without compromising the central objective that child contexts can be updated without updating their ancestor contexts.

Admittedly, this is quite verbose. Although outside of the scope of this RFC, one could imagine a syntactic sugar for type bundles or the ability to define inference holes in type aliases pairing quite well with this feature:

#[non_exhaustive]
struct Logger;

#[non_exhaustive]
struct Tracing;

#[non_exhaustive]
struct Database;

type ChildACx<'a> = Cx<
    NamedMut<'a, Logger, _: Logger>,
    NamedMut<'a, Tracing, _: Tracing>,
>;

type ChildBCx<'a> = Cx<
    NamedMut<'a, Logger, _: Logger>,
    NamedRef<'a, Database, _: Database>,
>;

type ParentCx<'a> = Cx<ChildACx<'a>, ChildBCx<'a>>;

Backwards Compatibility with Existing Traits and Implicit Passing

Unlike the effects-based proposal, this proposal does not currently allow users to inject additional context to an existing trait lacking facilities for that context passing.

It is, however, relatively easy to retrofit this functionality onto existing traits:

trait MyTrait {
    type Cx<'a>: AnyCx;

    fn do_something(&self, cx: Cx<'_>);
}

struct MyStruct<F> {
    targets: Vec<F>,
}

impl<F: MyTrait> MyStruct<F> {
    pub fn call_things(&self, cx: F::Cx<'_>) {
        for target in &self.targets {
            // This technically isn't possible in the current proposal yet because I never defined reborrowing
            // rules for `AnyCx`. This is a mistake and should be relatively easy to add in the next draft.
            targets.do_something(cx);
        }
    }
}

Of course, if this becomes too cumbersome, we can add implicit passing later. I'll have to think a bit more about the specifics of such a feature but I don't really see it being any more complicated than what would be required by the effects-based proposal.

The fact that deduplication happens at the time that the Cx type alias is resolved is a problem. It means that Cx<T, T> and Cx<T> are considered the same type, leading to unsoundness:

fn mk_ref<T>(r: &mut T) -> Cx<&mut T> {
    Cx::new((r,))
}

fn components<T, U>(cx: Cx<T, U>) -> (T, U) {
    (cx, cx)
}

fn dup_ref<T>(r: &mut T) -> (&mut T, &mut T) {
    let cx: Cx<&mut T> = mk_ref(r);
    let cx: Cx<&mut T, &mut T> = cx;
    components(cx)
}

Luckily, this isn't an issue because Cx is only resolved once very early on in the compilation process!

// Cx is resolved once and very early in the compilation process.
fn mk_ref<T>(r: &mut T) -> Cx<&mut T> {
    Cx::new((r,))
}

fn components<'a, T, U>(cx: Cx<&'a mut T, &'a mut U>) -> (&'a mut T, &'a mut U) {
    (cx, cx)
}

fn dup_ref<T>(r: &mut T) -> (&mut T, &mut T) {
    let cx: Cx<&mut T> = mk_ref(r);
    let cx: Cx<&mut T, &mut T> = cx;
    components(cx)
}

// Here is what they resolve to:
fn mk_ref<T>(r: &mut T) -> CxRaw<(&mut T,)> {
    Cx::new((r,))
}

fn components<'a, T, U>(cx: CxRaw<(&'a mut T, &'a mut U)>) -> (&'a mut T, &'a mut U) {
    (cx, cx)
}

fn dup_ref<T>(r: &mut T) -> (&mut T, &mut T) {
    let cx: CxRaw<(&mut T,)> = mk_ref(r);
    let cx: CxRaw<(&mut T,)> = cx;

    // Does not work, because CxRaw<(&mut T,)> cannot be coerced to `CxRaw<(&mut T, &mut T)>` (ambiguous target).
    components(cx)
}

// Even if we force the second `cx` to be `CxRaw<(&mut T, &mut T)>`, things still break just one line earlier.
fn dup_ref<T>(r: &mut T) -> (&mut T, &mut T) {
    let cx: CxRaw<(&mut T,)> = mk_ref(r);

    // Does not work, because the target is once again ambiguous.
    let cx: CxRaw<(&mut T, &mut T)> = cx;

    components(cx)
}
1 Like

What's the output of this code?

trait PrintComponents {
	fn print_components();
}

impl<T> PrintComponents for CxRaw<(T,)> {
	fn print_components(&self) {
		print!("1");
	}
}

impl<T, U> PrintComponents for CxRaw<(T, U)> {
	fn print_components(&self) {
		print!("2");
	}
}

let closure = |x, y| {
	// we start out knowing nothing about the relation between x and y's type
	Cx::new((x, y)).print_components();
	[&x, &y]; // x and y now known to have the same type
	Cx::new((x, y)).print_components();
};

closure((), ());

After thinking a bit more about my proposal for "capabilities" emulation through NamedCx, I realize that my solution is terribly unsound.

I assume that I can uphold an invariant that all NamedX objects with the same namespace ocurring in a CxRaw will have the same pointee type, making it sound to write:

struct MyNamespace;

fn consumer<T, V>(cx: Cx<NamedMut<'_, MyNamespace, T>, NamedMut<'_, MyNamespace, V>>) {
    let value: &mut T = cx;
    let value: &mut V = cx;
    // This should be sound because `T` and `V` should have been specified as
    // the same type in order to construct `CxRaw`.
}

Unfortunately, while this invariant is easy to uphold on its own, it breaks down when combining the feature with AnyCx merging:

fn merge<L: AnyCx, R: AnyCx>(left: L, right: R) -> Cx<L, R> {
    // Previously, I asserted that this would work because distinct generic parameters
    // are assumed not to alias.
    Cx::new((left, right))
}

fn caller(cx_a: CxMut<NamedMut<'_, MyNamespace, *mut u32>>, cx_b: CxMut<NamedMut<'_, MyNamespace, Box<u32>>>) {
    // Unfortunately, this allows us to break our oh-so-important invariant.
    let merged = merge(cx_a, cx_b);

    // Oh no! We just transmuted some random `*mut u32` into a `Box<u32>`!
    consumer::<u32, i32>(merged);
}

Luckily, there's a much easier way to solve the original problem: use the tried-and-true bundle type technique. Best of all, it doesn't require any complex additions to the existing proposal!

trait HasLogger {
    type Logger: Logger;
}

trait HasDatabase {
    type Database: Database;
}

trait HasTracing {
    type Tracing: Tracing;
}

trait HasBundleForCx1: HasLogger + HasDatabase {}

type Cx1<'a, B> = Cx<
    &'a mut <B as HasLogger>::Logger>,
    &'a mut <B as HasDatabase>::Database,
>;

trait HasBundleForCx2: HasLogger + HasTracing {}

type Cx2<'a, B> = Cx<
    &'a mut <B as HasLogger>::Logger,
    &'a mut <B as HasDatabase>::Tracing,
>;

type ParentCx<'a, B> = Cx<Cx1<'a, B>, Cx2<'a, B>>;

trait HasBundleForParent: HasBundleForCx1 + HasBundleForCx2 {}

fn consumer<B: ?Sized + HasBundleForParent>(cx: ParentCx<'_, B>) {
    let logger: &mut B::Logger = cx;
    let tracing &mut B::Tracing = cx;

    // This already works because of the no-alias assumptions.
    let _ = (logger, tracing);
}

fn caller(cx: Cx<&mut MyLogger, &mut MyDatabase, &mut MyTracing>) {
    consumer::<
        // We'll likely have to augment `Cx`'s inference system to allow
        // this to be inferred automatically.
        dyn HasBundleForParent<
            Logger = MyLogger,
            Database = MyDatabase,
            Tracing = MyTracing,
        >,
    >(cx);
}

I have no clue what I was thinking earlier today.

Whoops! This reply was intended for this comment:

This is, once again, an ambiguity because CxRaw always constructs a wrapper around the exact tuple it was given. Hence, in the sample:

let mut a = 3u32;
let mut b = 4u32;
let cx: = Cx::new((&mut a, &mut b))

cx will have the type CxRaw<&mut u32, &mut u32>.

Coercing this to anything depending upon u32 will cause a coercion error because the slot of the source CxRaw tuple from which to borrow that u32 is ambiguous.

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.