Stream-based Programming: Coding like you’re designing circuit boards

Why aren’t we building apps like we’re building electronic devices?

Stream-based Programming: Coding like you’re designing circuit boards
Photo by Harrison Broadbent / Unsplash

So recently I’ve been learning how to build a speaker system, which largely consists of trying to understand how circuit boards work. A couple of days ago, an idea struck me: Why aren’t we building apps like we’re building electronic devices?

Apps are basically circuits of data and events. We tap a virtual button and that translates into a user event by some View object, which then gets transformed into a command for some database, Model object, or a cloud API. And guess what? Electronic devices are also circuits of data and events. Pressing a button is an event, while audio signals are data streams. After all, apps are running on electronic devises themselves, so the similarity between the two is not that surprising. Or should I say, it’s obvious.

However, the way we design the components in an app is quite different. I’m talking about OOP, or Object-oriented Programming, where we scheme objects like they’re people, always telling each other what to do and waiting for responses. It’s a deviation from the concept of circuit because electronic devices, or just circuit boards, don’t know each other, neither do they wait for each other. All they do is sending signals to and receiving from connectors. There’s no concepts of someone or response, at least not the same as in OOP.

It all started with function.

Yes, the building stone of virtually all programming languages. Function is what I consider the source of the deviation.

Function is a concept borrowed from mathematics. It describes a mapping from an element in set X to another element in set Y, or something like that. The point is, there are always two elements.

In computer science, they’re usually known as parameter and return value. You feed some parameters to a function, and wait for it to return a value. That’s the way functions work, and it’s all good, except it isn’t. Functions are not only used to transform one value to another, but also to send commands and data, or to retrieve values. In OOP, they even get a new name, called methods. So even if methods are basically just functions in a named context, they are seldom doing the thing they are originally designed for: transform one value to another. Instead, they are treated like behaviors of a person where one can call and potentially retrieve something from that person.

But what is wrong with methods and anthropomorphism?

There are several reasons, IMO, that we should not use functions as methods and, in a larger scope, avoid anthropomorphism all together:

You’ll need to know each other.

Methods encourage coupling because it’s trivial to call other methods on self or other objects in a method. The result? Functions know about each other, and objects know about each other, too. It’s bad for modularization.

Let’s say when the view is loaded, we want to download an image and then display it. There’re one event and two commands. Now how would you put them together? Let’s try in a classical Swift way:

func viewDidLoad() {
    Downloader.shared.downloadImage { image in
        view.image = image
    }
}

We use a method to represent the event, and inside the method we execute the commands serially by nesting one inside another. Now the method viewDidLoad() knows about Downloader and view.

You can only notify but not observe.

It’s hard to observe values of an object from the outside because properties are basically getter methods, which require calling every single time you want to retrieve its value, unless the language has some kind of a property observer feature. Those features are usually very limited, though. For example, they only observes properties, not any other kinds of methods, unless we alter the methods themselves. Or they can only be used inside the owner object of the observed properties.

Simply put, we’ll need to modify a method (which could be a property) to make it send notifications, or so called "observable”.

Therefore, this notifier-based event-passing paradigm makes the notifier and the observer deeply coupled. The notifying object needs to know all observer objects and which methods to call. “You want to notify two observers at once? Sorry, my var delegate property can only hold one instance. Could you try hiring a middleman to be my delegate and reroute events to the observers?”

For the code example above, there’s no way we can observe when the view gets loaded other than implement and modify func viewDidLoad() itself.

You’ll need to wait a bit, sorry.

Every function needs to return. Before a function return, we’re all stuck here, even there’s no return value at all.

I’m still confused why a command would need to return. Or a message. “Can’t I just send a message to an object and be done with it? I don’t care about the result, but I really need to go handle other stuffs.” No, you’ll need to wait for it to return, and we won’t know how long it would take. Go check the documentation, I guess.

For the code example above, we can never be sure if func downloadImage(completionHandler:) would block the thread. It might be async or sync. Yes we can mark it as an async function, but that only means we have to await for it in a Task context. Task does solve the problem of waiting for return, but not that elegantly. Consider Unix Shell where you just need an & at the end of a call to do the same thing, but in Swift you need:

Task { let image = await downloadImage() }

The reason? The syntax of function, of waiting for a return value to continue.

Stream and pipeline are better syntax for connections.

If we rewrite the code example in a Unix Shell-like, pipeline way, it would be something like this:

