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.
Full name: Motivation.x
Full name: Motivation.rnd
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
Full name: Motivation.y
System.Random.Next(maxValue: int) : int
System.Random.Next(minValue: int, maxValue: int) : int
Full name: Motivation.yPure
from NovelFS.NovelIO
Full name: NovelFS.NovelIO.Random.nextIO
Full name: Motivation.keys
from Microsoft.FSharp.Collections
Full name: Microsoft.FSharp.Collections.Seq.initInfinite
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(intercept: bool) : System.ConsoleKeyInfo
Full name: Motivation.untilEnter
Full name: Microsoft.FSharp.Collections.Seq.takeWhile
| Backspace = 8
| Tab = 9
| Clear = 12
| Enter = 13
| Pause = 19
| Escape = 27
| Spacebar = 32
| PageUp = 33
| PageDown = 34
| End = 35
...
Full name: System.ConsoleKey
Full name: Microsoft.FSharp.Core.ExtraTopLevelOperators.printfn
Full name: Microsoft.FSharp.Collections.Seq.length
Full name: Microsoft.FSharp.Collections.Seq.map
val string : value:'T -> string
Full name: Microsoft.FSharp.Core.Operators.string
--------------------
type string = System.String
Full name: Microsoft.FSharp.Core.string
from Microsoft.FSharp.Core
Full name: Microsoft.FSharp.Core.String.concat
Full name: Motivation.keysIO
from NovelFS.NovelIO
Full name: NovelFS.NovelIO.Console.readKey
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<_>
from NovelFS.NovelIO.IO
Full name: NovelFS.NovelIO.IO.Loops.unfoldWhileM
Full name: NovelFS.NovelIO.IOBuilders.io
Full name: Microsoft.FSharp.Core.ExtraTopLevelOperators.sprintf
Full name: NovelFS.NovelIO.IO.run
Full name: Motivation.randomSeq
Full name: Microsoft.FSharp.Collections.Seq.init
Full name: Motivation.sortedSeq
Full name: Microsoft.FSharp.Collections.Seq.sort
Full name: NovelFS.NovelIO.IO.replicateM