Promise: IoC Lifespan management
In my last post about Promise i explained how a Type can be mapped to a particular Class to override the implicit Type/Class mapping like this:
This registration is global and always returns a new instance, i.e. it acts like a factory for the
User Type, producing
MockUser instances. In this post I will talk about the creation and disposal of instances and how to control that behavior via IoC and nested execution contexts.
So far all we have done is Type/Class mapping. Before I talk about Lifespan's I want to cover Dependency Injection, both because it's one of the first motivators for people to use an IoC container and because Lifespan is affected by your dependencies as well. Unlike traditional dependency injection via constructors or setters, Promise can inject dependencies in a way that looks a lot more like Service Location without its drawbacks. We don't have constructors, just a resolution mechanism. We do not inject dependencies through the initialization call of the class, we simply declare fields and either execute resolution manually or have the language take care of it for us:
stream simply calls the
Stream resolver as its initializer, which uses the IoC to resolve the instance, while
authProvider uses the plus (
+) prefix on the field type to tell the IoC to initialize the field. The only difference in behavior is that the first allows the passing of an initialzer JSON block, but using the resolver with just
(); is identical to the
Instance Disposal and Garbage Collection
Promise eschews destructors and provides Disposers in their stead. What, you may ask, is the difference? Instance destruction does not happen until garbage collection which happens at the discretion of the garbage collector. But disposal happens at context exit which is deterministic behavior.
Instances go through disposal if they either have a Disposer or have a field value that has a Disposer. The Disposer is a method slot named by a tilda (
~). Of course the above example would only need a disposer if Stream was mapped to a non-disposing implementation. Accessing a disposed instance will throw an exception. Disposers are not part of the Type contract which means that deciding whether or not to dispose an instance at context exit is a runtime decision made by the context.
Having deterministic clean-up behavior is very useful, but does mean that if you capture an instance from an inner context in an outer context, it may suddenly be unusable. Not definining a Disposer may not be enough, since an instance with fields does not know until runtime if one of the fields is disposable and the instance may be promoted to disposable. The safest path for instances that need to be created in one context and used in another is to have them attached to either a common parent or the root context, both options covered below.
Defining instance scope
This default scope for creating a new instance per resolver invocation is called
FactoryScoped and can also be manually set (or reset on an existing registration) like this:
.FactoryScoped instance may be garbage collected when no one is holding a reference to it anymore. Disposal will happen either at garbage collection or when its execution context is exited, whichever comes first.
The other type of lifespan scoping is
This registration produces a singleton for the current execution context, giving everyone in that context the same instance at resolution time. This singleton is guaranteed to stay alive throughout the context's life and disposed at exit.
Definining execution contexts
All code in Promise runs in an execution context, i.e. at the very least there is always he default root context. If you never define another context, a context scoped instance will be a process singleton.
You can start a new execution scope at any time with a context block:
Context scoped instances are singletons in the current scope. You can define nested contexts, each of which will get their own context scoped instances, providing the following behavior:
Since the context scope is tied to context the instance was resolved in, each nested context will get it's own singleton.
But what if i'm in a nested context, and want the instance to be a singleton attached to one of the parent contexts, or want a factory scoped instance to survive the current context? For finer control, you can target a specific context by name. The root context is always named
:root, while any child context can be manually named at creation time. If not named, a new context is assigned a unique, random symbol.
println context.name; // => :root
var catalogA = Catalog();
var cartA = Cart();
var catalogB = Catalog(); // same instance as catalogA
var catalogC = Catalog(); // same instance as A and B
var cartB = Cart(); // different instance from cartA
var cartC = Cart(); // same instance as cartB
.Use and .
(Factory|Context)Scoped can be used in any order, the
.In method on the registration should generally be the first method called in the chain. When omitted, the global version of the Type registration is modified, but when invoked with
.In, a shadow registration is created for that Type in the specified context. The reason for the deterministic ordering is that registration is just chaining method calls, each modifying a registration instance and returning the modified instance. But
.In is special in that it accesses one registration instance and returns a different one. Consider these three registrations:
These registrations mean, in order:
- "for the type
:foo, make it context scoped and use the class
- "for the type
Catalog__, make it context scoped, and in context
:foo__, use class
- "for the type
Catalog, make it context scoped, use the class
DBCatalogand in context
The first is what is intended 99% of the time. The second one might have some usefulness, where a global setting is attached and then additional qualifications are added for context
:foo. The last, however, is just accidental, since we set up the global case and then access the context specific one based on the global, only to not do anything with it.
This ambiguity of chained method could be avoided by making the chain a set of modifications that are pending until some final command like:
Now it's a set of instructions that are order independent and not applied to the registry until the command to build the registration. I may revisit this later, but for right now, I prefer the possible ambiguity to the extraneous syntax noise and the possibility of unapplied registrations because
.Build was left off.
What about thread isolation?
One other common scope in IoC is one that has thread affinity. I'm omitting it because as of right now I plan to avoid exposing threads at all. My plan is to use task based concurrency with messaging between task workers and the ability to suspend and resume execution of methods a la coroutines instead. So the closest analog to thread affinity i can think of is that each task will be fired off with its own context. I haven't fully developed the concurrency story for Promise but the usual thread spawn mechanism is just too imperative where I'd like to stay declarative.
Putting it all together
With scoping and context specific registration, it is fairly simple to produce very custom behavior on instance access without leaking the rules about mapping and lifespan into the code itself. Next time I will show how all these pieces can be put together, to easily build the Repository Pattern on top of the language level IoC.
More about Promise
This is a post in an ongoing series of posts about designing a language. It may stay theoretical, it may become a prototype in implementation or it might become a full language. You can get a list of all posts about Promise, via the Promise category link at the top.