Search code examples
rustfunctional-programmingioside-effects

Does Rust has a data type for encoding side effects as pure values?


I am doing some exercise from the book Grokking Functional Programming, the origin code examples are written in Scala, and I want to rewrite it with Rust and Rakulang.

In Scala's cats effect library, there is a type call IO, which is a data type for encoding side effects as pure values, capable of expressing both synchronous and asynchronous computations.

The following code print "hey!" twice:

import cats.effect.IO
import cats.effect.unsafe.implicits.global

object TestApp extends App {
  val program: IO[Unit] = for {
    _ <- IO.println("hey!")
    _ <- IO.println("hey!")
  } yield ()
  program.unsafeRunSync()
}

The code can be run in the Scala playground.

So how to do it with Rust?


Solution

  • Rust is not a functional language and doesn't have primitives or library facilities one would expect in a pure functional language. But we can make our own. How useful will they be in practice? Not much probably. Nevertheless it could be an enlightening exercise.

    Below is an implementation of an UI type that looks more or less like Haskell's and perhaps Scala's IO. I am not an experienced Rust user and the lifetimes are probably all over the place, but I hope you will find it useful anyway.

    use std::io;
    use std::io::Write;
    
    struct MyIo<'life, A: 'life> {
        run: Box<dyn FnOnce()->io::Result<A> + 'life>
    }
    
    fn unit_io<'life, A: 'life>(a: A)->MyIo<'life, A> {
        MyIo{run:Box::new(move||Ok(a))}
    }
    
    fn map_io<'life, A: 'life, B: 'life, Func: FnOnce(A)->B + 'life>(my_io: MyIo<'life, A>, func: Func) -> MyIo<'life, B>{
        MyIo{run:Box::new(move||
                match (my_io.run)() {
                    Ok(x) => Ok(func(x)),
                    Err(e) => Err(e)
                }
            )
        }
    } 
    
    fn bind_io<'life, A: 'life, B: 'life, Func: FnOnce(A)->MyIo<'life, B> + 'life>
        (my_io: MyIo<'life, A>, func: Func)->MyIo<'life, B> {
            MyIo{run:Box::new(move||
                match (my_io.run)() {
                    Ok(x) => (func(x).run)(),
                    Err(e) => Err(e)
                }
              )
            }
    }
    
    fn run_io<'life, A>(my_io: MyIo<'life, A>)->io::Result<A> {
        (my_io.run)()
    }
    

    A keen reader will recognise basic monadic operations unit and bind, and a functorial map, but you don't need to know what a monad is in order to use them. It is quite simple:

    - unit: take a normal value and pretend it's an IO operation (it does no IO, but an empty set is still a set)
    - map: take an IO operation that returns a value, and produce an IO operation that returns a transformed value
    - bind: chain two IO operations together
    

    With these primitives one can produce arbitrarily complex values that can do IO, without actually doing any IO while building them. When a value is built, run_io executes all its actions, chained one to another and intertwined with regular non-IO computations.

    These building blocks would be useless without having primitive operations that do actual IO. You can chain two operations all you want, but in the end of the day you need something that reads and writes files. Below are two simple operations that do that, wrapped in an IO type. Any IO (or, more generally, side-effecting) operation can be wrapped this way.

    fn my_read_string()->MyIo<'static, String> {
        MyIo{run: Box::new(move||std::io::read_to_string(io::stdin()))}
    }
    
    fn my_write_string(s: String)->MyIo<'static, ()> {
        MyIo{run: Box::new(move||(io::stdout().write_all(s.as_bytes())))}
    }
    

    Finally a test driver that exercises all of the above operations:

    fn concat_me(a: String)->String {
        format!("{}:{}\n", a, a)
    }
    
    fn main() {
    
        // 1: read a string from stdin, double it, write it back to stdout
        let input = my_read_string();
        let doppio = map_io(input, concat_me);
        let output = bind_io(doppio, my_write_string);
    
        // 2: write a constant string to stdout
        let s = "hello monad".into();
        let lifted = unit_io(s);
        let output2 = bind_io(lifted, my_write_string);
    
        // do 1 then 2
        let all_io = bind_io(output, move |_|output2);
    
        let _ = run_io(all_io);
    }
    

    The last line is the one responsible for doing all the IO.