let bindings: [Binding]
init() {
    bindings = [
        viewDidLoad | Downloader.shared.downloadImage > view.image
    ]
}

Notice how the methods are changed. viewDidLoad is now just a notifier, and view.image is in fact not a normal property but a value sink, or one of the value inputs of the view. downloadImage now has a standard output of type Image, and it’s closer to a process than a function.

Now, viewDidLoad doesn’t need to know about Downloader nor view, or any other thing, because it is not a function anymore. The only one who knows everything is init(), where binding happens. If we design it like SwiftUI, it can be even more simpler:

var bindings: [Binding] {
    viewDidLoad | Downloader.shared.downloadImage > view.image
}

This pattern is an observer-based solution. We observe upstream signals in a binding instead of notify downstream observers in a upstream function.

Furthermore, we don’t need to wait for anything. The binding binds immediately, and the stream flows in its own execution context. If there are more than one binding in var bindings: [Binding], they’ll only bind in sequence, but the streams would all be parallel. However, the stream itself is always sequential, flowing from left to right, unlike OOP statements:

// Statements are usually evaluated from right to left: Download the image, wait, and then assign it to view.image.
view.image = await Downloader.shared.downloadImage()

But if this pipeline paradigm so good to use, why aren’t we using it already? Actually, people have been using it for years, with functional reactive programming frameworks like ReactiveX or Combine.

FRP frameworks to the rescue.

In FRP frameworks, we can define SOPs in a declarative way, much like in Unix shell:

func viewDidLoad() {
    Downloader.shared.downloadImagePublisher
        .assign(\.view.image)
        .store(in: self.subs)
}

Not only that, they usually come with a construct called a Subject. It is like a bridge between two streams, and it achieves it by sending out what it receives. Unlike filters (those which transform upstream signals and pass them down the stream), a subject is either the source or the destination of a stream, and it won’t transform signals.

So basically, subjects are interfaces. It provides a way for other objects to observe value changes in an object. Think of it as an FRP version of local notification center. The notifying object does not know about who it notifies, nor does it need to wait for return. It just notifies.

We are only half way there, though.

FRP frameworks, while achieving something spectacular, do not overthrow the idea of an anthropomorphic object having things and doing things. Because on the language level, objects are still objects, and methods are still methods. There are no constructs tailored specifically for FRP, for example subject is on the type level but let is on the language level.

But what if there are these FRP constructs? Can we use the electronic analogy and name them after circuit terminologies?

enum ModelCommand {
    case delete(Identifier), create
}
device Controller {
    input buttonEvent: ButtonEvent
    output modelCommand: ModelCommand
}
device Model {
    input command: ModelCommand
}
let controller = Controller()
let model = Model()
let stream = controller.modelCommand | .removeDuplicates > model.command

In the code example, device is a bit like class, but can only have input andoutput connectors — subjects — instead of properties. There might still be internal values, but those values won’t be accessible directly from outside thedevice.

Pat attention to .removeDuplicates. It is not a function, but a Filter. Filters have standard input and standard output, which functions don’t. It would be something like this:

device Downloader {
    filter download(allowsCellular: Bool): (URL) -> Data {
        // ...
    }
}
Just(someURL) | Downloader.shared.download(allowsCellular: true) | handleData | // ...

See how it has a parameter (allowsCellular: Bool) and a standard input ((URL)) simultaneously. Parameters are for configuring the filter, while standard inputs are for receiving signal streams.

The bottom line.

In my ideal stream-based programming, we should use devices instead of objects and actors. Devices are a kind of construct whose interface allows only inputs, outputs, and filters, aside from init or similar static factory methods.

input, output, and filter are all stream-based, meaning we don’t access nor call them directly. We can only connect them into streams, and manipulate events, commands, and values inside the streams.

Now we are talking about separation of concerns and modularization. devices are self-contained, so are inputs, outputs, and filters. Only streams know about all other constructs, because that is exactly what they do: connecting nodes and filters. Therefore, only streams are not reusable, filters and devices are inherently modular and reusable.

And since we’re thinking about devices in terms of inputs and outputs, they are constructs of transformations, too, just more complex ones. It means that it is easier to follow the single-responsibility principle: One device, one kind of transformation.

For example, in the MVC architecture, view is transformation from data to visual information, controller is transformation from user action to model command, and model is transformation from model command to data. We can treat the user as a device, too, being a transformation from visual information to user action. All these devices form a circular path, looping indefinitely.

Subscribe to Narrativesaw

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe