🐤

【Swift】Reactive Programmingをコードでざっくり理解

2022/05/12に公開

SwiftにおけるReactive Programmingについていろいろ話を聞いたので、備忘録としてまとめておく。
※この記事はReactiveSwiftのすべての機能を説明する記事ではありません。

Reactive Programmingを導入したい理由

Reactive Programmingをやることでなにが嬉しいのか? その問いに対する答えとして今自分が一番しっくりきているのは、「データフローをそのままコードに落とし込むことができるから」

コードを書く際、命令型プログラミング言語を使う際であってもデータフローを意識するのは自然なことで、APIのレスポンスやボタンの状態変化によって受け取った値をコードの中で自分の求めているような構造に変えて出力として吐き出す。イメージはこんな感じ。

データフローのイメージ
このとき、今までの命令型プログラミングでは実際のコードがこのデータフローのような構成にならないことがよくある。
特に非同期イベントがイメージしやすく、たとえばボタンを押してからAPIでデータを取得しそのデータを使って進まなければいけない場合、ボタン押下のイベント内でAPIからのレスポンスの有無のコードを書かなければいけない。

ReactiveProgrammingはそういった処理を「データとデータがどう連携しているのか」を表すように書くことができ、非同期のイベントであっても「このデータはこの元データに応じて変化します」のようにフローに注目した書き方ができるため、命令型のコードだと理解しづらいような部分でも比較的理解しやすいコードが書ける。

逆に言うと、Reactiveに書かれているコードをみれば比較的簡単にデータフローを書き出すこともできるということになる。

ざっくりした解説

現在SwiftでReactiveProgrammingをする際に使われるライブラリは

  • RxSwift
  • ReactiveSwift
  • Combine

等あるが、今回はReactiveSwiftのコードについて書いていく。
個人的に具体的なコードで一番理解しやすいのはmapだったのでmapで説明。

[("hoge", 22), ("fuga", 34)].map { (s,i) -> String in
    return s + String(i) // result: ["hoge22", "fuga34"]
}

このとき、配列 [("hoge", 22), ("fuga", 34)] は先ほどの図の中にある一つのデータの箱。map関数はそのデータの箱から一つを抜き出し(ここでは(String, Int)のタプル)その中でIntの部分をStringに変換し、繋ぎ合わせてStringにして包んで返す、という処理を配列全てに対して行う。

このmap関数による処理は先ほどの図でいうデータとデータの箱の間の矢印に相当し、このような書き方をすることでデータフロー(に似たようなコード)を描いていく。

なんらかの値が投入されて、上流が変わっていたら下流が変わる、という処理を書いていくイメージ。これが多分ReactiveProgrammingが川の流れのような〜〜とよく説明されている部分。

実際のプロダクトのコードを見てみる

import ReactiveSwift

let dataList = Property.combineLatest(PropertyOne, PropertyTwo, PropertyThree).map { 
  dataOne, dataTwo, dataThree -> (resultOne: [NewType], resultTwo: Int64)? in
    guard let contentsOne = dataOne?.contents else { return nil }
    guard let selectedDataThree = dataThree, dataThree.hasSampleContent else { return nil }
    guard let selectedDataThreeID = dataThree.contentID else { return nil }
    return (contentsOne.flatMap { contentOne -> [NewType] in
      let isActive = dataTwo.contains { $0 == contentOne.name }
      let content = contentOne.content.isEmpty ? [.contentEmpty] : contentOne.content
      .map {
        NewType.init(content: $0, contentID: selectedDataThreeID ...)
      }
      return [
        [NewType.sample(.init(name: contentOne.name, count: contentOne.count, ...))], isActive ? content : []
      ].flatMap { $0 }
    }, selectedDataThreeID)
}

実際のコードを改変して作ったものだが、ほとんど同じようなコードが書かれていた。このコードを理解するために必要な知識はPropertyとcombineLatestとmapとflatMapくらい。

Property

