-
Notifications
You must be signed in to change notification settings - Fork 433
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
Adding lazyMap
#240
Adding lazyMap
#240
Conversation
liscio
commented
Jan 26, 2017
•
edited
Loading
edited
Includes init(lazyValue:) for SignalProducer, lens for PropertyProtocol, and associated tests to ensure everything works as advertised.
expect(getterEvaluated).to(beFalse()) | ||
expect(characters).to(beEmpty()) | ||
|
||
testScheduler.run() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm planning to respond properly to this, but in the meantime I spotted a theoretical false positive in this test. What do you think about this change? 840bf6a
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The test I added merely intended to demonstrate the following:
- Before the signal is started, the getter has not been evaluated
- When the signal is started, but the getter's queue hasn't scheduled the getter for execution, the getter has still not been evaluated
- Once the getter's queue is scheduled, the setter will have run, and the value will have been delivered
I'm probably missing the false positive here, but I think the synchronous execution of the scheduler is a benefit for the specific (simple!) test case I had in mind. Namely, "will the getter get scheduled at all, rather than executing immediately when the SignalProducer
is started.."
Now, what you're proposing could help write another test case I wasn't feeling well-equipped to write, which is, "the getter should execute on the specific scheduler/thread that is specified".
Let me know what I might have misinterpreted.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"the getter should execute on the specific scheduler/thread that is specified"
Yeah, that's exactly what I was trying to verify. I agree that in this case having a simpler test is better, but I admit I got caught up in trying to solve the problem of verifying that execution occurs on the expected queue/scheduler. 😛
I believe that code does what it says on the tin (and is thread-safe), so if you think it's valuable to include it feel free to use it as inspiration. Some of those details deserve to be factored out into some kind of test helper anyway, so that the test itself can be higher level.
We might as well go whole-hog if we're going to offer the concept of a lens...
I have also just now added the writing side as well in the PR, because it was far too tempting. Using lenses for reading and writing isn't appropriate for all situations, but it's certainly useful when transacting using value types, and you wish to offer a way to set individual properties on a struct. For example: let exportedImageSize: MutableProperty<CGSize>(.zero)
let width: BindingTarget<CGFloat> = exportedImageSize.lens { return CGSize(width: $1, height: $0.height) }
let height: BindingTarget<CGFloat> = exportedImageSize.lens { return CGSize(width: $0.width, height: $1) }
width <~ widthTextField.reactive.stringValues
height <~ heightTextField.reactive.stringValues Personally, this would un-complicate a few spots in my own code where I achieve something similar to the above by taking two discrete width/height properties that I'm forced to combine into a size elsewhere. Now the |
Sources/Property.swift
Outdated
@@ -678,3 +759,73 @@ public final class MutableProperty<Value>: MutablePropertyProtocol { | |||
observer.sendCompleted() | |||
} | |||
} | |||
|
|||
extension MutableProperty { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Perhaps this can wait until ComposableMutablePropertyProtocol
in #182 lands. hmm?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sources/Property.swift
Outdated
/// - returns: A signal producer that returns values obtained using `getter` | ||
/// each time this property's value changes. | ||
public func lens<U>(getter: @escaping (Value) -> U) -> SignalProducer<U, NoError> { | ||
return producer.flatMap(.latest) { model in SignalProducer(lazyValue: { getter(model) }) } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How does this behave differently than:
return producer.flatMap(.latest) { model in SignalProducer(value: getter(model)) }
?
I don't see how lazyValue:
makes any difference here. The returned producer will always be started immediately, so these should be equivalent.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@mdiep In the non-scheduled start variation, I don't think there's a difference as you've found.
Sources/SignalProducer.swift
Outdated
/// .start(on: backgroundScheduler) | ||
/// } | ||
/// ``` | ||
public init(lazyValue: @escaping () -> Value) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This could probably be:
public init(_ block: @escaping () -> Value)
You can think of it as a value preserving type conversion.
I'm not sure whether that'd cause problems in practice. I think it's unlikely to cause conflicts.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It would certainly cut down on verbosity. I'll see how it goes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The only potential conflict would be if the init(value:)
variant went away, because then there'd be confusion between () -> Value
and Value
itself, forcing closures to always get evaluated (if the compiler wasn't going to balk at the similarity.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we'd ever remove the label from init(value: )
. So I kinda like the idea of a label-less initializer. (We could also add a variant that uses a completion handler, which would be handy.)
Sources/Property.swift
Outdated
return SignalProducer(lazyValue: { getter(model) }) | ||
.start(on: scheduler) | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This would should be the same as this?
return producer
.observe(on: scheduler)
.flatMap(.latest) { model in SignalProducer(value: getter(model) }
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@mdiep I don't think that works in quite the same way, but does it make much of a difference?
According to the docs, what you've pasted above would deliver all the events from the inner signal on the specified scheduler. The start(on:)
variant would only evaluate the getter on the scheduler. Because there's only one event getting sent, I'm not sure it really matters all that much.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@mdiep I don't think that works in quite the same way, but it passes the tests both ways.
According to the docs, what you've pasted above would deliver all the events from the inner signal on the specified scheduler. The start(on:)
variant would only evaluate the getter on the scheduler. Because there's only one event getting sent, I'm not sure it really matters all that much. Thoughts?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The events are delivered to the flatMap
on scheduler
.
They do behave differently though… because cancellation happens on a different scheduler. Your way is probably better.
@mdiep I've got them set up locally but things got a little strange when I was trying to build out some tests and things weren't working out as expected. Hopefully it won't be much longer 'til I get my confusion worked out. |
No worries! I just wanted to leave a note (1) so that I remembered the status of this PR and (2) to make sure we were one the same page. |
Renamed the operations based on feedback, and eliminated the non-scheduled variations of lazyMap as they offered none of the benefits that lazy evaluation is offering.
lazyMap
and bindingTarget
I added to the anticipated questions above, and made a number of changes to the code in response to feedback. I also added some additional tests, and cleaned up the ones that were there. |
// propagate only the first and last values | ||
targetScheduler.advance() | ||
expect(getterCounter) == 2 | ||
expect(destination) == ["🎃", "👻"] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@andersio this test result that was a little unexpected. I had hoped that advancing the scheduler would only have sent the final "ghost" value, but it's clear now that the binding target's queue has to batch up its incoming setter
invocations.
A .flatMap(.latest)
equivalent on the setter side (i.e. switching to the newest scheduled setter
call) might be worth exploring since it only ever makes sense to accept the last-set value. Effectively, each value
event arriving on the bound signal would have to wipe out all the scheduled setter blocks on its queue and replace with the latest one.
(Yes, I know—easier said than done.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Well, it shouldn't be hard. A SerialDisposable
would do the job. In reality though, it probably wouldn't ever do you a favour, unless you really have a high traffic. But then you might want to throttle your signal or look for back pressure operators, I guess?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Typically I've done this by doing the equivalent of throttle(0, on: UIScheduler())
.
Sources/Property.swift
Outdated
/// ``` | ||
/// let personProperty: MutableProperty<Person> | ||
/// let nameProducer: SignalProducer<String, NoError> | ||
/// nameProducer = personProperty.lazyLens(on: scheduler) { $0.name } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
s/lazyLens/lazyMap/
Sources/Property.swift
Outdated
extension PropertyProtocol { | ||
/// Returns a `SignalProducer` that sends a new value each time this | ||
/// property's value changes, using the supplied `transform` to supply the | ||
/// value being sent. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This reads like documentation for map
, so it could use some cleanup.
Of note:
-
that sends a new value each time this property's value changes
isn't true. The point of the operator is that it doesn't guarantee a 1-to-1 mapping of input to output. -
It doesn't communicate what makes this lazy.
It looks like the other instances of lazyMap
have better documentation, so you may just need to copy that here.
Sources/Property.swift
Outdated
/// ``` | ||
/// let personProperty = MutableProperty(Person(firstName: "Steve", lastName: "McQueen")) | ||
/// let firstNameBinding = personProperty.bindingTarget { | ||
/// return Person(firstName: $1, lastName: $0.lastName) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you name these arguments? I think for documentation purposes that'll be a lot clearer.
Sources/Property.swift
Outdated
/// - setter: A closure that takes a value of type `U` to produce a new | ||
/// value for this property | ||
/// | ||
public func bindingTarget<U>(setter: @escaping (Value, U) -> Value) -> BindingTarget<U> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is pretty interesting, but it seems like we should consider this in a separate PR. I don't see any relation or dependency between the two? (Other than these were both originally part of your lens concept.)
Sources/Signal.swift
Outdated
/// will not force the transformation to get invoked immediately | ||
/// when a new value is sent. Furthermore, subsequent `.value` | ||
/// events will not cause `transform` to repeatedly evaluate when | ||
/// it has not been scheduled for execution by `scheduler`. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Indentation here is a bit wonky
Sources/Signal.swift
Outdated
/// when a new value is sent. Furthermore, subsequent `.value` | ||
/// events will not cause `transform` to repeatedly evaluate when | ||
/// it has not been scheduled for execution by `scheduler`. | ||
/// |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe add an important block that points out that there's not a 1-to-1 mapping between inputs and outputs?
Sources/Signal.swift
Outdated
/// - returns: A signal that sends values obtained using `transform` as this | ||
/// signal sends values. | ||
public func lazyMap<U>(on scheduler: SchedulerProtocol, transform: @escaping (Value) -> U) -> Signal<U, Error> { | ||
return flatMap(.latest) { model in SignalProducer({ transform(model) }).start(on: scheduler) } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe rename model
to value
?
This line is a little dense, so I'd also break it up onto multiple lines.
I like this direction a lot. ✨ Are you happy with it @liscio? |
@mdiep Yeah, I think that things are shaping up fairly well with this. I'll have to modify some of my own usage patterns to figure out how to mix and match these ideas nicely, because a "do-everything" I really did want to wrap up the Why? Because now we can do some really interesting things that get us closer to a clean pattern exposing our model object's properties in the viewModel. For example: extension Reactive where Base: MutableProperty<Album?> {
var titleValues: SignalProducer<String, NoError> {
return base.producer.map { $0?.title ?? "" }
}
var albumArtistValues: SignalProducer<String, NoError> {
return base.producer.map { $0?.albumArtist ?? "" }
}
} So now the need to repeat ourselves when "lensing" our model objects goes away in the view model. The "lens" can be defined once on a specialized extension of I guess this is a really long way of saying that I'm OK with dropping the |
@mdiep I addressed your comments, and in addition to pulling |
"Show Invisibles" my arse...
The tests don't compile on Linux. So this will need a little love. |
Oops. I fixed that Linux build issue earlier, but pushed the change over to the bindingTarget branch. Should be all good once this build completes. |
/// - parameters: | ||
/// - block: A block that supplies a value to be sent by the `Signal` in | ||
/// a `value` event. | ||
public init(_ block: @escaping () -> Value) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Any reason not make this an SignalProducer(value: )
overload that uses @autoclosure
instead?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How would that differentiate from the existing non-@autoclosure
version?
You wouldn't want to always have a closure—that creates the same problem, just in reverse.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@NachoSoto In a nutshell, @autoclosure
implies @noescape
, and hence we lose out on the ability to defer execution.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, you can have an @autoclosure @escaping
parameter. I guess it would have to have a different parameter name, you're right, but I think I would make the intention more clear.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@NachoSoto Interesting. I wonder, though, whether we'd still have an issue where the compiler wants to forcefully optimize a passed-in value as being evaluated immediately.
For example, consider the case of SignalProducer(value: callAFunction())
versus the SignalProducer({ callAFunction() })
construct. Would the former be turned into an immediately evaluated variant if the braces are omitted, versus being evaluated lazily?
That seems a little subtle/delicate from a usage standpoint… But yeah—having the lazyValue:
as I originally built it might be a good tiebreaker?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the unnamed parameter best fits with the other unnamed lossless-type-converting inits that we already have.
I'd also really like to add this variant:
public init<Value, Error>(_ block: @escaping ((Result<Value, Error>) -> Void) -> Void)