… Obviously, we can refer to an object by name, but in C++ (most) objects ‘‘have identity.’’ That is, they reside at a specific address in memory, and an object can be accessed if you know its address and its type …
Bjarne Stroustrup 《The C++ Programming Language》(Fourth Edition),Chapter 7.
During our last discussion, @Lollipop9z(Invalid Link)raised this interesting question. Due to their background in learning Python programming language, they were puzzled about why features like pointers and references are provided in languages like C++. In fact, many students who have studied programming languages that include concepts of pointers and/or references still lack a thorough understanding of the reasons behind the existence of these language elements. The following code snippet illustrates this using C++ as an example.
Thank @szouc for pointing out the issues regarding array declaration and implicit conversion of array types.
Why we have pointer?
Modern general-purpose electronic computers store the data they need for program execution in internal memory (RAM). Using a language understandable by the machine, we can instruct the computer to access data at a specific location in memory. For example, consider writing a program to count the number of lowercase letter ‘a’s in a string literal. This program would store the data used for counting at a particular location in memory. Operations like these are so common that high-level programming languages specifically provide a language concept called “variables” for programmers to use.
A variable is a named address space. In high-level programming languages, instead of instructing the computer program to access data at a specific location, we simply indicate that we need a space in memory named a
, which stores data of the default integer length. This is similar to the following definition in most programming languages:
int a;
This name-to-address one-to-one mapping frees programmers from having to concern themselves with the specific location of variable storage, providing greater convenience for compatibility with different memory addressing methods. However, not all types of variables have a one-to-one relationship between the variable and the address space allocated by the computer for storing that variable, as in the case of:
int b[10];
The above C++ code declares a block of contiguous space capable of storing 10 default-length integer variables, with the identifier (Identifier) b
being the array name. The C++ standard (conv.array, expr.unary.op, and expr.sub) guides us that in certain contexts, an expression containing the array name b
can be implicitly converted to the address of the first block of allocated space. Conventionally, expressions containing an indirect addressing operator, such as *b
, are used to access the content corresponding to the address of the first block.
How do we read the contents of the remaining blocks? We don’t have a direct way to access the content of these spaces, but because we know the address of the first block (and the compiler knows the length of each block of default-length integers), programming languages provide ways like *(b+2)
or b[2]
, allowing us to indirectly access the contents of other blocks using the address of the first block plus an offset.
This method of indirectly accessing the content of other blocks using address + offset is called indirection and array subscript operations. Variables that store the address of a block in memory, like this, are called pointers. With this understanding, the concept of pointers becomes readily acceptable.
It’s important to note that pointers have types because each block of memory occupied by different data types varies in size. The type system helps guide the program on how to interpret the data at a certain location in memory and ensures correct handling of offset operations like *(b+2)
.
Other programming languages, such as Python, do not provide a pointer data type. However, they offer the ability to access unnamed data through subscript operations (similar to b[2]
).
So… what about references?
Imagine we have the following function, which accomplishes a remarkable task:
void nicejob(int a)
{
a = a * 2;
}
This piece of code is remarkable. It attempts to double the value of the variable passed in and then assigns the doubled value to itself. Unfortunately, this code doesn’t work as expected. When you try to execute the following code, you’ll find that it doesn’t behave as intended:
#include <iostream>
int main()
{
int x = 2;
std::cout << x << std::endl;
nicejob(x);
std::cout << x << std::endl;
return 0;
}
Output:
2
2
What happened? Changes to the variable a
within nicejob
do not affect the value of x
. In many languages including C/C++, when passing parameters by value to a function, the variable a
obtained within the function is just a copy of x
– you’re manipulating the copy all along, so naturally, it won’t affect the original value of x
.
Obviously, we have two ways to solve this problem. We can either pass the address of variable x
to the nicejob
function, modify the value of x
at the corresponding memory location within the function, which is straightforward but somewhat rough. Alternatively, we can instruct our program to pass the value of x
itself (not a copy!) into the nicejob
function. This way, manipulating a
within the function is equivalent to manipulating x
directly – a
is an alias for x
.
void nicejob(int& a)
{
a = a * 2;
}
All we need to do is change the passing type of the variable a
in the function parameter list from int
to int&
– a reference type. This way, our nicejob
function will work as expected.
When we pass a large variable to a function, to avoid the significant overhead of copying the variable (for example, when passing a 1GB image to an image processing function), we also use reference types. To prevent accidental modifications of the passed variable within the function, we can declare the variable type in the function parameter list as a constant reference, like const Image&
.