IoC Container in Python using meta programming
Tags: python
Published: 2023-07-31 00:54:29

Often while working with python I miss having a DI framework (sadly this is not very common in the python world). There are multiple blog posts on the benefits of DI, so I won't go into that here.

Either way, I was on the lookout for a DI framework for python and this one caught my eye: [Dependency Injector](https://github.com/ets-labs/python-dependency-injector). Mainly because of this listed feature:


> "Performance. Fast. Written in Cython."

Which peaked my interest, how much overhead does a DI framework add to your code? So I wrote a [small benchmark](https://github.com/GabrielCappelli/meta-di/tree/main/benchmark) to test this.

For the benchmark we are requesting the containers to give us an instance of `FooService`, with the following dependency tree, with Cache, HTTPClient and Config being singletons and the rest being transient:

```
FooService
    - Config
    - Database
        - Config
    - Cache
        - Config
    - HttpClient
        - Config
    - BarService
        - Database
            - Config
        - Cache
            - Config
        - HTTPClient
            - Config
```

And these were the results:

```
$ python3.11 benchmark/run_benchmark.py 
Direct (No framework) 563.216755 ns
Dependency Injector Container (Cython) 1183.705037 ns
Rodi Container (Pure Python) 1983.972198 ns
```

Ok, so compared to directly instantiating the objects ourselves it seems a pure python container is ~4x slower and the cython implementation is ~2x slower.

Which makes sense, since a normal container has some overhead to get a service:
  - Check if service is a singleton or transient
  - If its a singleton check if its already instantiated
  - Find the dependencies for the service and get them (recursively)

## Meta programming to the rescue

So how can we make this faster?

After a few failed attempts using closures and meta classes I had the idea to use meta programming and generate the code for the container at runtime. If we already know all services beforehand, we can generate a class with specialized methods for each service we need to instantiate.

Here's the optimizations we can perform on each methods:
  - No need to check if the service is a singleton or transient
  - No need to find the dependencies for the service at runtime
  - Inlining the code to instantiate the dependencies
    - This saves us the overhead of recursively calling the container to get the dependencies
    - Preloading singletons also avoids the need to check if they are already instantiated

Here's a snippet of the generated code for the `FooService` method with all optimizations mentioned above:

```python
    def get(self, service_id): # to provide a clean interface we still have this method as overhead
        return self._service_getter_map[service_id]()

    def get_services_FooService(self):
        return services.FooService(
            config=self._singleton_instances[services.Config], # singleton instance is preloaded and inlined
            database=services.Database( # transient service is inlined
                config=self._singleton_instances[services.Config],
            ),
            cache=self._singleton_instances[services.Cache],
            http_client=self._singleton_instances[services.HTTPClient],
            bar_service=services.BarService(
                config=self._singleton_instances[services.Config],
                database=services.Database(
                    config=self._singleton_instances[services.Config],
                ),
                cache=self._singleton_instances[services.Cache],
                http_client=self._singleton_instances[services.HTTPClient],
            ),
        )
```

## Results

So, did it work? Here's the results:

```
$ python3.11 benchmark/run_benchmark.py 
Direct 563.216755 ns
MetaDI Container 792.128003 ns
Dependency Injector Container (Cython) 1183.705037 ns
Rodi Container 1983.972198 ns
```

Which are pretty good! We managed to be faster than the cython implementation while using pure python.

## Conclusion

Even though I believe this performance gain is irrelevant for most applications, it was a fun experiment and I learned a lot about meta programming. I also think this is a good example of how meta programming can be used to optimize code, and I will definitely keep this in mind for future projects.

Source code can be found on [Github](https://github.com/GabrielCappelli/meta-di)