ReactiveSwiftにおけるPropertyのきちんとした説明はライブラリのdocを見るのが一番良い。
かなり砕けた説明をすると、先ほどの図における一つ一つのデータの箱だというふうに表現できる。
つまり、let dataList はProperty以下に書かれているいろいろな処理(データフローの矢印)が行われたあとのデータの箱であるということ。

combineLatest

combineLatestもきちんとした説明はライブラリのdocを参照した上で砕けた説明をすると、先ほどの図におけるデータの箱とデータの箱を結合して一つのデータの箱に変換する処理にあたる。
つまり、

let dataList = Property.combineLatest(PropertyOne, PropertyTwo, PropertyThree)

という部分ではPropertyOne, PropertyTwo, PropertyThreeという三つのデータの箱をdataListという一つのデータの箱に変換している、ということになる。

flatMap

flatMapの説明はライブラリのdocにて。
flatMapでは二次元以上になった配列を一次元に変換してくれる。

[[("sample", 22)], [("fuga", 12)], [("hoge", 10)]].flatMap{ $0 }
// result: [("sample", 22), ("fuga", 12), ("hoge", 10)]

flatMapが使われている部分を抜粋すると、

return (contentsOne.flatMap { contentOne -> [NewType] in
  let isActive = dataTwo.contains { $0 == contentOne.name }
  let content = contentOne.content.isEmpty ? [.contentEmpty] : contentOne.content
     .map {
      NewType.init(content: $0, contentID: selectedDataThreeID ...)
     }
  return [
    [NewType.sample(.init(name: contentOne.name, count: contentOne.count, ...))], isActive ? content : []
  ].flatMap { $0 }
}

ここでreturnされているのは、返り値として指定されている (resultOne: [NewType], resultTwo: Int64)? のうちのresultOneの中身。
ここで最終的にreturnしたいのは[NewType]で、その型の値を返すにはisActivecontentが必要とのことなので作成されています。

isActive: dataTwoに、現在mapされているcontentOneのnameが合致するものがあればtrueを返す
content: contentOne.content.isEmptyがtrueの場合enum [.countEmpty]を持ち、
                  falseの場合contentOne.contentをmapして生成される[NewType]を持つ。

最終的にreturnされるのは、[[(return内でinitされた)NewType],[NewType(content)]]flatMapして変換された一次元配列[NewType]になる。

これで、PropertyOne, PropertyTwo, PropertyThreeという三つのProperty(データの箱)を一つにまとめ、([NewType], Int64)?に変化させるという二つの矢印の実装ができたということになる。

先ほどの図で表してみるとこんな感じ。

中間にある(Property)はコード上に中間として存在しないが、combineLatestをしただけの変数を宣言しその変数でmapを行えば図と完全に同等な処理になる。

おまけ

モナドという言葉をご存じでしょうか。自分も正直よくわかっていないんですが、モナドは「『箱』みたいなもの」というふうに教わりました。

IntやStringなど、データ型はよく初心者向けに「IntやStringといった種類の箱があり、そこにしか入れられないものがある」というような説明がされていますが、その「箱」に近いイメージ。

ところで、データ型にはIntStringなどAtomicな型に加え、Int?Array<Int>などAtomicではないものがあります。OptionalやArrayは一段階型が拡張されたようなイメージですが、もう一段階拡張された型としてResult<V, E>Future<V, E>などがあります。

Result<V, E>はValue, Errorの2パターンを持つenumで、ValueとErrorの型を開発者側が指定してswitch文でエラー処理を書いていくような実装になります。

Future<V, E>は同様のパターンを持つenumですが、Futureは非同期で値を返すことが可能です。
こういった型もモナドであるためmap(mapError)やflatMapが使えます。

ReactiveProgrammingではこの型の拡張が複数回行われているためぱっと見で理解することが難しく、なんとなく「Reactiveって何言ってるかわからない」と感じる原因になってしまうのではないか、というのがあります。

この話を聞いて、C言語などの手続型プログラミングからオブジェクト指向を勉強し始めた時に「何言ってるのかよくわからない」と感じるのと同じような感覚なのかな、と思いました。

Reactive Programmingも根気強く触って体で理解するにつきますね。

Discussion