The C++ Programming Language – Part 7

This post will be mostly for personal reference as I go through Bjarne Stroustrop’s “The C++ Programming Language” 4th edition textbook. Some of the notes will appear random.

Chapter 6 – Types and Declarations (continued)

Objects can be classified based on their lifetimes.

Automatic: Created when its definition is encountered and destroyed when it goes out of scope. Typically, these are function-local objects located on the stack. Also known as a storage class.

Static: Declared in global or namespace scope. These exist until the program terminates. Also known as a storage class.

Free store: Variable lifetime, depending on the programmer's needs. Pointers can be passed from function to function, or the objects can be manually destroyed when not needed. This type of object is a common source of bugs.

Temporary: Automatic objects that are needed during computation. Lifetime is determined by use.

Thread-local: Created when their thread starts, destroyed when their thread ends. These are declared via thread_local specifier.

Type aliases are useful when we need a new name for a type. There are a few main reasons for this new name:

Original name is too long and would clutter the code.
A programming technique requires different types to have the same name in a context (?).
A specific type is mentioned in one place only to simplify maintenance.

Type specifiers are not allowed with aliases. A few examples of type aliases and their typedef equivalents:

#include <iostream>

using namespace std;

int main()
{
    /* pointer to character */
    using ptr_to_char = char*;

    /* pointer to function taking a double and returning an int */
    using ptr_to_func = int(*)(double);

    typedef int int32_t;
    /* same as */ 
    using int32_t = int;
    
    typedef short int16_t;
    /* same as */ 
    using int16_t = short;
    
    return 0;
}

Rounding out this chapter are some solid “Advice” excerpts:

Avoid unspecified and undefined behavior.

Avoid assumptions like "the size of an int is 4 bytes".

Avoid unnecessary assumptions about the range and precision of floating point types.

Prefer plain char over signed char and unsigned char.

Beware of conversion between signed and unsigned types.

Name an object to reflect its meaning rather than its type.

Use small functions.

Avoid uninitialized variables.

Do code review.

Chapter 7 – Pointers, Arrays, and References

This chapter deals with the basic language mechanisms for referring to memory. A * denotes a pointer to an object in memory. A pointer holds the address of an object. At its lowest level, object members are accessed via offsets from this pointer. In assembly language, you’ll often see things like [rax+0x8], meaning “dereference the pointer in rax, and go to offset 0x8″ which could be the ‘name’ field in a user defined object, for example. There are many possibilities with the ‘*’ aka ‘pointer to’ unary operator. You can have pointers to pointers, arrays of pointers, pointers to functions, and more. You can utilize cdelc.org to demangle this syntax as things get confusing. For example inputting “int (*fp)(char*);” to cdelc.org results in “declare fp as pointer to function (pointer to char) returning int.” In the code below, you’ll see two arrays of int pointers. Notice how one is not initialized properly, causing random stack data (possibly from previous function calls in a real-world codebase) to be included in the array. If one of those “uninitialized” values is used, good luck.

#include <iostream>

using namespace std;

int main()
{
    /* pointer to int */
    int *a;

    /* pointer to a pointer to a char */
    /* b -> ? -> char */
    char **b;

    /* array of 10 int pointers, unintialized */
    int *dirty_c[10];

    /* array of 10 int pointers, intialized with brackets */
    int *clean_c[10]{};

    /* two int pointers, one on the heap, one on the stack */
    int *sample = new int(5);
    int sample2{10};

    /* place int pointers in improperly initialized pointer array */
    dirty_c[1] = sample;
    dirty_c[8] = &sample2;

    /* place int pointers in properly initialized pointer array */
    clean_c[1] = sample;
    clean_c[8] = &sample2;

    cout << "\ndirty_c: sample is at heap memory address " << dirty_c[1] << endl;
    cout << "dirty_c: sample dereferenced is " << *dirty_c[1] << endl;
    cout << "dirty_c: sample2 is at stack memory address " << dirty_c[8] << endl;
    cout << "dirty_c: sample2 dereferenced is " << *dirty_c[8] << endl;
    cout << "dirty_c: pointer array dirty_c contains: " << endl;
    for (int i = 0; i < 10; i++) {
        cout << dirty_c[i] << endl;
    }

    cout << "\nclean_c: sample is at heap memory address " << clean_c[1] << endl;
    cout << "clean_c: sample dereferenced is " << *clean_c[1] << endl;
    cout << "clean_c: sample2 is at stack memory address " << clean_c[8] << endl;
    cout << "clean_c: sample2 dereferenced is " << *clean_c[8] << endl;
    cout << "clean_c: pointer array clean_c contains: " << endl;
    for (int i = 0; i < 10; i++) {
        cout << clean_c[i] << endl;
    }

    /* pointer to an array of 10 ints */
    int (*d)[10];

    /* pointer to a function taking a char pointer */
    int (*fp)(char*);

    /* function returning an integer pointer taking a char pointer as argument */
    int *f(char*);

    return 0;
}

Output:

user@ubuntu:~/cpp/part_2/chapter_7$ g++ -std=c++11 -o ptr_sample2 ptr_sample2.cpp 
user@ubuntu:~/cpp/part_2/chapter_7$ ./ptr_sample2 

dirty_c: sample is at heap memory address 0x1573c20
dirty_c: sample dereferenced is 5
dirty_c: sample2 is at stack memory address 0x7ffc6645167c
dirty_c: sample2 dereferenced is 10
dirty_c: pointer array dirty_c contains: 
0x602078
0x1573c20
0x400820
0x7fc3b2e7c9a0
0x7fc3b2e71660
0x400820
0x602078
0x7fc3b275d299
0x7ffc6645167c
0x7ffc66451700

clean_c: sample is at heap memory address 0x1573c20
clean_c: sample dereferenced is 5
clean_c: sample2 is at stack memory address 0x7ffc6645167c
clean_c: sample2 dereferenced is 10
clean_c: pointer array clean_c contains: 
0
0x1573c20
0
0
0
0
0
0
0x7ffc6645167c
0

At this point, it is also interesting to note the usage of **argv vs *argv[] when defining main(). The two styles mean the same thing. In both cases, argv is a pointer to a null terminated array of arguments passed via the command line. These two ways to express argv are equivalent because of operator precedence (see cppreference). The rank 2 subscript operator [] takes precedence over the rank 3 indirection * operator. Thus in the case of *argv[], argv first becomes an array of chars. Next, it becomes a pointer to an array of chars. In the case of **argv, argv first becomes a char pointer. Next it becomes an array of pointers to chars. The key here is in any array, the name of the array is a pointer to first element of the array, so argv has become a pointer to argv[0], which is the first command line argument (the program name).

A void pointer is used to pass or store an address of a memory location without knowing what is stored there. Said by Stroustrup, “pointer to an object of an unknown type.” Void pointer is primarily used for passing pointers to functions that are not allowed to make assumptions about the type of the object and for returning untyped objects from functions. To use such an object, we must use explicity type conversion. Using void* should be highly scrutinized in code review, they are most likely an indication of bad design decisions. nullptr is a literal that represents the null pointer. It is part of the standard namespace, as std::nullptr_t. The predecessor to nullptr was simply to use zero. Avoid the use of NULL as its definition can be implementation-defined.