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

Add withLatest(from:) (Rx.withLatestFrom) #128

Merged
merged 5 commits into from
Dec 4, 2016

Conversation

inamiy
Copy link
Contributor

@inamiy inamiy commented Nov 28, 2016

This PR is RAC port of Rx.withLatestFrom (namely, sample(from:)) with samplee's error type constrained to NoError only.

Diagram: http://rxmarbles.com/#withLatestFrom

This was originally discussed in ReactiveCocoa/ReactiveCocoa#2082 (not accepted), but I found this operator very useful because it is easy to fetch current state (MutableProperty) via sourceSignal:

let state: MutableProperty<String> = ...

sourceSignal
    .sample(from: state.producer)
    .observeValues { sourceValue, currentState in ... }

Above code is much easier to read compared to:

sourceSignal
    .map { [unowned state] sourceValue in (sourceValue, state.value) }
    .observeValues { sourceValue, currentState in ... }

or:

state.producer
    .sample(with: sourceSignal)
    .observeValues { currentState, sourceValue in ... }
// receiver is flipped, and hard to understand which signal is the source of this whole chaining

Please note that sample(from:) is NOT actually a flipped version of sample(with:) since it discards samplee's any terminal events.
For .completed handling, this behavior is same as Rx.withLatestFrom (and is often easy to use).


The code is originally from: https://github.com/inamiy/ReactiveAutomaton/blob/28027a656b923008f34089c739e51cdbe13b65cb/Sources/ReactiveCocoa%2BSampleFrom.swift

@sharplet
Copy link
Contributor

Do you have an example with more meaningful names than sourceSignal and state? I think that would help to demonstrate the readability benefits.

@inamiy
Copy link
Contributor Author

inamiy commented Nov 28, 2016

@sharplet
Button with "2 enabled flags" can be a good example:

let enabled1 = MutableProperty<Bool>(true) 
let enabled2 = MutableProperty<Bool>(true) 
let buttonTap: Signal<(), NoError> = ...

buttonTap
    .sample(from: enabled1.producer)
    .sample(from: enabled2.producer)  // this signal is `Signal<((Void, Bool), Bool), NoError>`
    .filter { $0.1 && $1 }
    .observe { _ in print("hello") }  // prints only when both `enabled1` & `enabled2` are true

I think this is hard to implement if only existing sample(with:) is allowed to use.

@mdiep
Copy link
Contributor

mdiep commented Nov 28, 2016

That example seems like it'd be better with sample(on:).

let enabled1 = MutableProperty<Bool>(true) 
let enabled2 = MutableProperty<Bool>(true) 
let buttonTap: Signal<(), NoError> = ...

SignalProducer
    .combineLatest(enabled1.producer, enabled2.producer)
    .sample(on: buttonTap)
    .filter { $0 && $1 }
    .observe { _ in print("hello") }  // prints only when both `enabled1` & `enabled2` are true

@inamiy
Copy link
Contributor Author

inamiy commented Nov 28, 2016

@mdiep
Oh, your code works very nice 😅
How about asynchronously fetching 2 states, i.e. sample(with:).flatMap().sample(with:)?

/// Stores API response items.
let items = MutableProperty<[Item]>([])

items <~ buttonTap
    .sample(from: urlTextField.reactive.text)
    .map { APIRequest($1) }
    .flatMap(.merge) { sendRequest($0) }
    .sample(from: items.producer)
    .map { $1 + [$0.item] }

items.signal
    .observeValues { print("items updated: \($0)") }

@inamiy
Copy link
Contributor Author

inamiy commented Nov 28, 2016

Anyway, the dominant signal of above cases is buttonTap which is what I mean by sourceSignal (triggering signal). Placing it at topmost of the data flow (receiver) is more readable.

@mdiep
Copy link
Contributor

mdiep commented Nov 28, 2016

I definitely think the sample(from:) name is wrong. Similarities exist with the sample(on:) and sample(with:), but I think the defining characteristic of sample is that it's source.sample(_: trigger). The name totally trips me up.

withLatest seems like a much clearer name.

