slashbinbash.de / Applying the Input-Process-Output Model to Software Design

The input-process-output (IPO) model is based on a very basic principle. You take input A and, by processing, transform it into output B. It was probably one of the first models I was taught in my computer science classes at university. I quickly dismissed it because it looked too trivial to be any useful.

Years later, I started learning the functional programming language Haskell. I came across one description that explained that functions are like little building blocks that have inputs and outputs. You break down complex problems into smaller problems until you can solve the small problems with simple function blocks. Then you combine the simple function blocks into bigger function blocks, or function chains, until you have solved the complex problem.

Thinking of a program as different configurations of function blocks, that perform input, process, and output operations, gave me a different perspective on software design and analysis. Especially when you see that it can be applied on any abstraction level, and on every scale. For instance, programming language compilers, internet browsers, graphics pipelines, or neural networks, can all be broken down functionally into input-process-output systems.

This concept is not limited to software either. Take supply chains for instance. Raw materials are processed and shipped to factories that produce components. The components are shipped to other factories that assemble the components into the final product. The final product is shipped to stores, where it reaches the customer. Along the supply chain there are process units that transform inputs into outputs, and pass them to the next units. Of course the output of one unit has to match the input of the next unit, otherwise it cannot be processed. The supply chain visualizes the path of the materials through the entire system.

If you apply the same idea to software, the focus shifts to the flow of the data through the system. The question becomes: how do we represent data, what transformations of data are necessary to solve the problem, and what requirements do we have on these transformations.

This is the basis for the topics that I want to explore in this article.

Overview

Examples of IPO Systems

Before I start, I want to give some high-level examples of IPO models in software. The focus of these examples will be on the data transformations that are performed to solve the task at hand.

This should give you a motivation to consider the IPO model in the way you approach problems.

Compilers

When you write a program, you are using a programming language to describe what you want the machine to do.

First the text is transformed into tokens (words) that represent the symbols, keywords, numbers, strings of the language. Then the tokens are transformed into an abstract syntax tree (AST). The abstract syntax tree brings meaning to the stream of tokens. Then the abstract syntax tree is transformed into assembly code. The assembly code is a textual representation of processor instructions. The last step is to transform the assembly code into machine code, bits and bytes that the hardware can understand and execute. The output can be an executable or library.

Internet Browsers

When you visit a website, the browser downloads an HTML document and displays it on your screen.

First the browser translates the HTML document into a document object model (DOM). The style information is read from CSS files and attached to the DOM. The DOM is then converted into a layout tree, which specifies the positions and dimensions of all elements in the document. The layout tree is then painted to the screen, which requires a translation of the layout, style and content information to graphics API calls that produce the pixels on your screen.

Graphics Pipeline

The graphics pipeline consists of multiple steps that transform geometrical primitives into pixels on your screen.

First the 3D vertices of your object are transformed by the vertex processor. Then the transformed vertices are converted to fragments in the rasterization step. The fragment processor can then apply a color or texture to the fragments. Finally, the fragments are converted into 2D pixel-data that can be displayed on the screen.

Definitions

In the context of this article:

Separating Process and Data

When looking at the IPO model, it is important to differentiate between the process and the input and output data.

Lets say the customer wants a program that, given a number n, prints all the numbers from 0 to n, multiplied by 4. Sounds easy enough:

for i in range(0, n):
    print(i * 4)

This is a very straight forward approach that meets the requirements of the customer, but there are a few issues. It doesn't communicate the requirements very well because the information is so densely packed. Extending the implementation to new requirements might become challenging. None of the code can be reused. It is difficult to test because we don't have any testable output.

If we try to separate the different responsibilities, we might end up with something like this:

# generate data
numbers = []

for i in range(0, n):
    numbers.append(i)

# transform data
for i in range(0, len(numbers)):
    numbers[i] *= 4

# print data
for num in numbers:
    print(num)

As you can see, we are using n to generate data, we transform the data by multiplying it by 4, and then we print it. Because we have separated the different processes, we can change each of them individually, without breaking a part that already works. However, the gained flexibility comes at a cost. There is more code to maintain and there is repetition.

I think we can do a little bit better, using functions:

numbers = generateNumbers(n)
numbers = multiplyBy4(numbers)
print(numbers)

Since we have separated the code into independent parts, we can hide the implementation details behind simple function calls. The functions are responsible for iterating over and transforming the data. The generation and transformation of the data in our program is testable. If you don't like how multiplyBy4 is implemented, you can replace it with another method.

If a new customer wants the data to be multiplied by 8 instead of 4 you can generalize the multiplication:

numbers = generateNumbers(n)
numbers = multiplyBy(m, numbers)
print(numbers)

You can even change the source of the data:

numbers = readNumbersFromFile("numbers.csv")
numbers = multiplyBy(m, numbers)
print(numbers)

The processes and the data are clearly separated. The path of the data through the program has become visible. The details of the data transformation is hidden in the processes. The program is easy to extend by adding new processes along the path of the data. The processes can be tested in isolation.

Separating Parameters and Data

There is one detail in the last example that I'd like to draw your attention to. Namely, the difference between arguments that represent data which is transformed, and arguments that modify the behavior of a process.

numbers = multiplyBy4(numbers)

In the multiplyBy4 example, numbers is the input and output data. The function name clearly states the type of transformation. The purpose of the function is encoded directly into the name.

Then we generalized the function and made its behavior dependant on an argument.

numbers = multiplyBy(m, numbers)

We can still achieve the same behavior as multiplyBy4 by calling:

numbers = multiplyBy(4, numbers)

However, from an IPO perspective, the first parameter m acts as a mechanism to produce different variants of the multiplyBy processor. This means that in effect, every parameterization of the processor is like a new processor that can potentially have bugs that we haven't seen in other variants.

Because we said that numbers is our data, we expect numbers to change. We don't expect the behavior of multiplyBy to change during the call. Meaning, the call multiplyBy(4, numbers) should always behave the same.

I think it is important to make this distinction because in most programming languages, this is not obvious. What is the data that needs to be processed? What changes the behavior of the processor?

Data and Data Modeling

Since the focus of the IPO model is on data and its transformation, I would argue that it is important to reduce the friction between transformations from one data model into another.

For instance, if we want to preserve the state of the runtime data, we need to find a mapping between the runtime model (i.e. classes) and the persistance model (i.e. XML, JSON, etc.). Besides the programming effort that it takes to implement such a mapping, it also takes a cognitive effort to keep in mind the different models of data, their representations, as well as the points where data is exchanged between different systems.

In object-oriented programming languages, the border between data and behavior is often blurred. This can lead to designs where the flow of data is completely hidden inside the methods of a class. Bad class design can also make it difficult to transform objects from one model or representation into another. This does not mean that the object-oriented paradigm is unsuitable, but you have to be careful not to sacrifice clarity for a dogmatic object-oriented design.

Languages like Lisp are on the other end of the spectrum, where there is basically no difference between the program and the data representation. Both share the same syntax and can be manipulated in much the same way. The functional programming paradigm makes the flow of data more visible. If it is not visible, there are probably side-effects that should be avoided.

Check out my Introduction to Lisp Languages if you want to learn more about Lisp languages.

Things vs Behavior

I want to give you a sense of what modeling your data to reduce friction between transformations could look like.

Imagine you are programming a role-playing game and you want to create a health potion that restores 10 hit points. A first approach might be to create the following class:

class HealthPotion10

Then you decide that you want to also create a big health potion that restores 50 hit points. Obviously it was a mistake to encode this information into the name of the type. Instead of creating another class, you add a field to the class that indicates how many hit points are restored, when the item is used:

class HealthPotion:
    int restorePoints

class ManaPotion:
    int restorePoints

You also decide to add mana potions. What would an RPG be without mana potions?

At this point you might be thinking of potions in general. You could create a Potion class and inherit HealthPotion and ManaPotion from it, but this path will lead you down a slippery slope. You would have to create classes for dozens of different potion types.

Another option would be to generalize and add a type field:

enum Type:
    HP, MP

class Potion:
    Type type
    int restorePoints

But what if there is a potion that restores 10 HP and 20 MP? Potions might also cure conditions, like poisoned. What if the object that restores HP is not even a potion but a sandwich?

If you consider the behavior of a class instead of what thing it represents, you might arrive at the following solution:

class Consumable:
    String description
    int restoresHP
    int restoresMP

The class tells the program to restore hit points or mana points. An object of this class tells the program how many HP and MP to restore, when it is used. Only the description knows that it is a "Small Potion", a "Big Potion", an "Apple", or a "Turkey". For the behavior of the class (restoring HP/MP) it is totally irrelevant what the item actually is.

This kind of design also leads to an easy representation of the data, i.e. in XML:

<consumable>
    <name>Potion</name>
    <hp>3</hp>
    <mp>8</mp>
</consumable>

The friction of transforming the class model into the XML model is low. Imagine how it would be if you had a large hierarchy of classes. The behavior is easy to customize and extend on the data level.

Process

The process or processor is what converts input data into output data. Processors can be combined to form a chain, if their input and output types match. They can also be combined by composition to form a new processor. This allows us to create abstractions.

In Parameters vs Data I discussed that parameters change the behavior of a processor, effectively creating different variants. The same applies to the composition of processors. I would argue that the configuration of a processor must be immutable for the lifetime of the processor. This means that a process should always return the same result, given the same input data.

It follows that a processor should have no side-effects. It is a side-effect if:

Besides having good data representations, it should be the goal to design processors in such a way that they are easy to test in isolation.

In addition, the integrity of a processor can be improved by testing the pre- and postconditions upon processor application.

Implementation

I see the IPO model as a way of approaching problems, a way of thinking, rather than a programming paradigm like functional programming or data-oriented programming.

That said, I want to attempt to give a more formalized notion of how input, output, and processors could be expressed in a programming language.

The programming language should have the following properties:

What follows are examples in C++ and Haskell that should illustrate some of these properties in code. You will see that neither language is perfectly suitable to model all of the properties that are listed above.

Implementation in C++

For the first example, I chose C++ because it is a versatile language that allows you to express ideas in different ways, depending on your goal.

Example A:

struct Processor
{
    Processor(const int n, const int m) : mul(n), add(m) {}

    Array operator() (const Array& array) const
    {
        return add(mul(array)); // mul -> add
    }

    const MultiplyByN mul;
    const AddByN add;
};

Processor{4, 2}({1, 2, 3, 4, 5, 6});

It is important to differentiate between data structures and processors. An array is a data structure with methods that provide access to the underlying memory. Thus, I wouldn't create a processor to get the size of an array, or the n-th element of a list, or the value of a hashmap. But I would consider writing a processor to sort an array.

Example B:

template<int N, int M>
Array Processor(const Array& array)
{
    AddByN<M>(MultiplyByN<N>(array)); // mul -> add
}

Processor<4, 2>({1, 2, 3, 4, 4, 5});

There is one technical difference between example A and example B that I want to draw your attention to.

Example A is using memory to store the parameters. This means that when the data is being processed, it reads the parameter from memory. This memory could technically be overwritten or corrupted.

Example B is compiling the parameters directly into the code which means that they are not read from memory when the data is processed. This comes at the cost of more code being generated for every different parameterization of the Processor.

Implementation in Haskell

For the second example, I chose Haskell because it is a pure functional programming language that is strictly typed. It should lend itself well to the IPO approach.

Example:

multiplyByN :: Integer -> [Integer] -> [Integer]
multiplyByN n xs = map (*n) xs

addByN :: Integer -> [Integer] -> [Integer]
addByN n xs = map (+n) xs

processor :: Integer -> Integer -> [Integer] -> [Integer]
processor n m xs = let mul = (multiplyByN n)
                       add = (addByN m)
                    in mul . add $ xs

main = let proc = (processor 4 2)
        in print (proc [1, 2, 3, 4, 5, 6])

Please note that the type signatures above the functions are optional in this example. I included them to show that it is possible to make them explicit.

I have implemented a complete and working example in just a few lines of Haskell code, and it has the same benefits of the more verbose C++ version, if not more. The pure functions in Haskell give you immutability by default. However, the parameterization of the processor behavior is not immediately obvious because of the functional style. I think the intent is more clearly expressed in C++.

Limits

There is one problem domain that cannot be easily addressed with the IPO model. The way that GUIs are implemented doesn't lend itself well to the data-oriented approach. You could argue that UI components and their state are data that is transformed when certain events occur. However, the event loop is continuous, and its hard to perceive the UI of an application as this big heterogeneous blob of state that is in constant change.

Coincidentally, this issue is also present in functional programming, where it seems to be solved in two different ways:

  1. The functional part of the software communicates with an imperative UI implementation through an interface that separates the functional and non-functional part (see Monads)
  2. Functional Reactive Programming is an attempt to model the discrete events and continuous behaviors of the UI

Conclusion

Nothing in this article is really new. It is just one perspective on software design that follows already well established principles and patterns. Some of them emerge as a consequence of the IPO model.

Since these types of systems seem to appear "naturally", they are easy for us to understand. There are many examples in software where the IPO model is used to break down and solve complex problems.

That is why I think that designing a system through the lense of the IPO model, and analyzing the flow of the data through the system, can give you a better understanding about the system itself.

I would argue that this forces you to model your data and processes in a way that meets the actual requirements, while having enough flexibility to adapt to new requirements over time.