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 findSignWithColorNear​Loca⁠tion 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.