はじめに
依存性逆転の原則で抽象化したモジュールがエラーを起こす可能性がある場合、Swiftではどのようにすればいいのか迷ったので考えてみました。
結論は出ていません。
依存性逆転の原則については、Wikipediaを参考にします。
概要
ベースとして、公式にあるVendingMachineとVendingMachineErrorによるコードを利用します。
Wikipediaの依存性逆転パターンの部分、
低レベルレイヤーはこれらの抽象クラスやインターフェースを継承して生成される。
という内容に従うため、まずはVendingMachineをprotocol化し、従来書いていたコードを LocalVendingMachine
に変更します。
enum VendingMachineError: Error {
case invalidSelection
case insufficientFunds(coinsNeeded: Int)
case outOfStock
}
protocol VendingMachine {
func vend(itemNamed name: String) throws
}
class LocalVendingMachine: VendingMachine {
var inventory = [
"Candy Bar": Item(price: 12, count: 7),
"Chips": Item(price: 10, count: 4),
"Pretzels": Item(price: 7, count: 11)
]
var coinsDeposited = 0
func vend(itemNamed name: String) throws {
guard let item = inventory[name] else {
throw VendingMachineError.invalidSelection
}
guard item.count > 0 else {
throw VendingMachineError.outOfStock
}
guard item.price <= coinsDeposited else {
throw VendingMachineError.insufficientFunds(coinsNeeded: item.price - coinsDeposited)
}
coinsDeposited -= item.price
var newItem = item
newItem.count -= 1
inventory[name] = newItem
print("Dispensing \(name)")
}
}
これで、呼び出し側はLocalVendingMachineの実装に依存せず、VendingMachineのprotocolに依存するようになりました。
let vendingMachine: VendingMachine // ここにLocalVendingMachineなどを注入
let favoriteSnacks = [
"Alice": "Chips",
"Bob": "Licorice",
"Eve": "Pretzels",
]
func buyFavoriteSnack(person: String, vendingMachine: VendingMachine) throws {
let snackName = favoriteSnacks[person] ?? "Candy Bar"
try vendingMachine.vend(itemNamed: snackName)
}
do {
try buyFavoriteSnack(person: "Alice", vendingMachine: vendingMachine)
print("成功! おいしい。")
} catch VendingMachineError.invalidSelection {
print("無効な選択です。")
} catch VendingMachineError.outOfStock {
print("在庫切れです。")
} catch VendingMachineError.insufficientFunds(let coinsNeeded) {
print("お金が足りません。あと \(coinsNeeded) コイン投入してください。")
} catch {
print("予期しないエラー: \(error)。")
}
疑問
呼び出し側が実装に直接依存しなくなったので、VendingMachineのprotocolに適合したクラスであれば差し替えが可能になりました。
しかし、その分VendingMachineのprotocolに適合したクラスが独自のエラーを出す場合が考えられます。
例えば、ネットワーク経由で接続されるVendingMachineの適合クラス NetworkVendingMachine
に差し替えることを考えましょう。
struct NetworkVendingMachine: VendingMachine {
func vend(itemNamed name: String) throws {
throw ネットワークのエラー
}
}
NetworkVendingMachineは当然ネットワークに失敗することもありえ、それはエラーとしてthrowされることになります。そのエラー種別はどうするべきでしょうか?
VendingMachineErrorに新たに定義
enum VendingMachineError: Error {
case invalidSelection
case insufficientFunds(coinsNeeded: Int)
case outOfStock
case netWorkOffline //新たに追加
}
struct NetworkVendingMachine: VendingMachine {
func vend(itemNamed name: String) throws {
throw VendingMachineError.netWorkOffline
}
}
このように、VendingMachineErrorに新たにcaseを追加することで、VendingMachineの呼び出し側はVendingMachineErrorのハンドリングを行うことで処理が可能になります。
しかし新たなVendingMachine適合クラスが増えるたびにVendingMachineErrorのcaseを増やすことになるので、VendingMachineErrorはVendingMachine適合クラスの実装全てのエラーをサポートすることになります。
VendingMachineErrorのprotocol化
VendingMachineErrorもprotocol化して、起こるエラーをVendingMachineErrorに適合させるパターンです。
protocol VendingMachineError: Error {}
struct OutOfStockError: VendingMachineError {}
struct InvalidSelectionError: VendingMachineError {}
struct InsufficientFundsError: VendingMachineError {
let coinsNeeded: Int
}
extension VendingMachineError {
static var invalidSelection: InvalidSelectionError {
.init()
}
static var outOfStock: OutOfStockError {
.init()
}
static func insufficientFunds(coinsNeeded: Int) -> InsufficientFundsError {
.init(coinsNeeded: coinsNeeded)
}
}
// ネットワークエラーの新設
struct NetworkOfflineError: VendingMachineError {}
extension VendingMachineError {
static var networkOffline: NetworkOfflineError {
.init()
}
}
struct NetworkVendingMachine: VendingMachine {
func vend(itemNamed name: String) throws {
throw VendingMachineError.netWorkOffline
}
}
こうすることで、実装クラスはVendingMachineErrorという抽象エラーに適合したものをthrowすることになります。VendingMachineErrorは実装に依存することはありません。
しかし、呼び出し側のコードは各種具体的なエラー型に依存することになります。
呼び出し側で、独自にハンドリングしたいエラーを指定し、個別対応を行うことになります。
do {
try buyFavoriteSnack(person: "Alice", vendingMachine: vendingMachine)
print("成功! おいしい。")
} catch _ as InvalidSelectionError {
print("無効な選択です。")
} catch _ as OutOfStockError {
print("在庫切れです。")
} catch let error as InsufficientFundsError {
print("お金が足りません。あと \(error.coinsNeeded) コイン投入してください。")
} catch _ as NetworkOfflineError {
print("インターネットに接続されていません")
} catch {
print("予期しないエラー: \(error)。")
}
VendingMachineErrorに共通メッセージを用意した場合
エラーのプロトコルに共通的な処理を持たせることで、呼び出し側がprotocolのみに依存した処理を書くことが可能です。
例えばエラーメッセージを持たせるようにして、VendingMachineErrorの実装を見ないことも可能になります。
protocol VendingMachineError: Error {
var message: String { get }
}
do {
try buyFavoriteSnack(person: "Alice", vendingMachine: vendingMachine)
print("成功! おいしい。")
} catch _ as VendingMachineError {
print(error.message)
} catch {
print("予期しないエラー: \(error)。")
}
しかし、この場合のエラーメッセージはあくまでVendingMachineが呼び出し側にエラーを起こした理由、LocalizedErrorでいうfailureReason
を伝える内容であり、呼び出し側が最終的に表示したいメッセージとは異なる可能性があります。
例えばVendingMachineがネットワークエラーの時、呼び出し側がユーザーにメッセージを表示する役割を担っている場合、更新ボタンを押すべきなのか、それともPull to refreshをするべきなのかVendingMachineの実装がメッセージの内容を設定するのは不可能です。
LocalizedErrorでいうrecoverySuggestion
の部分は呼び出し側によって異なるので、その場合は呼び出し側が実装のエラーを見てメッセージを設定する選択になるんじゃないかと思います。
まとめ
以上、大きく分けて2パターンについて考えてみました。
個人的にはVendingMachineErrorをprotocol化する方を使おうと思います。