この記事は翻訳です#
原文リンク:Using errors as control flow in Swift
アプリやプロジェクトで制御フローを管理することは、コードの実行速度やデバッグの複雑さに大きな影響を与えます。コードの制御フローは本質的に関数や宣言の実行順序、及びコードの実行パスです。
Swift は多くのツールを提供して制御フローを定義します —— 例えば if、else、while 及び optional;今週は、Swift のコンパイル時エラーを通じてモデルをスローし、処理する方法を見て、制御フローをより簡単に管理できるようにします。
オプショナルな値をスローする#
オプショナル値は Swift の重要な特徴であり、空のデータを処理する際に合法的に無視することができます;また、制御フローの中で特定の関数のソーステンプレートとして頻繁に使用されます。
以下は、アプリからバンドルをロードし、画像を調整する方法を再構築したものです。各操作がオプショナルな画像を返すため、どこで関数を退出できるかを示す複数の guard 文を書く必要がありました:
func loadImage(named name: String,
tintedWith color: UIColor,
resizedTo size: CGSize) -> UIImage? {
guard let baseImage = UIImage(named: name) else {
return nil
}
guard let tintedImage = tint(baseImage, with: color) else {
return nil
}
return resize(tintedImage, to: size)
}
上記のコードの問題は、実行時エラーに対処するために nil を使用していることです —— これにより、各ステップで結果を解析する必要があり、このエラーが発生した根本的な理由が隠されてしまいます。
次に、関数をスローし、エラーを通じて制御フローを再構築する方法を見て、上記の問題を解決します。最初のステップは、画像処理の過程で発生する可能性のあるすべてのエラーを含む enum を定義することです:
enum ImageError: Error {
case missing
case failedToCreateContext
case failedToRenderImage
...
}
次に、関数が失敗したときに上記で定義したエラーをスローするように変更し、nil を返さないようにします。例えば、loadImage (named:) メソッドを変更し、非オプショナルな画像を返すか、ImageError.missing をスローします:
private func loadImage(named name: String) throws -> UIImage {
guard let image = UIImage(named: name) else {
throw ImageError.missing
}
return image
}
他の画像処理メソッドもこのように変更すれば、トップレベルの他の関数も同様に —— すべてのオプショナルを削除し、操作中に確実な画像を返すか、エラーをスローするようになります:
func loadImage(named name: String,
tintedWith color: UIColor,
resizedTo size: CGSize) throws -> UIImage {
var image = try loadImage(named: name)
image = try tint(image, with: color)
return try resize(image, to: size)
}
上記の変更は、関数本体をより簡潔にし、デバッグを容易にします。エラーが発生した場合、明確に定義されたエラーが得られるため —— どのステップで nil が返されたのかを調べる必要がありません。
しかし、実際にはすべての場所でエラーを処理する必要があるわけではないため、do、try、catch の使用を強制する必要はありません;また、do、try、catch の乱用は、必要なときに冗長なコードを避けるために注意が必要です。
良いニュースは、スローするメソッドを使用しても、いつでもオプショナル値に戻ることができることです。必要なのは、スローするメソッドを呼び出すときに try? キーワードを使用するだけで、オプショナル値が得られます:
let optionalImage = try? loadImage {
named: "Decoration",
tintedWith: .brandColor,
resizedTo: decorationSize
}
try? を使用する最も素晴らしい点は、両方の方法の利点を兼ね備えていることです。呼び出しの中でオプショナル値を得ることができ —— 同時に throw、error を使用して制御フローを管理できます。
入力の検証#
次に、入力を検証する際にエラーを使用して制御フローを向上させる方法を見てみましょう。Swift には非常に高度で強力な型システムがありますが、これにより関数が合法的な入力を受け取ることが保証されるわけではありません —— 時には実行時チェックが唯一の解決策です。
もう一つの例を見てみましょう。ユーザー登録時に、ユーザーが選択した証明書を検証します。前述のように、コードは guard 文を使用して各検証ルールを判断し、エラーが発生した場合は入力エラーメッセージを表示します:
func signUpIfPossible(with credentials: Credentials) {
guard credentials.username.count >= 3 else {
errorLabel.text = "ユーザー名は最低3文字を含む必要があります"
return
}
guard credentials.password.count >= 7 else {
errorLabel.text = "パスワードは最低7文字を含む必要があります"
return
}
// 追加の検証
...
service.signUp(with: credentials) { result in
...
}
}
上記のコードは 2 つの条件のみを検証していますが、検証ロジックが予想以上に増える可能性があります。このようなロジックが UI(特に View Controller 内)に存在すると、テストが難しくなります —— したがって、デカップリングし、コードの制御フローを向上させる方法を見てみましょう。
理想的には、コードが自己完結することを望みます。これにより、隔離された状態でテストでき、コード内で使用できます。これを実現するために、すべての検証ロジックのために特定の型を作成します。Validator と名付け、与えられた Value の検証クロージャを含む構造体です:
struct Validator<Value> {
let closure: (Value) throws -> Void
}
上記のコードを使用して、値の検証が失敗した場合にエラーをスローする validators を構築できます。しかし、各検証プロセスのために新しいエラータイプを定義することは無駄なテンプレートを生む可能性があります(特に、これらのエラーをユーザーに表示したい場合)—— したがって、Bool の条件と失敗時にユーザーに表示するメッセージを受け取る検証コードを持つ関数を定義します:
struct ValidationError: LocalizedError {
let message: String
var errorDescription: String? { return message }
}
func validate { _ condition: @autoclosure () -> Bool, errorMessage messageExpression: @autoclosure () -> String } throws {
guard condition() else {
let message = messageExpression()
throw ValidationError(message: message)
}
}
上記では再び @autoclosure を使用しています —— これは自動的にクロージャ内で評価される式です。詳細を知りたい場合は、"Using @autoclosure when designing Swift APIs".を参照してください。
上記が完了した後、指定された整然とした検証ロジックコードを書くことができます ——Validator 型の静的計算プロパティとして。例えば、以下はパスワード検証器の実装です:
extension Validator where Value == String {
static var password: Validator {
return Validator { string in
try validate(string.count >= 7, errorMessage: "パスワードは最低7文字を含む必要があります")
try validate(string.lowercased() != string, errorMessage: "パスワードには大文字の文字を含める必要があります")
try validate(string.uppercased() != string, errorMessage: "パスワードには小文字の文字を含める必要があります")
}
}
}
徹底的に行うために、新しい validate をオーバーロードし、検証したい値と使用する検証器を渡します:
func validate<T>(_ value: T, using validator: Validator<T>) throws {
try validator.closure(value)
}
すべての準備が整ったら、新しい検証システムを使用して呼び出しを更新します。上記のコードの優雅さは、いくつかの追加の型や設定が必要ですが、入力を検証する必要があるコードをより整然とさせることです。
func signUpIfPossible(with credentials: Credentials) throws {
try validate(credentials.username, using: .username)
try validate(credentials.password, using: .password)
service.signUp(with: credentials) { result in
...
}
}