Basics 16 - Functions (Part 2) - Stitching Pure and Impure Worlds

This article is a continuation of Basics 04 - Functions (Part 1).

Learning-FSharp/Ch16-FunctionStitchingPureAndImpure/Program.fs

Check out all the comments that are placed in the source file.

Recap

  1. Breaking an application down into the pure and impure worlds

  2. The pure world is where we have pure functions, the business logic

  3. The impure world is one where we have uncertainty, filesystem, network, HTTP, APIs, exceptions etc.

The Impure World and Result Container

Let's take an example. You need to create a function to read text file content:

// string -> string

let readAllText file = System.IO.File.ReadAllText(file)

Some problems you'll face when using readAllText:

  1. System.IO.File.ReadAllText raises exceptions, which means readAllText raises exceptions

  2. Since readAllText raises exceptions, there are two options when calling readAllText:

    1. Wrap the call to readAllText in a try...with

    2. Don't wrap the call in a try...with, let the exceptions be propagated upward

  3. Both these options are problematic:

    1. If the functions that call readAllText use try...with, you will end up with redundant and noisy code

    2. If try...with is not used by the functions that call readAllText, whose problem exception handling is then?

  4. Another major problem is how to integrate such a function with the pure world. Remember there are no exceptions in the pure world.

Exceptions: Flow Control vs Data

There are two ways to look at exceptions in F#/.NET:

  1. When you raise/reraise an exception: raise (ArgumentException("Whatever"))

  2. let ex = ArgumentException("Whatever"), ex is just another piece of data

try...with and raise etc. are flow control tactics. My learning of two decades with flow control is to avoid it as much as possible.

Let's implement an alternative version of readAllText, this time, for the callers there is no flow control involved:

// string -> Result<string, exn>
let readAllText file =
    try
        Ok(System.IO.File.ReadAllText(file))
    with ex ->
        Error ex

Benefits of this approach:

  1. The flow control, exception handling etc. is isolated; it is the problem of readAllText only

  2. readAllText returns Result<string, exn>, no matter what happens

  3. If all goes well, you get Ok case with file content

  4. If there is an exception, you get Error case with exception details

  5. Using the Result container type, you can perform pattern matching, map, bind etc.

Isolate Exception Handling

There is one problem with the above-given implementation of readAllText:

  1. Essentially, the purpose of readAllText is calling System.IO.File.ReadAllText

  2. Rest everything is boilerplate, repetitive, control flow code

  3. In reality, one creates many such functions... reading text, writing text, reading binary, writing binary, dealing with streams, sockets, HTTP calls and so on

  4. If you follow the approach given above, you will end up with tons of functions having the same template

Let's try to fix this by creating functions that isolate exception handling:

// Helper function for 1 param function
// Helper function for trying f when f is 'a -> 'b
// f: ('a -> 'b) -> p: 'a -> Result<'b, exn>
let tryFunc1 f p =
    try
        Ok(f p)
    with ex ->
        Error ex

// Helper function for 2 param function
// Helper function for trying f when f is 'a -> 'b -> 'c
// f: ('a -> 'b -> 'c) -> p1: 'a -> p2: 'b -> Result<'c, exn>
let tryFunc2 f p1 p2 =
    try
        Ok(f p1 p2)
    with ex ->
        Error ex

// Helper function for 3 param function
// Helper function for trying f when f is 'a -> 'b -> 'c -> 'd
// f: ('a -> 'b -> 'c -> 'd) -> p1: 'a -> p2: 'b -> p3: 'c -> Result<'d, exn>
let tryFunc3 f p1 p2 p3 =
    try
        Ok(f p1 p2 p3)
    with ex ->
        Error ex

Let discuss the tryFunc1 function. The other functions are its cousins only.

tryFunc1 takes two parameters, a function f, and the parameter value for f. tryFunc1 calls f in the try block, and wraps the output of f in Ok case. In case of an exception, the with block wraps the exception in Error case.

Let's rewrite readAllText again:

// Helper function for 1 param function
// Helper function for trying f when f is 'a -> 'b
// f: ('a -> 'b) -> p: 'a -> Result<'b, exn>
let tryFunc1 f p =
    try
        Ok(f p)
    with ex ->
        Error ex

// Wrapper for System.IO.File.ReadAllText
// May raise exceptions
// string -> string
// file -> content
let readAllTextImpl file = System.IO.File.ReadAllText(file)

// Reads the content of given text file
// string -> Result<string, exn>
let readAllText = tryFunc1 readAllTextImpl
  1. Function readAllTextImpl calls System.IO.File.ReadAllText

  2. Function readAllTextImpl may raise exceptions

  3. readAllText is partial application of tryFunc1

  4. How do you call readAllText? let result = readAllText "some.file.name.txt"

  5. The type of result in the above given-line is Result<string, exn>

Let's implement writeAllText, which is for writing text file content.

// Helper function for 1 param function
// Helper function for trying f when f is 'a -> 'b
// f: ('a -> 'b) -> p: 'a -> Result<'b, exn>
let tryFunc1 f p =
    try
        Ok(f p)
    with ex ->
        Error ex

// Helper function for 2 param function
// Helper function for trying f when f is 'a -> 'b -> 'c
// f: ('a -> 'b -> 'c) -> p1: 'a -> p2: 'b -> Result<'c, exn>
let tryFunc2 f p1 p2 =
    try
        Ok(f p1 p2)
    with ex ->
        Error ex

// Wrapper for System.IO.File.ReadAllText
// May raise exceptions
// string -> string
// file -> content
let readAllTextImpl file = System.IO.File.ReadAllText(file)

// Wrapper for System.IO.File.WriteAllText
// May raise exceptions
// string -> string -> unit
// file -> content -> unit
let writeAllTextImpl file content =
    System.IO.File.WriteAllText(file, content)

// Reads the content of given text file
// string -> Result<string, exn>
let readAllText = tryFunc1 readAllTextImpl

// Writes the content for the specified text file
// string -> string -> Result<unit, exn>
// file -> content -> result
let writeAllText = tryFunc2 writeAllTextImpl
  1. readAllTextImpl and writeAllTextImpl are low-level implementations/wrappers

  2. readAllText and writeAllText are the partial applications of tryFunc1 and tryFunc2 respectively

Concluding Remarks

  1. Exceptions are not the only uncertainties when stitching pure and impure worlds

  2. For instance, when using asynchronous constructs, the task is an uncertainty

  3. Not only can a task fail, but you also have to wait for its completion

  4. Using Result container type is not the only option, there are other mechanisms (which we will discuss later)

  5. Key points:

    1. Avoid flow control as much as possible

    2. Deal with data, not flow control

    3. Where flow control is not avoidable, isolate it, and create wrappers

If you have reached so far, congratulations.

Keep reading!