It’s often the case that dependency inversion patterns do a good job of hiding some implementation details, like what dependencies are required to instantiate some specific implementation or what backend/data retrieval process is being exactly utilized. However they are bad at hiding other implementation details, namely most effects. If your repository’s implementation has functions that require asynchronicity, error handling, or branching capability, those details will leak into the abstraction, otherwise the implementation’s function signature wouldn’t be valid.
The Environment Function (part 1) Link to heading
This pattern differs from most other dependency inversion patterns in that we’re not defining an abstract type to hold a bunch of abstract functions, we want separate abstract functions.
So while you might be used to something like:
protocol MatchInfoSource {
func fetchPlayerIds() -> [String]
func fetchMatchIds(playerId: String) -> [String]
func fetchMatch(matchId: String) -> String
}
We’d instead have each function in its own abstraction.
// callAsFunction is how we call an object as a function
protocol FetchPlayerIds { func callAsFunction() -> [String] }
protocol FetchMatchIds { func callAsFunction(playerId: String) -> [String] }
protocol FetchMatch { func callAsFunction(matchId: String) -> String }
What this allows is for implementations to not specify more than they have to. If we have some function that requires fetchPlayerIds, we don’t want it to require fetchMatchIds and fetchMatch just because those 3 functions are tied together in a protocol.
This does mean however that we’ll require more dependencies overall. Requiring the dependencies at a value level in this pattern will be essentially automatic because they’ll be required by the environment, and at the type level we can still combine dependencies into groups if they’re often required together.
What “required by the environment” means exactly is dependent on the language/framework. Sometimes what looks like just normally calling the function is actually requiring the function in the current environment and then calling it. Other times you’re building up sequence of data structures that will be later interpreted. For the purposes of this pattern, these two approaches are very similar.
Current Usage in Swift Link to heading
As it stands right now, the cost of this pattern is likely too high in Swift to commonly use, because there are certain abstractions that are impossible to make in Swift. There is a good chance this will change down the line. In the Generics Manifesto there are essentially three categories of items. Those at the top that are implicitly “we want to add these”, and then towards the bottom the “Maybe” and “Unlikely” sections. Generic associated types are near the top, while higher kinded types are in the “Maybe” section.
The rest of the article will have code examples in 3-4 different formats. GAT (Generic Associated Types), HKT (Higher Kinded Types), Vanilla (idiomatic swift), and Gross (attempting to do the pattern in modern swift, often extremely verbose and even unsafe).
GATs are simply adding the capability for associated types to themselves also be generic, this is something Rust added and Swift has been taking a lot of inspiration from Rust lately. HKTs assumes certain restrictions around constraints and single protocol conformances are lifted, so that requires a much deeper redesign of the type system, making it far less likely. The codeblocks will also have optional “Explanation” blocks afterwards, and a + sign will be attached to the tab if it has an explanation.
To showcase this, here’s a small example of a Mappable protocol in the different versions (this would generally be in a library):
and then usage of each (this is what you’d normally see in your codebase):
If Mappable makes sense, congratulations you understand what Functors are and how to use them, Mappable is just a different name for Functors.
Dependencies Link to heading
Now back to the pattern, what does “required by the environment” actually mean? It means rather than passing arguments down a chain of function calls, dependencies are resolved by some other mechanism. There are several things to consider here
- Are dependencies resolved at compile time or runtime?
- Does our method of dependency management compose with other effects?
- How much boilerplate is there for resolving dependencies?
Readers are a fantastic way to manage dependencies if you can write monadic code. The dependencies are resolved at compile time, they do compose in the same way we compose other effects with monads, and there’s either very little boilerplate if you have HKT/GATs.
First let’s focus on what a reader actually is, here’s the minimal viable definition of a Reader:
struct Reader<Env, A> {
let run: (Env) -> A
}
They’re really just functions with different semantics. Everything you can do with a Reader you can do with a function. The difference in semantics is that the input to the function is viewed as the “environment” that a value exists in. So while we might think of a function as having an input and an output, a reader has a value (output) that exists in an environment (input).
This means that managing dependencies with just the Reader struct can be viewed the same way as managing dependencies with just parameters to a function. We will use other abstractions on top of this to make it much more powerful though.
The most common operation you’ll see with readers is ask:
static func ask<Env>() -> Reader<Env, Env> {
Reader { env in env }
}
With function semantics, this is just the identity function (x -> x), in the context of readers you’re asking for the current environment.
Effect Fundamentals Link to heading
When thinking about effects though, we want to change our perspective from concrete data structures to thinking about capabilities. This is where the idea of a monad comes in. Monads allow us to bind/sequence effects together, and whatever monad we’re operating in defines our set of capabilities. Monad requires two operations
flatMap is often called bind (or >>=), since that describes what it is rather than how it does it. With flatMap you think “map then flatten”, with bind you think “bind effects”. Only choosing flatMap since that’s the norm in Swift.
The first of these two operations is pure, which is for putting a value into a monadic context. For example, if you want to put the value 4 into the List monad:
// Assuming we or a library defined an `extension List : Monad`
let nums: [Int] = .pure(4) //> [4]
The second operation flatMap let’s you combine two effects. What this mean depends on the effect. Let’s take the Result monad as an example:
// Assuming we or a library defined an `extension Result : Monad`
let someSuccess: Result<Int, String> = .flatMap(.success(4)) { .success($0 * $0) }
//> Result.Success(16)
let someFailure: Result<Int, String> = .flatMap(.failure("no")) { .success($0 + 1) }
//> Result.Failure("no")
The result monad gives us short-circuiting capability (usually given by Either in FP languages). While Swift throws is much better than say Java or other languages that don’t have type-safe throws, it still doesn’t compose with other effects nicely.
Effectful Dependencies Link to heading
With an understanding of Monad and Reader, we can now define MonadReader. MonadReader isn’t the monad implementation for reader, it’s rather “some monad that has reader-like capabilities”. Of course, Reader fits this description, but many other types can too. The hierarchy here goes:
Functor <- Monad <- MonadReader <- Reader
Here is the MonadReader:
protocol MonadReader: Monad {
associatedtype Env
static func ask() -> M<Env>
static func local<A>(_ transform: (Env) -> Env, _ ma: M<A>) -> M<A>
}
More Effects Link to heading
You can similarly define other “effects” like MonadTask that encapsulates the capability of Task, or MonadError that has the capability of Result’s short-circuiting. These capabilities are very similar to async and throws, but instead of requiring different (and sometimes combined) syntax to unwrap them, you always use <- to unwrap them.
The Environment Function (part 2) Link to heading
Now that we have a better idea of how our effect system works, let’s implement our environment functions again:
protocol EnvFn {
associatedtype M<_>: Monad
associatedtype Input
associatedtype Output
func run(_ input: Input) -> M<Output>
}
extension EnvFn {
static func callAsFunction(_ input: Input) -> M<Output>
where M: MonadReader<M, Self> { M.flatMap(M.ask()) { $0.run(input) } }
}
private typealias MR<M<_>, N<_<_>>> = MonadReader<M, N<M>>
protocol HasApi<M> = MR<M, FetchPlayerId> & MR<M, FetchMatchId> & MR<M, FetchMatch>
protocol HasDb<M> = MR<M, QueryMatchIds> & MR<M, InsertMatch>
protocol HasTracking<M> = MR<M, SkipRestIfMatchIdTracked> & MR<M, TrackMatchids> & MR<TrackSuccess>
protocol FetchPlayerId<M<_>>: EnvFn where Input == Void, Output == String {}
protocol FetchMatchid<M<_>>: EnvFn where Input == String, Output == String {}
protocol FetchMatch<M<_>>: EnvFn where Input == String, Output == String {}
protocol QueryMatchIds<M<_>>: EnvFn where Input == Void, Output == [String] {}
protocol InsertMatch<M<_>>: EnvFn where Input == Match, Output == Void {}
protocol SkipRestIfMatchIdTracked<M<_>>: EnvFn where Input == String, Output == Void {}
protocol TrackMatchIds<M<_>>: EnvFn where where Input == [String], Output == Void {}
protocol TrackSuccess<M<_>>: EnvFn where Input == Void, Output == Void {}
func fetchAndSaveGame<M<_>: HasApi & HasDb & HasTracking>() -> M.M<Void> {
#monadic {
ids <- QueryMatchIds()
_ <- TrackMatchIds(ids)
playerId <- FetchPlayerId()
matchId <- FetchMatchId(playerId)
_ <- SkipRestIfMatchIdTracked(matchId)
_ <- TrackMatchIds([matchId])
match <- FetchMatch(matchId)
_ <- InsertMatch(match)
TrackSuccess()
}
}
`
Explanation:
The EnvFn definition here is to help us with the boilerplate of the pattern. Swift's static inheritance is incredibly helpful for reducing protocol boilerplate.