But your example still doesn't seem ideal. In particular, I wouldn't use items in its own definition.

/// Stores API response items.
let items = MutableProperty<[Item]>([])

items <~ urlTextField.reactive.text
    .sample(on: buttonTap)
    .map { APIRequest($1) }
    .flatMap(.merge) { sendRequest($0) }
    .reduce([]) { $1 + $0 }

items.signal
    .observeValues { print("items updated: \($0)") }

@sharplet
Copy link
Contributor

And with the recent improvements to Action, you can take a different approach without any mutable state:

let url = Property(initial: nil, then: urlTextField.reactive.text)

// Only enabled when input url is non-nil
let submit: Action<(), [Item], Error> = Action(input: url) { url in
  // url is the current value when the button is tapped
  let request = APIRequest(url)
  return sendRequest(request)
}

// bind button to submit action:
//   - button is disabled while executing
//   - button is disabled if input is invalid
button.reactive.pressed = CocoaAction(submit)

let items = Property(
  initial: [],
  then: submit.values.scan([]) { $1 + $0 }
)

items.signal
  .observeValues { print("items updated: \($0)") }

// you can also handle errors gracefully by observing `submit.errors`

@inamiy
Copy link
Contributor Author

inamiy commented Nov 29, 2016

@mdiep
Renaming to withLatest(_:) is good to me too.
It's a cousin of sample(with:), but not just a flipped version anymore.

By the way, your code has difficulty when items.value is updated elsewhere other than buttonTap.
reduce causes unnecessary extra state inside.

@sharplet
Same here, scan is unnecessary internal state.
(and Action is overkill for just achieving map { ($0, stateProperty.value) })

@sharplet
Copy link
Contributor

Placing it at topmost of the data flow (receiver) is more readable.

Now that you phrase it in terms of data flow, I see your point. This flows pretty well for me at the point of use:

items <~ buttonTap
  .withLatest(from: textField.reactive.continuousTextValues)
  .flatMap(.latest) { api.getItems($1) }
  // etc.

Copy link
Contributor

@mdiep mdiep left a comment

Choose a reason for hiding this comment

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

I'm 👍, but it needs some changes and also requires tests.

