Inversion of control in rust

When working in .Net, Microsoft.Extensions.DependencyInjection is one of my most valued concepts. It allows multiple separately compiled assemblies to register their services in a very compact API without forcing all registered services to implement a common interface. Most crates on crates.io require static composition. When a dependency is missing, compilation simply fails. I missed this feature very much in .Net, because it leads to Null-Pointer-Exceptions or ServiceNotResolvableExceptions all the time. Because failing at compile time is obviously not possible for dynamic IOC, in ioc_rs these are checked when converting ServiceCollection into ServiceProvider.

In the beginning, I was sceptical if such a thing could be implemented in my new favorite language (:crab:), as it has a very minimal type system at runtime due to the compiler swallowing almost all type information. Of course this is expected behaviour in such a low level language.

I'm happy to tell you, that it seems to be doable: I'm planning to release the initial version in a couple of days. I'm very excited about your feedback. I'm currently working on the last remaining feature of detecting dependency-cycles, but this shouldn't alter the overall structure. You can find all code under:

There is however a soundness-bug I don't exactly know how to deal with. Transient services, which store a reference to a singleton can currently outlive the ServiceProvider. Example:

struct UserService<'a> { 
    // This reference becomes 'static when UserService is reloaded from once_cell -> Bad :-S
    db: &'a dyn SqlDatabase 
} 

This is a severe problem, as memory get's freed after ServiceProvider is dropped. There is a test in tests/compile_errors/use_singleton_with_ref_after_drop.rs, which currently fails to fail (:sweat_smile:)
I'm still hoping, that I can somehow reduce the lifetimes of all fields of a generic type to the lifetime of &ServiceProvider.

Best thanks in advance for any participation. Specially for the lifetime-Gurus who can help with the soundness issue

For shared non-temporary references use Arc, e.g. Arc<dyn SqlDatabase>.

Temporary borrows (&) are tied to a single scope, and don't manage memory, so they are not flexible enough to be a general-purpose way to refer to a widely shared object. You should use them only for things that live within a specific call graph, such as function-local variables and arguments that don't escape function calls.

1 Like

I want to avoid reference counting for the following reasons:

  • should become #[no_std]
  • Avoid (admittedly small) overhead
  • Make it impossible to leak a reference (no singleton should live longer than &ServiceProvider)

If you take a look at this you can see, that it basically works (using HRTBs) .

In addition to what @kornel said:

As a concept, in my opinion , Inversion of Control is a dead wrong anti-pattern and self-defeating concept. Its like other object-oriented features that are left out of rust for the better.

For the things that IOC is trying to solve.. consider just using the Builder pattern which rust does well and possibly even the type-state style, pass by value Builder pattern which is even more powerful, than any builder's in Java (without generics) where I think this whole IOC BS originates.

You can use Arc with no_std (if you add extern crate alloc; in the crate root).

1 Like

I agree that this pattern could be implemented poorly, but take a look at examples ... Maybe you can suggest a better name for what it is. This helped us in my previous company to drop compile-times significantly! PS: I'm no fan of [Attributes] all over the codebase either... Hope you'll take a closer look again

PS: I'm using the builder-pattern in ServiceCollection (Like it very much)

Ah, good to know... Alloc will be required anyways... So maybe I'll fall back to that if I can't find a better solution. But Point3 is still something I'd really like

I appreciate your openness to feedback, and hope this is also well received. Note I don't really understand the example, but mine is mostly a point on readability anyway:

let service = provider
        .get::<ioc_rs::Transient<&dyn interface::Service>>()
        .expect("Expected plugin to register a &dyn Service");

Is this is an example of the IOC in action (snippet from the examples you mentioned)?

If so , I think I'd prefer the builder:

let service = Provider::builder().build::<interface::Service>().expect("…");

// or
let service = Provider::builder().service::<Service>().expect("…");

// or:
let service: Service<&dyn interface::Service> = Provider::builder().service().expect("…");

Generally, as I've learned rust, I've been surprised at just how well type inference works to minimize code and enhance readability with the builder pattern. I mean its much better than Java which makes you spell it all out.

Edit: You'll see above I totally missed the Transient part of your original. But I hope my point is clear enough in any case.

The benefit is that you don't have to recompile your runtime/platform if you need a new implementation of &dyn Service. It doesn't know plugin at all (and neither does plugin know runtime). You can e.g. use that to switch the Inference-Engine from TensorFlow to OpenVino without compiling any new code... Just replace the opencv.dylib with openvino.dylib and you're good... Try to run the example on your system: if you're on linux/windows, you might need to change the dylib-extension to .dll or .so

Maybe I should find an example which isn't pointing to ::get::<Singleton<i32>>() but instead ::get::<SingletonServices<i32>>(). This returns an iterator over all registered services. You can use this to collect routes from multiple dll's (again, no recompillation needed). This is also useful if you sell your software in components... You can simply extend new functionality by putting a new .dylib into your plugin-folder and restart the application

PS: I can't see a single location where I have to specify more types than required...

1 Like

Thanks, I'll give that a try. Yes, perhaps my immediate reaction is just from old wounds from the Java sense of the IoC acronym. In other words, my feedback so far might just be more in terms of marketing semantics. But as you should know, in open source, sadly, marketing is often the linch pin to adoption!

This is why I asked you for a better name :wink:

1 Like

Can you expand on this? Data behind an Arc will only "leak" if there's some long-lived clone of the Arc; once the reference count hits zero it's deallocated, and there's no mechanism to prolong it beyond that (afaik). And of course as long as you stick to safe code you never have to worry about use-after-free :‌)

1 Like

I suspect that the idea is that destroying the ServiceProvider should also destroy the services it provided, and the compiler should reject any code that would require them to live beyond that time.

1 Like

Ah, okay -- could that be achieved by having the services hold Weak and the provider keep the only Arc? It's not the same kind of compile-time check but it might be almost as good.

Exactly

As far as I understand it, Services are still able to upgrade the Weak to an actual Arc... And it would be very unergonomic to work with upgrade all over the place...

1 Like

Only if Provider holding an Arc is still here. If the last Arc is dropped, trying to upgrade Weak will fail.

Agree... Unfortunately, I assume that library users will just call unwrap on every upgrade and if there is an error, it will fail at runtime and not compile time... I'd like to avoid that if possible

You could stick a wrapper around Weak that hides the short-lived upgrade, exposing the resulting reference only to a closure: playground. That prevents services from getting their own Arcs. But there is admittedly an ergonomic cost (which I think is worth paying if the alternative is unsoundness).

1 Like

This might be outside the box, right field, but if 99% of use cases are setup as start of program and live forever (i.e. no runtime reloading, etc.) then maybe you just mem::forget the registrants or use lazy static for 'static lifetimes?

A wrapper is what I just proposed on the explicit thread about this problem, as this thread was more about code review :slight_smile:. I totally agree that unsound code has no place in a public api. I however prefer a wrapper which simply implements an AsRef, as real programs often have no fallback if a crucial service is missing, which leads to long Error-Handling simply to tell the programmer that he messed up...

Maybe I'll introduce a use-after-free hook if a Service is still in use when killing the provider... The library user can then decide to panic (during development) or e.g. log an error in production...

1 Like