iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🕌

[Swift] Effective Use of Protocol Extensions

に公開

I am keeping a note here of my investigation into the internal implementation of a Swift library called Instantiate.

About Instantiate

Instantiate (GitHub) implements convenient protocols that handle things nicely when you want to perform DI (Dependency Injection) for classes that ViewController subclasses depend on.

Reference: Explanation of DI (Dependency Injection) - Qiita

For example, let's say there is a situation where a ViewController needs to depend on a ViewModel.

let storyboard = UIStoryboard(name: "ViewController", bundle: Bundle.main)
let vc = storyboard.instantiateInitialViewController() as! ViewController
vc.inject(ViewModel())

As shown above, you could declare the ViewController and then call the ViewModel via some method (in this case, the inject method), but this allows for human errors such as forgetting to call the method.

By using Instantiate, you can guarantee the injection at the time of initialization as follows:

let vc = ViewController(with: ViewModel())

I see, that's convenient!

It looks quite concise, but how is this implemented?

Injectable Protocol

First, regarding how to set the class you want to depend on (the ViewModel in the example above) into the ViewController, you simply make it conform to a specific protocol.

If you are building the ViewController's UI with a Storyboard, use StoryboardInstantiatable; if it's a Nib, use NibInstantiatable.

Also, InstantiateStandard automatically detects files with the same class name and handles them appropriately, so it's good to import it.

import Instantiate
import InstantiateStandard
class ViewController: UIViewController {
}

extension ViewController: StoryboardInstantiatable {
  func inject(_ dependency: ViewModel) {
    viewModel = dependency
  }
}

This StoryboardInstantiatable inherits from Injectable.

Furthermore, during ViewController initialization, it internally generates the ViewController from the Storyboard and calls the inject method for you.

And this inject method is pre-implemented as a protocol extension.

(Simplified for clarity)

Injectable.swift

protocol Injectable {
  associatedtype Dependency = Void
  func inject(_ dependency: Dependency)
}

extension Injectable where Dependency == Void {
  func inject(_ dependency: Dependency) {
  }
}

Storyboard+UIViewController.swift

protocol StoryboardInstantiatable: Injectable, OtherProtocols {
...
}

The original Injectable has Dependency = Void, and the type is already specified.

This raises the question: if used as is, wouldn't it be impossible to specify types like ViewModel, resulting in Void being used instead?

extension ViewController: StoryboardInstantiatable {
  func inject(_ dependency: Void) {
  }
}

Trying to create a protocol

You might deepen your understanding by preparing a Playground and actually trying it out while reading.

To follow the specifications of this protocol, the first thing to understand is associatedtype.

First, let's define a protocol using associatedtype for Dependency as shown below. (*1)

Additionally, each VC can define it as a typealias and implement it as a concrete type.

protocol AProtocol {
  associatedtype Dependency
  func call(dependency: Dependency)
}

extension AProtocol where Self: UIViewController {
  func call(dependency: Dependency) {
    print("AProtocol: \(dependency)")
  }
}

class VoidVC: UIViewController, AProtocol {
  typealias Dependency = Void
}
VoidVC().call(dependency: ()) // AProtocol: ()

class StringVC: UIViewController, AProtocol {
  typealias Dependency = String
}

StringVC().call(dependency: "aaaaa") // AProtocol: aaaa

Now, let's try defining it within the protocol itself while specifying a particular type. (*2)

We can see that it becomes omittable in the class that inherits from the protocol (VoidVC in this case).

On the other hand, in StringVC, it can be overwritten as Dependency = String.

protocol AProtocol {
  associatedtype Dependency = Void
  func call(dependency: Dependency)
}

class VoidVC: UIViewController, AProtocol {}

class StringVC: UIViewController, AProtocol {
  typealias Dependency = String
}
...

In the above, calling the call method from either VC yields the same result as (*1).
Now, let's implement the call method.

class StringVC: UIViewController, AProtocol {
  typealiase Dependency = String
  func call(dependency: Dependency) {
    print("StringVC: \(dependency)")
  }
}

StringVC().call(with: "bbbbb") // StringVC: bbbbb

You can also write it in a shorthand form as follows.

class StringVC: UIViewController, AProtocol {
  func call(dependency: String) {
    print("StringVC: \(dependency)")
  }
}

If you look closely at the argument of the call method, it looks different between Dependency and String.

As some of you might have noticed, by defining a concrete type for the call method's dependency, the compiler automatically interprets it as typealiase Dependency = String and allows the compilation to succeed.

This is the mechanism that allows various types, such as ViewModels, to be injected as dependencies.

Summary

In this article, we looked at the internal implementation using the library Instantiate as a case study.

  • Usage of associatedtype and typealias
  • About protocol extension

I hope this helps your understanding of the topics above at least a little bit.

It is very helpful to see how the Swift language specifications are used so effectively. 👏

Investigating how OSS is implemented like this is a great learning experience, so I hope to continue doing it.

Thank you for reading to the end!

Discussion