Generalizing Swift code
Discover page available: GenericsDeciding whether or not to generalize a piece of code to fit more than one use case can sometimes be quite tricky. While adapting a function or type to be usable in multiple parts of a code base can be a great way to avoid code duplication, making things too generic can often lead to code that’s hard to both understand and maintain — since it ends up needing to do too much.
This week, let’s take a look at a few key factors that can help us strike a nice balance between being able to reuse as much of our code as possible, while also avoiding making things too complicated or ambiguous in the process.
Starting with a specific implementation
In general, a great way to avoid over-generalizing code is to build an initial version with a very concrete, specific use case in mind. It’s often easier to make a new piece of code do one single thing well, rather than focusing on optimizing it for reuse right away — and as long as we make sure to separate concerns and design clear APIs, we can always refactor things to become more reusable once needed.
Let’s say that we’re working on some form of e-commerce app, and that we’ve built a class to let us load a Product
based on its identifier — looking like this:
class ProductLoader {
typealias Handler = (Result<Product, Error>) -> Void
private let networking: Networking
private var cache = [UUID : Product]()
init(networking: Networking) {
self.networking = networking
}
func loadProduct(withID id: UUID,
then handler: @escaping Handler) {
// If a cached product exists, then return it directly instead
// of performing a network request.
if let product = cache[id] {
return handler(.success(product))
}
// Load the product over the network, by requesting the
// product endpoint with the given ID.
networking.request(.product(id: id)) { [weak self] result in
self?.handle(result, using: handler)
}
}
}
Looking at the above code sample, we can see that the main responsibility of our ProductLoader
is to check whether the requested product has already been cached, and if not, start a network request to load it. Once a response is received it then decodes its result into a Product
model, and caches it — using a private handle
method that looks like this:
private extension ProductLoader {
func handle(_ result: Result<Data, Error>,
using handler: Handler) {
do {
let product = try JSONDecoder().decode(
Product.self,
from: result.get()
)
cache[product.id] = product
handler(.success(product))
} catch {
handler(.failure(error))
}
}
}
While the above class is, at the moment, completely Product
-specific — there’s really nothing about the work that it does that’s unique to products. In fact, there are only three things that our ProductLoader
needs to be capable of:
- Check whether a given cache entry exists.
- Ask the injected
Networking
instance to request an endpoint. - Decode network response data into models.
Looking at the above list, there’s nothing that stands out at something that we’d only want to do for products — we actually need to perform the exact same set of tasks to load any model within our app — such as users, campaigns, vendors, and so on. So let’s look into how we could generalize ProductLoader
, by enabling the same code to be used to load any model.
Generalizing a core piece of logic
What makes our ProductLoader
such a great candidate for generalization, besides the fact that we’ll need the exact same logic in multiple parts of our code base, is that its implementation only consists of very general-purpose tasks — such as caching, networking, and JSON decoding. That should let us keep more or less the same implementation, while still opening up our API to more use cases.
Let’s start by renaming our product loader to ModelLoader
, and make it a generic that can work with any Model
type that conforms to Decodable
. We’ll let it keep the same properties and initializer, apart from the fact that we’ll now also require a function that produces an Endpoint
to be injected as part of the initializer — since different models may be loaded from different server endpoints:
class ModelLoader<Model: Decodable> {
typealias Handler = (Result<Model, Error>) -> Void
private let networking: Networking
private let endpoint: (UUID) -> Endpoint
private var cache = [UUID : Model]()
init(networking: Networking,
endpoint: @escaping (UUID) -> Endpoint) {
self.networking = networking
self.endpoint = endpoint
}
}
When it comes to our main loading method, we’ll rename it to loadModel
, and make it use the injected endpoint
function to produce an Endpoint
to call when performing network requests — like this:
extension ModelLoader {
func loadModel(withID id: UUID,
then handler: @escaping Handler) {
if let model = cache[id] {
return handler(.success(model))
}
networking.request(endpoint(id)) { [weak self] result in
self?.handle(result, using: handler, modelID: id)
}
}
}
Finally, we’ll update our private handle
method to decode instances of its generic Model
type, rather than just Product
values. Since we can no longer rely on the decoded product’s ID for caching, we also have to pass the requested model’s ID down from our top-level loadModel
method as well:
private extension ModelLoader {
func handle(_ result: Result<Data, Error>,
using handler: Handler,
modelID: UUID) {
do {
let model = try JSONDecoder().decode(
Model.self,
from: result.get()
)
cache[modelID] = model
handler(.success(model))
} catch {
handler(.failure(error))
}
}
}
With the above in place, we’ve now successfully generalized our previous ProductLoader
into a generic type that can be used to load any decodable model — all without drastically changing either its implementation or API. The only difference when it comes to the call sites, is that we’ll now call loadModel
instead of loadProduct
, and we also need to pass an Endpoint
-producing function when initializing loader instances:
let productLoader = ModelLoader<Product>(
networking: networking,
endpoint: Endpoint.product
)
let userLoader = ModelLoader<User>(
networking: networking,
endpoint: Endpoint.user
)
Since our endpoint
parameter expects a function that produces an Endpoint
for a given ID, we’re passing both the product
and user
endpoints as first class functions above — which works great regardless if our server endpoints are described using an enum, or if we’re using static factory methods, like in “Constructing URLs in Swift”.
Domain-specific conveniences
Generalizing code so that it can be used for multiple different models — or in other words, within multiple domains — can be a great way to both reduce code duplication and make the architecture of a system a bit more consistent. However, doing so could also make it harder to figure out how a given type fits into the bigger picture.
When working with a type called ProductLoader
, it’s quite obvious what it does and what part of our code base that it belongs to — while ModelLoader
can sound much more ambiguous. However, there’s a few ways that we can mitigate that problem. One way is to use type aliases to bring back our model-specific type names, without actually having to maintain duplicate implementations under the hood:
typealias ProductLoader = ModelLoader<Product>
typealias UserLoader = ModelLoader<User>
Another way we can tweak ModelLoader
to make it feel a bit more connected to any given model, is to create domain-specific convenience APIs — for example by letting us skip the endpoint argument, by instead inlining it using a convenience initializer:
// Note how we can extend our type alias directly, which is
// equivalent to extending ModelLoader where Model == Product.
extension ProductLoader {
convenience init(networking: Networking) {
self.init(networking: networking,
endpoint: Endpoint.product)
}
}
Doing something like the above may seem unnecessary, but it can have a big impact on just how easy our new ModelLoader
is to use — especially if we’re creating multiple instances of it throughout our code base. It can also be a great way to make it backward compatible with our old ProductLoader
, since no call sites need to be updated if we add conveniences that make our new API completely match our old one.
The power of shared abstractions
The benefits of generalizing code are not only limited to reducing code duplication — generalizing a core set of logic can also be a great way to create a common foundation on top of which we can build powerful shared abstractions.
For example, let’s say that we keep iterating on our app, and at some point we find ourselves needing to load multiple models in one go in several different places. Rather than having to write duplicate logic for each kind of model, since we now have our generic ModelLoader
, we can simply extend it to add the API that we need — which lets us load an array of any kind of model, given any sequence of IDs:
extension ModelLoader {
typealias MultiHandler = (Result<[Model], Error>) -> Void
// We let any sequence be passed here, since some parts of
// our code base might be storing IDs using an Array, while
// others might be using a Dictionary, or a Set.
func loadModels<S: Sequence>(
withIDs ids: S,
then handler: @escaping MultiHandler
) where S.Element == UUID {
var iterator = ids.makeIterator()
var models = [Model]()
func loadNext() {
guard let nextID = iterator.next() else {
return handler(.success(models))
}
loadModel(withID: nextID) { result in
do {
try models.append(result.get())
loadNext()
} catch {
handler(.failure(error))
}
}
}
loadNext()
}
}
Note how the above example isn’t the most efficient way to load multiple models at once — since it’s completely sequential. For a more thorough implementation of performing a group of tasks in parallel — see “Task-based concurrency in Swift”.
Just like how we before added domain-specific conveniences on top of our generalized core APIs, we can do the same to wrap the above loadModels
method to create model-specific versions of it — such as this one, which lets us load all of the products within a given bundle, and then apply a discount to them:
extension ModelLoader where Model == Product {
func loadProducts(in bundle: Product.Bundle,
then handler: @escaping MultiHandler) {
loadModels(withIDs: bundle.productIDs) { result in
do {
let products = try result.get().map {
$0.applying(bundle.discount)
}
handler(.success(products))
} catch {
handler(.failure(error))
}
}
}
}
Using the above setup — generic, core logic at the bottom, and domain-specific APIs on top — can be a great way to achieve a sort of “best of both worlds” between code reuse and keeping our top-level APIs as simple as they possibly can be.
We still need domain-specific types
However, not all code should be generalized — and even though we can augment our generic types using type aliases and extensions, sometimes we just need a good old fashioned domain-specific API. When a type is performing a task that really only makes sense within a given domain, it’s most likely best to just hard-wire it to perform that single task well, rather than over-abstracting things.
Here’s an example of such a type, a controller class that handles purchases — which, at the moment, is logic that we only need to perform within the realm of products:
class ProductPurchasingController {
typealias Handler = (Result<Void, Error>) -> Void
private let loader: ProductLoader
private let paymentController: PaymentController
init(loader: ProductLoader,
paymentController: PaymentController) {
self.loader = loader
self.paymentController = paymentController
}
func purchaseProduct(with id: UUID,
then handler: @escaping Handler) {
loader.loadModel(withID: id) { result in
// Perform purchase
...
}
}
}
Note how the above ProductPurchasingController
uses our new ModelLoader
API, through the ProductLoader
type alias. Other than the fact that it calls loadModel
, rather than loadProduct
, there’s really no telling that it’s actually using a completely generic type — it fits right at home within the domain we’re currently working within.
Conclusion
When working with types or functions which logic isn’t actually tied to any specific domain — generalizing that logic to be reusable in multiple different scenarios can be a great way to both unify the core of our code base, to avoid code duplication, and to enable powerful shared abstractions to be built on top of that logic.
However, while it can be tempting to attempt to generalize all of our low-level logic, sometimes doing so can just create unnecessary complication without any real benefits. The key is often to find the logic that’s generic enough that it lends itself well to generalization, and to have multiple concrete use cases available that it can be applied to.
What do you think? What’s your strategy when it comes to generalizing and reusing code within your code base, and will you start applying some of the strategies from this article? Let me know — along with your questions, comments, or feedback — either via Twitter or email.
Thanks for reading! 🚀