ReactiveX / RxSwift

Reactive Programming in Swift

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

`asSingle()` to automatically run `take(1)` beforehand

inamiy opened this issue · comments

Current observable.asSingle() does not work as expected when observable is an infinite stream since class AsSingle requires upstream's termination to actually send the value to the downstream.

To workaround this, users always require to write observable.take(1).asSingle(), but it will be handy if asSingle() can automatically handle take(1).

What do you think?

Hey, thanks for the suggestion!
This came up numerous times - it is on the developer themselves to explicitly make sure the source only has one element before they do asSingle().

When considering adding it implicitly we felt that it is too much of a "hidden contract" and we are potentially taking away elements from a source stream silently and dropping them without notifying the consumer, which is not something we want.

Hi @freak4pc, thanks for quick answer :)

To be honest, I would rather think the current class AsSingle impl as more hidden contract, since it persists one element before actual completion occurs.

For example, consider creating class AsDouble which sends exact 2 elements that behaves like an async 2-element sequence.
The straightforward impl of this class would be:

  1. Forward onNext accordingly (or fail if too many upstream element count)
  2. Forward onComplete when reached 2 counts (or fail if too less upstream element count)

And I think this logic will also be naturally applied to 1-element AsSingle.

Also, for this behavioral breaking changes, my guess is that it is relatively safe without introducing any regressions.
But I agree that this will break the existing contract, so if that matters, I'm also fine with keeping existing behavior as is.

Another case study is, if we think about a "finite" observable that sends "1............Done!", then observable.asSingle() will inherit this completion timing different from 1st value which I personally find a bit weird.

Thanks for the interesting feedback :)
I think eventually it's a matter of preference and taste, since Traits don't exist outside of RxSwift we can make our own decisions outside of Reactive Extensions.

I prefer leaving the AsDouble case aside because it's not directly relevant to our case.

Single and Maybe don't make any guarantees about the completion timing, it makes a guarantee about delivering a Single value, but it does mean the developer has to do the extra work here.

In the case study you mentioned the problematic case is "1 ... (2... 3... 4..)... Done". If you take(1) implicitly you are dropping 2-3-4 and the developer might not know. What if they want the last element? What if they want one in the middle? We prefer leaving the explicit decision of this to the developer who know their own use case, and not make any assumptions inside the framework.

I think how to pick 1st or non-1st element from multi-element observable for AsSingle is not the core issue of this topic.

Considering my above case study of let oneObservable = "1............Done":

  • Current behavior: oneObservable.asSingle() = "..............1 Done"
  • Suggested behavior: oneObservable.asSingle() = "1 Done.............."

If we apply your logic of "explicitly make sure the source only has one element", oneObservable already satisfies this requirement, so end users normally expect asSingle() to (mostly) behave the same as previous oneObservable.
But this example will unfortunately require .take(1) even though it's already a single element observable.

That said, we could perhaps rewrite your slogan as "explicitly make sure the source only has one element and completes immediately, just like how Single works".
I personally think this is more strict rule than many devs first anticipated, so I raised a suggestion here.

@inamiy If you want that sort of behavior, then you could use .first(). It will complete the observable after the first element is emitted.

You see there are two issues around converting an Observable to a Single and you are only focusing on one. The other is "What if the Observable completes without emitting a value?"

Hi @danielt1263 , zero-element observable to Single will be treated as runtime error anyway, which is also a part of #2595 (comment) discussion (case study doesn't have to be 2-elements only, but arbitrary N-elements including zero)

