この記事は翻訳です#
元の記事のリンク:Using errors as control flow in Swift
アプリやプロジェクトでの制御フローの管理は、コードの実行速度やデバッグの複雑さに大きな影響を与えます。制御フローは、本質的には関数と宣言の実行順序、およびコードの実行パスです。
Swift は、if、else、while、オプションなど、制御フローを定義するための多くのツールを提供していますが、今週は、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:) メソッドを変更して、非 null の image を返すか、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? を使用する最大の利点は、両方の方法を組み合わせることができることです。呼び出し中にオプション値を取得できるだけでなく、スローとエラーを使用して制御フローを管理することもできます。
入力の検証#
次に、入力の検証時にエラーを使用すると、制御フローが向上する方法を見てみましょう。Swift の高度で強力な型システムがあるにもかかわらず、関数が有効な入力を受け取ることを保証するわけではありません - 時にはランタイムチェックが唯一の方法です。
次に、ユーザーが登録する際に、ユーザーが選択した資格を検証する例を見てみましょう。前述の例と同様に、コードは guard ステートメントを使用して各検証ルールを確認し、エラーメッセージを表示します:
func signUpIfPossible(with credentials: Credentials) {
guard credentials.username.count >= 3 else {
errorLabel.text = "Username must contain min 3 characters"
return
}
guard credentials.password.count >= 7 else {
errorLabel.text = "Password must contain min 7 charaters"
return
}
// Additional validation
...
service.signUp(with: credentials) { result in
...
}
}
上記のコードは、2 つの条件のみを検証していますが、検証ロジックが予想を超えるほど成長する可能性があります。このようなロジックが UI に存在する場合(特にビューコントローラ内の場合)、テストがより困難になります - したがって、コードの制御フローを改善し、解耦する方法を見てみましょう。
理想的には、コードが自己完結していることを望みます。これにより、テスト中に隔離され、コード内で使用できるようになります。これを実現するために、すべての検証ロジックに対して指定された型を作成します。Validator という名前の構造体で、指定された値の検証クロージャが含まれています:
struct Validator<Value> {
let closure: (Value) throws -> Void
}
上記のコードを使用して、値が検証に合格しない場合にエラーをスローする validators を構築できます。ただし、すべての検証プロセスに新しいエラータイプを定義することは、不要なテンプレートを生成することになります(特にこれらのエラーをユーザーに表示したい場合) - そのため、条件とユーザーに表示するエラーメッセージのみを受け取る関数を定義します:
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: "Password must contain min 7 characters")
try validate(string.lowercased() != string, errorMessage: "Password must contain an uppercased character")
try validate(string.uppercased() != string, errorMessage: "Password must contain a lowercased character")
}
}
}
完全にするために、新しい 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
...
}
}