🌾

[Swift] 【Combine入門】AnyPublisher の Output を Never にした場合の挙動

2021/12/25に公開

はじめに

Combine で処理を連結するときに、AnyPublisherOutput に空の Void を返して、何も返り値がないことを暗に示していたのですが、それよりは .ignoreOutput() して OutputNever にしたほうが意図が伝わるのでは? と思い、それで実装したところ思ったように動かなかったため、挙動を整理しました。

環境:Xcode 12.5.1

結論

先に結論から述べます。

  • AnyPublisherOutputNever になった段階でストリームは流れなくなる
  • 何も返り値がない場合の AnyPublisherOutputVoid を返して、Never にしたい箇所で .ignoreOutput() とつけて Never にしてあげるのが良さそう

実験その1

CurrentValueSubject<Int, Never>number が 0 か負の値かチェックするという適当な処理を例にして実験します。

import Combine
import Foundation

enum TestError: Error {
    case zeroError
    case minusError
}

var cancellables: Set<AnyCancellable> = []
let number = CurrentValueSubject<Int, Never>(0)

func start() {
       number
        .dropFirst()
        .setFailureType(to: TestError.self)
        .flatMap { _ -> AnyPublisher<Never, TestError> in
            print("checkZero")
            return checkZero()
        }
        .flatMap { _ -> AnyPublisher<Never, TestError> in
            print("checkMinus") // 上のflatMapの段階でNeverになるのでここにストリームは流れてこない
            return checkMinus()
        }
        .sink { completion in
            switch completion {
            case .finished:
                print("finished")
            case let .failure(error):
                print("error: \(error)")
            }
            print("completion.")
        } receiveValue: { hoge in
            print("receiveValue: \(hoge)")
        }
        .store(in: &cancellables)
}

func checkZero() -> AnyPublisher<Never, TestError> {
    Future<Void, TestError> { promise in
        let numberValue = number.value
        print("now: \(numberValue)")
        guard number.value != 0 else {
            promise(.failure(TestError.zeroError))
            return
        }
        promise(.success(()))
    }
    .ignoreOutput()
    .eraseToAnyPublisher()
}

func checkMinus() -> AnyPublisher<Never, TestError> {
    Future<Void, TestError> { promise in
        let numberValue = number.value
        print("now: \(numberValue)")
        guard numberValue > 0 else {
            promise(.failure(TestError.minusError))
            return
        }
        promise(.success(()))
    }
    .ignoreOutput()
    .eraseToAnyPublisher()
}

start()

number.value = 1
number.value = -1 // 本当はここでminusErrorになってほしいがcheckMinusに到達しない
number.value = 0

// 出力
// checkZero
// now: 1
// checkZero
// now: -1
// checkZero
// now: 0
// error: zeroError
// completion.

このように checkMinus()まで処理が到達していないことがわかります。
つまり、前段の checkZero() で止まっていることがわかります。

  • ポイント
    • AnyPublisher 返す関数側で Never を返すようにしている
    • AnyPublisherOutputNever になった段階でストリームは流れなくなる(本当は流れてほしい)
      • → 途中の flatMapNever にするとストリームはそこで止まる

実験その2

import Combine
import Foundation

enum TestError: Error {
    case zeroError
    case minusError
}

var cancellables: Set<AnyCancellable> = []
let number = CurrentValueSubject<Int, Never>(0)

func start() {
       number
        .dropFirst()
        .setFailureType(to: TestError.self)
        .flatMap { _ -> AnyPublisher<Void, TestError> in
            print("checkZero")
            return checkZero()
        }
        .flatMap { _ -> AnyPublisher<Never, TestError> in // 最後のflatMapでNeverにする
            print("checkMinus")
            return checkMinus() // AnyPublisher<Void, TestError>
                .ignoreOutput() // Void を Never に変換
                .eraseToAnyPublisher() // 型合わせ
        }
        .sink { completion in
            switch completion {
            case .finished:
                print("finished")
            case let .failure(error):
                print("error: \(error)")
            }
            print("completion.")
        } receiveValue: { hoge in
            print("receiveValue: \(hoge)")
        }
        .store(in: &cancellables)
}

func checkZero() -> AnyPublisher<Void, TestError> { // Voidに変更
    Future<Void, TestError> { promise in
        let numberValue = number.value
        print("now: \(numberValue)")
        guard number.value != 0 else {
            promise(.failure(TestError.zeroError))
            return
        }
        promise(.success(()))
    }
//    .ignoreOutput()
    .eraseToAnyPublisher()
}

func checkMinus() -> AnyPublisher<Void, TestError> { // Voidに変更
    Future<Void, TestError> { promise in
        let numberValue = number.value
        print("now: \(numberValue)")
        guard numberValue > 0 else {
            promise(.failure(TestError.minusError))
            return
        }
        promise(.success(()))
    }
//    .ignoreOutput()
    .eraseToAnyPublisher()
}

start()

number.value = 1
number.value = -1
number.value = 0

// 出力
// checkZero
// now: 1
// checkMinus
// now: 1
// checkZero
// now: -1
// checkMinus
// now: -1
// error: minusError
// completion.
// checkZero
// now: 0

今回は checkZero()flatMap では、Never としていないため、ストリームが流れて、checkMinus() まで処理が流れていることがわかります。

また、最後の AnyPublisher 返す関数側で Never であれば、sink()receiveValue までストリームがながれていないこともわかります(もしながれていれば、"receiveValue: \(hoge)" が出力されるはず)。

これは、 Never になった段階でストリームは流れなくなる証拠でもあります。

  • ポイント
    • AnyPublisher 返す関数側で Never とはせずに、flatMap の中で必要に応じて、 Never に変換する
    • これなら、ストリーム側でコントールが可能になる
      • → 何も返り値がない場合の AnyPublisherOutputVoid を返して、Never にしたい箇所で .ignoreOutput() とつけてあげるのが良さそう

まとめ

  • AnyPublisherOutputNever になった段階でストリームは流れなくなる
  • 何も返り値がない場合の AnyPublisherOutputVoid を返して、Never にしたい箇所で .ignoreOutput() とつけて Never にしてあげるのが良さそう

そもそも、こういう処理は async/await でやってあげるとよさそうであるが、 iOS15 以上対応まででよいアプリ開発がいつになったらできるのだろうか、、、

GitHubで編集を提案

Discussion