Chapter 17
In This Chapter
Introducing the concept of pointer variables
Declaring and initializing a pointer
Using pointers to pass arguments by reference
Allocating variable-sized arrays from the heap
This chapter introduces the powerful concept of pointers. By that I don’t mean specially trained dogs that point at birds but rather variables that point at other variables in memory. I start with an explanation of computer addressing, before getting into the details of declaring and using pointer variables. This chapter wraps up with a discussion of something known as the heap and how we can use it to solve a problem that I slyly introduced in the last chapter.
But don’t think the fun is over when this chapter ends. The next chapter takes the concept of pointers one step further. In fact, in one way or another, pointers will reappear in almost every remaining chapter of this book.
A pointer is a variable that contains the address of another variable in the computer’s internal memory. Before you can get a handle on that statement, you need to understand how computers address memory.
Every piece of random-access memory (RAM) has its own, unique address. For most computers, including Macintoshes and PCs, the smallest addressable piece of memory is a byte.
An address in memory is exactly like an address of a house, or would be if the following conditions were true:
So, for example, the address of a particular byte of memory might be 0x1000. The next byte after that would have an address of 0x1001. The byte before would be at 0x0FFF.
A char variable is designed to hold an ASCII character, an int an integer number, and a double a floating-point number. Similarly, a pointer variable is designed to hold a memory address. You declare a pointer variable by adding an asterisk (*) to the end of the type of the object that the pointer points at, as in the following example:
char* pChar; // pointer to a character
int* pInt; // pointer to an int
A pointer variable that has not otherwise been initialized contains an unknown value. Using the ampersand (&) operator, you can initialize a pointer variable with the address of a variable of the same type:
char cSomeChar = 'a';
char* pChar;
pChar = &cSomeChar;
In this snippet, the variable cSomeChar has some address. For argument’s sake, let’s say that C++ assigned it the address 0x1000. (C++ also initialized that location with the character 'a'.) The variable pChar also has a location of its own, perhaps 0x1004. The value of the expression &cSomeChar is 0x1000, and its type is char* (read “pointer to char”). So the assignment on the third line of the snippet example stores the value 0x1000 in the variable pChar. This is shown graphically in Figure 17-1.
Take a minute to really understand the relationship between the figure and the three lines of C++ code in the snippet. The first declaration says, “go out and find a 1-byte location in memory, assign it the name cSomeChar, and initialize it to 'a'.” In this example, C++ picked the location 0x1000.
The next line says, “go out and find a location large enough to hold the address of a char variable and assign it the name pChar.” In this example, C++ assigned pChar to the location 0x1004.
The third line says, “assign the address of cSomeChar (0x1000) to the variable pChar.” Figure 17-1 represents the state of the program after these three statements.
“So what?” you say. Here comes the really cool part, as demonstrated in the following expression:
*pChar = 'b';
This line says, “store a 'b' at the char location pointed at by pChar.” This is demonstrated in Figure 17-2. To execute this expression, C++ first retrieves the value stored in pChar (that would be 0x1000). It then stores the character 'b' at that location.
So what’s so exciting about that? After all, I could achieve the same effect by simply assigning a 'b' to cSomeChar directly:
cSomeChar = 'b';
Why go through the intermediate step of retrieving its address in memory? Because there are several problems that can be solved only with pointers. I discuss two common ones in this chapter. Subsequent chapters describe a number of problems that are solved most easily with pointers.
There are two ways to pass arguments to a function: either by value or by reference. Now, consider both in turn.
In Chapter 11, I write that arguments are passed to functions by value, meaning that it is the value of the variable that gets passed to the function and not the variable itself.
The implications of this become clear in the following snippet (taken from the PassByReference example program in the online material):
void fn(int nArg1, int nArg2)
{
// modify the value of the arguments
nArg1 = 10;
nArg2 = 20;
}
int main(int nNumberofArgs, char* pszArgs[])
{
// initialize two variables and display their values
int nValue1 = 1;
int nValue2 = 2;
// now try to modify them by calling a function
fn(nValue1, nValue2);
// what is the value of nValue1 and nValue2 now?
cout << "nValue1 = " << nValue1 << endl;
cout << "nValue2 = " << nValue2 << endl;
return 0;
}
This program declares two variables, nValue1 and nValue2, initializes them to some known value, and then passes their value to a function fn(). This function changes the value of its arguments and simply returns 0.
To understand why, examine carefully how C++ handles memory in the call to fn(). C++ stores local variables (such as nValue1 and nValue2) in a special area of memory known as the stack. Upon entry into the function, C++ figures out how much stack memory the function will require, and then reserves that amount. Say, for argument’s sake, that in this example, the stack memory carved out for main() starts at location 0x1000 and extends to 0x101F. In this case, nValue1 might be at location 0x1000 and nValue2 at location 0x1004.
As part of making the call to fn(), C++ first stores the values of each argument on the stack — starting at the rightmost argument and working its way to the left.
For reasons that have more to do with the internal workings of the CPU, the stack “grows downward,” meaning that the memory used by fn() will have addresses smaller than 0x1000. Figure 17-3 shows the state of memory at the point that the computer processor reaches the first statement in fn(). C++ stored the second argument to the function at location 0x0FF4 and the first argument at 0x0FF0.
The function fn(int, int) contains two statements:
nArg1 = 10;
nArg2 = 20;
Figure 17-4 shows the contents of memory immediately after these two statements are executed. Pretty simple, really — the value of nArg1 has changed to 10 and nArg2 to 20, just as you would expect. The main point of this demonstration, however, is to show that changing the value of nArg1 and nArg2 has no effect on the original variables back at nValue1 and nValue2.
So what if I wanted the changes made by fn() to be permanent? I could do this by passing not the value of the variables but their address. This is demonstrated by the following snippet (also taken from the PassByReference example program):
// fn(int*, int*) - this function takes its arguments
// by reference
void fn(int* pnArg1, int* pnArg2)
{
// modify the value of the arguments
*pnArg1 = 10;
*pnArg2 = 20;
}
int main(int nNumberofArgs, char* pszArgs[])
{
// initialize two variables and display their values
int nValue1 = 1;
int nValue2 = 2;
fn(&nValue1, &nValue2);
return 0;
}
Notice first that the arguments to fn() are now declared not to be integers but pointers to integers. The call to fn(int*, int*) passes not the values of the variables nValue1 and nValue2 but their addresses.
The state of memory upon entry into this function is shown in Figure 17-5.
The function fn(int*, int*) now stores its values at the locations to which its arguments point:
*pnArg1 = 10;
*pnArg2 = 20;
This first statement says “store the value 10 at the int location passed to me in the argument pnArg1.” This stores a 10 at location 0x1000, which happens to be the variable nValue1. This is demonstrated graphically in Figure 17-6.
The complete PassByReference program appears as follows:
//
// PassByReference - demonstrate passing arguments to a
// function both by value and by
// reference.
//
#include <cstdio>
#include <cstdlib>
#include <iostream>
using namespace std;
// fn(int, int) - demonstrate a function that takes two
// arguments and modifies their value
void fn(int nArg1, int nArg2)
{
// modify the value of the arguments
nArg1 = 10;
nArg2 = 20;
}
// fn(int*, int*) - this function takes its arguments
// by reference
void fn(int* pnArg1, int* pnArg2)
{
// modify the value of the arguments
*pnArg1 = 10;
*pnArg2 = 20;
}
int main(int nNumberofArgs, char* pszArgs[])
{
// initialize two variables and display their values
int nValue1 = 1;
int nValue2 = 2;
cout << "The value of nArg1 is " << nValue1 << endl;
cout << "The value of nArg2 is " << nValue2 << endl;
// now try to modify them by calling a function
cout << "Calling fn(int, int)" << endl;
fn(nValue1, nValue2);
cout << "Returned from fn(int, int)" << endl;
cout << "The value of nArg1 is " << nValue1 << endl;
cout << "The value of nArg2 is " << nValue2 << endl;
// try again by calling a function that takes
// addresses as arguments
cout << "Calling fn(int*, int*)" << endl;
fn(&nValue1, &nValue2);
cout << "Returned from fn(int*, int*)" << endl;
cout << "The value of nArg1 is " << nValue1 << endl;
cout << "The value of nArg2 is " << nValue2 << endl;
// wait until user is ready before terminating program
// to allow the user to see the program results
cout << "Press Enter to continue..." << endl;
cin.ignore(10, '
'),
cin.get();
return 0;
}
The following is the output from this program:
The value of nArg1 is 1
The value of nArg2 is 2
Calling fn(int, int)
Returned from fn(int, int)
The value of nArg1 is 1
The value of nArg2 is 2
Calling fn(int*, int*)
Returned from fn(int*, int*)
The value of nArg1 is 10
The value of nArg2 is 20
Press Enter to continue …
This program declares the variables nValue1 and nValue2 and initializes them to 1 and 2, respectively. The program then displays their value just to make sure. Next, the program calls the fn(int, int), passing the value of the two variables. That function modifies the value of its arguments, but this has no effect on nValue1 and nValue2 as demonstrated by the fact that their value is unchanged after control returns to main().
The second call passes not the values of nValue1 and nValue2 but their addresses to the function fn(int*, int*). This time, the changes to pnArg1 and pnArg2 are retained even after control returns to main().
Notice that there is no confusion between the overloaded functions fn(int, int) and fn(int*, int*). The types of the arguments are easily distinguished.
C++ provides a second way to pass arguments by reference: C++ allows variables — including arguments to functions — to be declared referential, as follows:
void fn2(int& rnArg1, int& rnArg2)
{
rnArg1 = 10;
rnArg2 = 20;
}
int nValue1 = 1;
int nValue2 = 2;
fn2(nValue1, nValue2); // called just like fn(int, int)
// the values of nValue1 and nValue2 are now 10 and 20
The int& declares rnArg1 and rnArg2 to be references to int. Calling fn2() actually passes the address of nValue1 and nValue2 but C++ handles the pointer manipulation for you behind the curtains. Changes in fn2(int&, int&) are retained in the calling function.
The program PassByReference2 contained in the online material demonstrates a version of pass by reference, using the reference variable type.
// the following two functions cannot be differentiated
// since they are called the same way
void fn(int nArg1, int nArg2);
void fn(int& nArg1, int& nArg2);
So why mess with pointer variables when the referential declaration can handle it all for you? The fact is that I recommend that beginning programmers avoid using referential variable types — C++ may be handling the pointer work for you, but you still have to understand what C++ is doing for you under the covers. Without a firm understanding of pointer types, the beginner has a hard time understanding errors generated by referential declarations.
One of the problems addressed in Chapter 16 was that of fixed-size arrays. For example, the concatenate() function concatenated two ASCIIZ strings into a single string. However, the function had to be careful not to overrun the target array in case the array didn’t have enough room to hold the combined string. This problem would have gone away if concatenate() could have allocated a new array that was guaranteed to be large enough to hold the concatenated string.
That’s a great idea, but how big should I make this target array — 256 bytes, 512 bytes? There’s no right answer, because there’s no way to know at compile time how to make the target array big enough to hold all possible concatenated strings. You can’t know for sure until runtime how much memory you will need.
C++ provides an extra area in memory just for this purpose, known by the somewhat cryptic name of the heap. A programmer can allocate any amount of memory off of the heap by using the keyword new, as in the following example snippet:
char* pArray = new char[256];
This example carves a block of memory large enough to hold 256 characters off of the heap. The new keyword returns a pointer to the newly created array. Unlike other variables, heap memory is not allocated until runtime, which means the array size is not limited to constants that are determined at compile time — they can also be variables that are computed at runtime.
int nSizeOfArray = someFunction();
char* pArray = new char[nSizeOfArray];
Here the size of the array is computed by someFunction(). Obviously this computation can’t occur until the program is actually executing. Whatever value someFunction() returns is used as the size of the array to be allocated in the next statement.
A more practical example is the following code snippet that makes a copy of an ASCIIZ string (assuming you consider copying a string as practical):
int nLength = strlen(pszString) + 1;
char* pszCopy = new char[nLength];
strncpy(pszCopy, nLength, pszString);
The first statement calls the string function strlen(), which returns the length of the string passed it not including the terminating null character. The + 1 adds room for the terminating null. The next statement allocates room for the copy off of the heap. Finally, the third statement uses the string function strncpy() to copy the contents of pszString into pszCopy. By calculating how big an array you need to store the copy, you are guaranteed that pszCopy is large enough to hold the entire string.
Allocating memory off of the heap is a neat feature, but it has one very big danger in C++: If you allocate memory off of the heap, you must remember to return it to the heap when you’re done using it.
You return memory to the heap by using the delete keyword, as in the following:
char* pArray = new char[256];
// ...use the memory all you want...
// now return the memory block to the heap
delete[] pArray;
pArray = nullptr;
The delete[] keyword accepts a pointer to an array that has been passed to you from the new keyword and restores that memory to the heap.
If you don’t return heap memory when you are done with it, your program will slowly consume memory and eventually slow down more and more as the operating system tries to fulfill its apparently insatiable gluttony. Eventually, the program will come to a halt when the OS can no longer satisfy its requests for memory.
Returning the same memory to the heap twice is not quite as bad: Doing so causes the program to crash almost immediately. It’s considered good programming practice to zero out a pointer, using the keyword nullptr, once you’ve deleted the memory block that it points to. You do this for two very good reasons:
The following ConcatenateHeap program is a version of the concatenate() function that allocates its memory from off the heap:
//
// ConcatenateHeap - similar to ConcatenateString except
// this version stores the concatenated
// string in memory allocated from the
// heap so that we are guaranteed
// that the target array is always
// large enough
//
#include <cstdio>
#include <cstdlib>
#include <iostream>
#include <cstring>
using namespace std;
// concatenateString - concatenate two strings together
// into an array allocated off of the
// heap
char* concatenateString(const char szSrc1[],
const char szSrc2[])
{
// allocate an array of sufficient length
int nTargetSize = strlen(szSrc1) + strlen(szSrc2) + 1;
char* pszTarget = new char[nTargetSize];
// first copy the first string into the target
int nT;
for(nT = 0; szSrc1[nT] != ' '; nT++)
{
pszTarget[nT] = szSrc1[nT];
}
// now copy the contents of the second string onto
// the end of the first
for(int nS = 0; szSrc2[nS] != ' '; nT++, nS++)
{
pszTarget[nT] = szSrc2[nS];
}
// add the terminator to szTarget
pszTarget[nT] = ' ';
// return the results to the caller
return pszTarget;
}
int main(int nNumberofArgs, char* pszArgs[])
{
// Prompt user
cout << "This program accepts two strings
"
<< "from the keyboard and outputs them
"
<< "concatenated together.
" << endl;
// input two strings
cout << "Enter first string: ";
char szString1[256];
cin.getline(szString1, 256);
cout << "Enter the second string: ";
char szString2[256];
cin.getline(szString2, 256);
// now concatenate one onto the end of the other
cout << "Concatentate second string onto the first"
<< endl;
char* pszT = concatenateString(szString1, szString2);
// and display the result
cout << "Result: <"
<< pszT
<< ">" << endl;
// return the memory to the heap
delete[] pszT;
pszT = nullptr;
// wait until user is ready before terminating program
// to allow the user to see the program results
cout << "Press Enter to continue..." << endl;
cin.ignore(10, '
'),
cin.get();
return 0;
}
This program includes the #include file cstring to gain access to the strlen() function. The concatenateString() function is similar to the earlier versions, except that it returns the address of a block of heap memory containing the concatenated string rather than modify either of the strings passed to it.
char* pFullName = concatenateString("Mr. ", pszName);
The string "Mr. " is a const character array in the same sense that 1 is a const integer.
The first statement within concatenateString() calculates the size of the target array by calling strlen() on both source strings and adding 1 for the terminating null.
The next statement allocates an array of that size from off the heap, using the new keyword.
The two for loops work exactly like those in the earlier concatenate examples by copying first szSrc1 and then szSrc2 into the pszTarget array before tacking on the final terminating null.
The function then returns the address of the pszTarget array to the caller.
The main() function works the same as in the earlier Concatenate program by prompting the user for two strings and then displaying the concatenated result. The only difference is that this version returns the pointer returned by concatenateString() to the heap before terminating by executing the following snippet:
delete[] pszT;
pszT = nullptr;
The output from running this program is indistinguishable from its earlier cousins:
This program accepts two strings
from the keyboard and outputs them
concatenated together.
Enter first string: this is a string
Enter the second string: THIS IS ALSO A STRING
Concatentate second string onto the first
Result: <this is a stringTHIS IS ALSO A STRING>
Press Enter to continue …
The subject of C++ pointers is too vast to be handled in a single chapter. The next chapter examines the relationship between arrays and pointers, a topic I admittedly glossed over in the final example programs in this chapter.
18.223.206.225