hachinoBlog

hachinobuのエンジニアライフ

今更だけどSwiftの型消去の使い所が少し分かったので備忘録

背景

2016年のtry!Swiftで話題になった型消去

なかなか使い所が分かってなかったんだけど、利用したいシーンに出会ったので備忘録を記す

説明

前提

私は、開発する上で、よくHTTPクライアントのライブラリであるAPIKitを使っています。

APIKitの使い方を簡単に説明すると、Requestというプロトコルに準拠したクラスを書いて、APIKitのSessionクラスのsendメソッドの引数に指定することで、指定したエンドポイントにアクセスして結果を返してくれます。

このAPIKitのRequestプロトコルは、エンドポイントにアクセスして取得するレスポンスの型をassociatedtypeを使って、Requestプロトコルに準拠したクラスで指定できるようになっています。

public protocol Request {
    /// The response type associated with the request type.
    associatedtype Response

    //その他、HttpMethodやpathやパラメータなどのプロパティがある
    //....
}

下記は、あるAPIを叩いて成功した場合にStringの配列を返すクラスです。

public class HogeEndpoint: Request {
    typealias Response = [String]

    //...
}

実際に使う場合は下記のような感じになります

let request = HogeEndpoint()
Session(request) { result in
    switch result {
    case .success(let items):
        //Stringの配列を取得
    case .failure(let error):
        //Error処理
    }
}

型消去を使って解決した課題

APIKitの説明を踏まえた上で、Requestプロトコルに準拠したクラスで、APIのエンドポイントは違うけれども成功した時に返ってくる値の型は同じであるパターンを想定してみます。

例えば下記のようなHogeEndpointFugaEndpointクラスです。

class HogeEndpoint: Request {
    typealias Response = [String]
    
    var path: String = "/api/v1/hoge"
    //...
}

class FugaEndpoint: Request {
    typealias Response = [String]

    var path: String = "/api/v1/fuga"
    //...
}

これは、接続するエンドポイントは違いますが、結果として得られる値の型は同じです。

そこでString型の配列をリストで表示する画面を作ることを想定してみましょう。

リスト表示のViewControllerにRequestプロトコルのStored propertyを宣言してやれば、ViewControllerが汎用的に使えると思い下記のようなコードを書きました。

import UIKit
import APIKit

class StringListTableViewController: UITableViewController {

    var request: Request!

    private var items: [String]? {
        didSet {
            tableView.reloadData()
        }
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "reuseIdentifier")
        fetchItem()
    }
    
    private func fetchItem() {
        
        Session.send(request) { [weak self] result in
            switch result {
            case .success(let items):
                self?.items = items
            case .failure(let error):
                print(error)
            }
        }
        
    }

    // MARK: - Table view data source
    override func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return items?.count ?? 0
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "reuseIdentifier", for: indexPath)
        cell.textLabel?.text = items?[indexPath.row]
        return cell
    }        

}

これで色々な画面から、String配列のリスト表示画面であるStringListTableViewControllerのStored propertyであるrequestに値を渡すだけで汎用的に使えると思うかもしれません。

しかし、これではProtocol 'Request' can only be used as a generic constraint because it has Self or associated type requirements.エラーになります。

f:id:hachinobu:20171115200204p:plain

Requestプロトコルassociatedtypeの型が決まってないのでダメよってことですね。

当然といえば当然です。

では、こんな時はどうすれば良いでしょうか?

結局、Requestプロトコルassociatedtypeに引きづられて、同じようなViewを作るしかないのでしょうか?

そうです。こんな時に型消去です。

型消去

(型消去自体の説明は沢山良記事があるので割愛します)

このエラーを回避してViewControllerを汎用的に使い回すために下記のような型消去クラスを作成します

struct TypeEraseEndpoint<ResponseType>: Request {

    typealias Response = ResponseType
    
    var path: String
    var method: HTTPMethod
    private let responseHandler: ((Any, HTTPURLResponse) throws -> ResponseType)
    
    init<R: Request>(request: R) where R.Response == ResponseType {
        self.path = request.path
        self.queryParameters = request.queryParameters
        self.method = request.method
        self.dataParser = request.dataParser
        self.responseHandler = request.response
    }
    
    func response(from object: Any, urlResponse: HTTPURLResponse) throws -> ResponseType {
        return try responseHandler(object, urlResponse)
    }

}

このような型消去のクラスを使うことで、先ほどのStringListTableViewControllerのStored propertyであるrequestの型をTypeEraseEndpoint<[String]>!に変更することで、ViewControllerを汎用的に使うことができました。

import UIKit
import APIKit

class StringListTableViewController: UITableViewController {

    var request: TypeEraseEndpoint<[String]>!

    private var items: [String]? {
        didSet {
            tableView.reloadData()
        }
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "reuseIdentifier")
        fetchItem()
    }
    
    private func fetchItem() {
        
        Session.send(request) { [weak self] result in
            switch result {
            case .success(let items):
                self?.items = items
            case .failure(let error):
                print(error)
            }
        }
        
    }

    // MARK: - Table view data source
    override func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return items?.count ?? 0
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "reuseIdentifier", for: indexPath)
        cell.textLabel?.text = items?[indexPath.row]
        return cell
    }        

}

この型消去のサンプルプロジェクトをQiitaのAPIを使って作ってみました。

SwiftTypeEraseSample

もしよければ触ってみてください。

最後に

そもそもCleanArchitectureみたいなレイヤードアーキテクチャを意識して作れば、このような場面には出くわさないんですけどね。

ViewControllerにRequestプロトコル渡すような書き方するなよとか言わないで!!

とはいえ、ほんと今更の理解で凹む。。

普段からassociatedTypeのProtocolを書いていないという設計力の低さを露呈しているような気がする。