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

Signal.mergeMany [] throws runtime exception #381

Closed
rtfeldman opened this Issue Aug 30, 2015 · 23 comments

Comments

Projects
None yet
6 participants
@rtfeldman
Member

rtfeldman commented Aug 30, 2015

Making a separate issue for this so it can be listed under https://github.com/elm-lang/core/issues/377 - clearly in scope, since this is a runtime exception that a Core API change could avoid.

@TheSeamau5

This comment has been minimized.

Show comment
Hide comment
@TheSeamau5

TheSeamau5 Aug 31, 2015

Contributor

I'm for turning this into

Signal.mergeMany : Signal (List a) -> Signal a -> Signal a
Contributor

TheSeamau5 commented Aug 31, 2015

I'm for turning this into

Signal.mergeMany : Signal (List a) -> Signal a -> Signal a
@rtfeldman

This comment has been minimized.

Show comment
Hide comment
@rtfeldman

rtfeldman Aug 31, 2015

Member

The use case I usually have for this is merging a dozen or so port Signals into one "user input" Signal.

For that purpose, something along the lines of andMap (or, my personal preference, a custom pipeline operator) would presumably suffice.

Member

rtfeldman commented Aug 31, 2015

The use case I usually have for this is merging a dozen or so port Signals into one "user input" Signal.

For that purpose, something along the lines of andMap (or, my personal preference, a custom pipeline operator) would presumably suffice.

@mgold

This comment has been minimized.

Show comment
Hide comment
@mgold

mgold Aug 31, 2015

Contributor

I am of the opinion that, because the function will never be used with Signal.map and therefore a runtime error will occur immediately, this is acceptable as-is.

The most common use of mergeMany is to gather signals of the action type into one signal:

arrows : Signal Action
clicks : Signal Action
clock : Signal Action

actions : Signal Action
actions = Signal.mergeMany [arrows, clicks, clock]

-- and often
state = Signal.foldp step state0 actions

There's absolutely no possibility of encountering the error in this code, so there's no reason to encumber the programmer with a Maybe (Signal Action) or similar.

Hassan's version could be named mergeInto, and it might be neat in some edge cases. However, it it can be implemented on top of existing function, I'd say put it in and -extra library. (And if it can't it's beyond the scope of core.)

Contributor

mgold commented Aug 31, 2015

I am of the opinion that, because the function will never be used with Signal.map and therefore a runtime error will occur immediately, this is acceptable as-is.

The most common use of mergeMany is to gather signals of the action type into one signal:

arrows : Signal Action
clicks : Signal Action
clock : Signal Action

actions : Signal Action
actions = Signal.mergeMany [arrows, clicks, clock]

-- and often
state = Signal.foldp step state0 actions

There's absolutely no possibility of encountering the error in this code, so there's no reason to encumber the programmer with a Maybe (Signal Action) or similar.

Hassan's version could be named mergeInto, and it might be neat in some edge cases. However, it it can be implemented on top of existing function, I'd say put it in and -extra library. (And if it can't it's beyond the scope of core.)

@rtfeldman

This comment has been minimized.

Show comment
Hide comment
@rtfeldman

rtfeldman Aug 31, 2015

Member

Personally I think if we rename <~ to |+ we no longer need mergeMany.

From the mergeMany docs:

type Update = MouseMove (Int,Int) | TimeDelta Float | Click

updates : Signal Update
updates =
    mergeMany
        [ map MouseMove Mouse.position
        , map TimeDelta (fps 40)
        , map (always Click) Mouse.clicks
        ]

Compare to:

type Update = MouseMove (Int,Int) | TimeDelta Float | Click

updates : Signal Update
updates =
    map MouseMove Mouse.position
      |+ map TimeDelta (fps 40)
      |+ map (always Click) Mouse.clicks
(|+) = Signal.merge

I think custom pipe operators are really nice, and worth exploring more. Whereas other custom infix operators make code more dense and difficult to scan, the conventions around how pipes are used in Elm mean they make code more organized and easier to scan.

Granted, mergeMany is more self-documenting, and may have nontrivial performance benefits (I actually have no idea), but this is both crash-proof and very readable to me. I look at it and I think "oh yeah, it's combining them."

Member

rtfeldman commented Aug 31, 2015

Personally I think if we rename <~ to |+ we no longer need mergeMany.

From the mergeMany docs:

type Update = MouseMove (Int,Int) | TimeDelta Float | Click

updates : Signal Update
updates =
    mergeMany
        [ map MouseMove Mouse.position
        , map TimeDelta (fps 40)
        , map (always Click) Mouse.clicks
        ]

Compare to:

type Update = MouseMove (Int,Int) | TimeDelta Float | Click

updates : Signal Update
updates =
    map MouseMove Mouse.position
      |+ map TimeDelta (fps 40)
      |+ map (always Click) Mouse.clicks
(|+) = Signal.merge

I think custom pipe operators are really nice, and worth exploring more. Whereas other custom infix operators make code more dense and difficult to scan, the conventions around how pipes are used in Elm mean they make code more organized and easier to scan.

Granted, mergeMany is more self-documenting, and may have nontrivial performance benefits (I actually have no idea), but this is both crash-proof and very readable to me. I look at it and I think "oh yeah, it's combining them."

@rtfeldman

This comment has been minimized.

Show comment
Hide comment
@rtfeldman

rtfeldman Aug 31, 2015

Member

Perhaps a simpler alternative: replace Signal.mergeMany with Signal.mergeMap:

Before:

type Update = MouseMove (Int,Int) | TimeDelta Float | Click

updates : Signal Update
updates =
    mergeMany
        [ map MouseMove Mouse.position
        , map TimeDelta (fps 40)
        , map (always Click) Mouse.clicks
        ]

After:

type Update = MouseMove (Int,Int) | TimeDelta Float | Click

updates : Signal Update
updates =
    map MouseMove Mouse.position
      |> mergeMap TimeDelta (fps 40)
      |> mergeMap (always Click) Mouse.clicks
mergeMap : (a -> b) -> Signal a -> Signal b -> Signal b
mergeMap fn signal =
  Signal.merge (Signal.map fn signal)
Member

rtfeldman commented Aug 31, 2015

Perhaps a simpler alternative: replace Signal.mergeMany with Signal.mergeMap:

Before:

type Update = MouseMove (Int,Int) | TimeDelta Float | Click

updates : Signal Update
updates =
    mergeMany
        [ map MouseMove Mouse.position
        , map TimeDelta (fps 40)
        , map (always Click) Mouse.clicks
        ]

After:

type Update = MouseMove (Int,Int) | TimeDelta Float | Click

updates : Signal Update
updates =
    map MouseMove Mouse.position
      |> mergeMap TimeDelta (fps 40)
      |> mergeMap (always Click) Mouse.clicks
mergeMap : (a -> b) -> Signal a -> Signal b -> Signal b
mergeMap fn signal =
  Signal.merge (Signal.map fn signal)
@mgold

This comment has been minimized.

Show comment
Hide comment
@mgold

mgold Aug 31, 2015

Contributor

These approaches look promising. It feels, just a little, like removing multi-way if in favor of repeated single ifs.

I'd like to see some benchmarks. For examples, having many signals that update at 60fps instead of 1 or 2 could plausibly be a problem. I'd hate to pick a notation we all love only to find it's not performant.

Contributor

mgold commented Aug 31, 2015

These approaches look promising. It feels, just a little, like removing multi-way if in favor of repeated single ifs.

I'd like to see some benchmarks. For examples, having many signals that update at 60fps instead of 1 or 2 could plausibly be a problem. I'd hate to pick a notation we all love only to find it's not performant.

@rtfeldman

This comment has been minimized.

Show comment
Hide comment
@rtfeldman

rtfeldman Aug 31, 2015

Member

Seems unlikely to be significantly off, given that mergeMany isn't Native; it's just a foldl over whatever you give it. 😉

Member

rtfeldman commented Aug 31, 2015

Seems unlikely to be significantly off, given that mergeMany isn't Native; it's just a foldl over whatever you give it. 😉

@jvoigtlaender

This comment has been minimized.

Show comment
Hide comment
@jvoigtlaender

jvoigtlaender Aug 31, 2015

Contributor

@rtfeldman, as a means of stress-testing your proposed notation/functions:

How would you replace something like the following code?

updates : Signal Time
updates =
    mergeMany (makeTimers 10)

