Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Proposal] Composable Reducers using Lenses and Prisms #171

Closed
wants to merge 11 commits into from
250 changes: 250 additions & 0 deletions Cookbook/Proposals/ComposableReducers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
# Composable Reducers using Lenses and Prisms

* Author: Yasuhiro Inami
* Review Manager: David Rodrigues

## Introduction

This proposal adds functional [`Lens` and `Prism`](#functional-lens--prism) to break down `reducer` / `state` / `event` (`action`) in [ReactiveFeedback](https://github.com/Babylonpartners/ReactiveFeedback) so that we can create a large application from small component compositions.

inamiy marked this conversation as resolved.
Show resolved Hide resolved
## Motivation

In Babylon.app, we are using [ReactiveFeedback](https://github.com/Babylonpartners/ReactiveFeedback) to control states, side-effects, and feedback loops to define a particular screen (view controller) behavior, owned by `ViewModel`.

However, this `ViewModel` can easily become too complex as `reducer: (State, Event) -> State` grows large with tons of pattern-matching.

Unfortunately, splitting into multiple `ViewModel`s is not a clever solution, as managing multiple `ReactiveFeedback`s that interact with each other tends to be hard to control.
inamiy marked this conversation as resolved.
Show resolved Hide resolved

To curcumvent this problem, we instead **split `reducer`s and also `state`s and `event`s**, and then combine them using `Lens` and `Prism`.

The basic idea can be found in @mbrandonw ’s talk:

[Brandon Williams \- Composable Reducers & Effects Systems \- YouTube](https://www.youtube.com/watch?v=QOIigosUNGU)

## Proposed solution

### Functional `Lens` & `Prism`

```swift
/// For accessing struct members.
/// e.g. Whole = whole struct (members), Part = partial member
struct Lens<Whole, Part> {
let get: (Whole) -> Part
let set: (Whole, Part) -> Whole
}

/// For accessing enum cases.
/// e.g. Whole = all possible enum cases, B = partial case
struct Prism<Whole, Part> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are preview and review standard lingo for a Prism? I know that Brandon used these names in his presentation but I don’t think they describe the purpose of the two functions very well. To me, subspace and update reads more clearly.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think Brandon followed Haskell's naming for those: http://hackage.haskell.org/package/lens-4.17.1/docs/Control-Lens-Prism.html

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@zzcgumn
Yes, preview and review are commonly used names in Haskell.

There are some alternatives for Prism method names which may be more intuitive:

  1. Scala Monocle uses getOption: Whole => Option[Part] and reverseGet (aka apply): Part => Whole
    https://julien-truffaut.github.io/Monocle/optics/prism.html

  2. tryGet: Whole -> Part? and inject: Part -> Whole is introduced here
    https://broomburgo.github.io/fun-ios/post/lenses-and-prisms-in-swift-a-pragmatic-approach/

I'm OK with renaming too (I personally prefer 2.), but we might want to stay as is, since there are many more upcoming layers in these functional Optics family as described in
http://oleg.fi/gists/posts/2017-04-18-glassery.html

So renaming each of them may probably consume our brain too much that it's often better to just borrow the same names from original.

let preview: (Whole) -> Part?
let review: (Part) -> Whole
}
```
inamiy marked this conversation as resolved.
Show resolved Hide resolved

- `Lens` is a pair of "getter" and "setter" (similar to `WritableKeyPath<A, B>`, but more composable)
- `Prism` is a pair of:
- `preview` (tryGet): Tries to get an associated value of particular enum case from whole enum cases, which is failurable
inamiy marked this conversation as resolved.
Show resolved Hide resolved
- `review` (inject): Creates whole enum from particular case (i.e. `case` as enum constructor)

While `Lens` is useful for traversing `struct` members, `Prism` is useful for traversing `enum` cases.
Because in ReactiveFeedback, `State` is normally defined as `struct` and `Event` is `enum`,
we need both features to be able to transform `reducer` and `feedback` into arbitrary structure.

### `Reducer`

```swift
struct Reducer<Action, State> {
let reduce: (Action, State) -> State

init(_ reduce: @escaping (Action, State) -> State) {
self.reduce = reduce
}

/// Zero value for `+`.
static var empty: Reducer {
return Reducer { _, s in s }
}

// Append operator, just like `+`.
static func <> (lhs: Reducer, rhs: Reducer) -> Reducer {
return Reducer { action, state in
rhs.reduce(action, lhs.reduce(action, state))
}
}
}
```

`Reducer` is a wrapper type around `reduce: (Action, State) -> State` function that conforms to `Monoid` (has "zero" and "+") to combine 2 reducers into one.

By using this `append`ing capability, we can create more complex `Reducer` from splitted `SubReducer`s.

### `Reducer` lifting from `SubState` / `SubAction`

However, `SubReducer`s don't normally have the same type with the others, even with the `(Main)Reducer` type.

For example,

- Main screen: `MainReducer = Reducer<MainAction, MainState>`
- Component 1: `SubReducer1 = Reducer<Sub1Action, Sub1State>`
- Component 2: `SubReducer2 = Reducer<Sub2Action, Sub2State>`
- ...

To convert `SubReducer1` and `SubReducer2` types into `MainReducer` (so that they can be combined using `<>`), we can use the following `lift` functions:

```swift
extension Reducer {
/// `Reducer<Action, SubState> -> `Reducer<Action, State>`
func lift<SuperState>(state lens: Lens<SuperState, State>) -> Reducer<Action, SuperState> {
return Reducer<Action, SuperState> { action, superState in
lens.set(superState, self.reduce(action, lens.get(superState)))
inamiy marked this conversation as resolved.
Show resolved Hide resolved
}
}

/// `Reducer<SubAction, State> -> `Reducer<Action, State>`
func lift<SuperAction>(action prism: Prism<SuperAction, Action>) -> Reducer<SuperAction, State> {
return Reducer<SuperAction, State> { superAction, state in
guard let action = prism.preview(superAction) else { return state }
return self.reduce(action, state)
}
}
}
```

In short, to bring each small reducers to the same level and combine, we need `lift`.

And to `lift`, we need `Lens` and `Prism`.

### `Feedback` composition

`Lens` and `Prism` are useful for not only composing `Reducer` but also [`ReactiveFeedback.Feedback`](https://github.com/Babylonpartners/ReactiveFeedback/blob/0.6.0/ReactiveFeedback/Feedback.swift#L6).

```swift
struct Feedback<Action, State> {
let transform: (Signal<State>) -> Signal<Action>
inamiy marked this conversation as resolved.
Show resolved Hide resolved

/// Zero value for `+`.
static var empty: Feedback {
return Feedback { _ in .empty }
}

// Append operator, just like `+`.
static func <> (lhs: Feedback, rhs: Feedback) -> Feedback {
return Feedback { state in
Signal.merge(lhs.transform(state), rhs.transform(state))
}
}
}

extension Feedback {
public func lift<SuperState>(state lens: Lens<SuperState, State>) -> Feedback<Action, SuperState> {
return Feedback<Action, SuperState> { superState in
self.transform(superState.map(lens.get))
}
}

public func lift<SuperAction>(action prism: Prism<SuperAction, Action>) -> Feedback<SuperAction, State> {
return Feedback<SuperAction, State> { state in
self.transform(state).map(prism.review)
}
}
}
```

## Example

```swift
// MARK: - Component 1 (isolated from Main & Component 2)
//---------------------------------------------------------

enum Sub1Action {
case increment
case decrement
}

struct Sub1State {
var count: Int = 0
}

let subReducer1 = Reducer<Sub1Action, Sub1State> { action, state in
switch action {
case .increment: return state.with { $0.count + 1 }
case .decrement: return state.with { $0.count - 1 }
}
}

let subFeedback1: Feedback<Sub1Action, Sub1State> = .empty

// MARK: - Component 2 (isolated from Main & Component 1)
//---------------------------------------------------------

enum Sub2Action { ... }
struct Sub2State { ... }
let subReducer2: Reducer<Sub2Action, Sub2State> = ...
let subFeedback2: Feedback<Sub2Action, Sub2State> = ...

// MARK: - Main
//---------------------------------------------------------

enum MainAction {
case sub1(Sub1Action)
case sub2(Sub2Action)
...
}

extension Prism where Whole == MainAction, Part == Sub1Action {
static let sub1Action = Prism(
preview: {
guard case let .sub1Action(action) = $0 else { return nil }
inamiy marked this conversation as resolved.
Show resolved Hide resolved
return action
},
review: MainAction.sub1Action
inamiy marked this conversation as resolved.
Show resolved Hide resolved
)
}

...

struct MainState {
var sub1: Sub1State
var sub2: Sub2State
// var shared: ... /* NOTE: Shared state can belong to here */
}

extension Lens where Whole == MainState, Part == Sub1State {
static let sub1State = Lens(
get: { $0.sub1 },
set: { whole, part in
whole.with { $0.sub1 = part }
}
)
}

...

let mainReducer: Reducer<MainAction, MainState> =
Copy link
Contributor

@ilyapuchka ilyapuchka Jul 1, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With this what will happen with the mainReducer? It will call the sub reducers to take care of their respective parts and then translate their results into the MainState (which is yet to be defined by the lens/prism implementations), right?

Is it different essentially from breaking reducer into smaller functions?

func reducer(state: State, event: Event) -> Event {
  switch event {
    case .sub1(sub1Event):
      return state.set(\.sub1, reduceSub1(state: state.sub1, event: sub1Event))
    ...
  }
}

func reduceSub1(state: Sub1State, event: Sub1Action) -> Sub1State {
  switch event { ... }
}

Maybe seeing how this will fit into splitting view models (if it will) and with feedbacks will make this difference more appealing than for just reducers on their own?
Comparing two different approaches side by side will also probably make the difference more visual.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By introducing "Composable Reducers", we don't have to write reducer (main reducer) pattern-matching, which we don't want to repeat things over and over again.

I also added "Composable Feedbacks" example in 4db5676 , so it becomes more apparent if we rewrite mainFeedback by hand.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd emphasise even more this point in the proposal itself, being one of the most powerful advantages of FP compositionality. 👍

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But don't we write the same pattern matching in the prisms implementations? =) It might be a bit easier to code generate, but until it is in place we'll have to write this boilerplate 🤷‍♂ I like how prisms look simple and separate concerns and I used them (in a bit different form) myself, but not sure what boilerplate will be more clear.
P.S. I'm 💯 into code generating this

Copy link
Contributor Author

@inamiy inamiy Jul 3, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ilyapuchka
Pattern-matching in small Prism layer and making its composition is more FP way than pattern-matching whole gigantic enum at once filled with un-reusable code.

It becomes more and more apparent once we have n-layered reducers / feedbacks.
(Though it may sound unrealistic, IMO we should be ready for such scalable architecture)

And I totally agree that this proposal gains more popularity once we introduce codegen.
But even without it, I think it's worth introducing Lens / Prism for scalability.

I personally can't live without these features to split large codebase.
(Currently ongoing in https://github.com/Babylonpartners/babylon-ios/pull/7938 )
And unfortunately, current Swift KeyPath can't solve this problem.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm all into splitting huge switch into smaller pieces, that should be probably emphasised in the proposal as a win with pros and cons of each approach (I'm not yet sold thought that it will improve reusability on practice due to how types depend on each other in our code base) 👍
I think it will worth also adding your thoughts about why KeyPaths don't fit in the proposal, good candidate for Alternatives Considered section 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

47bde8b Added composition example to explain the simpler solution.

subReducer1
.lift(action: .sub1Action)
.lift(state: .sub1State)
<>
subReducer2
.lift(action: .sub2Action)
.lift(state: .sub2State)

let mainFeedback: Feedback<MainAction, MainState> =
inamiy marked this conversation as resolved.
Show resolved Hide resolved
subFeedback1
.lift(action: .sub1Action)
.lift(state: .sub1State)
<>
subFeedback2
.lift(action: .sub2Action)
.lift(state: .sub2State)
```

Please notice how consistent the compositions of various types can be achieved with the same syntax!

## Impact on existing codebase

This proposal will affect all construction of `ViewModel` and `RAF`, but we can apply modification little by little.

## Alternatives considered

TBD