The point is asSingle() will emit an error if the source doesn't conform to the Single contract, while first() does not, instead it emits particular defaults. If you want error events for non-conformance, use asSingle() if you don't (and you clearly don't) then use first(). Another option would be to use toArray() which also converts an Observable to a Single without error (it uses a different set of defaults than first() does.)

The library gives you three options:

  1. Emit errors for non-conformance. asSingle()
  2. Emit completion after first event and nil if completes without an event. first()
  3. Emit a single event and complete no matter how many events the source provides. asArray()

Given your case study above, you clearly want to use first().

Another option would be to write your own custom operator. This is quite easy to do and I'd be happy to help you with it. All you need to do is specify how it should behave in all the scenarios. -a--|, --|, -a-b-|.

@danielt1263
In my project, I want to convert from Observable to Swift Concurrency's async / await , so conversion to Single will be necessary.
I know it's an unsafe operation, so runtime error will be automatically emitted by asSingle() if I do anything wrong (which is acceptable), and I don't expect to have some type-safety in this discussion.
The main topic here is: What is the best impl for asSingle().

P.S.
The alternative approach of unsafely converting from Observable to async func is try await observable.values.first(where: { _ in true}) which bypasses Single conversion.
While this can fulfill my needs, my goal here is to have asSingle() as simple API as Swift Concurrency's first.

Yes, let's go back to the original discussion. I don't think anyone would have an issue making their own operator to do .take(1).asSingle(), but I think @inamiy argues the default isn't the right implementation.

  • Current behavior: oneObservable.asSingle() = "..............1 Done"
  • Suggested behavior: oneObservable.asSingle() = "1 Done.............."

As I mentioned in my reply - I disagree with the way you see this.

For example: let oneObservable = "1.2.3.4.5.6.7.Done"

with your implementation I would get "1.Done", but maybe that's not what I want.
The way it is today I can do pick which of the elements I'd like to take and only then terminate, for example the first, last, any of them in the middle, and only then terminate and make a decision.

This puts the control in the developer's hands.

P.S.
The alternative approach of unsafely converting from Observable to async func is try await observable.values.first(where: { _ in true}) which bypasses Single conversion.
While this can fulfill my needs, my goal here is to have asSingle() as simple API as Swift Concurrency's first.

About this, what I usually do is

let thing = await observable.first().asSingle().value

Or drop the take(1) if I'm 100% sure it will terminate after 1 value (like a network request).

@inamiy

The main topic here is: What is the best impl for asSingle().

And you seem to want asSingle() to do exactly what first() does. Why would we want two operators that both do the same thing?
If I'm wrong, and you want different behavior than what is provided by asSingle() or first() or toArray() then define the behavior you want and we can workshop it.

@danielt1263

And you seem to want asSingle() to do exactly what first() does. Why would we want two operators that both do the same thing?

I just realized that first() returns Single<T?> which is a safe operation compared to asSingle().
So, your suggestion was actually a good one (thanks!) that we can use try await observable.first().value to simplify the call for Swift Concurrency conversion.
That also means, we might also want to consider about fully deleting unsafe asSingle() to enforce developers to always use more type-safe first().

But I assume such breaking change is not possible soon, and we will need to let both asSingle() and first() co-exist for a while.
In that case, I would still say it's good to have asSingle() to behave similar to first() so that many devs don't need to shooting their feet without knowing about first(), take(1), etc, e.g.:

@freak4pc
As I said earlier, multi-element observable makes discussion harder (and I believe it's orthogonal topic), so it's probably good to first think about 1-element case in #2595 (comment) and choose whether current or suggested behavior is more preferred (I think there is no other good options than these two).

In addition, current class AsSingle behavior has both notions of "first element" and "last completion (to finally send first element)" which is a mixture of "first and last" that is adding the complexity.
Although this complexity doesn't really matter if developer follows the rewritten slogan in #2595 (comment) correctly, I think many devs will still keep shooting their feet without ever knowing about its unsafety, so I believe my suggestion will be a safeguard.

And as I mentioned in @danielt1263 's reply above, fully deleting unsafe asSingle() to replace with first() seem like the best goal, which follows the exact same API as Swift Concurrency's AsyncSequence.first that is converted to async func.

But I assume such breaking change is not possible soon, and we will need to let both asSingle() and first() co-exist for a while.
In that case, I would still say it's good to have asSingle() to behave similar to first() so that many devs don't need to shooting their feet without knowing about first(), take(1), etc, e.g.:

I think the only thing we could do is add a clause in asSingle()'s documentation and refer people to use first() if they want that kind of behavior. I wouldn't want to remove asSingle(). It's entirely valid for multi-value streams, even if it's not the "most common" case, it is common enough that people use this (I know we have at least 5-10 use cases like this).

It's entirely valid for multi-value streams, even if it's not the "most common" case, it is common enough that people use this (I know we have at least 5-10 use cases like this).

Interesting. So, my suggested change will eventually break their code, so I think I need to give up on proposal :(
Meanwhile, I can still enforce using Observable.first(): Single<T?> instead, so I will stick with this better API.

Thanks @freak4pc , @danielt1263 for discussion!

Agreed. There are a number of ways to handle conversion from Observable to Single and we have operators to take care of the three most common. We haven't had any demand for the ones we don't cover.

It seems the real problem here is a documentation issue. @inamiy whatever documentation that caused you to use asSingle instead of first might need some adjustment. Was that something in this library? If so, maybe you could submit a PR that updates the documentation.