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
:
- Subscript Operator Evaluation:
list[2]
is evaluated first because the subscript operator has higher precedence than the assignment operator. This invokes theoperator[]
function, which we've defined to return a reference tolist.m_list[2]
. - Returning a Reference: Since
operator[]
returns a reference, it provides direct access to thelist.m_list[2]
array element. - 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 oflist.m_list[2]
, let's say it's 6.- The assignment operation
list[2] = 3
would then partially evaluate to6 = 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;
}