Can we make a Rusty effect system?

First, I don't see how provide_context! and get_context! are meaningfully different from a global god object in theory or practice. They are shared mutable state, for one thing, and that raises the question: how you ensure that provide_context! is called before get_context!? What about data races? I doubt you'll be able to do it statically (with all that that implies). Further down the list of problems, provide_context! seems destined to cause heap allocations: not cost free.

(To be fair to you, you did see these issues and are solving the get-before-set problem by always returning an Optional, the thread-safety issue with threadlocals, and the heap allocation problem with a heap allocation. But, those are some severe tradeoffs that take us well outside cost-free abstraction).

Beyond the implementation difficulties, $dayjob is a large system that makes data flow implicit (because it's based on the Dagger2 "dependency injection" system). After years of soul-crushing refactoring jobs on this behemoth, I am skeptical of all such systems.

It can be nice to update a utility's dependencies without changing all of that utility's callers, but it can (does) cause problems. Unfortunately the problems only manifest at large scale, so describing the problems that a DI system like Dagger can cause using a small example is hard. (Though I might try in a blog post).

Here are properties I want out of these systems:

  • The compiler knows all of the variables that are used in the body of a function and does its normal borrow and ownership checking and monomorphization.
  • In addition to the compiler, all other code analysis that works on regular rust code keeps working on my code after I tack on "implicits". (In other words, it's only implicit in the unexpanded source code, not hidden from analysis).
  • All of the formal parameters that I use in the body of the function are lexically declared...
  • ...except for the single special case of eliding parameters that I only need because I'm passing them along to other function calls.

The main benefit here as I see it is that we want to be able update our utility method's dependencies ("aspects") and only update a few places where the dependency needs to be resolved differently - not every call site.

What if we use a proc macro to rewrite our function signatures and calls with all the goop we don't care about? (Not sure if this is a possible, but I'm interested in finding out).

#[derive(Debug)]
struct Logger {}
impl Logger {
    fn log(&self, msg: &str) {
        println!("{}", msg);
    }
}

// #inject means approximately:
// For every function call in my body, for every parameter that I have not supplied lexically,
// create a new formal input parameter with the same type as the missing parameter,
// and pass the formal parameter through to the call site.
[#inject] 
fn my_func(num: u32, logger: Logger) {
    logger.log(&format!("{}", num));
}

// with!(foo) {} means:
// "In this block, any function that takes a foo parameter which I haven't supplied
// gets this one.
fn main () {
    let logger = Logger {};
    
    with!(logger) {
        my_func(13u32);
    }
}

The macros in tha texample would expand as so:


// Right now, my_func doesn't do anything during expansion, all formal params
// are lexically specified
fn my_func(num: u32, logger: Logger) {
    logger.log(&format!("{}", num));
}

fn main () {
    let some_num = 43;
    // The with! block should expand to this:
    {
        call(13u32, logger);
    }
}

Then later, we find that we'd like to update logger.log to take a new parameter, but unfortunately logger.log is called in 13.3k places so just adding a new formal input parameter is not feasible. But, because we're injecting, we can do it:

trait LogContext : Debug {
}

#[derive(Debug)]
struct BarContext {}
impl LogContext for BarContext;


#[derive(Debug)]
struct Logger {}
impl Logger {
    fn log<C: LogContext>(&self, msg: &str, ctx: C) {
        println!("[{:?}] {}", ctx, msg);
    }
}

/// We will have to supply the context from somewhere , maybe we want to do it in main?
fn main () {
    let logger = Logger {};
    
    with!(logger, BarContext {}) {
        my_func(13u32);
    }
}


// Now, my_func needs to receive a LogContext as an input so it can pass it down,
// So #[inject] kicks in to expand my_func() to this:
// Also note how this causes my_func to be written into a generic now.
fn my_func<C: LogContext>(num: u32, logger: Logger, __a: C) {
    logger.log(&format!("{}", num), __a);
}

// Note that my_func is unchanged pre-expansion ... we didn't have to update the logger.log call site.
[#inject] 
fn my_func(num: u32, logger: Logger) {
    logger.log(&format!("{}", num));
}

The main problem I see with implementing this is that it's hard to make it work with traits. You'd have to recapitulate the entire trait-based constraint satisfaction logic in the macro to find which item in the container satisfies the requirements of an injecting call-site, and it's extremely easy to wind up with a container where two different values in the container can satisfy a function's type constraints. (What happens if someone writes fn my_func<T: Debug>(debug: T) {}? Just about every type in the container will satisfy that. Yet, I don't think this system feels rusty unless it works with traits).

2 Likes