Click here to Skip to main content
15,867,686 members
Articles / Programming Languages / C

Who's Afraid of Pointers, Virginia Woolf?

Rate me:
Please Sign up or sign in to vote.
4.73/5 (15 votes)
19 Jun 2022CPOL19 min read 10.6K   7   4
Pointers don't have to be black magic.
Let's take something many newer C and C++ programmers are intimidated by, and make it not so scary. Pointers are powerful tools, and with some simple techniques and practices you can remove most of the common dangers and confusion around them.

Introduction

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.

What is a Pointer?

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.

  • If your code was a database a pointer would be a foreign key.
  • If this was BASIC, pointers are the C and C++ way to "peek" and "poke" but with so many more capabilities it will blow your hair back. Buckle up.
  • If you're used to high level languages like C#, there's no easy corollary here because referencing things is basically hidden from you, but a variable that holds a "class" instance in C# actually holds a pointer to that instance. The closest thing in .NET to a generalized pointer is a WeakReference/WeakReference<> but because there's no garbage collection in C or C++ it doesn't magically go away after a garbage collection.

When Do I Use a Pointer?

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.

  • If you need an array, a pointer is appropriate. The trick with C and C++ is to point to the first element in the array. Once you know the first element, you can easily figure out where the next element is, and the element after that, because they are laid out one right after another in memory. The only gotcha here is you need a terminator condition so you know when to stop. You'll almost always keep a count as a separate variable or argument to tell the consumer how many elements there are. No matter how an array is declared or accessed syntactically in C/C++, C and C++ store and access arrays in this manner. Just for your information, even if you declare an array like 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.
  • If you need a string. You probably already use pointers without really thinking about it, because you've almost certainly used strings in your code before. Strings are stored the same way arrays are, in that they point to the first in a series of elements (characters). They are different than arrays in that the terminating condition is the \0 character rather than keeping a separate count.
  • If you need to access the same data from multiple classes or structs in your code. Maybe you have some data you created, and you have some classes you created. Maybe each class needs access to that same data. You can keep a pointer to the data rather than having to copy it into each class. Just be careful to make sure the data remains in scope and therefore in memory for at least as long as the classes that access it!
  • If you need to pass a variable to a routine, and change the variable from inside the routine. Normally a copy of the data is created and passed when you call a routine, so if you pass a variable, you won't be able to change that variable - you can only change a copy of the data it contained, and that copy doesn't propagate outside the routine. One the routine finishes, that copy goes out of scope, and any changes are lost. The way around this in C and C++ is to pass a pointer and then you can not only read, but also write the contents of that variable from inside the routine. In BASIC this is facilitated by the ByRef keyword. In C# the ref keyword is used to indicate this.
  • You need to return an out value from a routine. This is the same concept as above. C/C++ has no concept of an out value, but it can modify variables that are passed in by passing a pointer to the variable as the argument the same way we do above. In BASIC this is facilitated by the ByRef keyword. In C# the out keyword is used to indicate this.
  • If you need to pass entire structs or arrays to routines, a pointer is the way to go. C will not copy arrays for you. Honestly it can't, because since it has no intrinsic concept of size, it can't be sure how big they are, so doesn't have enough information to copy the array. A similar restriction exists with strings which again, are basically arrays. Although strings are null terminated, C/C++ doesn't know that. It's simply an abstraction for us. The other thing is structs. C and C++ can copy structs as arguments because it knows the size of them, but doing so is often ill advised unless the struct is very small. It's usually better to pass a pointer to keep the function call fast and efficient. Again, in BASIC this is facilitated by the ByRef keyword. In C# the ref keyword is used to indicate this.
  • You need to access memory you've allocated (using 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.
  • You need to access a 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.
  • You need to keep a 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.
  • You need to have data that's only allocated once it's needed. You usually allocate this using malloc() or new and then store the pointer to it. In C# this is facilitated using Lazy/Lazy<T>.
  • You need to access memory as raw bytes. This is usually the same as accessing any array, but it's an array of bytes. It can also be a "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().
  • You need to hold a reference to a function around in a variable or as the argument to a function so you can call it. You can hold this reference in a variable or a 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.

Pointers and Type Safety

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:

  • To describe the data you point to. A type tells the compiler not only how the data is accessed, but what size it is in memory.
  • To facilitate arrays. Each element's size must be known in order to traverse it, so an array is typed to the kind of element it contains. This allows you to compute the offset in memory into the array for a particular index, which C/C++ will do for you automatically if you use the [] operator to access the pointer.
  • Related to the above, to provide some modicum of safety. If you're using a void pointer, it's basically used as a series of bytes you can access at any offset. This gets dangerous fast, if that pointer is actually a pointer to some structured data. You might accidentally compute the wrong offset and/or  misinterpret the data, and that's especially dangerous when writing it. If on the other hand, a pointer is typed, the compiler computes offsets and enforces what the data is and how it is accessed for you, eliminating that potential for error. In C# this is sometimes kind of like accessing something as an object versus a concrete type but not exactly, and only in a narrow context. The analogy isn't so great, but there isn't a direct corollary.
  • Readability is critical in C/C++ because being a mid level language it doesn't abstract a lot for you. Well, typed pointers are one thing it does abstract and taking advantage of it better clarifies the intent of your code. Intent is everything, and communicating intent with another developer or your future self makes everything better.

Syntax

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:

C++
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.

C++
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.

C++
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.

C++
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.

C++
*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:

C++
// dereference p
i = *p;
// get the first element of p
i = p[0];

The Null Pointer

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.

Pointer Arithmetic

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:

C++
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**:

C++
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.

The Subscript Operator [ ]

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 *(<target> + <index>). That's really all it is. If you want it without the star /dereference you could do &sz[3] (break it down in your head) but in that case (sz + 3) is cleaner looking. Otherwise it's all perfectly interchangeable.

Pointers and Indices

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(<type>) where <type> is the type of element, such as int.

The Indirect Field Accessor Operator ->

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:

C++
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:

C++
int i = p->the_int_field;

It doesn't really save typing, but it does make it clearer.

Pointers as Parameters

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:

C++
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:

C++
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

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:

C++
...
// pp is int**
// dereference once
int *p = *pp;
// work with p like
// a normal pointer

Function Pointers

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:

C++
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:

C++
my_callback callback;

To assign it, you must make a function with the same signature, and then pass the name of the function:

C++
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:

C++
callback(10,"foobar);

References in C++

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 *:

C++
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:

C++
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.

Conclusion

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.

History

19th June, 2022 - Initial Submission

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
United States United States
Just a shiny lil monster. Casts spells in C++. Mostly harmless.

Comments and Discussions

 
QuestionI would replace first element on a list by just an element in a list. Pin
Paulo Zemek6-Jul-22 23:50
mvaPaulo Zemek6-Jul-22 23:50 
Well... the title says all... a pointer is not limited to the first element and saying it is an element in a list is not only more precise, it might help clarify that it doesn't need to be the first.

Also, about strings, you talked about char* as strings, not std::string, which for most people is what strings are. I know, it is even another level of "indirection" on how to code things in C++.

Typo here: "One the routine finishes"... I am sure it is "Once" and not "One".


modified 7-Jul-22 5:56am.

QuestionSome example code is missing Pin
KarstenK6-Jul-22 21:17
mveKarstenK6-Jul-22 21:17 
PraiseKids, this is the one you *need* to read Pin
dandy7224-Jun-22 6:06
dandy7224-Jun-22 6:06 
GeneralRe: Kids, this is the one you *need* to read Pin
honey the codewitch24-Jun-22 6:45
mvahoney the codewitch24-Jun-22 6:45 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.