In many cases, a stream in Sodium can be treated as a drop-in replacement for a lis- tener/callback/observer pattern mechanism. Here’s an example of making this replacement.
Let’s say you’re writing a Player component for your media player that handles the I/O for the playback. It publishes three things:
■ Whether it’s paused
■ Where you’re up to in the track (seconds)
■ When the track has finished playing This is shown in the following listing.
public class Player {
public interface Listener {
public void paused(boolean isPaused);
public void seconds(int seconds);
public void ended();
}
private List<Listener> listeners = new ArrayList<>();
public void addListener(Listener l) { listeners.add(l);
}
public void removeListener(Listener l) { listeners.remove(l);
}
public void play(Track t) { ... } public void pause() { ... } public void resume() { ... } public boolean isPaused() { ... }
Listing 14.1 Media player component with observer interface
public int getSeconds() { ... } private void notifyPaused(boolean p) { for (l : listeners) l.paused(p); } private void notifySeconds(int s) { for (l : listeners) l.seconds(s); } private void notifyEnded() {
for (l : listeners) l.ended(); } }
You can replace seconds and paused with cells and ended with a stream, as in the next listing. Note that you keep the sink sides of the cells and streams private, and you export them as a Cell or Stream subclass. These classes don’t have a send() method, so the consumer of the exported cells and streams won’t be able to write to them.
public class Player {
public void play(Track t) { ... } public void pause() { ... } public void resume() { ... }
private final CellSink<Boolean> pausedSnk = new CellSink<>(false);
public final Cell<Boolean> paused = pausedSnk;
private final CellSink<Integer> secondsSnk = new CellSink<>(0);
public final Cell<Integer> seconds = secondsSnk;
private final StreamSink<Unit> sEndedSnk = new StreamSink<Unit>();
public final Stream<Unit> sEnded = sEndedSnk;
private void notifyPaused(boolean p) { pausedSnk.send(p); } private void notifySeconds(int s) { secondsSnk.send(s); } private void notifyEnded() { endedSnk.send(Unit.UNIT); } }
You might define a controller that plays the next track when a track ends, as shown in the next listing.
public class Controller implements Player.Listener { public Controller(Player player) {
this.player = player;
player.addListener(this);
playNext();
}
private Player player;
public void ended() { playNext();
}
public void playNext() { ... } }
The following listing rewrites this based on the FRP version of Player. Listing 14.2 Media player component, FRPized
Listing 14.3 Controller to play the next song using the observer interface
277 Stream as a drop-in replacement for callbacks
public class Controller {
public Controller(Player player) { this.player = player;
player.sEnded.listen(() -> playNext());
playNext();
}
private Player player;
public void playNext() { ... } }
14.3.1 Caveat: you can’t send() inside a listener
These changes are easy to make, but there’s an important caveat. Sodium has a restric- tion: it doesn’t allow StreamSink.send() or CellSink.send() to be called from inside a listener. It will throw an exception at runtime if you do this. This is the case for two reasons:
■ To ensure that messages are propagated in strict dependency order. If you use send() directly, then Sodium can’t track the dependency, so we don’t allow it.
■ To discourage writing “FRP code” in an imperative style.
Other FRP systems may not have this restriction, but they tend not to have strict denota- tional semantics and therefore aren’t “true FRP.” We discussed the importance of this issue in section 1.2.1 and talked about how RxJS doesn’t comply in chapter 6.
In the FRPized Controller in the previous example, it’s likely that playNext(), which is called from this handler
player.sEnded.listen(() -> playNext());
could call send(). Then playNext() would probably call Player.play(), which would in turn do this:
secondsSnk.send(0);
This wouldn’t be allowed because it breaks the restriction. There are two ways to deal with this. Which one you choose depends on how much of your program you want to refactor.
THE CONSERVATIVE WAY: DELEGATING TO ANOTHER THREAD
Instead of doing this
player.sEnded.listen(() -> playNext());
you can dump the processing onto a new thread:
player.sEnded.listen(() -> new Thread() { void run() { Player.this.playNext(); } }.start());
NOTE In Sodium, a listen() handler isn’t allowed to make any assumptions about what thread it’s running on. Each FRP system has different rules for this.
Listing 14.4 Controller to play the next song, FRPized
Spawning a thread like this requires that Player is thread-safe. A better way may be to use an asynchronous message queue and have Player run its own thread to process the requests. This is an actor model-like approach, and it neatly solves the concurrency issues. When dealing with I/O, we generally recommend this approach.
Doing things this way, you’re treating play() more as I/O than as a state update.
This means your state updates won’t be as “tight.” To state this more precisely, the state transition from ending one track to starting the next isn’t atomic. Depending on how the rest of the program works, it may be possible to observe a state between tracks when nothing is being played. This could be problematic, for example, if you wanted to detect when the player was idle. It might falsely come to that conclusion between songs. A full FRP approach eliminates this issue.
THE RADICAL WAY: TRANSFORMING PLAY() INTO A STREAM
Instead of this
Player() { ... } void play(Track t) { secondsSnk.send(0);
... Initiate I/O ...
}
you can write
Player(Stream<Track> sPlay) {
sPlay.listen(t -> { ... Initiate I/O ... });
seconds = sPlay.map(t -> 0).merge( ... other stuff ... ).hold(0);
}
where ... other stuff ... is a placeholder for whatever mechanism makes the sec- onds tick. For this to work properly, you also need to change Controller to manage all of its state using FRP. Now the code is turning completely into FRP. You’re bringing discipline to the state management and making the whole thing thread-safe. That’s the good news.
But if your program is large, taking this sort of approach consistently may force you to make too many changes at once. The best approach is to transform code into the world of FRP in stages, always maintaining a bridge between the imperative and FRP parts. It’s important to do this in small chunks so you can test as you go.
14.3.2 Choosing the right chunk size
As we said, converting a project to use FRP should be done in small chunks so you can keep the code tested and running. It’s best to find self-contained modules where you can initially keep the same interface on the outside. You need to ask these questions:
■ How much of the state is still mutable, and how much work is involved in chang- ing to immutable data structures?
■ What are the implications for threading when I try to maintain the same exter- nal interface?
Then do the work and retest before moving on to the next chunk.
279 Program initialization with one big transaction