Names

Back in the first unit, we talked about how important names are for code readability and maintainability. Choosing the right names can make a significant difference in how easily others (and your future self) can understand your code. Further, if you do not know what to name a variable or function, then you probably do not know what it does. Names are the gateway to understanding, and heavily influence not only the reader of the code, but also the writer.

Previously, we established five criteria for good variable names. Let’s review them again.

Clear

def check_if_valid_guess(guess1: str, guess2: list[str]) -> bool:
    """
    Check if the user's letter guess is valid.

    Args:
        guess1 (str): The first guess, should be a single letter.
        guess2 (list[str]): The list of previous guesses.

    Returns:
        bool: True if the guess is valid, False otherwise.
    """
    is_single_letter = not len(guess1) > 1
    is_letter = guess1 in ascii_letters
    is_not_repeated = guess2 not in guess1
    return is_single_letter and is_letter and is_not_repeated

First, names should be clear: someone looking at the variable name should be able to understand its purpose without needing additional context. That means avoiding ambiguous names, obscure jargon, or names that otherwise are not self-explanatory. For a properly documented function, the variable names should match the names used in the function’s docstring. If you find yourself writing a long description of a variable in the docstring, consider whether you can improve the variable name to make it more self-explanatory.

In the check_if_valid_guess function, we used the parameters guess1 and guess2, which are very poor variable names indeed. It suggests that the two variables are each individual guesses that the player has made, when the reality is that guess1 is the current guess and guess2 is the list of all previous guesses. The fact that guess2 is not plural even though it is a list is very suspicious and should set off alarm bells. Indeed, the poor variable names have made a specific error in the logic, which means they are not actually even correct!

Correct

def check_if_valid_guess(current_guess: str, past_guesses: list[str]) -> bool:
    """
    Check if the user's letter guess is valid.

    Args:
        current_guess (str): The current guess, should be a single letter.
        past_guesses (list[str]): The list of previous guesses.

    Returns:
        bool: True if the guess is valid, False otherwise.
    """
    is_single_letter = not len(current_guess) > 1
    is_letter = current_guess in ascii_letters
    # Incorrect: past_guesses not in current_guess
    is_not_repeated = current_guess not in past_guesses
    return is_single_letter and is_letter and is_not_repeated

Our second criteria was that names should be correct: they should accurately describe the thing they name. This means avoiding misleading names, and being precise about the variable’s purpose and usage. A common trap is that developers will choose a name, but then later change the implementation without updating the name, leading to confusion. It can be a little extra work to keep all your variable names correct, but this is critical for avoiding certain kinds of errors.

Going back to our check_if_valid_guess function, the function checks if guess2 not in guess1, which is incorrect. It should be guess1 not in guess2, checking if the guess is in the list of past guesses. Here we see a revised version of the function where the names have been changed to current_guess and past_guesses. The expression for is_not_repeated becomes much more natural, and the original error obvious. Better variable names helped us catch an error in our code!

Concise

@dataclass
class State:
    sec: str       # secret
    gs: list[str]  # guesses
    wng: int       # wrong
    lvl: int       # level
    scr: int       # score

The third criteria was that names should be concise, which means neither too short nor too long. A good rule of thumb is to aim for names that are descriptive enough to convey their meaning, but not so long that they become unwieldy. If you find yourself frustrated having to type a long name, consider a shorter one. Often, you can achieve conciseness by removing unnecessary words or by using abbreviations that are widely understood. Sometimes, decomposing a function will make it easier to choose a concise name for the function’s variables, because you can focus on the specific role each variable plays in the function.

In our experience, novices are more likely to make a name too short rather than too long. In particular, some people like to remove vowels from names, resulting in names like scr for score and lvl for level. Back when variables were limited to 8 characters or less, this made a lot more sense. But now, with modern IDEs and text editors, we have the ability to use longer, more descriptive names without worrying about character limits.

Take a look at this alternative version of our State class, where all the variable names have been shortened. Although slightly easier to type, this version sacrifices clarity and readability. Does state.sec mean state.secret or state.secondary? Is the state.gs variable referring to state.guesses or state.guess_scores? Is state.wng the number of wrong guesses or the number of winning guesses? These are questions that the developer knows the answer to, but others may not. Remember, the goal is always to minimize confusion, because you will always be more confused reading code than you were when writing it (at least at first).

Consistent and Conventional

# Inconsistent and unconventional names!
def RevealLetter(hidden: str, attempts: list[str], Q: int) -> str:
    if hidden[Q] in attempts:
        return hidden[Q]
    return "_"

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

The last two criteria are that names should be consistent and conventional. Being consistent means that you should follow the same naming schema throughout the program, while being conventional means that you should try to follow the same naming schema as other programmers in your community. Often, programming languages have their own expectations for how to write variable names - in Python, we use underscores in variable and function names, but capital letters in dataclass names. But this also applies to what words you use in the program. If you were calling it a secret_word in one part of the program, you shouldn’t change that variable to be hidden_word or private_word in another part. Consistency in naming helps avoid confusion and makes the code easier to read.

In the bad example shown here, we have our reveal and reveal_letter functions from before. But now we have made changes to make their names inconsistent and unconventional. We have changed reveal_letter to use capital camel case, which is not the standard convention in Python for function names. We have also changed the name of the index variable to be a capital Q, which is not how most Python developers would name a variable being used as an index (they would be more likely to write index or i).

The changes also introduce inconsistency. First, the secret and guesses arguments are matched to the hidden and attempts parameters, even though nothing about them changed on the way. This is likely to make readers wonder if the two sets of names refer to the same thing or not. Sometimes, dropping or adding more context to matching argument-parameter pairs can add more clarity, but that is not happening here. Finally, the second variable in the body of reveal is named letter1 instead of c1. A reader would quite correctly wonder if there was supposed to be something different about that variable, even though there isn’t.

These changes make the code harder to read and understand, for no good reason. They do not affect the correctness of the code, since they are only variable names. But they could easily be hiding a mistake of some kind, and further errors are likely to be added down the line when someone needs to modify this code.

Magic Numbers and Constants

def choose_word(level: int) -> str:
    levels = ["heat", "dogs", "jazz"]
    return levels[level]

def is_game_over(level: int) -> bool:
    return level > 4

Separate from the question of how to name variables, is a question of when to use a variable. In the check_if_valid_guess example from before, we introduced temporary variables to hold the result of each expression. This allowed us to clarify what each part was doing and make the code easier to read. This kind of change doesn’t affect the correctness of the code, since they are only variable names. But it might help us identify errors in our logic. A related idea is to extract out literal values into named constants, especially when they are used multiple times or when their meaning is not immediately clear. We call these kinds of literal values “magic numbers” or “magic strings”, since they appear in our source code without explanation even though they might be doing something important.

Both the choose_word and is_game_over functions refer to the concept of levels in the game, where each level has a different four letter word. When the current level is 0, the chosen word is “heat”. When the current level is 1, the chosen word is “dogs”, and so on. This list of levels is clearly central to the game, and is indirectly referenced in the check for whether the game is over, when the code checks if the current level is greater than 4. Unfortunately, this is actually a mistake, because the game needs to be over when the current level is greater than the number of levels in that list. Apparently, the is_game_over function was written or updated without reference to the actual list of levels defined in choose_word. Considering how spatially far apart these two functions are from each other in the codebase, that’s not terribly surprising.

Extracting Constants

LEVELS = ["heat", "dogs", "jazz"]

def choose_word(level: int) -> str:
    return LEVELS[level]

def is_game_over(level: int) -> bool:
    return level > len(LEVELS)

In our enhanced version shown here, we have extracted the magic list literal LEVELS into a named constant. This makes it clear that this list is important and used in multiple places. It also makes it easier to update the list of levels in the future, since we only have to change it in one place. But most importantly, this allows us to make both choose_word and is_game_over correct by ensuring they both refer to the same source of truth for the list of levels. It will be impossible for future editors of the code to get them out of sync with each other.

Once again, the day is saved by good variable names and constants.

Summary