tl;dr
- いわゆるインターフェースとして使うことで、複数の実装を切り替えたり、実装のスコープを狭める
- いわゆる抽象クラスとして使うことで、複数の似た型に共通のメソッドを提供する
- API クライアントを例に、インターフェースとしての使い方を整理する
- 複数の住所の型を例に、抽象クラスとしての使い方を整理する
インターフェースとして使う
プロトコルはインターフェースなので、他の言語におけるインターフェースのように使える。実装をインターフェースに依存させることで次の利点が得られる。
- 実装を差し替えられるようになる。テスト用実装や一時的な新旧実装の切り替えが可能になる。
- 呼び出し側にとって必要な API だけに狭めることができる。依存範囲が限定されるのでコードの見通しが良くなる。
抽象クラスとして使う
プロトコルは protocol extension を使ってデフォルト実装を追加できるので、他の言語における抽象クラスのように使える。複数の型に共通のインターフェースを抽出してそれを利用したデフォルト実装を追加することで次の利点が得られる。
- 型安全に共通処理を実装できる。
具体例1
API クライアントとそれを利用するアプリケーションを例に、インターフェースの使い方を確認する。
利用する側(以下、ドメイン)は自身が利用する範囲の API だけを宣言したプロトタイプを定義して、それに依存したコードを書く。
/// SearchAppAPI はアプリケーションの検索 API のインターフェースを表す。
protocol SearchAppAPI {
/// searchItems はアイテムを検索する。
func searchItems(_ request: SearchItemRequest) -> SearchItemResponse
}
/// SearchDomain は検索機能を実装する。
final class SearchDomain {
private let appAPI: SearchAppAPI
init(appAPI: SearchAppAPI) {
self.appAPI = appAPI
}
/// searchItems はアイテムを検索する。
func searchItems(_ request: SearchItemRequest) -> SearchItemResponse {
return appAPI.searchItems(request)
}
}
API クライアントは一つの大きなクラスとして実装しておく。これはドメインから独立したモジュール(いわゆるインフラ層)として完結させる。
/// RemoteAppAPI はアプリケーション API のクライアントを実装する。
final class RemoteAppAPI {
}
extension RemoteAppAPI: SearchAppAPI {
/// searchItems はアイテムを検索する。
func searchItems(_ request: SearchItemRequest) -> SearchItemResponse {
return SearchItemResponse()
}
}
extension RemoteAppAPI: AccountAppAPI {
...
}
extension RemoteAppAPI: ItemListAppAPI {
...
}
ドメインの初期化時に依存する API クライアントを渡す。
let remoteAppAPI = RemoteAppAPI()
let searchDomain = SearchDomain(appAPI: remoteAppAPI)
searchDomain.searchItems(SearchItemRequest())
これで、テストや API サーバーがまだない場合のダミーレスポンスの差し替えができるようになった。
/// MockSearchAppAPI はアプリケーションの検索 API のクライアントモックを実装する。
final class MockSearchAppAPI: SearchAppAPI {
/// searchItems はアイテムを検索する。
func searchItems(_ request: SearchItemRequest) -> SearchItemResponse {
return dummySearchItemResponse()
}
}
let mockSearchAppAPI = MockSearchAppAPI()
let mockSearchDomain = SearchDomain(appAPI: mockSearchAppAPI)
mockSearchDomain.searchItems(SearchItemRequest())
ドメインは自身が利用している API だけに依存することがコード上明確に表現されるようになったので、見通しも良くなった。
具体例2
複数の似た型を例に、抽象クラスとしての使い方を整理する。
例えば住所の型を考えてみる。住所はユーザーに入力してもらうときは項目別だが、その表示をする際には連結して表示したり、一部加工して表示したいという需要がある。そのため、住所の型には表示用のメソッドを定義すると便利である。
一方で、住所は複数の型を使って表現したい場合がある。例えば、フリマアプリでは、ユーザーはまず住所を登録しておかないといけない。これはユーザーに紐づく住所となる。そして購入者は登録した住所の中から発送先を設定して出品者はそこに発送する。この住所は発送先住所として別の型にしたい。これは購入(発送)に紐づく住所となる。さらに購入者が返品するときには出品者は登録した住所の中から返品先を設定する。この住所も返品先住所として別の型にしたい。これは購入(返品)に紐づく住所となる。
以下は住所を表す 3 つの型である。
/// UserAddress はユーザーの住所を表す。
struct UserAddress {
let prefecture: String
let city: String
let address: String
let building: String
...
}
/// UserShippingAddress はユーザーの発送先住所を表す。
struct UserShippingAddress {
let prefecture: String
let city: String
let address: String
let building: String
...
}
/// UserReturnAddress はユーザーの返品先住所を表す。
struct UserReturnAddress {
let prefecture: String
let city: String
let address: String
let building: String
...
}
住所である以上、表示のための実装は共通するところが多いので protocol extension を使ってデフォルト実装を追加すると効果的である。
/// UserAddressDisplayable はユーザーの住所の表示に関するデフォルト実装を提供する。
protocol UserAddressDisplayable {
var prefecture: String { get }
var city: String { get }
var address: String { get }
var building: String { get }
}
extension UserAddressDisplayable {
/// location は位置情報を表示に適した形に組み立てて返す。
public var location: String {
return "\(prefecture)\(city)\(address) \(building)"
}
}
extension UserAddress: UserAddressDisplayable {
}
extension UserShippingAddress: UserAddressDisplayable {
}
extension UserReturnAddress: UserAddressDisplayable {
}
3 つの型それぞれで、表示のための location プロパティを呼び出せるようになった。
let address = UserAddress(prefecture: "東京都", city: "渋谷区", address: "ホゲホゲ", building: "ビル16階")
address.location // 東京都渋谷区ホゲホゲ ビル16階
let shippingAddress = UserShippingAddress(prefecture: "東京都", city: "渋谷区", address: "ホゲホゲ", building: "ビル16階")
shippingAddress.location // 東京都渋谷区ホゲホゲ ビル16階
let returnAddress = UserReturnAddress(prefecture: "東京都", city: "渋谷区", address: "ホゲホゲ", building: "ビル16階")
returnAddress.location // 東京都渋谷区ホゲホゲ ビル16階