This is my first official feature request ^_^. I've been thinking about this for a long time*, and I think I've more or less perfected the idea. Because of the long "development" time, this is going to be a long post. I'm trying to be as complete as I can about it, and I have some code examples and diagrams. I do expect the reader to be familiar with Evans thesis [1], which explains about the implementation of the runtime system with synchronisation using Change/NoChange queues.
* This idea is the product of reading Evans thesis, having some sleepless nights, making chaotic notes on more and more paper, thinking about this while I was supposed to be studying for some tests, writing a proposal only to discover it doesn't work... I guess what I'm trying to say is that this took some serious effort, so please be gentle with your critique :)
Feature Request: Loop construct
The general problem
Apparently we can work with signal graphs in ours minds, but the limitation of a DAG is not always easy to work with. (See background materials, loops have been discussed on this mailing list many times [2-6]).
Why cycles are bad
The reason normal cycles are bad is because they get out of control. When a signal depends on itself then a Change of the value of that signal will make it recompute its value, and because of that recomputation it can fire another Change, and so on in an endless loop. This non-paced behaviour of the signal is not wanted, and should be avoided at all times, statically. So there are no loops in Elm.
A safe cycle
But if you look carefully, there is actually a very constrained looping construct in Elm: foldp (Evan mentions this in [2]). Here is a diagram in the style of the thesis:
So how is it that this construct is safe? Well if you were allowed to define signal dependant on themselves in Elm, then result = foldp f b s could be expressed with:
result = lift2 f s $ sampleOn s result
-- note the lack of b, because sampleOn doesn't need a base case (which I find odd)
I mean to illustrate with this code example that foldp makes the loop safe by not reacting on a change from that cyclic signal, but only considering the pace that input s has.
The power of foldp
The loop diagram of foldp seems rather simple, and yet most of the time it is a construct which is powerful enough. This power really comes from the rest of Elm. First of all, Elm is a functional language, which means first-class functions, which means easily composable functions. For example:
translates easily to foldp (f.g) b s
The second easy this in Elm is combining and splitting signals:
These tranlate to foldp (uncurry f) b $ (,) <~ s1 ~ s2,
and (r1,r2) = let r = foldp (\s (_,b) -> f s b) b s in (fst <~ r, snd <~ r)
Note that that last one already becomes harder to write. When you try to do more difficult things within the loop, the code becomes more convoluted and less the way you see it in your head:
(foldp (\(s1,s2) -> g s2 . f s1 ) b $ (,) <~ s1 ~ s2)
Apart from the packing and unpacking of signal to get everything in the corset that is foldp (which can cause needless recomputation), there is something you really can't do within the foldp loop: filtering. Because a filter function works on with the full Event type, not just the value of the Signal. Apart from being unable to write pure functions over the Event type because Elm doesn't expose it to the user, you would not be able compose these function with pure value functions.
Concluding
Cycles are bad because they spin out of control. foldp introduces safe cycles by pacing the cycle with an external signal (ignoring changes from the cyclic part). Though the loop/combine function is pure and works on one external input and the cyclic signal, functions can be composed and signals can be combined and split. But:
- A complicated signal graph with a loop doesn't map in a straight-forward way to code with foldp for the loop. (debatable, depending on coding style)
- All the packing and unpacking (combining/splitting) of signals can also cause needless recomputation.
- No filtering can be done inside the loop.
Specific use-cases
This is a situation I really ran into while writing Extreme Pong [2].
You want to write a little browser-game in Elm. The game has some game logic in the input and the state of the game, and shows some animations. Because it shows animations, fps is a handy part of the input.
input = Input <~ (fps 60) ~ otherKeyboardStuff
gameState = foldp gameLogic stdGameState input
main = render <~ gameState
Now consider adding a feature to pause the game. If a keyboard key is pressed, the game state turns to paused. Then you can ignore the fps signal for a bit because there is no animation required. To give the CPU of the gamer a break, you can use fpsWhen and have a Bool signal called playing which is changed by the keyboard key. All is well.
input = Input <~ (fpsWhen 60 playing) ~ otherKeyboardStuff
gameState = foldp gameLogic stdGameState input
main = render <~ gameState
Now consider that instead of giving the gamer the ability to pause the game, you give the game logic the ability to pause the game. Alas, now you cannot use fpsWhen anymore! Because playing is influenced by the foldp gameLogic, depends on it, and thus depends on input, and thus on fpsWhen, which depends on playing again. fpsWhen is a specialised filter on input signal fps. And filters are not allowed in foldp loops. So there is no solution, you just have to use the fps signal and ignore it when the game is paused.
I don't know any other simple examples of things you want to express with a filter in a signal loop, but I expect that there are more.
Specific approach
The solution I would like to propose is a construct --- let's call it loop for now --- more easily create a loop (or cycle) in Elm. This would be done in the easiest way by using a foldp-like function which has an extra argument, which is a signal which will be defined later and may thus be dependant on the signal being defined with loop:
The use-case I presented could then use fpsWhen even when using gameLogic for pausing:
split s = (fst <~ s, snd <~ s)
newPlaying spacebar (gameLogicPause,oldPlaying) =
if| gameLogicPause -> False
| spacebar -> True
| otherwise -> oldPlaying
playing = loop newPlaying False ((,) <~ pause ~ playing) Keyboard.space
input = Input <~ (fpsWhen 60 playing) ~ otherKeyboardStuff
(gameState,pause) = split $ foldp gameLogic stdGameState input
main = render <~ gameState
Background materials
List discussions:
[2]
My question with use-case
[6]
same explanation mentioned again by John
Alternative solutions
Right now this kind of functionality is possible using the FFI. Though the code style does make it very clear that something special is going on, but it's a (dangerous) hack. You may introduce unsafe loops this way.
Another solution which would require a less special construct would be:
loop : (Signal a -> Signal b -> Signal b) -> b -> Signal a -> Signal b
This alternate doesn't promote a style in which you define pure functions only. I don't like that way, with some signals defined inside a function, it looks messy:
-- hidden state loop
hsLoop : (Signal i -> Signal s -> Signal (o,s)) -> (o,s) -> Signal i -> Signal o
hsLoop sf b s = lift fst $ loop (\i (_,s) -> sf i s) b s
split : Signal (a,b) -> (Signal a, Signal b)
split s = (fst <~ s, snd <~ s)
newPlaying : Bool -> Bool -> Bool -> Bool
newPlaying gameLogicPause spacebar oldPlaying =
if| gameLogicPause -> False
| spacebar -> True
| otherwise -> oldPlaying
playingLF : Signal Bool -> Signal Bool -> Signal (GameState, Bool)
playingLF space playing =
let timeDelta = fpsWhen 60 playing
input = Input <~ timeDelta ~ otherKeyboardStuff
(gameState, pause) = split $ foldp gameLogic stdGameState input
in (,) <~ gameState ~ (newPlaying <~ pause ~ space ~ playing)
gameState : Signal GameState
gameState = hsLoop playingLF False Keyboard.space
main : Signal Element
main = render <~ gameState
Known issues
Things to consider about the loop function as proposed:
- Name/Argument order
- It now looks like a normal function, but doesn't behave like one because one of the arguments is very special! Perhaps a language construct which looks distinctly different would be better to make sure you pay attention to these parts of the code? e.g.: r = loopBack sb with f b s
On the other hand, other functions on signals are normal functions too... Perhaps do something with the type signature? e.g.: loop : (a -> b -> c) -> b -> FutureSignal b -> Signal a -> Signal c
- Is it always the case that it is also handy to have the last value of the signal result? Then f : a -> c -> b -> c would be better...
- Will this create more complex code? i.e. is it harder to read? (I find it easier to write, but is it easy to read for others?) Will it promote a messier coding style?
Justification
I think the known issues need to considered carefully. But otherwise I think this is a safe and simple way to introduce loops in Elm, making it a more powerful language. It takes out all three parts of the problem:
- A complicated signal graph with a loop doesn't map in a straight-forward way to code with foldp for the loop. (debatable, depending on coding style)
- All the packing and unpacking (combining/splitting) of signals can also cause needless recomputation.
- No filtering can be done inside the loop.
I don't think it will be very complex to implement this in the runtime. I don't know how well the compiler checks on cycles now and how dependant it is on DAGs though.
-- end of feature request