Navigating the World of Pointers: A C Programming Journey

Exploring the Power of Pointers in C: A Comprehensive Guide to Mastering Pointers.

Introduction

In this article, we will embark on a deep dive into the world of pointers in the C language. It's important to note that this article is not tailored for absolute beginners; some prior knowledge of the C language, especially the concepts of data types, functions and arrays, is highly recommended.

The concept of pointers holds immense significance in the C language. Understanding pointers is a fundamental and sometimes daunting step in your journey as a C programmer. However, it is precisely this feature that grants C its remarkable flexibility and efficiency.

So, stay with us until the end as we unravel the mysteries of pointers and equip you with the knowledge and confidence to utilize this powerful feature effectively in your C programming endeavours.

Here are some important key points about pointers:

  • The C programming language allows us to manipulate memory using what are called 'pointers.'

  • To do this, we can define variables known as pointers, which are intended to store memory addresses.

  • There are different pointer types, and a specific type is defined by the objects or functions they point to.

  • In C, pointers are strongly typed, which means we cannot put a pointer of type 'double' into the memory address of an 'int' type without a cast.

  • We can apply certain arithmetic operations, such as incrementation, decrementation, and subtraction. These operations take into account the size of the pointed objects.

An Overview of Pointer Concepts

int *adr; // We can also write int * adr or int* adr

This expression indicates that adr is a variable of the pointer type, specifically a pointer to an integer object.

Since the previous declaration of adr does not have an initializer, the value of adr is now undefined. To make it point to a specific integer, there are two ways:

The first method involves assigning the address of an existing object to adr, for example:

int n;
adr = &n;

The second way consists of using dynamic allocation functions to create a memory area for an object of the desired type with the function malloc (or calloc):

adr = malloc(sizeof(int));

Note that you can assign a pointer value to another pointer value:

int *adr1, adr2;
    ...
adr1 = adr2;  /* adr1 and adr2 point now to the same object */
  • If adr is a pointer, *adr is the object pointed by adr.

The declaration of a pointer allows you to specify the following information:

  • The name of the pointer.

  • The type of pointed elements with a qualifier (const, volatile) for the object pointers.

  • A storage class for the pointer itself.

  • A qualifier for the pointer itself.

For example :

static const unsigned long p, *adr1, * const adr2;
  • adr1 is a pointer to a constant object of type unsigned long.

  • adr2 is a constant pointer to a constant object of type unsigned long.

Storage class associated with the declaration of a pointer :

The declaration of a pointer can be associated with a storage class (static, extern, auto, register).

static int n, *ad;
  • The storage class specifier pertains to the pointer itself and not the pointed objects.

  • In the previous example, if ad is declared as a local variable within a function, it will have a static storage class specifier for the pointer itself. However, there are no restrictions on the data type or qualifiers of the pointed integers. This situation presents a risk that the pointer may point to an object whose memory allocation has been deallocated.

Qualifiers const & volatile :

With the following declaration :

const int n, *p;

The qualifier const applies to n and *p. This means p is a pointer at a constant int or *p is a constant int meaning that the modification of the object pointed by p is not allowed :

*p = ... ; /* Not allowed! */

However, the modification of p is allowed :

p = ... ; /* Allowed */

When you receive a constant pointer as an argument in a function, the use of the const qualifier is a way to protect the pointed data from being modified within the function. This provides a form of write protection for the data within the scope of the function, ensuring that it remains unaltered.

void fct(const int * adr)
{
    ...
    /* here, the modofication of *adr is not allowed */
}

It's important to note that you cannot pass a constant pointer to a function that expects a non-constant pointer as a parameter. However, if a function is designed to accept a constant pointer, it can also accept a non-constant pointer without any issues.

void f(int * adr); /* f takes a pointer to an int that it can modify */
    ...

int *adi;
const int * adci;

f (adi); // Correct
f(adci); // Incorrect

Qualifier associated with the declarator :

In this case, the qualifier pertains to the pointer variable itself and not the pointed object.

With the declaration:

int n, *const p; // In general, p must be initialized
  • *const p is an integer.

  • const p is a pointer to an integer.

  • p is a constant pointer to an integer.

This means that the modification of p is not allowed:

p = ... ; // Not allowed

However, modifying the object pointed to by p is allowed!

*p = ... ; // Allowed

Case of pointers of pointers:

const int n, *const ad1, *const *const ad2, **const ad3;
  • n is a constant int

  • ad1 is a constant pointer to a constant int

  • ad2 is a constant pointer to a constant pointer to a constant int

  • ad3 is a constant pointer on a (non-constant) pointer at a constant int

    • ad3 cannot be modified

    • **ad3 cannot be modifier

    • *ad3 can be modified

Arithmetic properties of pointers

Addition and substruction of an integer to a pointer:

If a variable is declared like this: int *ad;

Then, an expression like ad + 1; has the same type as ad, which is a pointer to an int. Its value is the address of the next integer that follows the current integer pointed to by ad.

The difference between ad and ad + 1 is sizeof(int) bytes. If ad was declared as double * ad;, then the difference between ad and ad + 1 will be sizeof(double) bytes.

Note that if ad is a pointer to a constant int: const int *ad;, then an expression like ad + 3 is of type const int and not just of type int, which means that the assignment *(ad + 3) = ...; is not allowed.

It's possible to subtract the value of two pointers pointing to the same type, and the result is an integer representing the number of elements between the two addresses. For example:

int t[10];
int * ad1 = &t[3];
int * ad2 = &t[8];

The expression ad2 - ad1 has the value 5.

