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 to improve naming and reduce concepts #228

Closed
Laimiux opened this issue Sep 9, 2021 · 8 comments
Closed

Proposal to improve naming and reduce concepts #228

Laimiux opened this issue Sep 9, 2021 · 8 comments

Comments

@Laimiux
Copy link
Collaborator

Laimiux commented Sep 9, 2021

What

To improve naming and create more cohesiveness, I'm thinking to consolidate around 3 concepts: data, calculations, and actions.

The concepts are borrowed from Grokking Simplicity

Data

Inert objects such as Input, State, Output, RenderModel, State, Evaluation.

Calculations

Place where decision-making lives. It takes inputs and produces an output (no actions/side-effects happen within this). An example would be initialState, evaluate, or event callbacks.

Action

Action is a function that contains side effects such as

  • Reading from or writing to a database
  • Making a network request
  • Firing analytics
  • Subscribing to an event (aka setting a listener)

Proposal

As the first step, I'm thinking to rename some types to fit the mental model above.

Old New
Effects Action
Stream DisposableAction
RxStream RxDisposableAction
Update BoundedAction
Evaluation.updates Evaluation.actions
Transition.OnlyEffects Transition.OnlyAction
@Laimiux Laimiux changed the title Improve naming and reduce concepts Proposal to improve naming and reduce concepts Sep 9, 2021
@alexanderbezverhni
Copy link
Collaborator

I guess I'll need to read the book to fully embrace this concept 😅

These renames look reasonable to me for the most parts. I'm a bit concerned about Stream and RxStream renames though. I think that currently they perfectly represent the fact that there might be more than 1 single action, but rather a series of actions. This is getting lost if we rename Stream -> DisposableAction. Maybe ActionStream? Although it's not following ...Action suffix pattern, so I'm not sure how it fits.

@Laimiux
Copy link
Collaborator Author

Laimiux commented Sep 10, 2021

I guess I'll need to read the book to fully embrace this concept 😅

These renames look reasonable to me for the most parts. I'm a bit concerned about Stream and RxStream renames though. I think that currently they perfectly represent the fact that there might be more than 1 single action, but rather a series of actions. This is getting lost if we rename Stream -> DisposableAction. Maybe ActionStream? Although it's not following ...Action suffix pattern, so I'm not sure how it fits.

