🌕

パート3 : TCAでactionをsendして実行されるまで

2023/08/27に公開

前回はStore.sendの処理を上から確認し再入可能なactionの処理ついて見ました。
今回はその続きのbufferdActionsからactionを取り出しこれをreducerに渡すところからです。

このreducerは私たちが実装したreducerではなくScopedReducerと呼ばれるreducerです。
今回は現状のScopedReducerの処理を見るのにその実装経緯などいくつか考古学していきます。

  @_spi(Internals)
  public func send(
    _ action: Action,
    originatingFrom originatingAction: Action?
  ) -> Task<Void, Never>? {
    self.threadCheck(status: .send(action, originatingAction: originatingAction))

    // bufferedActionsへの溜め込み
    self.bufferedActions.append(action)
    guard !self.isSending else { return nil }

    // 再入可能actionの処理
    self.isSending = true
    var currentState = self.state.value
    let tasks = Box<[Task<Void, Never>]>(wrappedValue: [])
    defer {
      withExtendedLifetime(self.bufferedActions) {
        self.bufferedActions.removeAll()
      }
      self.state.value = currentState
      self.isSending = false
      if !self.bufferedActions.isEmpty {
        if let task = self.send(
          self.bufferedActions.removeLast(), originatingFrom: originatingAction
        ) {
          tasks.wrappedValue.append(task)
        }
      }
    }

    // ↓ここから
    var index = self.bufferedActions.startIndex
    while index < self.bufferedActions.endIndex {
      defer { index += 1 }
      let action = self.bufferedActions[index]
      let effect = self.reducer.reduce(into: &currentState, action: action)
      
     ...

ScopedReducer

さてself.reducer.reduceは一見すると私たちが実装したReducerのreduceが呼ばれるように見えます。ここでreduce関数から帰ってきた副作用がeffectとして返されているのがわかります。

https://github.com/pointfreeco/swift-composable-architecture/blob/6dfdd189064f96fb2543265cf55148ec731c70b3/Sources/ComposableArchitecture/Store.swift#L459

しかし、実際にはこのreducer.reduceはこのScopedReducerを介してコールされます。

https://github.com/pointfreeco/swift-composable-architecture/blob/6dfdd189064f96fb2543265cf55148ec731c70b3/Sources/ComposableArchitecture/Store.swift#L711-L716

これは、WithViewStoreを介した際にrescopeという処理が行われるためです。
現在のScopedReducerに至るまでの経緯を振り返ってふたたび現代の実装を見比べることにします。

Stateのチェーン

TCAリリース初期の実装ではScopedReducerは存在せず、単にドメインを絞ったStoreを作成し、それを親のStoreとチェーンしている実装でした。

  public func scope<LocalState, LocalAction>(
    state toLocalState: @escaping (State) -> LocalState,
    action fromLocalAction: @escaping (LocalAction) -> Action
  ) -> Store<LocalState, LocalAction> {
    let localStore = Store<LocalState, LocalAction>(
      initialState: toLocalState(self.state),
      reducer: { localState, localAction in
        self.send(fromLocalAction(localAction))
        localState = toLocalState(self.state)
        return .none
      }
    )
    localStore.parentCancellable = self.$state
      .sink { [weak localStore] newValue in localStore?.state = toLocalState(newValue) }
    return localStore
  }

https://github.com/pointfreeco/swift-composable-architecture/commit/d2240d0e76c1a758dbadbf737ceefc888b2e807c#diff-50345ad35b59a32af74fda59695df9c6b1826302d5b41e5d8fbe47477917cc5dR58

ScopedReducerの誕生

現在と同様のScopedReducerが誕生したのはこのPRですが

https://github.com/pointfreeco/swift-composable-architecture/pull/1316

最初にそのアイディアが出たのはこのDiscussionです。

https://github.com/pointfreeco/swift-composable-architecture/discussions/1290

当時の挙動の問題についてはこのDiscussionを提起した方がサンプルを置いてくれていたのでそれで確認することができます(今でもそのまま落としてきて普通に動いて確認することができます)。

https://github.com/alexjameslittle/tca-performance-issues

当時のTCAはv0.39で、scopeの実装はこんな感じでした。

    // scopeによって新たに生成されたstore
    let localStore = Store<LocalState, LocalAction>(
      initialState: toLocalState(self.state.value),
      reducer: .init { localState, localAction, _ in
        isSending = true
        defer { isSending = false }
        let task = self.send(fromLocalAction(localAction))
        localState = toLocalState(self.state.value)
        if let task = task {
          return .fireAndForget { await task.cancellableValue }
        } else {
          return .none
        }
      },
      environment: ()
    )

仮に今ChildStoreA、ChildStoreB、ChildStoreCがあったとすると、ChildStoreAがscopeしてChildStoreBにこのlocalStoreを渡し、ChildStoreBがscopeしてChildStoreCにさらにlocalStoreを渡します。

ChildStoreCでactionをsendすると、ChildStoreCはChildStoreBのsendを呼び出し、ChildStoreBはChildStoreAのsendを呼び出しまします。Viewの階層が深くなり、D、E、Fとstoreのネストが深くなるとsendのrecursionとなり、このような長大なスタックトレースとなります。

このような呼び出しを回避するために@iampatbrownさんによってScopedReducerが提案されました。

scope関数は次のようになり、reducer.rescopeによってchildStoreが返るようになりました(初回はrescopeせずに後続の処理でScopedReducerを返します)。

  public func scope<ChildState, ChildAction>(
    state toChildState: @escaping (State) -> ChildState,
    action fromChildAction: @escaping (ChildAction) -> Action
  ) -> Store<ChildState, ChildAction> {
    self.threadCheck(status: .scope)
    #if swift(>=5.7) && !DEBUG
    if let childStore = self.reducer.rescope(self, state: toChildState, action: fromChildAction) {
      return childStore
    }
    #endif

    let reducer = ScopedReducer(store: self, state: toChildState, action: fromChildAction)
    let childStore = Store<ChildState, ChildAction>(
      initialState: toChildState(self.state.value),
      reducer: reducer
    )
    childStore.parentCancellable = self.state
      .dropFirst()
      .sink { [weak childStore] newValue in
        guard !reducer.isSending else { return }
        childStore?.state.value = toChildState(newValue)
      }
    return childStore
  }

rescopeはReducerProtocolのextensionに定義されています。自身が_ScopedReducerに準拠している場合にrescopeします。

extension ReducerProtocol {
  func rescope<ChildState, ChildAction>(
    _ store: Store<State, Action>,
    state toChildState: @escaping (State) -> ChildState,
    action fromChildAction: @escaping (ChildAction) -> Action
  ) -> Store<ChildState, ChildAction>? {
    (self as? any _ScopedReducer)?.rescope(store, state: toChildState, action: fromChildAction)
  }
}
protocol _ScopedReducer {
  func rescope<ChildState, ChildAction, NewChildState, NewChildAction>(
    _ store: Store<ChildState, ChildAction>,
    state toNewChildState: @escaping (ChildState) -> NewChildState,
    action fromNewChildAction: @escaping (NewChildAction) -> ChildAction
  ) -> Store<NewChildState, NewChildAction>?
}

rescopeの実装はこのようになっていおり、reducerのstoreを再利用することで先ほどの再起的なsendが起こらないようになっています。

extension ScopedReducer: _ScopedReducer {
  func rescope<ChildState, ChildAction, NewChildState, NewChildAction>(
    _ store: Store<ChildState, ChildAction>,
    state toNewChildState: @escaping (ChildState) -> NewChildState,
    action fromNewChildAction: @escaping (NewChildAction) -> ChildAction
  ) -> Store<NewChildState, NewChildAction>? {
    guard
      let toChildState = self.toChildState as? (ParentState) -> ChildState,
      let fromChildAction = self.fromChildAction as? (ChildAction) -> ParentAction
    else { return nil }
    let reducer = ScopedReducer<ParentState, ParentAction, NewChildState, NewChildAction>(
      store: self.store,
      state: { toNewChildState(toChildState($0)) },
      action: { fromChildAction(fromNewChildAction($0)) }
    )
    let childStore = Store<NewChildState, NewChildAction>(
      initialState: toNewChildState(store.state.value),
      reducer: reducer
    )
    childStore.parentCancellable = store.state
      .dropFirst()
      .sink { [weak childStore] newValue in
        guard !reducer.isSending else { return }
        childStore?.state.value = toNewChildState(newValue)
      }
    return childStore
  }
}

ScopedReducerは名前こそReducerProcolの実装の際に生成され存在していましたが、実装は@iampatbrownさんの実装前までは、根本的にはチェーンは同じままでした。ScopedReducerのstoreにselfを渡すため同じようにsend呼び出しが長くなる可能性がありました。

  public func scope<ChildState, ChildAction>(
    state toChildState: @escaping (State) -> ChildState,
    action fromChildAction: @escaping (ChildAction) -> Action
  ) -> Store<ChildState, ChildAction> {
    self.threadCheck(status: .scope)
    let reducer = ScopedReducer(store: self, state: toChildState, action: fromChildAction)
    let childStore = Store<ChildState, ChildAction>(
      initialState: toChildState(self.state.value),
      reducer: reducer
    )
    childStore.parentCancellable = self.state
      .dropFirst()
      .sink { [weak childStore] newValue in
        guard !reducer.isSending else { return }
        childStore?.state.value = toChildState(newValue)
      }
    return childStore
  }
private final class ScopedReducer<State, Action, ChildState, ChildAction>: ReducerProtocol {
  let store: Store<State, Action>
  let toChildState: (State) -> ChildState
  let fromChildAction: (ChildAction) -> Action
  private(set) var isSending = false

  @inlinable
  init(
    store: Store<State, Action>,
    state toChildState: @escaping (State) -> ChildState,
    action fromChildAction: @escaping (ChildAction) -> Action
  ) {
    self.store = store
    self.toChildState = toChildState
    self.fromChildAction = fromChildAction
  }

  @inlinable
  func reduce(into state: inout ChildState, action: ChildAction) -> Effect<ChildAction, Never> {
    self.isSending = true
    defer {
      state = self.toChildState(self.store.state.value)
      self.isSending = false
    }
    if let task = self.store.send(self.fromChildAction(action)) {
      return .fireAndForget { await task.cancellableValue }
    } else {
      return .none
    }
  }
}

https://github.com/pointfreeco/swift-composable-architecture/blob/79e9e3d60ba7d32ee53cb0c4611c1e649415c18b/Sources/ComposableArchitecture/Store.swift#L541

最終的な実装ではこのstoreがrootという変数に集約されてマージされたようです。
ReducerProcolを変更しないように書き換えているのでAnyScopeとなっています。

https://github.com/pointfreeco/swift-composable-architecture/pull/1316/files

ただ、これで現在のScopedReducerに近い形になり、またrootStoreなどの変数が存在する理由なども明らかになりました。

private final class ScopedReducer<RootState, RootAction, State, Action>: Reducer {
  let rootStore: Store<RootState, RootAction>
  let toScopedState: (RootState) -> State
  private let parentStores: [Any]
  let fromScopedAction: (State, Action) -> RootAction?
  private(set) var isSending = false

ScopedReducerの説明でちょうど切りが良いので続きは後続のパートで。

次はreducer.bodyを見ます。

Discussion