We can say that this operation is nothing but the inverse of the incrementation, if p1 and p2 are pointers of the same type such as p2 = p1 + i then p2 - p1 = i or p1 - p2 = -i.

The Relationship Between Pointers and Arrays

Let's consider the following declarations:

int t[10];
int *adr = ... ; /* we suppose that adr points on a sequence of objects of type int  */

In general, we access the different elements of t using expressions of the form t[i], and for objects pointed to by adr, we use expressions of the form *adr or *(adr + i).

  • *(t + i) is entirely equivalent to t[i].

  • adr[i] is completely equivalent to *(adr + i).

When the name of an array appears in a program, it is converted to a constant pointer to its first element. For example, if we define an array: int t[10];, when t appears in an instruction, it is replaced by the address of t[0]. For example, this instruction will be correct:

scanf("%d", t); /* equivalent to scanf("%d", &t[0]); */
t + 1; /* equivalent to &t[1] */
t + i; /* equivalent to &t[i] */

An Exception to the Rule!

  • When the sizeof() operator is applied to an array's name, the result is the size of the array and not the size of the pointer.

  • With the & operator, if we consider an array of type T, the expression &t gives the address of the first element of the array and not the address of this address.

Constness of an array:

It's important to note that the qualifiers of the array elements influence the type of the corresponding pointer. For example:

int t1[10];
const int t2[10];

In this case, t1 is of type int*, while t2 is of type const int*.

The NULL pointer

There is a symbol, denoted as NULL, which is defined in certain header files (such as stddef.h, stdio.h, and stdlib.h). The value of NULL represents a pointer that doesn't point to anything, signifying the absence of an associated address. This value can be assigned to a pointer of any type:

int *adi;
double (*adt) [10];
    ....
adi = NULL;
adt = NULL;

It's important to note that we can test the equality (using the == operator) or inequality (using the != operator) of any pointer with the NULL pointer. However, using the comparison operators (<, <=, >, >=) on pointers is theoretically discouraged. Some implementations may accept them, but the results obtained, which are likely based on memory addresses, may not be portable.

Example of using the NULL pointer

When working with variables of pointer type, it's a common practice:

  • To initialize all pointers that don't have a more suitable initialization to NULL, such as:

      int *adi = NULL;
    
  • To test if a pointer has received a value other than NULL, you can use a condition like:

      if (adi != NULL) {
          *adi = ...;
      }
    
  • In the context of linked lists, NULL is often used to indicate that a pointer does not point to anything, typically signifying the end of the list.

  • Functions that return a pointer as a result typically return NULL in case of an error or problem, allowing you to check for the success or failure of the function call.

Pointer conversion with cast

Pointer Alignment Constraints and Conversions:

For the sake of efficiency, it's common for an implementation to impose specific address alignment constraints on certain data types. Here are some typical scenarios:

  • Aligning two-byte integers on even addresses, which, on 16-bit machines, allows direct access to the corresponding integer in a single operation.

  • Aligning 4-byte objects on addresses that are multiples of 4, which, on 32-bit machines, permits direct access to the corresponding object in one go.

Under these conditions, using an address that does not adhere to these alignment constraints for a specific data type can sometimes be problematic. That's why the ANSI standard allows pointer conversions to modify the corresponding address, ensuring that the result always complies with the alignment constraint of the newly pointed object.

For example, let's assume that the implementation aligns int variables on even addresses:

char *adc;
int *adi;

the assignment:

adi = (int *) adc;

Is legal, but the address contained in adr may be:

  • The same as adc if adc is even.

  • Incremented or decremented by one if "adc" was odd, in order to make the result even.

Function pointers

The C language allows us to define pointer variables that store function addresses. This capability is particularly interesting in the following two cases:

  1. To parametrize the function call, which means allowing a specific instruction to call a function at runtime. This allows the called function to change dynamically. To achieve this, we declare an appropriate pointer variable.

  2. To pass a function as an argument to another function.

Declaration of a pointer variable for a function:

With the following declaration:

int (*adf) (double, int); /* adf points to a two-argument function */
                          /* of type double and int, returning an int */

Note that :

  • (*adf) (double, int) is an integer.

  • (*adf) is a function that takes as argument a double and an integer and returns an integer.

  • *adf is a function that takes as argument a double and an integer and returns an integer.

  • adf is a pointer to a function that takes as an argument a double and an integer and returns an integer.

It is important to note that the parenthesis in *adf is necessary. Actually, this declaration:

int *adf (double, int);

is interpreted as :

  • *adf2 (double, int) is an integer

  • adf2 (double, int) is a pointer to an integer

  • adf2 is a function that receives as an argument a double and an integer and returns a pointer to an integer.

Assignment of values to a pointer value at a function:

Let's consider the following declarations:

int f1 (double, int);  
int f2 (float);
double f3 (void);

int (*adf1) (double, int);
int (*adf2) (double, int);
double (*adf3) (double);

These assignments are correct :

adf1 = f1;
adf1 = f2;

However, these assignments will be rejected:

adf1 = f2; /* Incorrect : the types of arguments are not the same */
adf1 = f3; /* Incorrect : the types of return values are not the same as well as arguments type */

Thank you for reading, and let's connect!

Thank you for reading my blog. Feel free to subscribe to my email newsletter and let's get in touch on LinkedIn.
If you like this article! Don't miss the upcoming ones, follow me and subscribe to my newsletter to receive more!
See you soon :)

Did you find this article valuable?

Support Hamza EL Yousfi by becoming a sponsor. Any amount is appreciated!