Improve Code Readability and Efficiency - 11 Tips
Anthony Hawkins
Posted on August 11, 2024
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
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)
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'}
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'}
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)
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)
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.")
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)
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
After
def convert_mb_to_gb(megabytes):
num_mb_in_gb = 1024
gigabytes = megabytes / num_mb_in_gb
return gigabytes
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)
After
for fruit, qty in {"apple", 10, "pear": 5, "orange": 6}.items():
print(fruit, qty)
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}")
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}")
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
After
def has_access(role, is_active):
return role == 'admin' and is_active # No flag, needed, just use the conditional expression directly.
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!")
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!")
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)
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
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'}
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'}
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.
Posted on August 11, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.