🕌

[Swift]protocol extensionの上手な使い方

2020/11/30に公開

InstantiateというSwiftライブラリの内部実装を調査した内容をメモがわりに置いておきます。

Instantiateについて

Instantiate(GitHub)はViewControllerのサブクラスなどが依存するクラスをDI(Dependency Injection)したい時などに、よしなにしてくれる便利なprotocolが実装されています。

参考: DI(Dependency Injection) の解説 - Qiita

例えば、ViewControllerがViewModelを依存させたい!という場面があることとします。

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

上記のようにViewControllerを宣言してからViewModelをなんらかのメソッド(今回はinjectメソッド)で呼び出しても良いですが、これではメソッドの呼び忘れなどのヒューマンエラーが起こりうるのです。

そこで、Instantiateを利用すると下記のように初期化時に保証できるようになります。

let vc = ViewController(with: ViewModel())

なるほど、便利ですね!

かなり簡潔に見えますがこれはどうやって実装されているのでしょうか?

Injectableプロトコル

まず、依存させたいクラス(上記ではViewModel)をどうやってViewControllerに設定するかですが、下記のように特定のprotocolに準拠させるだけです。

StoryboardでViewContorllerのUIを作っていればStoryboardInstantiatableで、NibであればNibInstantiatableになります。

また、InstantiateStandardは同じクラス名のファイルを自動的に検出していい感じにしてくれるのでimportすると良いです。

import Instantiate
import InstantiateStandard
class ViewController: UIViewController {
}

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

このStoryboardInstantiatableinjectableを継承しています。

また、ViewController初期化時に、内部でStoryboardからViewControllerを生成し、injectメソッドを呼んでくれています。

そして、このinjectメソッドはprotocol extensionとしてあらかじめ実装されています。

(わかりやすさのために簡略化しています)

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, 他のprotocol {
...
}

もともとのInjectableにはDependency = Voidとあり、すでに型が指定されています。

そのまま使うとするならViewModelなどの型を指定できず、Voidが入ってしまうのでは?という疑問が出てきます。

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

protocolを作ってみる

Playgroundでも用意して実際に試しながら読むとさらに理解が深まるかと思います。

このprotocolの仕様を追うのにまず理解すべきはassociatedtypeについてです。

まずは下記のようにprotocolでassociatedtypeとしてDependencyを用いるように定義してみます。(*1)

また、各VCがtypealiasとして定義し具体型として実装できます。

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

では、ここでprotocol自身で定義しつつ特定の型を指定してみましょう。(*2)

protocolを継承したclass(ここではVoidVC)では省略可能であることがわかります。

一方、StringVCでは Dependency = Stringとして上書きできます。

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

class VoidVC: UIViewController, AProtocol {}

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

上記ではどちらのVCからcallメソッドを呼んでも、( *1 )とおなじ結果が得られます。
ではcallメソッドを実装してみましょう。

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

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

上記を省略した形で下記のように書くこともできます。

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

callメソッドの引数に注目するとDependencyStringと違って見えます。

もうお気づきの方がいるかもしれませんが、callメソッドのdependencyに具体型を定義することで、コンパイラがtypealiase Dependency = Stringとして勝手に解釈してコンパイルが通るようになります。

これがViewModelなどのように様々な型を依存させることができる仕組みです

まとめ

今回はInstantiateというライブラリを題材に、内部実装をみていきました。

  • associatedtypetypealiaseの使い方
  • protocol extensionについて

上記について少しでも理解の一助になれれば幸いです。

Swiftの言語仕様をうまく使っていてとても参考になりますね。👏

こうやってOSSの実装方法を調査してみるととても勉強になるので継続的にやっていけたらと思います。

最後までお読みいただきありがとうございました。

Discussion