Learning a new programming paradigm is like learning a foreign language. You learn the new vocabulary, the grammar, a few idioms, but you still formulate your thoughts in your native tongue. It takes years of immersion before you start thinking in a foreign language. In programming, the litmus test comes when you’re approaching a new problem. Will you formulate your solution in terms of the old or the new paradigm? Will you see procedures, objects, or functions?
I remember proposing an object oriented approach to the design of a content index back in my Microsoft years. The reaction was: It’s a great paradigm, but it’s not applicable to this particular problem. There are no “objects” in the content index. Indeed, you can’t find Employees and Payrolls, Students and Courses, DisplayableObjects and LightRays in the content index. But after a short brain storm we discovered such exotic objects as a Resource Manager or a Master Merge. We ended up with a fine piece of OO engineering that is still part of the Windows shell after all those years.
There’s a similar, if not larger, shift in thinking when you learn functional programming, especially if you come from an OO background. Initially you can’t help but see everything through the perspective of mutable data structures and loops. There are no obvious “functions” in your favorite problem. Functions are these weird stateless things–they always produce the same results when called with the same arguments. They are good in mathematics, but not in real-life programming.
And yet, there are people who write complete applications using functional (or at least hybrid) languages. One such example is the game The Path of Go created by Microsoft Research for the Xbox. It’s not a spectacular game as far as UI goes, but it plays some mean Go and it’s written in F#.
F# is a mostly functional language (based on ML) with support for object-oriented programming and access to the rich .NET libraries. In this respect it’s similar to Scala. Roughly: F# is to .NET what Scala is to JVM. Both languages were designed by excellent teams. F# was designed by Microsoft Research in Cambridge, England. The chief architect of F# is Don Syme. Scala is the brainchild of Martin Odersky.
I decided to learn F# and immerse myself in the new paradigm. So I had to pick a random problem, not one with obvious functional implementation, and start from scratch, design and all. In practice, I had to do a lot of experimenting in order to familiarize myself with the language and the library. Experimenting, by the way, was made relatively easy by the inclusion of an F# interpreter in Visual Studio 2010.
To learn F#, I used only online documentation which, as I found out, is not that good. There are also fewer online discussions about F# than, say, about Haskell. The two websites I used most are:
The Problem
Without further ado, let me describe the challenge:
Write a program that finds duplicate files on disk.
In particular, I was interested in finding duplicate image files, but for testing I used text files. The program should therefore concentrate on files with particular extensions. It should also be able to skip directories that I’m not interested in. The duplicates (or triplicates, etc.) don’t have to have the same names but have to have identical extensions and contents.
The Design
Not surprisingly, functional programming requires a major adjustment. It’s one thing to read somebody else’s code and admire the tricks, but a completely different thing to be designing and writing functional code from scratch. But once you get the hang of it, it actually becomes easy and quite natural.
The most important thing you notice when using functional languages is that types are mostly unobtrusive due to type inference, but type checking is very strong. Essentially, if you manage to compile your program, it usually runs correctly. You spend much less time debugging (which I found rather difficult in F#) and much more time figuring out why the types don’t match. Of course, you have to learn a whole new language of error messages.
So it’s definitely a steep learning curve but once you’re over the hump you start reaping the benefits.
The naive approach to solving my problem would be to list all files on disk (recursively, starting with the root directory) and compare each with each. That would scale very poorly, O(N2), so we need to do some pruning.
Let’s first group files by extension and size. There will be a lot of singleton groups– containing only one file with a particular combination of extension and size. Let’s eliminate them from consideration. After that we’ll be dealing with much smaller groups of files so, within those groups, we can do full-blown byte-by-byte comparisons. Strict comparisons will potentially split those groups into even smaller groups. Again, we should eliminate the resulting singletons. Finally, we should be able to print the resulting lists of lists of identical files.
For an imperative programmer the first impulse would be to use a lot of looping; e.g., for each file retrieve its extension and size, etc. An object-oriented programmer would use vectors, hash tables, and looping over iterators.
How would a functional programmer approach the subject? Iteration is out of the question. Recursion and immutable lists are in. Quite often functions operating on lists can be expressed as list comprehensions. But there’s an even better tool called sequences in F#. They’re sort of like iterators, but with some very nice compositional properties. Sequences can be composed using pipelining. So let me express the above design as a pipeline.
The Pipeline
This is the refinement of the original design that takes into account data structures: sequences, lists, and tuples in various combinations.
- The source for the pipeline is a sequence of file paths coming from a recursive enumerator.
- The first operation is to group the files that share the same key: in our case the key will be a tuple (file extension, file size).
- The next operation is to filter out groups of length one, the singletons.
- Since the grouping injected keys into our stream, we need to strip them now and convert groups to lists.
- Now we group byte-wise equal files within each group.
- Then we remove singletons within those subgroups,
- Flatten lists of lists, and
- Print the results.
There are only two stages that deal with technical details of data structures: the stripping of the keys and the flattening of the lists. Everything else follows from high-level design.
Here’s the pipeline in its full functional glory. The |>
symbol is used to forward the results of one stage to the next.
enumFilesRec (filterOutPaths ["c:\\Windows";"c:\\ProgramData";"c:\\Program Files"]) (filterExt [".jpg"; ".gif"]) "c:\\Multimedia" |> Seq.groupBy (fun pth->(Path.GetExtension pth, (FileInfo pth).Length)) |> Seq.filter (fun (_, s) -> (Seq.length s) > 1) |> Seq.map (fun (_, sq) -> [for path in sq -> path]) |> Seq.map groupEqualFiles |> Seq.map filterOutSingletons |> Seq.collect Seq.ofList |> Seq.iter (fun lst -> printfn "%A" lst)
I will go through it line by line shortly.
I realize that this is a handful and if you have no previous experience with functional programming you are likely to feel overwhelmed at some point. The important thing is to observe how the original design translates almost one-to-one into implementation. Notice also the points of customization–they are almost universally plugs for user-defined functions. For instance, you customize Seq.map
, Seq.filter
, or Seq.collect
by passing functions, often lambdas, as their arguments. Also, look how the function enumFilesRec
is used. I decided to make its first two arguments functions even though my first impulse was to directly pass lists of directories to be skipped and extensions to be accepted. This way my design will work even if I later decide to filter files by, say, time of creation or size.
The Stages
Here’s the line by line reading of the pipeline code. My suggestion is to read as far as your patience permits and then skip to conclusions.
- I’m calling my function
enumFilesRec
with three arguments:enumFilesRec (filterOutPaths ["c:\\Windows";"c:\\ProgramData";"c:\\Program Files"]) (filterExt [".jpg"; ".gif"]) "c:\\Multimedia"
- A directory filter: a function (predicate) that returns true for all directories except the ones listed as arguments to
filterOutPaths
. It’s worth mentioning thatfilterOutPaths
is a function that returns another function — the predicate expected byenumFilesRec
. - A file filter: a function that returns true only for listed extensions. Again,
filterExt
is a function that takes a list and returns a predicate. - The top directory: the root of the listing.
enumFilesRec
returns a sequence of paths. Since the sequence is only evaluated on demand, the call to this function returns almost immediately. - A directory filter: a function (predicate) that returns true for all directories except the ones listed as arguments to
- The next stage of the pipeline:
|> Seq.groupBy (fun p->(Path.GetExtension p, (FileInfo p).Length))
applies
Seq.gropuBy
to the incoming sequence of paths.Seq.groupBy
takes one argument– a function that takes a path and generates a key:fun path -> (Path.GetExtension path, (FileInfo path).Length)
The key is the tuple consisting of file extension and file length:
(Path.GetExtension path, (FileInfo path).Length)
F# notation for anonymous functions (lambdas) is of the form:
fun x -> expr
The function
Seq.gropuBy
groups all elements of the sequence into subgroups that share the same key. The result is a sequence of sequences (the groups). Of course, to perform this step the whole input sequence must be scanned. That forces the actual listing of directories on disk, which takes the bulk of the run time. - The next stage performs
Seq.filter
on the sequence:|> Seq.filter (fun (_, s) -> (Seq.length s) > 1)
Seq.filter
takes a predicate– here defined by a lambda– and applies it to all elements of the sequence; passing through only those that satisfy the predicate. This is the predicate:fun (_, s) -> (Seq.length s) > 1
Notice that the previous step produced a sequence whose elements were tuples of
(key, subsequence)
with the subsequences sharing the same key. The lambda pattern-matches these tuples,(_, s)
, ignoring the key and testing the length of the subsequence against one. That eliminates singleton groups. - We can now get rid of the keys and convert the subsequences into plain lists that will be needed for further processing.
|> Seq.map (fun (_, sq) -> [for path in sq -> path])
I use the workhorse of sequences,
Seq.map
, that applies a function to every element of the sequence. Remember that the element is still a tuple(key, subsequence)
. The lambda ignores the key and returns a list:fun (_, sq) -> [for path in sq -> path]
The expression:
[for path in sq -> path]
enumerates the paths in the sequence
sq
and uses them to initialize a list (the brackets denote a list in F#). In functional programming such constructs are known as list comprehensions. The expressionfor path in sq -> path
is called a generator. - The next stage looks deceptively simple:
|> Seq.map groupEqualFiles
It applies a function,
groupEqualsFiles
to each list in the sequence. The interesting work happens in that function, which I will analyze shortly. Suffice it to say that it produces a list of sublists of identical files. Some of the sublists may be singletons.It might be a little hard to keep track of all those sequences, subsequences, and sublists. A good development environment will show you all the types while you’re developing the program. You may also sketch simple examples:
seq[ [[a; a]; [b]]; [[c; c; c]; [d; d]; [e]] ]
This one shows a sequence of lists of lists of identical elements analogous to the output of the last stage.
- Next I apply another function,
filterOutSingletons
to each list of sublists.|> Seq.map filterOutSingletons
I end up with a sequence of lists of sublists of length greater than one containing identical files. The sequence above would be transformed to:
seq[ [[a; a]]; [[c; c; c]; [d; d]] ]
- In the next step I flatten this hierarchy using
Seq.collect
.|> Seq.collect Seq.ofList
Seq.collect
takes a function that turns each element of the original sequence into a sequence and concatenates all those sequences into one. Like this:seq[ [a; a]; [c; c; c]; [d; d] ]
Remember that the element of our sequence is a list of sublists. We can easily convert such a list to a sequence by applying
Seq.ofList
to it. It creates a sequence of sublists, andSeq.collect
will concatenate all such sequences. I end up with a big sequence of lists. Those lists contain identical files. Voila! - The final step is to print those lists.
|> Seq.iter (fun lst -> printfn "%A" lst)
I apply
Seq.iter
, which takes a void function (a function returningunit
, in the F# parlance):fun lst -> printfn "%A" lst
(which is really not a function because it has a side effect of printing its argument–a list).
Seq.iter
is just likeSeq.map
, but it consumes its input sequence producing nothing (except for side effects). Unlike Haskell, F# doesn’t track I/O side effects in the type system.
Details
For those who are really curious, I can go on filling in the details–the implementations of various functions used in the pipeline. Those functions use a variety of functional features of F# such as lists, recursion, pattern matching, etc. This is the bread and butter of functional programming.
Let me start with the function that enumerates files in a directory tree. The idea is to first list the files in the current directory and pass them through the file filter; then list the subdirectories, filter them through the directory filter, and recurse into each subdirectory. Since this function is the first stage of the pipeline, it should produce a sequence.
let rec enumFilesRec dirFilter fileFilter dir = seq { yield! enumFilesSafe dir |> Seq.filter fileFilter yield! enumDirsSafe dir |> Seq.filter dirFilter |> Seq.map (fun sub -> enumFilesRec dirFilter fileFilter sub) |> Seq.collect id }
Monads Anyone?
I don’t want to scare anyone but F# sequences are monads. People usually have strong feelings about monads, some love them, some hate them. But don’t get intimidated by monads. The theory behind them is hard, but the usage is pretty simple.
To create a sequence you use the seq { ... }
block sprinkled with yield
and yield!
statements. When such a sequence is enumerated (you can do it with a loop for instance: for elem in sqnc
), each yield
returns an element and suspends the execution of the seq
block until the next call. The next iteration resumes right after the last yield
. In our case we are building a new sequence from existing ones. To dive into another sequence inside the seq
block we use yield!
(yield bang). This is what the above code does: It first dives into file enumeration (a sequence returned by enumFilesSafe
) and then into the enumeration of files in subdirectories.
enumFilesSafe
is a function that calls the system’s Directory.EnumerateFiles
API (part of the .NET library). I had to encapsulate it into my own function in order to catch (and ignore) the UnauthorizedAccessException
s. Notice the use of pipelining to filter the paths.
After the sequence of files paths is exhausted, we enter the second yield!
. This one starts by enumerating subdirectories. Subdirectory paths are pipelined through the directory filter. Now we have to do something for each subdirectory– that’s the clue to use Seq.map
. The mapping function:
fun sub -> enumFilesRec dirFilter fileFilter sub
simply calls enumFilesRec
recursively, passing it the filters and the name of the subdirectory. Notice that enumFilseRec
returns another sequence, so we end up with a sequence of sequences corresponding to individual subdirectories. To flatten this hierarchy I use Seq.collect
. Notice that I pass it the identity function, id
, which just returns its argument: The elements of my sequence are sequences and I don’t have to do anything to them.
The second function I’d like to discuss is groupEqualFiles
. It gets a list of file paths and splits it into sublists containing byte-wise identical files. This problem can be decomposed in the following way: Let’s pick the first file and split the rest into two groups: the ones equal to that file and the ones not equal. Then do the same with the non-equal group. The “do the same” part hints at recursion. Here’s the code:
let groupEqualFiles paths = let rec groupEqualFilesRec soFar lst = match lst with | [] -> soFar | (file::tail) -> let (eq, rest) = groupFilesEqualTo file tail if rest.IsEmpty then eq::soFar else groupEqualFilesRec (eq::soFar) rest groupEqualFilesRec [] paths
A recursive solution often involves defining a recursive function and then calling it with some initial arguments. That’s the case here as well. The recursive function groupEqualFilesRec
takes the accumulator, the soFar
list, and a list of files to group.
let rec groupEqualFilesRec soFar lst = match lst with | [] -> soFar | (file::tail) -> let (eq, rest) = groupFilesEqualTo file tail if rest.IsEmpty then eq::soFar else groupEqualFilesRec (eq::soFar) rest
The new trick here is pattern matching. A list can be empty and match the pattern []
, or it can be split into the head and tail using the pattern (file::tail)
. In the first case I return the soFar
list and terminate recursion. Otherwise I call another function groupFilesEqualTo
with the head and the tail of the list. This auxiliary function returns a tuple of lists: the equal group and the rest. Symbolically, when called with a
and [b; a; c; b; d; a]
it produces:
([a; a; a], [b; c; b; d])
The tuple is immediately pattern matched to (eq, rest)
in:
let (eq, rest) = groupFilesEqualTo file tail
if the rest is empty, I prepend the eq
list to the accumulator, soFar
. Otherwise I recursively call groupEqualFilesRec
with the augmented accumulator and the rest.
The function groupEqualFiles
simply calls the recursive groupEqualFilesRec
with an empty accumulator and the initial list. The result is a list of lists.
For completeness, here’s the implementation of the recursive function groupFilesEqualTo
let rec groupFilesEqualTo file files = match files with | [] -> ([file], []) | (hd::tail) -> let (eqs, rest) = (groupFilesEqualTo file tail) if eqFiles file hd then (hd::eqs, rest) else (eqs, hd::rest)
Again, this function pattern-matches the list. If it’s empty, it returns a tuple consisting of the singleton list containing the file in question and the empty rest. Otherwise it calls itself recursively to group the tail. The result is pattern-matched into (eqs, rest)
. Now the comparison is made between the original file and the head of the original list (we could have done it before making the recursive call, but this way the code is more compact). If they match then the head is prepended to the list of equal files, otherwise it lands in the rest.
Did I mention there would be a test at the end? By now you should be able to analyze the implementation of filterOutSingletons
:
let rec filterOutSingletons lstOfLst = match lstOfLst with | [] -> [] | (h::t) -> let t1 = filterOutSingletons t if (List.length h) > 1 then h::t1 else t1
Conclusions
I am not arguing that we should all switch to functional programming. For one thing, despite great progress, performance is still a problem in many areas. Immutable data structures are great, especially in concurrent programming, but can at times be awkward and inefficient. However, I strongly believe that any programmer worth his or her salt should be fluent with the functional paradigm.
The heart of programming is composition and reuse. In object-oriented programming you compose and reuse objects. In functional programming you do the same with functions. There are myriads of ways you can compose functions, the simplest being pipelining, passing functions as arguments, and returning functions from functions. There are lambdas, closures, continuations, comprehensions and, yes, monads. These are powerful tools in the hands of a skilled programmer. Not every problem fits neatly within the functional paradigm, but neither do all problems fit the OO paradigm. What’s important is having choices.
The code is available on GitHub.