First Steps with IO

Raffiot is available as a pip package (and soon conda). For now just type this in a terminal:

$ pip install -U raffiot

This guide will teach you how to use Raffiot by exploring most of its features via the Python interactive shell (also known as Python's REPL):

$ python
Python 3.9.1 (default, Dec 13 2020, 11:55:53) 
[GCC 10.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>>

Start by importing Raffiot:

>>> from raffiot import *

Hello World!

Let's start by the classic "Hello World!":

>>> main : IO[None,None,None] = io.defer(print, "Hello World!")

As you can see, nothing is printed yet! The defer function delays the execution of print("Hello World!") until the the value main is run.

Very important: a value of type IO, like main, is the description of some computation, very much like the text of a Python script. Nothing is executed until the (value of type) IO is actually run, very much like the code of Python script is only executed when this script is run.

Inspecting the value main, gives you:

>>> main
Defer((<built-in function print>, ('Hello World!',), {}))

Running an IO is very simple! Just call its run method like:

>>> main.run(None)
Hello World!
Ok(success=None)
>>> main.run(None)
Hello World!
Ok(success=None)

As you can see, every call to run printed Hello World! and returned the value Ok(None). Ok means the computation was successful, None is the return value of computation.

defer : doing something later

The first argument of defer is the function you want to call later. The following arguments are the function's normal arguments. For example, to call datetime.now() later, just create the IO:

>>> from datetime import *
>>> now : IO[None,None,datetime] = io.defer(datetime.now)

Every time you run it, it will call datetime.now() and give you its result:

>>> now.run(None)
Ok(success=datetime.datetime(2021, 2, 19, 18, 38, 42, 572766))
>>> now.run(None)
Ok(success=datetime.datetime(2021, 2, 19, 18, 38, 47, 896153))

In the type IO[R,E,A], A is the type of values returned when the computation is successful. now being of type IO[None,None,datetime], it returns values of type datetime.

Likewise, you can define the print_io function that prints its arguments later:

>>> def print_io(*args, **kwargs) -> IO[None, None, None]:
...     return io.defer(print, *args, **kwargs)

Note that calling print_io("Hello") will not print anything but return an IO. To actually print Hello you need to run the IO:

>>> print_io("Hello", "World", "!")
Defer((<built-in function print>, ('Hello', 'World', '!'), {}))
>>> print_io("Hello", "World", "!").run(None)
Hello World !
Ok(success=None)

This ability to represent any computation as a value is one of the main strength of an IO. It means you can work with computation like any other value: composing them, storing them in variables, in lists, etc.

then : doing something sequentially.

You will often need to execute some IO sequentially. The method then compose some values of type IO, running them one by one. The return value is the one of the last IO:

>>> main : IO[None,None,datetime] = print_io("First line").then(
...   print_io("Second line"),
...   print_io("Third line"),
...   now
... )
>>> main.run(None)
First line
Second line
Third line
Ok(success=datetime.datetime(2021, 2, 20, 15, 52, 11, 205845))

You may sometimes prefer the analogous function io.sequence that behaves like the method then.

map : transforming results.

You can transform the return value of an IO using the map method. It is very similar to the map function on lists, but works on IO. Just provide map some function. It will use this function to transform the IO return value:

>>> date_str : IO[None,None,str] = now.map(lambda d: d.isoformat())
>>> date_str
Map((Defer((<built-in method now of type object at 0x7ff733070bc0>,
 (), {})), <function <lambda> at 0x7ff7338d9280>))
>>> date_str.run(None)
Ok(success='2021-02-19T23:54:46.297503')

flat_map : chaining IOs.

map transform the return value of an IO. So transforming the return value of date_str with print_io will give you an IO whose return value is also an IO. When you will run it, instead if executing the inner IO, it will return it to you:

>>> main : IO[None,None,IO[None,None,None]] = date_str.map(lambda x: print_io(x))
>>> main.run(None)
Ok(success=Defer((<built-in function print>, ('2021-02-20T15:54:38.444494',), {})))

When you want to use the result of some IO into some other IO, use flat_map:

>>> main: IO[None,None,None] = date_str.flat_map(lambda x: print_io(x))
>>> main.run(None)
2021-02-20T15:55:13.940717
Ok(success=None)

Here the return value of date_str is given to print_io via x and both IO are executed, returning the result of print_io(x).

flatten : concatenating an IO of IO.

Instead of having used flat_map, you could have used map and then flatten to reduce the IO of IO into a single layer of IO:

>>> main : IO[None,None,None]= date_str.map(lambda x: print_io(x)).flatten()
>>> main.run(None)
2021-02-20T15:58:44.244715
Ok(success=None)

Most of the time, you will use flat_map because it is simpler to use. But now you know where its name comes from: flatten of map.

pure : just a value.

pure is very simple: the result of the computation is the very same argument of pure:

>>> main : IO[None,None,int] = io.pure(5)
>>> main
Pure(5)
>>> main.run(None)
Ok(success=5)

It is very useful when some functions/method expect some IO but you want to provide a constant.

defer_io : computing an IO later.

defer_io is very much like defer but for functions returning IO:

>>> main : IO[None,None,IO[None,None,None]] = io.defer(print_io, "Hello", "World", "!")
>>> main.run(None)
Ok(success=Defer((<built-in function print>, ('Hello', 'World', '!'), {})))
>>> main : IO[None,None,None] = io.defer_io(print_io, "Hello", "World", "!")
>>> main.run(None)
Hello World !
Ok(success=None)

Like flat_map is faltten of map, defer_io is flatten of defer. It is useful to defer the call of function returning IO.

Use Case: Stack-Safety

Let's see one of the main feature of IO: it is stack safe! When you run the following function in Python, even if the argument times is small, the computation will fail miserably because it blew the stack:

>>> def print_date(times: int) -> None:
...   if times > 0:
...     d = datetime.now()
...     print(d.isoformat())
...     print_date(times - 1)
>>> print_date(1000)
<LOTS OF DATES>
2021-02-20T16:20:37.188880
2021-02-20T16:20:37.188883
2021-02-20T16:20:37.188886
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 5, in print_date
  File "<stdin>", line 5, in print_date
  File "<stdin>", line 5, in print_date
  [Previous line repeated 992 more times]
  File "<stdin>", line 4, in print_date
RecursionError: maximum recursion depth exceeded while calling a Python object
2021-02-20T16:20:37.188889

On the contrary, the equivalent function using IO will never blew the stack, even for very high values of times:

>>> def print_date_io(times: int) -> IO[None, None, None]:
...   if times > 0:
...     return (
...       now
...       .flat_map(lambda d: print_io(d.isoformat()))
...       .flat_map(lambda _: print_date_io(times - 1))
...     )
...   else
...     return io.pure(None)
>>> print_date(1000000)
<LOTS OF DATES>
2021-02-20T16:23:22.968454
2021-02-20T16:23:22.968464
Ok(success=None)

With IO, you can use recursion without fear! Just remember to wrap your computation in an IO (using defer, defer_io, map, flat_mapand others) to benefit from IO's safety.