makeTimers n =
    List.map (\i -> every (100 * i)) [1 .. n]

Where the point is that makeTimers could be any function that "creates" some list of signals that is not trivially just a list literal explicitly written out in the program code to begin with. For example, maybe imagine even a situation where makeTimers does not take the n as a parameter (here fixed to 10 at the call site), but instead that number comes in from the JavaScript side via a port (value, not signal) as a configuration parameter for the Elm program?

So my test is: You cannot change the makeTimers function. How do you change the updates signal definition to avoid use of mergeMany?

Contributor

jvoigtlaender commented Aug 31, 2015

@rtfeldman, as a means of stress-testing your proposed notation/functions:

How would you replace something like the following code?

updates : Signal Time
updates =
    mergeMany (makeTimers 10)

makeTimers n =
    List.map (\i -> every (100 * i)) [1 .. n]

Where the point is that makeTimers could be any function that "creates" some list of signals that is not trivially just a list literal explicitly written out in the program code to begin with. For example, maybe imagine even a situation where makeTimers does not take the n as a parameter (here fixed to 10 at the call site), but instead that number comes in from the JavaScript side via a port (value, not signal) as a configuration parameter for the Elm program?

So my test is: You cannot change the makeTimers function. How do you change the updates signal definition to avoid use of mergeMany?

@rtfeldman

This comment has been minimized.

Show comment
Hide comment
@rtfeldman

rtfeldman Aug 31, 2015

Member

Fortunately, there's Core precedent for exactly this situation!

We already know that you can't turn List (Signal a) into Signal a in the general case, because [] cannot be converted into a Signal a in the general case. However, the fact that you can't safely implement List (Signal a) -> Signal a is just a special case of the fact that you can't safely implement List a -> a.

Back in Core v1.1.1, when Signal.mergeMany and List.head were both partial, this test could just as easily have been asking how to implement the following if the crashy version of List.head were to go away:

timeValue : Time
timeValue =
    head (makeTime 10)

makeTime n =
    List.map (\i -> (100 * i)) [1 .. n]

This isn't a hypothetical anymore, because the crashy version of List.head indeed did go away! The short answer is that I am now responsible for either handling [] gracefully or pulling the Debug.crash trigger myself.

As in the Signal version of the test, I don't have any context about what to do in the case where I'm given [], so by process of elimination there is only one solution to either test: call Debug.crash in the event that I receive [].

timeValue : Time
timeValue =
    case makeTime 10 of
        [] ->
            Debug.crash ":("

        timeValue :: timeValues ->
            timeValue


updates : Signal Time
updates =
    case makeTimers 10 of
        [] ->
            Debug.crash ":("

        timer :: timers ->
            List.foldl merge timer timers

So if this is the only behavior that can pass that test, the question becomes whether forcing people to write this out is better or worse than offering it as a built-in library function.

We have precedent for that; List.head now requires exactly this, because we determined that if you really want something to crash, you should have to ask for a crash by name.

Member

rtfeldman commented Aug 31, 2015

Fortunately, there's Core precedent for exactly this situation!

We already know that you can't turn List (Signal a) into Signal a in the general case, because [] cannot be converted into a Signal a in the general case. However, the fact that you can't safely implement List (Signal a) -> Signal a is just a special case of the fact that you can't safely implement List a -> a.

Back in Core v1.1.1, when Signal.mergeMany and List.head were both partial, this test could just as easily have been asking how to implement the following if the crashy version of List.head were to go away:

timeValue : Time
timeValue =
    head (makeTime 10)

makeTime n =
    List.map (\i -> (100 * i)) [1 .. n]

This isn't a hypothetical anymore, because the crashy version of List.head indeed did go away! The short answer is that I am now responsible for either handling [] gracefully or pulling the Debug.crash trigger myself.

As in the Signal version of the test, I don't have any context about what to do in the case where I'm given [], so by process of elimination there is only one solution to either test: call Debug.crash in the event that I receive [].

timeValue : Time
timeValue =
    case makeTime 10 of
        [] ->
            Debug.crash ":("

        timeValue :: timeValues ->
            timeValue


updates : Signal Time
updates =
    case makeTimers 10 of
        [] ->
            Debug.crash ":("

        timer :: timers ->
            List.foldl merge timer timers

