Pointers and Memory Management¶
Note
From this point onwards, we will be using C++ instead of C. One of
the major differences is between the two languages is how they handle
dynamic memory. C++ uses new
and delete
/delete[]
, while C uses
malloc
and free
. We will only be using new
and
delete
/delete[]
in this course.
C++ Memory¶
managing memory turns out to be one of the trickiest and most important parts of C++ programming
a low-level way to think about memory in C++ is as one long vector of bytes:
address 0 1 2 3 4 5 6 N-2 N-1
+---+---+---+---+---+---+---+ ... +---+---+---+---+---+
value | | | |'a'| | | | | | | | | |
+---+---+---+---+---+---+---+ ... +---+---+---+---+---+
here, N
is the amount of memory in your computer
every value in a C++ program has a corresponding address, e.g. the character
a
is at address 3
for example, consider this code:
char x = 'a';
cout << x; // prints 'a'
cout << &x; // prints the address of 'a' (in this case 3)
&
is the address-of operator, and returns the address of a variable
in general, we don’t know at what address a C++ value will be stored at, so in general our programs should not depend upon specific address values
we use pointers to store addresses
for example, the address of a char
is of type char*
, i.e. char*
is
the type “pointer to a char
”
so we can write code like this:
char x = 'a';
char* px = &x; // px stores the address of x
// i.e. px points to x
in memory, it might look like this:
408 951
+---+---+---+---+---+ +---+---+---+---+
... | | |'a'| | | ... | |408| | | ...
+---+---+---+---+---+ +---+---+---+---+
x px
*px
x
has address 408, i.e. &x == 408
in this example (we don’t know the
exact address)
px
has address 951, i.e. &px = 951
in this example (we don’t know the
exact address)
the value in px
is the address of x
, so we say px
points to
x
since px
stores the address of x
, that means we can access the value
that location; C++ uses the notation *px
to access the value at x
so in this example, address 48 has two names: x
or px
, and you can
read/write the value there using either name, e.g.:
char x = 'a';
char* px = &x;
x = 'b';
cout << *px; // prints b
*px = 'c';
cout << x; // prints c
The Three Memory Regions of a Running C++ Program¶
a C++ program divides its memory into three main regions:
+--------------------------------------------+
| | | |
+--------------------------------------------+
Static Stack Free store
memory memory memory
Static memory has a fixed size (that is known at compile-time) that never gets bigger or smaller; global variables are stored here
Stack memory is where local variables and function parameters are stored; it automatically allocates and deallocates memory as needed
Free store (heap) memory is all the other memory, and it is not
automatically managed; instead, the programmer must allocated memory on the
free store using new
, and deallocate memory using delete
/delete[]
The Free Store¶
when we access the free store, we must do it through pointers:
double* y_addr = new double(6.4); // returns a pointer to a newly allocated
// double on the free store
new
always returns a pointer
use the *
to de-reference a pointer, i.e. to access the value it
points to
cout << *y_addr << "\n"; // prints 6.4
*y_addr += 2;
cout << *y_addr << "\n"; // prints 8.4
Deallocating Free Store Memory¶
whenever you are finished using memory that’s on the free store, you must
remember to delete
it, e.g.:
delete y_addr;
if you forget to do this, your program is said to have a memory leak
for long-running programs (such as an operating system, a web server, a web browser, a text editor, etc.), memory leaks can a major problem that eventually causes them to crash
- if you don’t de-allocate memory on the free store that you no longer need, a
future call to
new
might fail because there is not enough memory left
experience shows that finding and fixing memory leaks is very difficult in general
- the hard part is being 100% sure that you are
many other programming languages use an automatic memory deleter known as a garbage collector to deal with this
garbage collectors can have a performance impact that is too high for some of the applications that C++ is used for
- e.g. a real-time flight control software might not be able to tolerate even a short pause as the garbage collector runs and cleans up memory
instead of a garbage collector, C++ provides destructors, and also smart pointers, to give programmers control over memory in a way that it is often as easy having a garbage collector, but with more flexibility and control
Arrays on the Free Store¶
use new
and delete[]
(the []
-brackets are important!) to allocate
and de-allocate arrays on the free store:
int* arr = new int[5]; // arr points to a newly allocated array of 5 ints
// on the free store; the value of the ints is unknown
the []
-brackets are important: that’s how C++ knows you want to allocate
an array (as opposed to a single int
initialized to 5)
arr
points to the first value of the array, and so we can use pointer
arithmetic to access its individual elements:
*(arr + 0) = 1;
*(arr + 1) = 2;
*(arr + 2) = 3;
*(arr + 3) = 4;
*(arr + 4) = 5;
you can also use []
-bracket notation: arr[i]
is shorthand for *(arr + i)
:
arr[0] = 1;
arr[1] = 2;
arr[2] = 3;
arr[3] = 4;
arr[4] = 5;
here’s how you can imagine the array in memory:
218 219 220 221 222 223 224
+---+---+---+---+---+---+---+
... | | 1 | 2 | 3 | 4 | 5 | | ...
+---+---+---+---+---+---+---+
arr == 219
of course, we don’t know for sure the address values, so we’ve just made them up here
since arr
is an int*
(i.e. a pointer to an int
), for an array it
points to the first element of the array, i.e. it contains the address of
the first element (219 in this case)
arr + 1
is 220, the second element of the array
arr + 2
is 221, the third element of the array
arr + 3
is 220, the fourth element of the array
etc.
nothing stops you from accessing elements before or after the array
- e.g.
arr - 1
is address 218, which isint
-sized chunk of memory that appears just before the start of the array; in general, you have idea what memory value might be before the array, or even if you are allowed to access it - similarly,
arr + 5
is address 215, which us theint
-size chunk of memory immediately after the last element of the array; in general, you have idea what memory value might be before the array, or even if you are allowed to access it
C++ lets you add and subtract values to pointers, and this is called pointer arithmetic
- pointer arithmetic is useful for accessing array values
- but it has no restrictions: from a single pointer you can theoretically read/write any memory location on your computer, which is both dangerous and a security risk
as another example of using pointers, here are three different ways to print
the contents of arr
:
for(int i = 0; i < 5; ++i) { // *(arr + i) style
cout << *(arr + i) << "\n";
}
for(int i = 0; i < 5; ++i) { // arr[i] style
cout << arr[i] << "\n";
}
for(int* p = arr; p < arr + 5; p++) { // direct pointer style
cout << *p << "\n";
}
the last for-loop is interesting: it directly increments a pointer to print
each element, and no i
index variable is needed
- C++ knows that
p++
increments that address inp
by the length of 1int
since it knows thatp
is anint*
pointer
note that in all three loops we need to keep track of the size of the array ourselves since C++ arrays don’t store their size
Null Pointers¶
any pointer variable can be assigned the special value nullptr
, e.g.:
string* a = nullptr;
char* b = nullptr;
double* c = nullptr;
intuitively, a pointer with the value nullptr
points nowhere, i.e. it
doesn’t point to a valid memory address
you cannot de-reference a null pointer, i.e. *a
is an error if a ==
nullptr
because of this, you always need to be certain that a pointers value is not
nullptr
before you de-reference it, e.g. code that looks like this is
common with pointers:
if (a == nullptr) {
cmpt::error("error: null pointer");
} else {
cout << *a << "\n";
}
dealing with nullptr
values is yet another small annoyance when dealing
with pointer values, and it is up to the programmer to ensure they never
accidentally de-reference nullptr
- note that C++ references can never have a null value
- for example, if variable
x
has typeint&
(reference toint
), thenx
is guaranteed to contain anint
Example: Sorting a str_array¶
suppose str_array
is defined like this:
struct str_array {
string* arr;
int sz;
int cap;
}
str_array
is meant to store a dynamic array
arr
points to the first string
in the array
sz
is the number of strings in the array from the point of view of the
programmer using it
cap
is the size of the underlying array
here’s a diagram:
|
0 sz-1| sz cap-1
+----+-------- ---------+----+----+-- ---+----+
arr | | ... | | | ... | |
+----+-------- ---------+----+----+-- ---+----+
used entries | unused entries
|
arr[0]
to arr[sz-1]
are the first sz
elements of the array, and
these are the elements the programmer knows about
arr[sz]
to arr[cap-1]
are the unused array entries; they are there
to make it fast to expand the size of the array
- if you don’t have any used entries, then to increase the size of the dynamic
array you must make a new (bigger) array, copy all the elements from the old
array into it, and then delete the original array
- keeping some extra entries means often you won’t have to do this relatively expensive array copying
important: sz
< cap
must always be true
important: if sz
equals cap
, then there are no unused entries, and
so if you need to add any new strings it will be necessary to copy the
elements into a new array as described above
now suppose we want to sort the elements of a str_array
, i.e. we want to
re-arrange the strings in the underlying array so they are in alphabetical
order using this function call:
// words is a str_array that has already been created
sort(words);
after calling sort
, the strings in words
will be in alphabetical order
here is the function:
#include <algorithm>
using namespace std;
void sort(str_array a) {
sort(a.arr, a.arr + a.size); // this sort function is from <algorithm>
}
to sort a str_array
named a
, we have to sort all the elements in
a.arr
from a.arr[0]
to a.arr[a.size-1]
fortunately, the C++ library has a sorting function called sort(begin,
end)
that lets us sort any sub-array in an array
we need to give std::sort
two inputs: a pointer to the first element of
the array (a.arr
), and a pointer to one past the last element of the
array (a.arr + a.size
)
sort(a.arr, a.arr + a.size - 1)
is wrong: it misses the last element of the array, i.e. the element ata.arr[a.size - 1]
- by requiring a begin pointer and end pointer,
std::sort
can sort any part of the array
here is some sample code showing everything working together:
#include "cmpt_error.h"
#include <iostream>
#include <algorithm>
using namespace std;
struct str_array {
string* arr;
int sz;
int cap;
};
void sort(str_array a) {
sort(a.arr, a.arr + a.sz);
}
int main() {
// create an array of 10 strings
string* arr = new string[10];
// the str_array is of size 3, so it has 3 strings and 7 unused entries
str_array a{arr, 3, 10};
// add some strings
a.arr[0] = "mouse";
a.arr[1] = "cheese";
a.arr[2] = "cat";
// print the str_array
for(int i = 0; i < a.sz; i++) {
cout << "\"" << a.arr[i] << "\" ";
}
cout << "\n";
sort(a);
// print the str_array
for(int i = 0; i < a.sz; i++) {
cout << "\"" << a.arr[i] << "\" ";
}
cout << "\n";
// de-allocate the underlying array to avoid a memory leak
delete[] a.arr;
}
Sample Code: Pointers¶
// pointers.cpp
#include "cmpt_error.h"
#include <iostream>
using namespace std;
void test1() {
// Variable a has the type double. The value 6.02 is stored at some
// location in the computer's memory.
double a = 6.02;
cout << " a = " << a << "\n";
// Variable pa has the type double*, i.e. pointer to a double. A double*
// can store the address of a variable. In this case, pa stores the
// address of variable.
double* pa = &a;
// The value of pa is the address of where in RAM a is stored. The
// compiler and operating system determine the exact memory location of a,
// and so we can't usually predict the value of pa. In fact, the value of
// pa can change from run to run.
cout << " pa = " << pa << "\n";
// pa is the address of where in memory variable a is stored, and *a is
// the value itself. This is important: having a pointer to a value means
// you know the address of that value, and so you can access that variable
// itself.
cout << "*pa = " << *pa << "\n";
// This statement modifies the value stored in a.
*pa = 2.11;
cout << "\n";
cout << " a = " << a << "\n";
cout << "*pa = " << *pa << "\n";
// You can have pointers to any type in C++, even pointers to pointers.
// Here, variable ppa stores the address of pa. Thus, *ppa is the value of
// pa, and **ppa is the value of a.
double** ppa = &pa;
cout << "\n";
cout << " *ppa = " << *ppa << " (value of pa)\n";
cout << "**ppa = " << **ppa << " (value of a)\n";
}
// C++ designates a region of your program's memory as the free store, where
// memory can be allocated and de-allocated by the program as it runs. In C++,
// we use new to allocate memory on the free store, and delete (or delete[], in
// the case of arrays) to deallocate memory on the free store.
//
// While this seems simple enough, decades of experience have shown that this
// sort of manual memory management is notoriously hard to get right. Errors
// related to the mis-use of new and delete are the source of some of the most
// difficult programming bugs. This is a major reason we're using valgrind: to
// check that our programs don't have any memory errors.
//
// Later, we will see that C++ provides a number of useful techniques for
// simplifying memory management.
void test3() {
// The expression new int(4) allocates one int on the free store,
// initializes that int to 4, and then returns a pointer to that int.
int* p = new int(4);
cout << "*p = " << *p << "\n";
(*p)++;
cout << "*p = " << *p << "\n";
// When we are done with free store memory, we de-allocate it, which means
// we tell the free store that the memory pointed to by p is no longer
// needed and can, if necessary, be allocated later in future calls to
// new.
delete p;
// If you don't delete p, then the memory that p pointed to stays
// allocated and cannot be re-used. This is not a problem here since we
// have allocated only a single byte. But in larger, long-running programs
// losing memory in this way can eventually use up all of memory and crash
// your program. This kind of error is called a **memory leak**, and it is
// a very common error in C and C++. In C++, it is entirely up to the
// programmer to prevent memory leaks. Explicitly calling delete is the
// simplest way to do this, but in practice it turns out to be
// surprisingly difficult to always call delete at the right time.
}
// Here's an example of a common problem you can run into with dynamic memory.
void test4() {
int* p = new int(5);
int* q = p;
// Now p and q both point to the same int on the free store.
cout << "*p = " << *p << "\n";
cout << "*q = " << *q << "\n";
// If we call delete on p, then the int p was pointing to is now deleted.
// But the pointer q does not know that!
delete p; // ok, but dangerous: how does q know this has happened?
*q = 2; // oops: q is not pointing to allocated memory!
cout << "*q = " << *q << "\n";
}
// When I run test4 on my computer, there's no compiler error or run-time
// error:
//
// $ ./pointers
//
// ... program runs without any apparent problem ...
//
// That's a problem: there code is definitely incorrect! This is where
// valgrind is immensely useful. Running the program using valgrind catches
// the memory errors:
//
// $ valgrind ./pointers
//
// ... whoa! valgrind reports 2 memory errors ...
//
// These print functions will be used in the test5.
void print(int* arr, int size) {
cout << "[";
for(int i = 0; i < size; ++i) {
cout << arr[i] << " ";
}
cout << "]";
}
void println(int* arr, int size) {
print(arr, size);
cout << "\n";
}
// Prints the elements in the range [begin, end), it prints *(begin + 0),
// *(begin + 1), *(begin + 2), ... *(end - 1).
//
// Notice that the range [begin, end) is asymmetric: *(begin + 0) is printed,
// but *(end + 0) is not printed.
void print(int* begin, int* end) {
cout << "[";
for(int* p = begin; p < end; ++p) {
cout << *p << " ";
}
cout << "]";
}
void println(int* begin, int* end) {
print(begin, end);
cout << "\n";
}
// You can allocate arrays on the free store using new and delete[] (note the
// square brackets at the end of delete).
void test5() {
// arr points to a newly allocated array of 5 ints on the free store. The
// value of the ints is unknown.
int* arr = new int[5];
// We can access the individual elements of arr using pointer arithmetic
// and the *-operator.
*(arr + 0) = 1;
*(arr + 1) = 2;
*(arr + 2) = 3;
*(arr + 3) = 4;
*(arr + 4) = 5;
// Equivalently, and often more conveniently, we can use the []-notation
// to access individual elements. In general, the notation arr[i] is just
// shorthand for *(arr + i).
arr[0] = 1;
arr[1] = 2;
arr[2] = 3;
arr[3] = 4;
arr[4] = 5;
// Print the elements of arr using pointer notation.
for(int i = 0; i < 5; ++i) {
cout << *(arr + i) << "\n";
}
// Print the elements of arr using []-notation.
cout << "\n";
for(int i = 0; i < 5; ++i) {
cout << arr[i] << "\n";
}
// Print the elements of arr using a pointer (instead of an int) to point
// to each element. In this case p is a pointer of type int* that initially
// points to the first element of arr, and then each time through the loop
// is incremented to point to the next element.
cout << "\n";
for(int* p = arr; p < arr + 5; p++) {
cout << *p << "\n";
}
// Here we use a print function that takes as input and int* pointing to
// the first element of the array, and the size of the array.
cout << "\n";
println(arr, 5);
// Here we use a more general version of the print function that takes as
// input a pointer to the first element we want to print, and a pointer to
// one past the last element we want to print.
cout << "\n";
println(arr, arr + 5);
// De-allocate arr using delete[] (not delete!) so that future calls to
// could re-use this memory. You must use the []-bracket delete[] since
// new was called using []-brackets.
delete[] arr;
}
// sets all the values of arr to val
void fill(int* arr, int val, int size) {
for(int i = 0; i < size; ++i) {
arr[i] = val;
}
}
// returns a pointer to a newly allocated array
int* make_int_arr(int size, int val = 0) {
int* arr = new int[size];
for(int i = 0; i < size; ++i) {
arr[i] = val;
}
return arr;
}
void test6() {
// arr1 points to a newly created array of 5 ints on the free store.
int* arr1 = new int[5];
// Set all the values of arr1 to 0.
fill(arr1, 0, 5);
// Print it to cout.
println(arr1, 5);
// make_int_arr(6, 0) returns a pointer to a newly allocated array of 6
// ints on the free store, all initialized to 0. This is the standard
// technique to use when you want a function to return an array, i.e.
// return a pointer to an array on the free store.
int* arr2 = make_int_arr(6, 0);
print(arr2, 6);
// Always remember to de-allocate memory!
delete[] arr1;
delete[] arr2;
}
struct Point {
double x, y;
};
void test7() {
// p points to a newly created Point object on the free store.
Point* p = new Point{3.1, -4.2};
// p points to the Point, and so *p is the Point itself. You can thus
// access individual elements using the regular struct "."-notation
cout << "x = " << (*p).x << "\n";
cout << "y = " << (*p).y << "\n";
// Pointers to structs (i.e. objects) are so common in C++ that
// expressions like (*p).x are quite common, and so C++ provides a special
// notation, i.e. the -> operator. The expression p->x is the same as
// (*p).x.
cout << "\n";
cout << "x = " << p->x << "\n";
cout << "y = " << p->y << "\n";
// Always remember to de-allocate memory!
delete p;
}
int main() {
// test1();
// test2();
// test3();
// test4();
// test5();
// test6();
test7();
}