Dynamic scoping is the simplest form of dependency injection
Once upon a time programmers used to debate whether programming languages should be lexically or dynamically scoped. That might sound like gibberish to you but I think there’s an important lesson about modern software engineering to be learnt here, so allow me to explain.
Scoping: lexical or dynamic?
The question of lexical or dynamic scoping essentially boils down to this:
greeting is not defined inside the function
we have to look into the outside environment to determine its value.
- In a lexically scoped language we will look into the environment in which
printGreetingis defined and see that
greetinghas been assigned the value
- In a dynamically scoped language we will look into the environment in which
printGreetingis executing and see that
greetinghas been assigned the value
If dynamic scoping seems very weird to you, it likely is partly because you’ve never seen it before, and partly because it actually is very weird. Almost all modern programming languages are lexically scoped1. Dynamic scoping makes it hard to figure what our program actually does, without executing it, and that’s not a quality we want our programs to exhibit.
Dynamic scoping might seem dubious at first glance, but I’m going to argue that it can be tamed, and that it can then can be used to accomplish dependency injection with fewer drawbacks than other methods. First, let’s survey those other methods.
Dependency injection via objects
Let’s switch example language to java.
Consider the following piece of hypothetical code which handles an order in a web shop:
acceptOrder function above has some problems when it comes to testability.
We simply cannot call it without incurring severe side effects on the outside world;
charging money and sending mails are not stuff we want to do in test.
Commonly accepted wisdom tells us that the above problem can be solved by introducing objects and accepting our dependencies in the constructor:
Now two things have happened.
First of all, our code got much longer. However, this is mostly Java’s fault and not something inherently wrong with objects.
Second, we can now pass in different implementations of our dependencies when executing in test2. This is very good, but let me rephrase that in more general terms: the values associated with certain names are now dependent on the environment in which we are executing. This should sound very familiar.
Object construction is hard
The problem with objects is that they have to be constructed somewhere. To
OrderService we also need to build a
SupplyService, and whatever
SupplyService depend upon and so forth. This can easily turn into a huge
hassle where shared dependencies lead to code duplication and just thinking of
having to add a new dependency to an object makes you groan in annoyance.
One solution to this problem is to write a factory to construct our objects for us. This can remove all code duplication which makes changing dependencies easy again, but it still leads to lots of brain dead code that we’d rather not write. In practice many organizations end up forgoing their factories in favor of more dynamic dependency injection frameworks that construct objects using reflection, like Spring or Guice.
One alternative way to solve the testability problem would be to introduce a container for all things we want to change when testing:
Now we only had to construct objects for those services that we want to change
when testing. In this small example it didn’t have much effect on the code size
but in a large project it could remove a lot of useless objects. If we preferred to, we could also put
regular function references in the
Env, instead of service objects.
Finally, we could also remove the
Env entirely and pass around the services one by one, but
this quickly leads to an unmaintainable mess.
Here we are looking up names in an environment which we got passed to us by our caller. This again should sound very familiar.
You might think that this results in an entangled mess where every module depend upon every other module.
However, this can be tamed by having each module declare its own
Env interface and having the real
Env implement them all.
The real problem with this style of programming is that we have to pass the
Env around everywhere.
The reader monad is a technique that let’s us implicitly pass around an extra argument to
every function that declares that it returns a value of type
ReaderT. This seems like a great
compliment to the
Env passing style, let’s see how it can be used in Haskell:
By using a reader monad we avoid having to pass the
Env around, however,
the price we pay is having to use monad transformers. Honestly,
reader monads do have a lot of nice properties, but I don’t think they are worth
the inevitable fight with the type checker. I only have around 6 months of full time experience
with Haskell and I fully expect that this would turn into a non-problem after a few years.
But do I want to put in that time? Can I get my friends and co-workers to put in that time?
Explicit dynamic scoping
Some lexically scoped programming languages, like Perl and most Lisps, allow us to explicitly declare that a certain name should be dynamically scoped. Let’s explore how that would be done in Clojure34.
First let’s define a dangerous function that we don’t want called in test:
Then let’s define a test double:
Finally, let’s use the function:
Take note of how
send-nukes is namespaced. This ensures that we only swap out the
function we intend to, while others with the same name are left alone – even if they
too are defined as
^:dynamic. Namespaces are the magic sauce that make dynamic scoping behave in a sane way.
Selective dynamic scoping allows us to do exactly what we want: write testable code without harming our design5. It’s a shame that this feature it isn’t available in more languages.
Some examples of languages that use dynamic scoping by default are APL, Latex and Emacs Lisp. ⤴
There are reasons other than testability for wanting to swap out one implementation for another. However, testability is the most common reason and the solutions are the same regardless of our reasons. ⤴
There is a minor gotcha with threading though. Before spawning a new thread you have to wrap its main function using
bound-fn, or else it will use the default bindings. ⤴
Clojure also has
with-redefswhich lets you temporarily swap out lexically scoped functions. However, this is not thread-safe. ⤴