NovelIO


Introduction from an Object-Oriented Perspective

For those coming from an OOP background, the purpose of purely functional I/O might not seem immediately apparent but in many cases, what would be regarded as "good practise" in the object oriented world is something we can simply have by construction using referentially transparent IO.

Inversion of Control

In the OO world, you might design an interface to retrieve some resource. Alongside it, you might design a processing class with that interface as an injected dependency. You call a method on that interface in order to retrieve the data to process. One implementation of that interface might touch the file system and then a mock implementation would return test data for unit testing purposes.

A simple example might look something like this:

type IResourceSupplier =
    abstract member GetList : unit -> int list

You can then pass the IResourceSupplier implementation to its consumers.

Imagine we wished to add one to every number retrieved by our resource supplier so we decide to use a constructor injected dependency.

type Adder(supplier : IResourceSupplier) =
    member this.AddOne = List.map ((+) 1) (supplier.GetList())

This class is now pretty easy to test, we just use a custom IResourceSupplier implementation and we can test the logic that the Adder class performs in isolation of its dependency.

Of course, we've had to add extra boilerplate to do this and this is just a trivial example. I think it's also fair to say that those uninitiated to the world of object oriented programming probably wouldn't start by structuring their code in this way. Logically and intuitively, it makes more sense to think of getting a resource and passing it to a processing function.

We can actually maintain both the logical ordering and the testability by simply lifting a pure processing function into IO.

// this pure function works on a standard lists, it's trivial to unit test
let addOneToList lst = List.map ((+) 1) lst 

// this function is just the above function made to operate on lists in IO using map.
let addOneToIOList lstIO = IO.map (addOneToList) lstIO

// alternatively, we can write the above in operator form:
let addOneToIOList' lstIO = addOneToList <!> lstIO

IO.map can take any function of the form 'a -> 'b and return an IO<'a> -> IO<'b>, allowing our previously pure function to easily operate within IO.

This approach is simple, practical and retains all of the testability of the Inversion of Control based approach.

We can also see through the type system which function performs IO and which does not. That's a massive win for both readability and maintenance!

Command-Query Seperation

Command-query seperation (CQS) is an imperative programming principle that says that each method should either be a Command or a Query.

  • Commands perform side effects but return no data, in F# terms they return unit.

  • Queries return data to the caller, they must be referentially transparent (i.e. possess no side-effects).
type ExampleClass =
    /// Performs the side effect of writing text to the screen
    member this.Command() = printfn "Hello World!"
    /// Pure function that raises x to the power of 3
    member this.Query x = pown x 3 

CSQ has a laudable objective, to make it easier to reason about the way code behaves.

Any time we see unit we know that an effect is happening and any time we see a value returned, we know we have no side-effects. Unfortunately, this pattern forbids common patterns like random.Next() which are ubiquitous in OO language standard library APIs.

Now let's express these using NovelIO:

let exampleIO = Console.writeLine "Hello World!"

let query x = pown x 3

This looks very much the same as what we had before, exampleCommand is now of type IO<unit> instead of unit -> unit. But now lets look at the random example that we couldn't solve neatly using CQS:

let randomIO = Random.nextIO

randomIO here has type IO<int>. That provides a strong and clear distinction from the type of query which has type int -> int.

You can therefore think of referentially transparent IO as a more powerful version of CQS.

namespace NovelFS
namespace NovelFS.NovelIO
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 Operators

from NovelFS.NovelIO.IO
type IResourceSupplier =
  interface
    abstract member GetList : unit -> int list
  end

Full name: Oopintro.IResourceSupplier
abstract member IResourceSupplier.GetList : unit -> int list

Full name: Oopintro.IResourceSupplier.GetList
type unit = Unit

Full name: Microsoft.FSharp.Core.unit
Multiple items
val int : value:'T -> int (requires member op_Explicit)

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

--------------------
type int = int32

Full name: Microsoft.FSharp.Core.int

--------------------
type int<'Measure> = int

Full name: Microsoft.FSharp.Core.int<_>
type 'T list = List<'T>

Full name: Microsoft.FSharp.Collections.list<_>
Multiple items
type Adder =
  new : supplier:IResourceSupplier -> Adder
  member AddOne : int list

Full name: Oopintro.Adder

--------------------
new : supplier:IResourceSupplier -> Adder
val supplier : IResourceSupplier
val this : Adder
member Adder.AddOne : int list

Full name: Oopintro.Adder.AddOne
Multiple items
module List

from Microsoft.FSharp.Collections

--------------------
type List<'T> =
  | ( [] )
  | ( :: ) of Head: 'T * Tail: 'T list
  interface IEnumerable
  interface IEnumerable<'T>
  member GetSlice : startIndex:int option * endIndex:int option -> 'T list
  member Head : 'T
  member IsEmpty : bool
  member Item : index:int -> 'T with get
  member Length : int
  member Tail : 'T list
  static member Cons : head:'T * tail:'T list -> 'T list
  static member Empty : 'T list

Full name: Microsoft.FSharp.Collections.List<_>
val map : mapping:('T -> 'U) -> list:'T list -> 'U list

Full name: Microsoft.FSharp.Collections.List.map
abstract member IResourceSupplier.GetList : unit -> int list
val addOneToList : lst:int list -> int list

Full name: Oopintro.addOneToList
val lst : int list
val addOneToIOList : lstIO:IO<int list> -> IO<int list>

Full name: Oopintro.addOneToIOList
val lstIO : IO<int list>
val map : f:('a -> 'b) -> x:IO<'a> -> IO<'b>

Full name: NovelFS.NovelIO.IO.map
val addOneToIOList' : lstIO:IO<int list> -> IO<int list>

Full name: Oopintro.addOneToIOList'
type ExampleClass =
  member Command : unit -> unit
  member Query : x:int -> int

Full name: Oopintro.ExampleClass
val this : ExampleClass
member ExampleClass.Command : unit -> unit

Full name: Oopintro.ExampleClass.Command


 Performs the side effect of writing text to the screen
val printfn : format:Printf.TextWriterFormat<'T> -> 'T

Full name: Microsoft.FSharp.Core.ExtraTopLevelOperators.printfn
member ExampleClass.Query : x:int -> int

Full name: Oopintro.ExampleClass.Query


 Pure function that raises x to the power of 3
val x : int
val pown : x:'T -> n:int -> 'T (requires member get_One and member ( * ) and member ( / ))

Full name: Microsoft.FSharp.Core.Operators.pown
val exampleIO : IO<unit>

Full name: Oopintro.exampleIO
module Console

from NovelFS.NovelIO
val writeLine : str:string -> IO<unit>

Full name: NovelFS.NovelIO.Console.writeLine
val query : x:int -> int

Full name: Oopintro.query
val randomIO : IO<int>

Full name: Oopintro.randomIO
module Random

from NovelFS.NovelIO
val nextIO : IO<int>

Full name: NovelFS.NovelIO.Random.nextIO
Fork me on GitHub