the flaw with the simplest io

the flaw with the simplest io
Photo by Brina Blum / Unsplash

Picking up from where we left off, I wanted to next write about how to do bulk operations as part of io - but the purpose of defining the io library was to then simulate inlining the methods to create the C code.

Here's the problem:

input: Range = {start: 1, stop: 10}.
output: Array[Integer, 10].

// example:
(input map> [+ 1]) copy> output.

The call of input map> [+ 1] creates a MappingIterator which holds on to a block. We must disallow closures here because it could go out of scope - but that's not even the problem. The problem is with the inlining. What we want to produce in the end is this:

struct Range tmp1;
tmp1.start = 1;
tmp2.stop = 10;

int tmp2 = tmp1.start;
int tmp5 = 1;
loop:
  int tmp3 = input[tmp2];
  output[tmp5] = tmp3 + 1;
  if (tmp2 <= tmp1.stop) {
    tmp2++;
    goto loop;
  }

But we're already heading in the wrong direction. The inlining of map> would produce code that looks like this:

int anonymous_block(int tmp) { return tmp + 1 }

struct MappingIterator tmp1;
tmp1.target = input;
tmp1.op = anonymous_block;

We're adding all this extra indirection. Can we count on the compiler to remove it all when we're done? actually - no we cannot. It might be able to do it but we shouldn't expect it to. What we want to do is write code that maps directly to good code rather than introducing inefficiencies by the design of the libraries.

This is an important and key idea and one worth exploring as much as possible with STZ. Creating an object just to hold on to a point to a block that is already in our current context is a waste of steps and added complexity. The overhead is minimal at runtime - probably - but simply unnecessary.

So how do we create a composable io flow that doesn't have all these wasted steps? To put it simply, we cannot have a block stored in a variable, it needs to be passed to a method to trigger block inlining to happen. That means the method needs to do something with it rather than store it in an object.

The first thing we should do is take a step back from the abstractions and look at what the code is if we don't have an io abstraction layer like this:

input: Range = {start: 1, stop: 10}.
output: Array[Integer, 10].

input map> [+ 1] copy> output.

Pros: it's very simple and easy to understand (I hope).
Cons: it will produce inefficient code.

input: Range = {start: 1, stop: 10}.
output: Array[Integer, 10].

[output write: input read + 1] while: OK

Shockingly similar in almost every way, no actual extra implementation needed, puts the looping in to the hands of the developer. We could even flip it if we wanted it to look more like a left-to-right flow:

[input read + 1 into> output] while: OK

Pros: extremely concise.
Cons: precedence is not as clear.

The other gotcha here is + has to work with a Status parameter as the receiver and to simply pass that on instead of doing the + operation. That's do-able but is it good code?

And the other other gotcha - now when we consider how to do bulk do we increase complexity too much? what about when we're changing the data types, does that also become difficult?

And don't we need Readable and Writeable still? a regular Array or Range is not going to respond to read and write:.

input := "Hello World" reading.
output := Array[Integer, 10] writing.

[output write: input read as-code-point + 1] while: OK

Okay that seemed easy. Let's try doing sockets where we have to use a buffer and the amount of data we get back and amount of data we can write is variable.

input := 'localhost:2345' as-endpoint reading.
output := 'localhost:3456' as-endpoint writing.
buffer := RingBuffer[Integer, 4096].

[input read-into: buffer.
 output write-from: buffer]
   while: OK.

There's some tricky stuff going on here. RingBuffer knows how much data is currently in it, so read-into: can fill the remaining space. It will try to read as much data as it can from the socket and either fill or under-fill the buffer. Similarly, write-from: gets to be tricky too; it knows how much data is available in the buffer and will attempt to write it all.

Here's where this implementation falls down. The moment write-from: fails because it cannot write all the data in the buffer, the loop begins again - or it would if we accepted more than just OK. We attempt to read but that's a wasted operation (and may even block until timeout) if we haven't transmitted everything we need to yet.

Let's try again:

[
  // fill the buffer
  status := [input read-into: buffer] while: OutOfData.
  status != OK then: [return status].

  // process the data if we wanted to

  // transmit the buffer
  status := [output write-from: buffer] while: OutOfSpace.
  status != OK then: [return status].
] repeat

Where we could process the data is where we can filter, select, reject, map, anything else we might want to do with it. This gives us a pattern we can work with and even a method we can implement that accepts a block. That allows the block to be inlined.

IOStatus :: {OK, OutOfData, OutOfSpace, AtEnd}.

[dst copy: src process: transform buffer: buffer]
  [dst: Writeable[dst element-class],
   src: Readable[dst element-class],
   transform: (RingBuffer[dst element-class] -> ø) = [:buffer | ],
   buffer: RingBuffer[dst element-class, 1024]
   -> return: IOStatus |
     // fill the buffer
     status := [input read-into: buffer] while: OutOfData.
     status != OK then: [return status].

     // process the buffer
     transform evaluate: buffer.

     // transmit the buffer
     status := [output write-from: buffer] while: OutOfSpace.
     status != OK then: [return status].
      
     dst copy: src].

That works and would be efficient and what we'd want to do. We can apply operations easily to the buffer such as [map: [+ 1]] which would update the values in the buffer in-place, or [filter: [is-even]] which would (potentially) compact the values and reduce the size freeing up space in the ring buffer.

What we're missing here is when src element-class and dst element-class aren't the same. We also have to pass in the buffer. We can fix this by changing the Readable and Writeable to be buffered variants. That has the advantage that we can pass it around and utilise the buffer if we want to; or not.

BufferedSocket :: {socket: Socket, buffer: RingBuffer[Byte, 1024]}.

We also need to use the processing block to copy the values from source buffer to destination buffer. Things are getting a little sticky feeling again. Let's revisit this again in the next post. We should add common variants on top of process for the sake of sanity. With that, for the moment we're now here:

input := {Range | start: 1, stop: 10} reading.
output: Array[Integer, 10] writing.

output copy: input map: [+ 1].

It's interesting to see this in action. I wanted map> and copy> to make things composable, extensible, but the reality is we'd want to merge these things together to make them performant.

How many variants would there be anyway? Let's see if we can't figure that out. Assuming we can always choose to use process: instead of one of the shortcuts. We can also flip the API around so we get left-to-right looking flow:

src do: block.
src filter: [is-even] do: block.
src filter: [is-even] map: [+ 1] do: block.
src map: [+ 1] filter: [is-even] do: block.
src map: [+ 1] do: block.
src copy: dst.
src filter: [is-even] copy: dst.
src filter: [is-even] map: [+ 1] copy: dst.
src map: [+ 1] filter: [is-even] copy: dst.
src map: [+ 1] copy: dst.

Despite having source on the left hand side when calling copy: which does feel a bit odd to me, the rest of it looks quite sane. There would be more API of course but all of this ends up calling process:do: or process:copy:.

An example we've used previously in posts:

people where> [age ≥ 18] do> stdout.

becomes:

people filter: [age ≥ 18] copy: stdout.

A caveat here is that *:copy: will also accept a string as a parameter, or any object that can be printed as a string, eg: &Printable.

We could potentially simplify this by having it also accept a block and using the word into: instead of copy: or do:.

people filter: [age ≥ 18] into: stdout.
input map: [+ 1] into: output.

Let's figure out where the buffers belong in the next post. We've now created an API that is extensible but also maps avoids abstracting concepts too far away from sensible code.