How data is Stored in C++

Steven J. Zeil

Last modified: Oct 26, 2023
Contents:

You should enter this course with a solid knowledge of the basic data types in C++ and the ways in which we construct new data types using arrays, pointers, and structured types (structs and classes).

In this lesson, we take the time to back away from the details of specific data types and to talk instead about how data is stored and managed in C++ programs. Most of the concepts here were probably touched upon in your earlier courses, but it’s easy to lose sight of the forest when concentrating on the trees.

1 Storage Models

When a program is executing, its memory space will be divided into three areas:

 

The heap and the call stack grow toward each other. This allows us to make the most efficient use of the unused portion fo the address space. One program might have a huge call stack and little to no heap. Another might have a small stack and a massive heap. Others might be more balanced. As long as the heap and the call stack don’t actually collide, we still have address space left to grow in.

The real situation is slightly more complicated than that. Modern operating systems use virtual memory to map however much RAM memory is actually installed onto a much larger range of possible addresses, so we can run out of memory long before the heap and the call stack actually meet. But the essential thing to realize is that the two areas can never share the same block of addresses.

The storage area that a variable or value resides in will determine its lifetime, the period of time within which that value can be accessed. Before its lifetime, a value might not be initialized to anything meaningful. After its lifetime, the block of memory occupied by that variable might be reused for some other purpose, leading to the value being overwritten by some other data.

1.1 Automatic Storage

The call stack area manages the data associated with functions.

The activation record for a function call records various housekeeping information necessary to manage the call and return mechanisms. From the perspective of this lesson, however, the most important thing contained in the activations records is the storage space for all variables local to that function’s body.

Consider the following program:

struct Shipment {
  int numBooks;
  int numMagazines;
};

Shipment received[100];
int numShipments = 0;

void readShipments() // fills in received and numShipments
{
  ⋮
}

void announce (int n, string description)
{
  cout << "We have received " << n << " " << description << endl;
}

void countBooks()
{
  int count = 0;
  for (int i = 0; i < numShipments; ++i)
  {
    Shipment shipment = shipments[i];
    count += shipment.numBooks;
  }
  announce (count, "books");
}

void countMagazines()
{
  int count = 0;
  for (int i = 0; i < numShipments; ++i)
  {
    count += shipments[i].numMagazines;
  }
  announce (count, "magazines");
}


int main(int argc, char** argv)
{
  readShipments();
  countBooks();
  countMagazines();
}

If we trace what happens in this program, while watching the call stack, we can see how storage space for most of the variables appears and disappears.

 

The program starts by calling main. An activation record for main is pushed onto the stack. There will be a certain amount of bookkeeping information in there (designated as “???”) and space to hold the parameters argc and argv.

first prev1 of 15next last

So, the thing to observe from all this is that storage for parameters and local variables appears just when we need it, upon entering a function call, and disappears when it is no longer needed, upon return from the function.

The programmer doesn’t need to issue any explicit instructions at all to make this happen. Consequently, variables like this are said to have automatic storage.

1.2 Static Storage

 

Static storage holds data values whose lifetime starts and ends when the entire program starts and ends.

In C++, these are variables declared at the “file scope”, not inside a function body or class declaration.

In our earlier example, …

struct Shipment {
  int numBooks;
  int numMagazines;
};

Shipment received[100];
int numShipments = 0;

