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

Handler/Sink refactor, added Pipe trait #91

Merged
merged 12 commits into from
Nov 21, 2017

Conversation

mariusmuja
Copy link
Collaborator

I've done some refactoring to Handler/Sink and added a new trait Pipe which is like Handler, but exposes the sink and source 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 of Pipe very useful.

A Handler[A] is now a Pipe[A,A]. A Store[S,A] is also a Pipe[A,S]. In outwatch-redux I have an Effects[E, R], which is also a Pipe[E, R].

A Pipe also removes the need for the old ObservableSink.

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.

@@ -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: _*)
Copy link
Member

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.

Copy link
Collaborator Author

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?

Copy link
Member

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.

Copy link
Collaborator Author

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.

Copy link
Member

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.

Copy link
Collaborator Author

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.

Copy link
Member

Choose a reason for hiding this comment

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

You are right!

@fdietze
Copy link
Member

fdietze commented Nov 12, 2017

Why did you add Pipe instead of giving Handler two type arguments? Why do we need both?

@mariusmuja
Copy link
Collaborator Author

We don't need both, I kept Handler with one type argument just for backwards compatibility (and it's a type alias for Pipe[A,A]).

Also, not sure if Pipe is the best name. Any other suggestions? Should we change change Handler to have two type arguments?

type >-->[-I, +O] = Pipe[I, O]
val >--> = Pipe

type Handler[A] = Pipe[A, A]
Copy link
Member

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.

Copy link
Collaborator Author

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.

Copy link
Member

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 :)

@fdietze
Copy link
Member

fdietze commented Nov 12, 2017

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 Observer with Sink as a bit too much. It introduces new terminology with marginal gain (private observer) and makes it a bit incompatible with rxjs.

So maybe even just:

type Handler[-I,+O] = Observer[I] with Observable[O]

@mariusmuja
Copy link
Collaborator Author

@fdietze Having observer private in Sink guarantees correct handling of subscriptions in Sink redirects, which eliminates a whole class of possible resource leaks.

@fdietze
Copy link
Member

fdietze commented Nov 12, 2017

With guarantees you are talking only about the fact that the user cannot use the internal observer, right? redirect could still be added via implicit on Observer and provide this safety. Do I understand correctly?

I'm interested in an example for such a resource leak.

@mariusmuja
Copy link
Collaborator Author

Yes, that's what I meant. Redirect could obviously still be added using an implicit on Observer, but it would be possible to use the observer directly and have resource leaks without properly handling the subscription(s).

@fdietze
Copy link
Member

fdietze commented Nov 13, 2017

I took some time to think about the whole Sink concept. For me it looks like Sink is only there for safety, but it only provides a little safety for the subscription case. Even with Sink one can still produce resource leaks by using <--. It is also possible to produce resource leaks by re-creating components.

One possible solution:
Instead of trying to hide functionality from the user, make it clear that certain things could produce resource leaks. In many cases the unsafe cases are very useful for testing. We could use @deprecate to inform the user at compile-time with a warning:

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 (b:A).g and miss the warning. I just wanted to present the basic idea.

I'm wondering if Monix has preventions against resource leaks. Do you know? @mariusmuja

@fdietze
Copy link
Member

fdietze commented Nov 14, 2017

Independently of the Sink discussion, I strongly agree that the concepts of Hander and Pipe should be merged. Both are a Pipe[I,O] (or how we name it).

Proposal:

type Pipe[-I,+O] = Sink[I] with Observable[O]
type Handler[T] = Pipe[T,T]

Pipe can implement:

  • Profunctor

Handler can implement:

  • Functor (map returns Observable)
  • Contravariant (contramap returns Sink)
  • Invariant (imap returns Handler)

(I'm not exactly sure about the Handler type classes)

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]

@LukaJCB
Copy link
Member

LukaJCB commented Nov 14, 2017

I agree with above except for a couple of points:

Handler can only implement Invariant, map and contramap should just be syntax sugar, since valid Functors and Contravariant functors, should preserve structure :)

@mariusmuja
Copy link
Collaborator Author

I pushed some refactorings as discussed above.

It renames Pipe to Handler (changing Handler to have two type arguments), leaves Handler to be both an Observable and a Sink and extracts handler operations into a trait HandlerOps:

type Handler[-I, +O] = Observable[O] with Sink[I] with HandlerOps[I, O]

Also, Store has an implicit conversion to Handler instead of separate implicit conversions to Observable and Sink.

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?

@mariusmuja
Copy link
Collaborator Author

@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]) {
Copy link
Member

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?

Copy link
Collaborator Author

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) {
Copy link
Member

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?

Copy link
Collaborator Author

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]
Copy link
Member

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 {
Copy link
Member

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?

Copy link
Collaborator Author

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.

Copy link
Member

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?

Copy link
Collaborator Author

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".

Copy link
Member

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 ?

Copy link
Collaborator Author

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.

@fdietze
Copy link
Member

fdietze commented Nov 15, 2017

Interesting API difference between Monix and rxjs:
https://monix.io/api/2.3/monix/reactive/subjects/Subject.html (two type parameters)
https://lukajcb.github.io/rxscala-js/latest/api/rxscalajs/Subject.html (one type parameter)

@fdietze
Copy link
Member

fdietze commented Nov 15, 2017

While looking at the code, it seems like this could be done in a simpler way. I'll give it a try soon.

@fdietze
Copy link
Member

fdietze commented Nov 15, 2017

@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 Handler? But then we have the same problems with Pipe.

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]

@LukaJCB
Copy link
Member

LukaJCB commented Nov 20, 2017

I think most people will just use Handler so keeping it with one param seems like a good idea. If power users want to use Pipe they can use partial-unification.

@fdietze fdietze requested a review from LukaJCB November 20, 2017 16:01

trait PipeOps[-I, +O] { self : Pipe[I, O] =>

def mapSink[I2](f: I2 => I): Pipe[I2, O] = Pipe(redirectMap(f), self)
Copy link
Member

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...

Copy link
Collaborator Author

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).

Copy link
Member

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.

Copy link
Member

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.

@fdietze
Copy link
Member

fdietze commented Nov 21, 2017

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)
Copy link
Collaborator Author

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.

Copy link
Member

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.

Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Collaborator Author

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.

Copy link
Member

Choose a reason for hiding this comment

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

Great. Merge?

@mariusmuja mariusmuja merged commit a68965b into outwatch:master Nov 21, 2017
@mariusmuja mariusmuja deleted the handler_refactor branch November 21, 2017 23:24
mariusmuja pushed a commit to mariusmuja/outwatch that referenced this pull request Nov 22, 2017
LukaJCB pushed a commit that referenced this pull request Nov 22, 2017
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