Clean Code: Functions

Clean Code: Functions

Introduction

Get ready to roll up your coding sleeves and step into the heart of software craftsmanship. This blog throws open the doors to the function factory, where we'll learn to forge not just tools, but tiny masterpieces of purpose and clarity.

Forget about sprawling monoliths of code that leave you lost in a labyrinth of logic. This blog is your guide to crafting functions that are like miniature sculptures – compact, elegant, and each with a singular, beautiful purpose. By the time you emerge, you'll be equipped to build code that's not just functional, but a joy to read, maintain, and reuse.

Grab your coding tools and open your mind – the function factory awaits!

Descriptive Small Functions with Single Responsibility

In the world of clean code, small descriptive functions with single responsibility reign supreme. They are the building blocks of clarity, maintainability, and elegance. Why? Consider these compelling reasons:

  • Enhanced Readability: Small functions are like concise paragraphs in a well-written essay. They allow you to grasp their purpose quickly, making code easier to navigate and comprehend.

  • Simplified Testing: Testing becomes more manageable when you can isolate individual units of logic within small functions. You can verify their behavior independently, ensuring code quality and reliability.

  • Increased Reusability: Small, focused functions often prove valuable in other parts of your code or even across projects. They promote code modularity and reduce duplication.

  • Improved Maintainability: Modifying or debugging code becomes less daunting when you're dealing with smaller, self-contained functions. It's easier to pinpoint issues and make changes without unintended consequences.

Example

Consider this code snippet, while it works, it's relatively lengthy and could benefit from being broken down into smaller functions:

public void generateCustomerReport(Customer customer) {
    String report = "";
    report += "Customer Name: " + customer.getName() + "\n";
    report += "Address: " + customer.getAddress() + "\n";
    report += "Phone Number: " + customer.getPhoneNumber() + "\n";
    report += "Order History:\n";
    for (Order order : customer.getOrders()) {
        report += "- Order ID: " + order.getId() + "\n";
        report += "  Order Date: " + order.getOrderDate() + "\n";
        report += "  Items:\n";
        for (Item item : order.getItems()) {
            report += "    - " + item.getName() + " x " + item.getQuantity() + "\n";
        }
        report += "  Total: " + order.getTotal() + "\n";
    }
    System.out.println(report);
}

Refactored Functions

Crafting functions that are purposeful, concise, and readable, contributing to overall code clarity and maintainability. These are the key points we would apply to refactor the above code:

  • Small Functions:

    • Functions should ideally be small, aiming for less than 20 lines of code.

    • This makes them easier to comprehend, test, and reuse.

  • Do One Thing aka Single Responsibility:

    • Each function should have a single, well-defined responsibility.

    • Avoid functions that perform multiple, unrelated actions.

  • Descriptive Names:

    • Function names should clearly convey their purpose, making code more self-explanatory.

    • Use verbs and avoid abbreviations or overly generic names.

public void generateCustomerReport(Customer customer) {
    String report = buildCustomerDetailsSection(customer);
    report += buildOrderHistorySection(customer);
    System.out.println(report);
}

private String buildCustomerDetailsSection(Customer customer) {
    return "Customer Name: " + customer.getName() + "\n" +
           "Address: " + customer.getAddress() + "\n" +
           "Phone Number: " + customer.getPhoneNumber() + "\n";
}

private String buildOrderHistorySection(Customer customer) {
    String section = "Order History:\n";
    for (Order order : customer.getOrders()) {
        section += buildOrderDetails(order);
    }
    return section;
}

private String buildOrderDetails(Order order) {
    return "- Order ID: " + order.getId() + "\n" +
           "  Order Date: " + order.getOrderDate() + "\n" +
           "  Items:\n" +
           buildItemList(order.getItems()) +
           "  Total: " + order.getTotal() + "\n";
}

private String buildItemList(List<Item> items) {
    String list = "";
    for (Item item : items) {
        list += "    - " + item.getName() + " x " + item.getQuantity() + "\n";
    }
    return list;
}

Key improvements:

  • Single Responsibility: Each function now has a clear, specific task, making the code easier to understand and test.

  • Readability: Smaller functions with descriptive names make the code more self-explanatory.

  • Modularity: Functions can be reused in other parts of the code, reducing duplication.

  • Maintainability: Changes can be made to individual functions without affecting other parts of the code, reducing the risk of bugs.

  • Testability: Smaller functions can be tested independently, ensuring code quality and reliability.

One Level of abstraction

Imagine you're writing code to create a shopping cart experience for an online store. You might have a function like this:

public void processOrder(Order order) {
    validateOrderItems(order);
    calculateOrderTotal(order);
    applyTaxes(order);
    applyShippingCosts(order);
    sendOrderConfirmationEmail(order);
}

This function mixes different levels of abstraction:

  • High-level concepts: Processing the order, sending a confirmation email.

  • Low-level details: Validating items, calculating totals, applying taxes and shipping.

This can make the code harder to read and maintain. It's better to separate these concerns into functions that operate at the same level of abstraction.

Refactored version

  • Functions should operate at a consistent level of detail.

  • Mixing high-level concepts with low-level implementation details within a function can create confusion.

High level function:

public void processOrder(Order order) {
    if (!validateOrder(order)) {
        return; // Handle validation errors
    }

    calculateOrderTotal(order);
    applyOrderAdjustments(order); // Handles both taxes and shipping
    sendOrderConfirmationEmail(order);
}

Low level functions:

private boolean validateOrder(Order order) {
    // ... validation logic
}

private void calculateOrderTotal(Order order) {
    // ... calculation logic
}

private void applyOrderAdjustments(Order order) {
    applyTaxes(order);
    applyShippingCosts(order);
}

private void sendOrderConfirmationEmail(Order order) {
    // ... email sending logic
}

Now, each function focuses on a specific task at a consistent level of detail, making the code more readable, testable, and easier to modify in the future.

No Side Effects Functions

What are Side Effects?

  • In programming, side effects occur when a function modifies something outside its scope, such as global variables, input arguments, or external systems.

  • These changes can make code harder to reason about, test, and debug, as they introduce hidden dependencies and unexpected behavior.

Why Avoid Side Effects?

Predictability

Functions with side effects can produce different results depending on the state of external factors, making their behavior less predictable.

public boolean isUserLoggedIn() {
    if (checkSessionCookie()) {
        setUserStatus("online"); // Modifies external state
        return true;
    } else {
        return false;
    }
}

The function's return value depends on the external state of the session cookie. If the cookie is accidentally modified elsewhere, the function's behavior becomes unpredictable.

Refactored Function without side effects:

public boolean hasValidSessionCookie() {
    return checkSessionCookie(); // Just checks, doesn't modify
}

Testability

Functions with side effects are often harder to test in isolation, as you need to manage the external state they interact with.

public void sendWelcomeEmail(User user) {
    // Drafts the email, Connects to email server, sends email
}

Testing this function requires setting up a real email server or a complex mock environment, making testing more difficult and time-consuming.

Function without side effects:

public EmailDraft createWelcomeEmail(User user) {
    // Constructs the email content onyl without sending
    return new EmailDraft("Welcome!", user.getEmail(), ...);
}

public void SendWelcomeEmail(User user) {
    EmailServer emailServer = new EmailServer() //Connect to email server
    emailServer.connect(); //Connect to email server
    EmailDraf newUserWelcomeEmail = createWeclomeEmail(user);
    emailServer.Send(newUserWelcomeEmail);

}

Testing createWelcomeEmail() function now doesn't requires setting up a real email server or a complex mock environment, making testing much easier.

Reusability

Functions with side effects might not be reusable in different contexts if they rely on specific external conditions.

public void logMessageAndPrint(String message) {
    System.out.println(message); // Prints to console
    logToFile(message); // Writes to a specific file
}

This function is tightly coupled to both the console and a file, making it hard to reuse in contexts where logging is needed differently.

Function without side effects:

public List<LogEntry> createLogEntries(String message) {
    return List.of(new ConsoleLogEntry(message), new FileLogEntry(message));
}

The function doesn't directly perform any logging actions. It merely creates LogEntry objects for potential use by other parts of the code responsible for handling logging. This approach allows for more adaptable logging strategies. The calling code can decide how to use these LogEntry objects (e.g., log them immediately, store them, send them to a remote server).

List<LogEntry> entries = createLogEntries("Important message!");
// Use the log entries as needed, e.g., pass them to a logging service

Maintainability

Code with many side effects can become a tangled web of dependencies, making it harder to understand and change without introducing unintended consequences.

public void updateUserPreferences(User user, Preferences prefs) {
    user.setPreferences(prefs); // Modifies user object
    notifyOtherSystems(user); // Triggers actions in other parts of the code
}

Understanding the full impact of this function requires tracing its side effects across multiple systems, making maintenance more challenging.

Function without side effects:

public User createUpdatedUser(User user, Preferences prefs) {
    // Create a new user object with the updated preferences:
    User updatedUser = new User(user.getId(), prefs, /* other fields */);

    // Return the new user object without modifying the original or triggering notifications:
    return updatedUser;
}

