Understanding Pointers, Functions, and Memory

By: Cam Wohlfeil
Published: 2018-12-12 1330 EST
Category: Programming
Tags: c

Pointers are a very powerful programming tool, but wholly unnecessary in the majority of programming. I've never had an issue understanding the basic idea, but I've never quite understood when and how to use them. I know what a hammer can do, and I know why it's important, but what can I do with it? This is an attempt to answer that question for myself and hopefully others as well.

Note: I'll be using basic C for this, but as long as you understand types and general programming syntax, you shouldn't need to know C already. Very high level programmers might need to learn a bit first. Also note that this is very basic C code, not idiomatic modern C or C++.

Hopefully I will continue this in the future with more practical examples of what yo u can do with pointers, probably with data structure, and another on pointers in modern C++.

Pointers

So what is a pointer? It is simply an address to a memory location. Previously, this meant a location in RAM, but modern operating systems abstract all of this away and put memory wherever is best, be this in CPU cache, RAM, or virtual memory on disk.

Why do we need pointers? For most programs and situations we don't need pointers, the language and compiler handle everything for us. But underneath this, memory is everything to a computer. That variable we just declared? It's a memory address to the where the value is stored. Those functions we call? Ditto. A typical variable declaration will look like this:

int x = 5;

This is just a recipe for memory. We are telling the compiler "give me a memory location that can fit the type int (unsigned integer, 4 bytes for 32-bit), assign it to the name x, and give it the value 5". Variable names are strictly for programmer convenience. Programmers primarily care about the name and value of a variable, not its memory address. However the program and computer only cares about the memory address.

We can access the memory address by using the address of operator, &:

    printf(“%d”,  x); // 5
printf(“%u”, &x); // 0x3250, or something similar

We can declare a pointer variable by adding * to a declaration:

int *p = &x; // 0x3250

Here we tell the compiler “give me a memory location that can fit the type pointer to an int (the memory address of an int, not an int value!), assign it to the name p, and give it the value &x”.

At this point, we should note a few things about pointers:

    char * q; // valid, pointer to a char, which is an unsigned integer
    float* r; // also valid, pointer to a float, which is an unsigned integer
printf(“%u”,  p); // 0x3250, the value of p, which is the address of x
printf(“%u”, &p); // 0x4000, address of p

Wait, can I access the value of x by using p? Yes, and that's where the power of pointers starts to shine! For this, we also use indirection operator *, which tells the compiler I want the value at address p. Do not confuse this with the * from the pointer declaration, they are two different things.

printf(“%d”, *p); // 5

So, can I have a pointer to a pointer? Yes, we just use two pointer declarations, and this work exactly as we should expect.

    int **q = &p; // pointer to a pointer to a int, with a value of address of p
printf(“%u”, &q); // 0x2000, address of q
printf(“%d”, *q); // 0x3250, the value of p, which is the address of x
printf(“%d”, **q); // 5, the value of x!
printf(“%d”, *(*q)); // also 5!

Functions

To understand why pointers are useful, we need to first understand how functions work. A basic function is declared like so:

int main( ) {
    return 0;
}

Just like with a variable, this is a recipe that tells the compiler "give me a function that will return an int, assign it the name main, have it take no arguments, and have it return a value of 0". Note that main is a special type of function, the operating system will call main when our program is executed.

When the operating system executes main, it will create two segments: the code segment which stores all of the instructions, and the data segment which stores all of the variables. Within the data segment, there are three additional sections: the stack, the heap, and the data section (also called BSS).

These are fully virtualized by the operating system, which adjust their size and location as needed. The stack contains our local variables, the heap contains dynamically allocated memory, and the data section stores global and static variables.

A basic program will use functions like so:

int add(int a, int b) { // a = value of x, b = value of y
    int sum = 0;
    sum = a+b;
    return sum;
}

void main( ) {
    int x = 5;
    int y = 7;
    int z;
    z = add(x,y);
    printf(“The total is %d”, z);
}

Note that each function has a separate local scope, so it's okay if the function arguments or variables have the same names as things in other scopes, as long as it's not the same as something in a parent or global scope.

Behind the scenes, each variable is given a memory address and pushed on to the stack when declared. If not given a value when declared, it will contain a garbage value of whatever the memory address held before.

When a function is called within a function, execution is suspended and passed to the function being called, then given back once the function has returned. To do this, a return address is pushed on to the stack.

When the function is called the parameter variables are declared, assigned the value of the arguments given, and pushed on to the stack. From there the function also pushes its variable on to the stack. Finally, it will return by popping its variables off the stack, returning its value back to the return address, and execution returns to the caller.

Using Pointers

With that out of the way, let's look at how pointers can actually be used to help us. In general, we want our programs to be as fast as possible. The primary way to accomplish this is by simply doing less, i.e. read from and write to memory as little as possible. Let's take a very basic example, swapping the value of two integers:

void swap (int x, int y) {
    int temp;
    temp = x;
    x = y;
    y = temp;
}

void main() {
    int a = 10;
    int b = 20;
    printf(“%d, %d”,a,b); // 10, 20
    swap(a,b);
    printf(“%d, %d”,a,b); // 10, 20
}

This does not work! When we pass our variables as parameters to our function, they are copied on to the stack as the parameter variables x and y (pass by value). We never write the new values back to our original variables.

Why not just return the new values? We can only return one value from a function. Now, there's multiple solutions to this problem, such as returning an array, but only one that proves my point ;)

Instead of passing the values, we can pass pointers (pass by reference). This requires some minor changes to our program:

void swap (int *x, int *y) { // pointer to an int
    int temp;
    temp = *x; // temp = value at address x
    *x = *y; // value at address x = value at address y
    *y = temp; // value at address y = value of temp
}

void main() {
    int a = 10;
    int b = 20;
    printf(“%d, %d”,a,b); // 10, 20
    swap(&a,&b); // address of a, address of b
    printf(“%d, %d”,a,b); // 10, 20
}

Not only does this work, but it's more efficient. Since we are only using unsigned integers for our variables, and pointers are also unsigned integers, we're not taking up less space on the stack. However, pointers can point to any type, so the benefits become much more apparent when we use them to pass bigger types and dynamic data structures.

If we pass a large array by value, we're going to waste huge amounts of memory and CPU time. This may not seem important on modern computers with powerful CPUs and lots of memory, but if it wasn't then the compiler (or runtime) wouldn't be secretly doing this optmization for us! Small amounts of waste can add up quickly.

Arrays

Arrays are another area where pointers being to shine. When we declare an array normally, it's a fixed size known at compile time, so it can be stored on the stack in contiguous memory. Even if we don't initialize every element, they will be zero-filled. If we don't initialize it at all, it will be filled with garbage values already in the memory location, just like any other uninitialized variables.

int a[5] = {11, 22, 33}; // 11, 22, 33, 0, 0
printf(“%u”, a); // 0x1000, address to the first element of the array
printf(“%d”, *a); // 11, value of the first variable of the array

If we want to pass this array to a function that will print every element, we can do it like so:

    void display(int b[5], int n) {
            for (i=0, i<n, i++) {
                    printf(“/d”, b[i]);
            }
    }

    void main() {
            int a[5] = {11, 22, 33};
            display(a, 5);
}

… and since we've passed by value, we've doubled the array on the stack. With 5 elements, this doesn't mean much, but 5 can easily become 500, 5000, etc. 5000 elements at 4 bytes each is 20,000 bytes, pass that by value and now it's 40,000 bytes. We can easily blow the stack like this.

Now, as I said previously, our compiler or runtime will probably see what we are doing and optimize this away, but why take the chance when we can just do it the correct way? Explicit is always better than implicit.

Since arrays are fixed size and contiguous in memory, we can use pointer arithmetic to increment by the size of the type to get the next element. If we perform arithmetic to a pointer, we don't perform it to the value of what it points to, we do it to the memory address pointed to.

Note that a is just a pointer to the first element of the array, so we cannot mess with it. We can increment, decrement, and subtract two pointers, but we cannot add two of them.

int *p = a; // 0x1000
int *p = p+1; // 0x1004, since an unsigned integer is 4 bytes
int *p = p--; // 0x1000

