tl;dr: Solving a very specific problem using FP tends to produce a very general solution that's applicable to a wide range of problems.
Let's start with some Java code that we'll simplify using functional programming.
class Department {
Employee getYoungestEmployee() {
if (employees.isEmpty()) return null;
Employee result = employees.get(0);
for (Employee employee : employees) {
if (employee.getAge() < result.getAge()) {
result = employee;
}
}
return result;
}
Employee getBestPaidEmployee() {
if (employees.isEmpty()) return null;
Employee result = employees.get(0);
for (Employee employee : employees) {
if (employee.getSalary() > result.getSalary()) {
result = employee;
}
}
return result;
}
}
Here's a port of that code to Clojure. Note that it's very un-idiomatic in order to stay true to the Java version.
(defn get-youngest-employee [department]
(let [result (atom (first (:employees department)))]
(doseq [employee (:employees department)]
(if (< (:age employee) (:age @result))
(reset! result employee)))
@result))
(defn get-best-paid-employee [department]
(let [result (atom (first (:employees department)))]
(doseq [employee (:employees department)]
(if (> (:salary employee) (:salary @result))
(reset! result employee)))
@result))
Both of these functions walk through a sequence of values and update
the result (initialized to the first value in the sequence) with each
step. In Clojure this operation is abstracted by a function called
reduce
. As its argument, in addition to a sequence of values to
operate on, reduce
needs a function that produces a new result from
the old result and the next item in the sequence.
(defn get-youngest-employee [department]
(reduce (fn [result employee]
(if (< (:age employee) (:age result))
employee
result))
(:employees department)))
(defn get-best-paid-employee [department]
(reduce (fn [result employee]
(if (> (:salary employee) (:salary result))
employee
result))
(:employees department)))
The code can be made a bit cleaner using Clojure's syntactic sugar for anonymous functions. We lose the parameter names, but that's OK because we know that a reduction function's parameters are the old result and the next item.
(defn get-youngest-employee [department]
(reduce #(if (< (:age %1) (:age %2))
%1
%2)
(:employees department)))
(defn get-best-paid-employee [department]
(reduce #(if (> (:salary %1) (:salary %2))
%1
%2)
(:employees department)))
If we write the if
forms on one line and squint a little, they look
a bit like Java's ternary operator (a.getAge() < b.getAge() ? a : b
).
(defn get-youngest-employee [department]
(reduce #(if (< (:age %1) (:age %2)) %1 %2)
(:employees department)))
(defn get-best-paid-employee [department]
(reduce #(if (> (:salary %1) (:salary %2)) %1 %2)
(:employees department)))
Now the functions are as simple as they get, but the duplication
of code between them is a source of complexity. We'll tackle that in
the usual way by extracting the common parts into a new function,
which takes the varying parts as parameters. In this case the parts
that vary are the function to get the employee's attribute that we're
interested in (:age
or :salary
) and the function to tell which of
the attribute values better matches what we're looking for (<
or
>
).
(defn get-employee [better? attr department]
(reduce #(if (better? (attr %1) (attr %2)) %1 %2)
(:employees department)))
(defn get-youngest-employee [department]
(get-employee < :age department))
(defn get-best-paid-employee [department]
(get-employee > :salary department))
The extracted function is called get-employee
, but once it has
obtained the list of employees from the department, it only deals with
generic items of a generic sequence. If we shift the responsibility of
obtaining the sequence to the callers, it becomes completely generic.
Naming generic things is frustratingly hard, so I've opted for a name
that's undescriptive but reads nicely in a calling form.
(defn get-with [better? attr items]
(reduce #(if (better? (attr %1) (attr %2)) %1 %2)
items))
(defn get-youngest-employee [department]
(get-with < :age (:employees department)))
(defn get-best-paid-employee [department]
(get-with > :salary (:employees department)))
The bodies of our two employee-getting functions are almost as
readable as their names, so the functions aren't pulling their weight
anymore. Whoever needs them can call get-with
directly.
(get-with < :age (:employees department))
(get-with > :salary (:employees department))
The users of get-with
are not limited to operating on employees of a
department. They can just as easily use it find the biggest department
in the company (i.e. the department with the greatest count of
employees).
(get-with > (comp count :employees) (:departments company))
get-with
is not even limited to operations on records of domain
data. It can be used to find the number that's closest to zero, or the
programming language with the longest name.
(get-with < #(Math/abs %) [-8 5 12 -2 3 6])
(get-with > #(.length %) ["Clojure" "Haskell" "F#" "Objective Caml"])
Nice! Scala standard library has a method
for every sequence, which apparently does the same. So, in Scala you could write
Clojure might have something similar already, I don't know.
Even if there is no minBy at hand, reducing or folding is not the first thing that comes to my mind, so one could also write
which also works for empty sequences. It doesn't perform as well since it has to sort the whole sequence, but hey, who cares =)