Pointers: The why
I recently had a small discussion with a friend of mine who is pursuing a Computer Science education. It was mostly about pointers and void pointers, what they are and how they work along with some examples. However, when discussing this, I noticed that even throughout my personal journey of self-learning, it wasn't that easy to find resources explaining the WHY of pointers, instead, most of them were focused on explaining the WHAT and HOW of this seemingly complex topic. Thus, what I will be trying to do in this article is to explain WHY the pointer type exists, what advantages does it bring and what would happen if we didn't have pointers. Hope this will be of any help to anyone reading. Let's go...
Prerequisits: This article expects a basic familiarity and understanding of C and pointers.
Are pointers really needed?
This question is not as silly as it looks like. While we are all taught that pointers store the address of “something”, the question of why we need a pointer to do that in the first place is totally legit. For instance what is wrong with this code:
int main() {
int num = 5;
int numPtr = #
}
Some people will say, this doesn't work because pointers can be 8 bytes (on modern systems) not 4 bytes, and they are positive numbers always. Ok fine, let's adjust our code then:
int main() {
int num = 5;
unsigned long numPtr = #
}
Now we have 8 bytes reserved to store the address of num
,
does this work? In order to test it and compare it to pointers, let me
adjust the code one last time so we can do a proper comparison:
#include <stdio.h>
int main() {
int num = 5;
unsigned long fakePtr = #
int *realPtr = #
printf("The address of num is: %lx\n", fakePtr);
printf("The address of num is: %p\n", realPtr);
}
Now when we compile and run our program, we get the following output:
$ ./main
The address of num is: 7ffccb97c884
The address of num is: 0x7ffccb97c884
It works! using a regular long variable to store the address of
num
works fine without needing any pointer type. So the
question is still valid; What is the point of pointers? Let's go for a
different example and test our pointer-less pointer invention
and see if it works.
#include <stdio.h>
int main() {
int nums[] = {8, 10, 15};
unsigned long fakePtr = nums;
int *realPtr = nums;
printf("The address of num is: %lx\n", fakePtr);
printf("The address of num is: %p\n", realPtr);
}
Note:
nums
is in itself the address of where the array starts in the memory, but for the sake of keeping things simple, let’s just store this address in a pointer variable for demonstration purposes.
And the output is:
$ ./main
The address of nums is: 7ffc3ea5eae4
The address of nums is: 0x7ffc3ea5eae4
No surprises so far and our fake pointer still works fine for arrays too!
Knowledge check:
As you might already know, when you access an array element with the syntaxnums[1]
, this index is actually the offset and is a syntactic sugar on top of the following syntax:*(nums + 1)
which in simpler words means:
- Take the initial address of
nums
- Add to it the offset multiplied by the element type size:
nums + 1 * sizeof(int)
. Since this is an array containing integers, each element takes 4 bytes in memory, and the next element at index1
is1 * 4
bytes away from the beginning of the array, second element would be at2 * 4
bytes away, so on and so forth.- Once you're in the right location, give us the value stored there.
Now, based on the small pause above, we need to double check that both of our pointers still behave identically, so let's check the address of the second element in the array:
#include <stdio.h>
int main() {
int nums[] = {8, 10, 15};
unsigned long fakePtr = nums;
int *realPtr = nums;
printf("1st num is at: %lx -- 2nd num is at: %lx\n", fakePtr, fakePtr + 1);
printf("1st num is at: %p -- 2nd num is at: %p\n", realPtr, realPtr + 1);
}
And the output is:
$ ./main
1st num is at: 7fffef6da2bc -- 2nd num is at: 7fffef6da2bd
1st num is at: 0x7fffef6da2bc -- 2nd num is at: 0x7fffef6da2c0
Hmm … Things are differenet now! The address of the first element is the same for both, but the address of the second one is different. What's happening?
Moment of truth: Pointers don't just 'store' addresses
Even though pointers are generally defined as variables that contain the
address of another variable, the story doesn't end here, and the
benefits of pointers are way beyond just storing addresses, since as we
have seen so far, the act of storing can be easily done with a regular
unsigned long
variable.
Let's rewind to where we left off and try to understand what
happened. When we added 1 to fakePtr
, the compiler
literally did a simple arithmetic operation on the hex value and added
1
to 7fffef6da2bc
which led to:
7fffef6da2bd
. However when we added 1
to a
pointer variable, something else happened! It actually added 4 bytes!
Why 4 bytes? because of how the pointer was initially declared:
int *realPtr = nums;
On this line, we specified that realPtr
points to values of
type int
, so the compiler automatically knows that when we
do an arithmetic operation on a pointer like realPtr + 1
,
we don't only add 1
, but we also take into account the size
of the type this pointer is referring to, which leads back to the
knowledge check discussed above:
realPTr + 1
is in fact:
realPtr + 1 * sizeof(int)
.
We can verify this by taking a look at the assembly code for both
printf
statements:
16. mov rax, QWORD PTR [rbp-8]
17. add rax, 1 <-- We add 1
18. mov rsi, rax
19. mov edi, OFFSET FLAT:.LC0
20. mov eax, 0
21. call printf
22. mov rax, QWORD PTR [rbp-16]
23. add rax, 4 <-- We add 4
24. mov rsi, rax
25. mov edi, OFFSET FLAT:.LC1
26. mov eax, 0
27. call printf
In the first call to printf
, we store the hex value at
[rbp-8]
in rax
and then add 1
to
it on line 17. However in the second call, we actually add
4
as you can see on line 23. So it's the pointer type that
gives us this implicit mechanism for free, which allows us to use arrays
and traverse the elements using offsets/indexes.
Casting void pointers: Why?
All this long discussion and we haven't mentioned void pointers yet! How does all this relate to void pointers and casting them? Well, now that we have introduced the building block of why pointers are important and needed, we can take a look at the following code snippet:
#include <stdio.h>
int main() {
unsigned int num = 511;
void *ptr = #
}
In this code, we are storing the int 511
in
num
, and then storing the address of num
in
ptr
which is declared as a void
pointer. The
idea of void pointers is simpler than it looks like most of the times: A
void pointer is a pointer that is not associated to any type
yet. Why? well, to simulate the behavior of generic
functions for example. Sometimes you want to define a function that can
work for different types and not just only one. The most obvious example
is malloc
which has the following prototype:
void *malloc(size_t size);
malloc
returns a void pointer, and thanks to this, you can
allocate memory to any type you want. Now the question is, why do we
need to cast this void pointer? If all pointers have the same size of 8
bytes, and we are not doing any arithmetic operations on the pointer,
would our good old fake pointer be enough? …well…. let's revise the code
snippet above and see how the number is represented in memory.
511
in binary is:0000 0001 1111 1111
And this will be stored as follows:
--------------
0000 0000 ---> Fourth byte in memory
--------------
0000 0000 ---> Third byte in memory
--------------
0000 0001 ---> Second byte in memory
--------------
1111 1111 ---> First byte in memory
--------------
Let's try to go and read the integer via the void ptr:
#include <stdio.h>
int main() {
unsigned int num = 511;
void *ptr = #
printf("Num is %d\n", *ptr);
}
When running this code we get the following error:
main.c:8:27: warning: dereferencing 'void *' pointer
8 | printf("Num is %d\n", *ptr);
| ^~~~
main.c:8:27: error: invalid use of void expression
So what is the problem exactly? As we have stated in the previous section, a pointer is tightly associated with the type it points to. The relation between them is so important that the compiler cannot compile your code if you don't specify what this pointer points to. But why? Simply put: The compiler needs to know how many bytes to read!
Not specifying the number of bytes the computer should read can lead to incorrect results. Let's try the following code:
int main() {
unsigned int num = 511;
void *ptr = #
printf("Num is %d\n", *(char *)ptr);
}
We casted the void pointer to a char pointer. What this basically does
is transform the initial declaration of the void pointer from:
void *ptr
to char *ptr
, and then we
dereference it which gives us the following output:
$ ./main
Num is -1
Hmm…what is happening here? Some of you might have expected
511
, and others might have expected 255
, and I
wonder how many anticipated -1
. Leave a comment if you saw
-1
coming!! (No I'm kidding there is no comment section
here.)
The topic of why -1
is the result is beyond the scope of
this article, but basically it is related to computers interpreting
numbers using
two's complement
unless specified otherwise. So let's simplify things and specify that we
are dealing with unsigned numbers for now:
int main() {
unsigned int num = 511;
void *ptr = #
printf("Num is %d\n", *(unsigned char *)ptr);
}
and now the output is:
$ ./main
Num is 255
So why 255? what happened? Well, if we take a look again at the memory
layout we have above, we see that the first byte is
1111 1111
which is the equivalent of 255 in base 10. This
happened because of our casting! We told the compiler to look at the
addresses pointed out by ptr
and read only one byte! How?
By casting it into a char pointer, and since char type is only one byte in
C, it will only read one byte!
So now we know that pointers help with:
- Storing addresses
- Reading
- Doing some magical arithmetics
And how do we fix the problem we have? We cast it to an int pointer:
int main() {
unsigned int num = 511;
void *ptr = #
printf("Num is %d\n", *(unsigned int *)ptr);
}
Which will transform the void pointer into an int pointer that reads 4 bytes at a time and gives the following output:
$ ./main
Num is 511
If you want to play around a bit, you can even try to cast it into a short pointer, which will be fine for any number that fits into two bytes, like our
511
example.
The above explains that pointers not only help with storing addresses, but also they indicate to the compiler when dereferencing them, how many bytes are supposed to be read in order to get the value we want. Now we also understand why we need to cast the pointer returned by malloc, and hopefully have a better understanding of what pointers help with. The relation between the pointer and the type it points to is what allows many of the things we take for granted to happen correctly.
Summary
There is a lot more to say about pointers and how they are used, but everything boils down to the original question of this article. Can storing the address in a regular variable be enough? The simple answer is no for a combination of different reasons:
- 1. How to tell the compiler if the value stored is a regular value or a memory address?
- 2. How to make the compiler understand how many bytes it should read/move if addresses require 8 bytes? This causes a problem since even if you solve issue #1, then we can't even assume that the compiler can deduce the number of bytes from the type storing the address because the address needs to be 4 or 8 bytes on most modern systems. However, if we have an address for an array of chars, we need to step 1 byte at a time.
- 3. How to differentiate between regular arithmetic operations and the pointer arithmetics we discussed above?
For all these reasons and probably more, we can easily conclude that having a separate type that handles memory addresess exclusively is a better solution to all our problems.
Hope you learned something from this article, until next time...Keep coding! :)