NovelIO


Motivation

Referential Transparency

In functional programming, referential transparency is a very useful property, an expression which is referentially transparent can be replaced by its value without affecting the behaviour of the program.

Consider:

let x = 2 * 2

The value x, 4 and 2*2 are all completely interchangeable, any of these descriptions anywhere in our program are all equally valid and do not change its behaviour.

Now consider:

let rnd = System.Random()
let y = rnd.Next()

The value y does not have this property. We cannot freely interchange y and rnd.Next() without changing the behaviour of the program, this is because rnd.Next is not referentially transparent.

Referential transparency, however, is a very useful property, all functions in mathematics are referentially transparent and this helps us to reason about the behaviours of equations. In computer science, this is no different, referential transparency makes it easier to prove correctness and to avoid bugs (especially in concurrent and parallel code).

If we express this logic again using IO, we can restore referential transparency:

let yPure = Random.nextIO

Once again, we can freely replace yPure and Random.nextIO wherever they appear in our program. They both have precisely the same meaning, namely: an action which, when run, gets the next number from the global random number generator.

As mentioned in the introduction, IO.run is the only non-referentially transparent function exposed by this library and, as such, should be used sparingly!

Side-effects and lazy evaluation

In general, writing code that combines side effects and lazy evaluation can be complex and error prone, the developer can often be left with little idea when effects will actually be triggered.

Consider a program that gets some keystrokes:

let keys = Seq.initInfinite (fun _ -> System.Console.ReadKey())

let untilEnter = 
    keys |> Seq.takeWhile (fun ki -> ki.Key <> System.ConsoleKey.Enter)

printfn "Length: %d" (Seq.length untilEnter)

untilEnter
|> Seq.map(fun ki -> string <| ki.KeyChar)
|> String.concat ""
|> printfn "String: %s"

At first glance, this program might appear to record key strokes until the user presses 'Enter', print the length and then print the result. Of course, it does not.

In reality, this program counts key strokes until the user presses 'Enter' and prints this length, then it records key strokes again until the user presses 'Enter' and prints the result.

If we express this program using this library, the effects are clearly apparent:

let keysIO = 
    Console.readKey 
    |> IO.Loops.unfoldWhileM(fun ki -> ki.Key <> System.ConsoleKey.Enter)

We can then choose to perform the action twice (as before):

io {
    let! sequence1 = keysIO
    do! IO.putStrLn <| sprintf "Length: %d" (Seq.length sequence1)
    let! sequence2 = keysIO
    do! sequence2
        |> Seq.map(fun ki -> string <| ki.KeyChar)
        |> String.concat ""
        |> sprintf "String: %s"
        |> IO.putStrLn
} |> IO.run

Or execute the action only once:

io {
    let! sequence = keysIO
    do! IO.putStrLn <| sprintf "Length: %d" (Seq.length sequence)
    do! sequence
        |> Seq.map(fun ki -> string <| ki.KeyChar)
        |> String.concat ""
        |> sprintf "String: %s"
        |> IO.putStrLn
} |> IO.run

In both cases, we have successfully made our decision explicit.

Random numbers

Sequences of random numbers are another area where side-effects can be particularly devastating while being produced by code that looks innocuous.

Consider this code:

let randomSeq = Seq.init 4 (fun _ -> rnd.Next())
let sortedSeq = Seq.sort randomSeq

printfn "Sorted: %A" sortedSeq
printfn "Random: %A" randomSeq

Let's at the results of an example run of this program:

Sorted: seq [42595606; 980900814; 1328311795; 1497661916] Random: seq [308839936; 1514073672; 36105878; 741971034]

While this program appears to generate one sequence, sort it, then print the sorted and unsorted result - that isn't what it actually does. What it actually does is effectively define two random sequence generators, one of which is sorted and the other is not.

Each time we enumerate randomSeq or sortedSeq, the original side effect of getting random numbers is produced again and again!

Here is the original program we desired to write using NovelIO.

io {
    let randomSeqIO = IO.replicateM (Random.nextIO) 4
    let! randomSeq = randomSeqIO
    let sortedSeq = Seq.sort randomSeq
    do! IO.putStrLn <| sprintf "Sorted: %A" sortedSeq
    do! IO.putStrLn <| sprintf "Random: %A" randomSeq
    } |> IO.run

Sorted: seq [75121301; 124930198; 609009994; 824551074] Random: [824551074; 609009994; 75121301; 124930198]

Notice that now, both sequences contain the same values. The generation of actual random numbers is triggered by the line let! randomSeq = randomSeqIO which makes the effect completely explicit.

In order to get our program to behave like the original one that uses a sequence with side effects, we have to explicitly ask for a second set of evaluated effects.

io {
    let randomSeqIO = IO.replicateM (Random.nextIO) 4
    let! randomSeq = randomSeqIO
    let sortedSeq = Seq.sort randomSeq // sort the first set
    let! randomSeq2 = randomSeqIO // evaluate the effects of randomSeqIO again
    do! IO.putStrLn <| sprintf "Sorted: %A" sortedSeq
    do! IO.putStrLn <| sprintf "Random: %A" randomSeq2
    } |> IO.run

Sorted: seq [79034179; 1625119183; 1651455963; 1775638512] Random: [1801985798; 963004958; 1819358047; 292397249]

So, using this approach we can easily describe either behaviour while still keeping the intent clear and explicit.

namespace NovelFS
namespace NovelFS.NovelIO
val x : int

Full name: Motivation.x
val rnd : System.Random

Full name: Motivation.rnd
namespace System
Multiple items
type Random =
  new : unit -> Random + 1 overload
  member Next : unit -> int + 2 overloads
  member NextBytes : buffer:byte[] -> unit
  member NextDouble : unit -> float

Full name: System.Random

--------------------
System.Random() : unit
System.Random(Seed: int) : unit
val y : int

Full name: Motivation.y
System.Random.Next() : int
System.Random.Next(maxValue: int) : int
System.Random.Next(minValue: int, maxValue: int) : int
val yPure : IO<int>

Full name: Motivation.yPure
module Random

from NovelFS.NovelIO
val nextIO : IO<int>

Full name: NovelFS.NovelIO.Random.nextIO
val keys : seq<System.ConsoleKeyInfo>

Full name: Motivation.keys
module Seq

from Microsoft.FSharp.Collections
val initInfinite : initializer:(int -> 'T) -> seq<'T>

Full name: Microsoft.FSharp.Collections.Seq.initInfinite
type Console =
  static member BackgroundColor : ConsoleColor with get, set
  static member Beep : unit -> unit + 1 overload
  static member BufferHeight : int with get, set
  static member BufferWidth : int with get, set
  static member CapsLock : bool
  static member Clear : unit -> unit
  static member CursorLeft : int with get, set
  static member CursorSize : int with get, set
  static member CursorTop : int with get, set
  static member CursorVisible : bool with get, set
  ...

Full name: System.Console
System.Console.ReadKey() : System.ConsoleKeyInfo
System.Console.ReadKey(intercept: bool) : System.ConsoleKeyInfo
val untilEnter : seq<System.ConsoleKeyInfo>

Full name: Motivation.untilEnter
val takeWhile : predicate:('T -> bool) -> source:seq<'T> -> seq<'T>

Full name: Microsoft.FSharp.Collections.Seq.takeWhile
val ki : System.ConsoleKeyInfo
property System.ConsoleKeyInfo.Key: System.ConsoleKey
type ConsoleKey =
  | Backspace = 8
  | Tab = 9
  | Clear = 12
  | Enter = 13
  | Pause = 19
  | Escape = 27
  | Spacebar = 32
  | PageUp = 33
  | PageDown = 34
  | End = 35
  ...

Full name: System.ConsoleKey
field System.ConsoleKey.Enter = 13
val printfn : format:Printf.TextWriterFormat<'T> -> 'T

Full name: Microsoft.FSharp.Core.ExtraTopLevelOperators.printfn
val length : source:seq<'T> -> int

Full name: Microsoft.FSharp.Collections.Seq.length
val map : mapping:('T -> 'U) -> source:seq<'T> -> seq<'U>

Full name: Microsoft.FSharp.Collections.Seq.map
Multiple items
val string : value:'T -> string

Full name: Microsoft.FSharp.Core.Operators.string

--------------------
type string = System.String

Full name: Microsoft.FSharp.Core.string
property System.ConsoleKeyInfo.KeyChar: char
module String

from Microsoft.FSharp.Core
val concat : sep:string -> strings:seq<string> -> string

Full name: Microsoft.FSharp.Core.String.concat
val keysIO : IO<System.ConsoleKeyInfo list>

Full name: Motivation.keysIO
module Console

from NovelFS.NovelIO
val readKey : IO<System.ConsoleKeyInfo>

Full name: NovelFS.NovelIO.Console.readKey
Multiple items
module IO

from NovelFS.NovelIO

--------------------
type IO<'a> =
  private | Return of 'a
          | SyncIO of (unit -> IO<'a>)
          | AsyncIO of Async<IO<'a>>

Full name: NovelFS.NovelIO.IO<_>
module Loops

from NovelFS.NovelIO.IO
val unfoldWhileM : p:('a -> bool) -> f:IO<'a> -> IO<'a list>

Full name: NovelFS.NovelIO.IO.Loops.unfoldWhileM
val io : IO.IOBuilder

Full name: NovelFS.NovelIO.IOBuilders.io
val sequence1 : System.ConsoleKeyInfo list
val sprintf : format:Printf.StringFormat<'T> -> 'T

Full name: Microsoft.FSharp.Core.ExtraTopLevelOperators.sprintf
val sequence2 : System.ConsoleKeyInfo list
val run : io:IO<'a> -> 'a

Full name: NovelFS.NovelIO.IO.run
val sequence : System.ConsoleKeyInfo list
val randomSeq : seq<int>

Full name: Motivation.randomSeq
val init : count:int -> initializer:(int -> 'T) -> seq<'T>

Full name: Microsoft.FSharp.Collections.Seq.init
val sortedSeq : seq<int>

Full name: Motivation.sortedSeq
val sort : source:seq<'T> -> seq<'T> (requires comparison)

Full name: Microsoft.FSharp.Collections.Seq.sort
val randomSeqIO : IO<int list>
val replicateM : mFunc:IO<'a> -> n:int -> IO<'a list>

Full name: NovelFS.NovelIO.IO.replicateM
val randomSeq : int list
val sortedSeq : seq<int>
val randomSeq2 : int list
Fork me on GitHub