So far (I'm only halfway), the book covers basic functional concepts which I'm pretty familiar with (likely you are as well), but it does teach them in a well-structured way. Once I finish the book, I can let you know if it's worth reading in full.

Regarding the Stream, sending events back to Formula is one of the actions it can perform. It also performs the following actions:

  • Subscribe/unsubscribe from an Observable
  • Set/unset a listener
  • It could perform some actions without emitting an event back to Formula (such as making a fire-and-forget network request or firing analytics internally).

From a more general point of view, Stream doesn't provide much in terms of reactive APIs or composability which is why I don't think the name fully fits. It's mostly an adapter interface to allow you to connect to an "event bus" through listeners, subscribe to RxJava or Kotlin Flow (in the future), etc. Essentially, you need to have a separate mechanism on which to build your reactive input-output system.

@emmano
Copy link

emmano commented Sep 13, 2021

Hopefully I am not too late for this discussion, but here are my 2 cents having recently attempted to integrate coroutines into Formula (#233)

I really liked how Stream is an abstraction that represents a stream of Input to Output that is agnostic of reactive framework.
This made Flow integration much easier. Migrating Stream to DisposableAction seems to tie its implementation to Rx. As @Laimiux says a Stream provides a hook into the reactive part of the framework.

I definitely support the introduction of Actions as a concept, but I would like to expand on it a bit.

I believe Effects do not directly map Actions. Actions seems more related to Transitions.
A Transition is composed of a State and an optional Effect; given a State and an Effect produce a new State.
Transitions convey the concept of moving from one state to another, i.e. as a result of this transition I am moving from state A to state B.
Transitions are not really concerned about how the new State will be created but just about the fact that there will be a new State after the transition. The fact that Effect is optional in Transition also seems to support this.

I see different types of actions:

SyncAction -> A synchronous state generator based of pure state functions. fun() : suspend (S) -> S, runs on Dispatchers.main.immediate
AsyncAction -> An asynchronous state generator based on current state and single shot side effects. fun(currentState: S) : suspend (S) -> S, runs on Dispatchers.IO
StreamAction -> An asynchronous stream based state generator based on current state and side effects. fun(currentState: S) : Flow<(S) -> S}>, runs on Dispatchers.IO

This paradigm seems to collide with the Transition<Nothing> (i.e. OnlyEffects) construct, which begs the question, are there really Transitions that require no State or are these a subset of the cases outlined above?
Looking at evaluate() seems to indicate that all transitions have access to the state, but they may or may not need use it to generate a new state.

Couldn't all side effect only Transitions/Actions be defined as AsyncActions?

fun (currentState: S) : suspend (S) -> S {
val sideEffect: (suspend (S) -> S) = {accumulatedState: S ->  /*call api;*/ accumulatedState.copy() }
return sideEffect
}

A closer look at evaluate() also tells us:

  1. When it comes to state updates, the actual state passed in is not used until the callback()/updates() is invoked. It is inside the Action/Transition/Update where state really gets used.
  2. There are two different general mechanisms for creating new states: context.callback and context.updates.
  3. If we were to offload creating new states to Actions, evaluate() becomes a method that uses state to generate a RenderModel; it becomes more of a getModel()

Let's look at a simple example:

class CounterFormula : Formula<Unit, Int, CounterRenderModel> {

    override fun initialState(input: Unit): Int = 0

    override fun evaluate(
        input: Unit,
        state: Int,
        context: FormulaContext<Int>
    ): Evaluation<CounterRenderModel> {
        return Evaluation(
            output = CounterRenderModel(
                count = "Count: $state",
                onDecrement = context.callback {
                    transition(state - 1)
                },
                onIncrement = context.callback {
                    transition(state + 1)
                }
            )
        )
    }
}

would become:

class CounterFormula : Formula<Unit, State, CounterRenderModel> {

    override fun initialState(input: Unit): State = State(count: 0)

    override fun model(
        state: State
    ): CounterRenderModel {
        return CounterRenderModel(
                count = "Count: $state.count",
                onDecrement =  {
                    dispatch(DECREMENT)
                },
                onIncrement = {
                    dispatch(INCREMENT)
                }
        )
    }
}

Where DECREMENT and INCREMENT are SyncActions

object INCREMENT : SyncAction<State> {

override fun stateGenerator() = {accumulatedState: State -> accumulatedState.copy(count: accumulatedState.count + 1)}
}

object DECREMENT : SyncAction<State> {

override fun stateGenerator() = {accumulatedState: State -> accumulatedState.copy(count: accumulatedState.count - 1)}
}

Creating different types of Actions mostly removes the need of a context; Actions inherently determine how they are going to update state.
Having a unified mechanism for creating new states removes the need for Evaluation to hold a List<Update>. At that point Evaluation is just a wrapper around the Output (i.e. RenderModel) and can be removed as a concept.

It seems that the input parameter should only be used for initial state creation and is not directly needed by the model. Input is provided to the runtime via its onInput(), but it is either always Unit or seems to only be used for initial state creation.

dispatch() just takes an Action<S> and sends the action to the correct stream of Action<S>

How can all of this be implemented?

I took this code from here and changed it a bit to better fit what I described above. I have not updated it in a while hence the use of old classes/mechanisms (i.e. ConflatedBroadcastChannel). This code is part of a repo where I explored the MVAction pattern some time ago.

It is a pure kotlin implementation of what I outlined above, but the same can be accomplished with Rx.

@FlowPreview
@ExperimentalCoroutinesApi
class StateProvider<S>(
    private val initialState: S,
    private val syncDispatcher: CoroutineDispatcher = Dispatchers.Main.immediate,
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) {

    private val asyncActions by lazy { ConflatedBroadcastChannel<AsyncAction<S>>() }
    private val syncActions by lazy { ConflatedBroadcastChannel<SyncAction<S>>() }
    private val streamActions by lazy { ConflatedBroadcastChannel<StreamAction<S>>() }

    private val state by lazy { ConflatedBroadcastChannel(initialState) }

    private lateinit var errorAction: (cause: Throwable) -> (suspend (S) -> S)

    private val operation: suspend ((S, suspend (S) -> S) -> S) = { acc, value ->
        val newState = value(acc)
        check((acc !== newState)) { "State should be immutable. Make sure you call copy()" }
        newState
    }

    fun error(action: (cause: Throwable) -> (suspend (S) -> S)) {
        errorAction = action
    }

    private val asyncState by lazy {
        asyncActions
            .asFlow()
            .map {
                it.fire(state.value)
            }
            .catch { t ->
                emit(errorAction(t))
            }
            .flowOn(ioDispatcher)
    }

    private val streamState by lazy {
        streamActions
            .asFlow()
            .flatMapConcat {
                it.fire(state.value) //naïve implementation
            }
            .catch { t ->
                emit(errorAction(t))
            }
            .flowOn(ioDispatcher)
    }

    private val syncState by lazy {
        syncActions
            .asFlow()
            .map { it.fire() }
            .catch { t ->
                emit(errorAction(t))
            }
            .flowOn(syncDispatcher)
    }

    val combinedState by lazy {

        flowOf(syncState, asyncState, streamState)
            .flattenMerge()
            .scan(initialState, operation)
            .map {
                state.send(it);
                it
            }.flowOn(syncDispatcher)
    }

    suspend fun dispatch(action: Action<S>) {
        when (action) {
            is AsyncAction -> asyncActions.send(action)
            is SyncAction -> syncActions.send(action)
            is StreamAction -> streamActions.send(action)
        }
    }

}

interface Action<S>

interface SyncAction<S> : Action<S> {
    fun fire(): suspend (S) -> S
}

interface AsyncAction<S> : Action<S> {
    fun fire(currentState: S): suspend (S) -> S
}

interface StreamAction<S> : Action<S> {
    suspend fun fire(currentState: S): Flow<suspend (S) -> S>
}

Then combinedState can be transformed into a Stream if needed.

By no means I am trying to force this implementation or none of the terms above, I just found it really interesting that maybe there is some alignment in how we think about managing state.

@lgleasain
Copy link
Contributor

I'm still in the phase where I don't have strong opinions on this yet. That said, do these re-names make it easier to grok what is happening or abstract things to a level where someone needs to learn another concept to understand the framework. To me an action is easy to confuse with a calculation from hearing the words. I could see someone confusing the two. I'm not sure what a more descriptive alternative would be. Calculations feel like logic to me. But I'm struggling for a better word than just plain actions.

@emmano
Copy link

emmano commented Sep 13, 2021

I definitely agree, but at some point there will be a learning curve where new/foreign concepts will need to be assimilated. Using Actions maybe is helpful since other frameworks use the name for a similar construct; people trying to understand the concept can find help outside the "Formula" world.

I am not so much interested in the name difference as I am with how moving away from Transitions and Evaluations removes concepts, potentially making using Formula simpler.

Actions seem to be the term that more closely matches this mechanism with what other frameworks do, but if something better comes along I am all for it.

@Laimiux
Copy link
Collaborator Author

Laimiux commented Sep 13, 2021

Thanks for a really thoughtful comment. I generally think that some of this is out of scope for this issue as it would require huge potentially non-incremental changes to the Formula core API.

I really liked how Stream is an abstraction that represents a stream of Input to Output that is agnostic of reactive framework.
This made Flow integration much easier. Migrating Stream to DisposableAction seems to tie its implementation to Rx. As @Laimiux says a Stream provides a hook into the reactive part of the framework.

The API of Stream wouldn't change, only the name. So, your implementation would only change by renaming FlowStream to FlowDisposableAction and that's it. The naming is inspired by the SideEffect and DisposableSideEffect within Compose.

I believe Effects do not directly map Actions. Actions seems more related to Transitions.
A Transition is composed of a State and an optional Effect; given a State and an Effect produce a new State.
Transitions convey the concept of moving from one state to another, i.e. as a result of this transition I am moving from state A to state B.
Transitions are not really concerned about how the new State will be created but just about the fact that there will be a new State after the transition. The fact that Effect is optional in Transition also seems to support this.

This brings a good point that Action is a bit of a loaded term and different folks have different associations of it. I'm using Action term here as a synonym for a functional side-effect.

In Formula, Transition is a data object created by a calculation. Essentially, the developer writes the calculation which generates the Transition and Formula runtime executes it (which is an action). Again, I'm probably using concept of Action in a different way from the way you think of it.

SyncAction -> A synchronous state generator based of pure state functions. fun() : suspend (S) -> S, runs on Dispatchers.main.immediate
AsyncAction -> An asynchronous state generator based on current state and single shot side effects. fun(currentState: S) : suspend (S) -> S, runs on Dispatchers.IO
StreamAction -> An asynchronous stream based state generator based on current state and side effects. fun(currentState: S) : Flow<(S) -> S}>, runs on Dispatchers.IO

This is a big change and I'm not immediately sure this paradigm would cover all use-cases. Will need to think more about it.

  1. When it comes to state updates, the actual state passed in is not used until the callback()/updates() is invoked. It is inside the Action/Transition/Update where state really gets used.
  2. There are two different general mechanisms for creating new states: context.callback and context.updates.
  3. If we were to offload creating new states to Actions, evaluate() becomes a method that uses state to generate a RenderModel; it becomes more of a getModel()

This is a huge change that currently doesn't handle

  • Composition of child formulas.
  • Triggering disposable actions such fetch data

Having a unified mechanism for creating new states removes the need for Evaluation to hold a List<Update>. At that point Evaluation is just a wrapper around the Output (i.e. RenderModel) and can be removed as a concept.

It's unlikely we can get rid of the context concept.

It seems that the input parameter should only be used for initial state creation and is not directly needed by the model. Input is provided to the runtime via its onInput(), but it is either always Unit or seems to only be used for initial state creation.

Input is used frequently during formula composition.

@Laimiux
Copy link
Collaborator Author

Laimiux commented Sep 13, 2021

I definitely agree, but at some point there will be a learning curve where new/foreign concepts will need to be assimilated. Using Actions maybe is helpful since other frameworks use the name for a similar construct; people trying to understand the concept can find help outside the "Formula" world.

