Log

【Swift】エラーハンドリングと文字列

iOS開発でエラーハンドリング時のエラーメッセージの実装が間違えているような気がしたのでその調査メモです.
恥ずかしながら今まではCustomStringConvertibleやコンバータークラスを作成していました.

間違い等ありましたら指摘してくださると嬉しいです.

ErrorとNSError

Foundation/UIKit等を利用していると稀にNSErrorというものを見かけます.
SwiftのErrorとの違いは次の点が違います.

Error

Errorはdo-catch構文やResult型で補足することができる抽象型のプロトコルです.
Swiftで定義されているErrorプロトコルに準拠したオブジェクトを例外として投げることが可能で,クラスや共用型含む列挙型等に準拠させることができます.

NSError

Foundationで定義されたクラスで,エラーの発生とそのエラー情報を伝える使い方をします.
クラスはNSObjectを継承したクラスですが,SwiftのErrorプロトコルにも準拠しているためErrorと同様の使い方ができます.

以上がErrorとNSErrorの違いですが,一言で表すと
Errorはエラーの発生を知らせる事が可能.NSErrorはエラーの発生とその情報もセットで知らせることが可能.
という認識をしておけば大丈夫です.

NSError エラー情報

NSErrorはエラーの情報も格納できると上述しました.
エラーの情報は次のプロパティで取得することが可能です.

class NSError: NSObject {
    /// ローカライズされたエラーを説明する文字列
    var localizedDescription: String { get }

    /// ローカライズされたアラートの選択肢として表示する文字列
    var localizedRecoveryOptions: [String]? { get }

    /// ローカライズされたエラーへの対処方法を表す文字列
    var localizedRecoverySuggestion: String? { get }

    /// ローカライズされたエラーの発生原因を表す文字列
    var localizedFailureReason: String? { get }
}

ローカライズされているということは,UIへの表示を前提にされたものだということが推測できます.
API呼び出しなどではなくNSErrorのインスタンスを生成したい場合はuserInfoにローカライズされた文字列を格納します.

let nsError = NSError(domain: "nserror", code: 1, userInfo: [
    NSLocalizedDescriptionKey: "localized description",
    NSLocalizedRecoveryOptionsErrorKey: ["localized recovery options"],
    NSLocalizedRecoverySuggestionErrorKey: "localized recovery suggestion",
    NSLocalizedFailureReasonErrorKey: "localized failure reason"
])

print(nsError.localizedDescription)
print(nsError.localizedRecoveryOptions)
print(nsError.localizedRecoverySuggestion)
print(nsError.localizedFailureReason)

macOS向けネイティブアプリ開発に用いられるCocoaでは アラートを表示するAPIの引数にNSErrorを渡すことでラベル等に適切に表示してくれるものもあります.

let nsError = ...
NSAlert(error: nsError).runModal()

NSAlertでエラーを表示

LocalizedError

FoundationのNSErrorのようにエラー情報を保持可能で,SwiftのプロトコルとしてErrorを利用したい場合はLocalizedErrorというFoundationで定義されたプロトコルが利用可能です.

LocalizedErrorは次のプロパティが定義された,Errorを継承したプロトコルです.

public protocol LocalizedError : Error {
    var errorDescription: String? { get }

    var failureReason: String? { get }

    var recoverySuggestion: String? { get }

    var helpAnchor: String? { get }
}

先程の例として載せたNSErrorのプロパティと雰囲気が似ていると思います.
実際にプロトコルに準拠させた列挙型を定義して実際に利用してみると次のようになります.

enum MyError: LocalizedError {
    case a
    case b
    
    var errorDescription: String? { "error description" }
    var failureReason: String? { "failure reason" }
    var recoverySuggestion: String? { "recovery suggestion" }
    var helpAnchor: String? { nil }
}

do {
    throw MyError.b
}
catch {
    // MyError.errorDescription
    let myError = error as! MyError
    print(myError.errorDescription)

    // Error.localizedDescription
    print(error.localizedDescription)
}

// 両方とも"error description"が出力

定義したMyErrorのlocalizedDescriptionはどのケースでも"error description"としています.
例を見ると引数の異なるprint()が2回呼び出されていますが,その出力は両方とも同じです.
FoundationのextensionでErrorプロトコルlocalizedDescriptionというプロパティが追加されており,エラーの実装がNSErrorでもErrorでも意図した文字列(ローカライズされたエラーの説明)を返すという動作をします.

localizedDescriptionがNSErrorとErrorの違いを吸収してくれるので,利用者はキャスト等の処理を挟まなくて済みます.

NSErrorと同じくCocoaAPIのAlertにMyError.bを渡してあげると,意図した形でアラートが表示されたことが確認できました.

