was Uni.replaceWith(Supplier) a mistake
I hate opening this issue, since it's something I had already considered from one point of view when I originally proposed adding the replace() method, and decided was OK, but that I'm now thinking might have been a mistake.
So originally I thought of it this way. When I looked at replaceWith(Supplier) I immediately thought that:
- it looked more like an overload of
map()than an overload ofreplaceWith(), and - it was a bit weird having
replaceWith(Supplier<T>)andreplaceWith(Uni<T>)but noreplaceWith(Supplier<Uni<T>>).
But:
- by its very name,
map()should be mapping something to something else, not supplying something, and - the "
replaceWith(Supplier<Uni<T>>)" method actually already exists and is calledchain().
And, it seemed clear that replaceWith(Supplier<Uni<T>>) was useful, so I included it in the proposal, and nobody complained.
However, after having now actually used this stuff in anger, and having tripped up on thinking of replaceWIth() as a sort of chain(), which it's definitely not, I'm wondering if we need to make it more clear that replaceWIth() is an operation that can't depend on side effects of previous stages.
Having replaceWith(Supplier<T>) there makes it a lot less clear that the other two overloads should never be used when their argument depends on the completion of a previous stage, and I feel like users are sure to trip up on that.
I'm not saying we should necessarily fix this, but I wanted to draw your attention to my mistake, so we can at least discuss it.
Sorry about this.
By the way, after considering this, together with #318, I think that probably the cleanest API would have been for Uni and UniOnItem to both offer all the following operations:
-
replaceWith(Y)andreplaceWith(Uni<Y>) -
transform(Function<X,Y>)andtransform(Supplier<Y>) -
chain(Function<X,Uni<Y>>)andchain(Supplier<Uni<Y>>) -
invoke(Consumer<X>)andinvoke(Runnable) -
call(Function<X,Uni<?>>)andcall(Supplier<Uni<?>>)
Where:
- the existing
map()andflatMap()onUniwould be aliases fortransform()andchain()respectively, and - one or perhaps two of the overloads of
UniOnItem.transformToUni()would be deprecated in favor ofchain(), since they are the exact same thing and it's confusing that they have different names on the two interfaces.
Again, I'm not saying we should do this, I'm just saying I now realize it would be the cleanest and most consistent organization of these methods, and it would fix both #319 and #318.
In my defense, the UniOnFailure.recoverWithXxxx() methods suffer from the same "mistake", which I was also aware of when I was writing the proposal.
Thanks @gavinking, this is a good set of thoughts. I see where you are going, what you want is a clean separation between:
- value-transforming operations, possibly accepting a
Supplier<T>when you want to discard the item, and - operation-chaining, possibly accepting a
Supplier<Uni<?>>when you want to discard the item.
I have tried to summarize the proposed changes as follows, please correct me if I'm wrong. Comments in nested bullets.
- Discard
Uni.replaceWith(Supplier<T>)inUni - Add
transformtoUniwithFunctionandSupplieroverloads - Keep
chain,callandinvokeas-is inUniandUniOnEvent - Add
transform,transformToUniandtransformToMultioverloads inUniOnEventwithSuppliersof values (notSupplier<Uni<?>>)- Since these variants would drop the item I'd suggest
replaceWith,replaceToMultiandreplaceToUni - Alternatively:
replaceWith,discardToMultianddiscardToUni
- Since these variants would drop the item I'd suggest
- Replicate
chainofUniinUniOnEvent- Aren't we creating a feature envy here?
- Instead of
uni.onItem().chain(...), don't we simply wantuni.chain(...)here?
- Replicate
replaceWith(TandUni) ofUniinUniOnEvent
BTW @gavinking could you please point me to some snippets where these API changes would help?
I'm not necessarily interested in all, just 1-2 relevant ones. Thanks!
what you want is a clean separation between:
what you want is a clean separation between:
- value-transforming operations, possibly accepting a
Supplier<T>when you want to discard the item, and- operation-chaining, possibly accepting a
Supplier<Uni<?>>when you want to discard the item.
Well, it's more of a question: do we need such a clean separation? The issue is that I think I've seen that it's potentially dangerous to have the replacing value be evaluated at stream-construction time. I mean, it's not conceptually wrong, it's just that if that semantic isn't completely clear to the user, then they could be in for nasty surprises. Now, whether the naming really helps make that clear (or more clear than it already is from the signature) I'm not sure.
I'm not sure that your summary exactly captures what I'm saying (which I hesitate to call a proposal, it's more of a straw man, and I'm not actually advocating that we do it) so let me clarify a bit. I apologize in advance. This is going to be long-winded.
First of all, I want to say that I think I've finally nailed down precisely why I was uncomfortable with the older revisions of the Mutiny API: I was continuously forced to write onItem() when I just didn't have any item: I was often working with Uni<Void> to chain side-effecty computations, not to transform streams of values. From that point of view all the onItem() calls, apart from being very verbose, were also kinda misleading about what was going on. With the current revision of the API, that discomfort is almost completely gone: i.e. it was really the separation of Uni and UniOnItem that I had a problem with, not any of the other UniXxxxx interfaces, which quite nicely model "special" states of the stream.
Anyway, that's a long way of saying I've grown comfortable with UniOnFailure and friends but I still have trouble convincing myself about what essential role UniOnItem fills. I mean, I'm very OK with it having more specialized methods related to processing the item that don't belong on the main Uni and that's fine. But then I'm finding it sorta difficult to mentally model the real distinction between these types, given the restriction that I don't want to force people to call onItem() all the time for basic things.
So in response to your comment about "feature envy" @jponge, well, I guess my response is that they should share a basic set of operations because AFAICT they're not very different in terms of what they model.
Now, sure, if you wanted to be completely disciplined about enforcing the paradigm of having a type to represent each distinct "stream state", then I guess you could say that we should eliminate versions of chain() and call() and invoke() which accept an item, leaving only those which ignore the item, and make you call onItem() if you want access to it. That would leave you with a very clean separation between these types. But the cost of that is a lot of verbosity and people winding up finding map() and flatMap() just too tempting, and .... well, we've been through all this already and I don't think we should revisit that. I think the current API is much more enjoyable to use than what was there earlier and I wouldn't want to go back.
So my suggestion is that we should consider regularizing the operations shared between Uni and UniOnItem, instead of having to remember that Uni.map() is the same thing as UniOnItem.transform() and Uni.chain() is UniOnItem.transformToUni() etc. (Again, I'm not strongly advocating this, I'm just putting it out there.)
To my list:
replaceWith(Y)andreplaceWith(Uni<Y>)
Here I'm removing (actually renaming) one overload of replaceWith() on Uni.
Also I'm copying the operation to UniOnItem. But perhaps that's a bad idea. Why would you be calling onItem() just to throw away the item?
transform(Function<X,Y>)andtransform(Supplier<Y>)
This is the same operation as Uni.map() and UniOnItem.transform(), plus the overload of replaceWith() that I've removed above.
-
chain(Function<X,Uni<Y>>)andchain(Supplier<Uni<Y>>)
These are just the existing operations on Uni, which I think are great, but I'm suggesting replicating them to UniOnItem.
-
invoke(Consumer<X>)andinvoke(Runnable) -
call(Function<X,Uni<?>>)andcall(Supplier<Uni<?>>)
These we already have everywhere.
So what this naming largely achieves is separation of:
- operations which return
Unis from operations which return values orvoid(this is needed b/c of Java overload resolution rules), and - operations with arguments that might be evaluated at stream-construction-time.
It doesn't separate operations which process the item from the previous stage from operations which ignore it, and I think that's completely OK because it's just so obvious from the signature. @jponge, I think that was something you wanted to do, but I'm not convinced it's necessary or helpful.
Anyway, these are just thoughts.
A further thought that only just occurred to me. Again, I which I had thought of this sooner is: perhaps none of the operations which ignore the item belong on UniWithItem! Why would you call onItem() to throw it away?