Rule 4. Generalization Takes Three Examples
We’re all taught as new programmers that general solutions are preferable to specific ones. Better to write one function that solves two problems than to write separate functions for each problem.
You’re unlikely to write this code:
Sign
*
findRedSign
(
const
vector
<
Sign
*>
&
signs
)
{
for
(
Sign
*
sign
:
signs
)
if
(
sign
->
color
()
==
Color
::
Red
)
return
sign
;
return
nullptr
;
}
When it would be easy to write this code:
Sign
*
findSignByColor
(
const
vector
<
Sign
*>
&
signs
,
Color
color
)
{
for
(
Sign
*
sign
:
signs
)
if
(
sign
->
color
()
==
color
)
return
sign
;
return
nullptr
;
}
It’s natural to think in terms of generalization, especially for such a simple example. If you need to find all the red signs in the world, your natural instinct as a programmer is to write the code to find signs of an arbitrary color, then pass in red as that color. Nature abhors a vacuum; programmers abhor code that only solves one problem.
It’s worth thinking about why this feels so natural. At some level, the instinct to write findSignByColor
instead of findRedSign
is based on a prediction. Given that you’re looking for a red sign, you can confidently predict that at some point you’ll want to look for a blue sign and write the code to handle that case too.
In fact, why stop there? Why not write an even more general solution for finding signs?
You could create a more general interface that lets you query any aspect of the sign—color, size, location, text—so that searching for a sign by color is just a special subcase. You might do this by creating a structure defining the acceptable values for each aspect of a sign:
bool
matchColors
(
const
vector
<
Color
>
&
colors
,
Color
colorMatch
)
{
if
(
colors
.
empty
())
return
true
;
for
(
Color
color
:
colors
)
if
(
color
==
colorMatch
)
return
true
;
return
false
;
}
bool
matchLocation
(
Location
location
,
float
distanceMax
,
Location
locationMatch
)
{
float
distance
=
getDistance
(
location
,
locationMatch
);
return
distance
<
distanceMax
;
}
struct
SignQuery
{
SignQuery
()
:
m_colors
(),
m_location
(),
m_distance
(
FLT_MAX
),
m_textExpression
(
".*"
)
{
;
}
bool
matchSign
(
const
Sign
*
sign
)
const
{
return
matchColors
(
m_colors
,
sign
->
color
())
&&
matchLocation
(
m_location
,
m_distance
,
sign
->
location
())
&&
regex_match
(
sign
->
text
(),
m_textExpression
);
}
vector
<
Color
>
m_colors
;
Location
m_location
;
float
m_distance
;
regex
m_textExpression
;
};
Designing the query parameters requires some judgment calls, since each aspect forces a different query model. In this example, the judgment calls I made were:
-
Rather than specifying a single color, you can provide a list of acceptable colors. An empty list specifies that any color is acceptable.
-
Internally, a
Location
stores latitude and longitude as floating-point values, so looking for an exact match isn’t useful. Instead, you would specify a maximum distance from some location. -
You could use a regular expression to match the text or partial text of the sign, which would handle a lot of obvious cases.
The actual code to find a matching sign is simple:
Sign
*
findSign
(
const
SignQuery
&
query
,
const
vector
<
Sign
*>
&
signs
)
{
for
(
Sign
*
sign
:
signs
)
if
(
query
.
matchSign
(
sign
))
return
sign
;
return
nullptr
;
}
Finding a red sign with this model is still pretty straightforward—create a SignQuery
, specify red as the only acceptable color, then call findSign
:
Sign
*
findRedSign
(
const
vector
<
Sign
*>
&
signs
)
{
SignQuery
query
;
query
.
m_colors
=
{
Color
::
Red
};
return
findSign
(
query
,
signs
);
}
Remember that the design of SignQuery
is based on one example: finding a single red sign. The rest is all conjecture. At this point there aren’t other examples to build on, so you’re just predicting what other kinds of signs you’ll need to find.
And that’s the problem—your predictions are likely to be wrong. If you’re lucky, they’ll only be a little bit wrong…but you probably won’t be lucky.
YAGNI
Most obviously, you’ll anticipate and solve for cases that never occur in practice. Maybe the first few sign-finding use cases look like this:
-
Find a red sign.
-
Find a sign near the corner of Main Street and Barr Street.
-
Find a red sign near 212 South Water Street.
-
Find a green sign.
-
Find a red sign near 902 Mill Street.
You can solve all of these cases with SignQuery
and findSign
, so in that sense the code does a decent job predicting the use cases. But I don’t see any cases where you’re accepting multiple sign colors, and none of the use cases looks at the sign’s text. All the actual use cases look for a single color, at most, and some restrict to a location. The SignQuery
code solves for cases that aren’t occurring in practice.
This is a common pattern, common enough that the Extreme Programming philosophy has a name for it—YAGNI, or “You Ain’t Gonna Need It.” The work you did to define a list of acceptable colors rather than the single color in your known use case? Wasted time and effort. The experiments you did with the C++ regular expression class, figuring out how to distinguish complete matches from partial? That’s time you’re not getting back.
What’s more, the extra complexity of SignQuery
imposes a cost on anyone using it. It’s pretty obvious how to use the findSignByColor
function, but findSign
requires a little more investigation. There are three different querying models packed into it, after all!
Is a partial match of the regular expression sufficient, or does the expression need to match the entire text of the sign? It’s not obvious how the three conditions interact—is this an “and” or an “or”? If you read the code, it’s clear that a sign matches the query only if all of the conditions match, but that requires reading the code. Which introduces a new bit of confusion—which SignQuery
fields are required? As written, an empty query straight out of the constructor matches all signs, so you only need to set fields that you’re filtering on—but learning this required some investigation.
Given the clear pattern in the real-world use cases, it would have been better to have just solved the actual problem:
Sign
*
findSignWithColorNearLocation
(
const
vector
<
Sign
*>
&
signs
,
Color
color
=
Color
::
Invalid
,
Location
location
=
Location
::
Invalid
,
float
distance
=
0.0f
)
{
for
(
Sign
*
sign
:
signs
)
{
if
(
isColorValid
(
color
)
&&
sign
->
color
()
!=
color
)
{
continue
;
}
if
(
isLocationValid
(
location
)
&&
getDistance
(
sign
->
location
(),
location
)
>
distance
)
{
continue
;
}
return
sign
;
}
return
nullptr
;
}
Your response at this point might be to accuse me of cheating. Sure, now that the first few use cases are on the table, it seems like findSignWithColorNearLocation
is a better solution than SignQuery
—but you couldn’t have predicted that after the first use case. Writing findSignWithColorNearLocation
as a general solution wasn’t any more likely to succeed than writing SignQuery
turned out to be. One of the use cases might have allowed multiple colors or might have referred to the text of the signs.
That’s exactly my point! No general solution was predictable after one use case, so it was a mistake to try to write one. Both findSignWithColorNearLocation
and SignQuery
are mistakes. There’s no winner here, just two losers.
Here’s the best way to find a red sign:
Sign
*
findRedSign
(
const
vector
<
Sign
*>
&
signs
)
{
for
(
Sign
*
sign
:
signs
)
if
(
sign
->
color
()
==
Color
::
Red
)
return
sign
;
return
nullptr
;
}
Yes, I’m serious. I might pass in the color to match, but that’s as far as I’d go. If you’ve got one use case, write code to solve that use case. Don’t try to guess what the second use case will be. Write code to solve problems you understand, not ones you’re guessing at.
An Obvious Objection to This Strategy, in Response to Which I Double Down
“Wait a second,” you may say at this point. “Doesn’t writing code that barely meets the requirements of the use case guarantee that you’ll run into use cases that the code won’t handle? What do you do when the next use case that pops up doesn’t fit the code you’ve written? That seems inevitable.”
“And isn’t this an argument for writing more general code? Sure, the first five use cases we ran into with SignQuery
didn’t exercise all of the code we wrote, but what if the sixth use case did? Wouldn’t we be glad to have the SignQuery
code all written and ready to go when that happened?”
No, not really. Save your effort. When a use case pops up that your code doesn’t handle, write code to handle it. You might cut and paste your first effort, making adjustments to handle the new use case. You might start again from scratch. Both are fine.
The first use case in the list of five was “Find a red sign,” and I wrote code to do exactly that and no more. The second use case was “Find a sign near the corner of Main Street and Barr Street,” so now I’ll write code to do exactly that and no more:
Sign
*
findSignNearLocation
(
const
vector
<
Sign
*>
&
signs
,
Location
location
,
float
distance
)
{
for
(
Sign
*
sign
:
signs
)
{
if
(
getDistance
(
sign
->
location
(),
location
)
<=
distance
)
{
return
sign
;
}
}
return
nullptr
;
}
The third use case was “Find a red sign near 212 South Water Street,” and this isn’t handled by either of the two functions I’ve written. This is the inflection point—now that we’ve got three independent use cases, it’s starting to make sense to generalize. With three independent use cases, we can more confidently predict the fourth and fifth.
Why three? What makes three a magic number? Nothing, really, except for the fact that it’s not one or two. One example isn’t enough to guess the general pattern. Based on my experience, two usually isn’t either—after two examples, you’ll just be more confident in your inaccurate generalization. With three different examples, your prediction of the pattern will be more accurate and you’re likely to be a little bit more conservative in your generalization. Nothing like being wrong after examples one and two to leave you humble!
Still, there’s no requirement that you generalize at this point! It would be perfectly fine to write a third function without folding the first two functions into it:
Sign
*
findSignWithColorNearLocation
(
const
vector
<
Sign
*>
&
signs
,
Color
color
,
Location
location
,
float
distance
)
{
for
(
Sign
*
sign
:
signs
)
{
if
(
sign
->
color
()
==
color
&&
getDistance
(
sign
->
location
(),
location
)
>=
distance
)
{
return
sign
;
}
}
return
nullptr
;
}
This three-separate-functions approach has one important benefit—the functions are very simple. It’s obvious which one of them to call. If you have a color and a location, call findSignWithColorNearLocation
. If you just have a color, it’s findSignWithColor
; if you just have a location, it’s findSignNearLocation
.1
If your sign-finding use cases continue to check for a single color and/or location, those three functions will be fine forever. The approach doesn’t scale very well, of course—with two separate arguments and three separate findSign
functions the approach isn’t a disaster, but with more possible arguments it quickly becomes ridiculous. If at some point you have a use case that involves looking at the sign text, you’ll probably shy away from creating seven variations of the findSign
function.
There’s nothing wrong at this point with combining the three findSign
functions into a single function that handles all three cases. Once you have three separate use cases it’s safer to generalize. But generalize only if you think it makes the code easier to write and read, based solely on the use cases you have in hand. Never generalize because you’re worried about the next use case—only generalize on the use cases you know.
Writing generalized code in C++ for this is a little painful because C++ doesn’t really have optional arguments, only default values for arguments. That means inventing some way to mark our arguments as “not present.” One solution is to add Invalid
values for color and location to use when we don’t care about them. Repeating the first version of findSignWithColorNearLocation
:
Sign
*
findSignWithColorNearLocation
(
const
vector
<
Sign
*>
&
signs
,
Color
color
=
Color
::
Invalid
,
Location
location
=
Location
::
Invalid
,
float
distance
=
0.0f
)
{
for
(
Sign
*
sign
:
signs
)
{
if
(
isColorValid
(
color
)
&&
sign
->
color
()
!=
color
)
{
continue
;
}
if
(
isLocationValid
(
location
)
&&
getDistance
(
sign
->
location
(),
location
)
>
distance
)
{
continue
;
}
return
sign
;
}
return
nullptr
;
}
With this function written, all the calls to findSignWithColor
and findSignNearLocation
could be replaced with calls to findSignWithColorNearLocation
.
It’s Actually Worse than YAGNI
So far you’ve seen that generalizing prematurely means you’re likely to write code that never gets exercised, and that’s bad. The less obvious problem is that generalizing prematurely makes it harder to adapt to unanticipated use cases. That’s partly because the generalized code you’ve written is more complicated and therefore takes more work to adjust, but there’s also something more subtle that happens. Once you’ve established the template for generalization, you’re likely to extend that template for future use cases instead of reevaluating it.
Roll back the clock a bit. Imagine that you generalized early with the SignQuery
class, but this time the first few use cases look like this:
-
Find a red sign.
-
Find a red “STOP” sign near the corner of Main Street and Barr Street.
-
Find all the red or green signs on Main Street.
-
Find all white signs with text “MPH” on Wabash Avenue or Water Street.
-
Find a sign with the text “Lane” or colored blue near 902 Mill Street.
The first two use cases in this list fit SignQuery
pretty well, but then things start to fall apart.
The third use case, “Find all the red or green signs on Main Street,” adds two new requirements. First, the code needs to return all matching signs instead of a single sign. That’s not hard:
vector
<
Sign
*>
findSigns
(
const
SignQuery
&
query
,
const
vector
<
Sign
*>
&
signs
)
{
vector
<
Sign
*>
matchedSigns
;
for
(
Sign
*
sign
:
signs
)
{
if
(
query
.
matchSign
(
sign
))
matchedSigns
.
push_back
(
sign
);
}
return
matchedSigns
;
}
The second new requirement is to find all signs along a street, and that’s trickier. Assuming streets can be represented as a series of line segments connecting locations, both locations and streets can be packaged into a new Area
struct:
struct
Area
{
enum
class
Kind
{
Invalid
,
Point
,
Street
,
};
Kind
m_kind
;
vector
<
Location
>
m_locations
;
float
m_maxDistance
;
};
static
bool
matchArea
(
const
Area
&
area
,
Location
matchLocation
)
{
switch
(
area
.
m_kind
)
{
case
Area
::
Kind
::
Invalid
:
return
true
;
case
Area
::
Kind
::
Point
:
{
float
distance
=
getDistance
(
area
.
m_locations
[
0
],
matchLocation
);
return
distance
<=
area
.
m_maxDistance
;
}
break
;
case
Area
::
Kind
::
Street
:
{
for
(
int
index
=
0
;
index
<
area
.
m_locations
.
size
()
-
1
;
++
index
)
{
Location
location
=
getClosestLocationOnSegment
(
area
.
m_locations
[
index
+
0
],
area
.
m_locations
[
index
+
1
],
matchLocation
);
float
distance
=
getDistance
(
location
,
matchLocation
);
if
(
distance
<=
area
.
m_maxDistance
)
return
true
;
}
return
false
;
}
break
;
}
return
false
;
}
Then the new Area
struct can replace the location and maximum distance in SignQuery
:
struct
SignQuery
{
SignQuery
()
:
m_colors
(),
m_area
(),
m_textExpression
(
".*"
)
{
;
}
bool
matchSign
(
const
Sign
*
sign
)
const
{
return
matchColors
(
m_colors
,
sign
->
color
())
&&
matchArea
(
m_area
,
sign
->
location
())
&&
regex_match
(
sign
->
m_text
,
m_textExpression
);
}
vector
<
Color
>
m_colors
;
Location
m_location
;
float
m_distance
;
regex
m_textExpression
;
};
The fourth use case asks for all speed-limit signs on either of two streets, which doesn’t fit. It’s easy enough to support a list of areas:
bool
matchAreas
(
const
vector
<
Area
>
&
areas
,
Location
matchLocation
)
{
if
(
areas
.
empty
())
return
true
;
for
(
const
Area
&
area
:
areas
)
if
(
matchArea
(
area
,
matchLocation
))
return
true
;
return
false
;
}
Then you can replace the single area in SignQuery
with a list:
struct
SignQuery
{
SignQuery
()
:
m_colors
(),
m_areas
(),
m_textExpression
(
".*"
)
{
;
}
bool
matchSign
(
const
Sign
*
sign
)
const
{
return
matchColors
(
m_colors
,
sign
->
color
())
&&
matchAreas
(
m_areas
,
sign
->
location
())
&&
regex_match
(
sign
->
m_text
,
m_textExpression
);
}
vector
<
Color
>
m_colors
;
vector
<
Area
>
m_areas
;
regex
m_textExpression
;
};
Use case five really mixes things up—it’s looking for a sign to mark a point of historical interest. Those signs are usually blue, so it looks for that, but it also might be green with particular text. That doesn’t fit the model in SignQuery
.
Again, not impossible. Adding Boolean operations to SignQuery
addresses the new use case:
struct
SignQuery
{
SignQuery
()
:
m_colors
(),
m_areas
(),
m_textExpression
(
".*"
),
m_boolean
(
Boolean
::
None
),
m_queries
()
{
;
}
~
SignQuery
()
{
for
(
SignQuery
*
query
:
m_queries
)
delete
query
;
}
enum
class
Boolean
{
None
,
And
,
Or
,
Not
};
static
bool
matchBoolean
(
Boolean
boolean
,
const
vector
<
SignQuery
*>
&
queries
,
const
Sign
*
sign
)
{
switch
(
boolean
)
{
case
Boolean
::
Not
:
return
!
queries
[
0
]
->
matchSign
(
sign
);
case
Boolean
::
Or
:
{
for
(
const
SignQuery
*
query
:
queries
)
if
(
query
->
matchSign
(
sign
))
return
true
;
return
false
;
}
break
;
case
Boolean
::
And
:
{
for
(
const
SignQuery
*
query
:
queries
)
if
(
!
query
->
matchSign
(
sign
))
return
false
;
return
true
;
}
break
;
}
return
true
;
}
bool
matchSign
(
const
Sign
*
sign
)
const
{
return
matchColors
(
m_colors
,
sign
->
color
())
&&
matchAreas
(
m_areas
,
sign
->
location
())
&&
regex_match
(
sign
->
m_text
,
m_textExpression
)
&&
matchBoolean
(
m_boolean
,
m_queries
,
sign
);
}
vector
<
Color
>
m_colors
;
vector
<
Area
>
m_areas
;
regex
m_textExpression
;
Boolean
m_boolean
;
vector
<
SignQuery
*>
m_queries
;
};
Whew. That was a more demanding set of use cases than the set we saw in the beginning of this Rule. After making a lot of changes, though, the QuerySign
model can handle a broad range of requests. There are reasonable requests that still can’t be answered—“find two signs within 10 meters of each other,” say—but it’s easy to imagine that we’ve covered the important cases. Victory, right?
This Is Not What Success Looks Like
Actually, it’s not clear that extending SignQuery
so much has put us in a good spot, even though I was being scrupulously fair—there’s no YAGNI in any of the extensions, and I kept everything as neat and tidy as I could.
When you continue to extend a general solution, you can lose sight of the context. That’s exactly what has happened here.
Let’s compare solving that last use case using SignQuery
with doing the same thing directly. Here’s the SignQuery
solution:
SignQuery
*
blueQuery
=
new
SignQuery
;
blueQuery
->
m_colors
=
{
Color
::
Blue
};
SignQuery
*
locationQuery
=
new
SignQuery
;
locationQuery
->
m_areas
=
{
mainStreet
};
SignQuery
query
;
query
.
m_boolean
=
SignQuery
::
Boolean
::
Or
;
query
.
m_queries
=
{
blueQuery
,
locationQuery
};
vector
<
Sign
*>
locationSigns
=
findSigns
(
query
,
signs
);
And here’s the direct version:
vector
<
Sign
*>
locationSigns
;
for
(
Sign
*
sign
:
signs
)
{
if
(
sign
->
color
()
==
Color
::
Blue
||
matchArea
(
mainStreet
,
sign
->
location
()))
{
locationSigns
.
push_back
(
sign
);
}
}
The direct solution is better. It’s simpler, it’s easier to understand, it’s easier to debug, it’s easier to extend. All the work we did on SignQuery
just led us further and further away from the simplest and best answer. And that’s the real danger in premature generalization—not just that you’ll implement features that never get used, but that your generalization establishes a direction that will be hard to change.
Generalized solutions are really sticky. Once you establish an abstraction to solve a problem, it’s hard to even conceive of alternatives. Once you use findSigns
to find all the red signs, your instinct will be to use findSigns
whenever you need to find signs of any sort. The very name of the function tells you to do that!
So if you’ve got a case that doesn’t quite fit, the obvious answer is to extend SignQuery
and findSigns
to cover the new case. The same goes for the next case that doesn’t fit, and the one after that. As the general solution becomes more expressive, it also becomes more cumbersome...and unless you’re very careful, you won’t even notice that you’ve extended your generalization past its natural bounds.
When you’re holding a hammer, everything looks like a nail, right? Creating a general solution is handing out hammers. Don’t do it until you’re sure that you’ve got a bag of nails instead of a bag of screws.2
1 Or, if you’re using a language like C++ that supports function overloading, you could call all three versions of findSign
and let the compiler sort things out.
2 You can use a hammer to drive a screw, by the way. You just have to swing the hammer harder. At the risk of being painfully obvious, the same is true of code. You can get things to work with an awkward abstraction—you just have to swing the abstraction harder.
Get The Rules of Programming now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.