NSAlert(error: MyError.a).runModal()

NSAlertでカスタムエラーを表示

まとめ

Swiftのエラーハンドリングで型を定義する際にはLocalizedErrorを使いましょう.
エラーメッセージはError.localizedDescriptionで定義しておくと,NSErrorとの違いを吸収してくれるので積極的に利用したいです.

参照

Errors and Exceptions | Apple Developer Documentation

【Swift】 Combine 基礎

はじめに

Combineは2019年に発表されたフレームワークですが「アプリに組み込めるようになったのは最近」という人は多いのではないでしょうか.
私もそのうちの一人なのですが,備忘録としてまとめておきます.
間違っている箇所ありましたらご指摘いただけると助かります.

Combineとは

CombineはApple WWDC19で発表されたフレームワークです.
UIKitは処理の結果を元に各オブジェクト内の状態を変化させていくような手続き型プログラミングが主流でした.
SwiftUIを始めとした,オブジェクトが状態の変化を検知して適切な処理へ更新するような宣言型プログラミングスタイルを実現させるためのフレームワークとして登場しました.
iOS 13.0.macOS 10.15から利用可能です.

登場人物

Combineのコアとなるのは以下の3つのプロトコルです.
- Publisher
- Subscriber
- Operator

Publisherとは

Publisherは"発行者"と訳されます.
次に説明するSubscriberに値/エラーを通知することができます.
1対1の関係のDelegateパターンとは異なり,1つのPublisherに複数のSubscriberが登録(subscribe)することが可能です.この特徴が宣言型プログラミングスタイルを実現可能としています.

Subscriberとは

Subscriberは"登録者"と訳され,Publisherからの通知を受け取る役割があります.

Operatorとは

OperatorはPublisherとSubscriberの間に加入し,Subscriberが通知した値を加工しSubscriberへと渡す役割があります.
値の加工とはIntからLongへのキャストや,不要な値の破棄なども行うことができます.

Publisher

WWDC19よりPublisherとはプロトコルで以下のように定義されています.

protocol Publisher {
    associatedtype Output
    associatedtype Failure: Error

    func subscribe<S: Subscriber>(_ subsctiber: S)
        where S.Input == Output, S.Failure == Failure
}

通知を行うために出力するための型,異常を通知するError型を指定するassociatedtype
Subscriberが通知の送付先として登録するためのsubscribe(_:Subscriber)があります.

Subscriber

WWDC19よりSubscriberもプロトコルで以下のように定義されています.

protocol Subscriber {
    associatedtype Input
    associatedtype Failure: Error

    func receive(subscription: Subscription)
    func receive(_ input: Input) -> Subscribers.Demand
    func receive(completion: Subscribers.Completion<Failure>)
}

通知の入力として受け取る型と異常時のError型を指定します.
Publisherに登録する場合はPublisherのOutputとSubscriberのInputの型は一致している必要があります.
静的型付けを行うことによって実行時のキャストエラーを防ぎ,外部APIとして公開する場合も扱いやすくなります.

Combine組込みSubscriberとしてAssignSinkが用意されています. AssignはPublisherから受け取った値を,指定した変数に代入するSubscriber.
SinkはPublisherから値を受け取ったら,初期化時に指定したクロージャを呼び出すSubscriberです.

Operator

Operatorはプロトコルではありません.Publisherからの出力を受け取り,値のキャスト等を行いSubscriberへと出力するものです. Combineの組込みOperatorだけでもかなりの数のものが用意されており,様々なことが実現可能です.
Operatorの一つとして提供されているMapは以下のような定義です.

extension Publishers {
    struct Map<Upstream: Publisher, Output>: Publisher {
        typealias Failure = Upstream.Failure

        let upstream: Upstream
        let transform: (Upstream.Output) -> Output
    }
}

組込みOperatorはPublishersという名前空間に定義されています.詳しく知りたい方はドキュメントを参照してください.

Subject

SubjectはPublisherを継承したプロトコルです.
Publisherの実装に加えてsend()メソッドの実装を要求します. 組込みSubjectとしてCurrentValueSubjectとPassthroughSubjectが提供されており,既存の命令型コードと共存させる場合に力を発揮します.

ライフサイクル

