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).