Improve Code Readability and Efficiency - 11 Tips

anthonyhawkins

Anthony Hawkins

Posted on August 11, 2024

Improve Code Readability and Efficiency - 11 Tips

The clarity and efficiency of your code can make a significant difference in how well your program runs and how easily you or others can understand it. This article explores 11 actionable tips to enhance code readability and efficiency, making your code not only execute more efficiently but also look better.

Below is a summary of the techniques we'll be exploring. Before we dive into them, let's examine a few common themes.

  • Avoid Unnecessary Code Execution: The efficiency of your program can be simply defined as the ability to save resources by avoiding the execution of unnecessary logic. This includes practices like preventing unnecessary iterations and returning early from functions to avoid further execution when it's not needed.
  • Keep Your Happy Path to the Left: In your program, you'll have the "happy path," which refers to the scenario where everything goes according to plan. For example, if your function opens a file, reads its contents, and returns a list of lines, this is the happy path. On the other hand, situations like the file not existing or being corrupted are considered edge cases. A good practice is to keep the happy path as left-aligned as possible and push your edge cases into the indented sections.
  • Favor Readability and Meaning: If the code becomes easier to read by deriving meaning from variable and function names, it is better than being terse.

Summary of Techniques

  • Guarded If: Handle special conditions upfront by exiting early when a condition is met to prevent unnecessary code execution.

  • Early Exit: Simplify function logic by returning early, reducing nesting and improving readability.

  • Avoid Unnecessary Iteration: Terminate loops early, or continue to the next iteration when a condition is met to save time and compute resources.

  • Using Negated Conditions: Maintain a clear "happy path" by using negated conditions to handle edge cases, keeping your main logic clear and readable.

  • Explanatory Variables: Use descriptive variables to give meaning to magic numbers and make your code self-explanatory.

  • Use Meaningful Variable Names: Choose variable names that clearly convey their purpose, improving code comprehension.

  • DRY-ish Principle (Don't Repeat Yourself): Minimize code duplication by reusing functions or introducing iteration, making your code more modular and easier to maintain.

  • Using Boolean Conditionals for Assignment: Simplify your code by using boolean expressions for assignments instead of verbose conditional statements.

  • Declare Variables Locally: Limit the scope of variables by declaring them close to where they are used, enhancing both readability and manageability.

  • Use Caching to Avoid Expensive Calculations: Implement caching mechanisms to prevent redundant calculations, enhancing performance.

  • Use Maps/Dictionaries To Avoid Excessive Iteration: Leverage data structures like dictionaries for faster lookups, reducing the need for iteration.

The Techniques

1. Guarded If

The "Guarded If" pattern is a technique where special conditions are handled immediately, allowing the main logic to proceed without unnecessary checks. Notice how we keep the "happy path" as left-aligned as possible. This approach also helps avoid further execution of the function, leading us to our next tip.

Before

def process_customer_data(user):
    if user is not None:
        if user['customer']:
            print("Processing customer data.")
            # More processing logic here (Happy Path)
        else:
            print("User is not a customer.")
            return
            # Edge Case
    else:
        print("No user data provided.")
         # Edge Case

Enter fullscreen mode Exit fullscreen mode

After

def process_customer_data(user):
    if user is None:  # This is the Guarded if, it's "guarding" the rest of the function from edge cases.
        print("No user data provided.")
         # Edge Case
        return

    if not user['customer']:
        print("User is not a customer.")
         # Edge Case
        return

    print("Processing customer data.")
    # More processing logic here (Happy Path)


Enter fullscreen mode Exit fullscreen mode

2. Early Exit

If we can stop the further execution of a function, we increase our efficiency. A common example is when iterating through a list as part of a find function. Once the desired element is found, we should return from the function immediately. There is no need to iterate over the remaining elements.

Before

def find_user(users, username):
    result = None
    for user in users:
        if user['username'] == username:
            result = user
    return result

users = [{'username': 'alice'}, {'username': 'bob'}]
print(find_user(users, 'bob'))  # Output: {'username': 'bob'}

Enter fullscreen mode Exit fullscreen mode

After

def find_user(users, username):
    for user in users:
        if user['username'] == username:
            return user # Once we find what we are looking for, get out of the function!
    return None

users = [{'username': 'alice'}, {'username': 'bob'}]
print(find_user(users, 'bob'))  # Output: {'username': 'bob'}

Enter fullscreen mode Exit fullscreen mode

3. Avoid Unnecessary Iteration

Similar to exiting early, we can use break to prevent unnecessary iteration or use continue to stop the current iteration and proceed to the next one. Notice how our "happy path" is moved further to the left by doing this as well.

Before

def process_transactions(transactions, limit):
    for transaction in transactions:
        if transaction['status'] == 'invalid':
            print(f"Skipping invalid transaction: {transaction['id']}")
        else:
            if transaction['amount'] > limit:
                print(f"Alert! Transaction {transaction['id']} exceeds the limit.")
            else:
                print(f"Processing transaction: {transaction['id']} with amount {transaction['amount']}")
            # Continue checking even after finding a large transaction

# Usage
transactions = [
    {'id': 1, 'amount': 100, 'status': 'valid'},
    {'id': 2, 'amount': 250, 'status': 'valid'},
    {'id': 3, 'amount': 300, 'status': 'invalid'},
    {'id': 4, 'amount': 500, 'status': 'valid'},
    {'id': 5, 'amount': 150, 'status': 'valid'},
]
limit = 400
process_transactions(transactions, limit)

Enter fullscreen mode Exit fullscreen mode

After

def process_transactions(transactions, limit):
    for transaction in transactions:
        if transaction['status'] == 'invalid':
            print(f"Skipping invalid transaction: {transaction['id']}")
            continue  # Skip processing this transaction

        if transaction['amount'] > limit:
            print(f"Alert! Transaction {transaction['id']} exceeds the limit.")
            break  # Stop processing further transactions

        # Process valid transactions within the limit
        print(f"Processing transaction: {transaction['id']} with amount {transaction['amount']}")

# Usage
transactions = [
    {'id': 1, 'amount': 100, 'status': 'valid'},
    {'id': 2, 'amount': 250, 'status': 'valid'},
    {'id': 3, 'amount': 300, 'status': 'invalid'},
    {'id': 4, 'amount': 500, 'status': 'valid'},
    {'id': 5, 'amount': 150, 'status': 'valid'},
]
limit = 400
process_transactions(transactions, limit)

Enter fullscreen mode Exit fullscreen mode

4. Using Negated Conditions

Using negated conditions can help avoid the "pyramid of doom," which occurs when you write a lot of logic within the True or False (else) blocks of if statements. By checking the negative conditions first, you can catch edge cases early, keep your "happy path" as left-aligned as possible, and increase overall readability. If you start to see many else statements, consider negating the conditions to simplify your code.

Before

def process_files_in_directory(directory):
    if os.path.exists(directory):
        files = os.listdir(directory)
        if len(files) > 0:
            for file in files:
                file_path = os.path.join(directory, file)
                if os.path.isfile(file_path) and os.access(file_path, os.R_OK):
                    # This is what our functions is supposed to do
                    # it's indented very far from the left.
                    print(f"Processing file: {file}")
                    process_file(file)
                else:
                    print(f"Cannot read file: {file}")
        else:
            print("No files to process in the directory.")
    else:
        print("Directory does not exist.")
Enter fullscreen mode Exit fullscreen mode

After

def process_files_in_directory(directory):
    if not os.path.exists(directory):
        print("Directory does not exist.")
        return

    files = os.listdir(directory)
    if not files:
        print("No files to process in the directory.")
        return

    for file in files:
        file_path = os.path.join(directory, file)
        if not os.path.isfile(file_path) or not os.access(file_path, os.R_OK):
            print(f"Cannot read file: {file}")
            continue

        # Using negated conditions helps us keep the "Happy Path" of our code
        # as far left as possible
        print(f"Processing file: {file}")
        process_file(file)
Enter fullscreen mode Exit fullscreen mode

5. Explanatory Variable

If certain values have significant meanings, such as a well-known number or a flag passed into a function, you can increase readability by introducing an explanatory variable. This variable's sole purpose is to clarify the meaning of the value.

Before

def convert_mb_to_gb(megabytes):
    return megabytes / 1024

Enter fullscreen mode Exit fullscreen mode

After

def convert_mb_to_gb(megabytes):
    num_mb_in_gb = 1024
    gigabytes = megabytes / num_mb_in_gb
    return gigabytes

Enter fullscreen mode Exit fullscreen mode

6. Use Meaningful Variable Names

Following the theme of readability, favor the use of meaningful names over being terse. Your code will be read many more times than it will be written, and by using descriptive names, you make it easier for yourself and others when revisiting the code.

Before

for k, v in {"apple", 10, "pear": 5, "orange": 6}.items():
  print(k, v)

Enter fullscreen mode Exit fullscreen mode

After

for fruit, qty in {"apple", 10, "pear": 5, "orange": 6}.items():
  print(fruit, qty)
Enter fullscreen mode Exit fullscreen mode

7. DRY-ish Principle (Don't Repeat Yourself, but a little is ok)

The next technique is somewhat controversial, so I'll provide a general rule that I and others follow: if you see a block of code being repeated about three times or more, consider turning it into a function. However, be cautious not to take the DRY (Don't Repeat Yourself) principle too far. Over-abstraction and over-engineering can make your code harder to understand and maintain, as it becomes difficult to keep the context of an algorithm in your mind.

Keeping your code DRY-ish applies not only to functions but also to loops. If you notice repetition, consider using a loop to simplify your code.

Before

# Calculate area and perimeter for Rectangle 1
length1 = 5
width1 = 3
area1 = length1 * width1
perimeter1 = 2 * (length1 + width1)
print(f"Rectangle 1 - Area: {area1}, Perimeter: {perimeter1}")

# Calculate area and perimeter for Rectangle 2
length2 = 8
width2 = 6
area2 = length2 * width2
perimeter2 = 2 * (length2 + width2)
print(f"Rectangle 2 - Area: {area2}, Perimeter: {perimeter2}")

# Calculate area and perimeter for Rectangle 3
length3 = 7
width3 = 4
area3 = length3 * width3
perimeter3 = 2 * (length3 + width3)
print(f"Rectangle 3 - Area: {area3}, Perimeter: {perimeter3}")
Enter fullscreen mode Exit fullscreen mode

After

def calculate_area_and_perimeter(length, width):
    area = length * width
    perimeter = 2 * (length + width)
    return area, perimeter

# List of rectangles with their respective lengths and widths
rectangles = [(5, 3),(8, 6),(7, 4),]

# Loop through each rectangle and calculate area and perimeter
for i, (length, width) in enumerate(rectangles, start=1):
    area, perimeter = calculate_area_and_perimeter(length, width)
    print(f"Rectangle {i} - Area: {area}, Perimeter: {perimeter}")

Enter fullscreen mode Exit fullscreen mode

8. Using Boolean Conditionals for Assignment

Conditionals aren't just for if statements; they're often found in while loops and can be used as Boolean expressions on their own. Instead of using a flag variable with an if statement, consider using a Boolean expression directly.

Before

def has_access(role, is_active):
    access = False # Flag variable 
    if role == 'admin' and is_active:
        access = True
    return access
Enter fullscreen mode Exit fullscreen mode

After

def has_access(role, is_active):
    return role == 'admin' and is_active  # No flag, needed, just use the conditional expression directly.

Enter fullscreen mode Exit fullscreen mode

9. Declare Variables Locally

When you first learn to program, you might develop the habit of placing all your variables at the top. While this is fine in languages like C, the style in most newer languages is to declare variables close to where they are used, also known as locally. This approach increases readability and helps keep your code organized.

Before

# Variables declared at the top
min_number = 1
max_number = 100
target_number = random.randint(min_number, max_number)
guess = None
attempts = 0
max_attempts = 10
game_won = False

print(f"Welcome to the Number Guessing Game!")
print(f"Try to guess the number I'm thinking of between {min_number} and {max_number}.")

while attempts < max_attempts and not game_won:
    try:
        guess = int(input("Enter your guess: "))
        attempts += 1

        if guess < target_number:
            print("Too low! Try again.")
        elif guess > target_number:
            print("Too high! Try again.")
        else:
            print(f"Congratulations! You've guessed the number {target_number} in {attempts} attempts.")
            game_won = True
    except ValueError:
        print("Please enter a valid number.")

if not game_won:
    print(f"Sorry, you've used all {max_attempts} attempts. The number was {target_number}. Better luck next time!")

Enter fullscreen mode Exit fullscreen mode

After

import random

print("Welcome to the Number Guessing Game!")

# These are used in the next two statements
min_number = 1
max_number = 100
print(f"Try to guess the number I'm thinking of between {min_number} and {max_number}.")
target_number = random.randint(min_number, max_number)

#These Belong to the Main Loop
attempts = 0
max_attempts = 10
game_won = False

while attempts < max_attempts and not game_won:
    try:
        # This is only declared when we need it.
        guess = int(input("Enter your guess: "))
        attempts += 1

        if guess < target_number:
            print("Too low! Try again.")
        elif guess > target_number:
            print("Too high! Try again.")
        else:
            print(f"Congratulations! You've guessed the number {target_number} in {attempts} attempts.")
            game_won = True
    except ValueError:
        print("Please enter a valid number.")

if not game_won:
    print(f"Sorry, you've used all {max_attempts} attempts. The number was {target_number}. Better luck next time!")

Enter fullscreen mode Exit fullscreen mode

10. Use Caching to Avoid Expensive Calculations

This technique is used to avoid not only unnecessary execution but also expensive execution. Initializing a cache or a variable that holds the results of calculations or resources for inputs and requests that have already been "seen" by a function can drastically increase performance.

Before

def is_prime(n):
    # Handle edge cases for numbers less than or equal to 1
    if n <= 1:
        return False

    # Check divisibility for potential factors up to the square root of n
    for i in range(2, int(n**0.5) + 1):
        if n % i == 0:
            return False

    # If no divisors found, the number is prime
    return True

# Check if numbers are prime
print(is_prime(29))  # True
print(is_prime(15))  # False
print(is_prime(29))  # True (Recalculated)
Enter fullscreen mode Exit fullscreen mode

After

# Define a global cache dictionary
prime_cache = {}

def is_prime(n):
    # Check if the result is already cached, avoiding calculation
    if n in prime_cache:
        return prime_cache[n]

    # Handle edge cases for numbers less than or equal to 1
    if n <= 1:
        prime_cache[n] = False
        return False

    # Check divisibility for potential factors up to the square root of n
    for i in range(2, int(n**0.5) + 1):
        if n % i == 0:
            prime_cache[n] = False
            return False

    # If no divisors found, the number is prime
    prime_cache[n] = True
    return True

# Test the is_prime function with caching
print(is_prime(29))  # True, calculated and stored
print(is_prime(15))  # False, calculated and stored
print(is_prime(29))  # True, retrieved from cache
print(is_prime(15))  # False, retrieved from cache

Enter fullscreen mode Exit fullscreen mode

11. Use Maps and Dictionaries To Avoid Excessive Iteration

Continuing with the theme of avoiding excess iteration, you can sometimes increase efficiency by switching the type of data structure your algorithm uses. For example, by using a map or dictionary, you can avoid iteration altogether by performing a direct lookup instead.

Before

students_list = [
    {"id": 101, "name": "Alice", "grade": "A"},
    {"id": 102, "name": "Bob", "grade": "B"},
    {"id": 103, "name": "Charlie", "grade": "C"},
]

def find_student_by_id_list(student_id, students):
    for student in students:
        if student["id"] == student_id:
            return student
    return None

# Find student with ID 102
student = find_student_by_id_list(102, students_list)
print(student)  # Output: {'id': 102, 'name': 'Bob', 'grade': 'B'}
Enter fullscreen mode Exit fullscreen mode

After

students_dict = {
    101: {"name": "Alice", "grade": "A"},
    102: {"name": "Bob", "grade": "B"},
    103: {"name": "Charlie", "grade": "C"},
}

def find_student_by_id_dict(student_id, students):
    return students.get(student_id)

# Find student with ID 102
student = find_student_by_id_dict(102, students_dict)
print(student)  # Output: {'name': 'Bob', 'grade': 'B'}

Enter fullscreen mode Exit fullscreen mode

Further Exploration

If you've found this article useful, I encourage you, especially if you're new to software engineering, to revisit your previous code and see how you can implement one or more of these techniques.

What other simple tips can you suggest that I might have missed? Let me know in the comments.

If you're looking to explore these topics further, consider diving into subjects such as "Clean Code," "Design Patterns," "Data Structures and Algorithms," and "Optimizing Time Complexity" for a deeper understanding.

Keep in mind that topics like "Clean Code" and "Design Patterns" are fairly subjective, with varying opinions on their techniques. In contrast, topics such as "Data Structures and Algorithms" and "Optimizing Time Complexity" offer clear, provable results that can increase efficiency.

💖 💪 🙅 🚩
anthonyhawkins
Anthony Hawkins

Posted on August 11, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related