PublisherとSubscriberは以下の流れで処理を行います.
Combineを利用するにあたってOperatorは必須ではありませんのでここでは割愛します.

  1. Subscriberは通知を受け取りたいPublisherのsubscribe(_:Subscriber)に自身を引数として渡す.
  2. Publisherは登録要求で渡されたSubscriberのreceive(_:Subscription)を呼び出して接続を確率します.
  3. Subscriberはreceive(_:Subscription)で渡されたSubscriptionオブジェクトのrequest(_:Demand)メソッドを用いてN回もしくは無限回の通知を受け取ることをPublisherへと伝える.
  4. Publisherは通知イベントをSubscriberへ送るためにSubscriber.receive(_:Input)を呼び出します.このイベントは接続が確率されている間は複数回送ることが可能です.
  5. Subscriberは.cancel()メソッドを用いてPublisherとの接続をキャンセルすることが可能です..cancel()呼び出し以降はPublisherからの通知はされなくなります.
  6. PublisherはSubscriber.receive(_:Completion)を呼び出し,以降の通知は発生しないことを伝えることができます.

ライフサイクル PublisherとSubscriber

実践 Publisher Subscriber

Foundationの拡張でCombineを利用することができます.
今回はKVO(KeyValueObserve)をCombineで利用してみます.

import Foundation
import Combine


class User: NSObject {
    @objc dynamic var age: Int = 0
}

let user = User()

// ① User.ageの値の変更を通知するPublisher
let agePublisher = user.publisher(for: \.age, options: .new)

// ② 通知を受け取り,都度クロージャを呼び出す組込みSubscriber
let ageSubscriber = Subscribers.Sink<Int, Never>(receiveCompletion: { _ in
    // 完了/失敗時に呼ばれる
}, receiveValue: { value in
    // 値が更新されたときに呼ばれる
    print(value)
})

// ③ Publisherと接続する
agePublisher.subscribe(ageSubscriber)

user.age = 40
user.age = 30

// ④ キャンセル
ageSubscriber.cancel()

// ⑤ この変更は通知されない
user.age = 10

上記のコードを実行してみるとコンソールには

40  
30

が出力されます.
④ キャンセル以降のイベントは通知されないことが見て取れます.

また,④の.cancel()を実行した場合は②のreceiveCompletionクロージャは実行されません.
強引ですが,以下のように完了通知を呼び出してあげるとreceiveCompletionが呼び出されて.cancel()と同じ動きをすることが確認できました.

// ③ Publisherと接続する
agePublisher.subscribe(ageSubscriber)

user.age = 40
user.age = 30

ageSubscriber.receive(completion: .finished)

Chained Publisher

Combineの例としてよく見る書き方.
Chained Publisherと呼ばれており以下のような書き方で,先程の例と同じ動きをします.

class User: NSObject {
    @objc dynamic var age: Int = 0
}

let user = User()

let cancellable = user.publisher(
    for: \.age, options: .new).sink(receiveCompletion: { _ in
        // 完了/失敗時に呼ばれる
    }, receiveValue: { value in
        // 値が更新されたときに呼ばれる
        print(value)
    })

user.age = 40
user.age = 30

cancellable.cancel()

user.age = 10

Publisher.sinkメソッドで先程の例と同じ動きをしますが,返り値として渡されるのはAnyCancellableという型のオブジェクトです.
AnyCancellableはSubscriberをラップし.cancel()メソッドのみを呼び出し可能としたものです. ラップされたSubscriberは他のメソッドを呼び出される考慮をしなくて良くなる(先程の強引にreceive(_:Completion)を呼び出す等)ので外部APIとして公開する場合に有用です.

Publisher.sinkで返ってくるオブジェクトを保持しない場合はラップされているSubscriberも破棄されるため意図した動作をしませんので注意が必要です.

実践 Subject

組込みSubjectを用いて,Subjectをsubscribeする例を示します.
組込みSubjectはCurrentValueSubjectとPassthroughSubjectの2つがあります.2つの違いは
CurrentValueSubjectはSubscriberからのsubscribe時に,Subjectが最後に受け取った(保持している)値を渡します. PassthroughSubjectはsubscribeが完了した後に,send()にて受け取った値をSubscriberに渡すという動作になります.

let subject = CurrentValueSubject<String, Never>("hello")

let cancellable = subject.sink(receiveValue: { str in
    print(str)
})

subject.send("world")

コンソールの出力は

hello
world

となります.

今までの例ではsubscribeをするときにエラー時の処理も指定していましたが,Publisher/SubjectのFailureがNever(エラーが発生しないことを表す)の場合は,subscribe処理の(receiveCompletion:)を省略することが可能です.

おわりに

Combineというフレームワークを調べてみると,基本的なコンセプトとしては小さなものでした.
CombineはSwiftUIを支えている技術の一つでもあるため,その基礎を抑えておいて損はないかと思います.
使いこなせれば良い道具となりそうなので,これからも活用方法を探っていきたいと思いました.

参考

Combine | Apple Developer Documentation
Introducing Combine - WWDC19 - Videos - Apple Developer
Combine in Practice - WWDC19 - Videos - Apple Developer
Using Combine