Refactoring Global State in C++: Enhancing Modularity and Testability

Introduction

Now that we have explored various techniques to break dependencies, such as using abstract classes and dependency injection, we will focus on a specific type of dependency: global state. Global state can introduce hidden dependencies and make our code difficult to test and maintain. In this lesson, we will learn how to identify global state and refactor it using dependency injection, enhancing the modularity and testability of our code.

Understanding Global State

Global state refers to variables or data that are accessible from anywhere in our application. These variables often reside in a global scope, making them available to any part of the code. While this might seem convenient, it can lead to several issues. For instance, global state can create hidden dependencies between different parts of our code, making it difficult to understand how changes in one area might affect another. Additionally, global state can complicate testing, as it introduces shared data that can lead to unpredictable test results.

Identifying Global State in Code

Identifying global state in our code is the first step toward refactoring it. We should look for variables that are declared at the top level of our application or within static classes. These variables are often accessed directly by various parts of our code, indicating a reliance on global state. Take a look at the OrderProcessor class below:

class OrderProcessor
{
public:
    static constexpr double BULK_ORDER_THRESHOLD = 1000.0;
    static constexpr double BULK_ORDER_DISCOUNT = 0.1; // 10% discount
    static constexpr int MAX_ITEMS_PER_ORDER = 50;
    
    bool processOrder(Order& order)
    {
        // ... order processing logic ...
    }
};

Constants like BULK_ORDER_THRESHOLD and MAX_ITEMS_PER_ORDER are examples of global state. They are used throughout the class without being passed as parameters, making them potential candidates for refactoring. They could also be used in other parts of the system for other logic.

Refactoring Global State

To refactor global state, we can use dependency injection to pass configuration data as parameters or through interfaces. This approach allows us to encapsulate the state within a configuration object, which can be injected into classes that need it. Maintaining backward compatibility is crucial during refactoring to ensure the established codebase continues to function without modification. We can achieve this by providing a default constructor that initializes the configuration with the original global constants. Here's a streamlined example:

class IOrderConfiguration
{
public:
    virtual ~IOrderConfiguration() = default;
    virtual double getBulkOrderThreshold() const = 0;
    virtual double getBulkOrderDiscount() const = 0;
    virtual int getMaxItemsPerOrder() const = 0;
};

class OrderConfiguration : public IOrderConfiguration
{
public:
    double getBulkOrderThreshold() const override { return BULK_ORDER_THRESHOLD; }
    double getBulkOrderDiscount() const override { return BULK_ORDER_DISCOUNT; }
    int getMaxItemsPerOrder() const override { return MAX_ITEMS_PER_ORDER; }

private:
    static constexpr double BULK_ORDER_THRESHOLD = 1000.0;
    static constexpr double BULK_ORDER_DISCOUNT = 0.1;
    static constexpr int MAX_ITEMS_PER_ORDER = 50;
};

class OrderProcessor
{
public:
    OrderProcessor()
        : orderConfiguration(new OrderConfiguration()), ownsOrderConfiguration(true) {}

    OrderProcessor(IOrderConfiguration* orderConfig)
        : orderConfiguration(orderConfig), ownsOrderConfiguration(false) {}

    ~OrderProcessor()
    {
        if (ownsOrderConfiguration) delete orderConfiguration;
    }

    bool processOrder(Order& order)
    {
        // Order processing logic using orderConfiguration
    }

private:
    IOrderConfiguration* orderConfiguration;
    bool ownsOrderConfiguration;
};

In this example, the OrderProcessor class provides a default constructor that initializes the OrderConfiguration with the original global constants, ensuring backward compatibility. Additionally, a constructor with dependency injection is available for more flexible configuration, allowing different configurations to be provided for testing or other scenarios.

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal