Decomposition

We have previously discussed Decomposition as a prime tactic for managing complexity in code. Decomposition is the process of breaking down a complex problem or system into smaller, more manageable parts.

You don’t ever have to decompose. Strictly speaking, functions are not necessary. When code is executed internally at the deepest layer of the system, it is all just a series of instructions being executed in order. There was a period of time in programming where all code was written in a single monolithic block, without any functions or decomposition.

This was a nightmare, and seriously prevented people from writing programs at scale. It is simply a fact that human minds have finite mental windows, and can only handle so much complexity at size. Decomposition is a defensive mechanism that makes your life easier.

De-decomposing (Inlining)

@route
def submit_guess(state: State, guess: str) -> Page:
    guess = guess.lower().strip()
    if not (not len(guess) > 1 and guess in printable and state.guesses not in guess):
        return play_level(state)

    state.guesses.append(guess)
    if not (guess in state.secret):
        state.wrong += 1

    is_win = True
    if state.secret[0] not in state.guesses:
        is_win = False
    if state.secret[1] not in state.guesses:
        is_win = False
    if state.secret[2] not in state.guesses:
        is_win = False
    if state.secret[3] not in state.guesses:
        is_win = False

    if is_win:
        return win_level(state)

    if state.wrong >= 6:
        return lose_level(state)

    return play_level(state)

Sometimes, often for performance reasons, decomposition is reversed, and the code that was removed to a function is placed back into the main body of the code. This is known as inlining. For low-level languages, this can sometimes lead to a performance boost, since it reduces the overhead of function calls. However, most of the time, this boost is so negligible that it is not worth the loss of readability and maintainability that comes with inlining.

In the code shown here, we have created a new version of submit_guess that inlines the logic of all the helper functions. We have left the route function calls intact, but you can imagine that they could also be inlined. Considering each of those functions is a dozen or more lines of code, that would make the overall length of code almost twice as long! Although some folks might be fine reading this, we believe that it makes the code harder to understand.

On the other hand, some of our helper functions are quite small and simple, and inlining them might not have a significant impact on readability. The is_game_over and in_secret functions in particular are good candidates for inlining, as they are both short and self-contained. It is a matter of personal taste and experience to decide when it is worth inlining vs. decomposing.

Revealing the Error in reveal

def reveal(secret: str, guesses: list[str]) -> str:
    if secret[0] in guesses:
        c0 = secret[0]
    else:
        c0 = "_"
    if secret[1] in guesses:
        c1 = secret[1]
    else:
        c1 = "_"
    if secret[2] in guesses:
        c2 = secret[1]
    else:
        c2 = "_"
    if secret[3] in guesses:
        c3 = secret[3]
    else:
        c3 = "_"
    return c0 + c1 + c2 + c3

That said, a real benefit of inlining complex code is that it can help you identify errors you didn’t even know that you had. Consider the code shown here for the reveal function. Astute readers might have already noticed that it has a subtle mistake: the third if statement checks secret[2] against guesses, but it assigns c2 to secret[1] instead of secret[2]. Most likely, when the developer was writing this code block, they were repeatedly copy-pasting the same thing, and didn’t notice the minor typo. If there was less code to read, they might have noticed the mistake. That’s why this code block is a prime candidate for decomposition. We can extract the repeated if check into a separate helper function, although we will need to vary the index checked each time.

Improving reveal

def reveal_letter(secret: str, guesses: list[str], index: int) -> str:
    if secret[index] in guesses:
        return secret[index]
    return "_"

def reveal(secret: str, guesses: list[str]) -> str:
    c0 = reveal_letter(secret, guesses, 0)
    c1 = reveal_letter(secret, guesses, 1)
    c2 = reveal_letter(secret, guesses, 2)
    c3 = reveal_letter(secret, guesses, 3)
    return c0 + c1 + c2 + c3

In our new version of reveal shown here, we use a helper function named reveal_letter to encapsulate the logic for revealing a single letter in the secret word. This makes the reveal function much cleaner and easier to read. If we had written 2 instead of 1 for the third call, we are much more likely to notice the mistake.

A downside to this extraction was that we had to introduce a new variable (index) as a parameter for reveal_letter, since the index varies each time. This is not purely unfortunate, because it clarifies the purpose of the previously magic variable. However, it is more characters that have to be read and interpreted. These tradeoffs must always be considered when decomposing. If the number of new parameters adds more complexity than it removes, you might decide to keep the code more monolithic.

In the case of reveal, we believe that the added complexity is absolutely worth the tradeoff of a new function, so we will use this version going forward.

Summary