Programming/C++/Pointers and Memory Management

From Dev Wiki
< Programming‎ | C++
Revision as of 08:32, 7 March 2023 by Brodriguez (talk | contribs) (Create page)
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)
Jump to navigation Jump to search

Like C, C++ forces the programmer to manage pointers and memory.


This is both a blessing and a curse.

On one hand, the programmer can potentially manage references and variable/object deconstruction (aka, what many languages refer to as "garbage collection") much better than any language that has garbage collection built-in and handled automagically.
In other words, the program can potentially be magnitudes more efficient.

On the other hand, if not careful, or if they don't know what they're doing, a programmer can introduce many bugs and unintentional problems by incorrect manual garbage collection.
A set of memory can be allocated that's too small. So memory references overlap and garble data. Or data can be forgotten about, potentially hanging in memory and taking up space far far longer than it would have been around with automatic garbage collection.


Pointers and memory management is one of the biggest differences between C/C++ and any other language.


Computer Memory

A computer's memory is a series of locations called addresses. Each memory address:

  • Can contain a set amount of data, represented as a given amount of 1's and 0's.
  • Is represented with a unique identifier number.
ToDo: Document binary, at least basics.

The 1's and 0's are called Binary. The computer automatically converts data to binary to store it in memory, and then back to the original format, so we as humans can understand it.
Similarly, the computer will automatically determine the unique identifiers that correspond to each address space.
So while it's potentially useful to understand these underlying concepts, we as programmers don't (usually) need to directly deal with these concepts.


Note: For very large pieces of data, we can chain together memory addresses to store more information. Thus, addresses defined by pointers have varying sizes.
This is how one can make addresses overlap.

For example, let's say a programmer makes two sequential int address pointers. So we have ... | open memory | int pointer #1 | int_pointer #2 | open memory |....

Normally, one assigns a standard integer value to each location and this is fine. HOWEVER, if one assigns a larger data type to "int pointer #1", then it will overflow into the address space of "int pointer #2", overriding any bits that might have already been there.
Furthermore, if this other data type is somehow excessively large, it might overflow enough that it continues on past the entirety of "int pointer #2", and overrides even more memory address space.


Pointers, References, and Values

Pointers, References, and Values are all different ways to refer to a given object.

In the following sections, we will use an integer variable as our example. But note that these concepts can apply to any variable type.


Pointers

A Pointer is a variable that stores the given unique identifier for a specific memory address.

So for example, if you had an int pointer, then it holds the memory address that is the correct size to hold a single integer variable.

Presumably, the actual value located at said address is the binary representation of an integer.

Warn: Note that C/ C++ allow the programmer to do basically anything. Even if it doesn't make sense.

So while an integer generally SHOULD be located at this address, it may be corrupted (such as having overlapping memory address pointers). Or some other value type might have been placed there. Etc.

It depends on the local program and what's being done. Or if there are any erroneous memory references. Etc.


TLDR: A pointer provides the identifier for a given specific memory address.


References

Meanwhile, a Reference references the value at a specific memory address.
It's almost the direct literal variable value, but not quite.

If this reference is passed into different functions, then it still points to the original memory address.
Updating the reference in this new function WILL change the reference value in the old function as well.

This is where the term pass-by-reference comes from.


TLDR: A reference allows directly manipulating and referencing a value at a specific memory address. This stays true even as the reference is passed around and changes scope.


Performance Tip and Reference Example

If passing a value into a function, where the function is NOT modifying the passed value at all, we can save slight overhead by using references and the const keyword.

For example, let's say we have a trivial function of:

int add_one(int i) {
    return i + 1;
}

Technically with the above code, every time this function is called, it will make a copy of our passed i variable for the scope of the function.
Then we don't modify it, and return a new value of our original i, incremented by one.


Conversely, we could improve performance by skipping making a local copy of our i parameter. We can take it a step further by saying it's a const, ensuring that it never changes. Ex:

int add_one(int const &i) {
    return i + 1;
}

This guarantees both that our variable i:

  • Does not change at any point within the local scope of our function.
  • Does not make a local copy as it's passed into our function. Instead, it references whatever memory address it originally came from.


Values

Last, a Value is what we normally think of when we think of programming variables.
Aka, a value is the direct, literal value. So an int value is the actual value that our integer actually is.

