Container improvements
- Created
- Decided
- Versions
- History
Abstract
We will change how the container caches shared instances and resolves classes. Shared instances will be kept in one instance cache type, used once with strong references and once with weak references. Class-level marker attributes will be memoised as presence flags, and whether a resolution is liminal will be worked out from every source before the cache is checked. Circular dependencies will be detected, and reported with the chain of classes involved.
Motivation
The container keeps shared instances in four separate caches, and the logic that picks one is written twice, once for reading and once for writing, so the two can disagree.
When a resolution is checked against the cache, only what the resolution itself says is known. Whether its binding or its class makes it liminal is found out afterwards, once the cache has already been consulted.
A shared instance resolved through an alias is cached under a different class from the one it is read under, so each resolution through the alias constructs a new instance. A resolution given both a name and a qualifier ignores the qualifier without saying so. A lazy proxy is made for the class as requested, which PHP cannot do for an interface.
A class that depends on itself, directly or through other classes, exhausts the call stack, and nothing reports which classes are involved.
Proposal
Instance cache
We will add InstanceCache, a store of shared instances keyed by class, and by name or
qualifier class where a resolution has one. Each cache will
hold its instances either strongly or through weak references.
final class InstanceCache
{
public static function strong(): self;
public static function weak(): self;
public function get(string $class, ?string $name = null, ?string $qualifier = null): ?object;
public function put(string $class, object $instance, ?string $name = null, ?string $qualifier = null): void;
}
| Method | Effect |
|---|---|
strong() |
Static. Creates a cache that holds its instances strongly. |
weak() |
Static. Creates a cache that holds its instances through weak references. |
get(string $class, ?string $name = null, ?string $qualifier = null): ?object |
Returns the instance stored under the class, and the name or qualifier class where one is given, or null. |
put(string $class, object $instance, ?string $name = null, ?string $qualifier = null): void |
Stores the instance under the class, and the name or qualifier class where one is given. |
A cache will hold one instance per class, one per class and name, and one per class and qualifier class. The qualifier's class is its key, as it is in RFC-0001, and any value the qualifier carries is not part of it. The container will never give a cache both a name and a qualifier class, since a resolution cannot have both.
Whether a cache holds its instances strongly or weakly will be chosen when it is created. In a weak cache the weak
reference is internal: get() will return the instance, typed as the class requested, or null, both when nothing was
stored and when the stored instance has been collected.
An entry whose instance has been collected will stay in a weak cache until the next put() under the same class, and
the same name or qualifier class, replaces it. Neither a read that finds it nor anything else will remove it.
The cache will know nothing of a binding or an alias. It will store
and return under whatever class it is given. BindingCatalogue will expose alias normalisation, and the container will
normalise the class before it consults the cache.
The container will hold two caches:
private InstanceCache $instances = InstanceCache::strong();
private InstanceCache $liminalInstances = InstanceCache::weak();
Reading and writing will both pick a cache by effective liminality, then delegate to it. A liminal resolution and a shared resolution of the same class will occupy separate entries.
Class attributes
The container will memoise, for each class, whether it carries NoResolution, Lazy and Liminal, as
ADR-0014 decides. The flags will come from one
unfiltered getAttributes() pass, matched on getName(), the first time the flags are needed for the class, and no
attribute will be instantiated. Later resolutions will read the flags without reflection.
A class-level attribute will apply to a resolution when it is carried by the requested class, after alias normalisation, or by the concrete class of the resolution's binding where it has one. A binding given an object has the object's class as its concrete class, so that class is consulted. The class of an instance a factory returns is not known before the cache is checked, and will not be consulted.
Nothing will be memoised about parameters, as ADR-0020 decides.
Resolution
These steps will replace the resolution steps of RFC-0001:
- Alias. An alias is replaced by the abstract it points to, once. The cache and the resolution stack use the class this step produces, and class-level attributes are read from it and from the binding's concrete class.
- Binding. The binding for the requested class, name and qualifier is looked up. The lookup replaces an alias itself, once, so it reaches the same class as the first step.
- Liminality. The resolution is liminal if it asks to be, if its binding marks it
liminal, or if
Liminalapplies to it, as described under Class attributes. This is worked out once, and used for both reading and writing the cache. - Cached instance. If a shared instance is cached, it is returned: from the weak cache for a liminal resolution, while the instance is still alive, and from the strong cache otherwise, under the name or qualifier class where the resolution has one.
- Lazy proxy. If the resolution is lazy, its binding marks it lazy, or
Lazyapplies to it, a lazy proxy is returned. IfNoResolutionapplies and the binding has no instance or factory, it throws here instead, as it would at construction. The proxy is made for the binding's concrete class, or the class from the first step where there is no concrete class. If that class is an interface, is abstract or extends an internal PHP class, it throwsNotInstantiableException. A class with no properties that could trigger initialisation is resolved straight away, as in RFC-0001, so the resolution continues to the next step without a proxy. When the proxy is first used, the resolution runs again from the first step with this step skipped. If the instance it produces is not one PHP accepts for the proxy, such as an instance of a subclass, it throwsDependencyResolutionException. - Resolution stack. The resolution's entry is added to the resolution stack, as described under Circular dependencies.
- Instance. If the binding holds an instance, that instance is the result. If it has a factory, the factory is invoked through the container and its return value is the result.
- Construction. Otherwise the container constructs the binding's concrete class, or the class from the first
step where there is no concrete class. If
NoResolutionapplies to the resolution, it throws. A class that is not instantiable throws. A class with no constructor is instantiated directly, and a class with one has its constructor invoked through the container. - Sharing. Unless a binding marks it not shared, the instance is cached: in the weak cache if the resolution is liminal, and in the strong cache otherwise, under the class from the first step, and the name or qualifier class where the resolution has one.
A resolution will have a name or a qualifier, never both, as a dependency cannot in
RFC-0001. named() will throw when the resolution already has a qualifier,
and qualifiedBy() when it already has a name.
Circular dependencies
A circular dependency is a resolution whose eager construction requires the same resolution again, directly or through others.
The container will hold a resolution stack. A resolution will add its entry once it is past the cached instance and lazy proxy steps, and remove it when it finishes, whether it returns or throws. A resolution that returns a cached instance or a lazy proxy adds no entry. An entry will be identified the same way as a shared instance in the instance cache: by the class after alias normalisation, and the name or qualifier class where the resolution has one.
A resolution whose entry is already on the stack will throw CircularDependencyException instead of adding it. Its
message will name every entry in the chain in order, each by its class and by its name or qualifier class where it has
one, from the first appearance of the repeated entry to its repetition, such as A, B, C and then A again.
Only eager construction of the same resolution will be detected:
- A class that depends on a named binding or
qualified binding of its own abstract is not a
circular dependency, because the two resolutions have different entries. A decorator bound to
Cache, whose constructor takes theCachebinding namedinner, resolves normally. - A resolution already on the stack, requested again lazily, through its binding or through
Lazy, returns a lazy proxy instead of throwing, because a resolution that returns a proxy adds no entry. When the proxy is first used, the resolution runs again from the alias step, and a shared resolution whose first construction has finished finds its instance in the cache. If the first construction is still on the stack at that point, such as when the proxy is used inside the constructor that requested it, that is a circular dependency, and it throws. A class with no properties that could trigger initialisation is given no proxy, so a lazy request for one already on the stack is a circular dependency. - A ghost object is created without a resolution, and its constructor runs when it is
first accessed, so
Ghostbreaks a circular dependency without touching the stack. - A class resolved twice in sequence, such as the same class for two parameters of one constructor, is not a circular dependency, because the first resolution has left the stack before the second starts.
- A resolution that throws leaves the stack as it was before it started, so an unrelated resolution afterwards is not reported as a circular dependency.
Errors
A circular dependency will throw CircularDependencyException, which will be final, extend RuntimeException and
implement ContainerException. It will be thrown when a resolution being constructed
eagerly is required again before it finishes, and will carry the chain of entries in order.
A resolution given both a name and a qualifier will throw InvalidResolutionException,
which will be final, extend InvalidArgumentException and implement ContainerException, as
InvalidInvocationException does for an invocation.
A lazy resolution whose proxy would be made for an interface, an abstract class or a class extending an internal PHP
class will throw NotInstantiableException, which RFC-0001 throws for a class
to be constructed that is not instantiable. A lazy proxy whose resolution produces an instance PHP does not accept for
it will throw DependencyResolutionException when it is first used, which RFC-0001 throws when a
dependency cannot be resolved.
Out of scope
- Chained aliases. An alias pointing to another alias is not followed: the alias step and the binding lookup each replace an alias once. Whether chains are flattened when a catalogue is built, or followed at runtime, belongs to building catalogues.
- Memoising parameters and resolution plans, ruled out by ADR-0020.
Alternatives considered
The decisions this design rests on are recorded separately, with the alternatives each one rejected:
- memoising class-level attributes as presence flags, in ADR-0014
- not memoising resolution data per parameter, in ADR-0020
One cache holding every instance through weak references. We rejected this because a weak reference is pointless beside a strong one in the same store, and a liminal and a shared resolution of one class are different cache identities: resolving a class without liminality should still cache it normally.
Choosing strong or weak references with an enum or a flag. We rejected this because static factories follow the
container's own convention, such as Resolution::for() and Invocation::callable().
Reordering the existing branches. Reading and writing would keep their own branches over the four caches, reordered so that both check them in the same order. We rejected this because it leaves two hand-maintained copies of the same choice, which liminal named and qualified instances would grow to six caches.
Matching qualified instances by qualifier value. A qualified instance would be found by scanning the instances of
its class and comparing qualifiers with equals(), as the container does now. We rejected
this because RFC-0001 makes the qualifier's class the key and ignores any
value it carries.
A return type that depends on how the cache holds its instances. get() would declare a conditional return type
keyed on whether the cache is weak. We rejected this because PHPStan accepts conditional return types only over
parameters and template types, and a call site cannot know which type of cache it holds.
Reading class-level attributes from one class only. An attribute would apply only when carried by the requested class, or only when carried by the class being constructed. We rejected this because a binding's concrete class is also an alias of its abstract, so the requested class after normalisation is the abstract whichever of the two is requested. Reading it alone would never find an attribute on the concrete class, and reading only the class being constructed would never find one on an interface.
A name taking precedence over a qualifier. A resolution given both would resolve as if only the name were given. We rejected this because the qualifier would be dropped without the caller knowing.
Resolving eagerly when a proxy cannot be made. A lazy resolution whose proxy would be made for an interface, an abstract class or a class extending an internal PHP class would construct its instance at once. We rejected this because the laziness would be dropped without the caller knowing, and a circular dependency relying on it would be reported with nothing to say why.
Adding a stack entry when a resolution starts. Every resolution would add its entry before the cached instance and lazy proxy steps. We rejected this because a lazy request for a resolution already on the resolution stack would throw before its proxy could be returned, so laziness would break a circular dependency only where no lazy request meets its own entry.
Identifying stack entries by class alone. We rejected this because a class that depends on a named binding or qualified binding of its own abstract, such as a decorator, would be reported as a circular dependency.
Removing a stack entry only when a resolution returns. We rejected this because a resolution that throws would leave its entry behind, and an unrelated resolution afterwards would be reported as a circular dependency.
Removing a collected entry when a read finds it. get() would remove an entry whose instance has been collected.
We rejected this because a read that finds one is followed by construction and a put() under the same entry, which
replaces it, unless the resolution returns a lazy proxy that is never used, or throws. Removal on read would free an
entry only in those cases.
Sweeping collected entries. The cache would remove every entry whose instance has been collected, on each put() or
in a periodic pass. We rejected this because each entry it would free is one empty weak reference, one for each class,
name and qualifier class combination given a liminal resolution. Those combinations grow only when names are built at
runtime, such as one per server, for the life of the worker process that
ADR-0009 keeps running across requests.
Such names grow the strong cache in the same way, where each entry holds the instance itself, so sweeping the weak cache
would not address that growth.
No other alternatives were weighed.
Backwards compatibility
These will change for code that resolves through the container today:
- A circular dependency will throw
CircularDependencyExceptioninstead of exhausting the call stack. - A shared binding resolved through an alias more than once will return the same instance. Today each resolution through the alias constructs a new one, unless the binding holds an instance, and replaces the instance cached under the abstract.
- A resolution through an alias of a binding with no concrete class will construct the
binding's abstract. Today the container constructs the class as requested, so resolving an interface that aliases a
binding for its implementation throws
NotInstantiableException. - A resolution through an alias of an alias will construct and share the class the first alias points to. Today the binding lookup finds no binding, and the container constructs the class as requested.
- A shared resolution made liminal by its binding or by a class carrying
Liminalwill return the same instance while it is alive. Today each resolution that does not itself ask to be liminal constructs a new one, unless the binding holds an instance. - A liminal resolution with a name or qualifier will be cached under it, and a liminal resolution without one will no longer return an instance built for a named binding or qualified binding. Today every liminal instance of a class is written under the class alone and read under the name or qualifier, so a named or qualified liminal resolution never finds its instance.
- Qualified shared instances will be keyed by qualifier class, as RFC-0001
specifies, so two qualifiers of one class with different values will share one instance. Today they are matched with
Qualifier::equals()and get separate instances. The container will no longer callequals(). - A class with no binding will be shared, as RFC-0001 specifies. Today it is constructed again on every resolution.
- A binding marked lazy will return a lazy proxy, as
RFC-0001 specifies, or throw
NotInstantiableExceptionwhere the proxy would be made for an interface or an abstract class, such as for a factory binding of an interface. Today the container ignores the flag and resolves such a binding at once. LazyandLiminalwill apply when carried by the requested class or by the binding's concrete class, including for a binding with an instance or a factory. Today they are read only from the class the container constructs, and only when it constructs one. RFC-0001 applies them regardless of any binding, but the class of an instance a factory returns will not be consulted.NoResolutionwill apply when carried by either class of a resolution the container constructs, so an abstract carrying it and bound to a concrete class will throw. Today it is read only from the class the container constructs. RFC-0001 applies it only where the binding has no instance, factory or concrete class, so this changes its design as well as the code.- A lazy resolution to which
NoResolutionapplies, with no instance or factory, will throw when it is requested. Today it returns a lazy proxy, and throws when the proxy is first used. - A lazy proxy will be made for the binding's concrete class where it has one. Today it is made for the class as
requested, and a proxy for an interface or an abstract class throws PHP's own
Error, where it will throwNotInstantiableException. - A lazy resolution of a class with no properties that could trigger initialisation will be constructed straight away and cached, as RFC-0001 specifies. Today PHP returns an object whose constructor never runs, and it is not cached.
- A lazy proxy given an instance PHP does not accept for it, such as a factory's instance of a subclass, will throw
DependencyResolutionExceptionwhen it is first used. Today PHP throws its ownTypeError. - A resolution given both a name and a qualifier will throw
InvalidResolutionException. Today the name is used and the qualifier is ignored.
Open questions
Changelog
- : Reworded in the readable house style, and the project's own vocabulary linked to the glossary. The design is unchanged.
- : Settled collected weak entries. They stay in the weak cache until the next
put()under the same entry replaces them, and removing them on read and sweeping them are recorded as rejected alternatives. - : Revised after review. A stack entry is added only once a resolution is past the cached instance and lazy
proxy steps, so a lazy request breaks a cycle, and
Lazyis applied before construction. Class-level attributes apply when carried by the requested class or the binding's concrete class. The alias step and the binding lookup each replace an alias once. A resolution given both a name and a qualifier throwsInvalidResolutionException. The alternatives the issues name are recorded, and Backwards compatibility lists what changes against the code. - : Revised after a second review. The lazy proxy step applies the binding's lazy flag, checks
NoResolutionfirst, makes the proxy for the binding's concrete class, throwsNotInstantiableExceptionwhere that class is an interface or abstract, and runs the resolution again from the alias step. A binding given an object has its class consulted for class-level attributes. Alternatives the issues name for the qualifier dimension and the stack's key are recorded, the sweeping rejection states its reason, and Backwards compatibility adds the changes the revision makes. - : Revised after a third review. A class with no properties that could trigger initialisation is resolved
straight away, as in RFC-0001, instead of being given a lazy proxy. A class extending an internal PHP class throws
NotInstantiableExceptionas interfaces and abstract classes do, and an instance PHP does not accept for a proxy throwsDependencyResolutionException. A circular dependency is no longer called a cycle, and its message names each entry's name or qualifier class. Backwards compatibility adds the changes to lazy bindings,NoResolution, chained aliases and property-less classes, and the Motivation covers aliases, qualifiers and lazy proxies.
Sources
- Issue #43, Engine - Container - Memoise class-level attribute lookups, : memoising class-level attributes, and making class-level liminality available when the cache is checked.
- Issue #44, Engine - Container - Alias cache key mismatch, , edited the same day to land after #45:
normalising aliases once before the cache is consulted, exposing alias normalisation on
BindingCatalogue, and chained aliases left to building catalogues. - Issue #45, Engine - Container - Introduce InstanceCache, : the instance cache, its strong and weak
factories, its class and name dimensions, keeping the weak reference internal, two caches rather than weak
references throughout, static factories rather than an enum or a flag, reordering the branches and a conditional
return type rejected, alias normalisation staying in the container, and collected weak entries left undecided. It
kept the qualifier dimension as a linear scan matching qualifiers with
equals(), which this design does not adopt. - Issue #46, Engine - Container - Liminal information parity, : looking the binding up before the cache check, and working out liminality once from the resolution, the binding and the class attribute for both reading and writing.
- Issue #47, Engine - Container - Circular dependency detection, : the resolution stack, removed however
the resolution finishes rather than only when it returns, detection observing only eager construction, which it
proposed to achieve by adding entries on entry,
CircularDependencyExceptioncarrying the chain in order, being final and extendingRuntimeException,Lazyon both classes of a circular dependency andGhoston a parameter still breaking it, and sequential resolution of one class not counting as a circular dependency. It keyed the stack by class alone, which this design does not adopt. - The instance cache keying qualified instances by qualifier class, resolution stack entries being identified the same way as instance cache entries, so that named and qualified child bindings of one abstract are not reported as circular dependencies, and the chain running from the repeated entry's first appearance to its repetition: first written down on .
- Collected weak entries being kept until the next
put()replaces them, with removal on read and sweeping rejected: first written down on . - Stack entries added only once a resolution is past the cached instance and lazy proxy steps, in place of #47's
addition on entry, with
Lazyapplied before construction and ghosts not touching the stack; class-level attributes applying when carried by the requested class or the binding's concrete class; the alias step and the binding lookup each replacing an alias once; a resolution with both a name and a qualifier throwingInvalidResolutionException; the proxy being made for the binding's concrete class,NotInstantiableExceptionwhen that class is an interface or abstract, the proxy's resolution running again from the alias step, andNoResolutionchecked before a proxy is returned; and reading attributes from one class only, a name taking precedence over a qualifier, and resolving eagerly when a proxy cannot be made, all rejected: first written down on . - A class extending an internal PHP class throwing
NotInstantiableException, an instance PHP does not accept for a proxy throwingDependencyResolutionException, and the circular dependency message naming each entry's name or qualifier class: first written down on . - The binding's lazy flag applying at the lazy proxy step, and a class with no properties that could trigger initialisation being resolved straight away, as RFC-0001 specifies: first written into these steps on .
Source rfc/0006-container-improvements.md · commit 072385a · exported