// p = a = &a[0] = (p+0) = 0x1000
// *p = *(a+0) = a[0] = *(p+0) = p[0] = 11
// p+1 = a+1 = &a[1] = 0x1004
// *p+1 = *(a+1) = a[1] = p[1] = 22

a++; // doesn't work
a = a+1; // also doesn't work

int *q = p+1;
q-p; // 1, since they are both pointers and 1 unsigned integer memory space away
p+q; // will not work

So the improved version of our display function will look like so:

void display(int *b, int n) {
        for (i=0, i<n, i++) {
                printf(“/d”, b[i]); // still works
                printf(“/d”, *(b+1)); // also works now
        }
}

Dynamic Memory

As noted in the functions section, the stack contains variables and the heap contains dynamic memory. The difference here is that variables are of a fixed size which is known at compile time, while dynamic memory is not. This means that the stack can be a very fast last-in-last-out (LILO) data structure, but the heap has to be randomly read from and written to, so it cannot.

Thanks to pointers, we can dynamically allocate arrays like so:

int *p; // Declare a pointer to our first element, which lives on the stack
scanf("%d", &n); // Get the element count for an array from user
p = malloc(sizeof(int) * n);

The third line will allocate enough memory on the heap for n elements of type int and assign the memory address to p. We using sizeof() and int to remain platform agnostic.

malloc() always returns void pointers. Void pointers can point to any type, but they can't be used with the inderection operator since the compiler doesn't know what type they are pointing to. To deal with this, we can typecast it.

printf("%d", *p); // will not work
p = (int*)malloc(sizeof(int) * n);
printf("%d", *p); // will now work

Wait, aren't all pointers unsigned integers? Yes, even though a pointer may be void, it is still an unsigned integer. That's why it can be safely typecasted to void and vice versa, no actual conversion is made to the value. From this point on, we can work with our dynamic array just like in the previous section.

malloc() is not the only function for allocating memory on the heap, there is also calloc() and realloc(). The differences here are that calloc() will zero fill all elements and realloc() will resize the allocated memory. If realloc() cannot find enough contiguous memory to add, it may fail, so be careful.

calloc(n, sizeof(int)) // How many elements to allocate, and what size.
realloc(p+i, sizeof(int) * 5) // Where to start adding dynamic memory, and how much to add.

Lastly, there's a function for deallocating dynamic memory, free(). When allocating dynamic memory, we have to make sure to deallocate it when we are finished, otherwise our programs memory usage will continue to grow. I'll go in to this more in the next section.

free(p);

The Dangers of Pointers

With great power comes great danger, and when we start messing with pointers we can do many dangerous things. These can cause unexpected results and even segmentation faults, which are a disaster for our program. The big ones are:

If our program tries to access a memory location outside of its address space or a pointer which has been initialized to NULL, we will get a NULL pointer assignment error. Initializing to NULL is actually a decent practice to ensure we don't access a pointer before it has been properly initialized.

int *p = NULL;
printf("%d", *p); // either will not compile or will cause a crash

Wild pointers we have already discussed. If not initialized a variable will contain garbage data. If you try to use this pointer, it's going to point to memory out of scope and cause a NULL pointer assignment error.

int *p;
*p = 15; // either will not compile or will cause a crash

A dangling pointer is when a pointer stores a memory address that has already been deallocated. This pointer will likely still point to memory within our programs scope, so trying to use this pointer probably will not cause a NULL pointer assignment error. What is more likely however is that it will change the value of another variable, which may be worse. Even worse than that, it might point to a variable of a different type or to memory that gets allocated to something else later.

free(p);
*p = 15;

As I mentioned at the end of the previous section, we have to deallocate memory when we are done with it or it will continue to grow. Obviously, this is not an issue in garbage collected languages (i.e. ones where we cannot manually allocate dynamic memory), or if we use modern C++ techniques to let the compiler manage our pointers for us.

While exiting our program or a variable going out of scope will give back the memory we took, if we want to process lots of data, keep our program running for a while, or the amount of memory on our system is limited, then we need to properly manage our memory. If we don't properly manage our memory all the time then we may forget to do so when needed, so it's best to always do so.