Overloading the Subscript Operator ([])


The subscript operator is most frequently overloaded when a class has an array as one of its data members.

For example, the C++ string class has an array of char as one of its data members. It is nice to be able to access the individual characters of the string using code like this:

string s = "some text"
    
for (size_t i = 0; i < s.length(); i++)
    s[i] = toupper(s[i]);

cout << s << endl;

However, the subscript operator does not work on objects by default. We need to overload the operator to allow the code above to work.

At first glance, overloading the subscript operator appears to be fairly simple. It's a binary operator. That makes the string object the left-hand-side operand, while the right-hand-side operand is an integer (or size_t) subscript. Assuming the array of characters that is a data member of the string class is named characters, the implementation as a member function would look something like this:

char string::operator[](size_t pos)
{
    return characters[pos];
}

Using this in code like the following would work correctly:

string s = "some text"
        
for (size_t i = 0; i < s.length(); i++)
    cout << s[i];
cout << endl;

However, the following statement from the original code example would produce a syntax error:

    s[i] = toupper(s[i]);

The error message we would get is "error: lvalue required as left operand of assignment".

This is because the left-hand-side of an assignment statement needs to be an item that that occupies some identifiable location in memory (i.e., has an address). The technical term for this is an lvalue (locator value). But the overloaded operator that we have written above merely returns a copy of the value in element pos of the characters. We can't assign a new value to that copy (or if we could, it wouldn't be in the characters array where we want it to be). The copy is an rvalue, which is defined as any item that is not an lvalue (i.e., does not occupy an identifiable location in memory).

The intermediate solution is to return a reference to the array element rather than the value of the array element:

char& string::operator[](size_t pos)
{
    return characters[pos];
}

Notice that the code in the body of the member function is the same. Only the return data type of the function has changed. This change will allow the assignment statement to work and a reference works on the right side of an assignment statement as well. Both of the code examples above will now compile and run correctly.

But this change also introduces a different problem. What if the string object is constant? The first code example should definitely work with a constant string. All we're doing is printing the characters of the string, we're not trying to modify the string at all:

const string s = "some text"
        
for (size_t i = 0; i < s.length(); i++)
    cout << s[i];
cout << endl;

In order to work with a constant string, our member function needs to be constant as well:

char& string::operator[](size_t pos) const
{
    return characters[pos];
}

This change allows the overloaded subscript operator to be called for a constant object. Unfortunately, it also allows that constant object to be modified, which is not something that's supposed to happen!

const string s = "some text"
        
for (size_t i = 0; i < s.length(); i++)
    s[i] = toupper(s[i]);
        
cout << s << endl;    // Prints "SOME TEXT"

The constant property of the string is properly enforced in the overloaded subscript operator member function. All we are doing in that function is returning a reference to an element of the array data member. We're not modifying the array in that function.

In the calling code, the reference that we returned can be used to modify the character it refers to. The compiler's simply not smart enough to detect that the reference is being used to change a data member of a constant object.

The Solution

We really need two different versions of the overloaded subscript operator, one that is called for non-constant objects and one that is called for constant objects. This is why the C++ standard dictates that the overloaded subscript operator must be implemented as member function.

Assuming we have an array data member declared in the following fashion:

array-data-type array-name[array-size];

The non-constant version of the member function typically looks like this:

array-data-type& ClassName::operator[](size_t pos)
{
    // Return element pos of the array
    return array-name[pos];
}

while the constant version of the member function typically looks like this (if the array elements are a built-in type like char or int):

array-data-type ClassName:: operator[](size_t pos) const
{
    // Return element pos of the array
    return array-name[pos];
}

or this (if the array elements are objects):

const array-data-type& ClassName:: operator[](size_t pos) const
{
    // Return element pos of the array
    return array-name[pos];
}

Example

Assume that we have a Vector3D class that has an array as one of its data members. A partial class definition is shown below:

class Vector3D
{
private:

    double coordinates[3];
    
public:

    Vector3D(double = 0.0, double = 0.0, double = 0.0);
    
    ...
    
    double operator[](size_t) const;
    double& operator[](size_t);
    
    ...
};

Definitions for the two overloaded subscript operator methods would look like this:

double Vector3D::operator[](size_t pos) const
{
    return coordinates[pos];
}

double& Vector3D::operator[](size_t pos)
{
    return coordinates[pos];
}