Closed1

TCAのReducerのStateをinoutに設計した理由

morninmornin

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

気になったので読む。

概要

reduceがinoutを使用している理由は?

reduce関数のインターフェース

reduce関数がinoutではなくイミュータブルな値を使用したインターフェースを考える。

例えば次のように。

func reduce(state: State, action: Action) -> Effect {

}

確かに、なぜ下の方が適しているかきっぱり言えと言われるとわからない。

この理由を人間工学的な観点とパフォーマンスの観点から説明しているので見てみる。

func reduce(state: inout State, action: Action) -> Effect {

}

inoutの採用理由(人間工学的な観点から)

The only kinds of value-type mutations that are allowed are restricted to very local scopes and only when we use explicit sigils (var, &, inout).

上に述べているとおり値型の変更は限定されている。

で、今回の問いはなぜinoutなのかという点である。

inoutでないstateの場合、コピーを作成して状態を必要に応じて変えた後返すという3つのことをしている。

func reduce(state: State, action: Action) -> (State, Effect) {
  // 1️⃣ Make a copy
  var state = state
  switch action {
  case .buttonTapped:
    // 2️⃣ Perform mutation
    state.count += 1
    // 3️⃣ Return new state
    return (state,)
  }
}

が、実際これはinoutが自動でやってくれることをあえて人間がやっていて無駄である。

inoutの挙動を確認してみる。

https://docs.swift.org/swift-book/documentation/the-swift-programming-language/declarations/#In-Out-Parameters

その挙動は次のとおりで

関数が呼び出されると、引数の値がコピーされます。
関数の本体では、コピーが変更されます。
関数が戻ると、コピーの値が元の引数に割り当てられます。

上で見たことと同じことをinoutは勝手にやってくれる。

なので、次のようにinoutであれば一個で良い操作を、inoutを外すと3つに増やして無駄なことをしている。

func reduce(state: inout State, action: Action) -> Effect {
  switch action {
  case .buttonTapped:
    // 1️⃣ Make mutation. No more steps.
    state.count += 1
    return}
}

varを嫌ってletでやろうとするとさらに具合が悪くなる。
少しの変更を加えたいだけなのにStateを1からコンストラクトしないといけなくなる

func reduce(state: State, action: Action) -> (State, Effect) {
  switch action {
  case .buttonTapped:
    // 1️⃣ Construct a whole new state
    let result = State(
      count: state.count + 1,
      // 2️⃣ Provide every field that did not change
    )
    // 3️⃣ Return new state
    return (state,)
  }
}

inoutの採用理由(パフォーマンスの観点から)

The ergnomics of inout is by far the biggest reason to use it, but the other reason is for a possible performance benefit. It is not guaranteed, but sometimes it is possible for the Swift compiler to optimize away copies of value types and perform in-place mutations. And hopefully in the future this can be strengthened to guarantees once Swift has non-copyable types.

コンパイラの最適化によってコピーが最適化される場合がある。

~Copyableにも言及しているが、少し見てみる。

https://github.com/apple/swift-evolution/blob/main/proposals/0390-noncopyable-structs-and-enums.md

~Copyableはcomsumとborrowという二つの主要な概念がある。

例えば次のように、comsumしている間はxを排他的に使用するので、他のところからborrowはできない。ただし、borrow自体は複数行うことができるといった挙動をする。

func borrow(_: borrowing FileDescriptor, and _: borrowing FileDescriptor) {}
func consume(_: consuming FileDescriptor, butBorrow _: borrowing FileDescriptor) {}
let x = FileDescriptor()
borrow(x, and: x) // still OK to borrow multiple times
consume(x, butBorrow: x) // ERROR: consuming use of `x` would end its lifetime
                         // while being borrowed

inoutの場合はborrowへの暗黙コピーを抑制するようになりborrowはできないし、排他的に使用するのでcomsumeもできなくなるようだ。

func update(_: inout FileDescriptor, butBorrow _: borrow FileDescriptor) {}
func update(_: inout FileDescriptor, butConsume _: consume FileDescriptor) {}

var y = FileDescriptor()
update(&y, butBorrow: y) // ERROR: cannot borrow `y` while exclusively accessed
update(&y, butConsume: y) // ERROR: cannot consume `y` while exclusively accessed

~Copyableがどのような動きをするのかまだ分からない。

さっき見たinoutの3ステップが

関数が呼び出されると、引数の値がコピーされます。
関数の本体では、コピーが変更されます。
関数が戻ると、コピーの値が元の引数に割り当てられます。

~Copyableによってコピーが抑制されるのであればinoutのメリットは増す(がどうなんだろう)。
~Copyableの挙動は調べる必要がある。

まとめ

reduce関数のインターフェースがinoutである理由は、まとめると

  1. Swiftの言語機能でできることをあえて人間がやって無駄なコードを作らないのが重要
  2. コンパイラによるパフォーマンス改善が見込める

の二点で、特に一つ目の理由はうっかりやりがちな気がして参考になった。

パフォーマンス改善は~Copyableで変わる可能性があるものの引き続きメリットがありそうな気はする。

このスクラップは2023/09/10にクローズされました