// Example usage:
public updateUserPrefs() {
User originalUser = getUserFromDatabase();
Preferences newPrefs = createNewPreferences();
User updatedUser = createUpdatedUser(originalUser, newPrefs);

// Now you can decide how to use the updatedUser object:
saveUserToDatabase(updatedUser);
notifyOtherSystems(updatedUser);
// ... or other actions as needed
}

The code is easier to understand and change because the logic of creating an updated user object is isolated within the function.

By embracing the principle of no side effects, you create code that is more predictable, testable, reusable, and maintainable, leading to more robust and adaptable software systems.

Argument list

Limit Number of Arguments:

  • Why:

    • Too many arguments can make a function's purpose unclear and harder to understand.

    • It becomes difficult to grasp the relationship between arguments and their intended use.

    • Testing functions with many arguments becomes more complex.

  • Ideal Range:

    • Aim for 2-4 arguments for optimal readability and maintainability.
// Too many arguments:
public void calculateOrderTotal(Order order, List<Item> items, Discount discount, TaxRate taxRate, boolean isDelivery) {
    // ...
}

// Better:
public OrderSummary calculateOrderDetails(Order order) {
    // Encapsulate details within the OrderSummary object
}
  • Why:

    • When multiple arguments are closely related, create an object to group them logically.

    • Improves readability and understanding of the function's purpose.

    • Encapsulates related data, making code more maintainable.

// Separate arguments:
public void sendWelcomeEmail(String recipientName, String recipientEmail, String subject, String messageBody) {
    // ...
}

// Better:
public void sendWelcomeEmail(EmailDraft emailDraft) {
    // Encapsulate details within the EmailDraft object
}

Use Meaningful Argument Names

  • Why:

    • Descriptive names clarify the role of each argument and enhance readability.

    • Makes code self-documenting and easier to understand without additional comments.

// Unclear names:
public void calculateArea(int a, int b) {
    // ...
}

// Better:
public int calculateRectangleArea(int length, int width) {
    // ...
}

Additional Tips

  • Order arguments logically: Place the most important or conceptually significant arguments first.

  • Consider default values: Provide default values for optional arguments to simplify usage and reduce function calls.

  • Be consistent: Use consistent naming conventions and argument order throughout your code to promote predictability.

By following these guidelines, you'll create functions with clear, concise, and well-structured argument lists, leading to more readable, maintainable, and testable code.

Embrace Returning Values and Avoid Output Arguments

Output Arguments:

  • Arguments passed to a function that are intended to be modified and hold the function's results.

  • Can be used to return multiple values or avoid creating new objects.

Returning Values:

  • The preferred approach in Clean Code for communicating a function's results.

  • Involves explicitly returning a value from the function using the return keyword.

Why Prefer Returning Values:

  • Clarity: Makes the function's intent clearer by explicitly stating what it produces.

  • Reduced Side Effects: Avoids unexpected modifications to external variables, promoting predictability and testability.

  • Better Encapsulation: Separates computation from output handling, making code more modular and reusable.

  • Easier Debugging: Tracking the flow of values and identifying errors is often simpler when using return values.

Example

Using Output Arguments:

// Modifies an output argument:
public void calculateArea(Rectangle rectangle, double outputArea) {
    outputArea = rectangle.getLength() * rectangle.getWidth();
}

Refactor to Returning a Value:

// Returns a value instead:
public double calculateRectangleArea(Rectangle rectangle) {
    return rectangle.getLength() * rectangle.getWidth();
}

Additional Tips

  • Multiple Return Values: When multiple values need to be returned, consider using a custom object or a data structure to encapsulate them, rather than multiple output arguments.

  • Performance Optimization: In rare cases where performance is critical, output arguments might be slightly more efficient. However, prioritize clarity and maintainability first, and optimize only if necessary.

Example

Using Output Arguments (Less Clean):

public void calculateStatistics(List<Integer> numbers, int outputMin, int outputMax, double outputAverage) {
    // ... calculations
    outputMin = /* calculated minimum */;
    outputMax = /* calculated maximum */;
    outputAverage = /* calculated average */;
}

Refactor to Using a Custom Object (Cleaner):

public class Statistics {
    public int min;
    public int max;
    public double average;
}

public Statistics calculateStatistics(List<Integer> numbers) {
    Statistics stats = new Statistics();
    // ... calculations
    stats.min = /* calculated minimum */;
    stats.max = /* calculated maximum */;
    stats.average = /* calculated average */;
    return stats;
}

public calculateStatisticsOfSalaries(List<Integer> salaries){
    Statistics stats = calculateStatistics(salaries);
    int minimum = stats.min;
    int maximum = stats.max;
    double average = stats.average;
// ... use the statistics values
}

Benefits of Using a Custom Object:

  • Clarity: The function's intention is clear – it returns statistics about a list of numbers.

  • Encapsulation: The related values (min, max, average) are grouped logically within a single object.

  • Readability: The calling code is more readable, as it receives a meaningful object rather than individual variables.

  • Reusability: The Statistics object can be used in other parts of the code without needing to pass multiple arguments around.

  • Flexibility: The Statistics object can be extended to include additional statistics in the future.

By embracing the principle of returning values, you'll write code that is clearer, more predictable, and easier to reason about, leading to more robust and maintainable software systems.

Error/Exception Handling and Return Codes

Error Handling

  • Importance: Handling errors gracefully is crucial for writing robust and reliable code.

  • Goals:

    • Detect errors as early as possible.

    • Prevent errors from propagating silently.

    • Provide meaningful error messages for debugging and user feedback.

    • Allow for corrective actions or alternative execution paths.

Exception Handling

  • Mechanism: Exceptions are special objects that signal abnormal conditions and disrupt the normal flow of execution.

  • When to Use:

    • When errors are unexpected or infrequent.

    • When errors can occur at different levels of code abstraction.

    • When errors require context-specific handling.

try {
    int result = calculateValue(input);
    // Process the result
} catch (ArithmeticException e) {
    // Handle division by zero
} catch (InputMismatchException e) {
    // Handle invalid input
}
/h2

Return Codes

  • Mechanism: Functions return specific values to indicate success or different types of errors.

  • When to Use:

    • When errors are expected and part of the normal operation.

    • When errors are tightly coupled to the function's logic.

    • When performance is critical and exception handling overhead is a concern.

int resultCode = readFile(filename);
if (resultCode == SUCCESS) {
    // Process the file contents
} else if (resultCode == FILE_NOT_FOUND) {
    // Handle missing file
} else if (resultCode == ACCESS_DENIED) {
    // Handle permission issues
}

Additional Tips

Fail Fast

Detect and throw exceptions as early as possible to avoid cascading errors.

Java
// Checking for null values early:
public void processData(Object data) {
    if (data == null) {
        throw new NullPointerException("Data cannot be null");
    }
    // ... proceed with processing
}

Provide Informative Error Messages:

Include context and details for debugging and user feedback.

// Including context in exception messages:
try {
    int result = calculateValue(input);
} catch (ArithmeticException e) {
    throw new IllegalStateException("Division by zero occurred in calculateValue with input: " + input);
}

Handle Error Appropriately

Decide whether to retry, log errors, display messages, or take alternative actions.

// Retry logic for network errors:
int retries = 3;
while (retries > 0) {
    try {
        response = fetchDataFromServer();
        break; // Success, exit the loop
    } catch (NetworkException e) {
        retries--;
        if (retries == 0) {
            throw e; // Exhausted retries, rethrow
        } else {
            log.warn("Network error, retrying...");
        }
    }
}

Test Error Handling

Write tests to ensure errors are handled correctly.

// Testing exception handling:
@Test(expected = IllegalArgumentException.class)
public void testInvalidInput() {
    functionUnderTest(invalidInput);
}

By incorporating these practices, you'll create code that gracefully handles errors, prevents unexpected failures, and provides valuable information for debugging and improvement, leading to more robust and reliable software systems.

Bonus Nuggets

  • Comments:

    • Use comments judiciously to explain non-obvious logic or complex algorithms but strive for code that is self-documenting through clear naming and structure.
  • Formatting:

    • Apply consistent formatting and indentation to improve readability and maintainability.
  • Refactoring:

    • Regularly review and refactor code to keep functions well-structured and adhere to clean code principles.

You've unlocked the secrets of clean functions, but the journey doesn't end here. Can you weave them into tapestries of elegance? Can you make them sing sonnets of understanding? The true test of your craft lies in the code you write now.

The journey towards clean code continues! Is your code drowning in a sea of comments? Or lost in a desert of silence? Next blog, we navigate the perilous terrain of code annotation, uncovering the secrets of comments that illuminate, not obfuscate. Prepare to banish cryptic remarks and rewrite the unwritten rules of code communication.


Related Articles

Clean Code: A Demystified Intro

Clean Code: Meaningful Names

YouTube

Previous Videos

Clean Code: Meaningful Names

Clean Code: A Demystified Intro

Upcoming Videos

  • Clean Code: Functions