void readShipments() // fills in received and numShipments
{
  ⋮

… both received and numShipments would be static. The initialization of numShipments to 0 would be one of the very first actions taken by the running program, before even the first statement of main(...) is performed. Consequently, numShipments would appear to be properly initialized by the time any code we wrote had a chance to look at it.

1.2.1 Static variables in Functions

There is another way to declare static variables in C++, and that is to use the key word static in the declaration:

void foo()
{
  static int fooCounter = 0;
  int otherCounter = 0;
  ++fooCounter;
  ++otherCounter;
  cout << fooCounter << ' ' << otherCounter << endl;
}

int main()
{
  foo();
  foo();
  foo();
  return 0;
}

In the above code, fooCounter will be static. That means that it will be initialized to zero when the program starts running. But otherCounter is not static. It will be initialized to zero every time the foo function is called. The output of this program would be

1 1
2 1
3 1

Over the lifetime of this program, there will only be one fooCounter. But there will be three different values named otherCounter, each with a much shorter lifetime.

1.2.2 Static variables in Classes

static does something similar for data members declared within classes.

struct Point {
  int x = 0;
  int y = 0;
  static int pointCounter;
};

You should be familiar already with the fact that, if we want to get an x or y value, we need to request it via an object of type Point:

Point p1;
Point p2;
x = 1; // No! x only occurs inside a Point
p1.x = 1; // OK, there is an x inside p1
p2.x = 2; // OK, there is another, separate, x inside p2

A static data member, similarly, is only visible “within” the struct or class where it is declared.

Point p1;
Point p2;
pointCounter = 1; // Error! pointCounter only occurs inside Point
p1.pointCounter = 1; // Error! pointCounter is not a data member of p1
Point::pointCounter = 2; // OK, there is one pointCounter for all Points

We think of static data members as being a member of the class/struct itself, but not of the objects of that class/struct type. Another way to think of it is that a static data ember is shared by all the objects of that type.

1.3 Dynamic Storage

 

The heap area is for data that is allocated by explicit programmer instructions. In C++ these instructions take the form of uses of the new operator. The new operator returns a pointer to the newly allocated block of data. E.g.,

Shipment* pointerToShipment = new Shipment();

In C++, data that is allocated in the heap will remain there until another explicit programer instruction, the delete operator, is invoked on that pointer. Deleting a pointer returns the pointed-to block of data to the operating system.

delete pointerToShipment;

1.3.1 Reusing Deleted Storage

The operating system keeps track of the blocks of memory that have been deleted in a free list. This is done is discussed in a later lesson.

1.3.2 Why Use Dynamic Data?

Dynamic data offers two important advantages. It allows us to determine both the size of our “pieces” of data and the number of pieces of data at runtime.

Size

With static and automatic data, the size of each data item is fixed at compile time. This is most often evident when working with arrays. I can write code like this:

Shipment received[100];

to statically allocate an array. Or I can have an automatic array like this:

void processShipments()
{
  Shipment received[100];
  readShipments (received);
     ⋮
}

I can even use named constants to help clarify my code:

const int MaxShipments = 100;

void processShipments()
{
  Shipment received[MaxShipments];
  readShipments (received);
     ⋮
}

But I cannot do this:

void processShipments()
{
  cout << "How many shipments arrived today? " << flush;
  int numShipments;
  cin >> numShipments;
  Shipment received[numShipments];
  readShipments (received, numShipments);
     ⋮
}

The compiler cannot determine the value of numShipments at compile time. So it does not know how much space to set aside in the activation record of processShipments to store the received array.

Instead, we can allocate the array on the heap, in which case we don’t need to know the size of the array until runtime.

void processShipments()
{
  cout << "How many shipments arrived today? " << flush;
  int numShipments;
  cin >> numShipments;
  Shipment received = new Shipment[numShipments];
  readShipments (*received, numShipments);
     ⋮
  delete [] received;
}

Number

With static and automatic data, the number of “pieces” of data is equal to the number of variable names in your code. But with dynamic allocation, we can accumulate many more distinct pieces of data, so long as we have a means of storing the pointers to each of them.

void processShipments()
{
  cout << "How many shipments arrived today? " << flush;
  int numShipments;
  cin >> numShipments;
  Warehouse w;
  for (int i = 0; i < numShipments; ++i)
  {
    Shipment shipment = new Shipment();
    readShipmentDetails(*shipment);
    w.store(shipment);
  }
     ⋮
}

Each time around the loop, we allocate another Shipment value on the heap. The total number of such values can greatly exceed the number of variables in our code.

Exactly how we go about storing all of those pointers in a data structure will be the subject of much of the rest of this course.

1.3.3 Automatic Garbage Collection

In other programming languages (e.g., Java), programmers are not responsible for deleting pointers to data that they no longer need. Instead, the runtime system performs garbage collection, in which any data in the heap that can no longer be reached by a pointer is automatically collected and returned to the operating system.

1.3.4 Pointers are Risky

Compared to automatic and static storage, dynamic storage requires more work from the programmer and opens up the opportunities for many forms of runtime errors. These are explored in the next section.

2 Common Errors when using Pointers

Ideally, programmers will allocate their dynamic data with new and then delete it later when it is no longer needed. But, in practice, there is a lot of opportunity for mistakes.

One of the nastiest things about these pointer-based errors is that their effects are often delayed. If you make a mistake in a formula for calculating the value of some variable, the effect is immediate – that variable gets an incorrect value. Even if you don’t see that incorrect value until a much later output statement, you can pretty readily trace backwards using debugging output or an automatic debugger to see the incorrect values and figure out where they originated.

Errors involving pointers, on the other hand, may affect data that seems completely unrelated to the pointer and what it was pointing to. Some pointer errors may corrupt the structure of the heap itself (particularly the free list that tracks the blocks of memory that are available for reuse), in which case the problem will not manifest until a much later new request tries to reuse data from the corrupted list.

Many errors involving pointers will lead to inconsistent failures. This means that the error may cause a program to crash on one run, or run to completion but produce an incorrect output on another run, or run to completion but produce a different incorrect output on the next run. These are extremely frustrating to debug.

Many errors involving pointers will lead to intermittent failures. This means that on one run of the program, the error may > cause the program to fail, but on the next several runs the error seems to have no negative effect at all on the program. These > are a pain to debug.

In this course, you will likely make more use of pointers than you ever have before. So it’s a really good idea to familiarize yourself with the kinds of things that can go wrong.

2.1 Dereferencing a null pointer

“Dereferencing” is the term used for trying to access the data pointed at by a pointer.

null is a special value given to pointer that, at that moment, are not pointing at anything at all.

Shipment* nextShipment = nullptr;
    ⋮  (nothing assigning an address to nextShipment)
nBooks = nextShipment->numBooks;   // Error: dereferencing a null pointer

Dereferencing a null pointer is a common error, and is the easiest common pointer error to debug. On almost all machines, this error results in an immediate crash of the program. Runtime error messages associated with this problem include:

2.2 Dereferencing an uninitialized pointer

A pointer that is uninitialized will contain whatever bits happened to be left in its bytes of memory from earlier calculations.

Shipment* nextShipment;   // nextShipment contains "random" bits
    ⋮  (nothing assigning an address to nextShipment)
nBooks = nextShipment->numBooks;   // Error: dereferencing an uninitialized pointer

This is a potentially much scarier problem.

A good way to avoid this error is to never write a declaration like:

Shipment* nextShipment;   // nextShipment contains "random" bits

Always initialize your variables to a known, fixed value. That should be true of all variables, but especially pointers.

Shipment* nextShipment = null;

This may change your bug from “dereferencing an uninitialized pointer” to “dereferencing a null pointer”, but the latter is much easier to debug.

2.3 Leaking memory

If you allocate data on the heap, you are responsible for, eventually, deleting it.

If you fail to delete data that you no longer need, it accumulates in the heap. Later new requests don’t know that they could reuse that data, so the heap needs to expand instead. This is called a memory leak.

A few small blocks of data that you forget to delete may not do much harm. But if the allocation is inside a loop, you can wind up with a massive amount of unneeded, and often unreachable, data in the heap. Out “leak” has become a “flood”.

void foo(int k)
{
  for (int i = 0; i < k; ++i)
  {
    Shipment* ptr = new Shipment();
    doSomethingWith(*ptr);
    // Oops! Forgot to delete
  }
}

A program that leaks memory tends to grow steadily in process size as it continues to run. Eventually, the program begins to slow down because the operating system is swapping parts of the program out to disk because it won’t all fit in RAM memory. Eventually, the heap grows so large that it “bumps into” the call stack, and the program crashes.

But it’s actually potentially worse than that. When one program grows so large that the operating system struggles to keep it in memory, it may swap other programs on the system out to disk. That means that other, completely unrelated programs, start to slow down. Suddenly, none of your windows are refreshing, your mouse pointer movements and clicks seem to be ignored and everything begins to feel sluggish.

In the worst case, your program grows so large that the operating system begins to deny memory requests from other unrelated programs. All kinds of programs on your system start crashing because they can’t get enough memory to run in.

2.4 Dangling pointers

One of the reasons that we use pointers is because we can share data between different structures by pointing to a single value on the heap from multiple places.

But this practice makes us vulnerable to a new kind of error. For example, suppose that I share a shipment between two different structures:

Shipment* ptr = new Shipment();
addToWarehouse(ptr);
addToShippingManifest(ptr);
    ⋮
removeFromWarehouse(ptr);
delete ptr;    // Oops! Forgot to remove it from the manifest
    ⋮
// Remove a book from the manifest
Shipment* ptr2 = lastItemInManifest();   // this pointer is dangling
--ptr2->numBooks;  // Error! ptr2 does not point to a valid shipment

In the example above, I store a pointer in two different data structures. I later remove it from one and delete the pointer, believing that I have done with it. (I have forgotten that the same pointer still exists in the other structure.)

A pointer that holds the address of an already-deleted values is called a dangling pointer. Dangling pointers are not, in and of themselves, mistakes. But they are dangerous. The data that they point at may have been overwritten when it was put in the free list. Or it may have already been handed off for reuse in a completely different context and filled up with new data.

Reading from a dangling pointer produces, in essence, unpredictable results. Writing to a dangling pointer can corrupt unrelated data and is particularly prone to corrupting the free list. Again, these errors are likely to be both intermittent and inconsistent. We may not learn about the problem until much later, when, for example, new requests begin to fail because of a corrupted free list.

2.5 Using the wrong delete operator

One of the peculiarities of C++ is that it actually has two distinct delete operators, one for single instances of data, and one for arrays.

Shipment* ptr1 = new Shipment();  // Allocate one object on the heap
   ⋮ 
delete ptr1;                      // delete one object from the heap

Shipment* ptr2 = new Shipment[100]; // Allocate an array on the heap
   ⋮ 
delete [] ptr2;                     // delete an array from the heap

In theory, using delete where you should have used delete [], or vice versa, could lead to memory leaks or possibly corruption of the heap and eventual crashes. In practice, this seems to be innocuous when working with the g++ compiler, but there is no guarantee that it will always be so.

2.6 Deleting the same data twice

One of the big advantages of using pointers is that they permit data to be shared by having multiple pointers holding the address of the same block of data on the heap.

One of the big dangers of using pointers is that they permit data to be shared by having multiple pointers holding the address of the same block of data on the heap.

The above two statements are not contradictory. Sharing of data can significantly reduce the amount of memory required and can dramatically speed up a program, because the alternative is to make lots of copies of the same data, as we will explore in a later lesson in this module. But having multiple pointers holding the same address runs the risk of our eventually deleting both of those pointers.

Shipment ptr1 = new Shipment();
    ⋮
Shipment ptr2 = ptr1;
    ⋮
delete ptr1;   // Have to delete it one or else we will leak memory
    ⋮
delete ptr2;   // Oops! I must have forgotten that ptr1 and ptr2 had the same address.

Deleting the same address twice runs the same risks as writing to a dangling pointer. If that block of memory has already been reused, deleting it again will corrupt the data. If that block is still in the free list available for re-use, deleting it a second time will almost certainly corrupt that list, resulting in either massive memory leaks or failed new requests. These errors are likely to be both intermittent and inconsistent, and the actual symptoms may occur long after, and in a part of the program unrelated to, the place where we did the deletions.

3 The Address Sanitizer

The g++ compiler that we will use in this course has an optional feature (in Unix-like operating systems including Linux and MacOS) called the address sanitizer. This is a special version of the code for managing the heap that is capable of detecting most of the pointer errors we have discussed.

Using the sanitizer may significantly slow down your code, so it isn’t recommended for final “production” code. But while the code is under development, the sanitizer can be a real life-saver. Consequently, I will set up assignments with the sanitizer active whenever I think an assignment is particularly prone to pointer errors.

When the sanitizer cates a problem, it stops the program and emits a blast of text describing the problem. The trick is to realize that, in most cases, you need only focus on a few lines of information to get the help you need.

Here is a typical error report from the address sanitizer:

	=================================================================
	==7236==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000010 at pc 0x559078a7451d bp 0x7ffe1e8c8520 sp 0x7ffe1e8c8510
	READ of size 4 at 0x602000000010 thread T0
		#0 0x559078a7451c in danglingPointer() /home/zeil/courses/cs361/latest/lib/codeCrasher/codeCrasher.cpp:52
		#1 0x559078a748d9 in main /home/zeil/courses/cs361/latest/lib/codeCrasher/codeCrasher.cpp:100
		#2 0x7faaecf36bf6 in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x21bf6)
		#3 0x559078a74229 in _start (/home/zeil/courses/cs361/latest/lib/codeCrasher/codeCrasher+0x1229)

	0x602000000010 is located 0 bytes inside of 4-byte region [0x602000000010,0x602000000014)
	freed by thread T0 here:
		#0 0x7faaedfbe065 in operator delete(void*, unsigned long) (/usr/lib/x86_64-linux-gnu/libasan.so.5+0x10f065)
		#1 0x559078a744e5 in danglingPointer() /home/zeil/courses/cs361/latest/lib/codeCrasher/codeCrasher.cpp:51
		#2 0x559078a748d9 in main /home/zeil/courses/cs361/latest/lib/codeCrasher/codeCrasher.cpp:100
		#3 0x7faaecf36bf6 in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x21bf6)

	previously allocated by thread T0 here:
		#0 0x7faaedfbc99f in operator new(unsigned long) (/usr/lib/x86_64-linux-gnu/libasan.so.5+0x10d99f)
		#1 0x559078a744b8 in danglingPointer() /home/zeil/courses/cs361/latest/lib/codeCrasher/codeCrasher.cpp:49
		#2 0x559078a748d9 in main /home/zeil/courses/cs361/latest/lib/codeCrasher/codeCrasher.cpp:100
		#3 0x7faaecf36bf6 in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x21bf6)

	SUMMARY: AddressSanitizer: heap-use-after-free /home/zeil/courses/cs361/latest/lib/codeCrasher/codeCrasher.cpp:52 in danglingPointer()
	Shadow bytes around the buggy address:
	  0x0c047fff7fb0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
	  0x0c047fff7fc0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
	  0x0c047fff7fd0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
	  0x0c047fff7fe0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
	  0x0c047fff7ff0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
	=>0x0c047fff8000: fa fa[fd]fa fa fa fa fa fa fa fa fa fa fa fa fa
	  0x0c047fff8010: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
	  0x0c047fff8020: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
	  0x0c047fff8030: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
	  0x0c047fff8040: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
	  0x0c047fff8050: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
	Shadow byte legend (one shadow byte represents 8 application bytes):
	  Addressable:           00
	  Partially addressable: 01 02 03 04 05 06 07 
	  Heap left redzone:       fa
	  Freed heap region:       fd
	  Stack left redzone:      f1
	  Stack mid redzone:       f2
	  Stack right redzone:     f3
	  Stack after return:      f5
	  Stack use after scope:   f8
	  Global redzone:          f9
	  Global init order:       f6
	  Poisoned by user:        f7
	  Container overflow:      fc
	  Array cookie:            ac
	  Intra object redzone:    bb
	  ASan internal:           fe
	  Left alloca redzone:     ca
	  Right alloca redzone:    cb
	  Shadow gap:              cc
	==7236==ABORTING

That may seem a bit intimidating, but let’s break it down:

  1. The most important bit is right here. It tells us that the error message is coming from the address sanitizer, and that the problem is a “heap-use-after-free”, i.e., we tried to use a block of memory that had already been freed (deleted). In other words, we had a dangling pointer, and we tried to use it.

  2. Next most important bit is here. It tells us the name of the function (’danglingPointer()) that we were in when the problem was detected, and the file and line number (line 52 of codeCrasher.cpp) where that happened.

    That may be as much info as we need to start debugging. But, if we want more.

  3. We can also find out in what line of code that block of memory had been deleted, and even in what line of code it had been allocated.

I find that many students panic the moment they see an error message of more than one line long. But if you keep your head and simply read the text a bit at a time, you will find that you can pick out the critical info (what happened?, and where did we detect it?) with only a little effort.

3.1 Getting Familiar with the Sanitizer.

  1. Download this code and unzip it (unzip codeCrasher.zip) in a convenient directory on one of the CS Dept. Linux servers.
  2. cd to that directory and compile the code by giving the command “make”.
  3. Run the code:
    ./codeCrasher
    

This program is designed to let you choose among the various pointer-handling errors we have discusses so that you can see how the Address Sanitizer responds to each one. Run the program repeatedly, choosing a different option each time, and get used to the error messages issued for each one.

4 Pointers versus References

C++ has another data type that, like pointers, holds addresses of data rather than the data itself. These are references.

References have many of the advantages of pointers, but a lot of differences as well.

4.1 Head to Head Code Comparisons

Pointer Reference
declaring Shipment* pShip; Shipment& rShip = existingShipment;

Pointers are declared using ’*‘ to modify the type it will point to. References are declared using ’&’ to modify that type. However, a declaration of a reference must provide the name or an expression giving the object that the reference will point to.

To be clear, rShip does not contain a copy of the shipment. It contains the address of the existingShipment variable. Unlike our typical use of pointers, the address being stored is not, in this case, on the heap.

References can point to things on the heap if they are initialized from an address of something on the heap.

pShip = new Shipment();
Shipment& rShip2 = *pShip;   // rShip2 and pShip now point to the same place
Pointer Reference
accessing the whole object Shipment ship1 = *pShip; Shipment ship2 = rShip;
accessing part of an object int nb1 = pShip->numBooks; int nb2 = rShip.numBooks;

References do not use the special operators * and -> that decorate most pointer operations. In fact, if we did not know that rShip was a reference, it would look just like an ordinary variable of type Shipment;

Pointer Reference
make it point somewhere else pShip = &ship1; not possible with rShip

The unary operator & means “address of” in C++. So the pShip assignment causes pShip to point to a different location than it had before. That’s not possible with references. Once you initialize a reference with an address, you cannot make it point somewhere else.

Pointer Reference
replace the entire object *pShip = ship2; rShip = ship1;

Again, the reference does not use the * decoration. Instead, assigning an entire new value to the object pointed to by rShip looks just like assigning one Shipment variable to another.

Pointer Reference
these do not mean the same thing pShip = x; rShip = y;

The first of these statements changes where pShip points. The second one changes the value stored at the locatio nwhere rShip points.

4.2 Where do we use References?

You may very well have seen references in a single place – the declarations of parameters being passed to a function.

void compare(Shipment s1, Shipment& s2);

Most books and most instructors would show something like this and explain that s1 is a Shipment being passed “by copy” but that s2 is a Shipment being passed “by reference”. They would go on to explain that when something is passed by reference, only an address is actually passed. This is both faster (for large objects) and means that we can send output from the function by storing at that address.

But a more correct reading of that declaration is to say that s1 is a Shipment and s2 is a Shipment& (a reference to a shipment) and that both are passed “by copy”. It’s just that a Shipment& is really just an address, and copying an address is both faster and allows the address to be used for output.

Another common use of references is in holding on to computed addresses so that we do not need to repeat the computation over and over. This can both speed up the code and simplify it.

Compare, for example, this code:

for (int i = 0; i < 1000; ++i)
    for (int j = 0; j < 1000; ++j)
    {
      for (int k = 0; k < 1000; ++k)
      {
        a[i][j] += v[k];
        w[k] += a[i][j];
      }
    }

to this:

for (int i = 0; i < 1000; ++i)
    for (int j = 0; j < 1000; ++j)
    {
      double& aij = a[i][j];
      for (int k = 0; k < 1000; ++k)
      {
        aij += v[k];
        w[k] += aij;
      }
    }

We will see this sort of reference use more often when we look at data structures that support searching, which often have functions that return an address in the form of a reference. Compare

ShipmentsByDate byDate;
    ⋮

// If we received a shipment with at least one book today, remove one
// book to put in the display window
if (byDate.search(today()).numBooks > 0) 
{
  --byDate.search(today()).numBooks;
}

or

ShipmentsByDate byDate;
    ⋮

// If we received a shipment with at least one book today, remove one
// book to put in the display window
Shipment todaysShipment = byDate.search(today());
if (todaysShipment.numBooks > 0) 
{
  --todaysShipment.numBooks;
  byDate.replace(today(), todaysShipment);
}

to

ShipmentsByDate byDate;
    ⋮

// If we received a shipment with at least one book today, remove one
// book to put in the display window
Shipment& todaysShipment = byDate.search(today());
if (todaysShipment.numBooks > 0) 
{
  --todaysShipment.numBooks;
}

All three do the same thing, but the use of a reference variable in the to hold on to the address inside byDate makes for a much simpler update.

4.3 Const References & Const Pointers

When we have a pointer, there are two things we can change about it:

  1. We can change the value at the address that it points to, e.g., pShip->numBooks = 0;, or *pShip = shipment2;
  2. We can change where it points, e.g., pShip = pShip2;

When we have a reference, there is only one thing we can change with it:

  1. We can change the value at the address that it points to, e.g., rShip.numBooks = 0;, or rShip = shipment2;
  2. We cannot change where it points, however. References don’t allow that.

If we modify the declarations of pointers and references by adding “const”, we can no longer use them to change the value at the address they point to.

const Shipment* pShip = new Shipment(); // pShip is a const pointer
pShip = pShip2;           // OK. This changes where pShip points to.
pShip->numBooks = 0;      // Error! Cannot change values via a const pointer.
int k = pShip->numBooks;  // OK. this looks at the value but does not try to change it.
*pShip = shipment2;       // Error! Cannot change values via a const pointer.
shipment3 = *pShip;       // OK. this looks at the value but does not try to change it.

const Shipment& rShip = shipment1; // rShip is a const reference
rShip.numBooks = 0;      // Error! Cannot change values via a const pointer.
int k = rShip.numBooks;  // OK. this looks at the value but does not try to change it.
rShip = shipment2;       // Error! Cannot change values via a const pointer.
shipment3 = rShip;       // OK. this looks at the value but does not try to change it.

C++ is very particular about keeping track of what values you are allowed to change and what values you are not allowed to change. You see this a lot when working with function parameters passed as const references or const pointers.

void doSomething (Shipment s1, Shipment& s2, const Shipment& s3)
{
  s1 = s2;         // OK, s1 is a local copy and we are allowed to change it;
  s2.numBooks = 0; // OK. s2 is a non-const reference, so we can change the value
  s3.numBooks = 1; // Error. s3 was passed as a const reference. We can look at its value,
                   //        but we can't change it.
}

In essence, when we decide to declare a function parameter as const, we are making a promise to anyone using that function that we’ll never write a function body that changes the value of that parameter. That’s a useful thing to know when you are using a function.

And the compiler will enforce that promise!