Currently, there is a plethora of frameworks and libraries for state management on Android. While it's very different from some, conceptually, Formula is most similar to

  • Compose -> evaluate is very similar to @Composable function with certain constraints.
  • Square/Workflow -> Mental model around evaluate is similar to the render function in Square Workflow.

It also borrows some thinking from the non-Android world such as Elm and React / React Hooks.

I am not so much interested in the name difference as I am with how moving away from Transitions and Evaluations removes concepts, potentially making using Formula simpler.

It's highly unlikely that we can move away from these mechanisms.

@emmano
Copy link

emmano commented Sep 13, 2021

Thanks a lot for the response. I definitely understand that there are a lot of things I do not fully understand about Formula and my suggestions may not apply.

The API of Stream wouldn't change, only the name. So, your implementation would only change by renaming FlowStream to FlowDisposableAction and that's it. The naming is inspired by the SideEffect and DisposableSideEffect within Compose.

I meant to say here that I like how the word Stream is a formula specific term whereas DisposableAction is a derived term (i.e. from Compose), and it helped me easily understand what it represented.

Square/Workflow -> Mental model around evaluate is similar to the render function in Square Workflow.

I originally named the method render() in the first versions of the comment but later changed it to model(). I thought I would create confusion. Go figure.

This is a huge change that currently doesn't handle

Composition of child formulas.
Triggering disposable actions such fetch data

I definitely avoided child formulas, did not spend enough time to understand them.

I though that a disposable action was:

fun (currentState: S) : suspend (S) -> S {
val sideEffect: (suspend (S) -> S) = {accumulatedState: S ->  /*call api;*/ accumulatedState.copy() }
return sideEffect
}

Again, I might not be understanding all the concepts fully.

I understand that a lot of my comments would require huge changes and I do not expect Formula to change to adopt them. The purpose of the comment was to trigger some conversation, and it did.

In any case, thanks a lot for creating Formula!

@Laimiux Laimiux closed this as completed Mar 24, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants