Lesson 3
Clean Function Design in Python
Introduction

Welcome to your next step in mastering Clean Code! 🚀 Previously, we emphasized the significance of naming conventions in clean coding. Now, we delve into the realm of functions and methods, which serve as the backbone of application logic and are crucial for code organization and execution. Structuring these functions effectively is vital for enhancing the clarity and maintainability of a codebase. In this lesson, we'll explore best practices and techniques to ensure our code remains clean, efficient, and readable.

Clean Functions at a Glance

Let's outline the key principles for writing clean functions:

  • Keep functions small. Small functions are easier to read, comprehend, and maintain.
  • Focus on a single task. A function dedicated to one task is more reliable and simpler to debug.
  • Limit arguments to three or fewer. Excessive arguments complicate the function signature and make it difficult to understand and use.
  • Avoid boolean flags. Boolean flags can obscure the code's purpose; consider separate methods for different behaviors.
  • Eliminate side effects. Functions should avoid altering external states or depending on external changes to ensure predictability.
  • Implement the DRY principle. Employ helper functions to reuse code, minimizing redundancy and enhancing maintainability.

Now, let's take a closer look at each of these rules.

Keep Functions Small

Functions should remain small, and if they become too large, consider splitting them into multiple, focused functions. While there's no fixed rule on what counts as large, a common guideline is around 15 to 25 lines of code, often defined by team conventions.

Below, you can see the process_order function, which is manageable but has the potential to become unwieldy over time:

Python
1def process_order(order, inventory, logger): 2 # Step 1: Validate the order 3 if not order.is_valid(): 4 logger.log("Invalid Order") 5 return 6 7 # Step 2: Process payment 8 if not order.process_payment(): 9 logger.log("Payment failed") 10 return 11 12 # Step 3: Update inventory 13 inventory.update(order.get_items()) 14 15 # Step 4: Notify customer 16 order.notify_customer() 17 18 # Step 5: Log order processing 19 logger.log("Order processed successfully")

Given that this process involves multiple steps, it can be improved by extracting each step into a dedicated function, as shown below:

Python
1def process_order(order, inventory, logger): 2 # Validate the order; if invalid, log and return 3 if not validate_order(order, logger): 4 return 5 # Process payment; if failed, log and return 6 if not process_payment(order, logger): 7 return 8 # Update the inventory based on the order 9 update_inventory(order, inventory) 10 # Notify the customer about the order 11 notify_customer(order) 12 # Log successful order processing 13 log_order_processing(logger)

In this code block, the process_order function orchestrates the whole order processing operation. It first validates the order and attempts payment processing. If either step fails, the function logs an error and returns early. Successful orders trigger the inventory update, customer notification, and finally, a success log entry.

Python
1def validate_order(order, logger): 2 # Check if the order is valid, log if not 3 if not order.is_valid(): 4 logger.log("Invalid Order") 5 return False 6 return True 7 8 9def process_payment(order, logger): 10 # Attempt to process payment, log failure 11 if not order.process_payment(): 12 logger.log("Payment failed") 13 return False 14 return True 15 16 17def update_inventory(order, inventory): 18 # Update inventory with items from order 19 inventory.update(order.get_items()) 20 21 22def notify_customer(order): 23 # Notify the customer about order status 24 order.notify_customer() 25 26 27def log_order_processing(logger): 28 # Log a successful order processing message 29 logger.log("Order processed successfully")

In this code block, we define helper functions for each individual step in the order processing pipeline. validate_order checks the validity of the order, while process_payment handles the payment execution. Both functions log any failures. After these checks, update_inventory adjusts the stock levels, notify_customer communicates with the client, and log_order_processing captures a success message for completed orders.

Single Responsibility

A function should embody the principle of doing one thing only. If a function handles multiple responsibilities, it may include several logical sections. Below you can see the save_and_notify_user function, which is both too lengthy and does multiple different things at once:

Python
1def save_and_notify_user(user, data_source): 2 # Save user to the database 3 sql = "INSERT INTO users (name, email) VALUES (?, ?)" 4 5 try: 6 connection = data_source.get_connection() 7 statement = connection.prepare_statement(sql) 8 # Set user details in the prepared statement 9 statement.setString(1, user.get_name()) 10 statement.setString(2, user.get_email()) 11 12 # Execute the update query to save user 13 statement.execute_update() 14 except Exception as e: 15 print(f"Exception: {e}") # Handle exception 16 17 # Send a welcome email to the user 18 send_welcome_email(user)

To enhance this code, you can create two dedicated functions for saving the user and sending the welcome email. This results in dedicated responsibilities for each function and clearer coordination:

Python
1def save_and_notify_user(user, data_source): 2 # Save user to the database 3 save_user(user, data_source) 4 # Notify user with welcome email 5 notify_user(user)

Here we define save_and_notify_user, which coordinates saving a user to the database and subsequently notifying them. It calls two separate functions to achieve these tasks, promoting clear separation of responsibilities.

Python
1def save_user(user, data_source): 2 # SQL query to insert user data 3 sql = "INSERT INTO users (name, email) VALUES (?, ?)" 4 try: 5 # Establish database connection and execute query 6 connection = data_source.get_connection() 7 statement = connection.prepare_statement(sql) 8 statement.setString(1, user.get_name()) 9 statement.setString(2, user.get_email()) 10 statement.execute_update() 11 except Exception as e: 12 # Handle exception during database operation 13 print(f"Exception: {e}") 14 15def notify_user(user): 16 # Send a welcome email to the user 17 send_welcome_email(user) 18 19def send_welcome_email(user): 20 # Logic to send the welcome email 21 print(f"Sending welcome email to {user.get_name()}...")

In this code block, save_user handles database operations related to saving user data and manages any exceptions that arise. notify_user is responsible for sending notifications, using the send_welcome_email method, which contains the logic for email dispatch.

Limit Number of Arguments

Try to keep the number of function arguments to a maximum of three, as having too many can make functions less understandable and harder to use effectively. 🤔

Consider the save_address function below with five arguments, which makes the function less clean:

Python
1def save_address(street, city, state, zip_code, country): 2 # Logic to save address

A cleaner version encapsulates the details into an Address object, reducing the number of arguments and making the function signature clearer:

Python
1def save_address(address): 2 # Logic to save address

Here is what an Address class might look like:

Python
1class Address: 2 def __init__(self, street, city, state, zip_code, country): 3 self.street = street 4 self.city = city 5 self.state = state 6 self.zip_code = zip_code 7 self.country = country
Avoid Boolean Flags

Boolean flags in functions can create confusion, as they often suggest multiple pathways or behaviors within a single function. Instead, use separate methods for distinct behaviors.

The set_flag function below uses a boolean flag to indicate user status, leading to potential complexity:

Python
1def set_flag(user, is_admin): 2 # Logic based on flag

A cleaner approach is to have distinct methods representing the different behaviors:

Python
1def grant_admin_privileges(user): 2 # Logic for admin rights 3 4def revoke_admin_privileges(user): 5 # Logic to remove admin rights
Avoid Side Effects

A side effect occurs when a function modifies some state outside its scope or relies on something external. This can lead to unpredictable behavior and reduce code reliability.

Below, the add_to_total function demonstrates a side effect by modifying an external state:

Python
1# Not Clean - Side Effect 2def add_to_total(value): 3 global total 4 total += value # modifies external state 5 return total

A cleaner version, calculate_total, performs the operation without altering any external state:

Python
1# Clean - No Side Effect 2def calculate_total(initial, value): 3 return initial + value
Don't Repeat Yourself (DRY)

Avoid code repetition by introducing helper functions to reduce redundancy and improve maintainability.

The print_user_info and print_manager_info functions below repeat similar logic, violating the DRY principle:

Python
1def print_user_info(user): 2 print(f"Name: {user.name}") 3 print(f"Email: {user.email}") 4 5def print_manager_info(manager): 6 print(f"Name: {manager.name}") 7 print(f"Email: {manager.email}")

To adhere to DRY principles, use a generalized print_info function that operates on a parent Person type:

Python
1def print_info(person): 2 print(f"Name: {person.name}") 3 print(f"Email: {person.email}")
Summary

In this lesson, we learned that clean functions are key to maintaining readable and maintainable code. By keeping functions small, adhering to the Single Responsibility Principle, limiting arguments, avoiding side effects, and embracing the DRY principle, you set a strong foundation for clean coding. Next, we'll practice these principles to further sharpen your coding skills! 🎓

Enjoy this lesson? Now it's time to practice with Cosmo!
Practice is how you turn knowledge into actual skills.