LINQ Challenge #1 Answers in F#
Every now and then I create a “lunchtime LINQ challenge” for the developers at my work to try out and improve their LINQ skills. It’s been a while since I posted one to this blog, but I thought I’d take the first challenge and attempt to solve them in F#. For comparison, here are my answers in C#. Why not try the original challenge yourself first (in either C# or F#) before reading on?
Problem 1: Numbering Players
1. Take the following string "Davis, Clyne, Fonte, Hooiveld, Shaw, Davis, Schneiderlin, Cork, Lallana, Rodriguez, Lambert" and give each player a shirt number, starting from 1, to create a string of the form: "1. Davis, 2. Clyne, 3. Fonte" etc
This one is a fairly easy challenge in F#, so long as you know about the Seq.mapi
function, which is basically the same as map
, but also gives you the index of each item. Without that, you’d probably use Seq.zip
to zip a sequence of numbers with a sequence of players. One nice advantage F# has here over our C# solution is that the String.concat
function can be placed at the end of our pipeline. Note that I’m using my LINQPad DumpAs helper function to display the output in all these examples.
names.Split(',')
|> Seq.mapi (fun index item -> sprintf "%d.%s" (index + 1) item)
|> String.concat ", "
|> DumpAs "Q1"
Problem 2: Sort by Age
Take the following string "Jason Puncheon, 26/06/1986; Jos Hooiveld, 22/04/1983; Kelvin Davis, 29/09/1976; Luke Shaw, 12/07/1995; Gaston Ramirez, 02/12/1990; Adam Lallana, 10/05/1988" and turn it into an IEnumerable of players in order of age (bonus to show the age in the output)
This is a slightly tricky challenge to do in a single statement as the calculation to work out someone’s age given their date of birth is not a one-liner. So I wanted to make a getAge
helper function first. But since F# is a functional language, passing just the date of birth to this function would mean we don’t have a pure function, since it’s output would rely on the current date. So I made a getAgePure
function, which takes two date parameters, and would be easily unit testable, and then partially applied it with today’s date to make the single parameter getAge
function for convenience.
let getAgePure (today:DateTime) (dob:DateTime) =
let age = today.Year - dob.Year;
if dob > today.AddYears(-age) then age - 1 else age
let getAge = getAgePure DateTime.Today
Now we are ready to solve the problem. I’ve got three maps in a row to manipulate the input string into a triple of name, date of birth and age. I wanted to keep hold of date of birth so the sort can put two people in the right order even if they have the same age. But once they’re in the right order (sorted with sortByDescending
), we only need the name and age to form the output string.
let players = "Jason Puncheon, 26/06/1986; Jos Hooiveld, 22/04/1983; Kelvin Davis, 29/09/1976; Luke Shaw, 12/07/1995; Gaston Ramirez, 02/12/1990; Adam Lallana, 10/05/1988"
players.Split(';')
|> Seq.map (fun s -> s.Split(','))
|> Seq.map (fun p -> p.[0].Trim(), DateTime.Parse(p.[1].Trim()))
|> Seq.map (fun (name, dob) -> name,dob,(getAge dob))
|> Seq.sortByDescending (fun (name,dob,age) -> dob)
|> Seq.map (fun (name,dob,age) -> sprintf "%s %d" name age)
|> String.concat ", "
|> DumpAs "Q2"
Problem 3: Album Duration
Take the following string "4:12,2:43,3:51,4:29,3:24,3:14,4:46,3:25,4:52,3:27" which represents the durations of songs in minutes and seconds, and calculate the total duration of the whole album
This problem is solved by splitting the string, parsing to TimeSpans
and then summing them. In C# we had to use Aggregate
since Sum
can’t sum timespans, and in F#, Seq.reduce
is the function we want.
"4:12,2:43,3:51,4:29,3:24,3:14,4:46,3:25,4:52,3:27"
.Split(',')
|> Seq.map (fun s -> TimeSpan.Parse("0:" + s))
|> Seq.reduce (fun t1 t2 -> t1 + t2)
|> DumpAs "Q3"
Problem 4: Coordinates
Create an enumerable sequence of strings in the form "x,y" representing all the points on a 3x3 grid. e.g. output would be: 0,0 0,1 0,2 1,0 1,1 1,2 2,0 2,1 2,2
This problem was designed to show off how SelectMany
and Enumerable.Range
can be used, although were other ways of solving it. In C# the LINQ Query Expression Syntax was particularly well suited to expressing this problem cleanly.
In F#, we can use sequence expressions which are a nice alternative to Enumerable.Range
, but the syntax does get a little messier as we need yield!
to emit the items in the nested sequence. I suspect there may be a slightly cleaner way to code this in F#, but here’s what I came up with:
seq { for x in 0 .. 2 do
yield! seq { for y in 0 .. 2 do yield x,y } }
|> Seq.map (fun (x,y) -> sprintf "%d,%d" x y)
|> String.concat ", "
|> DumpAs "Q4"
Problem 5: Swim Length Durations
Take the following string "00:45,01:32,02:18,03:01,03:44,04:31,05:19,06:01,06:47,07:35" which represents the times (in minutes and seconds) at which a swimmer completed each of 10 lengths. Turn this into an enumerable of timespan objects containing the time taken to swim each length (e.g. first length was 45 seconds, second was 47 seconds etc).
To solve this problem you need to deal with each time in pairs (as well as prepending 0:00 onto the sequence). In C#, I recommended making a ZipWithSelf
extension method, but in F#, the library gives us exactly what we need with Seq.pairwise
. This emits a sequence of tuples, one for each pair of consecutive items in the original sequence.
This leaves us the simple task of subtracting the TimeSpans
in each pair, and formatting them back to strings. I also decided to factor out the TimeSpan
parsing into a little helper function just to make the whole thing a little more readable. This is another example of where the simplified F# syntax for creating one-liner functions like this encourages you to compose your code out of lots of little functions.
open System.Globalization
let splitTimes = "00:45,01:32,02:18,03:01,03:44,04:31,05:19,06:01,06:47,07:35"
let parseTimeSpan ts = TimeSpan.ParseExact (ts, "mm\\:ss", CultureInfo.CurrentCulture)
("00:00," + splitTimes)
.Split(',')
|> Seq.map parseTimeSpan
|> Seq.pairwise
|> Seq.map (fun (s,f) -> f-s)
|> Seq.map (fun t -> t.ToString("mm\\:ss"))
|> String.concat ", "
|> DumpAs "Q5b"
Problem 6: Ranges
Take the following string "2,5,7-10,11,17-18" and turn it into an IEnumerable of integers: 2 5 7 8 9 10 11 17 18
The first part of this problem involves taking the original sequence and mapping it into a sequence of tuples, representing start and finish of ranges (some of which may contain only a single number). I do this in three map
steps, as I think it makes for more readable code than trying to do it in one. A little trick of using Seq.last
on each range deals with individual numbers and ranges without the need for an if
expression.
Then we need Seq.collect
(which is essentially the F# version of LINQ’s SelectMany
), which expands those ranges, and here the F# sequence expression syntax gives us a nice way of expressing this. We can end by mapping simply to string rather than needing sprintf
which again makes for slightly cleaner code.
let input = "2,5,7-10,11,17-18"
input
.Split(',')
|> Seq.map (fun r -> r.Split('-'))
|> Seq.map (fun p -> p.[0], p |> Seq.last)
|> Seq.map (fun (f,l) -> (int f, int l))
|> Seq.collect (fun (f,l) -> seq { f .. l })
|> Seq.map string
|> String.concat ", "
|> DumpAs "Q6"
Over to You
Now I’m still fairly new to F#, so I don’t doubt that I’ve missed a few tricks along the way. Let me know how I could have done it better in the comments. And I hope to share some more of my Lunchtime LINQ Challenges in future posts.
Comments
I gave it a try and here's what I ended up with https://gist.github.com/Seh... (Edited to use gist)
Sehnsuchtgreat, thanks for sharing. I like your approach. Some good F# tricks in there like using (+) as a function that I need to get into the habit of using.
Mark Heath