Overloading Subscript Operator

In C++, we commonly use the subscript operator ([]) to access specific elements of an array:

myArray[0] = 7; // Puts the value 7 in the first element of the array

This notation allows direct manipulation of individual elements within an array.

However, consider the following code snippet:

class IntList {
private:
    int m_list[10]{};
};

This IntList class, contains a private member variable.

Since the m_list member variable is private, we can't access it directly. Thus, we need a way to get or set values in the array.

One approach, without using operator overloading, is to create access functions:

class IntList {
private:
    int m_list[10]{};

public:
    void setItem(int index, int value) { m_list[index] = value; }
    int getItem(int index) const { return m_list[index]; }
};

While functional, this approach isn't very user-friendly, especially when using the functions:

int main() {
    IntList list{};
    list.setItem(2, 3); // Unclear: setting element 2 to value 3 or vice versa?

    return 0;
}

Alternatively, we could return the entire list and use the subscript operator ([]) to access elements:

class IntList {
private:
    int m_list[10]{};

public:
    int* getList() { return m_list; }
};

However, this approach leads to somewhat awkward syntax:

int main() {
    IntList list{};
    list.getList()[2] = 3;

    return 0;
}

In both cases, operator overloading could provide a cleaner and more intuitive solution for accessing elements of the array.

Overloading operator[]

However, a better solution in this case is to overload the subscript operator ([]) to allow access to the elements of m_list. The subscript operator is one of the operators that must be overloaded as a member function. An overloaded operator[] function will always take one parameter: the subscript that the user places between the hard braces. In our IntList case, we expect the user to pass in an integer index, and we’ll return an integer value back as a result.

#include <iostream>

class IntList
{
private:
    int m_list[10]{};

public:
    int& operator[] (int index)
    {
        return m_list[index];
    }
};

/*
// Can also be implemented outside the class definition
int& IntList::operator[] (int index)
{
    return m_list[index];
}
*/

int main()
{
    IntList list{};
    list[2] = 3; // set a value
    std::cout << list[2] << '\n'; // get a value

    return 0;
}

Now, whenever we use the subscript operator ([]) on an object of our class, the compiler will return the corresponding element from the m_list member variable! This allows us to both get and set values of m_list directly.

When you write list[2], the compiler checks if there's an overloaded operator[] function in the IntList class. If it finds one, it passes the value inside the square brackets (in this case, 2) as an argument to that function. This allows for a straightforward and intuitive syntax for accessing elements of the m_list array within the IntList class.

Why operator[] returns a reference

Let's break down the evaluation of list[2] = 3:

  1. Subscript Operator Evaluation: list[2] is evaluated first because the subscript operator has higher precedence than the assignment operator. This invokes the operator[] function, which we've defined to return a reference to list.m_list[2].
  2. Returning a Reference: Since operator[] returns a reference, it provides direct access to the list.m_list[2] array element.
  3. Assignment Operation: The partially evaluated expression becomes list.m_list[2] = 3, which straightforwardly assigns the value 3 to the element at index 2.

Regarding why operator[] returns a reference:

  • The left-hand side of an assignment statement must be an l-value, representing a memory location where the assignment can occur.
  • References in C++ are always l-values, as they refer to actual memory addresses.
  • By returning a reference from operator[], the compiler ensures that the result can be used as an l-value, satisfying the requirement for assignment operations.

If operator[] returned an integer by value instead of by reference:

  • list[2] would retrieve the value of list.m_list[2], let's say it's 6.
  • The assignment operation list[2] = 3 would then partially evaluate to 6 = 3, which doesn't make sense for assignment.
  • The C++ compiler would complain because you can't assign a value to an intermediate result that's not an l-value.

Overloaded operator[] for const objects

In the above IntList example, operator[] is non-const, and we can use it as an l-value to change the state of non-const objects. However, what if our IntList object was const? In this case, we wouldn’t be able to call the non-const version of operator[] because that would allow us to potentially change the state of a const object.

The good news is that we can define a non-const and a const version of operator[] separately. The non-const version will be used with non-const objects, and the const version with const-objects.

#include <iostream>

class IntList
{
private:
    int m_list[10]{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; // give this class some initial state for this example

public:
    // For non-const objects: can be used for assignment
    int& operator[] (int index)
    {
        return m_list[index];
    }

    // For const objects: can only be used for access
    // This function could also return by value if the type is cheap to copy
    const int& operator[] (int index) const
    {
        return m_list[index];
    }
};

int main()
{
    IntList list{};
    list[2] = 3; // okay: calls non-const version of operator[]
    std::cout << list[2] << '\n';

    const IntList clist{};
    // clist[2] = 3; // compile error: clist[2] returns const refrence, which we can't assign to
    std::cout << clist[2] << '\n';

    return 0;
}

Removing duplicate code between const and non-const overloads

In the provided example, both the non-const and const versions of the operator[] function are identical in their implementations, with the only difference being the return type.

When the implementation of these operators is trivial (for few lines), it's acceptable and even preferred to have both functions share the same implementation, even if it introduces a small amount of redundancy.

However, in cases where the implementation involves complex logic, such as index validation, duplicating many lines of code in both functions becomes impractical and error-prone.

In such scenarios, it's desirable to have a single implementation that can be utilized for both const and non-const overloads. While directly calling one function from the other is not feasible due to const-correctness issues, we can employ a workaround.

One approach is to refactor the common logic into a private member function and have both const and non-const versions of operator[] call this function. By doing so, we ensure that the code for index validation, or any other complex logic, is only written once, improving maintainability and reducing the chance of errors.

The preferred solution is as follows:

#include <iostream>

class IntList
{
private:
    int m_list[10]{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; // give this class some initial state for this example

    // Use private const member function to handle duplicate logic
    const int& getIndex(int index) const
    {
        // Complex code goes here
        return m_list[index];
    }

public:
    // These overloaded operators can now be implemented as a single line,
    // helping to highlight the actual difference between them
    int& operator[] (int index)
    {
        // Since we know our implicit object is non-const
        // We can strip the const off the reference returned by getIndex
        return const_cast<int&>(getIndex(index));
    }

    const int& operator[] (int index) const
    {
        return getIndex(index);
    }
};

int main()
{
    IntList list{};
    list[2] = 3; // okay: calls non-const version of operator[]
    std::cout << list[2] << '\n';

    const IntList clist{};
    // clist[2] = 3; // compile error: clist[2] returns const refrence, which we can't assign to
    std::cout << clist[2] << '\n';

    return 0;
}