NovelIO


Introduction

NovelIO is a library designed to bring the explicit safety and robustness that comes with describing effects in the type system to the .NET framework. The result is a purely functional approach to describing I/O operations whereby functions do not perform side-effects but rather construct values that represent a sequence of effects that can later be executed.

The primary goal of this library is to help developers to design more maintainable and testable code by making it easier to reason about when I/O operations occur.

Much like in Haskell, we introduce the IO<'a> type which represents some action that, when performed successfully, returns some result 'a. Here are some examples:

  • An IO action that prints the string "Hello World" to the screen has type IO<unit>.
  • An IO action that gets a line of text from the Console has type IO<string>.
  • An IO action that opens a TCP connection has type IO<TCPConnectedSocket>.
  • An IO action that launches some missiles has type IO<unit>

The IO action can equally represent an arbitrary sequence of actions:

  • An IO action that requests a Name, then that person's Date of Birth from a service might have type IO<string, DateTime>
  • An IO action that returns a potentially unknown number of lines from a file might have type IO<string list>

Indeed an entire web server could be represented as a single value of type IO<unit>!

The key distinction between this representation of effects and the side-effects found in imperative programming is that values of type IO<'a> do not represent the result of some side effect, they actually represent the action/effect that can be run to produce a particular result.

The idea of actions being values rather than functions is extremely powerful, it allows us to begin with a small set of orthogonal primitive actions that can be passed around and composed, using combinator functions, to build up to advanced behaviours quickly, easily and safely!

Running IO Actions

IO<'a> Actions can be run using the IO.run function. This results in all of the side-effects being evaluated and the generation of a result of type 'a.

These values can then be re-used and run again to evaluate the side-effects once more.

Consider a standard .NET impure IO example:

let exmpl = System.Console.ReadLine()
printfn "%s" exmpl
printfn "%s" exmpl

And a NovelIO example:

let exmpl2 = io {return! Console.readLine}
printfn "%s" (IO.run exmpl2)
printfn "%s" (IO.run exmpl2)

Take careful note of the different behaviour.

In the first example exmpl represents the result of the user input from the console, we perform that side effect only once and print the same value to the console twice.

In the second example exmpl2 represents the action of reading user input from the console and running it gives us the result. Hence, in this case, the user is prompted for input twice and potentially different results are printed.

IO.run is the only non-referentially transparent function exposed to the user in this library and is equivalent to unsafePerformIO in Haskell. Since F# is fundamentally still an impure language, it is up to the developer how often they wish to make use of it.

It is possible (albeit certainly not recommended!) to call IO.run on every small block of IO code. It is also possible to call IO.run only once, in the main function, for an entire program: it is then possible to visualise the running of a program as the only effectful part of otherwise pure, referentially transparent code.

Compositionality

One of the key attractions of this representation of IO is that we can design IO actions and then compose them together to build new and more complex IO actions.

Let's assume for a moment that we wish to read two lines from the console and return them as a tuple. That can be achieved as follows:

let readTwoLines = 
    io {
        let! line1 = Console.readLine
        let! line2 = Console.readLine
        return line1, line2
    }

let! is used to request the result of an IO action when the enclosing action is run.

Notice that we have taken two primitive IO actions of type IO<string> and used them to construct a new IO action of type IO<string*string>

Likewise, if we wished to write two lines to the console, we could construct an action like this:

let writeTwoLines line1 line2 = 
    io {
        do! Console.writeLine line1
        do! Console.writeLine line2
    }

do! is used to evaluate an IO action of type unit when the enclosing action is run.

In this case, we have taken two IO actions of type IO<unit> and created a new action of type IO<unit>.

Loops

A common task during I/O operations is to perform some action until a condition is met. There are a variety of combinators to help with this sort of task:

Let's assume we wish to print the numbers 1..10 to the console. One way of doing this would be:

let print1To10 = 
    IO.iterM (fun i -> Console.writeLine <| string i) [1..10]
    // The lambda in the above could be replaced by (Console.writeLine << string)

The iterM function is used to define for loops in the IO monad. The below code is completely equivalent to the above:

let print1To10For = 
    io {
        for i in [1..10] do
            do! Console.writeLine <| string i
    }

A common task in File IO is performing a loop to retrieve lines from a file until you reach the end of the file.

In this case, we can't use a simple for loop as we did previously because the logic for checking the loop end condition is also side effecting! Fortunately, we have another function for this occassion:

let readFileUntilEnd path =
    File.withTextChannel File.Open.defaultRead path (fun channel ->
        IO.Loops.untilM (TextChannel.isEOF channel) (TextChannel.getLine channel))

The withTextChannel encapsulates the lifetime of the text channel, accepting as an argument a function where we make use of the channel.

In this function, we use the untilM combinator, its first argument is an IO<bool> condition and its second is an action to perform while the condition is false.

It runs a list of all the results we generated from the action argument while the condition was false.

Parallel and Asychronous IO

Forking IO actions

If you wish to perform some IO on another thread then forkIO is the function of choice. It simply performs the work on the .NET thread pool and doesn't ever return a result.

io {
    do! IO.forkIO someAction
}

If you wished to perform a task and then retrieve the results later, you would need to use forkTask and awaitTask.

io {
    // create a task that generates some random numbers on the thread pool
    let! task = IO.forkTask <| IO.replicateM Random.nextIO 100
    // await the completion of the task (await Task waits asychronously, it will not block threads)
    let! results = IO.awaitTask task
    return results
}

Parallel actions

Entire lists of IO actions can be performed in parallel using the functions in the IO.Parallel module. This gives us very explicit, fine-grained, control over what actions should take place in parallel.

In order to execute items in parallel, we can simply build a list of the IO actions we wish to perform and use the IO.Parallel.sequence combinator.

Aside: You may notice that IO.Parallel.sequence has exactly the same type signature as the IO.sequence function. These two functions are fundamentally very similar, the only difference is that IO.sequence joins a list of actions sequentially and IO.Parallel.sequence joins a list of actions in parallel.

For example:

io {
    let fName = File.Path.fromValid "file.txt"
    let! channel = File.openTextChannel File.Open.defaultRead fName
    return IO.Parallel.sequence [Console.readLine; TextChannel.getLine channel]
} |> IO.run

This describes a program that gets a line from the console and a line from a specified file in parallel and returns them in a list of strings.

Bringing impure functions into IO

It's very likely that the set of functions included in this library will not cover every possible IO action we might ever wish to perform. In this case, we can use the IO.fromEffectful to take a non-referentially transparent function and bring it within IO.

Here is an example of creating a simple IO action that increments a reference variable.

let num = ref 0
let incrIO = IO.fromEffectful (fun _ -> incr num)

This allows us to construct arbitrary programs entirely within IO.

namespace NovelFS
namespace NovelFS.NovelIO
val someAction : IO<unit>

Full name: Index.someAction
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<_>
val return' : x:'a -> IO<'a>

Full name: NovelFS.NovelIO.IO.return'
val exmpl : string

Full name: Index.exmpl
namespace System
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.ReadLine() : string
val printfn : format:Printf.TextWriterFormat<'T> -> 'T

Full name: Microsoft.FSharp.Core.ExtraTopLevelOperators.printfn
val exmpl2 : IO<string>

Full name: Index.exmpl2
val io : IO.IOBuilder

Full name: NovelFS.NovelIO.IOBuilders.io
module Console

from NovelFS.NovelIO
val readLine : IO<string>

Full name: NovelFS.NovelIO.Console.readLine
val run : io:IO<'a> -> 'a

Full name: NovelFS.NovelIO.IO.run
val readTwoLines : IO<string * string>

Full name: Index.readTwoLines
val line1 : string
val line2 : string
val writeTwoLines : line1:string -> line2:string -> IO<unit>

Full name: Index.writeTwoLines
val writeLine : str:string -> IO<unit>

Full name: NovelFS.NovelIO.Console.writeLine
val print1To10 : IO<unit>

Full name: Index.print1To10
val iterM : mFunc:('a -> IO<'b>) -> sequ:seq<'a> -> IO<unit>

Full name: NovelFS.NovelIO.IO.iterM
val i : int
Multiple items
val string : value:'T -> string

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

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

Full name: Microsoft.FSharp.Core.string
val print1To10For : IO<unit>

Full name: Index.print1To10For
val readFileUntilEnd : path:FilePath -> IO<string list>

Full name: Index.readFileUntilEnd
val path : FilePath
module File

from NovelFS.NovelIO
val withTextChannel : options:FileOpenOptions -> fName:FilePath -> fChannel:(TChannel -> IO<'a>) -> IO<'a>

Full name: NovelFS.NovelIO.File.withTextChannel
module Open

from NovelFS.NovelIO.File
val defaultRead : FileOpenOptions

Full name: NovelFS.NovelIO.File.Open.defaultRead
val channel : TChannel
module Loops

from NovelFS.NovelIO.IO
val untilM : pAct:IO<bool> -> f:IO<'a> -> IO<'a list>

Full name: NovelFS.NovelIO.IO.Loops.untilM
module TextChannel

from NovelFS.NovelIO
val isEOF : channel:TChannel -> IO<bool>

Full name: NovelFS.NovelIO.TextChannel.isEOF
val getLine : channel:TChannel -> IO<string>

Full name: NovelFS.NovelIO.TextChannel.getLine
val forkIO : io:IO<'a> -> IO<unit>

Full name: NovelFS.NovelIO.IO.forkIO
val task : System.Threading.Tasks.Task<int list>
val forkTask : io:IO<'a> -> IO<System.Threading.Tasks.Task<'a>>

Full name: NovelFS.NovelIO.IO.forkTask
val replicateM : mFunc:IO<'a> -> n:int -> IO<'a list>

Full name: NovelFS.NovelIO.IO.replicateM
module Random

from NovelFS.NovelIO
val nextIO : IO<int>

Full name: NovelFS.NovelIO.Random.nextIO
val results : int list
val awaitTask : task:System.Threading.Tasks.Task<'a> -> IO<'a>

Full name: NovelFS.NovelIO.IO.awaitTask
val fName : FilePath
module Path

from NovelFS.NovelIO.File
val fromValid : path:string -> FilePath

Full name: NovelFS.NovelIO.File.Path.fromValid
val openTextChannel : options:FileOpenOptions -> fName:FilePath -> IO<TChannel>

Full name: NovelFS.NovelIO.File.openTextChannel
module Parallel

from NovelFS.NovelIO.IO
val sequence : ios:IO<'a> list -> IO<'a list>

Full name: NovelFS.NovelIO.IO.Parallel.sequence
val num : int ref

Full name: Index.num
Multiple items
val ref : value:'T -> 'T ref

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

--------------------
type 'T ref = Ref<'T>

Full name: Microsoft.FSharp.Core.ref<_>
val incrIO : IO<unit>

Full name: Index.incrIO
val fromEffectful : f:(unit -> 'a) -> IO<'a>

Full name: NovelFS.NovelIO.IO.fromEffectful
val incr : cell:int ref -> unit

Full name: Microsoft.FSharp.Core.Operators.incr
Fork me on GitHub