-
Notifications
You must be signed in to change notification settings - Fork 42
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
Handler/Sink refactor, added Pipe trait #91
Conversation
@@ -22,7 +19,7 @@ trait Handlers { | |||
def createBoolHandler(defaultValues: Boolean*) = createHandler[Boolean](defaultValues: _*) | |||
def createNumberHandler(defaultValues: Double*) = createHandler[Double](defaultValues: _*) | |||
|
|||
def createHandler[T](defaultValues: T*): IO[Handler[T]] = Sink.createHandler[T](defaultValues: _*) | |||
def createHandler[T](defaultValues: T*): IO[Handler[T]] = Handler.create[T](defaultValues: _*) |
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 know, this was there before. but why have two methods for creating a handler? This is Handler.create
and createHandler
.
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 added Handler.create
because it think the place to create a handler should be in Handler
(somewhat analogous to Sink.create
, Observable.create
...). I kept Handlers.createHandler
for backwards compatibility, maybe it should be deprecated?
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.
Good idea. Deprecating the old one sounds like a good idea. What about the createNumberHandler
, createBoolHandler
, ...? Not sure whether, they really add value or whether something like Handler.create[Boolean]
is good enough.
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.
Yes, I think something like Handler.create[Boolean]
is good enough. The ones that add some value is the ones taking event types, such as createInputHandler
, createMouseHandler
as one doesn't need to import the respective events.
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 them to Handler
then? Makes them more consistent.
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 prefer Handler
completely generic. Plus Handler
is in the outwatch
package (not in outwatch.dom
package), so it shouldn't "know" about dom types.
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.
You are right!
Why did you add |
We don't need both, I kept Also, not sure if |
type >-->[-I, +O] = Pipe[I, O] | ||
val >--> = Pipe | ||
|
||
type Handler[A] = Pipe[A, A] |
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.
If we do this, we can call mapSink
on a Handler and get back a Pipe[A, B]
not sure if that makes sense.
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.
Yes, I see what you mean. How do you feel about changing Handler
to have two type parameters? I shouldn't break almost any client code, Handler type is not typically used much in client code.
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.
You're right, I think the solution below is probably the best :)
I prefer the whole thing to be as simple as possible. Therefore I suggest something like: trait Sink[-I] { private def observer:Observer[I] }
type Handler[-I,+O] = Sink[I] with Observable[O] In my opinion even wrapping So maybe even just: type Handler[-I,+O] = Observer[I] with Observable[O] |
@fdietze Having observer private in Sink guarantees correct handling of subscriptions in Sink redirects, which eliminates a whole class of possible resource leaks. |
With guarantees you are talking only about the fact that the user cannot use the internal observer, right? I'm interested in an example for such a resource leak. |
Yes, that's what I meant. Redirect could obviously still be added using an implicit on |
I took some time to think about the whole One possible solution: scala> trait A { def f:Int; def g:Int} // this could be the Observer
defined trait A
// Handler, which implements the Observer-trait, but deprecates the usage of the unsafe methods
scala> trait B extends A { def f = 5; @deprecated("Using g is unsafe", since="forever") def g = 17 }
defined trait B
scala> val b = new B {}
b: B = $anon$1@1e606f64
scala> b.f
res1: Int = 5
scala> b.g
<console>:13: warning: method g in trait B is deprecated (since forever): Using g is unsafe
b.g
^
res2: Int = 17 This is far from perfect. One could still do I'm wondering if Monix has preventions against resource leaks. Do you know? @mariusmuja |
Independently of the Proposal: type Pipe[-I,+O] = Sink[I] with Observable[O]
type Handler[T] = Pipe[T,T]
(I'm not exactly sure about the All the other methods are added via implicits, or we use a trait instead (which one is better?): trait Pipe[-I,+O] extends Sink[I] with Observable[O] |
I agree with above except for a couple of points: Handler can only implement |
I pushed some refactorings as discussed above. It renames type Handler[-I, +O] = Observable[O] with Sink[I] with HandlerOps[I, O] Also, There's a test that randomly fails (DomEventSpec.scala:41), I cannot reproduce the failure on my machine. The test assertion seems to happen before the DOM update takes place. Jsdom doesn't implement mutation observers, any idea how to properly fix it? |
@fdietze My understanding is that the Observable in Monix has memory safe operations, but one can still have memory leaks by subscribing directly with an observer and not managing the subscriptions properly. Sink hides the observer to discourage this, but as you mentioned it's still possible to do so using |
@@ -12,7 +12,7 @@ import scala.language.implicitConversions | |||
|
|||
final case class Store[State, Action](initialState: State, | |||
reducer: (State, Action) => (State, Option[IO[Action]]), | |||
handler: Observable[Action] with Sink[Action]) { | |||
handler: Handler[Action, Action]) { |
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.
Why does Store contain a Handler
instead of being a Handler?
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.
A Handler
is a Sink
(Observable
with Sink
) and Sink
is a sealed trait, so nothing can extend Handler
. It's one of the reasons I initially had the Handler
wrap the Observable
and Sink
.
def createHandler[T](defaultValues: T*): IO[Handler[T, T]] = Handler.create[T](defaultValues: _*) | ||
|
||
|
||
implicit class HandlerCreateHelpers(handler: Handler.type) { |
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.
Why is this not directly in the Handler
object in Handler.scala
?
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 preferred to keep Handler completely generic. Also, Handler is in the outwatch package (not in outwatch.dom package), so it shouldn't "know" about dom types.
@@ -10,6 +10,15 @@ package object dom extends Attributes with Tags with Handlers { | |||
type VNode = IO[VNode_] | |||
type VDomModifier = IO[VDomModifier_] | |||
|
|||
type Observable[+A] = rxscalajs.Observable[A] |
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 really like the direction of outwatch becoming frp-backend agnostic. 👍
|
||
import rxscalajs.Observable | ||
|
||
package object advanced { |
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 understand why we need an advanced package, or how these methods could be unsafe. I find these methods especially useful to write correct, bug-free code.
Do you have an example of an unsafe usage?
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's safe as long the original Observable
is transformed, but a different Observable
could be returned.
For example, let's say one would like to emit two elements every time a button is clicked and writes this (incorrect) code:
Handler.create[Int] { handler =>
val many = handler.transformSink[MouseEvent](_ => Observable.of(1,2))
button(click --> many)
}
However, this doesn't work as expected because the handler's observable is closed immediately on creation and the click
handler doesn't do anything.
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 is that unsafe? The whole observable is replaced by another one. We cannot protect the user to use the wrong operators. Am I missing something?
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.
Safe was not the correct term to use. What I was trying to say is that in the above example the button click doesn't do anything because the observable is completed after emitting (1, 2) , which is probably not what is intended, instead it should have been:
Handler.create[Int] { handler =>
val many = handler.transformSink[MouseEvent](_.concatMap(Observable.of(1,2)))
button(click --> many)
}
Often the transform...
methods will require the use of higher order observables. There's nothing wrong with that, but they are easier to use incorrectly, which is why I placed them under the outwatch.advanced._
import. It also aligns with one of the Outwatch goal: "Removing or restricting the need for Higher Order Observables".
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.
But aren't we already importing higher-order Observables from https://lukajcb.github.io/rxscala-js/latest/api/rxscalajs/Observable.html?
@LukaJCB ?
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've actually changed my mind. We should place the transform...
methods in the HandlerOps
trait. They are no more "advanced" than Sink.redirect
, which are available to the user.
Interesting API difference between Monix and rxjs: |
While looking at the code, it seems like this could be done in a simpler way. I'll give it a try soon. |
@mariusmuja You had good answers to my questions, so I'm fine with the code now. Last point: @LukaJCB mentioned that having Handler with two type parameters gives problems with partial unification: https://gist.github.com/djspiewak/7a81a395c461fd3a09a6941d4cd040f2 @LukaJCB, what do you suggest? Would it help to go back to only one type-parameter for type Pipe[-I,+O] = Sink[I] with Observable[O]
type Handler[T] = Pipe[T,T] vs type Handler[-I,+O] = Sink[I] with Observable[O] |
I think most people will just use |
src/main/scala/outwatch/Pipe.scala
Outdated
|
||
trait PipeOps[-I, +O] { self : Pipe[I, O] => | ||
|
||
def mapSink[I2](f: I2 => I): Pipe[I2, O] = Pipe(redirectMap(f), self) |
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.
Sadly, with all these methods we cannot overload and return a Handler
if I
and O
are the same type...
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.
@fdietze What do you think about having this:
implicit class HandlerOps[T](self: Pipe[T, T]) {
def imap[S](read: T => S)(write: S => T): Handler[S] = self.mapPipe(write)(read)
def lens[S](seed: T)(read: T => S)(write: (T, S) => T): Handler[S] = {
val redirected = self.redirect[S](_.withLatestFrom(self.startWith(seed)).map { case (a,b) => write(b,a) })
Handler(redirected, self.map(read))
}
def transformHandler[S](read: Observable[T] => Observable[S])(write: Observable[S] => Observable[T]): Handler[S] =
self.transformPipe(write)(read)
}
and leaving type Handler[T] = Pipe[T, T]
. This way a Handler
is a Pipe
, transformHandlerSink
, and transformHandlerSource
will not be needed any more (will be the same as transformSink
and transformSource
).
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.
Good idea, I'll give it a try.
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.
Done. I think it's much cleaner now.
I'll merge when there's one more review. |
|
||
implicit class HandlerOps[T](val self: Handler[T]) extends AnyVal { | ||
|
||
def imap[S](read: T => S)(write: S => T): Handler[S] = self.mapPipe(write)(read) |
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.
We should have the sink/source transformation order consistent between Pipe
and Handler
. Either transform the sink first everywhere, or the source first everywhere. Intuitively for me was to have the sink first, because something sent into the sink gets emitted by the observable afterwards.
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.
Good catch! Yes this should be consistent. My motivation for this order was that you can easily rewrite a handler.map(f)
to handler.imap(f)(g)
by just appending the second function.
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.
Interestingly
mapPipe
is consistent with Profunctor.dimap
:
https://github.com/typelevel/cats/blob/0997f43ab68aac1ea83ad0dbc0819d2ad3e5ef22/core/src/main/scala/cats/arrow/Profunctor.scala#L29
And imap
with Invariant.imap
:
https://github.com/typelevel/cats/blob/8d7b39407b282781c06ca580479b6cc125bcb312/core/src/main/scala/cats/Invariant.scala#L9
😅
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.
That's interesting. In that case, let's leave them as they are.
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.
Great. Merge?
I've done some refactoring to
Handler
/Sink
and added a new traitPipe
which is likeHandler
, but exposes thesink
andsource
as different members so it's possible to transform them individually (map the source, redirect the sink,... ) while keeping the Pipe structure. I've been experimenting which this in outwatch-extras lately (https://github.com/mariusmuja/outwatch-extras/blob/io_monad_refactor_monix/outwatch-redux/src/main/scala/outwatch/redux/package.scala) and I found the concept ofPipe
very useful.A
Handler[A]
is now aPipe[A,A]
. AStore[S,A]
is also aPipe[A,S]
. In outwatch-redux I have anEffects[E, R]
, which is also aPipe[E, R]
.A
Pipe
also removes the need for the oldObservableSink
.This PR has some overlap with #83 as it provides some of the same operations (
mapSink
-contramapMap
,mapSource
-comapMap
), but it doesn't requires the types to be the same.It also address #90 by providing transforming operations on the sink and source, but to use them one needs to
import outwatch.advanced._
because they can be more easily used incorrectly and be a cause of bugs.