If this value is passed into different functions, then the value is copied to memory corresponding to the new function, and now exists in two memory locations.
Updating the value in this new function will NOT change the value in the old function.


This is where the term pass-by-value comes from.


TLDR: A value is the literal value of a variable.


Syntax

For both Pointers and References, the syntax uses the & symbol. So how do we tell which is which?


If the & symbol is used as part of a variable declaration, then it's a reference. For example:

// These are standard variable declarations.
int soda = 5;                   // General name.
bool color_gray = false;        // US name.

// These are reference variable declarations.
int &pop = soda;                // Midwest name.
int &coke = soda;               // Southern name.
bool color_grey = &color_gray;  // European name.

// With the above, variables "soda", "pop", and "coke" all refer to the same value in memory.
// Meanwhile, variables "color_gray" and "color_grey" also refer to the same value in memory.


// Similarly, we can use references in function parameter declaration.
void my_function(int &my_int, bool &my_bool) {
    // Function logic here.
}


Alternatively, if the & symbol is used anywhere else, it's used to get the memory address and create a pointer.

#include <iostream>

...

// These are standard variable declarations.
int soda = 5;                  // General name.
bool color_gray = false;       // US name.

// Print out actual values of each.
std::cout << "\nDirect values:\n";
std::cout << "soda: " << soda << "\n";
std::cout << "gray: " << color_gray << "\n";

// Print out memory addresses of each.
std::cout << "\nMemory addresses:\n";
std::cout << "soda: " << &soda << "\n";
std::cout << "gray: " << &color_gray << "\n";

// Create pointer variables of each.
int* soda_pointer = &soda;
bool* gray_pointer = &color_gray;

Note: To create an actual pointer variable, you also need the * character after the type.

This is because, again, the size of different types varies. The size of an integer data type is not necessarily the same as the size of an integer data type's memory address.

Exact sizes and variations vary depending on the operating system.

Note that it is possible to make a pointer to a pointer. There is no limit to how deep this can go.
Each level of pointer needs another * character. Ex:

// Declare our standard "soda" variable again, and print to console.
int soda = 5;
std::cout << "soda: " << soda << "\n";

// Create a pointer to our soda variable, and print to console.
int* soda_pointer = &soda;
std::cout << "soda_pointer : " << soda_pointer << "\n";

// Create a pointer to our pointer, and print to console.
int** another_pointer = &soda_pointer;
std::cout << "another_pointer: " << another_pointer << "\n";

// Create yet another pointer to the above pointer...
int*** why_are_we_doing_this = &another_pointer;
std::cout << "why_are_we_doing_this: " << why_are_we_doing_this << "\n";

This may seem arbitrary and nonsensical right now. But see Arrays for a case where it's potentially useful.
The main point of this page is just to explain what's possible, and what the syntax is.


Dereferencing

Okay, we can now get references, access variable addresses, and get pointers. But what if we want the original value that a pointer points to?

That's what dereferencing is. We can Dereference a pointer to retrieve the original value at the given memory location. This also uses the * character:

// Declare our standard "soda" variable again, and print to console.
int soda = 5;
std::cout << "soda: " << soda << "\n";

// Create a pointer to our soda variable, and print to console.
int* soda_pointer = &soda;
std::cout << "soda_pointer : " << soda_pointer << "\n";

// Print one more time, by dereferencing to display our original value.
std::cout << *soda_pointer << "\n";

// Note: For a pointer to a pointer, you would need **, etc.


Null Pointers

We can initialize a pointer like so int* ptr;.

However, in C/C++, whenever a variable is initialized without a value, it will take on whatever value happened to be at the given address space at the time of initialization.
One would hope this means it starts with a value of 0. But depending on what else was running on the computer and in what order, this is not necessarily the case.

C/C++ does not magically sanitize values on initialization, unlike some other languages. See https://en.wikipedia.org/wiki/Uninitialized_variable for more.


This is particularly dangerous for pointers, because it will try to take whatever arbitrary value it was initialized on as a valid address space.
This may potentially be an address being used in an entirely different program, or even an address being used by the operating system.

For safety, the above int* ptr; really should be changed to int* ptr = nullptr;.