Pointers
Why do we need pointers?
This is a question that many people have when they first learn pointers. They might (at a high level) understand the what, but not the why or how.
To avoid this, before we even talk about pointers we will work through the reasoning for why they are useful. Try to forget most of what you may already understand about what high level languages you may already be familiar with (e.g. Java, Python), and instead follow this line of thinking in the context of C++.
The Stack: Primitive Variables
For now, let's limit ourselves to primitive variable types only. In C++, this includes int
(integer), char
, (character), and double
(decimal point number). This is not an exhaustive list, but will suffice for now.
In C++, you would define them like this in a basic main function.
But how does this program actually store all of these values? Where are these variables actually found in memory in your computer? To understand this, we need to talk about the stack.
When you run a program, there are two major sections of memory that exist. They are called the stack and heap.
The heap is incredibly important, but we are going to forget about it right now. Eventually we will run into problems without it, but we'll talk about it then.
The Stack is where all of our primitive variables will be stored. All of your int
, char
, and double
variables are going to be stored here.
Here is a diagram which shows how these regions of memory are laid out:
You will note that there are other sections and annotations which we didn't mention. They aren't relevant for what we are talking about, but you can read more about them online if you are interested.
What we are interested in, however, are the relative positions of the stack and heap in this diagram. You'll notice that there are arrows from the stack and the heap pointing to each other. This means that as you declare more primitives, your stack will grow larger, and it will get closer to your heap.
Earlier we mentioned that primitive variables are what are stored on the stack. So, if you were to diagram the stack for our main function we wrote above, it would look something like this (a simplified view):
------ Bottom of Stack
age = 21
letter = 'a'
fav_num = 4.2
------ Top of Stack
... (lots of empty space)
------ Top of Heap
So in our stack we have all of our primitive variables we defined. Note that the variable at the bottom of the stack is the first variable defined, and every subsequent variable gets "placed ontop of" the previous. This is a little confusing because in the diagram our stack is growing down, but just keep that in mind. You can imagine it like a stack of papers. Each variable we define is another paper we add to the stack, so the top variable is the most recently defined variable.
(tangent: in modern compilers the ordering on the stack might not exactly line up with the ordering in your program, for security or optimization reasons, but for learning it is fine to imagine it this way.)
In our sample program we never actually do anything with these variables, so let's extend the program slightly to print all of the values out to our terminal.
#include <iostream>
int main() {
int age = 21;
char letter = 'a';
double fav_num = 4.2;
std::cout << "age = " << age << "\n";
std::cout << "letter = " << letter << "\n";
std::cout << "fav_num = " << fav_num << std::endl;
return 0;
}
Running this program gives the following output, like we would expect:
So remember, after we have defined all of our variables, our stack looks like this:
Then, when the program gets to a line that is trying to access one of the values, it goes in the stack and is able to find the corresponding variable from the name in the program.
One important thing to note, however, is that the variable name is not stored anywhere on the stack. A more accurate (but still simplified) diagram would actually look like this:
So, on the stack it just has the literal values that are being stored. With this in mind, how does the program know where to get each value?
The answer is that the program remembers which variable is at which slot. So if we think of age
as being in slot 0, then the program actually identifies age as the variable that is in slot 0. Then letter
would correspond to slot 1, and fav_num
to slot 2.
(This might make you wonder how the stack looks throughout various function calls. Essentially, at a high level the stack will be split up into different sections (called stack frames) which contain all of the primitives for that given function. This is incredibly important to understand, but for this workshop we are just going to be assuming we never call a function from main
, so main's stack frame is the only one we have to worry about.)
Now, in preparation for something we are going to talk about in a moment, let's make our stack diagram a little more complicated. Ponder this statement:
Different variable types have different sizes.
Therefore, the amount of space that each of our primitive variables takes up on the stack will actually be different. Let's take a look at these:
The smallest of our primitives here is the char
, which takes up 1 byte (8 bits). This is the smallest possible variable size, because your operating system cannot work with anything smaller at a time when it reads and writes to memory.
The next largest of our primitives here is the int
, which takes up 4 bytes (32 bits). This is followed by the double
, which takes up 8 bytes (64 bits).
Therefore, a more accurate depiction of our stack would look like the following:
In this diagram, each line corresponds to 1 byte. I am using the "
character to signify that the byte at that location is being used to store the entirety of the above primitive variable.
Once you understand that different variables have different sizes, this explains many of the other primitive types in C. You can do a cursory google search if you want an exhaustive list. One of the most common, however, is the float
, which is like a double
but only takes up 4 bytes (32 bits). (You can think of a double
as a float, but it takes up "double" the amount of space, hence the name).
Now that we have introduced the basics of storing primitive values on the stack, ponder this statement:
The compiler must know the size of the stack at compile time.
In our simple example, this is undoubtably true. The compiler can look at the actual text of the program itself and see that it will need 13 bytes for all of the variables on the stack (4 bytes for the int
, 1 byte for the char
and 8 bytes for the double
).
Why is this the case? Well, to answer this it relies on a lower level understanding of how all of this memory is actually being laid out, but essentially the fact that your compiler knows the exact size the stack will be lets it lay out things in memory more efficiently.
Then how do we write programs where this is not the case? Does this ever actually happen?
A Program With Nondeterminable Size at Compile-Time
While you can write a program which has non determinable stack size at compile time, it will not compile. (Ignore the exceptions we briefly mentioned exist earlier.)
#include <iostream>
int main() {
int size;
// Read one integer from standard input
// Ignore input validation to make this simpler
std::cin >> size;
int arr[size];
for (int i = 0; i < size; i++) {
arr[i] = i;
std::cout << i << ", ";
}
std::cout << std::endl;
return 0;
}
If you try to compile this program, you will get the following output:
tyler:~/test2$ g++ main.cpp -Werror=vla
main.cpp: In function ‘int main()’:
main.cpp:10:9: error: variable length array ‘arr’ is used [-Werror=vla]
10 | int arr[size];
| ^~~
cc1plus: some warnings being treated as errors
If you analyze this output, you'll realize that I actually passed an extra flag into g++
. What this is doing is telling the compiler to error if it detects a vla (variable lengthed array). This is actually because by default g++
will allow variable lengthed arrays, even though they are not allowed by the C++ standard. This means that on other compilers, this program would fail by default.
Even if we were letting ourselves get away with variable length arrays, we would still run into problems if we made the example a little more complicated.
int main() {
int size;
std::cin >> size;
int arr[size]; // variable length array
for (int i = 0; i < size; i++) {
arr[i] = 0; // Zero out the array
}
// Do some stuff
char c = 'c';
double d = 'd';
// Now I want to resize arr
// How should this work?
// ...
}
In this example, we create a variable length array (which again, you should not do, but let's assume you did), then we do some work which involves creating more primitive variables on the stack beneath the array. Then later on, how would we handle resizing the array? Imagine our stack frame looked like this:
Would we have to shift down c
and d
to make room for more elements in arr? Would it make sense for the compiler to put c
and d
before arr
so that arr
would have room to grow beneath? But then, what if we had two arrays we wanted to dynamically resize throughout the program that were right next to eachother? Would we have to shift around the arrays in memory every time we wanted to resize them? Then this isn't even considering the fact that the compiler needs to remember in what offset each variable is. If we keep shifting around variables on the stack it will make it extra complicated, if not impossible, to determine in what slot each variable is stored.
The whole point of this thought experiment is to drive in the point that having dynamically sized data on the stack is a bad idea. With the nature of how the stack shrinks and grows, it makes everything better if we know at compile time how large a stack frame for a certain function will be.
Therefore, we need another place in memory to store dynamically allocated values.
The Heap
So, if you remember, our memory diagram from before looked like this:
We have our stack with primitive values growing down. Now, we'll have another place in memory: the heap with values growing up towards the stack. There is plenty of empty space between these two places, so they will be able to grow for a while. This provides a great property, because if we isolate all of our dynamically allocated data on the heap, when we want to mess with it then it wont shift around things on the stack.
For the time being we will shift from C++ to C. This is mainly because C's memory management is slightly lower level than that in C++, and explaining the extra details will hopefully help your understanding. If you understand C's memory management, C++'s is just simpler.
In C, when you want to store data on the heap you use the malloc
function. (Malloc = Manual Allocation). There are some other variations of malloc
, but malloc
is the most basic (and common).
For example, the following call to malloc
will find a free 4 bytes on the heap for you. This would be enough to store a single integer, or possibly 4 characters.
Ok... so we now have 4 bytes to play with on the heap. How do we actually, y'know, work with this memory?
Introducing Pointers
We have finally reached the point where we need pointers!
If you remember from earlier, all of our primitives are stored on the stack. When we want to access the value of a variable, behind the scenes the program translates the variable name into an offset, which it then uses to index into the stack.
So if the way we access variables is by offsets in the stack, how do we get all the way from the stack down into the heap? If you remember from the diagram, they are quite far from each other, and the programmer shouldn't really have to care about how far apart the stack and heap are. For the programmer, they should be two completely different things.
This is where pointers come in. Earlier we gave a non-exaustive list of primitive types, which included int
, char
, and double
. Now, we can introduce another kind of primitives: pointers. A pointer is a variable which holds the memory address of another variable, which usually lives in the heap. (Although sometimes you have pointers to variables that live on the stack as well.) Using this memory address, you can find data on the heap.
Earlier, we showed a call to malloc
for 4 bytes. However, we never actually did anything with it, and there was nothing we could do because we didn't get the address of the newly allocated memory. That line of code is actually the simplest example of a memory leak, since after calling it there is no way to later on tell the program "hey, I'm done with this memory, so you can have it back." We'll talk more about memory leaks later on, but for now just know that to actually do things with this new space on the heap, you need the address. To get the address, you have to access the return value of malloc.
There are two pieces of new syntax here:
- Note the type of the variable:
int*
. The star specifies that it is a pointer. So you can readint*
as "int pointer" or "pointer to an int" - We use
sizeof(int)
instead of manually typing in 4 bytes. It is considered good practice to do this instead of typing out raw numbers, because it is clearer what you are trying to do, and on certain machines different variables might have different sizes. For example, on some older machines ints might only be 2 bytes, or 16 bits.
Earlier, we talked about the different sizes of primitives. If you remember, int
was 4 bytes, char
1 byte, and double
8 bytes. Most modern systems are what we call 64 bit systems, so on these systems pointers will be 64 bits (8 bytes), the same size as a double. However, on some older 32 bit systems pointers will be 32 bits (4 bytes).
Now, our memory diagram will look something like this. Note that we are abstracting away the complexity of addresses and the varying sizes of things on the stack, but know in the back of your mind that it takes twice the size to store the pointer than the int in our example because the pointer takes up 8 bytes and the int takes up 4. Also, I am including the name of the stack variable for clarity, even though that would not actually be stored on the stack.
--------------------- Bottom of Stack
ptr = Address A
--------------------- Top of Stack
empty space
--------------------- Top (end) of Heap
Address A | ???
--------------------- Bottom (start) of Heap
Address A, in reality, would look like some hex number. In addition, the space in memory where ptr
is stored would also have a memory address (everything in memory does), but it is not relevant here.
Also note that the value of the integer on the heap is currently undefined. This is because in C when you allocate space on the heap, it will be set to whatever value was already there. This could be 0, but probably will be some arrangement of garbage bits.
Let's try to print out this garbage value on the heap. Using printf
, you use %d
to print a decimal integer, so your first attempt might look like this:
#include <stdio.h> // printf
#include <stdlib.h> // malloc
int main() {
int* ptr = malloc(sizeof(int));
printf("%d\n", ptr);
return 0;
}
Which when compiled tells you this:
tyler:/tmp$ gcc test.c
test.c: In function ‘main’:
test.c:7:14: warning: format ‘%d’ expects argument of type ‘int’, but argument 2 has type ‘int *’ [-Wformat=]
7 | printf("%d", ptr);
| ~^ ~~~
| | |
| int int *
| %ls
This is saying that when it parsed the format string passed into printf
(which was set to "%d"), when it encountered the %d, it expected to find an integer to print in the following argument list, but instead it found a pointer to an integer.
This is because when you type ptr
, you are referring to the actual memory address that is stored inside the primitive ptr
on the stack. If that was what we wanted to print out, then you would have to change the %d to %p (to tell printf to print out a pointer). This would look like:
#include <stdio.h> // printf
#include <stdlib.h> // malloc
int main() {
int* ptr = malloc(sizeof(int));
printf("%p\n", ptr);
return 0;
}
Note that the output of the memory address is a 12 digit hex number: that is how memory addresses are represented.
(Brief Tangent: you might expect the addresses to be 16 digits, because that would correspond to the 64 bits it takes to store the pointer. However, modern systems currently only use 48 of the 64 bits for the actual address since more bits aren't really needed, and 64 is a lot nicer to work with than 48. In addition, it also gives the benefit that in the future if we want to upgrade systems to use all 64 bits none of the underlying hardware will have to change.)
But that was all just to print the memory address that ptr
contains, how do we actually get the value stored there?
This is where the dereference operator ( * ) comes in. Before, we saw that you use an asterisk in the type of a variable to specify that it is a pointer. In addition, you also use the asterisk on a variable itself to dereference it. In code, this looks like this:
#include <stdio.h> // printf
#include <stdlib.h> // malloc
int main() {
int* ptr = malloc(sizeof(int));
printf("%d\n", *ptr); // Note: it is *ptr not ptr
return 0;
}
In essence, you can read *ptr
as "take the memory address stored in ptr
, go there, and pull out the value. However, earlier we saw that the memory address is just one byte, which is the starting byte the value is stored at. How does it know to read the full 4 bytes? It does that because we declared ptr
as an int*
, so when we dereference it, C knows to take 4 bytes because that is the size of an int. If we had declared a char*
, it would know to only take that one byte.
Executing the above program gives me the following output (your output will vary, depending on what was already stored in that memory location).
In this case, the value was zero. But it is also possible it will be some random number.
Arrays of Values
A very common use of manual memory allocation in C is for strings. In C, there is no String type like you would find in a higher level language, instead strings are just arrays of chars. But even then, arrays are just pointers, so strings are actually just pointers to chars. To see what I mean, lets first talk about integer arrays.
We already know how to instantiate an array of static size, such that it can be determined at compile time and placed on the stack. This is an example of such a program:
#include <stdio.h>
int main() {
int array[] = {1, 2, 3, 4, 5};
for (int i = 0; i < 5; i++) {
printf("%d\n", array[i]);
}
return 0;
}
When running this program, it will print out the numbers 1-5, which are being accessed by indexing into the array. On the stack, it will look something like this:
-------- Bottom of the Stack (high addresses)
arr[4] = 5
arr[3] = 4
arr[2] = 3
arr[1] = 2
arr[0] = 1
-------- Top of the Stack (low addresses)
Note that the array is filled upwards. The base index of the array has the lowest address. This will be relevant in a moment;
What would be the equivalent way to write this code, but by allocating the array on the heap? Well, it would look something like this:
#include <stdio.h> // printf
#include <stdlib.h> // malloc
int main() {
const int size = 5;
int* arr = malloc(sizeof(int) * size);
for (int i = 0; i < size; i++) {
arr[i] = i + 1;
}
// Could do this all in one for loop, but want to mimic the
// above example as close as possible
for (int i = 0; i < size; i++) {
printf("%d\n", arr[i]);
}
return 0;
}
This will output the same 1-5 as before, but this time our memory diagram will look something like this:
--------------------- Bottom of Stack
ptr = Address A
--------------------- Top of Stack
empty space
--------------------- Top (end) of Heap
Address E | 5
Address D | 4
Address C | 3
Address B | 2
Address A | 1
--------------------- Bottom (start) of Heap
If we wanted to fill in example memory addresses instead of using arbitary A-E, then it would look like this:
--------------------- Bottom of Stack
ptr = 0x55b863cfd2a0
--------------------- Top of Stack
empty space
--------------------- Top (end) of Heap
0x55b863cfd2b0 | 5
0x55b863cfd2ac | 4
0x55b863cfd2a8 | 3
0x55b863cfd2a4 | 2
0x55b863cfd2a0 | 1
--------------------- Bottom (start) of Heap
The first thing to note is that to create this array on the heap, we requested the amount of bytes necessary for an int
, then multiplied that by 5 because we wanted enough space to store 5 ints. Then, once we did that, we could use the [] operator as normal to index into the array.
This works because of how the [] operator works behind the scenes. Essentially, the [] operator is equivalent to:
int* arr = malloc(sizeof(int) * 5);
printf("%d\n", *arr); // arr[0]
printf("%d\n", *(arr + 4)); // arr[1]
printf("%d\n", *(arr + 8)); // arr[2]
printf("%d\n", *(arr + 12)); // arr[3]
printf("%d\n", *(arr + 16)); // arr[4]
Let's break this down.
When the program comes to the statement *arr
, it interprets that as "take the memory address stored in arr
, and dereference it. Because arr
is of type int*
, pull the next 4 bytes because that is how big an int is."
But then, when the program comes to the statement *(arr + 4)
, it has to first add 4 to the memory address. Essentially, this moves us up one integer in the array, and then we dereference that value.
Note that there is implicitly no bounds checking in this operation. Once you have used malloc
to acquire the starting memory address, for the purposes of indexing C doesn't keep track of how big that original block of space actually was. Therefore, there in the above example there is nothing stopping you from doing
and accessing the theoretical 1000th element in the array, which will be some random point of unallocated memory, so it will read garbage bytes and could be anything. Similarly, there is nothing stopping you from using a negative index either, and going backwards to even smaller addresses like so:
This above construction is exactly what the [] operator does. It takes the size of the variable type being stored in the array, and multiplies it by the index that you are trying to get, treating the 0th index as the the value stored at the starting pointer itself. Therefore, this is undefined behavior:
In most implementations of C, this will just go 5 spaces back in memory to whatever is there, but that is not guaranteed by anything in the C specification. In other words, there is a formal specification for the C programming language, which says that accessing beyond (or before) the bounds of an array is undefined behavior, so any particular compiler could do whatever they want and still be accurately implementing C. In reality, however, most implementations will just do what we described above, because it is the simplest and quickest thing to do.
A Note on Memory Leaks
Throughout all of the above examples, we called malloc
to manually allocate data on the heap, but then we never actually told the program that we were done with it. This wasn't a problem for these toy examples because when the program exited all of the memory was reclaimed by the Operating System, but in larger programs this would lead to a memory leak. Essentially, the program was never told that that specific location of memory is no longer in use, so it didn't know it could reuse it. On a small scale you probably have enough memory so this isn't a problem. But if you have a larger application which has to run 24/7 (think a web server) then this can lead to a huge problem because eventually the program will run out of memory on the heap and crash.
Here is a simple example of a memory leak.
#define TRUE 1
void do_work() {
int* arr = malloc(sizeof(int) * 10000);
// do cool things with this value...
}
int main() {
while (TRUE) {
do_work();
}
}
Let's imagine this program is running constantly. Each time the function do_work
is called, it will allocate space in the heap for 100 integers. Eventually, however, there will not be enough space to get another 100 integers, so the program will crash. If you let this program run for long enough, it will eventually crash.
To fix this, you have to call a function called free
and pass in the pointer. So the above example would be fine if we made it look like this:
#define TRUE 1
void do_work() {
int* arr = malloc(sizeof(int) * 100);
// do cool things with this value...
free(arr); // we are done with this int arr, so free it
}
int main() {
while (TRUE) {
do_work();
}
}
The free
function will be able to know that that block of space was for 100 ints, and it will mark all of that space as available for use in the future. (Note: it doesn't actually erase the values there, however, so if you then immediately after try to ask for space for another 100 integers, it is very likely that C will end up setting the new array to the same starting address as the old one! This should not be relied upon, however, since it is an effect of the C implementation (compiler) and not the C Standard.)
A Note on Strings
Earlier I mentioned that strings in C are just arrays of chars, which meant that they are just pointers to chars. Hopefully, this makes sense now. Here is a common example of string manipulation in C. Note that now we are adding parameters to the main function to take in command line arguments. If you are familiar with Java, these parameters should look very similar.
#include <stdio.h> // printf
#include <string.h> // strlen
#include <stdlib.h> // malloc
int main(int argc, char** argv) {
// Not checking bounds in argv for brevity...
// Assume the user passes in one command line arg, which
// will be stored in argv[1] (argv[0] is the program name)
printf("%d\n", argc);
printf("%s\n", argv[0]);
printf("%s\n", argv[1]);
// + 1 to include null terminating character '\0'
int string_length = strlen(argv[1]) + 1;
char* buffer = malloc(sizeof(char) * string_length);
strncpy(buffer, argv[1], string_length);
printf("buffer: %s\n", buffer);
// do work on the buffer....
free(buffer);
}
And here is example output of this program:
Let's break down this program.
First, we analyze the command line arguments passed through. In the example program, they are equal to the following values
value | reasoning | |
---|---|---|
argc | 2 | One command line arg passed in, plus the implict name of the program. |
argv[0] | "./a.out" | The name of the program |
argv[1] | "Hello!" | The command line argument passed in |
argv[2] | undefined behavior | This is outside the bounds of the argv array, so it could be anything! |
In the code, I wrote the type of argv
as char** argv
. But, it could also be written as char* argv[]
. Because arrays in C are just pointers, these are the same thing, but the latter may help you wrap your head around the concept.
We know that char*
is how we store strings, because the pointer is a pointer to the first byte of the string, and each subsequent byte is the next character in the string (with the last character being '\0', specifying the end of the string).
Therefore, char* argv[]
is an array of strings. So the first indexing operating, if you were to index at index i
, returns the i
th string in the array. Then if you were to index further at index j
, you would get the j
th character in the i
th string.
In code, this is argv[i][j]
. But most of the time you arent indexing into characters of strings, so argv[i]
just gives you the i
th string, which is of type char*
. You can pass in this value to functions that expect string input, like strlen
.
And coming full circle, since arrays are just pointers, you can rewrite char* argv[]
as char** argv
.
Now that we've explained how argc
and argv
work, we can move onto the actual logic of the program.
After printing the values of argc
and argv
for demonstration purposes, we get the length of argv[1]
, which is what the user typed in. In the example, this was equal to "Hello!"
. When we call strlen
on this string, we get the expected value of 6, since there are 6 characters in the string.
HOWEVER, that is not the amount of bytes it takes to store a string in C! Remember, there is nothing on the stack or heap itself that says how large an array is. So, for strings, they are terminated by the null terminator character \0
. The backslash is important here. If we just wrote '0'
, then that would correspond to the ASCII value of 48, since that representing the digit 0. But \0
literally means 0 in memory, that being a sequence of bits which looks like 0000 0000
. (Remember, 8 bits = 1 byte, so that is how many bits it takes to store a char). So when I typed in "Hello!"
into the terminal, when that gets placed into argv[1]
, in memory it actually looks like this:
argv[6] = '\0' (ASCII 0)
argv[5] = '!' (ASCII 33)
argv[4] = 'o' (ASCII 111)
argv[3] = 'l' (ASCII 108)
argv[2] = 'l' (ASCII 108)
argv[1] = 'e' (ASCII 101)
argv[0] = 'H' (ASCII 72)
With this in mind, that is why we add an additional 1 to the result of strlen
, because we are going to create a buffer on the heap where we will store the string. A buffer is a general term we use when we have a space in memory where we hold some value temporarily as we either wait for more input, or perform operations on it. In this case, we are copying the command line argument into the buffer (that lives on the heap), and you could imagine later on we would do some string processing on this buffer and return some sort of result.
The size of this buffer will be 7, not 6, because it needs enough room to store the final '\0
character. Forgetting this + 1 to the result of strlen
is an incredibly common error in C.
The last piece of the puzzle is strncpy
. This function takes in two pointers, dest
and src
, and copies n
characters from src
to dest
. So we copy exactly the number of characters needed into this buffer.
Polymorphism: Back to C++
So, all of this is fine and good for a lower level language like C, where we have to do a lot of pointer arithmetic manually to even use things like strings. But what about in a higher level lanaguage like C++, where you have an std::string
type for strings, and std::vector
for dynamically sized arrays? Well, pointers are (surprise!) still incredibly important in C++ for other reasons. We will go over one of them here that arises when doing Object Oriented Programming.
Before we do this however, a quick primer on C++ syntax and how it compares to C:
C++ Syntax
To allocate memory on the heap:
This is equivalent to
This is nice because you don't have to specify the size.
To free memory on the heap:
which is equivalent to the following C code:
Pointers and Polymorphism
Consider the following C++ program. It is written in the context of a hypothetical game. There is an abstract base class Enemy
from which Skeleton
and Zombie
derive from. Read through the comments in the code as they explain various parts of it. Note that various functions you would have for most classes (e.g. constructors and destructors) are not present to make the example more focused.
#include <iostream>
class Enemy {
public:
// Virtual means that the function can be overridden by any deriving class.
// In Java, all methods are virtual by default, but in C++ you have to specify it.
virtual void talk() {
std::cout << "I am an enemy." << std::endl;
}
}; // don't forget semicolon after the class definition!
// Note the syntax for inheritance: Skeleton "inherits" from Enemy. For normal cases this will always be public.
class Skeleton : public Enemy {
public:
// Note two things:
// 1. the "override" keyword guarantees at compile time that this function is overriding a
// virtual function from the base class. This helps improve code readability, and helps
// protect against errors where you might accidentally spell one of the function names
// wrong, because then it would be a compile error since the function names don't match up.
// 2. note that this function is not virtual because it is not expected to be overridden again.
void talk() override {
std::cout << "I am in eternal pain." << std::endl;
}
};
class Zombie : public Enemy {
public:
void talk() override {
std::cout << "Mmmmmmm Brains" << std::endl;
}
};
int main() {
Zombie zombie;
Skeleton skeleton;
zombie.talk();
skeleton.talk();
}
If we run this program, we get the following output:
If you have seen object oriented programming before, this shouldn't be too groundbreaking. Some important things to note are:
- We are not using any pointers or manual memory management, which is why we aren't getting much benefit out of this object oriented structure.
- Therefore, everything we are doing is currently being stored on the stack. If you are familiar with Java, then you'll know that objects in Java are inherently stored on the heap. This is not true, however, for C++, as objects created in this manner will still be stored on the stack. This works because we are defining the variables as of types Zombie and Skeleton, and the compiler knows the relative sizes of those. (In this case they aren't storing much data, but you can imagine they might have many member variables). If you want to be convinced, you can do a simple experiment:
int main() {
Zombie zombie;
Skeleton skeleton;
int stackInt = 0;
int* heapInt = new int;
std::cout << "zombie address: " << &zombie << "\n";
std::cout << "skeleton address: " << &skeleton << "\n";
std::cout << "stackInt address: " << &stackInt << "\n";
std::cout << "heapInt address: " << &heapInt << "\n";
std::cout << "address of int that heapInt points to: " << heapInt << std::endl;
}
This program (when I ran it) gave the following output:
zombie address: 0x7ffdcccc7ca0
skeleton address: 0x7ffdcccc7ca8
stackInt address: 0x7ffdcccc7c9c
heapInt address: 0x7ffdcccc7cb0
address of int that heapInt points to: 0x558f9f478eb0
Now, the exact values here are not important. What is important to note is that the memory addresses for zombie
, skeleton
, stackInt
, and heapInt
are all close to each other, while the memory address for the integer pointed to by heapInt
is far away. This lets us know that all of the memory addresses beginning with 0x7ffd are on the stack, while the address beginning with 0x558f is on the heap. (Note that the & syntax means "the address of." So &zombie
means the memory address of the zombie object. When we say &heapInt
we mean the address of the pointer that points to the int on the heap, so this gives the memory address on the stack that contains the memory address for an integer on the heap.) This is confusing, so if you're having trouble understanding this, take some time to digest it, or ask questions if needed.
If this were java, then the zombie and skeleton would implicitly be like the heapInt
pointer. On the stack would be a pointer that points to the object in the heap. But this is C++, so since we didn't say new
it lives on the stack in its entirety.
In this hypothetical game, imagine that we have a list of all the enemies which are currently alive. A naive approach would be to set up something like this. (Note we are using the standard library vector class, which is similar to ArrayList in Java, or any dynamic array in other languages).
#include <vector>
int main() {
// Just creating objects to place into the lists...
// You can imagine this is done elsewhere by some game system that spawns enemies
Zombie z1;
Zombie z2;
Zombie z3;
Skeleton s1;
Skeleton s2;
std::vector<Zombie> aliveZombies {z1, z2, z3};
std::vector<Skeleton> aliveSkeletons {s1, s2};
for (int i = 0; i < aliveZombies.size(); i++) {
aliveZombies[i].talk();
}
for (int i = 0; i < aliveSkeletons.size(); i++) {
aliveSkeletons[i].talk();
}
}
This works and prints the output you would expect, but still is obviously not ideal, because for every enemy we add to the game we still have to go through the code and add another entire list to deal with it. What we want is one list that can hold any enemy, so it makes the most sense to just have one vector which holds a type Enemy
, then to add any new enemy we just have to create a class that derives from Enemy
. A first attempt would look like this:
#include <vector>
int main() {
Skeleton s1, s2, s3;
Zombie z1, z2, z3;
std::vector<Enemy> aliveEnemies;
// using push_back to add everything individually instead of an initializer list
// another way to add things to a vector, probably the most common
aliveEnemies.push_back(s1);
aliveEnemies.push_back(s2);
aliveEnemies.push_back(s3);
aliveEnemies.push_back(z1);
aliveEnemies.push_back(z2);
aliveEnemies.push_back(z3);
for (int i = 0; i < aliveEnemies.size(); i++) {
aliveEnemies[i].talk();
}
}
When we run this program, we get this:
What's going on? Does inheritance just not work in C++?
Well, what's happening is that we defined the vector as a vector of Enemy
, so when we placed the Zombie
and Skeleton
objects inside of it, from that point on we essentially lost a lot of information from the vector's point of view. In essence, the vector just knows that it has objects of type Enemy
inside of it. To get the expected behavior for polymorphism, you need to use pointers. This is an example of how you would do it:
int main() {
Enemy* obj1 = new Skeleton();
Enemy* obj2 = new Zombie();
std::vector<Enemy*> aliveEnemies;
aliveEnemies.push_back(obj1);
aliveEnemies.push_back(obj2);
for (int i = 0; i < aliveEnemies.size(); i++) {
aliveEnemies[i]->talk();
}
}
This gives the expected output:
Lets break down what's going on here.
First, we defined our enemy objects a little differently. Before, we defined them like this:
This is how you create an object in C++ on the stack. Implicitly, we are calling the default constructor. If we had a constructor for these classes we needed to call (say, pass in an HP value), it would look like this:
(If you aren't familiar with constructors, we will talk about them in a bit.)
We even could have used a slightly different syntax, which more closely resembles how we initialize data on the heap:
Note that for this last example, however, we are NOT using the keyword new, because we are not allocating memory on the heap. When creating an object like this, we are still placing it on the stack.
Let's take a look at how we created these objects dynamically in the example that worked:
Remember, when you are doing dynamic memory allocation you are handling memory in two different places at once. You need memory in the stack to hold the pointer, and memory on the heap to store the object.
On the stack, we are creating a pointer of type Enemy*
, which points to a location in memory which contains a Zombie
. This means that whenever you use this pointer, you can only use this pointer to do things that the Enemy
class can do! Say, we extended the Zombie
class to have an additional void
function called eatBrains()
. We would NOT be able to write obj1->eatBrains()
EVEN THOUGH the memory that the pointer points to on the heap is actually a Zombie
! At compile-time, the compiler wants to make sure the types all will work out, and it will see that you are using a pointer to an Enemy
and calling a function that does not exist on Enemy
, so it will lead to a compile time error. That specific error would look like this:
#include <iostream>
#include <vector>
class Enemy {
public:
virtual void talk() {
std::cout << "I am an enemy." << std::endl;
}
};
class Skeleton : public Enemy {
public:
void talk() override {
std::cout << "I am in eternal pain." << std::endl;
}
};
class Zombie : public Enemy {
public:
void talk() override {
std::cout << "Mmmmmmm Brains" << std::endl;
}
void eatBrains() {
// do something, like perhaps eating brains
}
};
int main() {
Enemy* obj1 = new Zombie();
obj1->eatBrains();
}
test.cpp: In function ‘int main()’:
test.cpp:41:11: error: ‘class Enemy’ has no member named ‘eatBrains’
41 | obj1->eatBrains();
| ^~~~~~~~~
BUT if we did change the declaration of obj1
to look like this:
Then the call to eatBrains()
would be allowed because the compiler knows that obj1
is a pointer to a zombie.
Let's get back to the original example. So far we've worked out explanation up until this point:
Enemy* obj1 = new Skeleton();
Enemy* obj2 = new Zombie();
std::vector<Enemy*> aliveEnemies;
aliveEnemies.push_back(obj1);
aliveEnemies.push_back(obj2);
Now we are creating a vector
of Enemy*
. This means that every item inside of the vector
has to be a pointer to an enemy. This works, because we defined obj1
and obj2
to be Enemy*
, so we can slot them inside of the vector no problem.
It is important to note, however, that if we had defined these pointers like this instead
Then we still would have been able to slot them into the vector fine because when the compiler sees that a Zombie*
is being placed inside of a vector
that holds Enemy*
, it knows that is okay because Zombie
inherits from Enemy
. (Zombie
is an Enemy
, if you rememeber your inheritance relationships.) However, once you are inside of the vector and indexing from it, you can only treat the pointer as if it were an Enemy*
pointer.
And this makes sense. Imagine you wrote a piece of code that randomly generated enemies and then placed them in the vector. Later on, when you are iterating through the vector each individual enemy could be either a Zombie
or a Skeleton
, and you would have no way of knowing at compile time which was which.
Of course, however, if you were unable to get different behavior based on what kind of enemy was in the vector then that wouldn't be very useful, which is why we have virtual
functions and polymorphism. Because the vector contains Enemy*
, you are able to call talk()
beacuse it exists as a function on Enemy
. When you call the talk
function it is able at runtime to call the correct version of the talk
function based on what actual object exists on the heap.
So, if we are in an iteration of the for loop at the bottom of the example, we have a given Enemy*
in the form of aliveEnemies[i]
. This we are able to dereference and follow the pointer to the heap (aliveEnemies[i]->
). Then, when we call the talk
function using aliveEnemies[i]->talk()
it is able to call the correct overridden function based on the data it found on the heap.
You do not get this behavior when you don't use pointers, like in the above example where it just printed out "I am an Enemy" repeatedly.
Another Example of Pointers: Linked Lists
Let's say I wanted to make my own linked list data structure, but didn't know about pointers. This would also lead me down another pitfall, and understanding why this doesn't work will give you more insight on why pointers are extremely useful.
If you are not familiar with a linked list, a linked list is another type of structure, like an array, which lets you store a series of values. The main distinguishing feature about linked lists, however, is that they are a loose collection of "nodes" which only know about the nodes around it.
Consider this list:
1, 10, 5, 7
The first item is 1. This would be the head
node. The head node would know it's own value (1) and how to get to the next node (using a pointer). This would continue on until the last node, where you could have the final pointer be nullptr
, signifying there is no additional item in the list.
But what if we didn't want to use pointers? What would a potential Node
struct definition look like?
(tangent: in C you only have structs, but in C++ you have structs and classes. The main difference is that structs are by default public, while classes are by default private. So I'm just using a struct here because it is easier to write without having to specify things as public or private or define propper getter/setters because everything is just public.)
Well, it would look something like this:
If you try to compile this, your compiler will yell at you.
test.cpp:6:10: error: field ‘next’ has incomplete type ‘Node’
6 | Node next;
| ^~~~
test.cpp:4:8: note: definition of ‘struct Node’ is not complete until the closing brace
4 | struct Node {
| ^~~~
test.cpp:7:2: error: expected ‘;’ after struct definition
7 | }
| ^
| ;
You can conceptualize why this doesn't work by following this line of logic: the compiler needs to know the size of Node
, so it goes in and figures out the following:
- "Oh, there's an
int
, so I know that will take up 4 bytes. Good, let's write that down" - "Oh, there's a
Node
, let me go check how large aNode
should be.... Oh wait, I'm currently defining aNode
, so I don't know how large this should be...."
And then it errors. If you were to put a size to this struct, it would actually be infinity because each Node
contains a Node
which contains a Node
which contains a Node
....
If we switch it to use a pointer, then everything works, and we get our nice little linked list.
Final Note: If you want a linked list in C++, don't write one yourself. There are much better ways to set up linked lists than the bare bones basic one described here (e.g. a tail
node and prev
pointers, to start), and they get complicated. Also, C++ gives you a Linked List for free in the form of std::list
. Just #include <list>
and you are good to go.
If you are using C, then try to find somebody who has already written a linked list in some library online. The only case I can think of for why you might write a linked list yourself would be if 1. You are doing it for learning (Pop off) 2. You are doing a class assignment and are not allowed to use outside code.
But either way, you might make your own data structure which requires pointers for a similar reason, so it's useful to understand the mechanics behind them.
Using Pointers to Resolve Circular Dependencies
Another reason to use pointers in C++ is to resolve something called a circular dependency. So far, all the code examples have been given as if they were written in one file, but most of the time that is not true. It is good to split code into multiple files so that no one individual file is too big to browse, and so that you have modular pieces of code which can be slotted into other programs if you so desire.
But when you start writing bigger programs, you might run into something confusing like this:
// A.h
#pragma once // or equivalent header guard
#include "B.h"
struct A {
B b;
};
// B.h
#pragma once // or equivalent header guard
#include "A.h"
struct B {
A a;
};
// main.cpp
#include "A.h"
#include "B.h"
int main() {
// do stuff
}
If you try to compile this program, you get the following output:
tyler:/tmp$ g++ main.cpp
In file included from A.h:3,
from main.cpp:1:
B.h:5:5: error: ‘A’ does not name a type
5 | A a;
| ^
Let's trace the compilation of the program and understand why this happens and what is wrong.
- You start compiling
main.cpp
. The first thing you do is#include "A.h"
This literally copies the contents ofA.h
intomain.cpp
, but for now we will imagine it as we are going to that file to compile that code. - We are now in
A.h
The first thing we do is#include "B.h"
. - We are now in
B.h
. We are defining a structB
. ButB
needs to know aboutA
, andA
hasn't been defined yet. ERROR!
This is a circular dependency. A
needs to know about B
, while at the same time B
needs to know about A
. How do we solve this? Is this even something that can be solved?
(Is this something that ever would actually happen? -> The answer to this is yes, but here is just a distilled version with the simplest possible setup.)
The first way you might think to solve this, considering this is a pointer workshop, is by changing the types of the data members in the structs to be pointers. So, our code would look like:
// A.h
#pragma once // or equivalent header guard
#include "B.h"
struct A {
B* b;
};
// B.h
#pragma once // or equivalent header guard
#include "A.h"
struct B {
A* a;
};
// main.cpp
#include "A.h"
#include "B.h"
int main() {
// do stuff
}
This will give the same error, because inherently we have the same problem: A
is not a defined type when we are trying to compile B
, so we can't compile B.
But to compile it just generally needs to know the size and types of things, and the compiler shouldn't need to know anything about the specifics of A
to allow B
to contain an A*
inside of it. So, we can play a little game and trick the compiler. Look at this code:
// A.h
#pragma once // or equivalent header guard
struct B;
struct A {
B* b;
};
// B.h
#pragma once // or equivalent header guard
struct A;
struct B {
A* a;
};
// main.cpp
#include "A.h"
#include "B.h"
int main() {
// do stuff
}
This will actually work. But why?
Essentially, we replaced our #include
with just an empty struct definition. Then, you use that empty struct definition as a valid type for a pointer. What you are saying, basically, is "Hey compiler, this struct B
here needs inside of it an A*
, but you don't really need to know much about A
for this to compile, so I'm just going to put a dummy definition for A
so that this type is valid. Later on, I'll actually define the real definition for A
, and you'll overwrite my dummy definition which does nothing."
Therefore, we aren't #include
ing anything, and there is no longer a circular dependency.
This is actually a good pattern to follow for anytime you are defining a class that only needs pointers to certain things. If you can get away with #include
ing something in a header file by doing this empty struct/class definition, you should.
Constructors & Destructors in C++
Constructors and destructors in C++ are very useful tools for managing memory at the object level. Remember, to prevent memory leaks you need to make sure you delete
any memory that you have manually allocated with new
.
But often times this can get tricky, especially in the context of C programs where you do not have classes. You might have random functions allocating memory and deallocating memory, and it can become hard to keep track of who is responsible for freeing and taking care of everything.
With classes in C++, however, it becomes a lot simpler. Let's tackle this through an example Location
class which you imagine could be used in a video game to encapsulate all of the logic and data needed for a specific location in the world.
Constructor
To start, what is a constructor? A constructor defines how you create and initialize an object.
By default, every class and struct has a default constructor, which takes no arguments and does nothing.
class Location {
// Implicitly has a default constructor, which can be called
// with either of these statements;
// Location loc = Location();
// Location loc;
};
But perhaps a location has certain attributes associated with it that should be initialized on creation.
// Location.h
#ifndef LOCATION_H
#define LOCATION_H
#include <string>
class Location {
public:
Location(std::string name, int altitude) {
// Note usually you would define this in a separate cpp file,
// not in the header file
this->name = name;
this->altitude = altitude;
}
private:
std::string name;
int altitude;
};
#endif LOCATION_H
Now you could create a location like this:
#include "Location.h"
int main() {
// both valid
Location loc1 = Location("Mountain", 5000);
Location loc2("Beach", 0);
}
But the default constructor would no longer exist:
#include "Location.h"
int main() {
// both valid
Location loc1 = Location("Mountain", 5000);
Location loc2("Beach", 0);
Location loc3;
}
main.cpp:23:14: error: no matching function for call to ‘Location::Location()’
23 | Location loc3;
| ^~~~
main.cpp:5:9: note: candidate: ‘Location::Location(std::string, int)’
5 | Location(std::string name, int altitude) {
| ^~~~~~~~
main.cpp:5:9: note: candidate expects 2 arguments, 0 provided
main.cpp:3:7: note: candidate: ‘Location::Location(const Location&)’
3 | class Location {
| ^~~~~~~~
main.cpp:3:7: note: candidate expects 1 argument, 0 provided
main.cpp:3:7: note: candidate: ‘Location::Location(Location&&)’
main.cpp:3:7: note: candidate expects 1 argument, 0 provided
So, if you still wanted there to be a default constructor, you would have to explicitly define one:
// Location.h
#ifndef LOCATION_H
#define LOCATION_H
#include <string>
class Location {
public:
Location() {
// set some default values...
this->name = "";
this->altitude = 0;
}
Location(std::string name, int altitude) {
this->name = name;
this->altitude = altitude;
}
private:
std::string name;
int altitude;
};
#endif LOCATION_H
Now, lets do some manual memory allocation in the constructor:
// Location.h
#ifndef LOCATION_H
#define LOCATION_H
#include <string>
#include <vector>
#include "Enemy.h" // imagine we have our enemies from earlier defined here...
class Location {
public:
// removed the default constructor to force you to specify
// value when created
Location(std::string name, int altitude) {
this->name = name;
this->altitude = altitude;
for (int i = 0; i < 10; i++) {
// You could imagine these are random
// for a real game, and that there is
// more complicated spawning logic
enemies.push_back(new Skeleton(100));
}
}
private:
std::string name;
int altitude;
std::vector<Enemy*> enemies;
};
#endif LOCATION_H
....So using this location might look like this:
// assume we have proper includes
int main() {
Location* loc = new Location("Mountain", 10000);
// Do stuff with location...
// Now it has 10 skeletons inside of it
}
So we placed the call to new
inside of the constructor for Location
, and we know that every call to new
should have a corresponding call to delete
. So where do we put the delete calls? Do you have to manually figure out where the Location
is going to go out of scope and then clear out the std::vector
of enemies? What if we don't want this vector to be public to outside code?
The simplest answer, of course, is destructors.
Destructor
A correct destructor for our Location
would look like this:
// Location.h
#ifndef LOCATION_H
#define LOCATION_H
#include <string>
#include <vector>
#include "Enemy.h" // imagine we have our enemies from earlier defined here...
class Location {
public:
// removed the default constructor to force you to specify
// value when created
Location(std::string name, int altitude) {
this->name = name;
this->altitude = altitude;
for (int i = 0; i < 10; i++) {
// You could imagine these are random
// for a real game, and that there is
// more complicated spawning logic
this->enemies.push_back(new Skeleton(100));
}
}
~Location() {
for (int i = 0; i < enemies.size(); i++) {
if (this->enemies[i] != nullptr) {
delete this->enemies[i];
}
}
}
private:
std::string name;
int altitude;
std::vector<Enemy*> enemies;
};
#endif LOCATION_H
Note the syntax for a destructor is exactly like a constructor (in that it has the same name as the class), but with a tilde (~) infront of it. This function will be run automatically when the object goes out of scope (if on the stack) or when manually deleted (if on the heap). Also note that destructors, unlike constructors, cannot have parameters as they are implicitly called.
Caveat: Virtual Destructor
When using destructors with classes that might be overwritten, make sure to declare your destructor as virtual
, which would look something like this:
class Base {
Base() {
// constructor
}
virtual ~Base() {
// destructor, destroy things relevant to Base
}
};
This will allow subclasses to write their own destructors, which will be correctly called in addition to the Base class's destructor when the object goes out of scope.
This stack overflow response explains this a little more in depth, but still concisely.
Conclusion
Hopefully through this workshop you have gotten the hang of the "why" of pointers, and perhaps have started to get familiar with using them as well.
The only way to really get comfortable with pointers is to write programs that use them, so don't feel bad if this still is a little abstract. This knowledge can take a long time to really soak in and understand, and if you want practice I would recommend trying to write a C++ (or even C) program yourself that needs pointers.
You can find many project ideas online, or potentially even find some work to be done in our Onboard Computer here in TUAS, which is currently written in C++.