So if this is the only behavior that can pass that test, the question becomes whether forcing people to write this out is better or worse than offering it as a built-in library function.

We have precedent for that; List.head now requires exactly this, because we determined that if you really want something to crash, you should have to ask for a crash by name.

@rtfeldman

This comment has been minimized.

Show comment
Hide comment
@rtfeldman

rtfeldman Aug 31, 2015

Member

That reminds me...I vaguely recall seeing this on a mailing list thread somewhere, so it's probably worth noting here.

This would be total and wouldn't require any Result or Maybe stuff:

Signal.mergeAll : Signal a -> List (Signal a) -> Signal a
Member

rtfeldman commented Aug 31, 2015

That reminds me...I vaguely recall seeing this on a mailing list thread somewhere, so it's probably worth noting here.

This would be total and wouldn't require any Result or Maybe stuff:

Signal.mergeAll : Signal a -> List (Signal a) -> Signal a
@jvoigtlaender

This comment has been minimized.

Show comment
Hide comment
@jvoigtlaender

jvoigtlaender Aug 31, 2015

Contributor

@rtfeldman, it's actually not true we don't have context for doing anything else than crash in the two cases you give. For example, you could do:

timeValue : Time
timeValue =
    case makeTime 10 of
        [] ->
            0

        timeValue :: timeValues ->
            timeValue

and something similar for the signal case. But of course that's not the point.

The point was whether it is good to get rid of mergeMany and instead use something like your binary operators. As I expected and now see, for my "test" you didn't use the notation you previously suggested. Instead, turned to a List.foldl merge. That suggests there is a role for a named function here that does more than combine only two signals.

The next question is then what type that function should have. Signal a -> List (Signal a) -> Signal a is an option. You have probably seen it in a comment by @TheSeamau5 in earlier discussions of Signal.mergeMany (and also above in this thread here, though there the type is a bit mixed up).

Anyway, my main point with my "test" above was: an API with only binary andMap or mergeMap or |+ seems not very desirable. We want something that can work on a list. How to deal with the case of an empty list then is a separate question. But do we agree now that simply dropping Signal.mergeMany is not so nice?

Contributor

jvoigtlaender commented Aug 31, 2015

@rtfeldman, it's actually not true we don't have context for doing anything else than crash in the two cases you give. For example, you could do:

timeValue : Time
timeValue =
    case makeTime 10 of
        [] ->
            0

        timeValue :: timeValues ->
            timeValue

and something similar for the signal case. But of course that's not the point.

The point was whether it is good to get rid of mergeMany and instead use something like your binary operators. As I expected and now see, for my "test" you didn't use the notation you previously suggested. Instead, turned to a List.foldl merge. That suggests there is a role for a named function here that does more than combine only two signals.

The next question is then what type that function should have. Signal a -> List (Signal a) -> Signal a is an option. You have probably seen it in a comment by @TheSeamau5 in earlier discussions of Signal.mergeMany (and also above in this thread here, though there the type is a bit mixed up).

Anyway, my main point with my "test" above was: an API with only binary andMap or mergeMap or |+ seems not very desirable. We want something that can work on a list. How to deal with the case of an empty list then is a separate question. But do we agree now that simply dropping Signal.mergeMany is not so nice?

@jvoigtlaender

This comment has been minimized.

Show comment
Hide comment
@jvoigtlaender

jvoigtlaender Aug 31, 2015

Contributor

Rereading your comment, I see you might actually prefer to write List.foldl merge. I'd prefer if this were given a name, so that people don't have to use a higher-order combinator "just" to merge their list of input signals into one signal.

Contributor

jvoigtlaender commented Aug 31, 2015

Rereading your comment, I see you might actually prefer to write List.foldl merge. I'd prefer if this were given a name, so that people don't have to use a higher-order combinator "just" to merge their list of input signals into one signal.

@rtfeldman

This comment has been minimized.

Show comment
Hide comment
@rtfeldman

rtfeldman Aug 31, 2015

Member

I'd prefer if this were given a name, so that people don't have to use a higher-order combinator "just" to merge their list of input signals into one signal.

Seems reasonable to me!

I've made a PR to elm-signal-extra for the option I like best (mergeAll), so anyone who'd like a named total version of mergeMany can start using it today, regardless of how this issue shakes out.

Member

rtfeldman commented Aug 31, 2015

I'd prefer if this were given a name, so that people don't have to use a higher-order combinator "just" to merge their list of input signals into one signal.

Seems reasonable to me!

I've made a PR to elm-signal-extra for the option I like best (mergeAll), so anyone who'd like a named total version of mergeMany can start using it today, regardless of how this issue shakes out.

@mgold

This comment has been minimized.

Show comment
Hide comment
@mgold

mgold Aug 31, 2015

Contributor

Well, that is indeed a convincing argument about performance. The rest of the discussion cements the idea of "mergeMany = merge + fold + handling the empty case".

As Janis brings up, when the type is monomorphic, one does not have to crash. This suggests that, in cases like makeTimers, one could supply a default.

safeMergeMany : a -> List (Signal a) -> Signal a
safeMergeMany default signals =
    case signals of
        [] ->
            Signal.constant default

        x :: xs ->
            List.foldl Signal.merge x xs

However, in the common case of a list literal, one is required to provide a default that is never used (and this is known, to the programmer but not the compiler, at compile time).

I think some kind of binary operator is the best way to handle the common case. Part of me thinks that mergeMap is too trivial to add to core (uncurry Signal.map >> Signal.merge |> curry), but it works really well.

Contributor

mgold commented Aug 31, 2015

Well, that is indeed a convincing argument about performance. The rest of the discussion cements the idea of "mergeMany = merge + fold + handling the empty case".

As Janis brings up, when the type is monomorphic, one does not have to crash. This suggests that, in cases like makeTimers, one could supply a default.

safeMergeMany : a -> List (Signal a) -> Signal a
safeMergeMany default signals =
    case signals of
        [] ->
            Signal.constant default

        x :: xs ->
            List.foldl Signal.merge x xs

However, in the common case of a list literal, one is required to provide a default that is never used (and this is known, to the programmer but not the compiler, at compile time).

I think some kind of binary operator is the best way to handle the common case. Part of me thinks that mergeMap is too trivial to add to core (uncurry Signal.map >> Signal.merge |> curry), but it works really well.

@sindikat

This comment has been minimized.

Show comment
Hide comment
@sindikat

sindikat Sep 2, 2015

We can't have a total function of type List a -> a, but we can have a total function of type List (List a) -> List a:

f : [[a]] -> [a]
f xs =
  case xs of
    [] -> []
    (x::_) -> x

Is it possible in Elm type system to have a Signal that emits nothing, never, and have a polymorphic type of Signal a? This “empty” might be the result of mergeMany [].

sindikat commented Sep 2, 2015

We can't have a total function of type List a -> a, but we can have a total function of type List (List a) -> List a:

f : [[a]] -> [a]
f xs =
  case xs of
    [] -> []
    (x::_) -> x

Is it possible in Elm type system to have a Signal that emits nothing, never, and have a polymorphic type of Signal a? This “empty” might be the result of mergeMany [].

@mgold

This comment has been minimized.

Show comment
Hide comment
@mgold

mgold Sep 2, 2015

Contributor

I'm afraid not. One of the premises of signals is that they're always defined. If I map over this signal, what do I get? Does the mapping just not happen (a silent error)?

I have no problem leaving this as it is; it almost never comes up in practice and is always an immediate error. Failing that, I'll say again that an easy binary merge could work as well.

Contributor

mgold commented Sep 2, 2015

I'm afraid not. One of the premises of signals is that they're always defined. If I map over this signal, what do I get? Does the mapping just not happen (a silent error)?

I have no problem leaving this as it is; it almost never comes up in practice and is always an immediate error. Failing that, I'll say again that an easy binary merge could work as well.

@jvoigtlaender

This comment has been minimized.

Show comment
Hide comment
@jvoigtlaender

jvoigtlaender Sep 2, 2015

Contributor

@sindikat, what you suggest would be perfectly possible if there were different concepts for Signals and Streams, where Streams are the ones that need not have an initial value. In fact, when such a separation was discussed for the core API a while ago, mergeing operated on Streams, not on Signals, so there we would have had an approriate "empty" to use as result of mergeMany []. But not with Signals.

@mgold, "immediate" in what sense? Always happening on the developer's machine before the code is even shipped (so that the developer would notice, and not ship the code, but instead repair the bug first), or possibly happening only after deployment (but then immediately on page load)? I think the latter can happen here, because the behavior (whether mergeMany gets called on an empty list or not) can depend on an incoming port value, thus be decided at page load time, not before.

Contributor

jvoigtlaender commented Sep 2, 2015

@sindikat, what you suggest would be perfectly possible if there were different concepts for Signals and Streams, where Streams are the ones that need not have an initial value. In fact, when such a separation was discussed for the core API a while ago, mergeing operated on Streams, not on Signals, so there we would have had an approriate "empty" to use as result of mergeMany []. But not with Signals.

@mgold, "immediate" in what sense? Always happening on the developer's machine before the code is even shipped (so that the developer would notice, and not ship the code, but instead repair the bug first), or possibly happening only after deployment (but then immediately on page load)? I think the latter can happen here, because the behavior (whether mergeMany gets called on an empty list or not) can depend on an incoming port value, thus be decided at page load time, not before.

@mgold

This comment has been minimized.

Show comment
Hide comment
@mgold

mgold Sep 2, 2015

Contributor

@jvoigtlaender Okay, I hadn't considered that you could do something like

port numbers : List Int

possiblyBadSignal = Signal.mergeMany <| List.map Signal.constant numbers

But that seems really contrived to me. Almost always you'd have a fixed number of ports of signals, and combine them in a list literal.

Contributor

mgold commented Sep 2, 2015

@jvoigtlaender Okay, I hadn't considered that you could do something like

port numbers : List Int

possiblyBadSignal = Signal.mergeMany <| List.map Signal.constant numbers

But that seems really contrived to me. Almost always you'd have a fixed number of ports of signals, and combine them in a list literal.

@jvoigtlaender

This comment has been minimized.

Show comment
Hide comment
@jvoigtlaender

jvoigtlaender Sep 2, 2015

Contributor

@mgold As already hinted above, I was thinking more of something like

port configParameter : Int

updates : Signal Time
updates =
    mergeMany (makeTimers configParameter)

makeTimers n =
    List.map (\i -> every (100 * i)) [1 .. n]

(where of course the makeTimers could be a more interesting function).

I don't find this particularly contrived.

As soon as the HTML that calls/embeds the Elm code passes a config parameter that is not positive, the error will be triggered.

Contributor

jvoigtlaender commented Sep 2, 2015

@mgold As already hinted above, I was thinking more of something like

port configParameter : Int

updates : Signal Time
updates =
    mergeMany (makeTimers configParameter)

makeTimers n =
    List.map (\i -> every (100 * i)) [1 .. n]

(where of course the makeTimers could be a more interesting function).

I don't find this particularly contrived.

As soon as the HTML that calls/embeds the Elm code passes a config parameter that is not positive, the error will be triggered.

@mgold

This comment has been minimized.

Show comment
Hide comment
@mgold

mgold Sep 2, 2015

Contributor

Ah, right. Yeah, I agree that's problematic. How would you implement this in a safe way, perhaps using only merge?

Contributor

mgold commented Sep 2, 2015

Ah, right. Yeah, I agree that's problematic. How would you implement this in a safe way, perhaps using only merge?

@jvoigtlaender

This comment has been minimized.

Show comment
Hide comment
@jvoigtlaender

jvoigtlaender Sep 3, 2015

Contributor

@mgold Addressing this would go like in this or this comment

Contributor

jvoigtlaender commented Sep 3, 2015

@mgold Addressing this would go like in this or this comment

@rtfeldman

This comment has been minimized.

Show comment
Hide comment
@rtfeldman

rtfeldman May 11, 2016

Member

🎉🎉🎉🎉 🏆 🏆 🏆 🏆 🏆 😸 😸 😸 😸 😸

Member

rtfeldman commented May 11, 2016

🎉🎉🎉🎉 🏆 🏆 🏆 🏆 🏆 😸 😸 😸 😸 😸

@mgold

This comment has been minimized.

Show comment
Hide comment
@mgold

mgold May 11, 2016

Contributor

OBE, as they say. (Overcome By Events)

Contributor

mgold commented May 11, 2016

OBE, as they say. (Overcome By Events)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment