Updated on 2022-06-19
Pointers don't have to be black magic.
I have a friend who I'm helping brush up on C++. He's still a beginner, and this article is for him and others like him who are maybe newish to C++ and need some help getting around where pointers and "references" (which we'll cover) are concerned.
We'll be exploring them as though you have a programming background, and maybe have coded a little C++ in say the Arduino IDE or something but it's still relatively new.
We will not be using the C++ Standard Template Library, which abstracts pointers to a degree, and while more powerful, is ultimately more complicated. Learning pointers in the raw first will help you understand the STL later.
Related to the above, most of the content of this article works in both C and C++. In practice C++ is less permissive than C in terms what it lets you do with pointers, but it's also significantly more flexible in the same regard - it lets you restrict access to pointers, and also provides a safer way to access pointers than C in many situations. We'll be covering some of the C++isms toward the end of the article.
This article follows a different format than my usuals. Bear with me.
A pointer "points to" a thing, whether it is a variable, a method/function, the first element in a list of the aforementioned things, or even just raw bytes in memory.
That's a lot to chew on.
We learn things based on things we already know, so I'm going to use some loose analogies here to get at the concept of a pointer, and hopefully you'll find one that makes sense to you or that you're familiar with.
Please understand that these analogies aren't necessarily 100% applicable in every context. They're necessarily somewhat loose, with the idea being to convey broad concepts even if the details don't always match up the same with pointers in every situation.
WeakReference
/WeakReference<>
but because there's no garbage collection in C or C++ it doesn't magically go away after a garbage collection.Pointers obviously add some complexity to your code, so we only want to use them when there is need for them. Here we'll cover when to use them.
int foo[10]
C/C++ does not "remember" the size. It's your job to do that. foo
is actually a pointer to the first element. That's all C/C++ keeps around.\0
character rather than keeping a separate count.ByRef
keyword. In C# the ref
keyword is used to indicate this.ByRef
keyword. In C# the out
keyword is used to indicate this.ByRef
keyword. In C# the ref
keyword is used to indicate this.new
or malloc()
for example) on the heap. In C/C++ memory is either allocated on the heap or the stack. Your local variables exist on the stack and C/C++ allows you to access those directly. Your heap allocated objects must be accessed via a pointer with the exception of globals which C/C++ also allows you to access directly even as they exist on the heap.virtual
class. You may not understand this yet, but these are special classes that must be accessed indirectly. You can't keep an instance of a virtual
class on the stack or as a global and access it directly**. BASIC doesn't exactly have a corollary, but in C# an interface
or class with abstract
methods is pretty close. C# uses a pointer for that but hides the fact from you.struct
that can be variable size. In C/C++ you have to create structs and classes that are a fixed size in memory. That is to say each instance is always the same size. This isn't always realistic. Sometimes you need a struct
that can have an indeterminate size. You must hold the fields that are of variable size as pointers. This also requires that you handle the memory required to keep the pointer valid/alive yourself. It's not a good idea to access memory that isn't around anymore. There are techniques to creating structs this way - especially structs where it's only the final field that is variable length, but it's not a beginner concept. It requires a firm understanding of the concepts in this article. C# has a similar restriction, but with no way around it that isn't a hack.malloc()
or new
and then store the pointer to it. In C# this is facilitated using Lazy
/Lazy<T>
.void
pointer" (void *
) which is simply an "untyped" pointer. We'll get to pointer types next. In BASIC this is all you can really do via Peek()
and Poke()
.struct
or pass it to another routine. This is often used for callback functions. C# has a direct corollary in the delegate
abstraction.** I'm taking a liberty here. You can keep concrete instances of virtual classes on the stack, but to access them virtually requires a pointer. Don't worry if you don't understand this. You don't need to right now.
In C/C++ all pointers with the exception of void pointers have a type associated with them which indicates what is stored at the memory the pointer points to. There are several reasons for this:
[]
operator to access the pointer.Pointer syntax is a common source of confusion, often times because _ serves multiple purposes, and it can also be hard to understand the relationship between _, & and [].
Consider the following:
int *p;
Here the * immediately follows the type. This indicates the pointer type, in this case it indicates that this is a pointer to one or more int values. It doesn't hold the values directly. It holds a memory address that points to where the value lives in memory. In this case we have yet to assign the address to anything.
int i = *p;
Here we are dereferencing p. This means we are asking C/C++ to fetch the value that p points to. You can tell because the _ immediately precedes the pointer variable p. In BASIC, this is a Peek() except it's typed and peeks an entire int at once, in this case. Note that we hold the result in an int, not an int_ pointer.
int i = *p * j;
Here we are dereferencing p and them multiplying the result by j. How does it know the difference between a multiplication and a dereference? It's all about context. Here we can see the second * is between two values, indicating a multiplication.
int i = 5;
int *p = &i;
Here we are creating a pointer p to the variable i. Now you can dereference p (*p) to get the value of i, which is 5. We indicate the fact that we want the address of i using &. In BASIC, an & is like your AddressOf operator, assuming your BASIC has one.
Finally, let's write a value indirectly using a pointer.
*p = 2 + 2; // sets i from above to 4.
When a * appears before an lvalue (on the left hand side of the equals sign) it means we're dereferencing in order to write rather than read. In BASIC this is basically Poke().
There's one more significant operator - the subscript operator [], but we'll only touch on it briefly here, and circle back to it later.
What it does is it derefences the value at the specified index relative to the pointer you're operating on. The following two lines are equivelent:
// dereference p
i = *p;
// get the first element of p
i = p[0];
NULL in C or nullptr in C++ sets a pointer to the special address zero which indicates that the pointer is not set. You should generally set pointers to null when you initialize them and check for null before you use them if you want to be safe.
Pointers can be added to or subtracted from, which changes their address rather than the value they point to. You'll typically use pointer arithmetic with things like arrays, memory buffers and sometimes even strings.
The idea here is you compute the offset from the base address of the pointer to get your final address. It's simple in practice, you just add or subtract an index and then you can dereference that. What this:
printf("foobar\n"+3); // prints bar!
How does that work? It's not magic.
Let's break this down. First C/C++ turns any string literal, like "foobar" in this case into a char* (char pointer) that points to the first character in the string, in this case 'f'. It's shorthand for this**:
char sz[8]; // holds our literal
sz[0]='f';
sz[1]='o';
sz[2]='o';
sz[3]='b';
sz[4]='a';
sz[5]='r';
sz[6]='\n';
sz[7]='\0';
printf(sz+3); // prints bar!
What's going on here with bar though? Starting at the first element above, and at 0, step through each element while you count, and at 3 you will land on 'b'. You'll also note your count followed the indices above. Consequently sz + 3 starts at 'b' rather than 'f'. Note that adding an integer to a pointer yields a pointer. That's all we did here.
** I took a liberty here. There is a small yet significant difference between the first string and the second string where it was an array. The difference is that in the first instance that string exists in the ".text" segment of your binary - not really in your executable's "scratch" memory and therefore it is read only (unless you do awful things), whereas in the second instance it exists on the stack and you can write to it. That's pretty different. But aside from where each string lives, the rest is identical. The first is essentially shorthand for the second with the caveat above.
The point of all of this anyway, is the arithmetic, and hopefully you can see now why sz + 3 yields a new char* (char pointer) advanced by three characters.
I'm putting this under the pointer arithmetic section because it solves a common pointer arithmetic problem. Looking at sz from above if we want to get to the 'b' character at index 3 you could refer to it using *(sz + 3). You should understand based on putting together what we've already covered.
However, that's clunky. You can see above in the example we simply did sz[3] = 'b'. Well, that's the subscript operator. It does *(
We've been dealing in indices, not bytes. The compiler will automatically compute the number of bytes each element requires, and advance by that many bytes per index. void pointers obviously can't do that, so they only advance by bytes. You can get the size of an element in bytes by using sizeof(
You might have a pointer to a struct or class and need to operate on fields or methods off that pointer. Without -> you would have to do this due to operator precedence:
int i = (*p).the_int_field;
That's clunky. That's why we have ->. With this operator you can simply do the following, which is much more readable:
int i = p->the_int_field;
It doesn't really save typing, but it does make it clearer.
The idea of using pointers for parameters in functions and methods was touched on very briefly but now we'll get into some details.
In C particularly, but even in C++ in cases where exceptions create undesirable overhead it's customary to use the return value to indicate a success or error code. This means that if you also want to return a value you must pass a value out of a routine.
Also, there are situations where you must accept an argument, and then modify the original contents. Essentially the argument is both an in and an out value.
Another situation is where we may want to pass a large struct to a routine, but copying it is prohibitive.
Finally, if you're passing a string or an array, it will always be a pointer.
In BASIC these scenarios are typically (but not always) handled by ByRef. In C# they can usually be handled by the out and ref modifiers, respectively. In C and C++ we use pointers to accomplish the same thing.
Normally, arguments are copied into the routine so the original values cannot be modified. We can pass a pointer, however which allows us to modify the original value. We use this technique to handle the above scenarios. Consider the following:
void sum(int lhs, int rhs, int *out_result) { *out_result = lhs + rhs; }
Rather than returning the sum through the return value the result is returned through out_result. It's a pointer so that it can be modified. Because it's a pointer you must take the address of the variable you use to call it:
int result;
sum(3, 4, &result);
printf("%d\n",result); // prints 7
Passing a value both in and out is the same as the above. C/C++ makes no distinction. It's up to your routine to decide to try to use the value of (in this case) out_result before you set it.
Multiple indirection can occur in some cases such as when you need to return a pointer as an out value, or an otherwise modifiable argument like we covered before. Another common case is if you need to keep an array of strings, which is an array of char pointers, ergo a pointer to pointers.
The thing to remember is all of the previous rules apply - it's just that your target is not something like an int as we used before, but rather it's another pointer.
It can get confusing fast, especially if your code does triple indirection instead of double indirection, which I've only seen a handful of times in really nasty code. The trick here with double indirection anyway is to assign the result from indirecting your target to a temporary variable and work on that so it's clearer, like this:
...
// pp is int**
// dereference once
int *p = *pp;
// work with p like
// a normal pointer
The main difficulty with function pointers is the somewhat difficult to remember and slightly inconsistent syntax for declaring them. Other than that they work a lot like delegates do in C# sans the ability to capture or call multiple targets with one call.** If you're using C++, your target function(s) must be statically scoped if it's a class member.
** C++ can capture but doing so requires using "functors" and is beyond the scope of this article.
We'll cover the syntax shortly. It's kind of confusing, and even though I'm pretty seasoned at using function pointers even I forget a particular detail of the syntax from time to time. I had to look it up to make sure I got it correct for the article! What I'm saying is don't worry if it's a little weird and hard to remember at first. It's janky like SQL/DDL/DML can be sometimes, though obviously with much different syntax.
In order for a function pointer to work, your compiler needs to know the function signature. The function signature is the function's parameter type list and return value type. The compiler needs to know this so it can prepare the stack frame to call the function correctly. If you don't understand that, don't worry. The takeaway here is the compiler needs to know the types of arguments and the return value of the function. Think of this information as being part of the pointer's type like how int is the type int* points to.
I strongly recommend creating a typedef for the function pointer type. There are some narrow cases where it doesn't make a lot of sense, but for the most part using it will dramatically increase readability and maintainability, plus reduce the potential for typos:
typedef void(*my_callback)(int param1, char* param2);
Here we've created a typedef alias called my_callback for a function pointer that has a void return type, and takes two parameters, an int and a char*. You do not have to specify the parameter names, just the types, but specifying the names can make your code more readable.
You can then use it as a parameter to a function:
void myfunc(my_callback callback);
or as a member of a struct or a variable:
my_callback callback;
To assign it, you must make a function with the same signature, and then pass the name of the function:
void test_callback(int param1, char* param2) {
printf("param1 = %d, param2 = %s\n", param1, param2);
}
...
my_callback callback = test_callback;
Finally, to call it, you just call it like any other function, using the variable name or argument name:
callback(10,"foobar);
References are a special kind of pointer that hides the derefencing and must be assigned upon initialization. They are pointers but you access them like regular variables, fields and arguments.
You declare a reference using & instead of *:
int i = 5;
// note like a pointer, we have to take
// i and assign it to the reference
int& ri = i;
When you use it, you use it the same as if it were not a reference:
int j = ri;
printf("ri and j = %d",j); // will be 5
Note that we did not explicitly dereference ri. It is done automatically. Using references is a good way to hide some of the extra complexity of accessing pointers and can make your code somewhat safer and more readable.
Hopefully this has helped clear up some confusion around pointers in C and C++. We haven't covered every possible scenario but I've endeavored to give you enough to get you started.
19th June, 2022 - Initial Submission