/// sampled (possibly multiple times) by `self`, then terminate
/// once `self` has terminated. **`samplee`'s terminated events
/// are ignored**.
public func sample<U>(from samplee: Signal<U, NoError>) -> Signal<(Value, U), Error> {
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 withLatest(_ samplee: Signal<U, NoError>) would be clearer.

Copy link
Member

@andersio andersio Nov 29, 2016

Choose a reason for hiding this comment

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

Alternative: combineLatest(from:).

Edit: IMO with is not directional, but from implies a direction.

Copy link
Contributor

Choose a reason for hiding this comment

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

This is different from combineLatest since it only sends a new value when one of the signals changes. So it should have a different name.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I prefer withLatest(_:) because "with" sounds like a small addition to the main stream, and receiver remains as the first class that also implies the rank direction.
Maybe withLatest(from:) can be a compromise, but I think we don't need this "from".

Copy link
Member

@andersio andersio Nov 30, 2016

Choose a reason for hiding this comment

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

withLatest(_:) should be fine.

withLatest(textField.reactive.continuousTextValues)

reads like "with latest text values" to me. Edit: That's said the from label would give a stronger implication of "with the latest (one) from".

/// sampled (possibly multiple times) by `self`, then terminate
/// once `self` has terminated. **`samplee`'s terminated events
/// are ignored**.
public func sample<U>(from samplee: SignalProducer<U, NoError>) -> Signal<(Value, U), Error> {
Copy link
Contributor

Choose a reason for hiding this comment

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

This should be removed. We don't expose mixed operators like this. If someone wants to use a Signal with a SignalProducer, they need to convert the Signal to a SignalProducer.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We already have mixed operators like:

  • combineLatest<U>(with other: SignalProducer<U, Error>)
  • combineLatest<U>(with other: Signal<U, Error>)
  • flatMap<U>(Value -> SignalProducer<U, Error>)
  • flatMap<U>(Value -> Signal<U, Error>)

I think there are also needs for withLatest(signal) case too.

Copy link
Member

@andersio andersio Nov 29, 2016

Choose a reason for hiding this comment

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

We do not have hot + cold -> hot except for flattening operators though. Only on SignalProducer we have overloads that take Signals directly.

Copy link
Contributor Author

@inamiy inamiy Nov 30, 2016

Choose a reason for hiding this comment

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

Is it illegal to have different type for the argument?
I think hot -> T -> hot or cold -> T -> cold keeps the same structure, and it's good enough.
(This is probably the "endofunctor" thing in category theory after the partial application of T, though arguments are often flipped in Swift.)

Copy link
Member

Choose a reason for hiding this comment

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

It is not illegal. Just that hot -> cold -> hot doesn't seem something very commonly used, except for flattening operators (Signal<SignalProducer, E>).

/// sampled (possibly multiple times) by `self`, then terminate
/// once `self` has terminated. **`samplee`'s terminated events
/// are ignored**.
public func sample<U>(from samplee: Signal<U, NoError>) -> SignalProducer<(Value, U), Error> {
Copy link
Contributor

Choose a reason for hiding this comment

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

This should also be removed.

Copy link
Member

@andersio andersio Nov 29, 2016

Choose a reason for hiding this comment

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

This is for producer though. We have a couple of few cold -> hot -> cold producer operators.

}

return disposable
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Can this be implemented with sample(with:)?

return samplee.sample(with: self).map { ($1, $0) }

Copy link
Contributor Author

Choose a reason for hiding this comment

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

To achieve the simplest map { ($0, stateProperty.value) } alternative as possible, I think returning signal should not get interfered with any of samplee's terminal events.

sample(with:), on the other hand, will wait for both sampler and samplee's .completed to finally emit its .completed, so this PR is not just a flipped sample(with:).

For comparison, in RxSwift, samplee's .completed is ignored but .failed is forwarded.

(As a side note, I have restricted samplee's error type as NoError only for now, allowing a future extension to also support samplee with non-NoError types when decision (ignore or forward error) is settled.)

@inamiy inamiy changed the title Add sample(from:) (Rx.withLatestFrom) Add withLatest (Rx.withLatestFrom) Nov 30, 2016
@andersio
Copy link
Member

andersio commented Nov 30, 2016

(Nit)

// Button taps with (the) latest continuous text values.
buttonTaps.withLatest(textField.reactive.continuousTextValues)

// Button taps with (the) latest (one) from (the) continuous text values
buttonTaps.withLatest(from: textField.reactive.continuousTextValues)

@sharplet
Copy link
Contributor

I agree, and think withLatest(from:) clarifies what "latest" refers to: the latest value from the signal.

@inamiy
Copy link
Contributor Author

inamiy commented Dec 1, 2016

OK, it seems 2:2 now, but I can go for withLatest(from:) too.
If no other votes, I will rename it again later.
(BTW, is travis dead? Test is not working...)

@andersio
Copy link
Member

andersio commented Dec 1, 2016

Travis CI has had a huge backlog in Mac jobs since a day or two ago.

@mdiep
Copy link
Contributor

mdiep commented Dec 1, 2016

I'm 👍 with withLatest(from:).

@inamiy inamiy changed the title Add withLatest (Rx.withLatestFrom) Add withLatest(from:) (Rx.withLatestFrom) Dec 1, 2016
@inamiy
Copy link
Contributor Author

inamiy commented Dec 1, 2016

Great! Renaming is done in 95bd754, and previous test has finally passed.

/// nothing happens.
///
/// - parameters:
/// - samplee: A signal that its latest value is sampled by `self`.
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 this would read better as "A signal whose latest value..."

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 9580123.

Copy link
Contributor

@sharplet sharplet left a comment

Choose a reason for hiding this comment

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

LGTM

@mdiep mdiep merged commit 9030845 into ReactiveCocoa:master Dec 4, 2016
@inamiy inamiy deleted the feature/sample-from branch December 7, 2016 13:19
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

Successfully merging this pull request may close these issues.

None yet

4 participants