CSCI 200 - Spring 2024
Foundational Programming Concepts & Design

Pointers and Const

The images in https://stackoverflow.com/a/31331389 do a good job of explaining how const gets applied to pointers. Read it backwards:

int * ptr; ptr is a pointer to int const int * ptr; ptr is a pointer to an int constant (i.e. const int) int const * ptr; ptr is a pointer to const int int * const ptr; ptr is a const pointer to int const int * const ptr; ptr is a constant pointer to const int
int * pValueName;               // p is a pointer to an integer

const int * pVALUE_NAME;        // p is a pointer to an integer constant
int const * pVALUE_NAME;        // p is a pointer to a constant integer (equivalent to above)

int * const P_valueName;        // P is a const pointer to an integer

const int * const P_VALUE_NAME; // P is a const pointer to an integer constant
int const * const P_VALUE_NAME; // P is a const pointer to a constant integer (equivalent to above)

Notice the mixing of naming styles, it is accurate to which part of the pointer is constant or not.

So what does this mean in practical purposes? Pointers have two values associated with them:

Now, for the pointer, which of those two values should be constant? If we have a constant pointer, then the value of the pointer cannot change.

int * const P_value = new int(1); // a constant pointer
// P_value = new int(2);          // once we initialize it, we can't change the value of the pointer
                                     // we can't change the address stored
*P_value = 3;                     // but we can still change the value being pointed at

If we point at a constant value, then the value of the pointer can change, but what we're pointing at cannot.

const int * pVALUE = new int(1); // pointing at a constant value
// *pVALUE = 2;                  // we can't change the value we're pointing at
pVALUE = new int(3);             // but we can point at something different

Where would we potentially see this come in? Consider the following:

const float PI = 3.14f;     // PI cannot be reassigned
const float* pPI = Π     // pointer to a constant float
// *pPI = 3.141f;           // compiler error! cannot reassign the value of PI
                               // that we are pointing at

Those two uses of const are independent, so if we want a pointer whose value can't change AND the value we're pointing at can't change, then we'd apply them both.

const int * const P_PI = new float(3.14); // constant pointer pointing at a constant value
// *P_PI = 2.78;                          // can't change the value we're pointing at
// P_PI = new float(6.28);                // nor can we change our pointer

These concepts can apply in any scope anywhere in the program. We mostly think of it in the contexts of functions. Circling back to functions, what's missing from the above? Any concept of reference. Now it gets even messier. Consider:

void foo(int * & pParam) { ... }              // a reference to a pointer to an integer
void bar(const int * & pPARAM) { ... }        // a reference to a pointer to a constant integer
void baz(int * const & P_param) { ... }       // a reference to a constant pointer to an integer
void qux(const int * const & P_PARAM) { ... } // a reference to a constant pointer to a constant integer

Now we need to ensure the type of the argument matches the type of the parameter. So only the following calls are valid for each function:

int* pa = new int(4);
const int* pB = new int(5);
int* const P_c = new int(6);
const int* const P_D = new int(7);
//--------------------------------------------------------------------
foo(pa); // int* and int*& - reference to a pointer to an int
         // pB can't be supplied because it would lose const qualifier
            // and the parameter would be able to change what the argument is pointing at
         // P_c can't be supplied because it would lose const qualifier and the parameter
            // would be able to change the value of the argument
         // P_D can't be supplied because it would lose const qualifier for
            // both of the pB and P_c scenarios
//--------------------------------------------------------------------
         // pa can't be supplied because the parameter could reassign the argument to point
            // at a constant value, and then later the argument would be able to change the constant
bar(pB); // const int* and const int*& - reference to a pointer to a constant int
         // P_C can't be supplied because it would lose const qualifier and the parameter
            // would be able to change the value of the argument
         // P_D can't be supplied because it would lose const qualifier and the parameter
            // would be able to change the value of the argument
//--------------------------------------------------------------------
baz(pa); // int* and int* const & - reference to a constant pointer to an int
            // the parameter pointer value cannot change within the scope of the function,
            // we can add constant to ensure the value of the argument doesn't change
         // pB can't be supplied because it would lose const qualifier and the parameter
            // would be able to change what the argument is pointing at
baz(P_c);// int* const and int* const & - reference to a constant point to an int
         // P_D can't be supplied because it would lose const qualifier and the parameter
            // would be able to change what the argument is pointing at
//--------------------------------------------------------------------
qux(pa); // int* and const int* const & - reference to a constant pointer to a constant int
            // the parameter pointer value cannot change nor the value pointing at within
            // the scope of the function, we can add constant to ensure the value of the argument
            // and the value pointed at doesn't change
            // note the difference between bar(pa) and qux(pa) - since qux doesn't allow the parameter
            // to change, it is ensuring nothing about the argument can change
            // bar(pa) has the possible side effect where the argument could change the parameter
            // after the fact (see below)
qux(pB); // const int* and const int* const & - reference to a constant pointer to a constant int
            // the parameter pointer value cannot change within the scope of the function,
            // we can add constant to ensure the value of the argument doesn't change
qux(P_c);// int* const and const int* const & - reference to a constant pointer to a constant int
            // the value the parameter points at cannot change, we can add constant to ensure the
            // value the argument points at doesn't change
qux(P_D);// const int* const and const int* const & - reference to a constant pointer to a
            // constant int

Here's an example of why bar(pa) is invalid - none of the below will work:

float* pFloat = nullptr; // point at a float

const float PI = 3.14f;  // PI cannot change

const float* p_PI = Π // point at PI, which is constant
*p_PI = 3.141f;          // invalid, cannot change the value we're pointing at
                            // -- this is a compiler error

const float*& pFLOAT = pFloat;     // pFLOAT should point at a constant float,
                            // but we're saying to create ourselves at the location of a pointer
                            // to a float -- this is a compiler error
pFLOAT = Π            // pFLOAT now points at a constant
                         // pFloat is now also pointing at a constant
*pFLOAT = 3.141f;        // invalid, cannot change the value we're point at
                            // -- this is a compiler error

*pFloat = 3.141f;        // while this line on its own is valid, pFloat is pointing at
                            // the constant PI, which can't change.  but we just would have
                            // indirectly changed the constant
                            // thus why the int* to const int*& conversion fails

Looking at why baz(pa) and qux(pB) work is based on where the second const applies. The function declarations for those would look like follows:

void baz(int*&);         // reference to a pointer to an integer
void qux(const int*&);   // reference to a pointer to a constant integer

The const qualifier that applies to the value of the pointer only applies when we have an actual variable. The function definitions is where we add the const safety:

void baz(int* const &P) { ... };       // P cannot be reassigned so the argument won't be reassigned, but what P and the argument points at can
void qux(const int* const &P) { ... }; // P cannot be reassigned so the argument won't be reassigned, neither can what P or the argument points at

Consider even further returning to our C roots and determine what happens here:

void foo(const float* p) {
  p = new float(3.141f);  // legal #1?
  *p = 3.141f;            // legal #2?
}

void bar(float* const P) {
  P = new float(3.141f);  // legal #3?
  *P = 3.141f;            // legal #4?
}

void baz(const float* const P) {
  P = new float(3.141f);  // legal #5?
  *P = 3.141f;            // legal #6?
}

int main() {
  float distance = 3.14f; // legal #7?
  foo(&distance);         // legal #8?
  bar(&distance);         // legal #9?
  baz(&distance);         // legal #A?

  const float PI = 3.14f; // legal #B?
  foo(&PI);               // legal #C?
  bar(&PI);               // legal #D?
  baz(&PI);               // legal #E?

  return 0;
}

# 1, 4, 7, 8, 9, A, B, C, E are the legal statements.

Hopefully 2, 3, 5, 6 are apparent from the above explanation. D fails because the parameter of bar allows what we're pointing at to change, but the value the parameter would be pointing at is not allowed to change - the compiler prevents this from occurring.