đšâđ« No office hours this week. Iâm retooling and figuring out the format. Stay tuned.
đ§đȘ Iâll be speaking next week at Heart of Clojure in Belgium. Get your tickets for 10% off with this magic coupon link. This looks like one cool Clojure conference.
đ Grokking Simplicity has passed 14k copies sold! Thatâs not bad for a programming book. If you want a copy, please buy it on Amazon because it boosts the rank so people who want it find it. Leaving a review will help, too, because it will help people decide whether to buy. You can also find Grokking Simplicity on O'Reilly Online.
đ I would love any comments you have on Runnable Specifications. The latest âchapterâ is the Composition Lens Supplement. Itâs a twenty page reference detailing ten important algebraic properties. My questions are: Is it useful? Whatâs missing? Howâs the presentation?
Givens + needs
My physics teacher in college used to make us list out the given values, the needed values (the values we needed to provide as an answer), and the equations we knew that used those values. I balked at the idea. I had taken physics in High School, so many of the problems seemed easy to me. I didnât need to write those things downâŠ
Until I started getting to the problems we hadnât covered in High School. They were trickier. There were fewer knowns, and they werenât just plug numbers into an equation and solve for x. You usually had to use two or more equations in steps. Once the easy problems were out of the way, these small disciplines were important for making the connections you needed. By doing that little bit of work, the medium problems became easy again.
And it turns out that this is exactly the kind of discipline that helps us define functions and solve problems in code. And, lucky us, the language syntax makes us do the work.
You see, the givens of any function are its arguments. They happen to be variables (that represent some quantity that will be known later when the function is called). And the needed value is the return value. Our job, much like in physics, is to relate the givens to the needed value.
I was mentoring someone the other day, and it struck me how much they needed this discipline. As I was helping them through a problem, I also realized how much I was doing it in my head unconsciously. I was listing the possible operations on the arguments and the possible operations that yielded the return value, and I was trying to forge a path between them. I suppose for a big enough skill difference between mentee and mentor, it would start to seem like wizardry. But itâs very mundane. Letâs look at the same example we saw last week, the function find-by
. Here is the signature:
(defn find-by [pred coll]
Look at those two arguments. pred
is a function of one argument that returns true
/false
. coll
is a collection, which really only has a handful of possible operations:
empty?
first
rest
conj
The funny thing about abstraction (we donât know what type of collection) is that it helps us by limiting what we can do. I think not knowing the type used to give me anxiety because when abstraction is done wrong, you need to do a lot of work to figure out what type it is, and I coded a lot in systems with bad abstraction. But in this case, itâs not like that. We donât want to know whether itâs a set or a vector or whatever.
Knowing what find-by
does also tells us that pred
will be called on the elements of coll
. Letâs start to make a list of this stuff:
Givens
pred
- function from elements ofcoll
totrue
/false
The only thing we can do is to call it on an element of
coll
.
coll
- a collectionempty?
- check if the collection is empty, dividing all collections into two âcasesâfirst
- the first element of a non-empty collectionrest
- get another collection containing everything but the first elementconj
- add another element to the collectioncount
,drop
,take
, etc. are also candidates
Needed
The first element of
coll
thatpred
returnstrue
for ORnil
if there isnât one
I posit that this is dead obvious to anyone skilled in Clojure. But to a newbie, it may be difficult to generate. And, admittedly, there are many operations on collections that I did not list because I know they are not needed. These include map, filter, reduce, partition, concat, etc. These can be learned over time. And they are built out of the first four (empty?
, first
, rest
, conj
), so theyâre basically shortcuts.
This list of givens and needed, plus a little case analysis, will get you there with no muss. Letâs take a look.
First, we canât call first
and rest
on coll
because we donât know if there is a first element (and what would we do with the nil
it returned?). coll
could be empty. And we donât have anything to put into coll
, so we canât call conj
. It really only leaves empty?
, which gives us our first case:
(defn find-by [pred coll]
(cond
(empty? coll)
And we know that if we have an empty collection, we have not found what we are looking for, and the spec says to return nil
in that case.
(defn find-by [pred coll]
(cond
(empty? coll)
nil
Now, in the next case, we know itâs non-empty. We donât need empty? anymore. We could get the first element or the rest of the elements. We canât conj because we still donât have one.
If we get the first
element, that will let us subdivide into two cases again by using pred
. So letâs get the first element. Notice that I had to think one step ahead to decide. We have limited lookahead ability, but one step is within that limit.
(defn find-by [pred coll]
(cond
(empty? coll)
nil
(pred (first coll))
Now, if pred
returns true, we are done! Itâs easy to see that this fulfills the needed: The first element, if it returns true for pred
, is the first element that returns true for pred
. So we return it.
(defn find-by [pred coll]
(cond
(empty? coll)
nil
(pred (first coll))
(first coll)
Now we know that (pred first)
is not true
. We need to handle that case. We donât need empty?
, we donât need first
(itâs useless now), and we donât have anything to conj
. So we need the rest
. Now hereâs a trick that we didnât put on the list, which we should have: You can always call the function youâre in recursively.
Knowns
find-by
- takes a predicate and a collection
And guess what: We have a predicate
, and rest
will give us a new collection! And because itâs in tail position, in Clojure we should use the recur
form:
(defn find-by [pred coll]
(cond
(empty? coll)
nil
(pred (first coll))
(first coll)
:else
(recur pred (rest coll))))
And weâre done. We listed out all of the givens (the arguments), the operations that apply to the givens, and the needed values. Then, at each step, we used the process of elimination to figure out what to do next. Occasionally we needed to think one extra step ahead. In more complex code, sometimes the elimination will leave you more than one option to explore. But it is surprising how much the simple process of elimination can make the path ahead obvious. Try it on problems you find challenging.
As you practice this method, youâll find that your mind eliminates options faster. The right next step will pop out to you brighter and quicker. Youâll be able to do more challenging problems. You will not need to externally list the givens and their operations. You can do it in your head.
But that takes time. I remember when I taught math. Students were writing just the final answer, and if it was wrong, I had no idea how they got it. I asked them to show their work. The students groaned, âWhy do we have to do all of that?â
âWell,â I replied, âif you get the answer right, it doesnât matter. You get full credit. But if you get the answer wrong, I have nothing to grade. I canât give you partial credit. If you show me the steps, I can give you one point per correct step.â
They groaned again. Some of them did start doing it, and they got partial credit. But the ironic thing was, those who did it were more likely to get the answer right. They were more careful. They had extended their working memory to the paper. But, also, they practiced the discipline of listing the givens, the formulas, and the needed values. The repetition made it easier for them to recall them. And they got better faster. By the end of the class, they could do it in their heads and write just the correct answer.
Sometimes I wonder if I should have been more strict on that. If a kid canât understand the logic of partial credit (as I couldnât as late as in college physics), maybe the logic of immediate reward/punishment could have gotten them to do it, and theyâd be better off.
It took me a long time to learn that when someone makes something look easy, itâs probably because they worked really hard to develop a high level of skill. So instead of doing what the best mathematicians do, which looks like they just write a number down, perhaps trusting their intuition, we need to do what they did to get there, which was careful practice.
So, no matter what your current skill level, you have an edge of challenge. What you do at the edge determines if you improve your skill or stay at that level. When you get stuck, youâre now a beginner again. Go back to the basics. Write out what you know. Start to eliminate possibilities. Look a few steps ahead. Do a case analysis and get the easy ones out of the way. This is how we grow.
This essay was partly inspired by a lecture by Edsger Dijkstra, structured thinker extraordinaire: