Simple But No Simpler

I've learned some valuable lessons from my own mistakes and from others. This article shares these lessons to help you avoid common pitfalls.

The key to good programming is simplicity.

Einstein said, "As simple as possible, but no simpler."

This means finding the simplest way to solve a problem without sacrificing effectiveness.

Let's take a simple example: counting the number of "on" bits in an integer. There are many ways to do this, but one approach is to repeatedly set a bit to zero and count how many times it was already off:

function countSetBits(value: number): number {
    let count = 0;
    while (value) {
        count++;
        value = value & (value - 1);
    }
    return count;
}

Or you might opt for a loop-free implementation, with bit shifting and masking to count the bits in parallel:

function countSetBits(value: number): number {
    value = ((value & 0xaaaaaaaa) >>> 1) + (value & 0x55555555);
    value = ((value & 0xcccccccc) >>> 2) + (value & 0x33333333);
    value = ((value & 0xf0f0f0f0) >>> 4) + (value & 0x0f0f0f0f);
    value = ((value & 0xff00ff00) >>> 8) + (value & 0x00ff00ff);
    value = ((value & 0xffff0000) >>> 16) + (value & 0x000ffff);
    return value & 0x3f; // Mask to 6 bits, as the result can never exceed 32 bits
}

Or you might just write the most obvious code possible:

function countSetBits(value: number): number {
    let count = 0;
    for (let bit = 0; bit < 32; ++bit) {
        if (value & (1 << bit)) {
            count++;
        }
    }
    return count;
}

The first two examples were clever but hard to understand. They had a surprise twist inside the loop that took some effort to figure out. Even with a head start, it would have been harder to understand if I didn't explain what the code did beforehand. The third example is simple and obvious. It directly shows how to count set bits, making it the best choice.

Measuring simplicity

Several ways exist to evaluate code simplicity. One approach is to measure how easily another colleague can comprehend the code. If someone unfamiliar with the code can understand it instantly, it's considered simple.

Another approach is to measure how easily the code can be written and tested. Complicated code takes longer to get right, whereas simple code is quicker to complete.

These two measures align, as code that's easy to write is often easy to read. Other valid measures of complexity include:

Line of code

Simpler code tends to be shorter, though it’s possible to jam a lot of complexity into a single line of code.

Number of ideas

Simple code tends to build on the concepts that everyone on your team knows; it doesn’t introduce new ways of thinking about problems or new terminology.

Time of explaination

Simple code is easy to explain and review. Complicated code requires explanation. Code's simplicity varies depending on the perspective. Focus on ease of creation and comprehension to create simple code. Work efficiently and quickly to create easy-to-read code.

Simple,but no simpler

It’s better for code to be simpler, but it still needs to solve the problem it intends to solve.

Imagine that you’re trying to count how many ways there are to climb a ladder with some number of rungs, given the stipulation that you gain 1, 2, or 3 rungs with each step. If the ladder has 2 rungs, there are 2 ways to climb it—either you step on the 1st rung or not. Similarly, there are 4 ways to climb a 3-rung ladder—step on the 1st rung, step on the 2nd rung, step on the 1st and 2nd rungs, or step directly to the top rung. A 4-rung ladder can be climbed in 7 ways,a 5-rung ladder in 13 ways, and so on.

You can write simple code to calculate this recursively:

function countStepPatterns(stepCount: number): number {
    if (stepCount < 0) {
        return 0;
    }
    if (stepCount === 0) {
        return 1;
    }
    return countStepPatterns(stepCount - 3) +
           countStepPatterns(stepCount - 2) +
           countStepPatterns(stepCount - 1);
}

The basic idea is that any journey up the ladder has to step to the top rung from one of the three rungs below it. Adding the number of ways to climb to each of those rungs gives the number of ways to climb to the top rung. After that, it’s just a matter of figuring out the base cases. The preceding code allows negative step counts as a base case to make the recursion simpler.

Unfortunately, this solution doesn’t work.

Well, it does work, at least for small stepCount values, but countStepPatterns(20) takes about twice as long to complete as countStepPatterns(19).

Computers are really fast, but exponential growth like this will catch up to that speed. In my test, the example code started getting pretty slow once stepCount got into the twenties.

If you’re expected to count the number of ways up longer ladders, then this code is too simple.

The core issue is that all the intermediate results of countStepPatterns are recalculated over and over, and this leads to exponential run times.

A standard answer to this is memoization—hanging onto the calculated intermediate values and reusing them, as in this example:

function countStepPatternsMemo(memo: Map<number, number>, rungCount: number): number {
    if (rungCount < 0) return 0;
    if (rungCount === 0) return 1;

    if (memo.has(rungCount)) {
        return memo.get(rungCount)!;
    }

    const stepPatternCount = countStepPatternsMemo(memo, rungCount - 3) +
                             countStepPatternsMemo(memo, rungCount - 2) +
                             countStepPatternsMemo(memo, rungCount - 1);

    memo.set(rungCount, stepPatternCount);
    return stepPatternCount;
}

function countStepPatterns(rungCount: number): number {
    const memo = new Map<number, number>();
    return countStepPatternsMemo(memo, rungCount);
}

Memoization helps each value be calculated only once and stored in a map. Next calls find this value in the map in constant time, removing the exponential growth issue. Memoized code is slightly more complex, but avoids performance limits. If you prefer, you can use dynamic programming for simpler code with a slight increase in complexity.

function countStepPatterns(rungCount: number): number {
    // Array to hold the count of step patterns for each rung
    const stepPatternCounts: number[] = [0, 0, 1];
    // Iterate through each rung and calculate the number of step patterns
    for (let rungIndex = 0; rungIndex < rungCount; rungIndex++) {
        stepPatternCounts.push(
            stepPatternCounts[rungIndex] +
            stepPatternCounts[rungIndex + 1] +
            stepPatternCounts[rungIndex + 2]
        );
    }
    // Return the count of step patterns for the final rung
    return stepPatternCounts[stepPatternCounts.length - 1];
}

This approach runs quickly enough, too, and it’s even simpler than the memoized recursive version

Simplify the problem

The problems in the original, recursive version of countStepPatternsappeared for longer ladders. The simplest code worked perfectly well for small numbers of rungs, but hit an exponential performance wall for large numbers of rungs. Later versions avoided the exponential wall at the cost of slightly more complexity…but they soon run into a different problem. If I run the previous code to calculate countStepPatterns(36), I get the right answer, 2,082,876,103. Calling countStepPatterns(37), though, returns –463,960,867. That’s clearly not right!

That’s because the version of typescript I’m using stores number as signed 64-bit values, and calculating countStepPatterns(n) will overflow the available bits.

So maybe the code is still too simple. It seems reasonable to expect countStepPatterns to work for all ladder lengths, right?

Dropping bigint into the last example in place of int produces exact answers for longer ladders:

So…problem solved? With the introduction of bigint, an exact answer can be calculated for much longer ladders.

Before solving a complex problem, examine the problem closely.

Is this really the issue that needs fixing? Are you making assumptions that complicate the solution?

In reality, if you're counting ladder rungs, you can likely assume a maximum length. If the maximum length is 15 rungs, any solution will do.

Add a check to confirm the function's limits and pat yourself on the back.

function countStepPatterns(rungCount: number): number {
    if (rungCount > 36) {
        throw new Error("rungCount must be less than or equal to 36");
    }
    const stepPatternCounts: number[] = [0, 0, 1];
    for (let rungIndex = 0; rungIndex < rungCount; ++rungIndex) {
        stepPatternCounts.push(
            stepPatternCounts[rungIndex] + 
            stepPatternCounts[rungIndex + 1] + 
            stepPatternCounts[rungIndex + 2]
        );
    }

    return stepPatternCounts[stepPatternCounts.length - 1];
}

For very long ladders, an approximate step count may be sufficient. In this case, we can easily switch from integers to floating-point numbers.

Keep in mind that solutions always reach their limits. Trying to solve extreme scenarios can lead to overly complex solutions.

Instead of solving the strictest definition of a problem, focus on finding a simple solution for the actual requirement. If you can't simplify the solution, try to simplify the problem itself.

Simple algorithms

A poor algorithm choice can make code complex. There are many ways to solve a problem, and simpler algorithms lead to simpler code. The issue is finding the simple algorithm. For example, sorting a deck of cards. One approach is to simulate the shuffle you learned as a kid: split the deck, combine the two halves, and repeat until the deck is shuffled.

That might look like this:

type Card = any; // Define the Card type as appropriate

function shuffleOnce(cards: Card[]): Card[] {
    const shuffledCards: Card[] = [];
    const splitIndex = Math.floor(cards.length / 2);
    let leftIndex = 0;
    let rightIndex = splitIndex;

    while (true) {
        if (leftIndex >= splitIndex) {
            shuffledCards.push(...cards.slice(rightIndex));
            break;
        } else if (rightIndex >= cards.length) {
            shuffledCards.push(...cards.slice(leftIndex, splitIndex));
            break;
        } else if (Math.random() < 0.5) {
            shuffledCards.push(cards[rightIndex]);
            rightIndex++;
        } else {
            shuffledCards.push(cards[leftIndex]);
            leftIndex++;
        }
    }

    return shuffledCards;
}

function shuffle(cards: Card[]): Card[] {
    let shuffledCards = [...cards];

    for (let i = 0; i < 7; i++) {
        shuffledCards = shuffleOnce(shuffledCards);
    }

    return shuffledCards;
}

The simulated-riffle-shuffle algorithm works. Here's a simple implementation:

However, this shuffling method can be improved. A simpler approach is to build the shuffled deck one card at a time. On each iteration, swap the new card with a random card in the current deck. This can be done in place.

function shuffle(cards: Card[]): Card[] {
    const shuffledCards = [...cards];
    for (let cardIndex = shuffledCards.length - 1; cardIndex >= 0; cardIndex--) {
        const swapIndex = Math.floor(Math.random() * (cardIndex + 1));
        [shuffledCards[swapIndex], shuffledCards[cardIndex]] = [shuffledCards[cardIndex], shuffledCards[swapIndex]];
    }
    return shuffledCards;
}

This version is superior due to its simplicity. It took less time to write and is easier to read. It has less code, making it simpler to explain. The improvement comes from a better algorithm choice, not the code itself.

Don’t lose the plot

Code is easy to understand when it's simple and follows a clear flow. But code isn't a book, so complexity can be overwhelming.

When too much code is packed into one spot, it can become confusing. Take the riffle-shuffle code, for example, where parts handling right and left piles look alike.

The logic to move one card or a series of cards to the shuffled pile could be split into separate functions, then called from shuffleOnce:

type Card = any; // Replace 'any' with the actual type definition of 'Card'

function copyCard(destinationCards: Card[], sourceCards: Card[], sourceIndex: number): number {
    destinationCards.push(sourceCards[sourceIndex]);
    return sourceIndex + 1;
}

function copyCards(destinationCards: Card[], sourceCards: Card[], sourceIndex: number, endIndex: number): void {
    while (sourceIndex < endIndex) {
        sourceIndex = copyCard(destinationCards, sourceCards, sourceIndex);
    }
}

function shuffleOnce(cards: Card[]): Card[] {
    let shuffledCards: Card[] = [];

    const splitIndex = Math.floor(cards.length / 2);
    let leftIndex = 0;
    let rightIndex = splitIndex;

    while (true) {
        if (leftIndex >= splitIndex) {
            copyCards(shuffledCards, cards, rightIndex, cards.length);
            break;
        } else if (rightIndex >= cards.length) {
            copyCards(shuffledCards, cards, leftIndex, splitIndex);
            break;
        } else if (Math.random() < 0.5) { // rand() & 1 equivalent in TypeScript
            rightIndex = copyCard(shuffledCards, cards, rightIndex);
        } else {
            leftIndex = copyCard(shuffledCards, cards, leftIndex);
        }
    }

    return shuffledCards;
}

The previous version of shuffleOnce was readable top-to-bottom; this one isn’t. That makes it harder to read. While reading through the shuffleOnce code you run into the copyCard or copyCards function. Then you have to track down those functions, figure out what they do, pop back to the original function, then match the arguments passed from shuffleOnce to your new understanding of copyCard or copyCards. That’s a lot harder than just reading the loops in the original shuffleOnce. So, the do-not-repeat-yourself version of the function took more time to write and is harder to read. It’s more code, too!

Removing duplication can make code more complex, not simpler. While reducing duplication is important, it's okay to leave simple duplicates in small amounts of code. It's better for readability and ease of writing.

Complexity is the enemy

Complexity is the enemy of progress in programming. As code grows more complicated, it becomes harder to work with and bugs become harder to fix. Eventually, you'll reach a point where it's impossible to make further progress.

Effective programming is about delaying this inevitability. Add complexity only when necessary, and aim to remove it whenever possible. This applies to both your code and your team's workflow.

If you're dedicated, you can keep complexity at bay indefinitely. Remember that complexity is your enemy, and you'll succeed if you stay vigilant.