12

F# Basics – Result : Reed Copsey, Jr.

 3 years ago
source link: http://reedcopsey.com/2020/12/17/f-basics-result/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

F# Basics – Result<'a>: or How I Learned to Stop Worrying and Love the Bind

Posted by Reed on Thursday, December 17, 2020 · Leave a Comment 

This year, for FsAdvent, I’m taking a request and am going to write about embracing the happy path while writing F#. When coming to F# or other functional programming languages, one of the hurdles I see people face is often rethinking how they approach problems. While many posts talk about how to do this, I want to focus on one of the huge advantages – less worry and stress about the code working correctly.

For this post, I’m going to use a small example task – parsing a file on disk and summarizing some information from it. In this case, we’ll do something simple – read a CSV log file of “events” that occurred, and get the range of ID numbers from the file. Here is our input file:

12/17/2020 1928 Child in snow 12/17/2020 1929 Child grabbing snow 12/17/2020 1930 Snowball being formed 12/17/2020 1931 Snowball flinging towards tree 12/17/2020 1932 Tree gets hit 12/17/2020 1933 Tree vibrates 12/17/2020 1934 Snow falls off branches 12/17/2020 1935 Snow lands on child and dog 12/17/2020 1936 The dog wimpers, then says "Woof" 12/17/2020 1937 The child hugs dog 12/17/2020 1938 Everybody runs around 12/17/2020 1939 A good time is had by all

Our task will be to read this file, parse it, and get the range of IDs being used. In this case, we’ll end up with 2 numbers as our result – 1928 and 1939.

When working in F#, I often start by describing the data I’ll be using, as well as the result I want to get or manipulate. Here, each row represents three things; a date, an ID, and a description of the event. We want to eventually get two numbers, a starting ID and an ending ID. In addition, we know there are a few things that could go wrong – the file might not exist, there might be no data in the file, or the data might be malformed. I’m going to start by defining a couple of types to help with this:

// The data in our rows type Event = { Date : DateTime ; ID : int ; Description : string }

// Our end result type Range = { Start : int ; End : int }

// Our potential problems // Our potential problems type Issue = | IO of message : string * exn : Exception | DataMissing | DataMalformed of message : string * exn : Exception

My goal when writing this utility will be to always write in a “happy path”. Instead of focusing on handling bad data or errors, I’m going to try to break everything up into small steps, each a function, that does one thing. I’m always going to assume my inputs are good, and my output will always either be good result or an Issue from above. Basically, I never want to think about “bad data” – I just want to write code that does something as I go.

In F#, there is a type that helps with this significantly – Result. The Result type is a discriminated union with two options; an Ok result, or an Error.

We’ll start by opening our file. In this case, we want a function that takes a filename and returns a Stream we can use for reading. File IO is one place where exception handling is nearly required, so I’m using that to create my error case:

let openFile filename = try File.OpenRead filename |> Result.Ok with | e -> IO(message = "Could not open file", exn = e) |> Result.Error

This function will follow a common pattern – it takes an input (filename) and creates a Result: val openFile : filename:string -> Result

To parse the file, I’m going to use the CsvHelper library. This will allow me to not think about parsing the “hard parts” of CSV, like strings with embedded commas or quotes. Now we need a small function to take a stream from our openFile and convert it to a CsvReader:

let openCsv (strm : Stream) = new CsvReader(new StreamReader(strm), Globalization.CultureInfo.InvariantCulture)

Here, we know, if we have a valid stream, we’ll get a valid reader, so we can just write a simple Stream -> CsvReader helper. We can put these together with Result.map, which allows us to take an “Ok” result and use it’s value to make a new result.

let file = openFile "C:\\users\\reedc\\desktop\\events.csv" let csv = file |> Result.map openCsv

Running in FSI, with a good filename, gives us val csv : Result = Ok CsvHelper.CsvReader. With a bad filename, you get:

val csv : Result =
Error
(IO
("Could not open file",
System.IO.FileNotFoundException: Could not find file

Now that our file is opened, we can move onto parsing. Again, we’ll assume we have a good input (our reader), and just write routines to just parse. In this case, I’m going to write a function to parse a single row and create an option, and a separate function to parse until we receive None, which will return our Result.

This does get a little uglier with the routine to read all of the rows. We want to make sure to close our file (we effectively pass ownership), so we need the try/finally to dispose of it anytime our routine to open succeeded. We’ll use a simple try/with to handle parsing errors. Using Seq.initInfinite and Seq.takeWhile allows us to “loop” through our data until we return None:

let readRow (reader : CsvReader) = if reader.Read () then let evt = { Date = reader.GetField<DateTime>(0) ; ID = reader.GetField<int>(1) ; Description = reader.GetField<string>(2) } Some evt else None

let readRowsAndClose reader = try try let rows = Seq.initInfinite (fun _ -> readRow reader) |> Seq.takeWhile (fun r -> r.IsSome) |> Seq.choose id |> Seq.toList if List.isEmpty rows then Result.Error DataMissing else Result.Ok rows with | e -> DataMalformed ("Unable to parse data", e) |> Result.Error finally reader.Dispose ()

Our final step is to reduce the Event list to our two numbers:

let getIds (events : Event list) = let ids = events |> List.map (fun e -> e.ID) let min = ids |> List.min let max = ids |> List.max { Start = min ; End = max }

Now that we have all of the pieces, we can stream these together. We will use two functions from the Result module in the core libraries to string these together – Result.map and Result.bind.

Whenever we have a function that doesn’t produce an error case, we will use map. When we have a function that takes our input and might need an error case, we will use bind. In our case, openFile and getIds both “always work” if their input are good, so we can map them. readRowsAndClose can map the data through to a list, but can also create an error case, so we will use bind. When stringing these together, we end up with:

let result = openFile "C:\\users\\reedc\\desktop\\events.csv" |> Result.map openCsv |> Result.bind readRowsAndClose |> Result.map getIds

When run via FSI, and given the input file above, this now produces:

val result : Result = Ok { Start = 1928
End = 1939 }

If we pass an invalid filename, we get a nice error:

val result : Result =
Error
(IO
("Could not open file",
System.IO.FileNotFoundException: Could not find file...

If we pass a good filename, but the file has bad data, that is also handled:

val result : Result =
Error
(DataMalformed
("Unable to parse data",
CsvHelper.TypeConversion.TypeConverterException: The conversion cannot be performed.
Text: '193d3'

By breaking this into pieces, and using Result.map and Result.bind, we were able to parse this in stages, where every stage could assume everything was “good”, and our errors propagate through cleanly at the end. Each step in the chain only has to focus on the task at hand.

Filed under .NET, F#, Uncategorized · Tagged with

87b3a4c585e6fd2ad5308e15e12bdc36?s=64&r=gAbout Reed
Reed Copsey, Jr. - http://www.reedcopsey.com - http://twitter.com/ReedCopsey


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK