Lecture Notes for CSE 701: Foundations of Modern Scientific Programming
McMaster University, Fall 2023

Table of contents

1Introduction
1.1A short overview of programming languages
1.1.1Machine code and assembly language
1.1.2C and C++
1.1.3Higher-level languages
1.2Visual Studio Code
1.2.1Installing the IDE and compiler
1.2.2Creating and configuring your first program
1.2.3The JSON configuration files
1.2.4Learning more about Visual Studio Code
2Fundamentals of C programming
2.1Basic C syntax and analysis of the "Hello, World!" program
2.1.1Line-by-line analysis
2.1.2Comments and semicolons
2.1.3Indentation and code formatting
2.2Variables and integer data types
2.2.1Integers and bit width
2.2.2The integer types
2.2.3Line-by-line analysis and printf format placeholders
2.2.4Width modifiers
2.2.5Declaring and naming variables
2.2.6Initializing variables
2.2.7Constant variables
2.2.8Arithmetic operators on integers
2.2.9Integer overflows
2.2.10Fixed-width integer types
2.3Selection statements
2.3.1If statements and comparison operators
2.3.2If subtleties and Boolean values
2.3.3The (ternary) conditional operator
2.3.4Switch statements
2.3.5Enumerations
2.4Iteration statements (loops)
2.4.1While and do-while loops
2.4.2Controlling loop evaluation
2.4.3For loops
2.4.4Nested loops
2.4.5Variable scope
2.5Arrays
2.5.1Declaring arrays
2.5.2Initializing arrays to zeros
2.5.3Accessing array elements out of range
2.5.4Multi-dimensional arrays
2.5.5Characters and strings
2.6Functions
2.6.1Defining functions
2.6.2Constant vs. non-constant arguments
2.6.3Recursion
2.6.4Forward declaration and mutual recursion
2.6.5Global variables
2.6.6Static variables
3Advanced topics in C programming
3.1Debugging with Visual Studio Code and GDB
3.1.1Getting ready for debugging
3.1.2Breakpoints and the debug toolbar
3.1.3Conditional breakpoints and logpoints
3.1.4Variables, watches, and the call stack
3.1.5Using the debug console
3.1.6Further reading about debugging
3.2Floating-point data types
3.2.1Floating-point numbers and bit width
3.2.2Single, double, and extended precision
3.2.3Entering and printing floating-point numbers
3.2.4Rounding errors and special values
3.2.5Diving deeper into the floating point representation
3.2.6Common mathematical functions
3.2.7Complex numbers
3.3Type conversion
3.3.1Implicit conversion
3.3.2Explicit conversion: type casting
3.4Pointers
3.4.1Memory addresses and the stack
3.4.2Using pointers
3.4.3Constant pointers vs. pointers to constant variables
3.4.4Arrays and pointers
3.4.5Functions and pointers
3.4.6Passing arrays to functions
3.4.7Passing multi-dimensional arrays to functions
3.4.8Jagged arrays
3.5Dynamic memory allocation
3.5.1Allocating memory in the heap
3.5.2Proper use of malloc and calloc
3.5.3Proper use of realloc
3.6Input and output
3.6.1Input from the command line
3.6.2Input from the terminal during run time
3.6.3Input from a file
3.6.4Output to a file
3.7Structs and typedefs
3.7.1Structs
3.7.2Arrays of structs
3.7.3Structures as pointers
3.7.4Typedef
3.8Further reading
4Introduction to C++
4.1Some new features of C++
4.1.1The "Hello, World!" program
4.1.2Using namespaces
4.1.3Reserved keywords
4.1.4Function overloading
4.1.5Constant expressions
4.1.6The cin object
4.1.7Namespaces revisited
4.1.8Default function arguments
4.2Pointers and references
4.2.1References
4.2.2Functions and references
4.2.3Improving performance with (constant) references
4.2.4Range-based for loops and references
4.2.5Null pointers
4.3Vectors and strings
4.3.1Vectors
4.3.2Strings
5Classes and object-oriented programming
5.1Classes
5.1.1Introduction to classes
5.1.2Classes and structures
5.1.3Member functions
5.1.4Constructors
5.1.5Exceptions: try-throw-catch
5.1.6Invariants, private members, and encapsulation
5.1.7Constructors for the triangle class
5.1.8Separating member functions from the class
5.1.9Inline functions
5.1.10Splitting a project into separate files
5.1.11Const vs. non-const member functions
5.1.12Enumeration classes
5.1.13Static class members
5.2Operator overloading
5.2.1Introduction to operator overloading
5.2.2Overloading the << operator
5.2.3Overloading the == and != operators
5.2.4Overloading the + and += operators
5.2.5Overloading the - and -= operators
5.2.6Overloading the * operator
5.2.7Combining fundamental and user-defined types
5.2.8The complete vector overloads: a header-only library
5.2.9Summary: the matrix class
5.3Input and output stream classes
5.3.1Formatting output
5.3.2I/O streams and files
5.3.3File stream modes
5.3.4Seeking
5.3.5String streams
5.3.6Buffered output
5.3.7I/O error handling
5.3.8Reading and writing binary files
5.4Class friendship and inheritance
5.4.1Friend functions
5.4.2Uses for friend functions
5.4.3Inheritance and derived classes
5.4.4Deriving from a derived class
5.4.5Deriving from two base classes
5.4.6Protected members
5.4.7Virtual functions
5.4.8Creating custom exceptions
6Numerical aspects of C++
6.1The C++ numerics library
6.1.1Mathematical functions
6.1.2Numeric algorithms
6.1.3The complex number class
6.1.4Mathematical constants
6.2Random numbers
6.2.1True random number generation
6.2.2Pseudo-random number generation
6.2.3Probability distributions
7Templates and the standard template library
7.1Templates
7.1.1Introduction to templates
7.1.2Function template example: the vector operator overloads
7.1.3Class template example: the matrix class again
7.1.4The standard template library
7.2The array container: static (fixed-size) contiguous array
7.2.1Introduction to STL arrays
7.2.2Iterators
7.2.3Performance and memory considerations with STL arrays
7.2.4The vector operator overloads for arrays
7.3The vector container: dynamic contiguous arrays
7.3.1Introduction to STL vectors
7.3.2Iterators and iterator invalidation
7.3.3Interlude: measuring performance with chrono
7.3.4Performance considerations with vectors
7.4Other sequence containers: deque, forward_list, and list
7.4.1Templates of templates: the auto keyword
7.4.2The deque container: double-ended queue
7.4.3The forward_list and list containers: singly-linked and doubly-linked list
7.5Associative containers: sets and maps
7.5.1Sets
7.5.2Maps
7.6The standard template library: algorithms
7.6.1General syntax and lambda expressions
7.6.2Non-modifying sequence operations
7.6.3Modifying sequence operations
7.6.4Other useful algorithms
7.6.5Algorithms from the numerics library
7.7Further reading on C++
8Development and collaboration tools
8.1Optimizing your code
8.1.1Compiler optimizations
8.1.2Comparing execution time with different compiler optimizations
8.1.3Writing optimized code
8.2Configuring Visual Studio Code tasks
8.2.1The tasks.json file
8.2.2The launch.json file
8.2.3Setting up additional tasks for your project
8.2.4Using shell scripts
8.3Customizing the compilation process
8.3.1The stages of compilation
8.3.2Preprocessor directives
8.3.3Preventing double inclusion of header files
8.3.4Using CMake
8.3.5Customizing CMake
8.4Documentation
8.4.1Creating documentation using Markdown
8.4.2Documenting your code using Doxygen
8.5Version control
8.5.1Installing and configuring Git
8.5.2Using Git
8.5.3Branching
8.5.4Using GitHub
9Advanced memory management in C++
9.1Dynamic memory allocation and related member functions
9.1.1The new and delete operators
9.1.2Avoiding memory leaks
9.1.3Destructors
9.1.4The matrix class template with manual memory allocation
9.1.5Copy constructors
9.1.6Overloading the assignment operator
9.1.7Move constructors and move assignments
9.1.8The full code for manually allocated matrices
9.1.9emplace and emplace_back
9.2Memory debugging and smart pointers
9.2.1Memory debugging with Dr. Memory
9.2.2"Resource Acquisition Is Initialization"
9.2.3Smart pointers
9.2.4Performance of smart pointers
9.2.5The matrix class template with smart pointers
10Epilogue

1 Introduction ^

This course is intended to give the students a good understanding of computing, solid programming skills in C and C++, experience with debugging, optimization, algorithm design, and numerical calculations, and the ability to produce high-quality and well-organized scientific software, both on their own and in collaboration with others. Please see the table of contents of the lecture notes for a detailed outline of the course.

This is an advanced graduate-level course, and is not intended to be a first introduction to programming. Therefore, students must possess basic prior knowledge of some programming language (any language will do) in order to take this course.

1.1 A short overview of programming languages

1.1.1 Machine code and assembly language

There are hundreds of programming languages, and they can be categorized according to their level, which roughly indicates how close the language is to the way the computer actually operates at the hardware level. The lowest-level language is machine code, the binary instructions that are executed directly by the CPU.

However, machine code is intended for CPUs, not for humans, and programmers never write machine code directly, which would be an extremely tedious task. Instead, code is generally written in higher-level languages, and this code is then automatically translated to machine code so that it can actually run on the CPU.

A slightly higher-level language is assembly language, which is basically a way to write machine code directly in a language that humans can understand. Assembly language first appeared in 1949, years ago! Assembly code still tells the CPU exactly what to do and how to do it - but with human-readable characters instead of binary codes.

Theoretically, you could write your programs in assembly, but that would be almost as tedious as writing machine code. Furthermore, both assembly language and machine code differ from one CPU type to another; if you write code that will run on a particular CPU, and you want to run it on a different CPU, you will need to rewrite it.

1.1.2 C and C++ ^

The next higher level after assembly is that of C and similar languages. C is one of the oldest programming languages in common use today, created in 1972, years ago. This language adds a level of abstraction on top of assembly language; instead of telling the CPU directly what to do, you can define abstract entities such as variables and functions, and use them in human-readable statements that resemble spoken language.

A program called the compiler then translates these abstract statements to machine code. This has the great advantage that you can write one C program and then use different compilers to compile the exact same program to machine code for a variety of different CPUs. You can think of C as "portable assembly".

C is essentially the lowest-level language humans can efficiently write code in, and it provides the programmer with direct, low-level access to the computer's hardware. For this reason, it is often used to write the operating systems on which other programs run, the device drivers which provide an interface between software and hardware, and the compilers and interpreters which translate other programming languages (including all the ones listed below!) to machine code.

Embedded systems in a variety of devices, from digital watches to electric cars, also use C almost exclusively due to their very limited resources, as higher-level programming languages generally require more resources to operate.

A slightly higher level is that of C++ and similar languages. C++ is basically an extension of C which adds object-oriented programming, and it was created in 1985, years ago. C++ adds an additional level of abstraction over C by allowing the programmer to define abstract entities called classes. We will see exactly what this means when we learn C++ later in the course. C++ is generally preferable to C for large-scale applications which can benefit from the increased abstraction.

C and C++ are sometimes called "mid-level languages" because they are located in the middle between assembly language and higher-level languages. While higher-level programming languages offer more abstraction, which often simplifies the language and shortens development time, they are also slower and less flexible.

Programs written in C and C++ generally run much faster than programs written in higher-level programming languages, and therefore they are especially suitable for applications where performance is of the utmost importance, including, of course, scientific computing.

Warning: The unmatched speed of C and C++ comes at a cost. Since they allow the programmer direct access to hardware components such as memory, it is very easy to make mistakes such as accessing the wrong memory address, which would be impossible to do in higher-level languages.

1.1.3 Higher-level languages ^

At the next higher level we have Java, C# (pronounced "C sharp"), and similar languages. These languages are newer (Java appeared in 1995 and C# in 2000), and they don't allow the programmer as much freedom or flexibility as C and C++ do. For example, instead of allowing direct access to the computer's memory, Java and C# manage the memory themselves; this is also called garbage collection. Although this can prevent problems such as accessing the wrong memory address, it also incurs a performance penalty.

Finally, at the highest level we have interpreted languages such as Python and JavaScript. The languages we discussed up until now are compiled languages, which means the entire code is first translated to machine code using a compiler, and only then can be executed. In contrast, interpreted languages run using an interpreter, which reads the source code and executes it line-by-line.

This has the advantage of being able to instantly run the same source code on any platform using a suitable interpreter, whereas compiled languages require compiling the source code separately for each type of CPU and operating system.

Warning: Although interpreted languages have many benefits in terms of ease of use, they are significantly slower and use much more resources compared to compiled ones. The difference in speed and/or memory usage between a Python program and a C program that does the same thing can sometimes be several orders of magnitude!

The programming languages we mentioned here - C, C++, Java, C#, Python, and JavaScript - are considered to be the most popular programming languages right now. However, there are hundreds of other languages, including some that are specifically intended for scientific computing, such as Mathematica. In this course, we will focus on C and C++, since they are the fastest and thus most suitable for scientific programming.

1.2 Visual Studio Code ^

1.2.1 Installing the IDE and compiler

Throughout this course, we will be using the Visual Studio Code IDE (Integrated Development Environment). It provides a convenient GUI (Graphical User Interface) for writing programs in a variety of programming languages, as well as incredibly useful tools such as syntax highlighting, code completion, automatic code formatting, debugging, version control, and much more.

Visual Studio Code is one of the most popular IDEs, used by millions of developers, and has a huge selection of extensions that provide additional functionality. It is also cross-platform, so you can use it on either Windows, Linux, or Mac. To install the IDE, visit the website, click on the "Download" button, and run the downloaded file. The 64-bit version, which is the one you should be using, will download by default.

On Windows, I recommend using the winget package manager. Then you can install Visual Studio Code by simply writing:

winget install vscode

After you finish installing the app, open it and click on View > Extensions in the menu bar. You can also use the keyboard shortcut Ctrl+Shift+X (note that the keyboard shortcuts I will be using here are for Windows and Linux, but on Mac you usually just need to replace "Ctrl" with "Command"). Yet another option is to click on the Extensions icon, which looks like four squares and should be the bottom one in the bar to the left.

In the Extensions side bar, type "C++" and you should find the C/C++ extension by Microsoft. Simply click on the green "Install" button to install the extension, which will allow you to use the IDE to write C/C++ code. Alternatively, you can use this link to install the extension directly from your browser.

The next step is to install the C/C++ compiler. In this course, we will be using the latest version of GCC (the GNU Compiler Collection). This compiler is cross-platform, so all of the students should be able to use it regardless of their OS or CPU architecture of choice.

On Ubuntu Linux, type the following commands in a terminal window:

GCC_VER=13
sudo apt-get install gcc-$GCC_VER g++-$GCC_VER gdb -y
sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-$GCC_VER 100 --slave /usr/bin/g++ g++ /usr/bin/g++-$GCC_VER

Notes:

  1. The reason we are doing this and not just sudo apt install gcc is that the latter may install an older version of GCC. Instead, we are installing the latest version explicitly and then using update-alternatives so that writing gcc or g++ will execute that specific version.
  2. The above commands assume that the latest version of GCC is the one given by GCC_VER. However, if a newer version is released before I update these notes, you can just change the value of GCC_VER to the latest version.
  3. If the package manager can't find the latest version of GCC, run sudo add-apt-repository ppa:ubuntu-toolchain-r/test -y && sudo apt-get update and then try again.
  4. If you are not running the latest release of Ubuntu, then you may not have access to the latest version of GCC even with the command above. This is one of the annoying side effects of Linux package managers. It is possible to install the latest version manually, but you should probably just upgrade to the latest release of Ubuntu anyway.

Then type gcc -v. You should see gcc version X.Y.Z in the last line, where X is the latest version number as indicated above. If you use another Linux distribution which does not support apt, simply look up how to install the latest version of GCC using the appropriate package manager; there are plenty of good guides online.

On Windows, we will use the WinLibs build of GCC. Go to the WinLibs website and download the latest version of GCC. The version you should choose is Win64, with UCRT runtime, and without LLVM/Clang/LLD/LLDB. Clang is a different compiler which we will not use in this course, but if you do want to check it out, it has its own official Windows release which you should download instead.

No installation is needed. The 7-Zip or Zip archive should include only one folder named mingw64. Extract that folder to a location of your choice, for example C:\Users\<your username>. That is, if your username is barak, then the extracted folder will be C:\Users\barak\mingw64.

After you extract the files, you need to add the path to the binary (executable) files to your system's PATH environment variable. To do this, press the Windows key and start writing "environment". You should see "Edit environment variables for your account" appear in the menu. Click on it and double-click on "Path". Then press "New" and add the path to the binary files; the path should be the bin folder under the mingw64 folder. For example, if you extracted the files to C:\Users\barak\mingw64 then the folder will be C:\Users\barak\mingw64\bin.

To check that GCC works and the PATH environment variable has been set up correctly, open the command prompt (press Win+R and type cmd) and type gcc -v. You should see gcc version X.Y.Z in the last line where X is the latest version number as indicated above.

On macOS, even though the VS Code C/C++ extension page suggests installing the Clang compiler, you should nevertheless install GCC instead, since that is the compiler I and the rest of the class will be using. You may already have a command named gcc on your Mac, but it's most likely just an alias for Clang, which is Apple's preferred compiler. To make sure, type gcc -v and see if it says "GCC" or "Apple Clang" in the output. If it's the former, you already have GCC installed. If it's the latter, you should install GCC using Homebrew. Open a terminal and enter the following:

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
brew update
brew upgrade
brew install gcc
sudo ln -s /usr/local/bin/gcc-13 /usr/local/bin/gcc

The GCC compiler is now installed and accessible via the command /usr/local/bin/gcc. Note that /usr/bin/gcc (without /local) will still be an alias for the Apple Clang compiler. Type /usr/local/bin/gcc -v and verify that the output says gcc version X.Y.Z where X is the latest version number as indicated above. In VS Code, you may need to select this specific compiler manually (instead of the one in /usr/bin) when presented with a list of compilers.

Note: If you tried this and it doesn't work, or it looks too complicated, you may use Clang instead of GCC. Since we are only using standard C, both compilers should be able to compile any program in these notes and (theoretically) produce the exact same results. However, please be aware that there are still some differences; for example, you may get a warning from Clang that you would not have gotten in GCC or vice versa.

To install Clang on macOS, first type clang -v to check if it is perhaps already installed. If you get a valid output, make sure it is the latest version number, which can be found on the official website. If it isn't, or if Clang is not installed, you need to go to the Apple Developer website and download the latest version of the Command Line Tools for Xcode. After installation, check again to verify that Clang is installed properly.

1.2.2 Creating and configuring your first program ^

After installing the compiler, first create a new empty folder in which to save your code, e.g. a folder named CSE 701 in your home directory. Now open Visual Studio Code, click on File > Open Folder... or press Ctrl+K Ctrl+O, and open the folder you created (you can also create the folder directly from the "Open Folder" interface, at least on Windows). You should now see that folder in the Explorer side bar, accessible by clicking the top button on the left, choosing View > Explorer in the menu, or pressing Ctrl+Shift+E.

Now, go to View > Command Palette... or press F1 or Ctrl+Shift+P. Start writing "config" and choose the option C/C++: Edit Configurations (UI) when it appears. Under "Compiler path", choose the path to the gcc binary, and under "IntelliSense mode", choose <your OS>-gcc-x64. For C standard choose c17, and for C++ standard choose c++20.

You will notice that a folder named .vscode has been created in your workspace folder, with a file named c_cpp_properties.json inside it. If you double-click on that file in the Explorer side bar, you will see that the contents correspond to the configuration you chose.

Next, right-click in an empty space of the Explorer side bar and select "New File". Name your file hello.c. The file will be automatically opened in the editor (if not, just double-click on it). Copy and paste the following code into the file:

#include <stdio.h>

int main(void)
{
    printf("Hello, World!\n");
}

Click File > Save or Ctrl+S to save the file. This is the source code for the program, but we still need to tell VS Code how to compile it. Click on Terminal > Configure Default Build Task and choose gcc from the drop-down menu. A file named tasks.json will be created in the .vscode folder, and opened automatically in the editor. This file contains information about how to compile your code.

By default, the args field in tasks.json should look something like this (here I am showing how it looks on Windows):

"args": [
    "-g",
    "${file}",
    "-o",
    "${fileDirname}\\${fileBasenameNoExtension}.exe"
],

Let us add some more compiler arguments (also called flags). First, the arguments -Wall, -Wextra, -Wconversion, -Wsign-conversion, and -Wshadow will instruct the compiler to warn us about many different types of common mistakes. VS Code will then display these warnings right in the editor. This is an incredibly useful feature, which will automatically detect and help prevent potential bugs in our code. Never develop any C or C++ program without turning on all of these warning flags!

In addition, -Wpedantic and -std=c17 will instruct GCC to comply with the ISO C17 standard (the most recent standard, published in 2018), which will ensure that our program is maximally portable and can be compiled on other standards-complying compilers without too much hassle.

Finally, on Windows only, we should add the flag -D__USE_MINGW_ANSI_STDIO=1, which will ensure that printing to the terminal follows the C standard.

The end result (on Windows) should look similar to this:

"args": [
    "-g",
    "${file}",
    "-o",
    "${fileDirname}\\${fileBasenameNoExtension}.exe",
    "-Wall",
    "-Wextra",
    "-Wconversion",
    "-Wsign-conversion",
    "-Wshadow",
    "-Wpedantic",
    "-std=c17",
    "-D__USE_MINGW_ANSI_STDIO=1"
],

I highlighted the new text. Make sure that each argument, both the old ones that were already there and the new ones that you added, is inside quotes, on a line of its own, and with a comma at the end of the line, except for the last one. Note that the first few lines will look different if you're using a different OS, but what's important is that you add the new arguments at the end as shown in the highlighted text.

Save tasks.json and close the tab. With the hello.c tab open in the editor, press F1 to open the Command Pallette, select "C/C++ Add Debug Configuration", and then choose the option "C/C++: gcc.exe build and debug active file" (without the .exe on Linux and macOS). A file named launch.json will be created in the .vscode folder, and opened automatically in the editor. This file contains information about how to launch and debug your program.

For some reason, Visual Studio Code's default launch.json is configured such that your program opens files for input or output in the same folder the compiler is located in. This doesn't really make sense; usually we want to open files in the workspace folder, and we should not mess with any files in the compiler's folder, as that might make the compiler unusable! In launch.json, find the line that starts with "cwd". On my system, it looks like this:

"cwd": "C:/Users/barak/mingw64/bin",

On your computer, it will of course look different. Change it to:

"cwd": "${workspaceFolder}",

The string ${workspaceFolder} will automatically be replaced with the current workspace folder, so this will configure debugging sessions so that your program opens files from that folder.

On the bottom of the page, select the Terminal panel; if you can't see it, click View > Terminal or Ctrl+`. You should see the message "Hello, World!" there. Congratulations, you just compiled, ran, and (sort of) debugged your first C program! To run it again, simply press F5 when you are in the editor tab for hello.c. You should also try changing the program a bit and see what happens when you run it.

1.2.3 The JSON configuration files ^

For your convenience, here are examples for how the three JSON files in the .vscode folder should look like. These examples will work on Windows, as long as the compiler has been added to the PATH environment variable as explained in the previous section. Later in the course we will learn more about how to customize these files.

c_cpp_properties.json:

{
    "configurations": [
        {
            "name": "Win32",
            "includePath": [
                "${workspaceFolder}/**"
            ],
            "defines": [
                "_DEBUG",
                "UNICODE",
                "_UNICODE"
            ],
            "compilerPath": "C:/Users/barak/mingw64/bin/gcc.exe", // Change this to the correct path on your system!
            "intelliSenseMode": "windows-gcc-x64",
            "cStandard": "c17",
            "cppStandard": "c++20"
        }
    ],
    "version": 4
}

tasks.json:

{
    "version": "2.0.0",
    "tasks": [
        {
            "type": "cppbuild",
            "label": "Build for debugging (GCC)",
            "command": "gcc",
            "args": [
                "-fdiagnostics-color=always",
                "-g",
                "${file}",
                "-o",
                "${fileDirname}${pathSeparator}${fileBasenameNoExtension}.exe",
                "-Wall",
                "-Wextra",
                "-Wconversion",
                "-Wsign-conversion",
                "-Wshadow",
                "-Wpedantic",
                "-std=c17",
                "-D__USE_MINGW_ANSI_STDIO=1"
            ],
            "options": {
                "cwd": "${workspaceFolder}"
            },
            "problemMatcher": [
                "$gcc"
            ],
            "group": {
                "kind": "build",
                "isDefault": true
            }
        }
    ]
}

launch.json:

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Build and debug (GCC)",
            "type": "cppdbg",
            "request": "launch",
            "program": "${fileDirname}${pathSeparator}${fileBasenameNoExtension}.exe",
            "args": [],
            "stopAtEntry": false,
            "cwd": "${workspaceFolder}",
            "environment": [],
            "externalConsole": false,
            "MIMode": "gdb",
            "miDebuggerPath": "gdb",
            "setupCommands": [
                {
                    "description": "Enable pretty-printing for gdb",
                    "text": "-enable-pretty-printing",
                    "ignoreFailures": true
                }
            ],
            "preLaunchTask": "Build for debugging (GCC)"
        }
    ]
}

These files should also work on Linux and macOS if you remove the .exe extension from the program name, indicate the correct OS in c_cpp_properties.json under name and intelliSenseMode, and on macOS, specify the compiler path explicitly as /usr/local/bin/gcc and the debugger as /usr/local/bin/gdb.

1.2.4 Learning more about Visual Studio Code ^

To learn more about how to use the IDE, you can use the following resources:

  • The documentation, also accessible within VS Code via Help > Documentation, contains a lot of information.
  • The Tips and Tricks page (Help > Tips and Tricks) contains tips, tricks, and keyboard shortcuts which could save you a lot of time and effort.
  • A basic tutorial is also provided within VS Code itself, under Help > Interactive Playground.
  • Video tutorials are available under Help > Introductory Videos.
  • Under Help > Keyboard Shortcuts Reference, you can find a printable PDF file with some important keyboard shortcuts.
  • A full list of all available keyboard shortcuts can be viewed by going to View > Command Palette... (or pressing F1 or Ctrl+Shift+P) and choosing "Preferences: Open Keyboard Shortcuts". This list is also accessible via the shortcut Ctrl+K Ctrl+S.

2 Fundamentals of C programming ^

This chapter and the next one will serve two purposes: to teach you the basics of C, and to provide an easily-accessible reference for the rest of the course. I have decided to structure it in this way because I expect most students to already have some programming experience, if not in C then in some other programming language, and thus be familiar with concepts such as variables, selection and iteration statements, and so on.

Therefore, instead of giving just a short explanation of each concept, I present each concept in full detail, focusing on the precise syntax and common mistakes unique to C, as well as the underlying structure, such as how variables are stored in memory.

Warning: You can't learn to code just by reading - you have to do some actual coding! During this course, you will be required to submit several programming projects. In addition, you should also take the initiative and write some small programs on your own to practice what you learned in each lecture. Finally, you should run the examples provided throughout these notes on your own system and try to modify them in various different ways to see what happens.

2.1 Basic C syntax and analysis of the "Hello, World!" program

2.1.1 Line-by-line analysis

Now that we have installed our IDE and compiler, and ran our first program, let us go over the lines in the program and explain what they do. The first line is:

#include <stdio.h>

This line instructs the C compiler to read the header file stdio.h, which contains definitions used in the C Standard Input and Output Library, and include those definitions in our program. We need to do this in order to print text to the terminal using the printf function, which is included in this library. Note that stdio.h does not contain the function printf itself, only its definition.

The second line is:

int main(void)

In C, functions are declared by first stating what kind of value the function returns as output (in this case: int), then the name of the function (in this case: main), and finally, in parentheses, any parameters that the function may take as input (in this case: void).

  • The main function is a special function which must be included in all C programs, and it contains the code that is executed when the program starts - i.e., the main part of the program.
  • int means integer, and therefore the main function - and thus, the program itself - returns an integer value as output, with 0 indicating that there were no errors. This is the standard way in which all C programs must be defined, and a value of 0 is automatically returned if the program finished executing successfully. Generally, you don't have to worry about the returned value unless you call your program from another program or script which needs to make use of this value.
  • Inside the parentheses, void means that the function main - and thus, the program itself - does not take any values as input. Often, programs get their input from the command line; we will see later how to declare main in this case. Programs which employ a GUI (Graphical User Interface) can instead get their input using menus, buttons, and other interactive visual components.

The third line is just a curly bracket {. Curly brackets (or braces) indicate that a code block (or compound statement) is starting. Any code that is entered between the opening bracket { and the closing bracket } will belong to that code block - in this case, the code block defining the main function and thus the main part of the program.

The fourth line is:

    printf("Hello, World!\n");

Let us deconstruct this line carefully:

  • printf is a function to print (f)ormatted text to the terminal. Here, we did not use the "formatted" part, we just printed a single string of text. Soon, we will see how to output other types of values with printf.
  • The parentheses ( and ) contain the arguments of the function printf. In this case, there is just one argument: the string to be printed.
  • The quotation marks " contain a string. A string is simply a sequence of characters enclosed within quotation marks. In this case, the string is "Hello, World!\n".
  • The backslash \ denotes a special character, which we usually cannot enter as is in the code editor. The \ must always be followed by a letter indicating which special character should be used. In this case, \n means (n)ew line. This simply ensures that if we print other strings later, they will be printed in a new line in the terminal, instead of on the same line.
  • The semicolon ; indicates that a command has ended.

Finally, the fifth line is simply the closing bracket }, indicating that the definition of the function main is done. When the execution of the program reaches this line, the program exits.

2.1.2 Comments and semicolons ^

Warning: A common mistake made by beginner C programmers is to forget the semicolon ; at the end of the line.

Copy and paste this code to the IDE:

#include <stdio.h>

int main(void)
{
    printf("Hello, ");
    printf("World!\n") // This line is missing a semicolon!
}

First of all, notice that in the line before last, we have two slashes //. Any text that appears after two slashes (and until the end of the line) is not executed, and is used to provide comments on the code. In this case, I added the comment to let you know that this line is missing a semicolon. To write comments that span multiple lines, we can use /* to indicate the beginning of the comment and */ to indicate the end, for example:

/* This is a comment that
    spans multiple lines */

Now you can familiarize yourself with an indispensible feature of the IDE: static code analysis. A few seconds after you paste the new code, the IDE will already tell you that there is a problem with it, even though you have not tried to compile it yet. If this does not happen automatically, press Ctrl+S to save the file, which will trigger the analysis.

You will see the closing bracket } highlighted with a squiggly red underline (just like a spellchecker). If you hover on it with the mouse, you will see the error message expected a ';'. Note that this message is followed by the text "C/C++", indicating that it came from the C/C++ extension itself via static code analysis, rather than from the compiler.

Alternatively, on the bottom of the page, click on the Problems tab, and you will see the error there, along with the text "C/C++" again, as well as the pair of numbers [7, 1] which indicate the row and column number where the error was found. If you can't see the Problems tab, you can enable it by choosing View > Problems from the menu or pressing Ctrl+Shift+M.

If you press F5 to run the program without fixing the problem, it will not compile. You might expect that the string "Hello, " will be printed, because the problem is only with the next line; however, this is not the case, because C is a compiled language. The compiler doesn't run each line individually, it compiles the program as a whole, so if there are any errors anywhere in the code, the program simply won't compile.

After you press F5 and the code runs through the compiler, another error message will appear in the Problems tab: expected ';' before '}' token. This error will also appear as a squiggly line where the semicolon should have been. Note that this message is followed by the text "gcc", indicating that it came from the GCC compiler. So you now have two error messages, one from VS Code's C/C++ extension and one from the GCC compiler. The compiler can also find many other errors that VS Code cannot, since it is much more thorough.

If you add the missing ;, the problem labeled "C/C++" will disappear from the Problems tab after a few seconds, since VS Code will no longer detect an error. Now you can press F5 to compile the code again. The problem labeled "gcc" will then disappear as well, and the message "Hello, World!" will appear in the terminal, as expected.

It is worth noting that the message "Hello, World!" is printed in one line, even though we have two instances of printf. This is because the first one, printf("Hello, "), does not contain a newline character \n, and therefore any additional output will appear in the same line.

2.1.3 Indentation and code formatting ^

The C compiler mostly doesn't care about spaces or line breaks. For example, the "Hello, World!" program could also be written without any line breaks (except in the first line), as follows:

#include <stdio.h>
int main(void) { printf("Hello, "); printf("World!\n"); }

or with lots of spaces and line breaks, like so (please never format your code like this!):

#include <stdio.h>

int
    main(void)        {

            printf
                (    "Hello, "    );

            printf
                (    "World!\n"    );

                 }

The special command #include <stdio.h>, known as a preprocessor directive (we will talk about that later), has to be written in exactly one line, since it is not part of the actual code, but rather an instruction meant for the compiler. However, most other commands can be formatted as you wish. Generally, adding line breaks in some places - such as before and after the curly brackets, and after semicolons - significantly improves the clarity and readability of your program.

Every C developer has their own style preferences. For example, sometimes you will see the opening bracket on the same line as the function instead of in a new line, i.e. int main(void) {. This is a matter of personal taste; I personally feel that having the opening and closing bracket on the same column makes it easier to see where each code block begins and ends, but other people may have different opinions. (Note that by default, Visual Studio Code formats code with the { in a new line.)

Luckily, since we are using an IDE, we don't need to worry about formatting our code manually - the IDE will do it for us. Paste either of the two code snippets above into a Visual Studio Code C file, and then right-click anywhere in the editor and choose "Format Document" (or press Shift+Alt+F). The IDE will format your code, and it should look exactly like the code I wrote in the beginning of the previous section.

In fact, VS Code can format your code automatically for you on-the-fly, without having to tell it to do it. To enable this feature (which I highly recommend), go to File > Preferences > Settings (or simply press Ctrl+,) and then choose Text Editor > Formatting from the menu on the left. Enable the checkboxes for Format On Paste, Format On Save, and Format On Type. Now your code will be formatted for you automatically whenever you do any of these actions.

Next, choose Extensions > C/C++ from the menu on the left of the Settings tab, and make sure that C_Cpp.clang_format_fallbackStyle is set to "Visual Studio", since this is the style I will be using in this course (but feel free to try out other styles if you like).

2.2 Variables and integer data types ^

Now that we understand some basic facts about writing C programs, let us consider one of the most important entities in C (or any program language): variables. A variable has a value which can be changed (or varied), hence the name "variable". There are many types of data that can be stored inside variables. For now, we will only consider variables that have integer values.

In C, there are many different data types used for integer variables. The basic types are char and int, and these are further modified by one or more of the modifiers signed, unsigned, short, and long. Newer versions also added another useful type, bool. This variety of integer types is one of the most confusing parts of C for beginners. Let us discuss these data types now.

2.2.1 Integers and bit width

Warning: It is very important that you read and understand this section and the next one. Using the wrong integer data type in C is one of the most common mistake made by beginner C programmers, and can lead to serious bugs and data corruption.

The data type int stands for integer, and it is used to store integers within various different ranges - depending how many bits are allocated for it in memory. An int can either be signed or unsigned. Let N be the number of bits used to store the integer. If it is unsigned, then the range of values is simply from 0 to 2N-1. For example, for N=3 we have:

  • 0 = 000,
  • 1 = 001,
  • 2 = 010,
  • 3 = 011,
  • 4 = 100,
  • 5 = 101,
  • 6 = 110,
  • 7 = 111 = 2N-1.

If the integer is signed, then it should allow for negative values as well. Unfortunately, the computer only knows how to store data as sequences of bits - 0s and 1s - and nothing else. It cannot store a plus or minus sign explicitly in memory. Therefore the sign, whether plus or minus, must also be encoded using bits.

In C, signed numbers are represented using a system called two's complement. Given a number in binary, its negative is represented by inverting all the bits and then adding one to the result. For example, for N=3 we have the positive numbers:

  • 0 = 000,
  • 1 = 001,
  • 2 = 010,
  • 3 = 011 = 2N-1-1,

and the negative numbers:

  • -1 (invert 001, get 110, then add 1) = 111,
  • -2 (invert 010, get 101, then add 1) = 110,
  • -3 (invert 011, get 100, then add 1) = 101.

Notice that we haven't used the bit sequence 100, so we might as well use it to store an additional number. (3 bits allow for 23 = 8 combinations in total, and we only used 7.) Also notice that all the positive numbers have 0 as their leftmost bit, and all the negative numbers have 1 as their leftmost bit, so 100 should be a negative number. Therefore, it is natural to define:

  • -4 = 100 = -2N-1

In conclusion, we see that signed integers with N bits can take values from -2N-1 to 2N-1-1.

In C, unlike in some higher-level languages, each variable must have a fixed data type. If we declare a variable as an integer with 8 bits of storage, for example, then we cannot assign to it an integer that requires more than 8 bits of storage later on without losing data, and we definitely cannot assign to it a string or some other data type.

This is because, as I explained above, C provides direct, low-level access to the computer's hardware - including its memory. Therefore, sufficient space must be explicitly allocated in memory for each variable that you declare, and this means the compiler must know how much space the variable is going to take, which depends on what kind of data you intend to store in it.

In some higher-level languages like Python and Mathematica, an integer can have any value, without limit, and reallocate memory on-the-fly when the integer becomes larger and requires more storage space. This is called arbitrary-precision or infinite-precision arithmetic.

However, arbitrary-precision operations are significantly slower, since they cannot use the CPU's built-in integer operations, which are only available for integers with a fixed and bounded number of bits, as captured in C's integer data types. On the other hand, in C, if you are not careful, you might exceed the range that you allocated for your integers, causing bugs and loss of data; this is called integer overflow, and we will demonstrate it below.

2.2.2 The integer types ^

Modern 64-bit C compilers support integer sizes from 8 to 64 bits. The names of each size of integer vary between different compilers, which is a source of confusion. Copy and paste the following code to the IDE and run it to find out what the definitions mean on your system:

#include <limits.h>
#include <stdio.h>

int main(void)
{
    printf("%s contains %zu bits.\n", "A char", 8 * sizeof(char));
    printf("    %s takes values from %d to %d.\n", "A signed char", SCHAR_MIN, SCHAR_MAX);
    printf("    %s takes values from %u to %u.\n", "An unsigned char", 0, UCHAR_MAX);
    printf("\n");

    printf("%s contains %zu bits.\n", "A short", 8 * sizeof(short));
    printf("    %s takes values from %d to %d.\n", "A signed short", SHRT_MIN, SHRT_MAX);
    printf("    %s takes values from %u to %u.\n", "An unsigned short", 0, USHRT_MAX);
    printf("\n");

    printf("%s contains %zu bits.\n", "A long", 8 * sizeof(long));
    printf("    %s takes values from %ld to %ld.\n", "A signed long", LONG_MIN, LONG_MAX);
    printf("    %s takes values from %u to %lu.\n", "An unsigned long", 0, ULONG_MAX);
    printf("\n");

    printf("%s contains %zu bits.\n", "A long long", 8 * sizeof(long long));
    printf("    %s takes values from %lld to %lld.\n", "A signed long long", LLONG_MIN, LLONG_MAX);
    printf("    %s takes values from %u to %llu.\n", "An unsigned long long", 0, ULLONG_MAX);
    printf("\n");

    printf("%s contains %zu bits.\n", "An int", 8 * sizeof(int));
    printf("    %s takes values from %d to %d.\n", "A signed int", INT_MIN, INT_MAX);
    printf("    %s takes values from %u to %u.\n", "An unsigned int", 0, UINT_MAX);
    printf("\n");
}

We will first discuss the output, and then the new aspects of the code itself. On my computer, which is running Windows 11 with the 64-bit GCC compiler (Mingw-w64), the output is as follows:

A char contains 8 bits.
    A signed char takes values from -128 to 127.
    An unsigned char takes values from 0 to 255.

A short contains 16 bits.
    A signed short takes values from -32768 to 32767.
    An unsigned short takes values from 0 to 65535.

A long contains 32 bits.
    A signed long takes values from -2147483648 to 2147483647.
    An unsigned long takes values from 0 to 4294967295.

A long long contains 64 bits.
    A signed long long takes values from -9223372036854775808 to 9223372036854775807.
    An unsigned long long takes values from 0 to 18446744073709551615.

An int contains 32 bits.
    A signed int takes values from -2147483648 to 2147483647.
    An unsigned int takes values from 0 to 4294967295.

As a rule, a char always contains exactly 8 bits. In some old computers, a byte could contain a number of bits other than 8, in which case this number would be given by the constant CHAR_BIT, but this is irrelevant for modern computers.

On the other hand, the number of bits contained in an int depends on the platform you are using (Windows, Linux, etc.), and can range from 16 to 64, with short and long modifying that number, again on a platform-dependent basis. Note that int is the actual data type, while short, long, and long long are just shorthands for short int, long int, and long long int, but this distinction doesn't really matter.

We see that on 64-bit Windows, the sizes are:

  • short has 16 bits.
  • long and int have 32 bits.
  • long long has 64 bits.

This data model is called LLP64, which is a mnemonic for "(only) long long and pointers are 64 bits". We will learn about pointers later in the course.

However, on 64-bit Linux-based operating systems (including macOS), the sizes are instead:

  • short has 16 bits.
  • int has 32 bits.
  • long and long long have 64 bits.

This data model is called LP64, which is a mnemonic for "long and pointers are 64 bits" (and since long long cannot be smaller than long, it is implied that it's also 64 bits). Both standard have advantages and disadvantages, which we will not cover here.

The C standard itself does not enforce any specific sizes for the integer types; it merely requires that, regardless of which platform you are using, the following minimum sizes must be satisfied:

  • short and int must have at least 16 bits.
  • long must have at least 32 bits.
  • long long must have at least 64 bits.

Unfortunately, this confusion regarding the number of bits in each integer type can result in programs that only work on one platform and not another. Luckily, this issue is solved using fixed-width integer types, which we will present below.

Finally, note that all integer data types are signed by default. So int actually means signed int, long means signed long, and so on. However, if you have a variable that you know will never be negative (e.g. the size of an array), then you should define it as an unsigned integer. There are two benefits to this:

  1. unsigned integers can represent numbers twice as large. If you're not using the negative range, then you're just wasting 1 bit of memory.
  2. More importantly, declaring a variable as unsigned indicates to the human reading your code that the variable is expected to always be non-negative, which adds to the readability of your code.
Warning: Only use unsigned integers if you are absolutely sure their values will never be negative. As we will see below, assigning a negative number to an unsigned integer leads to unexpected behavior.

2.2.3 Line-by-line analysis and printf format placeholders ^

Now, as promised, let us go over the new aspects of the code we used above. The first line is:

#include <limits.h>

This simply instructs the C compiler to read the file limits.h, which contains the definitions of the limits of the various integer sizes, and include those definitions in our program - just like we do for stdio.h in the second line.

The main function consists of many printf statements. The first parameter for printf is a string, as in the "Hello, World!" program. However, here the strings contain various format placeholders, which take the form of a percent sign % followed by one or more letters.

When printf encounters a format placeholder, it takes the next parameter passed to the function and prints it out in the specified format. So the first % corresponds to the first parameter after the string (the second parameter overall), the second % corresponds to the second parameter after the string (the third parameter overall), and so on.

Some common format placeholders are as follows:

  • %s corresponds to a string. Therefore, we used it in place of strings such as "A char", "A signed char", "An unsigned char" and so on.
  • %d (or equivalently %i) corresponds to a signed int. Therefore, we used it in place of signed integers that we knew had at most the same number of bits as an int, namely char, short, and int.
    • Here, the d means "decimal". One can optionally use %x or %X to print the number as hexadecimal (base 16), with lowercase or uppercase letters respectively. For example, printf("%X", 255); will print "FF".
  • %u corresponds to an unsigned int. Therefore, we used it in place of unsigned integers that we knew had at most the same number of bits as an int.
  • Adding the letter l in front of d or u indicates that the placeholder corresponds to a long. Thus, %ld is a signed long and %lu is an unsigned long.
  • Adding the letters ll in front of d or u indicates that the placeholder corresponds to a long long. Thus, %lld is a signed long long and %llu is an unsigned long long.
  • The placeholder %zu is a special placeholder that gets replaced with the type of the return value of sizeof (see below). Usually on 64-bit systems this would be long long, but it might be different depending on the specific system. Since we don't know in advance what this type will be, we use %zu which is guaranteed to always be the correct type.

In the first line of the main function we have

    printf("%s contains %zu bits.\n", "A char", 8 * sizeof(char));

Here, the placeholder %s gets replaced with the string "A char", which is the second parameter passed to printf. The placeholder %zu gets replaced with the unsigned long long number 8 * sizeof(char), which is the third parameter passed to printf. But what is this number?

sizeof is an operator which returns the size in bytes of a data type or a variable. We then multiply the result by 8 using the multiplication operator * to get the number of bits. So sizeof(char) returns the value 1, which we multiply by 8 to get 8 bits. Note that on a 64-bit system this number will be an unsigned long long, hence %zu is equivalent to %llu. If you try replacing %zu with %d, for example, you will get a warning from the compiler.

In the second line of the main function we have

    printf("    %s takes values from %d to %d.\n", "A signed char", SCHAR_MIN, SCHAR_MAX);

Here, the placeholder %s gets replaced with the string "A signed char". The placeholder %d gets replaced with the integer SCHAR_MIN, which is defined in limits.h to be -128, the minimum value of a signed char. Finally, the last placeholder, which is also %d, gets replaced with the integer SCHAR_MAX, which is defined in limits.h to be 127, the maximum value of a signed char. The same principles apply to the rest of the printf statements in our program.

2.2.4 Width modifiers ^

printf allows you to specify that the integer will occupy a fixed width on the screen, by adding the desired number of characters after the %. This is used for more aesthetically pleasing output by aligning different numbers together - although if you really want your program's output to look nice, you should probably implement a GUI instead!

By default, the number is aligned to the right and padded with spaces on the left. If you add - after the %, the number will be aligned to the left instead, and if you add 0 after the %, the number will be padded with zeros instead. This is illustrated in the following program:

#include <stdio.h>

int main(void)
{
    int x = 123456;
    printf("| %d |\n", x);
    printf("| %10d |\n", x);
    printf("| %-10d |\n", x);
    printf("| %010d |\n", x);
}

The output is:

| 123456 |
|     123456 |
| 123456     |
| 0000123456 |

Note how the first line only prints out the 6 digits of the number without any padding, and is thus misaligned with the other lines, which we specified to have a width of 10 characters. Also note how the third line starts like the first line, but then pads spaces to the right in order to have a total of 10 characters.

2.2.5 Declaring and naming variables ^

We spent a long time discussing the possible types and ranges of integers in C and how to print them. Now it's time to do some actual programming with integers! As we stressed above, every variable in C must be properly declared so that the compiler knows how much memory to allocate for it. To declare a variable of a specific type, we write the type followed by the name of the variable.

For example, to declare an integer named x, we write:

int x;

Remember, this will be by default a signed 32-bit integer on most systems.

If we want to declare more than one variable of the same type, for example two integers x and y, we can do it in one line, separating the variable names by commas:

int x, y;

However, if we want to add comments explaining what each variable does, which would make our code clearer to the reader, then we should declare the variables separately, for example:

int x; // The horizontal axis
int y; // The vertical axis

Variable names in C can contain lowercase and uppercase English letters, digits, and underscores (_), but no other symbols. In addition, the name must start with a letter.

Furthermore, there are 34 reserved keywords that are used by the language and therefore cannot be used as variables: auto, break, case, char, const, continue, default, do, double, else, enum, extern, float, for, goto, if, inline, int, long, register, restrict, return, short, signed, sizeof, static, struct, switch, typedef, union, unsigned, void, volatile, and while.

Warning: Variable names can also start with an underscore, but such names are often used by libraries and by additional reserved keywords we did not mention here (added in later versions of the C standard), and so should be avoided by the user.

Some examples of valid variable names are var, Var, var1, and var_1. Some examples of invalid names are:

  • 1var (begins with a digit),
  • var-1 (contains the symbol - which is not a letter, digit, or underscore; will be interpreted as subtracting 1 from var),
  • long (reserved keyword).

Also note that variable names in C are case-sensitive, so var and Var are not the same variable. Keywords are case-sensitive as well; for example, long is a data type, but Long is unused and could therefore technically be used as a variable name, although this is very strongly discouraged.

Warning: It is extremely important to give variables informative names. If you have 26 different variables in your program and you just name them a, b, ..., z, this pretty much guarantees that no one will be able to easily understand your code - including you in the future! Remember that variable names exist solely for human readers; if you change n to number_of_events, the resulting machine code executed by the CPU will look exactly the same, but the C source code will be much more readable to humans.

The so-called classic C naming convention uses lowercase words separated by underscore to name variables, e.g. my_variable_name, and uppercase words separated by underscore to name constants, e.g. MY_CONSTANT_NAME. This is the convention I will use here.

Sometimes you will also see camel case, where instead of separating words by an underscore, each word starts with an uppercase letter. This has two variations: either the first word (and only the first word) starts with a lowercase letter, e.g. myVariableName, or all words start with an uppercase letter, e.g. MyVariableName.

Warning: In scientific programming, variables often contain values of measurements or physical constants. In this case, it is highly recommended to include the units of measurement in the variable's name, to prevent errors due to using the wrong units. For example, if instead of time you use time_seconds, this will prevent you, or someone you are collaborating with, from later assuming that the variable is actually in milliseconds, which will lead to serious errors.

2.2.6 Initializing variables ^

A variable can be assigned a value either when it is declared:

int x = 0;

or later:

int x;
x = 0;

We can also assign values when we declare more than one variable:

int x = 0, y = 1;

What happens if we never assign a value? To answer that, let us run the following program:

#include <stdio.h>

int main(void)
{
    long long a, b;
    printf("%lld, %lld\n", a, b);
}

On my computer, the output is:

16, 12588272

(In fact, for some reason the value of a is always 16, but the value of b changes every time.) Where did these numbers come from?

When we declared the variables, unused space was automatically allocated in memory to store them - 64 bits of memory for each, since that is the number of bits in a long long. However, nothing was actually written to that space, as we did not explicitly give the variables any specific initial values, which is called initializing the variables. The numbers printed by the program are simply the numbers that happened to be stored in that particular space in memory before the program was executed.

This is another example of C not "holding your hand" by doing things automatically for you, unlike most higher-level languages. The C compiler doesn't automatically initialize any variables to some default value; instead, you must always initialize them by hand.

Warning: Always initialize variables as soon as they are declared by assigning a value to them. Avoid uninitialized declarations such as int x, and instead only use initialized declarations such as int x = 0.

In most cases, initializing the variables as soon as they are declared does not affect performance, since they have to be initialized sometime, if not now then later. However, if the initial value of a variable is not known when we write the code - for example, if the value is taken from a file or from user input - then we could potentially save a bit of CPU time by declaring the variable uninitialized, and only assigning the initial value later, when we know what it should be.

Nevertheless, even in cases like this, it would still be safer to initialize the variable at declaration time - for example, to a default value that the variable should have in case the input operation fails. Initializing a variable only takes a small fraction of a second, so unless the operation is performed billions of times, there will not be any noticeable performance penalty.

If you added the -Wextra compiler argument, as I explained above, then after you press F5, the Problems tab (Ctrl+Shift+M) should show you a warning: 'a' is used uninitialized in this function. The program still runs, since unlike errors, warnings do not halt the compilation; therefore, paying attention to compiler warnings in the Problems tab is extremely important and can help you prevent potential bugs in your code.

Warning: It is good programming practice to declare and initialize all of the variables used by a function, including main, at the very beginning of the function's code block. This ensures that the names, types, and initial values of every single variable used in the function are immediately known to the reader without having to read through the whole function. In addition, it helps the programmer to avoid declaring the same variable twice in two different parts of the function by mistake.

To illustrate the warning, compare this (not properly organized) code:

#include <stdio.h>

int main(void)
{
    printf("Hello, World!\n");

    // do some stuff...

    short a = 5;
    printf("a = %d\n", a);

    // do some more stuff...

    unsigned long b;

    // do some more stuff...

    b = 7;
    printf("b = %lu\n", b);
}

With this (properly organized) code:

#include <stdio.h>

int main(void)
{
    short a = 5;
    unsigned long b = 7;

    printf("Hello, World!\n");

    // do some stuff...

    printf("a = %d\n", a);

    // do some more stuff...

    printf("b = %lu\n", b);
}

Both programs do the same thing, but in the second program it is immediately clear to the reader that two variables, a and b, will be used in the program, and that they are initialized to 5 and 7 respectively. In the first program, the reader must read the whole program to get the same information. Furthermore, there is also a risk that b will be used uninitialized - especially if someone made changes to the code that resulted in accidentally erasing the line containing the initialization b = 7.

2.2.7 Constant variables ^

Sometimes, we want to introduce a variable that will be used throughout the program, but its value will never change. In these cases, the variable can be declared as a constant using the keyword const. This has many subtle uses, that we won't get into here; however, its most basic and common use is to simply ensure that the programmer does not accidentally modify the variable later. Here is an example:

#include <stdio.h>

int main(void)
{
    const int answer = 42;
    answer = 41;
}

If you enter that code, before you even run it, the IDE will display the error expression must be a modifiable lvalue in the Problems tab on the bottom of the page. (If you can't see the Problems tab, you can enable it by choosing View > Problems from the menu, or pressing Ctrl+Shift+M.) Therefore, the variable answer is protected from accidental modification.

In these lecture notes, I will make sure to always define constant variables as const. This is good programming practice, as it guarantees that constant variables are never accidentally modified, and thus prevents bugs. I will also make sure to do the same for any function arguments that are not modified by the function (see below).

Of course, in the simple examples in these lecture notes, this isn't really necessary, but it's good to develop a habit of using const wherever possible. You should make sure to do the same when you write your own code!

2.2.8 Arithmetic operators on integers ^

Once we declare variables, we can operate on them and combine them together in various ways. The following program demonstrates the most common arithmetic operators:

#include <stdio.h>

int main(void)
{
    const int x = 5, y = 2;
    printf("Addition: %d + %d = %d\n", x, y, x + y);         // Addition: 5 + 2 = 7
    printf("Subtraction: %d - %d = %d\n", x, y, x - y);      // Subtraction: 5 - 2 = 3
    printf("Multiplication: %d * %d = %d\n", x, y, x * y);   // Multiplication: 5 * 2 = 10
    printf("Integer division: %d / %d = %d\n", x, y, x / y); // Integer division: 5 / 2 = 2
    printf("Modulo: %d %% %d = %d\n", x, y, x % y);          // Modulo: 5 % 2 = 1
}

Note that integer division rounds towards zero, so 5 / 2 = 2.5 is rounded to 2 and -5 / 2 = -2.5 is rounded to -2. The modulo (or remainder) is defined such that ((x / y) * y) + (x % y) is equal to x.

Also note that in the code above, to print out the character % in the last line, we had to enter it twice in the printf string. This is because a % indicates a format placeholder, so in order to print it as is, we use %%.

This program only printed the results, but did not reassign any value to x and y. Any variables can be reassigned a new value, simply by using the = operator, for example:

x = y + 1;

C also employs some useful shorthands for reassigning values, in cases where the new value depends on the old value:

  • x += y is equivalent to x = x + y.
  • x -= y is equivalent to x = x - y.
  • x *= y is equivalent to x = x * y.
  • x /= y is equivalent to x = x / y.
  • x %= y is equivalent to x = x % y.

Finally, if we just want to increase or decrease the value of an integer by 1, we can use the following shorthands:

  • x++ is equivalent to x = x + 1.
  • x-- is equivalent to x = x - 1.

2.2.9 Integer overflows ^

Warning: As we stressed above, it is extremely important that integers are declared with the proper range for the values they might take. Otherwise, this may lead to integer overflow.

To illustrate integer overflows, let's run the following program:

#include <stdio.h>

int main(void)
{
    unsigned char x = 255;
    x++;
    printf("255 + 1 = %d\n", x);
    unsigned char y = 0;
    y--;
    printf("0 - 1 = %d\n", y);
}

If you run the program, you will see the following output:

255 + 1 = 0
0 - 1 = 255

What happened? Recall that an unsigned char (8 bits) takes values from 0 to 255. In binary, we have

  • 00000000 binary = 0 decimal,
  • 11111111 binary = 255 decimal.

When we added 1 to 255, the result has 9 bits: 100000000. However, x, which is an unsigned char, can only store numbers with at most 8 bits. Therefore, the 9th bit gets truncated, and we are left with 00000000, which is just zero.

A similar thing happened when we subtracted 1 from 0. Remember that in the two's complement representation of negative numbers, the negative of a number is obtained by inverting all the bits and then adding one to the result. So the negative of 1, which is 00000001 in binary, is 11111111. However, since y is an unsigned char, the binary number 11111111 is interpreted as usual to be 255 in decimal.

More generally, when the result of an operation is stored in an integer with N bits, the result will be taken modulo 2N. This is called integer overflow, and when you program in C (unlike in some higher-level languages like Python), you have to be very careful to avoid it, because otherwise your program will not work correctly, since it will think that, for example, 256 is actually 0.

It is possible to check if an overflow will occur before doing the calculation. Suppose we want to add two N-bit integers, x and y. An overflow will occur if x + y > 2N-1, so we could first check if x > 2N-1-y, and if so, issue an error to the user or deal with the problem in another way. Similarly, for subtraction, an overflow will occur if x - y < 0, so we could first check if x < y. However, as always, there is a tradeoff between speed and safety; if we perform a check every time we do an arithmetic operation, our code will take more time to run.

2.2.10 Fixed-width integer types ^

Confused by the variety of integer data types and the fact that they have different sizes on each platform? You're not alone. Because of this confusion, the C99 standard added fixed-width integer types, which guarantee that an integer will have precisely the number of bits that you want it to have, regardless of platform. The types are:

  • int8_t, int16_t, int32_t, int64_t for signed integers with width of exactly 8, 16, 32 and 64 bits respectively.
  • uint8_t, uint16_t, uint32_t, uint64_t for unsigned integers with width of exactly 8, 16, 32 and 64 bits respectively.

To access them, we must include the header file stdint.h. Furthermore, there are also corresponding format placeholders for printf:

  • PRId8, PRId16, PRId32, PRId64 for signed integers with width of exactly 8, 16, 32 and 64 bits respectively.
  • PRIu8, PRIu16, PRIu32, PRIu64 for unsigned integers with width of exactly 8, 16, 32 and 64 bits respectively.

These are defined in the header file inttypes.h. To use them, replace e.g. "%d\n" with "%"PRId32"\n".

The following code is similar to the one we used above, but this time, the output will be exactly the same on any platform:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

int main(void)
{
    printf("  int8_t contains %2zu bits and takes values from %" PRId8 " to %" PRId8 ".\n", 8 * sizeof(int8_t), INT8_MIN, INT8_MAX);
    printf(" uint8_t contains %2zu bits and takes values from 0 to %" PRIu8 ".\n", 8 * sizeof(uint8_t), UINT8_MAX);

    printf(" int16_t contains %2zu bits and takes values from %" PRId16 " to %" PRId16 ".\n", 8 * sizeof(int16_t), INT16_MIN, INT16_MAX);
    printf("uint16_t contains %2zu bits and takes values from 0 to %" PRIu16 ".\n", 8 * sizeof(uint16_t), UINT16_MAX);

    printf(" int32_t contains %2zu bits and takes values from %" PRId32 " to %" PRId32 ".\n", 8 * sizeof(int32_t), INT32_MIN, INT32_MAX);
    printf("uint32_t contains %2zu bits and takes values from 0 to %" PRIu32 ".\n", 8 * sizeof(uint32_t), UINT32_MAX);

    printf(" int64_t contains %2zu bits and takes values from %" PRId64 " to %" PRId64 ".\n", 8 * sizeof(int64_t), INT64_MIN, INT64_MAX);
    printf("uint64_t contains %2zu bits and takes values from 0 to %" PRIu64 ".\n", 8 * sizeof(uint64_t), UINT64_MAX);
}

The output (on any platform) should be:

  int8_t contains  8 bits and takes values from -128 to 127.
 uint8_t contains  8 bits and takes values from 0 to 255.
 int16_t contains 16 bits and takes values from -32768 to 32767.
uint16_t contains 16 bits and takes values from 0 to 65535.
 int32_t contains 32 bits and takes values from -2147483648 to 2147483647.
uint32_t contains 32 bits and takes values from 0 to 4294967295.
 int64_t contains 64 bits and takes values from -9223372036854775808 to 9223372036854775807.
uint64_t contains 64 bits and takes values from 0 to 18446744073709551615.

Note that here we used the format placeholder %zu to print out the size of the integer types in bits. %zu is intended specifically to print out the result of the sizeof operator, which has the special type size_t. The purpose of size_t is to always be able to store the maximum theoretical size, in bytes, of any variable or array. Thus, on 64-bit systems, size_t is the same as uint64_t. Other than storing the result of sizeof, the type size_t can be used, for example, to count the elements of an array.

Nowadays there's no reason to write 32-bit programs (certainly not for scientific computing!), so there's never any reason to assume size_t will have only 32 bits. Thus, I usually prefer to use uint64_t explicitly instead of size_t, even if just for readability purposes, to let the human reader know that I expect this to be a 64-bit integer. I will take this approach in these notes as well.

From now on, I will always use these fixed-width integer types in these lecture notes, and avoid using any ambiguous integer types (with the exception of the main function itself, which must always return int). You should make sure to do the same in your course projects, and in any other programs you write in C and C++!

2.3 Selection statements ^

2.3.1 If statements and comparison operators

The if statement is used to execute a code based on some condition. The basic syntax is:

if (condition)
    statement;

This checks condition and, if it is satisfied, executes statement. It is not necessary to write statement on a separate line, although I think it looks clearer; you could also write if (condition) statement;. Furthermore, if you want to execute several different statements if the condition is satisfied, you can enclose them in curly brackets:

if (condition)
{
    statement1;
    statement2;
    // etc...
}

For example:

if (x > 0)
    x++;

This increases x by 1, but only if its value is positive. More generally, the following comparison operators are available:

  • x == y: "x is equal to y".
  • x != y: "x is not equal to y".
  • x < y: "x is less than y".
  • x > y: "x is greater than y".
  • x <= y: "x is less than or equal to y".
  • x >= y: "x is greater than or equal to y".
Warning: Notice that comparison == has two equal signs, while assignment = only has one. It is a very common mistake in C to write something like if (x = y); this does not compare x to y, it assigns to x the value of y, resulting in unexpected consequences!

In addition, logical operators can be used to modify and combine conditions:

  • !A: "not A". For example, instead of if (x <= y) we can equivalently write if (!(x > y)); if x is not greater than y, then it must be either less than or equal to y.
  • A && B: "A and B", meaning that A and B must both be true. For example, (-1 <= x) && (x <= 1) checks if x is in the interval [-1,1], that is, between -1 and 1 inclusive; note that -1 <= x <= 1 is not possible in the C syntax.
  • A || B: "A or B", meaning that at least one of A and B must be true. For example, (x < -1) || (1 < x) checks if x is outside the interval [-1,1].

Optionally, one may add an else statement, which will be executed if condition is not satisfied:

if (condition)
    statement;
else
    another_statement;

Or with brackets:

if (condition)
{
    statement1;
    statement2;
    // etc...
}
else
{
    another_statement1;
    another_statement2;
    // etc...
}

For example:

#include <stdint.h>
#include <stdio.h>

int main(void)
{
    const int64_t x = 2;
    if (x % 2 == 0)
        printf("x is even.\n");
    else
        printf("x is odd.\n");
}

else may also be followed by another if, for example:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

int main(void)
{
    const int64_t x = 7;
    if (x % 3 == 0)
        printf("%" PRId64 " is divisible by 3.\n", x);
    else if (x % 3 == 1)
        printf("%" PRId64 " divided by 3 has remainder 1.\n", x);
    else if (x % 3 == 2)
        printf("%" PRId64 " divided by 3 has remainder 2.\n", x);
}

(Recall that int64_t is a 64-bit integer and PRId64 is the format placeholder for it; see fixed-width integer types).

2.3.2 If subtleties and Boolean values ^

Consider the following code:

#include <stdint.h>
#include <stdio.h>

int main(void)
{
    const int64_t x = 0;
    if (x)
        printf("x is true.\n");
    else
        printf("x is false.\n");
}

If you run this code, you will get the output x is false. However, if you change x to any non-zero integer value, you will get the output x is true. In other words, the if statement considers the condition to be true if it evaluates to a non-zero integer. Indeed, let us run the following code:

#include <stdio.h>

int main(void)
{
    printf("%d\n", 1 == 1);
    printf("%d\n", 1 == 2);
}

From the output, we see that 1 == 1 evaluates to 1 (a non-zero integer, and thus true), and 1 == 2 evaluates to 0 (and thus false).

This convention in C can be very confusing, and it can cause errors if you are not careful. Here is one of the most common problems C programmers encounter. Before, I warned you that == is a comparison operator, while = is an assignment operator. Now, an assignment operator actually evaluates to the value that was assigned. So for example, x = 1 assigns 1 to x and also evaluates to 1. This can cause an error if we accidentally write = instead of ==. For example:

#include <stdint.h>
#include <stdio.h>

int main(void)
{
    int64_t x = 0;
    if (x = 1) // Notice that we (incorrectly) used = instead of == here!
        printf("x is 1.\n");
    else
        printf("x is not 1.\n");
}

The output of this program is x is 1. The reason is that x = 1 does not compare x to 1, it sets x to 1, and evaluates to 1, which if then interprets as "true"! If you configured GCC to provide extra warnings, as I explained above, then you will see the warning suggest parentheses around assignment used as truth value in the Problems tab. This warning will appear whenever the compiler thinks you used = when you should have used ==.

Another problem with interpreting 0 as "false" and any other integer as "true" is that both 1 and 2 mean "true", but they are not equal to each other, which means that false equals false, but true does not always equal true!

To avoid this and other confusions, C provides (since 1999) a Boolean data type called bool, which is accessible by including the header file stdbool.h. Once you include that file, you may declare variables using the bool type, and their values can be either true or false. These values can then be modified, e.g. using logic operators.

Here is an example:

#include <stdio.h>
#include <stdbool.h>

int main(void)
{
    const bool A = true;
    const bool B = false;
    if (A)
        printf("A is true.\n");
    if (B)
        printf("B is true.\n");
    if (!A)
        printf("A is not true.\n");
    if (!B)
        printf("B is not true.\n");
    if (A && B)
        printf("A and B are both true.\n");
    if (A || B)
        printf("At least one of A and B is true.\n");
}

The output is:

A is true.
B is not true.
At least one of A and B is true.

You can change the truth values of A and B and see what you get. Note that e.g. if (A) is equivalent to if (A == true).

Warning: It is highly recommended to always use bool to represent boolean values whenever you need to directly manipulate logic values in your program.

2.3.3 The (ternary) conditional operator ^

A very convenient short form for the if-else statement is given by the conditional operator. The following if statement:

if (condition)
    statement;
else
    another_statement;

can alternatively be written using the conditional operator as follows:

condition ? statement : another_statement;

This is most often used not as a stand-alone expression, but rather as part of a larger expression. For example, recall the code we used above:

#include <stdint.h>
#include <stdio.h>

int main(void)
{
    const int64_t x = 2;
    if (x % 2 == 0)
        printf("x is even.\n");
    else
        printf("x is odd.\n");
}

Instead of having several different lines for the if and else statements, and two different printf statements, we can use the following more compact and (arguably) elegant version:

#include <stdint.h>
#include <stdio.h>

int main(void)
{
    const int64_t x = 2;
    printf("x is %s.\n", x % 2 ? "odd" : "even");
}

2.3.4 Switch statements ^

if statements only consider two cases: either condition is satisfied or not. switch allows you to consider several different cases. The syntax is:

switch (expression)
{
case 1:
    statement1;
    break;
case 2:
    statement2;
    break;
    // etc...
}

This will evaluate expression, which must return an integer. If expression is equal to 1, it will evaluate statement1; if expression is equal to 2, it will evaluate statement2; and so on.

Warning: A break; must be added after each case, otherwise all of the code until the closing bracket }, including the code for every subsequent case, will be evaluated.

To illustrate the warning, let us run the following code:

#include <stdint.h>
#include <stdio.h>

int main(void)
{
    const int64_t x = 2;
    switch (x)
    {
    case 1:
        printf("1\n");
        // no break
    case 2:
        printf("2\n");
        // no break
    case 3:
        printf("3\n");
        // no break
    }
}

The output will be:

2
3

Since we did not properly break after each statement, switch started evaluating at the label case 2:, and the evaluation then ran until the closing bracket }. The proper was to do this is:

#include <stdint.h>
#include <stdio.h>

int main(void)
{
    const int64_t x = 2;
    switch (x)
    {
    case 1:
        printf("1\n");
        break;
    case 2:
        printf("2\n");
        break;
    case 3:
        printf("3\n");
        break;
    }
}

Now the output will be 2, as expected. Finally, we can also add a default label, which will be evaluated if none of the other cases are satisfied:

#include <stdint.h>
#include <stdio.h>

int main(void)
{
    const int64_t x = 5;
    switch (x)
    {
    case 1:
        printf("1\n");
        break;
    case 2:
        printf("2\n");
        break;
    default:
        printf("default\n");
        break;
    }
}

If we run this code, the output will be default, since the case x = 5 is not accounted for in any of the other labels.

switch can be used to write the program we wrote above, which determines if x is divisible by 3, in a different way:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

int main(void)
{
    const int64_t x = 7;
    switch (x % 3)
    {
    case 0:
        printf("%" PRId64 " is divisible by 3.\n", x);
        break;
    case 1:
        printf("%" PRId64 " divided by 3 has remainder 1.\n", x);
        break;
    case 2:
        printf("%" PRId64 " divided by 3 has remainder 2.\n", x);
        break;
    }
}

2.3.5 Enumerations ^

An enumeration defines a new data type which can take integer values, while assigning a label to each value, so that we don't need to remember what each number represents. This is done using the keyword enum. The syntax is:

enum name { LABEL, ANOTHER_LABEL, ... };

Here, name is the name of the new data type we are creating. The first label will be assigned the number 0, the second label will be assigned the number 1, and so on. It is conventional to use uppercase for the labels, in order to distinguish them from variables. Also, note that enumerations should usually be defined outside the main function, so that the have global scope and can be used by other functions as well (see variable scope and global variables below).

If we want to assign specific integers to the labels, we can do so as follows:

enum name { LABEL = value, ANOTHER_LABEL = another_value, ... };

This is useful in cases where the numbers are generated by some other program, and we want to assign them meaningful labels in our program.

Once we define an enumeration, the labels will be replaced with their corresponding numbers throughout the program. For example:

#include <stdio.h>

enum color
{
    RED,
    GREEN,
    BLUE
};

int main(void)
{
    printf("RED = %d, GREEN = %d, BLUE = %d\n", RED, GREEN, BLUE);
}

The output will be RED = 0, GREEN = 1, BLUE = 2. However, the most common use of enumerations is to define an actual variable that can take one of these values. We can then use switch to execute different statements based on the value of the variable:

#include <stdio.h>

enum color
{
    RED,
    GREEN,
    BLUE
};

int main(void)
{
    const enum color c = GREEN;
    switch (c)
    {
    case RED:
        printf("Color is red.\n");
        break;
    case GREEN:
        printf("Color is green.\n");
        break;
    case BLUE:
        printf("Color is blue.\n");
        break;
    }
}

Here we first declare a variable called c whose data type is enum color, which means it can be either RED, GREEN, or BLUE. We also initialize it to GREEN. Then we print a message based on the value. At no point did we need to use the actual integer values assigned to the different labels. Indeed, the whole point of enumerations is that we don't need to know the numerical values of the labels. Any program what uses enumerations must always use only the labels and not the numerical values, since the values might change, but the labels are fixed.

Warning: Never assign an integer value to an enum variable manually; always assign a value using the appropriate label. That is, always write e.g. c = GREEN and not c = 1. There are two reasons for this. First, the IDE and the compiler do not check that the numerical value of the integer corresponds to one that has actually been assigned a label. Second, if you later change the values of the labels or add more labels, any code that explicitly uses the integer values the labels had before will need to be rewritten.
Warning: Never perform an arithmetic operation, such as adding or subtracting, on an enum variable. The integer value has no actual numerical meaning; it simply encodes a particular label.

2.4 Iteration statements (loops) ^

2.4.1 While and do-while loops

The while statement allows us to define a loop which will run while a specific condition is satisfied. The loop will automatically stop when the condition is no longer satisfied. The syntax is:

while (condition)
    statement;

As usual, if the loop body contains more than one line (i.e. it is a compound statement), it must be enclosed in curly brackets:

while (condition)
{
    statement1;
    statement2;
    // etc...
}

For example, here is a program that prints the squares of the integers from 1 to 5:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

int main(void)
{
    uint64_t i = 1;
    while (i <= 5)
    {
        printf("%" PRIu64 "^2 = %" PRIu64 "\n", i, i * i);
        i++;
    }
}

Notice that here I used uint64_t, that is, an unsigned 64-bit integer, as the data type for i, since I know for certain that it will never have any negative values. I therefore also used PRIu64, the format placeholder for an unsigned 64-bit integer, to print the value of i.

It is almost always a good idea to use unsigned integers in loops like this, where the integer is being used to count the number of times the loop has been executed, since a count is always positive, and using an unsigned integer type allows us to double the range of the counting variable.

The output is:

1^2 = 1
2^2 = 4
3^2 = 9
4^2 = 16
5^2 = 25

The do statement does the same thing, except it only checks that the condition is satisfied after the loop body has been evaluated:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

int main(void)
{
    uint64_t i = 1;
    do
    {
        printf("%" PRIu64 "^2 = %" PRIu64 "\n", i, i * i);
        i++;
    } while (i <= 5);
}

This will have the same output. The main difference is that in a do-while loop, the loop body will be evaluated at least once before the condition is checked. For example, if we replace int i = 1 with int i = 7, this code will first output 7^2 = 49 and only then check if i <= 5. On the other hand, if we do the same with the while loop above, there will be no output.

Warning: Always make sure the iteration condition is set such that there are no circumstances where the loop repeats infinitely!

2.4.2 Controlling loop evaluation ^

There are two ways to control the evaluation of a loop from within the loop body. break, which we already encountered above when we discussed switch statements, immediately ends the loop. For example, the following code will have the same output as the code above, except that instead of specifying a condition for the while loop, it checks manually if i >= 5 and if so, breaks:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

int main(void)
{
    uint64_t i = 1;
    while (1)
    {
        printf("%" PRIu64 "^2 = %" PRIu64 "\n", i, i * i);
        if (i >= 5)
            break;
        i++;
    }
}

Note that while (1) will keep looping infinitely (since 1 is equivalent to true) unless you explicitly break the loop! This code was only written to illustrate how the break statement works, and by no means should be interpreted as an example of good programming. Loop conditions such as while (1) should generally be avoided at all costs.

Another option is to use continue, which will skip the rest of the loop body and continue immediately to the next iteration of the loop. For example, the following code skips the even numbers:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

int main(void)
{
    uint64_t i = 0;
    while (i <= 5)
    {
        i++;
        if (i % 2 == 0)
            continue;
        printf("%" PRIu64 "^2 = %" PRIu64 "\n", i, i * i);
    }
}

Note that we changed the order of the statements i++ and printf so that continue will skip printf but not i++. Otherwise we would have had an infinite loop, since once i reached an even number, it would stop incrementing, and thus the condition i <= 5 will always be satisfied (try switching the order and see what happens).

2.4.3 For loops ^

The for statement provides a more compact way of defining loops. It has the following syntax:

for (initialization; condition; iteration)
    statement;

or with a compound statement:

for (initialization; condition; iteration)
{
    statement1;
    statement2;
    // etc...
}

initialization is evaluated once, before the loop starts. condition is evaluated before the loop body, and the loop terminates if it evaluates to false (that is, 0). iteration is evaluated after the loop body, and then condition is evaluated again, and so on.

In the first while example above, we initialized i = 1 before the start of the loop, then checked that the condition i <= 5 is satisfied before each iteration of the loop, and finally increased i by 1 at the end of each iteration of the loop. This can all be written using one compact for statement as follows:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

int main(void)
{
    for (uint64_t i = 1; i <= 5; i++)
    {
        printf("%" PRIu64 "^2 = %" PRIu64 "\n", i, i * i);
    }
}

Generally, for loops should be used whenever the iteration is over a specific range of integers. Note that for loops may be controlled using break and continue, as before.

2.4.4 Nested loops ^

All loops can be nested, which means one loop is inside another loop (which can itself be inside a third loop, and so on). This is especially common with for loops, since we can then iterate over every possible combination of the values of two (or more) integers. For example, the following code prints out a multiplication table:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

int main(void)
{
    for (uint64_t i = 1; i <= 5; i++)
    {
        for (uint64_t j = 1; j <= 5; j++)
        {
            printf("| %" PRIu64 " * %" PRIu64 " = %2" PRIu64 " ", i, j, i * j);
        }
        printf("|\n");
    }
}

The output is:

| 1 * 1 =  1 | 1 * 2 =  2 | 1 * 3 =  3 | 1 * 4 =  4 | 1 * 5 =  5 |
| 2 * 1 =  2 | 2 * 2 =  4 | 2 * 3 =  6 | 2 * 4 =  8 | 2 * 5 = 10 |
| 3 * 1 =  3 | 3 * 2 =  6 | 3 * 3 =  9 | 3 * 4 = 12 | 3 * 5 = 15 |
| 4 * 1 =  4 | 4 * 2 =  8 | 4 * 3 = 12 | 4 * 4 = 16 | 4 * 5 = 20 |
| 5 * 1 =  5 | 5 * 2 = 10 | 5 * 3 = 15 | 5 * 4 = 20 | 5 * 5 = 25 |

Note that we only printed a newline character \n after each iteration of i, so that all the iterations of j will appear in one line. We also printed the right border | of the table at the same time. Finally, we used %2 in the format placeholder for the product to ensure that it is always printed with exactly 2 characters, adding a space if necessary, since some numbers will have one digit and others will have two, so this guarantees the table will be correctly aligned.

2.4.5 Variable scope ^

In C, each variable has a scope within which it is accessible. Generally, whenever we are inside a code block indicated by a pair of curly brackets { and }, any variable declared within that block is considered a local variable, local to that block. When the block ends, the variable is no longer accessible, and the memory it used is freed up. Variables declared within the context of statements such as for, even if they are not inside the curly brackets, are nonetheless local to the code block associated with that statement.

For example, consider the following code:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

int main(void)
{
    for (uint64_t i = 1; i <= 5; i++)
    {
        printf("%" PRIu64 "^2 = %" PRIu64 "\n", i, i * i);
    }
    printf("i = %" PRIu64 "\n", i); // This won't work; i was local to the for loop
}

Since we initialized uint64_t i = 1 within the scope of the for loop, the variable i is only defined within that scope. Once the loop finishes, i no longer exists. Therefore this code won't compile, and even before you try to compile it, the IDE will add the error identifier "i" is undefined to the Problems tab and highlight i in the last line with a squiggly red underline.

If we need to use the last value of i, we must declare it before the loop starts so that its scope is the entire main function:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

int main(void)
{
    uint64_t i;
    for (i = 1; i <= 5; i++)
    {
        printf("%" PRIu64 "^2 = %" PRIu64 "\n", i, i * i);
    }
    printf("i = %" PRIu64 "\n", i);
}

This will print i = 6 after the for loop finishes, since the value of i was increased to 6 via the iteration statement i++, and then the condition i <= 5 was no longer satisfied, which is why the loop stopped.

When we declare a local variable, and another local variable with the same name exists within a higher scope, the new variable will replace the old variable until the scope of the new variable ends. For example, in the following code, i is declared in the first line of the main function and initialized with the value 9. Another i is then declared in the for loop, and iterates on the values 1 to 6. However, once the for loop ends, the scope of this new i ends, and it disappears. If we then try to access the original variable i, it will still have the original value 9:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

int main(void)
{
    uint64_t i = 9;
    printf("(main) i = %" PRIu64 "\n", i);
    for (uint64_t i = 1; i <= 5; i++)
    {
        printf("(for) i = %" PRIu64 "\n", i);
    }
    printf("(main) i = %" PRIu64 "\n", i);
}

The output is:

(main) i = 9
(for) i = 1
(for) i = 2
(for) i = 3
(for) i = 4
(for) i = 5
(main) i = 9
Warning: It is highly recommended that every initialization statement in a for loop will be a declaration and initialization of the form type i = value instead of just an assignment i = value, since this ensures that if a variable named i has already been declared and used elsewhere in the program, its value will not be modified by the for loop.
Warning: Even though it is perfectly legal to use the same variable name twice or more in different scopes, this should be avoided, as it can cause confusion and errors. In the case of a for loop, a good programming practice is to use i, j, and k exclusively as the names of the variables to be incremented in all for loops, and not to use these variable names anywhere else in the program.

Note that a code block does not have to be associated with anything in particular; we can begin and end a code block manually wherever we want. This is very useful if we want to declare one or more temporary variables that will only be used for a specific task, and then thrown away. By declaring them within a nested code block, we ensure that they will not conflict with any variables declared elsewhere in the program, and that the memory they used has been freed up. For example:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

int main(void)
{
    const uint64_t x = 9;
    printf("(main) x = %" PRIu64 "\n", x);
    {
        const uint64_t x = 7;
        printf("(nested) x = %" PRIu64 "\n", x);
    }
    printf("(main) x = %" PRIu64 "\n", x);
}

The output is:

(main) x = 9
(nested) x = 7
(main) x = 9

However, it usually makes more sense to define a function rather than a nested code block in such cases. Notice also that even though we declared x as const both times, when we declare it for the second time inside the code block that doesn't count as "changing its value", since we are, of course, declaring a completely new variable in a local scope.

2.5 Arrays ^

2.5.1 Declaring arrays

Arrays allow a single variable to hold multiple values. In an array of constant length, memory will be allocated for all of the values in advance, and the values will be stored contiguously (one after the other) in memory. For now, we will only consider arrays whose length is constant and determined at compilation time. Later we will discuss how to declare and allocate memory for arrays with variable length.

To declare an array of constant size, we use the following syntax:

type name[size];

type is the data type of the values, such as int64_t; name is the name of the array; and size is the number of values in the array. The Nth element of the array can then be referred to using name[N - 1].

Warning: Arrays in C start with the index 0. Therefore, if the array a contains 3 elements, then the first element will be a[0], the second will be a[1], and the third and last element will be a[2]. This is a common source of confusion for beginner C programmers.

The array can also be initialized, as follows:

type name[] = {name[0], name[1], ..., name[size - 1]};

In this case, we don't have to specify the size of the array manually - the size will be automatically determined based on how many elements are in the list. Again, note that the first element is name[0] and the last one is name[size - 1].

In the following example, we first define an array with the first 5 primes, and then print the elements of the array:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

int main(void)
{
    const uint64_t primes[] = {2, 3, 5, 7, 11};
    for (uint64_t i = 0; i < 5; i++)
        printf("primes[%" PRIu64 "] = %" PRIu64 "\n", i, primes[i]);
}

The output will be:

primes[0] = 2
primes[1] = 3
primes[2] = 5
primes[3] = 7
primes[4] = 11

2.5.2 Initializing arrays to zeros ^

Above we warned that variables should always be initialized to a specific value as soon as they are declared, since memory is automatically allocated but not automatically initialized, so an uninitialized variable will just take whatever value happened to be stored in that particular address in memory at the time. The same goes for arrays. For example, consider this program:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

int main(void)
{
    const uint64_t nums[5];
    for (uint64_t i = 0; i < 5; i++)
        printf("nums[%" PRIu64 "] = %" PRIu64 "\n", i, nums[i]);
}

On my computer, one possible output is:

nums[0] = 229359221408
nums[1] = 140700945750376
nums[2] = 1582342677712
nums[3] = 4294967296
nums[4] = 140700945747968

The numbers also change every time I run the program. On your computer, the output will be different, depending on whatever happens to be in those particular memory addresses at the time.

To avoid potential errors, it is best to initialize the array as soon as we declare it so that all of the elements are zero. We could do this by writing the zeros explicitly, i.e. const uint64_t nums[5] = {0, 0, 0, 0, 0}, but if the array contained more than a few elements, this would be extremely tedious.

Luckily, there is a shorthand for this. If we only specify some of the elements in the initialization list, and the array's size is larger than the number of elements we initialized, then the remaining elements will be initialized to zero. For example, replace the line const uint64_t nums[5]; with

const uint64_t nums[5] = {1, 2};

The output is now:

nums[0] = 1
nums[1] = 2
nums[2] = 0
nums[3] = 0
nums[4] = 0

Therefore, to automatically initialize the entire array to zeros, we can simply initialize just one element to zero, and the rest will be initialized to zero as well:

const uint64_t nums[5] = {0};

Output:

nums[0] = 0
nums[1] = 0
nums[2] = 0
nums[3] = 0
nums[4] = 0

Of course, this means that we have to specify the size of the array manually so that the compiler knows how many elements to initialize.

Warning: Initializing an array with an empty list, e.g. const uint64_t nums[5] = {}, is not allowed in the C standard. If you try this, and you added the -Wpedantic argument as I instructed above, then the compiler will generate the warning ISO C forbids empty initializer braces in the Problems tab.

2.5.3 Accessing array elements out of range ^

Warning: When you access an element of an array, the IDE and the compiler do not check if the element index is within the range of the array. Accessing an elements outside the range will lead to unexpected errors.

In the following example, we create an array of one element, and initialize this element to zero. This element is nums[0]. We then try to access nums[-1] and nums[1], which are both outside the range of the array:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

int main(void)
{
    const int64_t nums[1] = {0};
    for (int64_t i = -1; i <= 1; i++)
        printf("nums[%" PRId64 "] = %" PRId64 "\n", i, nums[i]);
}

On my computer, one possible output is:

nums[-1] = 4294967296
nums[0] = 0
nums[1] = 1

The "elements" nums[-1] and nums[1] are not actually part of the array; the numbers that were printed were simply the numbers that happened to be stored in memory before and after the space reserved for the array.

2.5.4 Multi-dimensional arrays ^

The arrays we have defined so far were 1-dimensional, analogous to vectors. It is also possible to define higher-dimensional arrays, analogous to matrices or higher-rank tensors. The syntax to define an N-dimensional array is:

type name[size1][size2]...[sizeN];

This will define a size1 by size2 by ... by sizeN array. Elements of a multi-dimensional array can be accessed using the same notation, with the element index inside each bracket. Recall that the first element has index 0 - so the first element in the array will be name[0][0]...[0].

To initialize the array, we use nested curly brackets:

type name[size1][size2]...[sizeN] = {elements1, elements2, ..., elementsN};

where here elements1 is a sub-array with size1 elements, given by a list inside curly brackets, and so on. For example:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

int main(void)
{
    const int64_t a[2][3] = {{1, 2, 3}, {4, 5, 6}};

    printf("%" PRId64 "\n", a[0][1]);
}

The program prints 2 because that is the element in index [0][1] of the array; the 0 indicates we are in the first sub-array {1, 2, 3}, and the 1 indicates the second element of that sub-array. Note that we could also write a[][3], and the compiler will automatically recognize that the array has 2 sub-arrays; but the size of the sub-arrays has to be declared explicitly, so a[][] will not work.

2.5.5 Characters and strings ^

A string is a sequence of characters. In C, strings are literally arrays of elements of type char. To specify a specific value for a single character, we use single quotes:

char c = 'A';

This will define a variable c of data type char which has as its value the letter A. To specify a specific value for a string of characters, we declare the string as an array of characters, and use double quotes:

char s[] = "ABCDEFG";

When using printf, we have seen that the format placeholder %s stands for a string. Similarly, %c stands for a single character. Since a string is just an array of characters, we can read and write each character individually, as we would for an array:

#include <stdio.h>

int main(void)
{
    char s[] = "ABCDEFG";
    printf("%c\n", s[2]);       // Prints "C" since that is the 3rd character in the string (recall that arrays start at index 0)
    s[4] = 'e';                 // Changes the 5th character "E" to lowercase "e"
    printf("%s\n", s);          // Prints "ABCDeFG"
    printf("%zu\n", sizeof(s)); // Prints "8"
    printf("%d\n", s[7]);       // Prints "0"
}

Note the last two lines: sizeof(s) is 8 bytes, but the string only has 7 characters. Furthermore, s[7], the 8th character in the string, has a value of 0. The reason is that in C, strings are null-terminated: the last character in the string is followed by a null character, which is simply a char with a value of 0. The null character indicates that the string has terminated. This also means that 'e' and "e" are not the same, as 'e' is just one character (taking 1 byte of space) while "e" is a null-terminated string (taking 2 bytes of space, including the terminating null).

C provides a variety of standard functions for manipulating strings. We will discuss some of them later in the course.

2.6 Functions ^

2.6.1 Defining functions

A function is a block of code that gets an input, executes some statements, and returns an output. C, by itself, does not contain any functions; they are either defined by the user, or imported from libraries.

main is an example of a function defined by the user, and indeed, it must be defined in any C program. Its (optional) input is given in the form of command-line arguments, as we will discuss below. Its executed statements are the main code of the program itself. Its output, as we discussed above, is an integer, with 0 (returned by default) indicating successful execution.

An example of a function imported from a library is printf, which we import by including the header file stdio.h. Its (mandatory) input is a format string and the data to print. Its executed statements print the data to the terminal using the specified format. Its output is usually not important - it's an integer indicating the number of characters printed, or a negative value if an error occurred.

Functions are mainly used as a form of abstraction. When you use a function, you only need to know that it takes a certain input, performs a certain well-defined task, and returns a certain output. The details of how the function works internally are not relevant. For example, you don't need to know how exactly printf works; all you need to know is that it prints out the data you give it as input.

Of course, when you write your own functions, you do know how they work, since you wrote them. However, what's important here is that you can modify how those functions work internally, for example to fix bugs or to optimize their performance, and yet safely keep all the rest of the code unchanged, since any statements which call those functions do so independently of how the function works internally. (This approach is taken to the next level in object-oriented programming, which we will cover later in the course.)

Virtually any non-trivial C program must make use of some user-defined functions other than main. To define a function, we use the following syntax:

type name (type1 arg1, type2 arg2, ...)
{
    statement1;
    statement2;
    // etc...
    return value;
}

Where:

  • type is the data type of the function's output. If the function returns no output, we use void as the data type.
  • name is the function's name, which must follow the same rules as variable names.
  • The function's input takes the form of variables arg1, arg2, etc. which are of the data types type1, type2, etc. respectively. Again, if the function doesn't take any arguments, we use void instead. If the function does not change the value of the argument, use const as part of the type declaration (see below).
  • The statements executed by the function are those inside the code block indicated by the curly brackets.
  • value is the value to be returned as the function's output, and it must be of the data type type declared in the function's definition. Note that there can be multiple return statements in a function, e.g. if we want to return one value in one case and another value in another case; however, once return is executed, the function terminates. If the function has a return type of void, we do not need to write a return statement.

To call this function, we simply use the syntax name(arg1, arg2, ...). If the function has no input, we use empty parentheses: name().

Any variables declared in the context of the function are completely independent of the rest of the program (recall the discussion on variable scope). Variables declared elsewhere in the program are not accessible within the function, and variables declared in the function, including both the arguments and any variables declared within the function's code block, are not accessible elsewhere in the program.

A function may only be defined once in the program - if we define two functions with the same name, the compiler won't know which one to call. Furthermore, functions cannot be nested. This means that we cannot define a function inside another function, including inside the main function. Instead, each function must be defined at the highest scope (the file scope).

Most functions generally take a fixed number of arguments, which have to be of specific data types. However, printf, for example, can take any number of arguments of any type, as long as the first argument is a string specifying the number and type of the remaining arguments (e.g. "%d %s" indicates that the function should expect one integer and one string). This is called a variadic function. User-defined functions can also be variadic, but this is almost never actually needed, so we will not cover it here.

2.6.2 Constant vs. non-constant arguments ^

Here is a simple example of defining and using a function:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

int64_t add(const int64_t a, const int64_t b)
{
    return a + b;
}

int main(void)
{
    printf("%" PRId64 "\n", add(5, 6));
}

The function add simply takes two 64-bit integers and gives their sum as output. This means that add(5, 6) will return the value 11, which is then printed out using printf.

Notice that we added the const modifier to the arguments to indicate - both to the compiler and to the human reading our code - that these arguments will not be modified by the function. This can often help avoiding mistakes and bugs. If, for example, we write the statement a++; as the first line of the function, the program will not compile, since a is constant and thus cannot be changed.

Warning: Make sure to always use the const modifier on any variables that should not be changed. This applies both to variables declared within the function and to variables obtained as function arguments. Doing so will ensure that these variables are not accidentally modified, either by you or by someone else using your code, which may introduce bugs. It will also make your code easier to understand, by clearly indicating which variables are assumed to be constant.

Often we do actually want the function to be able to modify the values of the arguments it receives as input, in which case we do not use const. However, note that in this case, only the local copy of the variable, within the function's local scope, is modified. Here is an example:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

void increment(int64_t x)
{
    x++;
    printf("increment(): x = %" PRId64 "\n", x);
}

int main(void)
{
    int64_t x = 0;
    increment(x);
    printf("main():      x = %" PRId64 "\n", x);
}

The output of this program is:

increment(): x = 1
main():      x = 0

The local copy of x inside the function increment() has been incremented using x++, but this does not affect the value of x in main(). The only way for a function to modify variables outside its local scope is by using pointers, which we will learn about later.

2.6.3 Recursion ^

Recursion is when a function calls itself. Of course, this only makes sense if the recursion terminates at some point, otherwise the function will be called an infinite number of times. For example, let's write a program which calculates the Fibonacci sequence. This sequence, denoted Fn, is defined for any non-negative integer n using the relations

  • F0 = 0,
  • F1 = 1,
  • Fn = Fn-1 + Fn-2 for n ≥ 2.

Since each element in the sequence is defined in terms of the previous two elements, it is natural to calculate the elements of this sequence by recursion:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

uint64_t fibonacci(const uint64_t n)
{
    if (n == 0)
        return 0;
    else if (n == 1)
        return 1;
    else
        return fibonacci(n - 1) + fibonacci(n - 2);
}

int main(void)
{
    for (uint64_t i = 0; i <= 10; i++)
        printf("F_%" PRIu64 " = %" PRIu64 "\n", i, fibonacci(i));
}

Output:

F_0 = 0
F_1 = 1
F_2 = 1
F_3 = 2
F_4 = 3
F_5 = 5
F_6 = 8
F_7 = 13
F_8 = 21
F_9 = 34
F_10 = 55

The function fibonacci is literally just the definition of the sequence: it returns 0 for n = 0, 1 for n = 1, and the sum of the two previous elements for n ≥ 2.

2.6.4 Forward declaration and mutual recursion ^

Above we declared and defined add before main, because add is being used in main, and it needs to be declared before it is used. Let us move the function add after main:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

int main(void)
{
    printf("%" PRId64 "\n", add(5, 6));
}

int64_t add(const int64_t a, const int64_t b)
{
    return a + b;
}

The program does not execute, and gives some errors and warnings. Here is the full output from the compiler, as displayed in the Terminal tab:

C:\Users\barak\CSE701\main.c: In function 'main':

C:\Users\barak\CSE701\main.c:7:29: warning: implicit declaration of function 'add' [-Wimplicit-function-declaration]
    7 |     printf("%" PRId64 "\n", add(5, 6));
      |                             ^~~

C:\Users\barak\CSE701\main.c:7:12: warning: format '%lld' expects argument of type 'long long int', but argument 2 has type 'int' [-Wformat=]
    7 |     printf("%" PRId64 "\n", add(5, 6));
      |            ^~~              ~~~~~~~~~
      |                             |
      |                             int

In file included from C:\Users\barak\CSE701\main.c:1:
c:\users\barak\mingw64\x86_64-w64-mingw32\include\inttypes.h:33:19: note: format string is defined here
   33 | #define PRId64 "lld"

C:\Users\barak\CSE701\main.c: At top level:
C:\Users\barak\CSE701\main.c:10:9: error: conflicting types for 'add'; have 'int64_t(const int64_t,  const int64_t)' {aka 'long long int(const long long int,  const long long int)'}
   10 | int64_t add(const int64_t a, const int64_t b)
      |         ^~~
C:\Users\barak\CSE701\main.c:7:29: note: previous implicit declaration of 'add' with type 'int()'
    7 |     printf("%" PRId64 "\n", add(5, 6));
      |                             ^~~

Build finished with error(s).

What happened here is that we tried to use the function before it was declared, and therefore the compiler had to guess what kind of data type the function returns. By default, if the compiler doesn't know what the return type of a function is, it always assumes it returns an int. So the compiler implicitly declared the function as one that returns an int. Unfortunately, the function actually return an int64_t (that is, a 64-bit instead of a 32-bit integer), and therefore the actual function doesn't match the implicit declaration.

Note that if we defined the function as returning an int, then the program would actually compile, since the implicit declaration would then match the actual definition; but it will still generate warnings, and generally you should never rely on implicit declarations. Also, note that the types of the arguments (or even the number of arguments) do not need to be known in advance, only the return type.

You're probably wondering why the compiler can't just look ahead and find the declaration later in the code. This is certainly the case in many higher-level languages, but in C and C++, the language specifications require us to always declare functions before we use them. Although it should in principle be possible to modify the compiler so that it looks ahead for the declaration, this would be a very substantial change to the way the language works, and might break older code.

Unfortunately, there are some (rare) cases where a function cannot be declared before it is used. In such cases, we must still declare the function before it is used, but we can actually define the function - that is, provide the actual statements executed by the function - anywhere we want. This is called forward declaration, and the initial declaration is called a prototype.

As a trivial example of forward declaration, we can declare the return value of the add function at the top of the file, before main, and then define it at the bottom of the file, after main:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

int64_t add();

int main(void)
{
    printf("%" PRId64 "\n", add(5, 6));
}

int64_t add(const int64_t a, const int64_t b)
{
    return a + b;
}

The code now executes with no warnings. Note that in the prototype declaration int64_t add(), we only needed to declare the return type of the function. We do not need to provide the types or even the number of arguments. A prototype declared without arguments can take any number of arguments of any type.

However, if we wanted to, we could declare the prototype using the full expression int64_t add(const int64_t a, const int64_t b), and that declaration would also have served to document the input and output types of the function for a human reader. If we wanted, we could even declare int64_t add(int64_t, int64_t) without the const modifier or the variable names, and that would still work.

In this simple case, forward declaration was not actually necessary, we could have just defined the function before main and saved ourselves the trouble. However, forward declaration is mandatory in some situations, for example if two functions call each other recursively, also known as mutual recursion. Here is an example:

#include <inttypes.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>

bool is_odd();

bool is_even(const int64_t n)
{
    if (n > 0)
        return is_odd(n - 1);
    else if (n < 0)
        return is_odd(n + 1);
    else
        return true;
}

bool is_odd(const int64_t n)
{
    if (n > 0)
        return is_even(n - 1);
    else if (n < 0)
        return is_even(n + 1);
    else
        return false;
}

int main(void)
{
    const int64_t n = 7;
    printf("%" PRId64 " is %s", n, is_even(n) ? "even" : "odd");
}

This is an extremely inefficient way of determining whether an integer is even or odd - we are just giving it here as an example of mutual recursion. Basically, as long as the argument is not zero, each of the functions is_even and is_odd calls the other function with an argument closer to zero by one step (subtract 1 if positive, add 1 if negative). By simulating each step manually (e.g. on a piece of paper), you can convince yourself that this is indeed a (very bad) algorithm for finding if a number is even or odd.

If you remove the prototype declaration bool is_odd(), you will see that the program does not execute, because then is_odd is called from within is_even without being declared first, so the compiler implicitly defines it as returning int instead of bool. In this case, there is no way to rearrange the program so that we do not need to declare at least one of the functions in advance. Therefore, this is one case where forward declaration must be used.

2.6.5 Global variables ^

Above we discussed local variables, which are only visible within a particular scope. Global variables are those which are defined in the highest possible scope, before any functions, including main, are declared. Since they are not local to any specific scope, they are accessible from anywhere in the program, including the main function and any other function, unless any local variables with the same names are defined.

For example, the following program does not compile, since n is only defined locally in the main function and is thus inaccessible to the print_n function:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

void print_n(void)
{
    printf("%" PRId64 "\n", n);
}

int main(void)
{
    int64_t n = 5;
    print_n();
}

If we declare n before main but after print_n, this still doesn't work, since print_n does not know about n:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

void print_n(void)
{
    printf("%" PRId64 "\n", n);
}

int64_t n;

int main(void)
{
    n = 5;
    print_n();
}

However, if we declare n before print_n as well, the program now works:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

int64_t n;

void print_n(void)
{
    printf("%" PRId64 "\n", n);
}

int main(void)
{
    n = 5;
    print_n();
}

Note also that if we declare another variable named n in main, then that is a local variable, visible only to main, and therefore changing that variable doesn't change the value of the global variable n. In the following example, the output will not be 5, but 7:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

int64_t n = 7;

void print_n(void)
{
    printf("%" PRId64 "\n", n);
}

int main(void)
{
    int64_t n = 5;
    print_n();
}

Note that the compiler will warn us that n is an unused variable - this refers to the local variable n inside main(), which we indeed never used, since only the global n was used.

Warning: In most cases, global variables can and should be avoided. For example, providing n as an argument for print_n in the code above will have the same effect, without needing to use a global variable. With global variables, it is very easy to make mistakes which result in the wrong value being used, as in the last example, and it is also much harder to keep track of where exactly the global variable gets modified, since it can be modified anywhere in the program.

2.6.6 Static variables ^

We have seen that variables defined inside functions are local to that function. They are inaccessible by any other function, and moreover, their value is discarded once the function finishes. Static variables are variables that are local to a function, but keep their values after the function has finished executing, such that the value can still be accessed and modified every subsequent time the function is called. They are defined using the keyword static before the variable definition. Here is an example:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

void count(void)
{
    static uint64_t n = 0;
    n++;
    printf("This function has been called %" PRIu64 " times.\n", n);
}

int main(void)
{
    count();
    count();
    count();
}

The output is:

This function has been called 1 times.
This function has been called 2 times.
This function has been called 3 times.

However, if we delete the word static, all three lines will say This function has been called 1 times.

Note that the line static int64_t n = 0 itself is called only once, that is, n is only initialized the first time the function is called. Of course, this has to be the case, otherwise there would be no point to making the variable static.

3 Advanced topics in C programming ^

3.1 Debugging with Visual Studio Code and GDB

3.1.1 Getting ready for debugging

Debugging is the process of finding and fixing bugs in your program. In this course we will debug using GDB, the GNU Debugger, which comes with GCC. This debugger is conveniently integrated into Visual Studio Code's graphical user interface. For debugging to work, a workspace must be open in Visual Studio Code (the status bar must be blue, not purple), that workspace must include a .vscode folder with a properly-configured launch.json file, and a C or C++ source file must be open in the editor.

Click on the Run icon in the Activity bar on the left (looks like a "play" button with a little bug next to it), or press Ctrl+Shift+D. This opens up the Run view, which is the one that is used when debugging. If you did not configure launch.json yet, a big blue button with the label Run and Debug will appear. If that is the case, you need go back and configure GCC and Visual Studio Code properly according to my instructions above.

If you did properly configure launch.json, you will see a dropdown menu at the top of the Run view with the name of the task you created, which should be something like "gcc - Build and debug active file". The play button can be used to start debugging, but it's usually easier to just press F5. The gear button gives quick access to launch.json.

The overflow menu (three dots) allows you to enable or disable the four components of the Run view:

  • Variables
  • Watch
  • Call Stack
  • Breakpoints

Make sure all four are enabled. The last option in this menu opens the debug console (which can also be opened with Ctrl+Shift+Y).

If you configured the args field in the tasks.json file as I previously instructed, it should look similar to this:

"args": [
    "-g",
    "${file}",
    "-o",
    "${fileDirname}\\${fileBasenameNoExtension}.exe",
    "-Wall",
    "-Wextra",
    "-Wconversion",
    "-Wsign-conversion",
    "-Wshadow",
    "-Wpedantic",
    "-std=c17",
    "-D__USE_MINGW_ANSI_STDIO=1"
],

The first argument, -g, which was added automatically by VS Code, instructs the compiler to include debugging information in the compiled binary file. This information will be used by GDB to debug the program. However, we will use the argument -ggdb3 instead:

  • The -ggdb part tells GCC to produce debugging information in the format most suitable for GDB, rather than in the operating system's native format. Since we are only going to be using GDB, this option is preferable.
  • The 3 indicates that the maximum possible amount of information should be provided (0 produces no debugging information at all, 1 produces minimal information, and 2 is the default). This can improve our debugging experience.

So let us delete -g and add -ggdb3 instead:

"args": [
    "${file}",
    "-o",
    "${fileDirname}\\${fileBasenameNoExtension}.exe",
    "-Wall",
    "-Wextra",
    "-Wconversion",
    "-Wsign-conversion",
    "-Wshadow",
    "-Wpedantic",
    "-std=c17",
    "-D__USE_MINGW_ANSI_STDIO=1"
    "-ggdb3"
],

(I added it at the end so that it's in the same place as the other arguments.)

Warning: The -ggdb or -g flags should only be used when debugging. When you are done writing and debugging the code, and are ready to compile and distribute the release version of your program, you should remove the debugging flag, and preferably add -O2 or similar optimization flags instead (more on that later).

3.1.2 Breakpoints and the debug toolbar ^

One of the most important aspects of debugging is the ability to set particular points in the source code as breakpoints, meaning that when the execution of the program reaches any of these points, it pauses.

To add a breakpoint at a particular line, press F9 while the cursor is in that line. This will cause a red dot to appear on the left margin of the editor, to the left of the line number, indicating that a breakpoint has been set at that line. Alternatively, you can also click with the mouse on the margin where the red dot should be.

When you add a breakpoint, it will appear in the Breakpoints section of the Run view with the name of the source file and the line number. To remove the breakpoint from a line, press F9 while the cursor is in that line, click with the mouse on the red dot, right-click on it in the Breakpoints section and choose Remove Breakpoint, or hover over it with the mouse in the Breakpoints section and click on the "X".

You can disable a breakpoint temporarily, without removing it, by clicking on the checkbox next to it in the Breakpoints section, or by directly right-clicking on the red dot in the editor and choosing Disable Breakpoint. The dot will turn grey instead of red, and the breakpoint will not cause the program to pause until you enable it again.

If you hover the mouse over the Breakpoints section you will see three buttons. The button with two circles lets you deactivate all of the breakpoints, so that the execution will not stop on any breakpoints, whether enabled or disabled. The button with two squares permanently removes all of the breakpoints.

To experiment with breakpoints, paste the following program into the editor:

#include <stdio.h>

void test(void)
{
    printf("2 ");
    printf("3 ");
}

int main(void)
{
    printf("1 "); // Place a breakpoint here
    test();
    printf("4 ");
}

Place a breakpoint at the indicated line and press F5 run the program. You will see that the program pauses its execution, the line where the breakpoint is located is highlighted, and the debug toolbar appears on top of the editor. This toolbar has the following options from left to right:

  • Continue / Pause (F5)
  • Step Over (F10)
  • Step Into (F11)
  • Step Out (Shift+F11)
  • Restart (Ctrl+Shift+F5)
  • Stop (Shift+F5)

First, let us try Continue / Pause (F5). You will see that the program will simply continue running until it ends. Try this, and then press F5 to run the program again and pause on the breakpoint.

Now, let us try Step Over (F10). It will execute the first line on the main function, and you will see the output 1 in the terminal. Press F10 again to step over the second line, test(). You will notice that the output 2 3 is written to the terminal, but the debugger doesn't enter the test function itself - that is the meaning of stepping over the line. Finally, press F10 again to execute the last line of the main function, which will output 4 to the terminal.

Press Restart (Ctrl+Shift+F5) to restart the program and pause on the breakpoint again. Use Step Over (F10) on the first line to step over the printf function. But when the cursor is on the second line, press Step Into (F11) to step into the test function instead of over it. By pressing Step Over (F10) inside the function, you will be able to execute it line-by-line, until you reach the end of the code block (}), which will transfer you back to the main function. If you now try to step into the printf function, you will be taken into the header file stdio.h where the function is defined, but not into the actual function, since its source code is not available to the debugger.

Next, press Restart (Ctrl+Shift+F5) again, and Step Into (F11) the test function again. Then press Step Out (Shift+F11). You will see that you stepped out of the test function and back to the main function, with the execution now on the last line of the main function. Press Stop (Shift+F5) to stop the execution without executing the last line.

A function breakpoint is a breakpoint that will be triggered when a particular function is executed, instead of on a particular line. To add one, you can use one of the following methods:

  1. Click on Run > New Breakpoint > Function Breakpoint....
  2. In the Run view, hover with the mouse over the Breakpoints section, and click on the plus sign.
  3. Use a keyboard shortcut. This is the most convenient way, but you will have to set up this shortcut yourself. You can do so by pressing F1 to bring up the Command Pallette and choosing "Preferences: Open Keyboard Shortcuts" (or just pressing Ctrl+K Ctrl+S). Then search for "Debug: Function Breakpoint" and set the keybinding (I like to use Ctrl+F10).

Remove the existing breakpoint, and add two function breakpoints: one for main and one for test. Notice that the symbol for a function breakpoint is a red triangle instead of a red dot. When you press F5, you will see that the execution stops in the beginning of the main function. Press Continue / Pause (F5) and execution will continue, and then stop in the beginning of the test function.

3.1.3 Conditional breakpoints and logpoints ^

A conditional breakpoint is a breakpoint that only triggers if a certain condition is met, rather than whenever execution reaches the line where the breakpoint was set.

Copy the following program into the editor:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

int main(void)
{
    for (uint64_t i = 1; i <= 10; i++)
        printf("%" PRIu64 " ", i); // Place a breakpoint here
}

We would like to place a conditional breakpoint at the indicated line inside the for loop, which will only trigger when i reaches the value 5. There are three ways to do this:

  1. Create a normal breakpoint with F9, right-click on the red dot, select Edit Breakpoint..., and choose "Expression" from the dropdown menu.
  2. Click on Run > New Breakpoint > Conditional Breakpoint....
  3. Use a keyboard shortcut. As above, press F1 to bring up the Command Pallette and choose "Preferences: Open Keyboard Shortcuts" (or just press Ctrl+K Ctrl+S), search for "Debug: Conditional Breakpoint", and set the keybinding (I like to use Ctrl+F9).

Using any of these methods, create a new conditional breakpoint at the indicated line, write i == 5 as the condition, and press Enter. Notice that the red dot will have a tiny equal sign on it. Now press F5 to run the program. You will see that the numbers 0 1 2 3 4 will be printed to the terminal, and only then, when the condition i == 5 is met, the execution will stop on the breakpoint. If you press F5 to continue, the program will keep running without breaking again.

A logpoint is similar to a breakpoint, except that instead of pausing the execution of the program, it prints a message to the debug console. Programmers often emulate logpoints manually by explicitly adding a statement such as printf("i is now %" PRIu64 "\n", i) into the source code, but a logpoint allows us to do this in a more convenient and dynamic way, without changing the source code itself.

As with conditional breakpoints, there are three ways to create logpoints:

  1. Create a normal breakpoint with F9, right-click on it, select Edit Breakpoint..., and choose "Log Message" from the dropdown menu.
  2. Click on Run > New Breakpoint > Logpoint....
  3. Set up a keybinding for "Debug: Add Logpoint..." (I like to use Alt+F9).

First remove the conditional breakpoint, and then, using any of these methods, create a new logpoint at the indicated line and type for loop is running as the message. Notice that the symbol will be a red diamond instead of a red dot. Press F5, and the message will be written to the debug console (Ctrl+Shift+Y) ten times.

Logpoints can do more than just print messages: they can evaluate expressions when they are entered inside curly brackets {}. Edit the logpoint (right-click on it and select Edit Logpoint...) and change the message to i is now {i}. When you press F5, you will see that the messages i is now 1, i is now 2, etc. will be printed to the debug console.

However, be careful: expressions evaluated by logpoints will affect the program itself! For example, if you change the message to i is now {++i}, then you will see that only even values of i will be printed, both to the debug console and the terminal, since i is actually incremented twice in each run of the loop.

Finally, note that logpoints can also be conditional; for example, under Edit Logpoint..., we can specify i == 5 for Expression and Stopped at i == {i} for Log Message. This will print the message Stopped at i == 5 to the debug console when execution reaches i == 5, but will not otherwise pause the program.

3.1.4 Variables, watches, and the call stack ^

Consider the following program, which prints out a multiplication table:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

void print_product(const uint64_t a, const uint64_t b)
{
    printf("%3" PRIu64 " ", a * b);
}

void mul_table(const uint64_t rows, const uint64_t cols)
{
    printf("Multiplication table with %" PRIu64 " rows and %" PRIu64 " columns:\n", rows, cols);
    for (uint64_t i = 1; i <= rows; i++)
    {
        for (uint64_t j = 1; j <= cols; j++)
            print_product(i, j);
        printf("\n");
    }
}

int main(void)
{
    const uint64_t num_rows = 10; // Place a breakpoint here
    const uint64_t num_cols = 10;
    mul_table(num_rows, num_cols);
}

Let us place a (normal) breakpoint at the indicated line - or alternatively, add a function breakpoint for the main function. Press F5 and look at the Variables section of the Run view. You will see a label "Locals", and under it the variables num_rows and num_cols, which are local to the scope of the main function. Notice that both num_rows and num_cols are currently uninitialized, so they will have garbage values.

Next, look at the Call Stack section of the Run view. You will see that Thread #1 is currently "paused on breakpoint". Since our program is not multi-threaded (that is outside the scope of our course), Thread #1 is the only relevant one. If you expand this thread using the bracket on the left, you will see main() and main.c (or whatever the name of your source file is) listed. This simply indicates that we are currently in the main function of the file main.c.

Now, follow these steps and notice the changes in the Run view each time a statement is executed:

  1. Press F10. num_rows will be initialized to 10. num_cols is still uninitialized.
  2. Press F10. num_cols will also be initialized to 10.
  3. Press F11 to step into the function mul_table.
    • Notice that the Call Stack section changed to reflect that, with mul_table appearing above main; the order of the functions in the stack indicates that when mul_table finishes executing, control will be passed down to main.
    • Also notice that the variables in the Variables section are now rows, and cols, the variables local to the scope of mul_table. They will both have the value of 10 that they obtained via the function call. If you click on main in the call stack, you will see num_rows and num_cols again.
  4. Press F10. Now we have entered the scope of the first for loop. A new variable i will be added to the list, uninitialized.
  5. Press F10. Now we have entered the scope of the second for loop. i will be initialized to 1. A new variable j will be added to the list, uninitialized.
  6. Press F10. j will be initialized to 1.
  7. Press F11 to step into the function print_product.
    • Again, notice that this function is now on top of the call stack.
    • The Variables section now lists only the variables a and b, both equal 1 since those were the values passed to the function as arguments. I intentionally gave them different names to stress that they are different variables, not the same variables i and j that were local to mul_table.
  8. Press F10. 1 will be printed to the terminal.

You can continue pressing F10, and see the variables i and j gradually increase and their products being printed to the terminal. Also, if the focus is on the Variables section, you can start typing the name of a variable, and it will be highlighted. This feature is useful when you have many variables and you're looking for a specific one.

In the Watches section of the Run view, you can click on the plus icon to create a new watch. This can be any expression, and it will be evaluated in real time as the program runs. Some examples of watches you can try include:

  • i * j will show which number will be printed to the terminal in each iteration of the loop.
  • cols * i + j - 10 will show which element in the table (out of 100) is currently being printed.
  • i == j will evaluate to 1 if dealing with a diagonal element.

However, note that when i and/or j are out of scope - for example, when if you step into print_product, or even when the second for loop is finished printing a full row and therefore j is no longer defined - then expressions with i and j cannot be calculated.

Finally, you can press Shift+F11 (Step Out) to step out of mul_table, which will take you back to main with the entire table printed.

The variables in the Variables section can be of any type, including arrays, strings, and other types we will define below. Here is an example with a string:

#include <stdint.h>
#include <stdio.h>

int main(void)
{
    char letters[] = "abcde";
    for (uint64_t i = 0; i < 5; i++)
        printf("%c \n", letters[i]); // Place a breakpoint here
}

When you place a breakpoint at the indicated line and press F5 run the program, you will see the variables i and letters in the Variables section. Recall that strings in C are actually arrays of integers of type char, terminated with a null character, which has the value 0. You can expand letters to see that this is in fact the case.

Sometimes it's useful to be able to change the values of variables to see what happens. You can try it out with this program. First press F10, and you will see the letter a printed to the terminal. Now, before the letter b is printed, double-click on it (element [1] of the array) and change its value to 122, which corresponds to the letter z. Press F10 two more times and you will see that the letter z was printed to the terminal, not b.

3.1.5 Using the debug console ^

Above we saw that logpoints print log messages to the debug console. Another very important use of the debug console is to evaluate expressions manually during the run time of the program: simply write an expression at the bottom of the debug console and press Enter. You can also write expressions with multiple lines by pressing Shift+Enter to separate the lines (although this is seldom needed, since C++ doesn't care about newlines).

To illustrate how to use the debug console, let us use the following program:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

int64_t square(const int64_t x)
{
    return x * x;
}

void print_square(const int64_t x)
{
    printf("%" PRId64 "\n", square(x));
}

int main(void)
{
    const int64_t n = 2;
    print_square(n); // Place a breakpoint here
}

Place a breakpoint at the indicated line, and press F5 run the program. Now go to the debug console (Ctrl+Shift+Y) and try the following:

  • Calculations using standard operations. For example, 1 + 1 will print 2 to the debug console.
  • Expressions involving variables in your program. For example:
    • n will print 2 to the debug console.
    • n = 9 will change the value of n to 9 and print that value to the debug console. (Note that the value of n will not change in the Variables section, but it will change if you add a watch.)
    • n will now print 9 to the debug console.
  • Calls to functions defined in the program. For example:
    • square(5) will print 25 to the debug console.
    • square(n) will print 81 to the debug console.
    • print_square(n) will print void to the debug console, since that's the return type of print_square, but it will print 81 to the terminal.
  • Calls to functions not defined in your program, but accessible by it, meaning that the proper header file has been included. For example, printf("test") will print test to the terminal.

3.1.6 Further reading about debugging ^

If you want to know more about debugging in Visual Studio Code, the relevant chapters of the user guide and the C/C++ extension guide provide a good overview, which also includes many screenshots. In addition, this page lists advanced configuration options that can be used in launch.json.

3.2 Floating-point data types ^

3.2.1 Floating-point numbers and bit width

For applications such as scientific computing, integers are not enough; we need to be able to do calculations with non-integer numbers. Furthermore, even a 64-bit long long int can only take values with magnitude up to roughly 9×1018, but sometimes we need to do calculations with larger numbers.

One option is to use infinite-precision (or arbitrary-precision) arithmetic, which allows doing calculations with arbitrary real numbers (or at least, arbitrary computable numbers) with any number of digits and of any magnitude.

Unfortunately, infinite-precision arithmetic is not implemented at the hardware level, but rather at the software level using special libraries (which we will discuss later in the context of C and C++). Therefore, infinite-precision calculations are much slower compared to calculations that can be done directly at the level of the CPU. Unless you're doing calculations in pure math, where it is crucial to get precise results rather than approximations, it is almost never worth it to use infinite-precision arithmetic.

The CPU represents and manipulates non-integer numbers using floating-point arithmetic. Unlike integer and infinite-precision numbers, which are always exact, floating-point numbers are almost always just an approximation. This is because there is an infinite number of real numbers within any interval, but only a limited number of floating-point representations, based on how many bits are allocated to represent the number. A 32-bit floating-point number, for example, will only allow storing at most 232 different real numbers, by definition. Therefore, we can only represent numbers with a limited number of significant digits.

Floating-point arithmetic is generally very fast. In fact, a popular way to measure the performance of a computer is to find out how many floating-point operations per second (or FLOPS) it can achieve. Roughly speaking, an average laptop can achieve several hundred gigaFLOPS (where gigaFLOPS = 109 FLOPS), a high-end PC with a top-of-the-line GPU can achieve a few dozen teraFLOPS (where teraFLOPS = 1012 FLOPS), and the fastest supercomputers in the world right now can achieve several hundred petaFLOPS (where petaFLOPS = 1015 FLOPS).

A floating-point number is encoded in the CPU in a form similar to:

significand × baseexponent,

where:

  • The significand (also called the mantissa) is an integer representing the significant digits of the number.
  • The base is an integer representing the base of the number: usually 2 (binary), but it is also possible to use 10 (decimal) on some systems.
  • The exponent is an integer representing the power to which we take the base.

For example, the number 42.75 is represented as a floating-point number as follows:

42.75 = 4275 × 10-2.

This example is in base 10 because that's the base we are used to; but in the computer, this number will be represented as sequence of bits. Here there are some complications that we will discuss below, but just for the purpose of illustration, in base 2, this number has the form 101010.11. Therefore, its floating-point representation will be (notice that the base and exponent are also in binary, so 10 is actually 2):

101010.11 = 10101011 × 10-10.

Since the base is fixed, we only have two integers to store: the significand and the exponent. In this case, the significand requires 8 bits and the exponent requires 2 bits. Now, imagine that we only have 7 bits of storage for the significand. Then we will have to truncate 10101011 to 1010101 and compensate by decreasing the exponent, so the number will be:

101010.1 = 1010101 × 10-1.

In decimal, this is 42.5, which is a very crude approximation of the original number. Of course, in practice, floating-point numbers have more than 7 bits of storage. But no matter how many bits you have, floating-point numbers will always be just approximations, except for some specific numbers that can be represented exactly, such as 42.5 in this example.

In C (as in most programming languages), floating-point numbers are represented using the IEEE-754 floating-point standard, which is more complicated than our simple example. Here is how floating-point numbers are actually stored in memory:

  • Floating-point numbers are always signed, and the sign is stored in the first bit: 0 for positive and 1 for negative.
  • The next bits represent the exponent. The exponent is also signed. The way this works is that, for N bits in the exponent, the actual exponent is the bit value minus 011..111 (zero followed by N-1 ones), that is, the value minus 2N-1-1. We say that the exponent has a bias of 2N-1-1. So 011..111 is an exponent of zero, 011..110 is -1, 100..000 (one followed by N-1 zeros) is +1, and so on. Furthermore, the representations 000..000 (all 0s) and 111..111 (all 1s) are reserved for special numbers, which we will discuss below. Therefore, the available range of exponents is from -(2N-1-2) to +2N-1-1.
  • The rest of the bits represent the significand. However, although in the examples above I used an integer significand for simplicity, in IEEE-754 the significand is a fraction of the form 1 followed by a radix point (analogous to a decimal point, but in binary) and then the rest of the bits. This is similar to scientific notation; there is always exactly one digit before the radix point. Since that digit is always 1 (there are no other options - unlike in decimal, where it can be any digit from 1 to 9), the first 1 is implied and is not stored explicitly. So effectively we have one more bit of storage, but we must reserve a special value for zero.

3.2.2 Single, double, and extended precision ^

The three floating-point types used in C are:

  • float, or single-precision floating-point type. Uses a total of 32 bits, of which 1 is a sign, 8 are for the exponent, and 23 (effectively 24) for the significand.
  • double, or double-precision floating-point type. Uses a total of 64 bits, of which 1 is a sign, 11 are for the exponent, and 52 (effectively 53) for the significand.
  • long double, or extended-precision floating-point type. The number of bits here is platform-dependent, but on x86 systems it generally uses a total of 80 bits, of which 1 is a sign, 15 are for the exponent, and 64 for the significand. (Since this is not a standard IEEE-754 type, it does not use the trick where the first bit is implied to be 1.)

Note that the C standard merely requires that long double is at least as precise as double and double is at least as precise as float, but the exact sizes depend on the implementation. However, the sizes of 32, 64, and 80 bits are implemented in most systems.

The following program is analogous to the program we used above to display the ranges of integers:

#include <float.h>
#include <stdio.h>

int main(void)
{
    printf("\nA %s has a minimum value of %e and a maximum value of %e, with at least %d decimal digits of precision.\n", "float", FLT_MIN, FLT_MAX, FLT_DIG);
    printf("\nA %s has a minimum value of %e and a maximum value of %e, with at least %d decimal digits of precision.\n", "double", DBL_MIN, DBL_MAX, DBL_DIG);
    printf("\nA %s has a minimum value of %Le and a maximum value of %Le, with at least %d decimal digits of precision.\n", "long double", LDBL_MIN, LDBL_MAX, LDBL_DIG);
}

Here are some new things in this code:

  • The header file float.h stores the limits of floating-point types, just as limits.h stores the limits of integer types. They are stored in the variables FLT_MIN, FLT_MAX, and so on.
  • The format placeholder %e for printf indicates that a floating-point number is to be printed using scientific notation, i.e. as a significand followed by an exponent. The output will have the format X.XXXe±YYY where X.XXX is the significand and ±YYY is the exponent. %E does the same, but with an uppercase E for the exponent.
  • The syntax %Le is necessary if using a long double, similarly to how %lld is necessary if using a long long. On my computer, the output is:
A float has a minimum value of 1.175494e-38 and a maximum value of 3.402823e+38, with at least 6 decimal digits of precision.

A double has a minimum value of 2.225074e-308 and a maximum value of 1.797693e+308, with at
least 15 decimal digits of precision.

A long double has a minimum value of 3.362103e-4932 and a maximum value of 1.189731e+4932, with at least 18 decimal digits of precision.

On your computer, the output might be different, as your operating system, CPU, and/or compiler might not support an 80-bit long double.

In choosing between these data types, there are five factors that need to be taken into consideration:

  1. Range:
    • float has a very limited range, which is often insufficient. For example, the Planck time, a fundamental unit of measurement in theoretical physics, is approximately 5.4e-44 seconds, which is too small to be represented by a float.
    • The range of double is sufficient for almost any conceivable scientific calculation.
    • The range of long double is basically overkill and not needed in most applications.
  2. Precision:
    • float has very low precision, only guaranteeing 6 significant digits. It is therefore unsuitable for scientific calculations where accuracy is important.
    • double provides a very significant improvement, more than doubling the number of significant digits.
    • The improvement provided by long double isn't as significant, but those few extra digits may prove to be very important in some applications.
  3. Memory:
    • float takes the least amount of space, 32 bits.
    • double takes double that space, 64 bits.
    • long double, even though it technically only uses 80 bits, is actually stored in memory using 96 or 128 bits, because the computer likes to access memory in 32-bit increments. If you need to store billions of numbers in memory, this might be an issue; for example, 1 billion float numbers will take up 4 GB of memory, compared to up to 16 GB for long double numbers.
  4. Performance:
    • On most 64-bits systems, float and double offer roughly the same performance.
    • long double will generally be slower on 64-bit Intel and AMD CPUs, since it is calculated using an older and slower instruction set (namely x87, while the newer SSE instruction set only supports up to 64-bits floating-point numbers).
  5. Portability:
    • float and double are supported by virtually all 64-bit CPUs, operating systems, and compilers.
    • An 80-bit long double is not always supported. For example, in the Microsoft Visual C++ compiler, long double and double are both 64-bit. (This is done for maximum compatibility with different types of CPUs.)

My recommendation is as follows:

  • The only situation where you should use float is if memory is limited. In all other cases, float should be avoided, as it provides much less precision with no boost to performance.
  • double should be used in most cases. It is also the default floating-point type used by the C standard library functions.
  • long double should only be used when very high precision and/or range are desired, provided you are absolutely sure your program will only run on platforms that support 80-bit floating-point types, and you are willing to accept the trade-off of slower performance and higher memory usage.

3.2.3 Entering and printing floating-point numbers ^

Constant numbers written in the source code are automatically interpreted as double if they contain a decimal point followed by at least one digit. This digit can also be 0, so e.g. 1 is an integer but 1.0 is a floating-point number. To interpret them as float instead, append f to the number , e.g. 1.0f. To interpret them as long double, append L to the number , e.g. 1.0L.

We have seen above that %e is used to print floating-point numbers in scientific notation, that is, with an exponent. %f prints the number as-is, without an exponent, which can sometimes result in very long numbers. If a number is added after the %, this specifies the width for the purpose of alignment on the screen - as we saw above for integers. For example, %20f indicates that the number should take a space of (at least) 20 characters, padding with spaces if needed. Note that the decimal point also counts as one characters, so 123.456 has a width of 7.

If a . is added after the width (or after the %, if no width is specified), this specifies the number of digits to print after the decimal point. For example, %.10f indicates that there should be 10 digits after the decimal point. The default, if no value is entered, is 6 digits, so %f is equivalent to %.6f This is illustrated in the following code:

#include <stdio.h>

int main(void)
{
    const double d = 1.23456789;
    printf("%12.2f\n", d);
    printf("%12.4f\n", d);
    printf("%12f\n", d);
    printf("%12.8f\n", d);
    printf("%12.10f\n", d);
}

The output is:

        1.23
      1.2346
    1.234568
  1.23456789
1.2345678900

Notice that the numbers get rounded, not truncated. With 2 digits after the decimal, the number gets rounded down to 1.23 since the next digit is 4, while with 4 digits, it gets rounded up to 1.2346 since the next digit is 6.

3.2.4 Rounding errors and special values ^

In the last line of the program above, we printed the number 1.23456789 with 10 digits after the decimal, which is 2 more than the 8 digits after the decimal in the original number, and as a result it seemingly got padded with zeros. This seems to indicate that the number 1.23456789 is stored exactly as-is in memory. Unfortunately, that is not the case.

As we explained above, only a very small set of real numbers can be represented exactly using the limited number of bits available to us - and 1.23456789 is not one of those numbers. The difference between the intended number and the number stored in memory is called a rounding error.

For example, consider the following program:

#include <stdio.h>

int main(void)
{
    const float f = 1.23456789f;
    const double d = 1.23456789;
    const long double l = 1.23456789L;
    printf("float:       %.20f\n", f);
    printf("double:      %.20f\n", d);
    printf("long double: %.20Lf\n", l);
}

The output on my computer is:

float:       1.23456788063049316406
double:      1.23456788999999989009
long double: 1.23456789000000000003

We can see that all three floating-point data types get the first 8 significant digits right, but then differences begin to appear:

  • float, which has the smallest precision, essentially replaced the last digit, 9, with a number that is very close to 8, and the rounding error is of the order of 10-8.
  • double does a better job, replacing the last digit, 9, with something very close to 9, and the rounding error only appears in the 16th digit after the decimal, so it is of the order of 10-16
  • long double, of course, provides the highest precision. The rounding error now only appears in the 20th digit after the decimal, so it is of the order of 10-20. However, there is still a rounding error!

You will find that most real numbers inevitably introduce rounding errors when represented as floating-point numbers. This is especially true for non-integers, but even large enough integers cannot be represented exactly. For example, 4e10 can be represented exactly as a float, but 5e10 cannot, although is can still be represented exactly as a double or long double. If we go up to 5e22, it cannot even be represented exactly as double anymore, but it can be represented exactly as a long double:

#include <stdio.h>

int main(void)
{
    printf("float:       %54.30f\n", 4e10f);
    printf("double:      %54.30f\n", 4e10);
    printf("long double: %54.30Lf\n\n", 4e10L);

    printf("float:       %54.30f\n", 5e10f);
    printf("double:      %54.30f\n", 5e10);
    printf("long double: %54.30Lf\n\n", 5e10L);

    printf("float:       %54.30f\n", 5e22f);
    printf("double:      %54.30f\n", 5e22);
    printf("long double: %54.30Lf\n", 5e22L);
}

Output:

float:                   40000000000.000000000000000000000000000000
double:                  40000000000.000000000000000000000000000000
long double:             40000000000.000000000000000000000000000000

float:                   49999998976.000000000000000000000000000000
double:                  50000000000.000000000000000000000000000000
long double:             50000000000.000000000000000000000000000000

float:       49999998890981541806080.000000000000000000000000000000
double:      49999999999999995805696.000000000000000000000000000000
long double: 50000000000000000000000.000000000000000000000000000000

In the case of fractions, those that can be written exactly in binary (to within the bit limit of the significand) can generally be represented exactly as floating-point numbers. For example, 0.25 = 1/4 can be written in binary as 0.01, but 0.20 = 1/5 has the binary representation 0.0011 (i.e. 0011 repeating infinitely). Therefore, the former can be represented exactly, but the latter cannot - although it is, of course, more exact as a double and even more exact as a long double:

#include <stdio.h>

int main(void)
{
    printf("float:       %.30f\n", 0.25f);
    printf("double:      %.30f\n", 0.25);
    printf("long double: %.30Lf\n\n", 0.25L);

    printf("float:       %.30f\n", 0.20f);
    printf("double:      %.30f\n", 0.20);
    printf("long double: %.30Lf\n", 0.20L);
}

Output:

float:       0.250000000000000000000000000000
double:      0.250000000000000000000000000000
long double: 0.250000000000000000000000000000

float:       0.200000002980232238769531250000
double:      0.200000000000000011102230246252
long double: 0.200000000000000000002710505431

Another interesting feature of floating-point numbers is that some sequences of bits are reserved to represent special numbers. These include:

  • Negative zero, -0.0, which is simply zero with the sign bit set to 1. It is the same as the usual (positive) zero, but behaves as a negative number in arithmetic, so for example -0 × +0 = -0 and -0 × -0 = +0.
  • Positive and negative infinity, +∞ and -∞, which are obtained whenever a result's magnitude is above the maximum value allowed by the data type (e.g. above roughly 1.797693e+308 for a double), or for example when dividing by zero.
  • Not-a-Number, NaN, which is obtained whenever the result is undefined or indeterminate, such as when dividing zero by zero or infinity by infinity.

The following program demonstrates this:

#include <stdio.h>

int main(void)
{
    printf("+0.0 * +0.0 = %+.1f\n", +0.0 * +0.0);
    printf("-0.0 * +0.0 = %+.1f\n", -0.0 * +0.0);
    printf("-0.0 * -0.0 = %+.1f\n\n", -0.0 * -0.0);

    printf("double:      2 * 1.7e+308 = %.1e\n", 2 * 1.7e+308);
    printf("long double: 2 * 1.7e+308 = %.1Le\n\n", 2 * 1.7e+308L);

    printf("1.0 / 0.0 = %e\n", 1.0 / 0.0);
    printf("0.0 / 0.0 = %e\n", 0.0 / 0.0);
}

Output:

+0.0 * +0.0 = +0.0
-0.0 * +0.0 = -0.0
-0.0 * -0.0 = +0.0

double:      2 * 1.7e+308 = inf
long double: 2 * 1.7e+308 = 3.4e+308

1.0 / 0.0 = inf
0.0 / 0.0 = nan

Note that here we used the specifier + after % to print out the sign whether the number is positive or negative.

All of these special numbers are represented by specific strings of bits within the floating-point representation that are not used for other numbers. We won't discuss how exactly this works, since it is beyond the scope of this course. Interested students can check the Wikipedia pages for floating-point arithmetic and IEEE 754 for this and many other technical details of floating-point numbers.

3.2.5 Diving deeper into the floating point representation ^

The following program contains the function IEEE754_float(), which takes a number as a string and prints out its binary IEEE-754 representation, that is, the actual bits stored in memory, as well as some additional information. The reason it takes the number as a string is so that it can print out both the number we wanted and the number we actually got, which in most cases will be different.

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

void IEEE754_float(const char* str)
{
    printf("32-bit IEEE-754 representation of %s:\n", str);

    const float flt = strtof(str, NULL);
    const uint32_t bits = *(uint32_t*)&flt;
    char sign = (char)(((bits >> 31) & 1) + '0');
    char exp[] = "00000000";
    for (size_t i = 0; i < 8; i++)
        exp[i] = (char)(((bits >> (30 - i)) & 1) + '0');
    char mantissa[] = "00000000000000000000000";
    for (size_t i = 0; i < 23; i++)
        mantissa[i] = (char)(((bits >> (22 - i)) & 1) + '0');
    printf("%c|%s|%s\n", sign, exp, mantissa);

    printf("Sign bit: %c -> %s\n", sign, sign == '1' ? "negative" : "positive");
    printf("Exponent: %s - 01111111 -> ", exp);
    const int32_t the_exp = (int32_t)(strtol(exp, NULL, 2) - 127);
    if (the_exp == 128)
        printf("128 (inf or nan)\n");
    else
        printf("%" PRId32 "\n", the_exp);
    char mantissa_val_str[33] = "001111111";
    strcat_s(mantissa_val_str, 33, mantissa);
    uint32_t mantissa_val_int = strtoul(mantissa_val_str, NULL, 2);
    printf("Mantissa: 1.%s -> %f\n", mantissa, *(float*)&mantissa_val_int);
    printf("Actual number: %.32f\n\n", *(float*)&bits);
}

int main(void)
{
    IEEE754_float("0");
    IEEE754_float("-0");
    IEEE754_float("1");
    IEEE754_float("-1");
    IEEE754_float("1.234");
    IEEE754_float("1.25");
    IEEE754_float("1e-20");
    IEEE754_float("1e+20");
    IEEE754_float("nan");
    IEEE754_float("inf");
}

Here is the output:

0|00000000|00000000000000000000000
Sign bit: 0 -> positive
Exponent: 00000000 - 01111111 -> -127
Mantissa: 1.00000000000000000000000 -> 1.000000
Actual number: 0.00000000000000000000000000000000

32-bit IEEE-754 representation of -0:
1|00000000|00000000000000000000000
Sign bit: 1 -> negative
Exponent: 00000000 - 01111111 -> -127
Mantissa: 1.00000000000000000000000 -> 1.000000
Actual number: -0.00000000000000000000000000000000

32-bit IEEE-754 representation of 1:
0|01111111|00000000000000000000000
Sign bit: 0 -> positive
Exponent: 01111111 - 01111111 -> 0
Mantissa: 1.00000000000000000000000 -> 1.000000
Actual number: 1.00000000000000000000000000000000

32-bit IEEE-754 representation of -1:
1|01111111|00000000000000000000000
Sign bit: 1 -> negative
Exponent: 01111111 - 01111111 -> 0
Mantissa: 1.00000000000000000000000 -> 1.000000
Actual number: -1.00000000000000000000000000000000

32-bit IEEE-754 representation of 1.234:
0|01111111|00111011111001110110110
Sign bit: 0 -> positive
Exponent: 01111111 - 01111111 -> 0
Mantissa: 1.00111011111001110110110 -> 1.234000
Actual number: 1.23399996757507324218750000000000

32-bit IEEE-754 representation of 1.25:
0|01111111|01000000000000000000000
Sign bit: 0 -> positive
Exponent: 01111111 - 01111111 -> 0
Mantissa: 1.01000000000000000000000 -> 1.250000
Actual number: 1.25000000000000000000000000000000

32-bit IEEE-754 representation of 1e-20:
0|00111100|01111001110010100001000
Sign bit: 0 -> positive
Exponent: 00111100 - 01111111 -> -67
Mantissa: 1.01111001110010100001000 -> 1.475739
Actual number: 0.00000000000000000000999999968266

32-bit IEEE-754 representation of 1e+20:
0|11000001|01011010111100011101100
Sign bit: 0 -> positive
Exponent: 11000001 - 01111111 -> 66
Mantissa: 1.01011010111100011101100 -> 1.355253
Actual number: 100000002004087734272.00000000000000000000000000000000

32-bit IEEE-754 representation of nan:
0|11111111|10000000000000000000000
Sign bit: 0 -> positive
Exponent: 11111111 - 01111111 -> 128 (inf or nan)
Mantissa: 1.10000000000000000000000 -> 1.500000
Actual number: nan

32-bit IEEE-754 representation of inf:
0|11111111|00000000000000000000000
Sign bit: 0 -> positive
Exponent: 11111111 - 01111111 -> 128 (inf or nan)
Mantissa: 1.00000000000000000000000 -> 1.000000
Actual number: inf

The output is mostly self-explanatory. Notice that inf and nan are indicated by setting the exponent to 128. You should try it out with different numbers to see what you get. Developing intuition for how floating point numbers work is very important, because using them incorrectly can and will lead to incorrect results.

In this program, I used some more advanced programming techniques such as pointers and string operations, which I will not explain right now, as we will learn about them later in a more organized fashion. Come back after the C portion of the course is over, and the code will be much easier to understand.

3.2.6 Common mathematical functions ^

The header file tgmath.h contains many useful mathematical functions that act on floating-point numbers. In older C standards, you had to use math.h, which contained a different function for each type; for example, exp() is the exponential function that takes a double and outputs a double, while expf() does the same with float and expl() with long double. However, tgmath.h, which stands for type-generic math, is much convenient since it offers just one function exp() which accepts all data types.

Here is an example:

#include <stdio.h>
#include <tgmath.h>

int main(void)
{
    const double x = 2.0;
    printf("sqrt(2)   = %f\n", sqrt(x));
    printf("exp(2)    = %f\n", exp(x));
    printf("log(2)    = %f\n", log(x));

    const long double pi = acos(-1.0L);
    printf("sin(pi/4) = %Lf\n", sin(pi / 4));
    printf("cos(pi/3) = %Lf\n", cos(pi / 3));
    printf("tan(pi/2) = %Lf\n", tan(pi / 2));
}

The output on my computer is:

sqrt(2)   = 1.414214
exp(2)    = 7.389056
log(2)    = 0.693147
sin(pi/4) = 0.707107
cos(pi/3) = 0.500000
tan(pi/2) = -36893488147419103232.000000

Here I used a trick: to find the value of pi, I took the arccosine of -1, acos(-1.0L). Note that tan(pi/2) should be infinity, but due to accumulating errors, it is merely a very large but non-infinite number. As we stressed before, floating-point arithmetic is not precise! Infinite-precision arithmetic would have provided us with the exact answer "infinity", but would have taken longer to calculate.

For a full list of the available functions in tgmath.h, please see the C reference.

3.2.7 Complex numbers ^

In scientific programming, we sometimes need to use complex numbers, that is, numbers of the form a+ib where i2=-1. For example, complex numbers are used in Fourier transforms. The header file tgmath.h allows us to use complex numbers in C. (There is also another header file complex.h which was used in older C standards, but it is not type-generic and thus less convenient to use.)

We declare complex variables using float complex, double complex, or long double complex, and enter the imaginary unit i using I. Many functions declared in tgmath.h, such as exp, log, and so on, work on complex numbers as well.

Furthermore, there are functions specific to complex numbers:

  • creal returns the real part.
  • cimag returns the imaginary part.
  • conj returns the complex conjugate.
  • carg returns the argument (or phase), i.e. the angle on the complex plane.
  • fabs returns the absolute value (or magnitude). Do not use cabs, as it is not type-generic.
Warning: If using tgmath.h, the symbol I is used for the imaginary unit, so uppercase I cannot be used as a variable name.

Here is an example:

#include <stdio.h>
#include <tgmath.h>

int main(void)
{
    const double complex z = 1 + 2 * I;
    printf("z      = %4.1f%+4.1fi\n", creal(z), cimag(z));
    printf("z*     = %4.1f%+4.1fi\n", creal(conj(z)), cimag(conj(z)));
    printf("|z|    = %9f\n", fabs(z));
    printf("arg(z) = %9f\n", carg(z));
    printf("z^2    = %4.1f%+4.1fi\n", creal(pow(z, 2)), cimag(pow(z, 2)));
    printf("1/z    = %4.1f%+4.1fi\n", creal(1 / z), cimag(1 / z));
}

Output:

z      =  1.0+2.0i
z*     =  1.0-2.0i
|z|    =  2.236068
arg(z) =  1.107149
z^2    = -3.0+4.0i
1/z    =  0.2-0.4i

3.3 Type conversion ^

As we have seen, variables in C belong to very strictly defined and constrained data types. However, it is possible to convert from one type to another.

3.3.1 Implicit conversion

When a value in one type is assigned to a variable declared in another type, that value is implicitly converted to the target variable's type. For example, a number with a decimal point in the source code is automatically interpreted as a double, but if we assign it to a variable with an integer data type, it will be implicitly converted to an integer. Note that the value will not be rounded to the nearest integer; anything after the decimal point will simply be thrown away.

To illustrate, the following code will output 3, meaning that x has the value 3 and not 3.6, due to implicit conversion from double to int64_t:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

int main(void)
{
    const int64_t x = 3.6;
    printf("%" PRId64 "\n", x);
}

If you turned on the -Wconversion compiler flag as I instructed above, you will be warned that this implicit conversion changes the value of x. Therefore, it is very important to turn this warning flag on, as otherwise you may not be aware that x does not have the correct value.

Warning: Implicit conversion can lead to serious bugs. There is really no reason to ever use it intentionally, and it should be avoided at all costs. Make sure to always enable the -Wconversion flag in order to detect accidental implicit conversions!

3.3.2 Explicit conversion: type casting ^

A value can be explicitly converted from one data type to another using type casting. To do this, add the target type in brackets before the value to be converted.

One example where this is needed is when we divide two integers. In C, dividing two integers results in an integer, with anything after the decimal truncated. So for example, 5/3 ≈ 1.666667 will be truncated to 1. To get a fraction, we must first cast the integers to a floating-point data type such as double:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

int main(void)
{
    const int64_t x = 5, y = 3;
    // Integer division truncates towards zero, so 5/3 will be truncated to 1.
    printf("Integer division:   x / y = %" PRId64 "\n", x / y);
    // Generates a warning and does not print the correct result, since we are trying to format an integer as a floating-point number.
    printf("No casting:         x / y = %f\n", x / y);
    // Generates a warning, but does print the correct result since x is cast to double.
    printf("Casting x:          x / y = %f\n", (double)x / y);
    // Same as the last one, but this time we cast y to double, with the same effect.
    printf("Casting y:          x / y = %f\n", x / (double)y);
    // To avoid the warning, we must cast both integers to double.
    printf("Casting both:       x / y = %f\n", (double)x / (double)y);
}

Output:

Integer division:   x / y = 1
No casting:         x / y = 0.000000
Casting x:          x / y = 1.666667
Casting y:          x / y = 1.666667
Casting both:       x / y = 1.666667

3.4 Pointers ^

3.4.1 Memory addresses and the stack

Warning: Pointers are possibly the most confusing thing in C, so make sure to read this section very carefully!

Any variable defined in C (or indeed, any programming language) is stored in the computer's memory. We can find the variable's address - a number specifying where in the memory it is stored - by preceding the variable's name with an ampersand &. Consider the following program:

#include <stdint.h>
#include <stdio.h>

int main(void)
{
    const int8_t var_int8_t = 0;
    const int16_t var_int16_t = 0;
    const int32_t var_int32_t = 0;
    const int64_t var_int64_t = 0;
    const long double var_long_double = 0;
    const double var_double = 0;
    const float var_float = 0;

    printf("sizeof(var_int8_t)      = %2zu, &var_int8_t      = %p\n", sizeof(var_int8_t), (void *)&var_int8_t);
    printf("sizeof(var_int16_t)     = %2zu, &var_int16_t     = %p\n", sizeof(var_int16_t), (void *)&var_int16_t);
    printf("sizeof(var_int32_t)     = %2zu, &var_int32_t     = %p\n", sizeof(var_int32_t), (void *)&var_int32_t);
    printf("sizeof(var_int64_t)     = %2zu, &var_int64_t     = %p\n", sizeof(var_int64_t), (void *)&var_int64_t);
    printf("sizeof(var_long_double) = %2zu, &var_long_double = %p\n", sizeof(var_long_double), (void *)&var_long_double);
    printf("sizeof(var_double)      = %2zu, &var_double      = %p\n", sizeof(var_double), (void *)&var_double);
    printf("sizeof(var_float)       = %2zu, &var_float       = %p\n", sizeof(var_float), (void *)&var_float);
}

On my computer, one possible output is as follows:

sizeof(var_int8_t)      =  1, &var_int8_t      = 000000af047ff7ff
sizeof(var_int16_t)     =  2, &var_int16_t     = 000000af047ff7fc
sizeof(var_int32_t)     =  4, &var_int32_t     = 000000af047ff7f8
sizeof(var_int64_t)     =  8, &var_int64_t     = 000000af047ff7f0
sizeof(var_long_double) = 16, &var_long_double = 000000af047ff7e0
sizeof(var_double)      =  8, &var_double      = 000000af047ff7d8
sizeof(var_float)       =  4, &var_float       = 000000af047ff7d4

This program prints out the memory addresses of variables of various bit sizes. The output will change every time you run the program, since the program's memory is allocated in a different location each time it runs.

To print the addresses, I used the format placeholder %p, which is intended specifically to print memory addresses. The type cast (void *) is used to convert the pointers to the appropriate data type, void *, which is expected by %p. (The code will work even without the casting, but it will produce warnings.)

%p prints out each address as a 16-digit hexadecimal number, which is appropriate since its size is exactly 64 bits. Hexadecimal is base 16, so one hexadecimal digit represents log216 = 4 bits, and thus 16 digits represent 16 × 4 = 64 bits.

For the digits above 9, we use a = 10, b = 11, c = 12, d = 13, e = 14, and f = 15. Therefore, 0000000000000000 is the very first memory address, and ffffffffffffffff would be the largest address it is possible to access on a 64-bit system (if I happened to have 264 bytes = 16 exabytes of memory).

The memory for these variables was allocated automatically by the compiler; I didn't have to explicitly allocate memory for them. This means that these variables are stored in the stack, which is the (small) portion of memory used to store all of the variables for which memory has been automatically allocated.

The stack is small, typically around 1-8 MB, and is generally used to store small amounts of temporary data, such as local variables, and not anything that's too big (such as large arrays) or that needs to be stored for a long time, which should instead be stored in the heap (more on that later).

As you can see by comparing the last digits of each address, newer variables stored at lower addresses than older variables. This is how addresses in the stack are usually allocated: from top to bottom. The data for the variables themselves is stored in consecutive bytes in ascending order, so for example, var_int16_t takes up the 2 bytes at the addresses 000000af047ff7fc and 000000af047ff7fd.

If you run the program several times, you may notice that the last digit always stays the same; this is due to stack alignment. On 64-bit CPUs, memory blocks are aligned to 16 bytes (or 128 bits), which means each block starts at a memory address that is a multiple of 16 - and therefore the starting address always ends with a 0, even though the preceding digits can be arbitrary. Since the stack for this program always contains the same variables with the same sizes, their addresses will always have the same last digit.

The stack pointer holds the address of the last variable that was allocated in the stack. When a new variable is declared, the stack pointer goes down one step, and when the scope of a variable expires (i.e. we exit the code block in which it was declared), the stack pointer goes up one step. You can see an illustration on Wikipedia.

You may be wondering why there is a gap of 3 bytes between var_int8_t and var_int16_t (in hexadecimal, f - c = 3), even though var_int16_t only takes up 2 bytes. This is again due to alignment; each variable must be stored at an address that is an integer multiple of the size of the variable, so var_int16_t must be stored at an even address. Storing it at 00000012afdff9fd would eliminate the gap, but would also violate the alignment, since this address is odd. If you add another 1-byte variable between var_int8_t and var_int16_t, then you will see that it fills the gap.

3.4.2 Using pointers ^

A pointer is simply a variable which points to a particular address in memory. As I said in the introduction, C, unlike higher-level programming languages, allows you to access memory directly - which, if done correctly, can result in significantly improved flexibility, performance, and resource use compared to other languages. Directly accessing memory is done using pointers.

Pointers are declared using an asterisk *, with the syntax:

type *name;

Here, type indicates the type of the variable the pointer points to - not the type of the actual value of the pointer itself, which (on a 64-bit system) is always going to be a 64-bit integer, since memory addresses are 64 bits long. * indicates that we are declaring a pointer, and name is the name of the pointer.

Once we declared the pointer, name will be the address it points to, while *name will be the variable stored at that address. This is demonstrated in the following program:

#include <stdint.h>
#include <stdio.h>

int main(void)
{
    int32_t x = 7;
    int32_t *p = &x;
    printf("p = %p, *p = %d\n", (void *)p, *p);
    *p = 9;
    printf("x = %d\n", x);
}

On my computer, the output is:

p = 000000000061fe14, *p = 7
x = 9

Let us go over the code line by line:

  • In the first line, int32_t x = 7; declares and initializes a 32-bit integer variable x. This means that 32 bits of memory are automatically allocated (in the stack), and the number 7 is then stored at that point in memory.
  • In the second line, int32_t *p = &x; declares a pointer p which will be used to point to a 32-bit integer. We initialize p to &x, which is the (64-bit) address where the value of x is stored in memory.
  • In the third line, we print out the values of p and *p. You can see that p is a memory address, while *p is the value stored at that address, which is 7 since that is the value we assigned to x. Reading or writing the value at the address pointed to by a pointer via the syntax *p is called dereferencing a pointer.
  • In the fourth line, *p = 9 changes the value at the address pointed to by p (not the value of p itself) to 9. Of course, since the value at that address is none other than the value of x, this means that the value of x will now be 9. This is another example of dereferencing a pointer.
  • In the fifth line, we print the value of x to verify this.
Warning: A pointer's value is always a 64-bit memory address, but it can only point to variables of a specific data type chosen at declaration time. The declaration type * means "pointer to a variable of the given type". If you try to assign to a pointer the address of a variable of a different type, e.g. int16_t x; int8_t *p = &x;, you will get a warning from the compiler.

Note that the declaration void * can be used to define (or type cast) a general pointer that doesn't refer to any specific data type; this is what we used in the programs above to print the addresses using the %p placeholder.

It is important to understand that even if we don't use pointers explicitly in our code, behind the scenes any variable is nothing but a pointer to a certain address in memory. This means that every time we write something like x = 7, what actually happens is *(&x) = 7, that is, the number 7 is stored in the address in memory pointed to by &x. In other words, once we assign p = &x, the dereference *p is essentially synonymous to x.

3.4.3 Constant pointers vs. pointers to constant variables ^

Consider the following program:

#include <stdio.h>

int main(void)
{
    double var = 0;
    double other_var = 0;
    const double con = 0;
    const double other_con = 0;

    double *var_ptr_to_var = &var;
    double *const con_ptr_to_var = &var;
    const double *var_ptr_to_con = &con;
    const double *const con_ptr_to_con = &con;

    var = 1; // Legal: var is not const, so can be changed.
    con = 1; // Illegal: con is const, so cannot be changed.

    var_ptr_to_var = &other_var; // Legal: pointer itself is not const, so can be changed.
    *var_ptr_to_var = 2;         // Legal: variable pointed to is not const, so can be changed.

    con_ptr_to_var = &other_var; // Illegal: pointer itself is const, so cannot be changed.
    *con_ptr_to_var = 2;         // Legal: variable pointed to is not const, so can be changed.

    var_ptr_to_con = &other_con; // Legal: pointer itself is not const, so can be changed.
    *var_ptr_to_con = 2;         // Illegal: variable pointed to is const, so cannot be changed.

    con_ptr_to_con = &other_con; // Illegal: pointer itself is const, so cannot be changed.
    *con_ptr_to_con = 2;         // Illegal: variable pointed to is const, so cannot be changed.
}

This program illustrates an important distinction between:

  1. A constant vs. non-constant variable,
  2. A constant vs. non-constant pointer.
  • A non-constant variable has the type type. The variable can then be changed at will.
  • A constant variable has the type const type. Once it is defined and initialized, it can never be changed. This has two benefits. First, it helps the human reader know that the variable is not supposed to be changed, and if they try to modify the program in a way that changes the variable (which can potentially cause bugs), it will not compile. Second, it helps the compiler know that the variable will not change, which could result in better optimizations.
  • A non-constant pointer has the type type * or const type *. The pointer itself can then be changed at will, meaning that the memory address it points to can be changed to a different address. Whether the actual variable stored at that address can be changed depends on whether that variable is constant or not:
    • type * is a non-constant pointer to a non-constant variable of type type, so the variable can be changed as well.
    • const type * is a non-constant pointer to a constant variable of type const type, so the variable cannot be changed, even though the pointer itself can be changed.
  • A constant pointer has the type type *const or const type *const. The pointer itself can never be changed, meaning that it forever points to the same memory address. Whether the actual variable stored at that address can be changed depends on whether that variable is constant or not:
    • type *const is a constant pointer to a non-constant variable of type type, so the variable can be changed even though the pointer cannot be changed.
    • const type *const is a constant pointer to a constant variable of type const type, so the variable cannot be changed either.

This can be confusing at first, but it becomes easier to understand once you realize that types in C must be read from right to left:

  • type x means "a variable x of type type".
  • const type x means "a variable x of type type which is constant".
  • type *p means "a variable p which serves as a pointer (*) to a variable of type type".
  • const type *p means "a variable p which serves as a pointer (*) to a variable of type type which is constant".
  • type *const p means "a variable p which is constant and serves as a pointer (*) to a variable of type type".
  • const type *const p means "a variable p which is constant and serves as a pointer (*) to a variable of type type which is constant".

3.4.4 Arrays and pointers ^

An array in C is actually nothing more than a pointer to the address in memory where the first element of the array is stored. Consider the following code:

#include <stdint.h>
#include <stdio.h>

int main(void)
{
    const int32_t a[] = {2, 3, 5};
    printf("First element:  address %p, value %d\n", (void *)a, *a);
    printf("Second element: address %p, value %d\n", (void *)(a + 1), *(a + 1));
    printf("Third element:  address %p, value %d\n", (void *)(a + 2), *(a + 2));
}

On my computer, the output is:

First element:  address 000000000061fe14, value 2
Second element: address 000000000061fe18, value 3
Third element:  address 000000000061fe1c, value 5

In the first line, we see that the value of the array a itself is, in fact, a pointer to the address where the first element is stored - on my computer, that address is 000000000061fe14. When we dereference that pointer using *a, we get the first element of the array, a[0].

In the next line, we add 1 to the address. Confusingly, this does not result in 000000000061fe15, as one might expect, but rather in 000000000061fe18. This is because each element of the array is a 32-bit integer, and 32 bits is 4 bytes; when we add 1 to the pointer, C instead adds the number of bytes required to advance to the next element in the array - in this case, 4 bytes. Similarly, when we add 2 to the pointer in the third line, C actually adds 8 to the address. This is called pointer arithmetic. From this we learn that a[n] in C is actually just a convenient shorthand for *(a + n)!

Also note that a is automatically a constant pointer, since if you try to assign any value to a itself, the program will not compile.

3.4.5 Functions and pointers ^

When a function gets its input arguments, they are stored in new local variables within that function's scope. These variables are, as we explained above, completely independent of any other variables in the program, and will be destroyed when the function finishes executing.

Consider the following (incorrect) program:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

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

int main(void)
{
    int64_t a = 1, b = 2;
    printf("Before swap: a = %" PRId64 ", b = %" PRId64 "\n", a, b);
    swap(a, b);
    printf("After swap:  a = %" PRId64 ", b = %" PRId64 "\n", a, b);
}

If you run the program, you will see that a and b did not actually swap their values. This is because when we called swap(a, b), the values of a and b are stored in the new local variables x and y, which exist at a completely different place in memory. The function then swaps these two local variables with each other, but the original variables a and b remain untouched.

You might think that changing the names of a and b in main to x and y will solve the problem. However, as we stressed above (see variable scope and functions), any variable declared within a scope is a new variable local to that scope, regardless of whether any other variables with the same name already exist in other scopes. Therefore, this will not work.

To make this program work, we must give the function swap the pointers to a and b instead of their values. Here is how to do it:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

void swap(int64_t *const x, int64_t *const y)
{
    const int64_t temp = *x;
    *x = *y;
    *y = temp;
}

int main(void)
{
    int64_t a = 1, b = 2;
    printf("Before swap: a = %" PRId64 ", b = %" PRId64 "\n", a, b);
    swap(&a, &b);
    printf("After swap:  a = %" PRId64 ", b = %" PRId64 "\n", a, b);
}

Now swap is declared with arguments that are pointers to integers, and accesses the values of these integers by dereferencing them, with the usual syntax *x and *y. When we call swap, we do not pass the values of a and b, but rather their addresses, using the syntax &a and &b. If you run this code, you will see that it works as intended:

Before swap: a = 1, b = 2
After swap:  a = 2, b = 1

Notice that the arguments of swap() have the type int64_t *const, which as we explained above means the pointer itself is constant, but not the variable it points to. If you instead write const int64_t *, you will get an error, because then the variable itself is const and thus cannot be changed. int64_t *const means "a const pointer to a variable of type int64_t", thus the pointer (i.e. the address of the variable) is constant, which is indeed the case here since we never change the actual pointer, but the variable itself (i.e. the value at that address) can be changed, which must be the case here since we want to change both variables.

3.4.6 Passing arrays to functions ^

Consider the following program:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

void print_array(const char name[const], const uint64_t size, const uint64_t the_array[const])
{
    for (uint64_t i = 0; i < size; i++)
        printf("%s[%" PRIu64 "] = %2" PRIu64 " (%p)\n", name, i, the_array[i], (void *)&the_array[i]);
}

int main(void)
{
    const uint64_t primes[] = {2, 3, 5, 7, 11};
    print_array("primes", 5, primes);
}

The function print_array() takes three arguments:

  1. An array of chars, or in other words, a string, which provides the name of the array to print.
  2. The size of the array to print. This must be given, since the function can't tell the size of the array otherwise, so it won't know how many elements to print!
  3. An array of uint64_t, which is the actual array to print.

(I'll explain what the [const] means in a moment.) The function prints the elements of the_array, as well as the memory address of each element. One possible output is:

primes[0] =  2 (000000893a3ffd40)
primes[1] =  3 (000000893a3ffd48)
primes[2] =  5 (000000893a3ffd50)
primes[3] =  7 (000000893a3ffd58)
primes[4] = 11 (000000893a3ffd60)

Notice that the values are stored in consecutive addresses, with 8 bytes (the size of an int64_t) between each address.

Above we said that an array in C is equivalent to a pointer to the memory address of the first element. Therefore, when we pass an array to a function, we are actually passing a pointer. In fact, we can replace the arrays in the arguments of print_array() with pointers to the appropriate data types, and the program will run exactly the same:

void print_array(const char *const name, const uint64_t size, const uint64_t *const the_array)

By comparing this with the previous function definition we can see what [const] means in the declaration of the arrays as function arguments. type a[] is the same as type *a, that is, a non-constant pointer, while type a[const] is the same as type *const a, that is, a constant pointer.

To illustrate, consider what happens if we add the following line to the function:

name = "different name";

Note that this does not modify the contents of the memory address pointed to by name! Instead, it modifies name itself to point to a different memory address.

With the definition const char name[const] (equivalent to const char *const name), the pointer name is constant, and the code will not compile. However, if you change the definition to const char name[] (equivalent to const char *name), the code will compile. Of course, whether you should define the pointer as const or not depends on whether you intend to change it or not.

So which syntax should you use for passing arrays to functions, array syntax type a[] or pointer syntax type *a? Personally I prefer the array syntax, since it gives more information to the reader. If you write type *a, then the reader may think a is just a pointer to a single variable. Writing type a[] makes it clear that a is expected to be an array of multiple elements.

Warning: Because arrays are always passed as pointers, they are never copied. This means that when a function modifies an array, it doesn't modify a local copy, it modifies the original array. If you do not intend to modify the array at all, make sure to pass it as const to prevent accidental modification. If you do intend to modify the array, but you want to do so only locally within the function without affecting the original array, then you will have to copy the elements to a new array yourself; however, generally there is no reason to do this, and it can slow down your program, as copying large arrays takes time.

3.4.7 Passing multi-dimensional arrays to functions ^

For multi-dimensional arrays, the situation is a bit more complicated. If the dimensions of the array are known at compilation time, then we can pass the array directly:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

void print_array_2D(const char name[const], const uint64_t the_array[const 3][2])
{
    for (uint64_t i = 0; i < 3; i++)
        for (uint64_t j = 0; j < 2; j++)
            printf("%s[%" PRIu64 "][%" PRIu64 "] = %" PRIu64 " (%p)\n", name, i, j, the_array[i][j], (void *)&the_array[i][j]);
}

int main(void)
{
    const uint64_t matrix[3][2] = {{1, 2},
                                   {3, 4},
                                   {5, 6}};
    print_array_2D("matrix", matrix);
}

To pass the_array as a constant pointer, we added const inside the first bracket. If we intended to change the pointer for some reason, we would have passed it as the_array[3][2] instead.

One possible output is:

matrix[0][0] = 1 (000000986d9ff930)
matrix[0][1] = 2 (000000986d9ff938)
matrix[1][0] = 3 (000000986d9ff940)
matrix[1][1] = 4 (000000986d9ff948)
matrix[2][0] = 5 (000000986d9ff950)
matrix[2][1] = 6 (000000986d9ff958)

Notice, again, that the elements are stored consecutively in steps of 8 bytes. In fact, we see that this 2-dimensional array is just a 1-dimensional array in disguise! The element matrix[i][j] is actually the element matrix[2 * i + j]. This means that the compiler must know the number of columns in the array in order to use the matrix[i][j] notation; if the number of columns is unknown, then the compiler won't know how to convert matrix[i][j] to the correct 1-dimensional array element matrix[2 * i + j].

Since the compiler only needs to know the number of columns, but not the number of rows, we can also define print_array_2D without specifying the number of rows, as follows:

void print_array_2D(const char name[const], const uint64_t the_array[const][2])

However, if we don't specify any dimensions, the_array[][], or we only specify the number of rows, the_array[3][], then the program will not compile. This is not a desirable situation, since in general we want to have generic functions that can accept arrays of any size, as we had above for the 1-dimensional arrays.

To write a generic function, we can use the following syntax:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

void print_array_2D(const char name[const], const uint64_t rows, const uint64_t cols, const uint64_t the_array[const rows][cols])
{
    for (uint64_t i = 0; i < rows; i++)
        for (uint64_t j = 0; j < cols; j++)
            printf("%s[%" PRIu64 "][%" PRIu64 "] = %" PRIu64 " (%p)\n", name, i, j, the_array[i][j], (void *)&the_array[i][j]);
}

int main(void)
{
    const uint64_t matrix[3][2] = {{1, 2},
                                   {3, 4},
                                   {5, 6}};
    print_array_2D("matrix", 3, 2, matrix);
}

Note that the arguments rows and cols must appear before the array itself in the argument list, since we use their values to define the dimensions of the array.

Another way to do this, which does not require passing rows and cols before the array itself, is to cast the array into a pointer to a 1-dimensional array, pass that pointer, and then use the_array[cols * i + j] explicitly. This will have the same result:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

void print_array_2D(const char *const name, const uint64_t *const the_array, const uint64_t rows, const uint64_t cols)
{
    for (uint64_t i = 0; i < rows; i++)
        for (uint64_t j = 0; j < cols; j++)
            printf("%s[%" PRIu64 "][%" PRIu64 "] = %" PRIu64 " (%p)\n", name, i, j, the_array[cols * i + j], (void *)&the_array[cols * i + j]);
}

int main(void)
{
    const uint64_t matrix[3][2] = {{1, 2},
                                   {3, 4},
                                   {5, 6}};
    print_array_2D("matrix", (uint64_t *)matrix, 3, 2);
}

(Here we also passed name as a pointer, just for consistency.)

3.4.8 Jagged arrays ^

The 2-dimensional array we used above had the shape of a matrix, with the same number of columns in each row. A jagged array is a 2-dimensional array in which each row can contain a different number of columns. More generally, a jagged n-dimensional array is an array of arrays (of arrays, etc...), with sub-arrays of different sizes.

A 2-dimensional jagged array is thus as an array of arrays, which means an array of pointers. A commonly-used type of jagged array is an array of strings; since a string is just an array of chars, this is indeed an array of arrays. Here is an example:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>

void print_jagged_array(const char name[const], const char *const the_array[const], const uint64_t rows)
{
    for (uint64_t i = 0; i < rows; i++)
        printf("%s[%" PRIu64 "] = %-5s (%p -> %p, size %zu)\n", name, i, the_array[i], (void *)&the_array[i], (void *)the_array[i], strlen(the_array[i]));
}

int main(void)
{
    const char *const jagged[] = {"One", "Two", "Three", "Four"};
    print_jagged_array("jagged", jagged, 4);
}

The output looks like this:

jagged[0] = One   (0000004fb21ffd10 -> 00007ff6a85bb076, size 3)
jagged[1] = Two   (0000004fb21ffd18 -> 00007ff6a85bb07a, size 3)
jagged[2] = Three (0000004fb21ffd20 -> 00007ff6a85bb07e, size 5)
jagged[3] = Four  (0000004fb21ffd28 -> 00007ff6a85bb084, size 4)

Each of the elements of jagged is a pointer to a string. For example, jagged[0], the first element, is located at 0000004fb21ffd10, and contains the pointer 00007ff6a85bb076. At the memory address 00007ff6a85bb076, we find the string "One", which has a size of 3. (We used the function strlen(), from the header file string.h, to find the length of the string; you can find a list of all the functions available in this header file in the C reference.)

Notice that the strings are also stored consecutively in memory, with the number of bytes used by each string being its size plus 1 (recall that in C, every string has a null character appended to it, which indicates where the string ends). For example, the string "One" uses the 4 bytes from 00007ff6a85bb076 to 00007ff6a85bb079, while the string "Three" uses the 6 bytes from 00007ff6a85bb07e to 00007ff6a85bb083.

We defined the function print_jagged_array() to take the array as a const char *const the_array[const], which means "a constant pointer to an array ([const]) named the_array which contains const pointers (*) to chars which are const".

We could also define the argument as const char *const *const the_array, which means "a variable named the_array which is a const pointer (*) to a const pointer (*) to a char which is const". (Again, types in C are best understood by reading from right to left!)

As with 1-dimensional arrays, the notation type *a[] is more informative than type **a, since it tells us a is supposed to be an array of pointers to type, and not just a pointer to a single pointer to type. Therefore, the notation type *a[] (with or without const) should be preferred.

Do we really need three separate consts in the function argument definition? Probably not... You will most likely never see anything like const char *const *const in "real life", but I used it here for pedagogical reasons, since it guarantees 100% that a beginner programmer cannot change any aspect of the input argument and potentially cause bugs. Experienced C programmers don't really need all this extra protection from human error, so you generally won't see three consts in one definition, but there's nothing wrong with taking precautions, except that it looks a bit cumbersome.

However, since I initially defined jagged as const char *const jagged[], which has two separate consts, if I just wrote char **the_array or equivalently char *the_array[] (without any consts), or even const char **the_array or equivalently const char *the_array[], or char *const *the_array or equivalently char *const the_array[] (with just one const), I would have received a warning from the compiler, since the type of the_array would not have matched the type of jagged. Remember: const is considered part of the type definition. The third const, however, is optional; that's the one that protects the pointer the_array itself, rather than defining the type of the array elements.

3.5 Dynamic memory allocation ^

3.5.1 Allocating memory in the heap

Above we said that when C allocates memory for a variable automatically, it does so in the stack. This can be done for any variable whose size is already known at compilation time - that is, we explicitly specified in the source code that we are, for example, allocating a 64-bit double, or an array of five 8-bit chars.

However, often we do not know in advance how much memory we need to allocate. A common example is reading data from a file, which can be of any size. In this case, we must employ dynamic memory allocation, manually allocating the required amount of memory at run time. In this case, the memory will not be allocated from the stack, but rather from the heap.

The stack is typically only a few MB in size. The heap is much larger - typically only limited by how much total memory the computer has. Therefore, for very large arrays, even if the size is already known at compile time, it is often best to allocate memory for them in the heap anyway - otherwise, we may run out of space in the stack, which results in a stack overflow error, and typically causes the program to crash.

To use dynamic memory allocation, we must include the header file stdlib.h, which stands for "standard library". The relevant functions are:

  • malloc(total_size) allocates total_size bytes and returns a pointer to the memory address where the allocated block begins.
  • calloc(number, element_size) allocates memory for an array with number elements of element_size bytes each, initializes all elements to zero, and returns a pointer to the memory address where the allocated block begins. Note that calloc(x, y) is equivalent to malloc(x * y) in terms of how much space is allocated.
  • realloc(pointer, new_size) resizes the memory block allocated at pointer by malloc, calloc, or realloc to new_size. Returns a new pointer, which may point to an address different from the old pointer, especially if the block size has increased and needed to be moved to a location with more free space. If the new address is different, realloc automatically copies the contents of the memory block at the old address and frees it up.
  • free(pointer) frees up (deallocates) the space allocated at the pointer by malloc, calloc, or realloc.

3.5.2 Proper use of malloc and calloc ^

Warning: Improper use of dynamic memory allocation is a very common source of serious bugs and crashes, even for experienced C programmers!

To avoid bugs, you must make sure to use dynamic memory allocation properly, by following these guidelines:

  1. Always check for allocation failure. If malloc, calloc, or realloc fail, they return a null pointer, which is given by the constant NULL. This should be checked whenever you use any of these functions, and if the result is NULL, the program should either do something else (e.g. try to allocate less memory), print out an error message, and/or quit.
  2. Always deallocate memory using free when you are done with it. Failure to do so will cause a memory leak, and the computer may run out of memory. If the pointer to the allocated memory was defined within a scope (such as a function), then it must be freed before the scope ends, since otherwise the variable containing the pointer will no longer be accessible.
  3. Never try to use memory before allocating it, or after freeing it. This will cause a segmentation fault and crash your program.

The following program demonstrates proper use of dynamic memory allocation with malloc and free:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    const uint64_t size = 1000;
    int64_t *array = malloc(size * sizeof(*array));
    if (array == NULL)
    {
        printf("Failed to allocate memory!\n");
        return -1;
    }
    printf("Successfully allocated memory for an array of %zu elements of %zu bytes each at address %p using malloc.\n", size, sizeof(*array), (void *)array);
    printf("First element (uninitialized): %" PRId64 ".\n", array[0]);
    free(array);
}

Notice that if we fail to allocate the memory, we terminate the program by calling return -1. Recall that return is used to return a certain value from a function. But main() is a special function, which contains the main code of the program, so calling return in main() terminates the program itself. The integer value that is returned can then (optionally) be processed by some other program.

If no return value is specified, main() returns 0 by default, which means the program finished successfully. Any number other than 0 means there was an error, and here we are using the number -1 to indicate an error with memory allocation, although we could have used any other number. If we call this program as part of a script, then we can check the return value to figure out if the program ran successfully, or if not, what was the error.

If memory is being allocated inside a function, and we wish to terminate the program, then of course we cannot call return, since that will just terminate that particular function and not the whole program. In such a case we should use exit(n) where n is the number we want the program to return. For example:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>

int64_t *allocate_memory(const uint64_t size)
{
    int64_t *array = malloc(size * sizeof(*array));
    if (array == NULL)
    {
        printf("Failed to allocate memory!\n");
        exit(-1);
    }
    printf("Successfully allocated memory for an array of %zu elements of %zu bytes each at address %p using malloc.\n", size, sizeof(*array), (void *)array);
    return array;
}

int main(void)
{
    int64_t *array = allocate_memory(1000);
    printf("First element (uninitialized): %" PRId64 ".\n", array[0]);
    free(array);
}

In the following program, we replaced malloc with calloc, so now the array is initialized to zero, which is often the preferred behavior:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    const uint64_t size = 1000;
    int64_t *array = calloc(size, sizeof(*array));
    if (array == NULL)
    {
        printf("Failed to allocate memory!\n");
        return -1;
    }
    printf("Successfully allocated memory for an array of %zu elements of %zu bytes each at address %p using calloc.\n", size, sizeof(*array), (void *)array);
    printf("First element (initialized): %" PRId64 ".\n", array[0]);
    free(array);
}

3.5.3 Proper use of realloc ^

When using realloc, we need to be careful, because if it fails, the original pointer still remains valid and needs to be deallocated with free to prevent memory leaks. Therefore, we can't use the same variable to store both the old pointer and the new pointer. Here is how to use realloc properly:

#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    const uint64_t size1 = 1000, size2 = 100, size3 = 10000;
    int64_t *array1 = NULL, *array2 = NULL, *array3 = NULL;

    array1 = malloc(size1 * sizeof(*array1));
    if (array1 == NULL)
    {
        printf("Failed to allocate memory!\n");
        return -1;
    }
    printf("Successfully allocated memory for an array of %zu elements of %zu bytes each at address %p using malloc.\n", size1, sizeof(*array1), (void *)array1);

    array2 = realloc(array1, size2 * sizeof(*array2));
    if (array2 == NULL)
    {
        printf("Failed to reallocate memory!\n");
        free(array1);
        return -1;
    }
    printf("Successfully reallocated memory for an array of %zu elements of %zu bytes each at address %p using realloc.\n", size2, sizeof(*array2), (void *)array2);

    array3 = realloc(array2, size3 * sizeof(*array3));
    if (array3 == NULL)
    {
        printf("Failed to reallocate memory!\n");
        free(array2);
        return -1;
    }
    printf("Successfully reallocated memory for an array of %zu elements of %zu bytes each at address %p using realloc.\n", size3, sizeof(*array3), (void *)array3);

    free(array3);
}

Here, since we don't use all of the pointers array1, array2, and array3 immediately, we made sure to initialize them to NULL, which stands for a null pointer, that is, a pointer that doesn't point to anything. If we did not initialize these pointers, they would have pointed to some random addresses in memory. As always, all variables must be properly initialized, and that includes pointers!

On my computer, the output is:

Successfully allocated memory for an array of 1000 elements of 8 bytes each at address 0000000000163f90 using malloc.
Successfully reallocated memory for an array of 100 elements of 8 bytes each at address 0000000000163f90 using realloc.
Successfully reallocated memory for an array of 10000 elements of 8 bytes each at address 0000000000620080 using realloc.

Note how when we reallocated a smaller amount of memory, the same memory address was used, but to reallocate a larger amount of memory, the old location was not suitable anymore, so a new address was used instead.

In any of the above programs, you can change the size of the array to a very large number (e.g. 1e15) to see what happens when the program fails to allocate or reallocate memory.

3.6 Input and output ^

3.6.1 Input from the command line

So far, we have always declared main as int main(void), which means that it (and thus, the program as a whole) does not take any arguments. However, often - especially if your program does not have a graphical user interface - you want it to take arguments from the command line. In this case, you must declare main as follows:

int main(int argc, char *argv[])
  • argc (argument count) is an integer which counts the number of arguments passed to the program, which are assumed to be separated by spaces.
  • argv (argument vector) is an array of argc pointers to strings which contain the actual arguments passed.

Note that argc is usually at least 1, since it also counts the name of the executable file of the program itself, which is always stored in argv[0].

Here is an example:

#include <stdio.h>

int main(int argc, char *argv[])
{
    printf("argc = %d\n", argc);
    for (int i = 0; i < argc; i++)
        printf("argv[%d] = %s\n", i, argv[i]);
}

If you just run this program by pressing F5 in the IDE, you will get an output similar to this:

argc = 1
argv[0] = main

Alternatively, you can compile it and then write in the command line:

main first second third "fourth fifth"

Then the output will be:

argc = 5
argv[0] = main
argv[1] = first
argv[2] = second
argv[3] = third
argv[4] = fourth fifth

Note that "fourth fifth" counts as one argument, since the words are enclosed in quotes.

3.6.2 Input from the terminal during run time ^

Alternatively (or additionally), you can ask the user for input from the terminal after the program runs. This is done using the function scanf, which accepts a format string similar to printf as its first arguments, and the addresses of the variables to store the input in the next arguments. As for printf, each format placeholder corresponds to one consecutive argument.

The output of scanf is an int containing the number of arguments received, or 0 in case the input does not match the format string. It is important to check the output, in case the user inputs the wrong value. In this case, the program can either terminate or ask again for the correct value.

Try this example:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

int main(void)
{
    int64_t x = 0, y = 0;
    int r = 0;

    printf("Enter x: ");
    r = scanf("%" PRId64, &x);
    if (r == 0)
    {
        printf("Incompatible value entered!");
        return -1;
    }

    printf("Enter y: ");
    r = scanf("%" PRId64, &y);
    if (r == 0)
    {
        printf("Incompatible value entered!");
        return -1;
    }

    printf("The sum of the numbers is %" PRId64 " + %" PRId64 " = %" PRId64 ".\n", x, y, x + y);
}

If you enter two integers, their sum is displayed. However, if you enter something else, such as a letters, the program will terminate.

Warning: In scientific programming, it is best not to take any input from the terminal during run time. Instead, take all input from files and/or command line arguments. If the user wants to run the program 50 times with 50 different sets of data, this can easily be automated using a script, but only if the program can run without requiring any live input from the user during run time.

3.6.3 Input from a file ^

Warning: Before you run any code that opens files for input or output, review the steps above for configuring the launch.json file. As indicated there, you must change your working folder to the workspace folder using "cwd": "${workspaceFolder}".

To open a file, we use the function fopen, which is defined in stdio.h. The first argument is the name of the file, and the second argument specifies the file access mode:

  • "r" opens the file for reading only. "r+" opens the file for both reading and writing.
    • If the file exists, reading begins from the start.
    • If the file does not exist, an error occurs.
  • "w" opens the file for writing only. "w+" opens the file for both reading and writing.
    • If the file exists, its contents are destroyed.
    • If the file does not exist, it is created.
  • "a" opens the file for appending. "a+" opens the file for both reading and appending.
    • If the file already exists, the output is appended to the existing contents.
    • If the file does not exist, it is created.
Warning: If an existing file is opened with the access modes "w" or "w+", its contents will be permanently destroyed with no way to recover them!

The output of fopen is a pointer to a variable of type FILE, which is used whenever we want to access the file. Once we're done with the file, we must close it with fclose, which takes the FILE pointer as its argument. To read a character from a file, we use fgetc. To read a formatted string from a file, we use fscanf, which functions like scanf but reads from a file rather than from the terminal.

The following program prints out its own source file (assuming that the file is called print_self.c):

#include <stdio.h>

int main(void)
{
    const char filename[] = "print_self.c";
    int chr = 0;
    FILE *file_ptr = NULL;

    file_ptr = fopen(filename, "r");
    if (file_ptr == NULL)
    {
        perror("Cannot open file");
        return -1;
    }

    while ((chr = fgetc(file_ptr)) != EOF)
    {
        putchar(chr);
    }

    if (ferror(file_ptr))
        printf("\nEncountered an error before reaching the end of file %s.", filename);
    else if (feof(file_ptr))
        printf("\nSuccessfully read file %s.", filename);

    fclose(file_ptr);
}

After calling fopen, we store the generated FILE pointer in the variable file_ptr. This variable is used to access the file throughout the program.

If file_ptr == NULL, this indicates that an error has occurred. We use perror to inform the user of this error. perror prints out the given string, followed by ": ", followed by an automatically-generated description of the error code stored in the system variable errno. For example, if you change filename[] to a file that does not exist, you will see the error message Cannot open file: No such file or directory.

We use a while loop with the condition (chr = fgetc(file_ptr)) != EOF to read the file. fgetc reads one character from file_ptr, and we store the output in the variable chr. Notice that chr is an int, not a char; this is because it needs to be able to store not only characters, but also the special value EOF (typically set to -1) which indicates that either an error has occurred or the end of the file has been reached.

At each step of the loop, we compare the result (i.e. the value of chr) with EOF. As long as EOF has not been reached, we print out the character that was read from the file using the function putchar, which simply prints one character to the terminal.

Once EOF has been reached, we use the function ferror, which checks file_ptr for errors. If this function returns true, then we tell the user about the error. Otherwise, we use the function feof to confirm that we indeed reached the end of the file, and if it returns true, we notify the user of the successful operation.

Finally, we close the file using fclose. This is very important, as it releases the file so that other programs can use it, frees up memory, and in the case of opening a file for writing, ensures that all the data we wrote to the file is saved on the disk.

The function fscanf works similarly to scanf, except that it takes a file pointer as its first argument. We can use it to read data from files in a particular format. For example, perhaps we would like to read integers stored in CSV (comma-separated values) format. Create a file named data.csv and enter the following into it:

1,2,3
4,5,6
7,8,9

We can read this file with the following program:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

int main(void)
{
    const char filename[] = "data.csv";
    int num_read = 0;
    FILE *file_ptr = NULL;
    int64_t x = 0, y = 0, z = 0;
    uint64_t line = 1;

    file_ptr = fopen(filename, "r");
    if (file_ptr == NULL)
    {
        perror("Cannot open file");
        return -1;
    }

    do
    {
        num_read = fscanf(file_ptr, "%" PRId64 ",%" PRId64 ",%" PRId64, &x, &y, &z);
        if (num_read == 3)
            printf("Read values: (%" PRId64 ", %" PRId64 ", %" PRId64 ") on line %" PRIu64 ". Sum is %" PRId64 ".\n", x, y, z, line, x + y + z);
        else
        {
            printf("Error: Encountered invalid data on line %" PRIu64 "!\n", line);
            break;
        }
        line++;
    } while (!feof(file_ptr));

    if (feof(file_ptr))
        printf("Successfully read the file %s (%" PRIu64 " lines total).", filename, line - 1);

    fclose(file_ptr);
}

To read the three integers we simply tell fscanf that the format of the input is integer - comma - integer - comma - integer. fscanf will return the total number of integers read, so if it's 3 we know the line was formatted correctly; if not, then either we've reached the end of the file, or there was invalid data.

The output is:

Read values: (1, 2, 3) on line 1. Sum is  6.
Read values: (4, 5, 6) on line 2. Sum is 15.
Read values: (7, 8, 9) on line 3. Sum is 24.
Successfully read the file data.csv (3 lines total).

But if I change data.csv so that one of the lines contains invalid data, for example:

1,2,3
aaa,5,6
7,8,9

Then the output will be:

Read values: (1, 2, 3) on line 1. Sum is 6.
Error: Encountered invalid data on line 2!

By the way, notice that in this program I defined num_read, the variable that stores the return value of fscanf, as int. You may be wondering: doesn't that make the program not portable? Why did I not use, for example, int32_t instead?

The reason is that the C standard explicitly defines fscanf as a function that returns int. So I cannot define num_read as an integer with a specific number of bits, such as int32_t, since the number of bits stored in that variable may be different on different systems, depending on how int is defined on each system. This means that, perhaps counter-intuitively, the only portable way to use fscanf is to define its return variable as int.

3.6.4 Output to a file ^

If we open a file for writing, we can use fputc to write one character or fprintf to write a formatted string. The syntax of fprintf is exactly the same as that of printf, except that the first argument is a FILE pointer. Here is an example:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

int main(void)
{
    const char filename[] = "squares.txt";
    const uint64_t num_squares = 100;
    FILE *file_ptr = NULL;

    file_ptr = fopen(filename, "w");
    if (file_ptr == NULL)
    {
        perror("Cannot open file");
        return -1;
    }

    for (uint64_t i = 1; i <= num_squares; i++)
    {
        if (fprintf(file_ptr, "%" PRIu64 "\n", i * i) < 0)
        {
            perror("Error occurred");
            fclose(file_ptr);
            return -1;
        }
    }

    printf("Squares of all numbers from 1 to %" PRIu64 " written successfully to file %s.\n", num_squares, filename);
    fclose(file_ptr);
}

There is much more to be said about input, output, and other file operations, but we will not cover it here. Instead, please see the C reference.

3.7 Structs and typedefs ^

3.7.1 Structs

As we have seen, arrays can be used to group together different values. However, all values in an array must have the same type. Structures, declared with the keyword struct, allow us to conveniently store any desired combination of different data types in one variable. The syntax is as follows:

struct name
{
    type1 member1;
    type2 member2;
    // etc...
};

Here name is the name of the structure, and member1, member2, and so on are the members, which have data types type1, type2, etc. respectively.

One example of where we might want to use a struct is for database entries. For example, we can store a student's ID, name, and email address in one struct data type as follows:

struct student
{
    uint64_t id;
    char *name;
    char *email;
};

Here, we have defined a new data type called struct student. Note that since we do not know in advance the size of the strings that will contain the name and email address, we don't define them as a fixed-size array (e.g. char name[20]) but rather as simply a pointer to a string, which will be stored elsewhere in memory. (This is not necessarily the fastest method, since the program will then have to navigate to the other memory addresses to access the strings, but it is the most convenient.)

Once we define a struct, we have to declare an actual variable which will have the new data type. This is done as usual with the syntax type var_name, so in this case, struct struct_name var_name. The members are then accessed using a dot: var_name.member1, var_name.member2, and so on. In the example of a student entry, struct student s declares s as a variable of type struct student. The fields id, name, and email and then accessed using s.id, s.name, and s.email.

To initialize a struct, we use the following syntax:

struct name var_name = {.member1 = value1, .member2 = value2, ...};

Note that struct should usually be defined outside the main function, so that it is a global definition that can be accessed by other functions. For example:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

struct student
{
    uint64_t id;
    char *name;
    char *email;
};

int main(void)
{
    const struct student s = {.id = 654873, .name = "Alice", .email = "alice@universityname.ca"};
    printf("| Student ID: %" PRIu64 " | Name: %s | Email: %s |\n", s.id, s.name, s.email);
}

3.7.2 Arrays of structs ^

Often, we will want to declare an array of structs. For example, for an array of 3 students, we can use struct student s[3]. Then s[i] is the ith element of the array (starting from zero, as usual). To access the individual values, we use s[i].id, s[i].name, and s[i].email. Here is an example:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

struct student
{
    uint64_t id;
    char *name;
    char *email;
};

int main(void)
{
    const uint64_t num_students = 3;
    struct student s[num_students];

    s[0].id = 654873;
    s[0].name = "Alice";
    s[0].email = "alice@universityname.ca";

    s[1].id = 23564;
    s[1].name = "Bob";
    s[1].email = "bob@universityname.ca";

    s[2].id = 7634126;
    s[2].name = "Charlie";
    s[2].email = "charlie@universityname.ca";

    for (uint64_t i = 0; i < num_students; i++)
        printf("| Student ID: %7" PRIu64 " | Name: %-7s | Email: %-25s |\n", s[i].id, s[i].name, s[i].email);
}

Note that we used width specifiers in the %s format placeholders to make the output look nice. The minus sign, just as for numbers, specifies that the string should align to the left. The output will be:

| Student ID:  654873 | Name: Alice   | Email: alice@universityname.ca   |
| Student ID:   23564 | Name: Bob     | Email: bob@universityname.ca     |
| Student ID: 7634126 | Name: Charlie | Email: charlie@universityname.ca |

3.7.3 Structures as pointers ^

We can streamline the previous program by defining a function that will store values inside a struct:

#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

struct student
{
    uint64_t id;
    char *name;
    char *email;
};

void store_student(struct student *const s, const uint64_t id, char *const name, char *const email)
{
    s->id = id;
    s->name = name;
    s->email = email;
}

int main(void)
{
    const uint64_t num_students = 3;
    struct student s[num_students];

    store_student(&s[0], 654873, "Alice", "alice@universityname.ca");
    store_student(&s[1], 23564, "Bob", "bob@universityname.ca");
    store_student(&s[2], 7634126, "Charlie", "charlie@universityname.ca");

    for (uint64_t i = 0; i < num_students; i++)
        printf("| Student ID: %7" PRIu64 " | Name: %-7s | Email: %-25s |\n", s[i].id, s[i].name, s[i].email);
}

Since we want the function to modify the contents of the array, we pass the addresses of the array elements using the & modifier. This means that, inside the function store_student, the variable s is a pointer. In this case, we use the syntax -> instead of . to access the values inside the struct. Note that s->id is equivalent to (*s).id.

3.7.4 Typedef ^

A typedef is used to define a shorthand for a particular type. It has the syntax:

typedef original_type new_name;

We have encountered typedefs before. For example, uint64_t is actually a typedef for unsigned long long:

typedef unsigned long long uint64_t;

typedefs are often used to define shorter names for structs. For example, we can write

typedef struct
{
    uint64_t id;
    char *name;
    char *email;
} student;

This will allow us to write just student instead of struct student when we declare a variable of this type.

3.8 Further reading ^

Unfortunately, in this course we only have limited time. I chose to talk in detail here only about the most important things you need to know in order to start programming in C. For more information, a good starting point is the C reference, which lists all the keywords, types, functions, and other important aspects of C and its standard library.

Stack Overflow is another very good website with trustworthy content in the form of questions and answers, where you can also ask your own questions if needed - but before posting a new question, make sure it has not been asked already!

I find that most online C tutorials are not of very good quality, and sometimes even just plain wrong. For this reason, I recommend not to use them, or if you do, to take them with a grain of salt. For learning C seriously, there is no replacement for a proper textbook.

I will not recommend any particular C textbooks here, since I don't think you'll need them - the intention of this course is for you to eventually program in C++, which is a much more useful and modern language than C. I will recommend some great C++ textbooks below.

4 Introduction to C++ ^

Now that we have a fairly good mastery of C, we can start learning C++. C is, for the most part, a subset of C++. Most C programs will compile using a C++ compiler, perhaps with a few changes, but the opposite is not true. We will see that many aspects of C remain the same in C++, but some things are done a bit differently, and many new features and concepts are added - most importantly, object-oriented programming.

C++ can do everything C can do, and more. The main benefit of C compared to C++ is that it is lower-level, supported on more types of systems, and simpler. Therefore, it is great for embedded systems, operating systems, device drivers, and so on. However, for most applications, including scientific computing, it is generally better to use C++. You will soon see why.

Note that both C and C++ are evolving languages. Some of the things I will list here as new in C++ compared to C are also new in C++ compared to older revisions of C++. However, I will always assume here that you are using a compiler which supports the latest revision, C++20, which is what you should have if you installed the latest version of GCC as I instructed in the beginning of the course. (However, note that as of 2022, some C++20 features are still not supported by GCC, or supported only experimentally; more details can be found here.)

You should create a new Visual Studio Code workspace for your C++ programs, separate from the workspace you used so far for your C programs, since it will have slightly different .json configuration files. The steps to configure Visual Studio Code to run C++ programs and generate the .json files are exactly the same as in creating and configuring your first program above, except for two changes:

  • First, whenever the compiler gcc is mentioned in the instructions above, replace it with g++.
  • Second, replace the argument -std=c17 in tasks.json with -std=c++20. This will instruct the compiler to comply with the C++20 standard. (The warning flags should be the same, and -g should be replaced with -ggdb3 as we instructed above).

Note: In c_cpp_properties.json, the value of intelliSenseMode will still contain gcc-x64, and in tasks.json, the value of the problemMatcher will still be $gcc. That's normal.

4.1 Some new features of C++

4.1.1 The "Hello, World!" program

Copy and paste the following program to VS Code:

#include <iostream>

int main()
{
    std::cout << "Hello, World!\n";
}

Let us consider several new things in this program:

  • #include <iostream> is basically the C++ equivalent of #include <stdio.h>. However, in C++ the relevant header file is iostream, and we don't add .h to the file name; the actual file's name is, in fact, just iostream without any extension. iostream stands for input/output stream.
    • To include the a C header file of the form NNN.h, use #include <cNNN>. For example, to include math.h, we use #include <cmath>.
  • int main() is the same old main function from C. However, while the C standard requires that void appears in the parentheses if the program does not take command-line arguments, the C++ standard instead requires that nothing appears in the parentheses. Both options, void or no void, work in both languages, but here we will adhere to the standard. To get input from the command line, the exact same syntax as in C should be used: int main(int argc, char *argv[]).
  • std::cout, which is declared in iostream, allows us to print output to the terminal. Although printf still works in C++, std::cout is preferred whenever we don't need the output to be formatted in a particular way.
    • std indicates that we are accessing the standard library namespace. A namespace is simply a space where we can declare names so that they do not conflict with names declared in other namespaces. For example, I can (although this is not a very good idea...) define another object named cout in another namespace, and it will not conflict with cout in the std namespace.
    • :: indicates that we are accessing an object in the namespace.
    • cout is an object in the namespace std, which represents the standard output stream. Usually, the standard output stream is the terminal. The name cout stands for character output. Note that cout is not a function, it is an object; it doesn't have any input or output, like printf does.
  • The operator << ("put to") indicates that the string "Hello, World!\n" should be written into the standard output stream std::cout. Since the stream represents the terminal, writing this string to the stream means that "Hello, World!\n" will be printed to the terminal.

4.1.2 Using namespaces ^

The object cout is in the std namespace, so we had to write std:: in front of it in order to access it. This can get tedious when an object is used frequently. However, we can eliminate the need to write std:: by invoking the following statement:

using namespace std;

This will instruct the compiler that we are "using" the namespace std, which essentially means we merge that namespace with the namespace of our program. Now we can simply write cout:

#include <iostream>
using namespace std;

int main()
{
    cout << "Hello, World!\n";
}

We will generally invoke using namespace std throughout the course, since it makes C++ code more compact and readable. The only downside is that we cannot define objects in our program which have the same names as objects in the C++ standard library (such as cout), but that would be a bad idea anyway.

4.1.3 Reserved keywords ^

The 2011 version of the C++ standard, also called C++11, defined the following 83 reserved keywords: alignas, alignof, and, and_eq, asm, auto, bitand, bitor, bool, break, case, catch, char, char16_t, char32_t, class, compl, const, constexpr, const_cast, continue, decltype, default, delete, do, double, dynamic_cast, else, enum, explicit, export, extern, false, float, for, friend, goto, if, inline, int, long, mutable, namespace, new, noexcept, not, not_eq, nullptr, operator, or, or_eq, private, protected, public, register, reinterpret_cast, return, short, signed, sizeof, static, static_assert, static_cast, struct, switch, template, this, thread_local, throw, true, try, typedef, typeid, typename, union, unsigned, using, virtual, void, volatile, wchar_t, while, xor, and xor_eq.

These include 33 of the 34 C keywords (restrict is the only one missing), plus 50 new keywords. However, many of the new keywords in fact already existed in C. For example, the operators and, or, and not are just more readable alternatives for the operators &&, ||, and ! respectively. Similarly, bool, true, and false can be used in C if we include the header file stdbool.h.

The next two versions, C++14 and C++17, changed the meaning of some keywords but did not add any new ones. However, C++20 added 8 new keywords: char8_t, concept, consteval, constinit, co_await, co_return, co_yield, and requires.

4.1.4 Function overloading ^

In C++, we can define different functions with the same name, which accept different numbers and/or types of arguments. This is called function overloading. Here is an example:

#include <iostream>
using namespace std;

void print_type(const int32_t x)
{
    cout << "I got a 32-bit integer: " << x << '\n';
}

void print_type(const int64_t x)
{
    cout << "I got a 64-bit integer: " << x << '\n';
}

void print_type(const double x)
{
    cout << "I got a double: " << x << '\n';
}

void print_type(const char x)
{
    cout << "I got a character: " << x << '\n';
}

int main()
{
    print_type(1);             // Prints "I got a 32-bit integer: 1"
    print_type(1000000000000); // Prints "I got a 64-bit integer: 1000000000000"
    print_type(1.0);           // Prints "I got a double: 1"
    print_type('1');           // Prints "I got a character: 1"
}

By default, C++ interprets any literal integer (i.e. an integer written explicitly in the source code) as the smallest data type among int, long, and long long that can fit that integer. 1 fits into int, which on most 64-bit platforms means int32_t. However, 1000000000000 is too big to be represented using 32 bits, and thus it only fits into a long long, which on most 64-bit platforms means int64_t. You can remind yourself about integer data types here and read more about how integer literals are interpreted in C++ here.

Therefore, when we invoke print_type(1), the function print_type(int32_t) is called, but when we invoke print_type(1000000000000), the function print_type(int64_t) is called. Similarly, print_type(1.0) calls print_type(double) and print_type('1') calls print_type(char x).

Notice also that we used multiple <<'s to print out multiple strings and numbers by putting them into cout in order. This is (arguably) more convenient than using printf and having to specify the exact type of each value we are printing using the appropriate format placeholder. With cout, the type is automatically detected.

Function overloading is especially useful in the case of mathematical functions. Recall that in C, unless we used tgmath.h, we had different function names for different types of arguments, e.g. sqrt for double, sqrtf for float, and sqrtl for long double. In C++, there is just one sqrt function (defined in the header <cmath>) that can accept any floating-point or integer type.

4.1.5 Constant expressions ^

In C, we learned that the const keyword allows us to define a variable that cannot be changed later; if we try to change it, we will get an error from the compiler. In C++, an additional keyword constexpr (constant expression) is provided.

While const can be used even if the value of the constant is known only at run time (i.e. when the user actually runs the program), constexpr can only be used if the value is known at compile time (i.e. when we compile the code). Generally, constexpr is preferred to const since it provides better performance.

For example, consider the following code: (In C++, you do not need to include stdint.h manually to use fixed-width integer types, it is automatically included when you include iostream.)

#include <iostream>
using namespace std;

int64_t add_one(const int64_t n)
{
    return n + 1;
}

int main()
{
    const int64_t x = add_one(1);
    cout << x;
}

The value returned by add_one() is not known at run time, but that's okay, because when we define x as const, all that really means is that we prevent it from being changed later; the initial value can be decided at any time. However, if we replace const with constexpr, the code will not compile, since the value of x is not known until we evaluate the function add_one() at run time.

If we also add the keyword constexpr to the definition of add_one(), the code will work:

#include <iostream>
using namespace std;

constexpr int64_t add_one(const int64_t n)
{
    return n + 1;
}

int main()
{
    constexpr int64_t x = add_one(1);
    cout << x;
}

In this case, the function add_one() will be evaluated in advance at compilation time and the value of x will be recorded, so that we do not need to evaluate the function again at run time. Obviously, this wouldn't work if the function depended on something that will only be known at runtime, such as input from a file; in that case the program would not compile.

Warning: A good programming practice is to never use literals, such as 299792458 for the speed of light in meters per second, anywhere in the program, aside from trivial ones such as 0 and 1. Instead, define constexpr uint32_t speed_of_light = 299792458 and use speed_of_light throughout the program instead of the literal 299792458. If you later decide to make a change - for example, use different units of measurement - then you don't have to replace the literal number 299792458 throughout the code, you can simply change the constant expression speed_of_light.

4.1.6 The cin object ^

Just as in C++ we usually prefer to use cout instead of printf to print output to the terminal, we also usually prefer cin to scanf to get input from the terminal. Here is an example:

#include <iostream>
using namespace std;

int main()
{
    int64_t n = 0;
    cout << "Enter a number: ";
    cin >> n;
    cout << "The square of the number is " << n * n << ".\n";
}

Here, >> is the operator "get from", the opposite of <<, the operator "put to". Instead of putting output into cout, we get input from cin.

Warning: As we explained above, in scientific programming it is best not to take any input from the terminal during run time. Programs should only take input from files and/or from command line arguments, so that they can be automated without requiring live input from the user during run time.

4.1.7 Namespaces revisited ^

Just as the std namespace is defined in <iostream>, we can define our own namespaces. This is used whenever we want to guarantee that none of the names we used in our code (variables, functions, etc.) will clash with the names used in other peoples' code, and vice versa. Here is an example:

#include <iostream>

namespace other
{
    constexpr int64_t cout = 7;
}

int main()
{
    // Prints "Value of other::cout is 7."
    std::cout << "Value of other::cout is " << other::cout << ".\n";
}

There are two couts in this code:

  • The usual std::cout, the standard output stream, defined in the namespace std.
  • Our custom other::cout, which is not a stream but an integer, defined in the custom namespace other.

You can see that both couts can be used in conjunction, even in the same statement, as long as the prefix indicating the namespace (std:: or other::) is included. Of course, if we write just cout without an explicit namespace for either of them, the code will not compile - since that name is only defined within the context of these namespaces.

So far, we have added the statement using namespace std to every program, which imports all of the names in the namespace std. However, it is actually possible to only import individual names with using. For example, let us import only cin but not cout or anything else:

#include <iostream>

using std::cin;

int main()
{
    int64_t n = 0;
    std::cout << "Enter n: ";
    cin >> n;
    std::cout << "The square of n is " << n * n << ".\n";
}

You can see that cin works without the std:: prefix here, but if you try to replace std::cout with cout, you will get an error.

4.1.8 Default function arguments ^

We can specify a default value for a function's argument, which will be used if that argument is omitted. For example:

#include <cmath>
#include <iostream>
using namespace std;

double root(const double num, const int32_t rt = 2)
{
    return pow(num, 1.0 / rt);
}

int main()
{
    cout << "Square root of 5: " << root(5) << '\n';
    cout << "Cubic  root of 5: " << root(5, 3) << '\n';
}

Here, the function root() takes the root of the first argument with respect to the second argument, but if the second argument is omitted, the default is a square root. (We used the function pow() from the header <cmath> to calculate the roots; pow(base, exp) returns base to the power exp.)

Note that arguments with default values cannot be followed by arguments without default values. In other words, the arguments with default values must be the last arguments of the function. So f(double x, double y = 0) is okay, and so is f(double x = 0, double y = 0), but f(double x = 0, double y) is invalid.

4.2 Pointers and references ^

4.2.1 References

Consider a variable declared as type x, where type is any data type and x is the variable's name. We have seen that this variable is simply a value of the corresponding data type, stored in a particular address in memory. This address can be determined by &x.

We can define a pointer p that will store the address of a variable using type *p = &x. When we dereference the pointer using the syntax *p, we can read or modify the value of x - that is, the value stored at the address &x - without using x itself.

Unfortunately, pointers are one of the most confusing and prone-to-errors concepts in C. To remedy that, C++ introduces the new concept of references. A reference is similar to a const pointer, in that it points to an address in memory, but once it is initialized to the address of a particular variable, this address can never be changed.

Furthermore, as you will see, unlike pointers, references do not need to be dereferenced - whenever the reference is used, it is automatically replaced with the variable it refers to. This essentially means that a reference is just another name, or alias, for an already existing variable. We never have to deal with the actual memory address directly.

To define a reference, we use the following syntax:

type &ref = var;

Where ref is the name of the reference, var is the name of the variable it will point to, and type is the data type. This is illustrated in the following example:

#include <iostream>
using namespace std;

int main()
{
    int64_t var = 0;     // Declare an integer var and initialize it to 0
    int64_t &ref = var;  // Declare a reference ref and initialize it to be an alias of var
    int64_t *ptr = &var; // Declare a pointer ptr and initialize it to the address of var
    cout << "Initialization: "
         << "var = " << var << ", ptr = " << ptr << ", *ptr = " << *ptr << ", ref = " << ref << ".\n";
    var++;
    cout << "var++:          "
         << "var = " << var << ", ptr = " << ptr << ", *ptr = " << *ptr << ", ref = " << ref << ".\n";
    ref++;
    cout << "ref++:          "
         << "var = " << var << ", ptr = " << ptr << ", *ptr = " << *ptr << ", ref = " << ref << ".\n";
    (*ptr)++;
    cout << "(*ptr)++:       "
         << "var = " << var << ", ptr = " << ptr << ", *ptr = " << *ptr << ", ref = " << ref << ".\n";
    ptr++; // Warning: This increases the address ptr points to, NOT the value at the address!
    cout << "ptr++:          "
         << "var = " << var << ", ptr = " << ptr << ", *ptr = " << *ptr << ", ref = " << ref << ".\n";
}

On my computer, the output is:

Initialization: var = 0, ptr = 0x1f91fff648, *ptr = 0, ref = 0.
var++:          var = 1, ptr = 0x1f91fff648, *ptr = 1, ref = 1.
ref++:          var = 2, ptr = 0x1f91fff648, *ptr = 2, ref = 2.
(*ptr)++:       var = 3, ptr = 0x1f91fff648, *ptr = 3, ref = 3.
ptr++:          var = 3, ptr = 0x1f91fff650, *ptr = 135593457232, ref = 3.

We see that adding 1 to var using var++ is the same as adding 1 to ref using ref++; they are essentially two names for the same variable. Dereferencing ptr and adding 1 to the value at the address it points to using (*ptr)++ is also equivalent. You can think of a reference as essentially a fixed pointer for which the * is automatically added whenever the reference is used.

However, if we do not dereference ptr, and instead add 1 to the value of ptr itself using ptr++, we change the address the pointer points to, and the value at the new address is garbage - whatever happens to be in memory at that address at the time. (On your computer, the address that ptr points to will be different, and the garbage value in the last line will also be different.)

One way to avoid making mistakes like this is to declare the pointer as const, as we discussed above and as we have indeed done whenever possible in our code examples so far:

int64_t *const ptr = &var;

Now the code will not compile, since we are trying to change ptr itself (which is const) rather than var (which is not const).

References in C++ have many uses, and in particular, they simplify some uses that would have required pointers in C. We will see some of these uses below.

4.2.2 Functions and references ^

Above, in functions and pointers, we considered the following program (slightly rewritten here for C++):

#include <iostream>
using namespace std;

void swap(int64_t *const x, int64_t *const y)
{
    const int64_t temp = *x;
    *x = *y;
    *y = temp;
}

int main()
{
    int64_t a = 1, b = 2;
    cout << "Before swap: a = " << a << ", b = " << b << '\n';
    swap(&a, &b);
    cout << "After swap: a = " << a << ", b = " << b << '\n';
}

By passing pointers to the variables to be swapped instead of the values of the variables, we can modify the variables from within the function, even though they are in a different scope. In C++, this code can be rewritten in a much cleaner way, without all the *s, using references instead of pointers:

#include <iostream>
using namespace std;

void swap(int64_t &x, int64_t &y)
{
    const int64_t temp = x;
    x = y;
    y = temp;
}

int main()
{
    int64_t a = 1, b = 2;
    cout << "Before swap: a = " << a << ", b = " << b << '\n';
    swap(a, b);
    cout << "After swap: a = " << a << ", b = " << b << '\n';
}

Notice that all we needed to do was add & in the argument list of the function's definition; every instance of a, b, x, and y is simply the variable's name without any *s, resulting in more compact and easy to read code. Furthermore, when calling swap() we simply pass a and b themselves, rather than their addresses &a and &b, as we had to do in the case of pointers.

Since references cannot be changed to refer to a different variable, we do not need to make them const; in other words, a reference works similarly to a const pointer.

4.2.3 Improving performance with (constant) references ^

As we discussed above, functions have their own scope, so arguments passed to a function are treated as new variables within that new scope, independent of variables in any other scope. This means that when a variable is passed to a function, its value has to be copied to a new address in memory, so that if it is modified, it will not affect the original variable.

In the example of the swap function above, we explicitly wanted to modify the variables passed as arguments, instead of creating new variables in the function's scope. In order to do that, we passed the arguments by reference, which ensured that the function could directly access the passed variables instead of their copies.

However, even if we don't need the function to be able to modify the variables that were passed as arguments, it is still often preferable to pass arguments by reference, since this can provide us with a performance boost. The reason is that copying the value of the variable to a different address in memory can be a time-consuming task - especially for large objects such as structs (or classes, which we will discuss later).

On the other hand, if we are not careful, passing an argument by reference may cause unexpected behavior. Consider this example:

#include <iostream>
using namespace std;

void print_plus_one(int64_t x)
{
    x++;
    cout << "print_plus_one: " << x << '\n';
}

int main()
{
    int64_t a = 1;
    print_plus_one(a);             // Prints "print_plus_one: 2"
    cout << "main: " << a << '\n'; // Prints "main: 1"
}

The function print_plus_one accepts an integer, increases it by one, and prints out the result. Notice that the variable a in the scope of the main function was not modified by print_plus_one, since it created a new variable x within its own scope. The value of a was copied to a new address in memory for this purpose.

Perhaps we would like to improve the performance of our program by passing the argument as reference. (Obviously, in this case it would make no difference, but if we're passing 1 GB of data to the function, the performance improvement would be very significant!) Naively, we would just add & to the argument of the function:

#include <iostream>
using namespace std;

void print_plus_one(int64_t &x)
{
    x++;
    cout << "print_plus_one: " << x << '\n';
}

int main()
{
    int64_t a = 1;
    print_plus_one(a);             // Prints "print_plus_one: 2"
    cout << "main: " << a << '\n'; // Prints "main: 2"
}

Notice that now the variable a in the scope of the main function has also changed, since x in the scope of print_plus_one is not an independent variable, it is merely an alias for a and refers to the same address in memory. This was not the intended behavior; we just wanted print_plus_one to print the value plus one, but not change the variable that was passed to it.

This error could have been prevented if we declared the argument as a const reference. If you define void print_plus_one(const int64_t &x) and try to run the following program, it will not compile, and we will get the error increment of read-only reference 'x' from the compiler.

The correct way to write this code using references is:

#include <iostream>
using namespace std;

void print_plus_one(const int64_t &x)
{
    cout << "print_plus_one: " << x + 1 << '\n';
}

int main()
{
    int64_t a = 1;
    print_plus_one(a);             // Prints "print_plus_one: 2"
    cout << "main: " << a << '\n'; // Prints "main: 1"
}

Now we enjoy increased performance (at least in principle, if the program was more complicated) by using a reference to x instead of copying it, but we also do not modify x before we print it, which would inadvertently modify a as well.

Finally, notice that if the argument of print_plus_one is defined as a non-constant int64_t &x, then the function cannot take literals as arguments - that is, we cannot call, for example, print_plus_one(5). The reason is that 5 is a literal, not a variable, so there is no way to create an alias for it. However, if the argument of print_plus_one is defined as const int64_t &x, then we are free to call the function with either a variable or a literal.

In conclusion, a good programming practice is to follow these rules:

  • Functions should only accept variables as arguments if they must make copies of the variables, and there is no simple way to use references instead.
  • Otherwise, functions should always accept references.
    • If the function is meant to change the value of the variable, as in swap, then the references should not be const.
    • Otherwise, the references should always be const to prevent accidental modification, allow passing literals as arguments, and increase readability.

4.2.4 Range-based for loops and references ^

for loops are almost always used to iterate on a specific range or specific values. C++ introduces a new type of for loop which greatly simplifies this. Simply write

for (type i : a)

Where a is the array of values you want to iterate on, and type is the type of the values. You can also write explicitly

for (type i : {value1, value2, ...})

Where value1, value2, etc. are the values to be iterated on. Here is an example:

#include <iostream>
using namespace std;

int main()
{
    constexpr uint64_t primes[] = {2, 3, 5, 7, 11};

    for (const uint64_t p : primes)
        cout << p << '\n';
}

This will simply print out the elements of the array primes. It is important to understand that here p is not a variable that gets incremented in each step, as with a normal for loop; in each step, p will be the actual value of each array element.

Here we declared p as const since we do not change p itself inside the loop. p is a local variable inside the loop, hence a local copy, so we can change it if we want, without affecting the actual array elements; as with all other variables, if we do not plan to change it, then we should define it as const both to avoid accidental modification and to make the code more readable.

In some cases, we do want to change p, while not changing the array elements themselves. This is okay, since p is just a local copy of each element, so we can change it without changing the array; we just need to remove the const qualifier. For example:

#include <iostream>
using namespace std;

int main()
{
    constexpr uint64_t primes[] = {2, 3, 5, 7, 11};

    for (uint64_t p : primes)
    {
        p++;
        cout << p << '\n';
    }

    for (const uint64_t p : primes)
        cout << p << '\n';
}

The first loop will increase the value of each prime and then print it. However, this will not affect the array primes itself, since p is a copy.

If the array we're looping on contains a lot of data, then we should avoid copying each element, since that can take a significant amount of time. As with passing arguments to functions, we can avoid copying the elements of the array by using references:

#include <iostream>
using namespace std;

int main()
{
    constexpr uint64_t primes[] = {2, 3, 5, 7, 11};

    for (const uint64_t &p : primes)
        cout << p << '\n';
}

Now p is not a copy, but rather a reference to each array element. This may considerably improve performance in many cases.

However, now we should be careful; we can remove the const qualifier to allow modifying p, but doing so will now also modify the array (as long as the array itself is not const or constexpr, of course). For example, in the following code we permanently increase each element of the array by 1:

#include <iostream>
using namespace std;

int main()
{
    uint64_t primes[] = {2, 3, 5, 7, 11};

    for (uint64_t &p : primes)
    {
        p++;
        cout << p << '\n';
    }

    for (const uint64_t &p : primes)
        cout << p << '\n';
}

The first loop iterates over the references &p, which are used as aliases for the array elements. So in the first iteration &p will be an alias for primes[0], in the second iteration it will be an alias for primes[1], and so on. When we write p++, this increases the array elements themselves, as if we were writing primes[0]++ and so on. The second loop iterates over the elements again and prints them - you will notice that their values have indeed increased by 1, which did not happen in the previous example, where we increased only the copies.

In other words, the loop

for (uint64_t &p : primes)
    p++;

Is equivalent to

for (uint64_t i = 0; i < 5; i++)
    primes[i]++;

Again, as with passing references to functions, if we don't intend to change the array itself then we should define the references as const. Here it's even more important than in the first example, as we may be accidentally changing the array itself and not just a copy.

The same rules we discussed in the case of passing arguments to functions as references also apply here:

  • You should only loop over copies if you must make copies of the array elements, and there is no simple way to use references instead.
  • Otherwise, you should only loop over references.
    • If the loop is meant to change the values of the array elements, then the references should not be const.
    • Otherwise, the references should always be const to prevent accidental modification and to increase readability.

4.2.5 Null pointers ^

In C, we saw that the null pointer (e.g. as returned by fopen) was given by NULL and had the value 0. In C++, to prevent confusion between the integer 0 and the null pointer, the latter is instead given by nullptr, which in fact is a pointer and not an integer. Note that there is no "null reference".

As an (artificial) example of the difference between NULL and nullptr, consider the following code:

#include <iostream>
using namespace std;

void print_type(const uint64_t x)
{
    cout << "I got a 64-bit integer: " << x << '\n';
}

void print_type(const void *const x)
{
    cout << "I got a pointer: " << x << '\n';
}

int main()
{
    print_type(NULL);
}

This code won't compile, and will produce the error call of overloaded 'analyze(NULL)' is ambiguous. This is because the compiler doesn't know if NULL is meant to be an integer or a pointer. However, if we replace NULL with nullptr, the code will compile and will output I got a pointer: 0.

As in C, the null pointer is useful when we declare a pointer variable, but only assign a value to it later. In this case, the pointer will have a garbage value until we assign its value, which may lead to bugs. To avoid this, we can simply initialize it to the null pointer, for example:

double *p = nullptr;

4.3 Vectors and strings ^

4.3.1 Vectors

C++ introduces vectors, which are a much improved version of C arrays. Vectors are a type of container; we will discuss other C++ containers later.

The main benefit of C++ vectors is that they manage memory automatically. You can create an array of any size, and later add or remove elements to it, and it will automatically allocate and deallocate memory as needed. Memory is also freed up automatically when the vector is no longer being used. This means you never have to worry about managing memory manually, which is prone to bugs and leaks. Furthermore, vectors can be accessed in a safe way which prevents accessing elements out of the range of the array.

You pay for this convenience with slightly reduced performance. The penalty to performance is most significant in situations where the vector needs to resize itself, which may mean having to reallocate memory and copy everything to the new memory address. However, if you declare the vector in advance with a size big enough to store everything you plan to put in it, you can avoid the need to resize it later. Merely accessing the elements of a vector is essentially just as fast as accessing the elements of an array.

To use vectors, we must #include <vector>. Then, we define vectors using the new type std::vector, or just vector if we are using namespace std. The syntax is:

vector<type> name(size);

This will create a vector with size elements of type type. If size is not specified, i.e. the parentheses are omitted, it will be zero by default. The elements will be automatically initialized, so we don't need to worry about initialization. Here is an example:

#include <iostream>
#include <vector>
using namespace std;

int main()
{
    vector<int64_t> vec(5);

    for (int64_t &i : vec)
        cout << i << '\n';
}

This will output 5 zeros, since the vector has been automatically initialized to zeros. We can also initialize it manually with the usual array syntax, e.g.:

vector<uint64_t> primes = {2, 3, 5, 7, 11};

In this case we did not need to specify the size of the vector; it was automatically inferred from the initialization list. The standard array notation [] works for vectors too, starting from zero as usual, so for example, primes[0] is 2 and primes[4] is 11.

vector contains many useful member functions. These are functions that are accessed using the syntax vec.function(arguments) where vec is the name of the vector, function is the name of the functions, and arguments are optional arguments to be passed to the function.

Here are some examples of commonly-used member functions:

  • size() returns the number of elements in the vector.
  • push_back(value) adds an element with specified value to the end of the vector.
  • assign({value1, value2, ...}) re-initializes the vector with the specified elements.
  • pop_back() removes the last element in the vector.
  • clear() re-initializes the vector to size zero.

These functions are demonstrated in the following program:

#include <iostream>
#include <vector>
using namespace std;

void print_vector(const vector<uint64_t> &v)
{
    cout << "Size: " << v.size() << ", Elements: ";
    for (const uint64_t &i : v)
        cout << i << ' ';
    cout << '\n';
}

int main()
{
    vector<uint64_t> vec;
    print_vector(vec); // Size: 0, Elements:
    vec.push_back(5);
    print_vector(vec); // Size: 1, Elements: 5
    vec.push_back(7);
    print_vector(vec); // Size: 2, Elements: 5 7
    vec.assign({1, 2, 3});
    print_vector(vec); // Size: 3, Elements: 1 2 3
    vec.pop_back();
    print_vector(vec); // Size: 2, Elements: 1 2
    vec.clear();
    print_vector(vec); // Size: 0, Elements:
}

Notice that we passed the vector to print_vector by (constant) reference in order to avoid copying the whole vector every time we call the function. For this reason, we also had to define the reference i inside the for loop as const uint64_t &i - otherwise, if it was not a constant reference, we could have modified the elements of the vector by modifying i, which is forbidden since we passed the vector as a constant reference. (If you write for (uint64_t &i : v) instead, you will get an error from the compiler.)

Another very important member function is at(n), which returns the element at position n, starting from 0 as usual. vec.at(n) is equivalent to vec[n] as long as n is between 0 and vec.size() - 1. However, if n is out of range, vec[n] will simply return whatever garbage value is in the memory address being referred to, just as was the case for C arrays. That's not good!

On the other hand, vec.at(n) will cause an error if n is out of range. (More precisely, it will throw an exception; we will learn what that means later.) Naturally, this means that there is a bit of overhead to at compared to [], since at must spend some extra time checking if the requested element is in range or not. Therefore, [] may be preferred if you need the maximum performance possible, but only if you are absolutely sure that the element you are requesting will never be out of range.

Here is an example of using [] vs. at:

#include <iostream>
#include <vector>
using namespace std;

int main()
{
    vector<int64_t> vec = {1, 2, 3};
    // Accessing out-of-range element permitted: will print garbage
    cout << vec[5] << '\n';
    // Accessing out-of-range element prohibited: will terminate the program
    cout << vec.at(5) << '\n';
}

For a full list of the available member functions for vector, please see the C++ reference or Microsoft's C++ reference.

4.3.2 Strings ^

In C, strings were null-terminated arrays with elements of type char. C++ introduces a new way to define strings. To use vectors, we must #include <string>. Then, we define vectors using the new type std::string, or just string if we are using namespace std. For example:

#include <iostream>
#include <string>
using namespace std;

int main()
{
    string message = "This is a string.";
    cout << message << '\n';
}

You can concatenate strings by using the + operator:

#include <iostream>
#include <string>
using namespace std;

int main()
{
    string message1 = "This is ";
    string message2 = "a string.";
    string message = message1 + message2;
    cout << message << '\n'; // Prints "This is a string."
}

Note that this was not possible in C, since you can't "add" arrays. (Instead, you would have had to #include <string.h> and use the function strcat.) However, it is still possible to use the array operator [] to access individual characters in the string, starting from 0 as usual:

#include <iostream>
#include <string>
using namespace std;

int main()
{
    string message = "ABCDE";
    cout << message[0] << '\n'; // Prints "A"
    message[1] = 'b';
    cout << message << '\n'; // Prints "AbCDE"
}

You can also compare two strings using the == and != operators:

#include <iostream>
#include <string>
using namespace std;

int main()
{
    string password = "1234";
    string input;
    cout << "Enter password: ";
    cin >> input;
    if (input == password)
        cout << "Password is correct.\n";
    else
        cout << "Password is incorrect.\n";
}

Again, in C this was not possible (you would have had to use the function strcmp). Notice that we did not initialize input; one of the great benefits of using string is that strings are automatically initialized to an empty string if no initialization string is provided.

string has many useful member functions, including:

  • size() returns the length of the string.
  • insert(position, string) inserts string at position.
  • replace(position, length, string) replaces length characters at position with string.
  • find(string) looks for an instance of string and returns its position. Returns npos if the string was not found.
  • substr(position, length) extracts a substring of length characters starting at position.

The following program illustrates these functions:

#include <iostream>
#include <string>
using namespace std;

int main()
{
    string message = "I like apples.\n";
    cout << message; // I like apples.
    // Length of string: 15 characters.
    cout << "Length of string: " << message.size() << " characters.\n";
    message.insert(7, "red ");
    cout << message; // I like red apples.
    message.replace(7, 3, "green");
    cout << message; // I like green apples.
    // "apple" found at position 13.
    cout << "\"apple\" found at position " << message.find("apple") << ".\n";
    // 4 characters starting at position 2: "like".
    cout << "4 characters starting at position 2: \"" << message.substr(2, 4) << "\".\n";
}

For a full list of the available member functions for string, please see the C++ reference or Microsoft's C++ reference.

5 Classes and object-oriented programming ^

5.1 Classes

5.1.1 Introduction to classes

Classes are the most important and significant new feature offered by C++. They provide an additional level of abstraction beyond the abstractions we have discussed so far such as variables, functions, and structures. Essentially, a class is a user-defined type. Just like we can create, for example, a variable of type int, we can also create a variable - or, more generally, an object - of a user-defined type defined using a class.

A class can have two types of members: data, in the form of variables, and code, in the form of functions. The concept of an object with internal variables is already familiar to us from our discussion of structs in C above, and the concept of member functions is familiar to us from our discussion of vectors and strings - which are, in fact, classes defined in the C++ standard library (more precisely, vector is a template, but I will explain what that means later).

Classes have two separate parts: interface and implementation. The interface is the part that users can access directly. The implementation is only accessible internally, to the class itself, and not to the user. As an analogy, in a calculator, the interface is the buttons (input) and the display (output), while the implementation is the code that performs the actual calculations.

We define a class using the following syntax:

class name
{
public:
// Public members
private:
// Private members
};

The public members correspond to the interface (they are accessible to the user) while the private members correspond to the implementation (they are inaccessible to the user). Note that members are private by default, but one should always include the labels public: and private: explicitly to enhance readability and prevent confusion.

Most people who read the code for your class would only be interested in knowing the syntax for the public functions that they can access via the interface, and not the private functions and data that are part of the internal implementation of the class. Therefore, public members should always appear first.

5.1.2 Classes and structures ^

A struct in C++ is essentially equivalent to a class. The only difference is that in a struct, members are public by default, while in a class, members are private by default. We access members of a struct or a class, both data and functions, using a dot as in C structs - a notation that we are already familiar with from the classes string and vector. Consider a struct defining a point:

#include <iostream>
using namespace std;

struct point
{
    double x, y;
};

int main()
{
    point p;
    // Error: uninitialized, prints two arbitrary numbers
    cout << '(' << p.x << ", " << p.y << ")\n";
}

The declaration point p creates a new object of the structure point. We access the members of this object using p.x and p.y, as usual. Note that we did not need to write struct point or use a typedef, as in C; we only need to write point to declare an object.

Of course, the problem here is that the user did not initialize the point, so the members have garbage values. To avoid such errors, we should add some default values to the definition of the struct:

#include <iostream>
using namespace std;

struct point
{
    double x = 0, y = 0;
};

int main()
{
    point p;
    cout << '(' << p.x << ", " << p.y << ")\n"; // Prints (0, 0)
}

If the user does want to initialize this point, they can do so using array syntax:

point p = {1, 2};
cout << '(' << p.x << ", " << p.y << ")\n"; // Prints (1, 2)

This syntax works because point is a passive data structure (sometimes referred to as "plain old data" or POD), that is, it only holds specific data types in a specific order and does nothing else (just like a struct in C). For more complicated objects, we will need to use constructors.

5.1.3 Member functions ^

Let us now start using class instead of struct, even though they are essentially the same thing; class is what you should normally use in C++, because it is preferable to hide members from the users by making them private. We rewrite the program as follows:

#include <iostream>
using namespace std;

class point
{
public:
    double x = 0, y = 0;
};

int main()
{
    point p;
    cout << '(' << p.x << ", " << p.y << ")\n"; // Prints (0, 0)
}

It would be nice if point could automatically print its coordinates to the terminal, so we won't have to write a clunky cout statement every time. This will also allow us to easily change the format of all outputs at once by changing how point implements the output. So let's add a public member function print, which prints the point as a tuple:

#include <iostream>
using namespace std;

class point
{
public:
    void print()
    {
        cout << '(' << x << ", " << y << ")\n";
    }

    double x = 0, y = 0;
};

int main()
{
    point p1 = {1, 2};
    point p2 = {3, 4};
    p1.print(); // Prints (1, 2)
    p2.print(); // Prints (3, 4)
}

Notice that we now declared two different points, named p1 and p2, and used the member function print to easily print each of the points. Also, notice that inside point itself, the members are simply referred to by their names. x is just x when we're inside point, and it will always refer to the particular x for the object for which the code of print is being executed.

Finally, let us also add a member function to scale both coordinates of the point by the same factor:

#include <iostream>
using namespace std;

class point
{
public:
    void print()
    {
        cout << '(' << x << ", " << y << ")\n";
    }

    void scale(const double &s)
    {
        x *= s;
        y *= s;
    }

    double x = 0, y = 0;
};

int main()
{
    point p = {1, 2};
    p.scale(3);
    p.print(); // Prints (3, 6)
}

We could similarly add member functions to rotate the point around the origin, calculate its distance from the origin, and so on.

5.1.4 Constructors ^

Constructors are special member functions used to properly initialize a newly-created object of the given class. The constructors generate the object based on some input, and they are free to do whatever processing and error-checking is needed in order to properly create an object from the input. Constructors are defined as follows:

  • They are member functions with the same name as the class.
  • They must be public, since the user needs to access them as part of the class interface in order to initialize objects.
  • They have no return value.
  • Their arguments are the input used to initialize the object.

Often, there are multiple overloaded versions of constructors that take different kinds of inputs. In the case of point, let us define three different constructors:

#include <iostream>
using namespace std;

class point
{
public:
    // Default constructor: coordinates are initialized to their default values as specified below
    point() {}
    // Constructor with one argument: assumes the argument is the value of both x and y
    point(const double &_xy) : x(_xy), y(_xy) {}
    // Constructor with two arguments
    point(const double &_x, const double &_y) : x(_x), y(_y) {}

    // Prints the coordinates of the point as a tuple
    void print()
    {
        cout << '(' << x << ", " << y << ")\n";
    }

    // Scales both coordinates by the specified amount
    void scale(const double &s)
    {
        x *= s;
        y *= s;
    }

    // The values of the coordinates
    double x = 0, y = 0;
};

int main()
{
    point p1;       // Uses default constructor
    p1.print();     // (0, 0)
    point p2(5);    // Uses constructor with one argument
    p2.print();     // (5, 5)
    point p3(6, 7); // Uses constructor with two arguments
    p3.print();     // (6, 7)
}

Note that the constructors in this case are all empty code blocks {}; the actual assignment of values happens through the initializer list, which is of the form member(argument), ..., assigning the arguments of the function to specific member variables. The constructor

point(const double &_x, const double &_y) : x(_x), y(_y) {}

Does the same thing as

point(const double &_x, const double &_y)
{
    x = _x;
    y = _y;
}

However, it is more compact. More importantly, the values are assigned before the body of the function is executed. Here this doesn't really matter, since the function body is empty anyway, but in other cases, it would ensure that the members have been properly initialized, so that the function body cannot possibly make use of uninitialized variables, which would - as usual - cause unexpected behavior.

5.1.5 Exceptions: try-throw-catch ^

Consider a function invert() which returns the multiplicative inverse of a number:

#include <iostream>
using namespace std;

double invert(const double &x)
{
    return 1.0 / x;
}

int main()
{
    cout << invert(10); // Prints "0.1"
}

What happens if we call invert(0)? Nothing too catastrophic, actually; it simply returns inf, that is, the special floating-point value representing infinity. So we could use the function isinf(), defined in the header <cmath>, to check if the result is infinity, and issue an error message in that case:

#include <cmath>
#include <iostream>
using namespace std;

double invert(const double &x)
{
    return 1.0 / x;
}

int main()
{
    const double result = invert(0);
    if (isinf(result))
        cout << "Error: Division by zero!";
    else
        cout << result;
}

This works, but if we wanted to invert several different numbers, we would have to check for this error every single time. Things become even more complicated if, for example, we have a function that calls another function that calls another function that calls invert()... In that case, we would have to check for division by zero all over again in each function, and then propagate that error all the way back to main(). This would quickly make the code very cumbersome.

C++ includes a much smarter and more convenient way to handle errors in the form of exceptions. We enclose a block of code that may generate an error in brackets following the keyword try. The code may then generate an exception using the keyword throw. If an exception is thrown, execution of the try block is terminated, and control transfers to a catch block. If there is no catch block, or if we don't catch the specific exception that was thrown, the program itself terminates.

Here's how to implement the division by zero check using exceptions:

#include <iostream>
#include <stdexcept>
using namespace std;

double invert(const double &x)
{
    if (x == 0)
        throw invalid_argument("Division by zero!");
    return 1.0 / x;
}

int main()
{
    try
    {
        cout << invert(10) << '\n';
        cout << invert(0) << '\n';
        cout << invert(20) << '\n';
    }
    catch (const invalid_argument &e)
    {
        cout << "Error: " << e.what() << '\n';
    }
}

The output of this program is:

0.1
Error: Division by zero!

In main(), we try to print out the inverse of some numbers. The first line calls invert(10), which returns 0.1, and prints the result. The second line calls invert(0). Looking at the invert() function itself, we see that if x is zero, the function uses the throw keyword to throw an exception. Once the exception is thrown, the execution of the try block terminates, so the third line, which calls invert(20), never gets executed.

In this case, we chose to throw the exception invalid_argument, which is a ready-made exception class defined in the header file <stdexcept>, intended to be used when an invalid argument (in this case 0) is passed to a function. Note that <stdexcept> is included automatically when we include <iostream>, so we don't need to include separately, but we included it anyway for readability purposes. When we write invalid_argument("Division by zero!"), we are constructing an invalid_argument object and giving the constructor the string "Division by zero!" which is the error message to be (optionally) passed to the user.

For a full list of pre-defined exception classes, please see the C++ reference. We can also make our own custom exceptions by deriving them from the exception class, which is indeed how invalid_argument and other ready-made exceptions are defined; we will explain what "deriving" means later.

In principle, we could throw any user-defined class, or even a fundamental data type such as int. However, that is not recommended, since the user will expect to catch an exception object like invalid_argument. If we throw other types of objects, the user will need to consult the documentation to know which ones to catch. Furthermore, using exception objects makes the code more readable since the reader can immediately know what each exception means.

In the catch statement, we caught the exception as a constant reference. Although in principle exceptions can be caught by value, that's not recommended, as it would mean having to make a local copy of the object; you will get a warning from the compiler if you try to do that. As with any other object, it is best to deal with a reference to the object and not a copy of the object, as that is faster and takes up less space in memory.

The invalid_argument class, as well as any other class derived from the exception class, only has one member function: what(), which simply returns the string passed to the constructor. Therefore, when we call e.what(), we get the string "Division by zero!", which we then print out as a user-friendly description of the error.

5.1.6 Invariants, private members, and encapsulation ^

Let us now define a class named triangle, which describes a triangle with sides of lengths a, b, and c. Unlike the coordinates of a point, which can be any two real numbers, the sides of a triangle must have non-negative length and must satisfy the triangle inequality.

Since we can't just trust the user to give us valid input, our constructor must validate the input by checking that the numbers can define a triangle. Furthermore, the values of the sides must be private, so that once the sides have been validated, the user won't be able to change them to invalid values.

To facilitate error checking, if invalid input is given, the constructor will throw an invalid_argument exception. We also provide a print() member function to print out the value of the sides. Here is how it works:

#include <iostream>
#include <stdexcept>
using namespace std;

class triangle
{
public:
    triangle(const double &_a, const double &_b, const double &_c)
        : a(_a), b(_b), c(_c)
    {
        if ((a < 0) or (b < 0) or (c < 0))
            throw invalid_argument("Sides cannot be negative!");
        if ((a > b + c) or (b > c + a) or (c > a + b))
            throw invalid_argument("Triangle inequality must be satisfied!");
    }

    void print()
    {
        cout << '(' << a << ", " << b << ", " << c << ")\n";
    }

private:
    double a = 0, b = 0, c = 0;
};

int main()
{
    try
    {
        triangle t1(4, 2, 5);
        t1.print();
        triangle t2(6, -7, 8);
        t2.print();
        triangle t3(2, 2, 5);
        t3.print();
    }
    catch (const invalid_argument &e)
    {
        cout << "Error: " << e.what() << '\n';
    }
}

The first triangle t1 gets initialized successfully. However, the second triangle t2 has a negative side length, so it does not get initialized, and instead we get an exception. Execution of the try block will then terminate. If you remove or fix t2, then t3 will also generate an exception, since it does not satisfy the triangle inequality.

We defined a public constructor, but the side lengths a, b, and c are private so they are inaccessible to the user. Indeed, if you try to write, for example,

t1.a = 1;

you will get errors saying that a is private and inaccessible. This is very important, since it ensures that the implementation of triangle can always assume it is dealing with a valid triangle, with non-negative side lengths which satisfy the triangle inequality.

The condition for what constitutes valid data for a class is called an invariant. This condition is assumed to be satisfied always - it does not vary, which is why it's called "invariant". A well-written class must ensure that the invariant is always satisfied by:

  • Writing proper constructors which enforce the invariant,
  • Making all data private and thus inaccessible to the user,
  • Ensuring that any member function which changes the data does so in a way which preserves the invariant.

If this is done correctly - and it must, if you intend to write a good C++ program - it greatly simplifies the implementation, improves performance, and eliminates bugs. The reason is that verifying the invariant every time a member function is called could significantly decrease performance, while not verifying it would lead to bugs whenever the data is invalid. If your code guarantees that the invariant is always satisfied, you don't have to verify it every time a member function is called.

Therefore, either we don't allow the user to change the sides at all once the triangle object has been initialized, or we provide a member function change_sides() which will only change the sides if the input is valid. In that case, the constructor can simply call that same member function, so we don't have to write the validation code twice. Here is how it works:

#include <iostream>
#include <stdexcept>
using namespace std;

class triangle
{
public:
    triangle(const double &_a, const double &_b, const double &_c)
    {
        change_sides(_a, _b, _c);
    }

    void change_sides(const double &_a, const double &_b, const double &_c)
    {
        if ((_a < 0) or (_b < 0) or (_c < 0))
            throw invalid_argument("Sides cannot be negative!");
        if ((_a > _b + _c) or (_b > _c + _a) or (_c > _a + _b))
            throw invalid_argument("Triangle inequality must be satisfied!");
        a = _a;
        b = _b;
        c = _c;
    }

    void print()
    {
        cout << '(' << a << ", " << b << ", " << c << ")\n";
    }

private:
    double a = 0, b = 0, c = 0;
};

int main()
{
    triangle t(4, 2, 5);
    t.print();
    try
    {
        t.change_sides(4, 3, 5);
        t.change_sides(1, 3, 5);
    }
    catch (const invalid_argument &e)
    {
        cout << "Error: " << e.what() << '\n';
    }
    t.print();
}

The output is:

(4, 2, 5)
Error: Triangle inequality must be satisfied!
(4, 3, 5)

Therefore, t.change_sides(4, 3, 5) succeeded, and the sides were changed, but t.change_sides(1, 3, 5) failed, so the try block terminated and the sides were not allowed to change. When we print out the sides after the try-catch blocks, we see that indeed, the sides were changed to (4, 3, 5) but not to (1, 3, 5).

We have defined two classes so far: point and triangle. These classes illustrate encapsulation, one of the fundamental principles of object-oriented programming. This principle has two components:

  1. Data should be bundled together with the functions that process and manipulate that data. In other words, an object - that is, an instance of a class - should be a self-sufficient package. For example, a point object not only contains the data on the coordinates of the point, but also the member functions print and scale which operate with that data.
  2. Data should not be directly accessible, instead only accessible via member functions that ensure its validity. In other words, there should be a "filter" that does not let any invalid data be stored in the object. For example, a triangle object ensures that it always holds data for a valid triangle, which would be impossible if the user was able to manually modify each side of the triangle instead of going through the constructor.

5.1.7 Constructors for the triangle class ^

There's no such thing as an "uninitialized" triangle object. In its current form, a triangle cannot be declared without initializing all three sides explicitly. If we try to declare

triangle t;

we will get an error saying no default constructor exists for class "triangle". The compiler doesn't know how to construct this object, since the only constructor we have expects three arguments, while here we have zero arguments. Trying to initialize it as triangle t{}; will also fail, this time with the error no instance of constructor "triangle::triangle" matches the argument list.

If we want to allow initializing an "empty" or degenerate triangle object, with all its sides set to the default value of zero, we must create a default constructor (i.e. a constructor which does not take any arguments) manually, as we did for the point class.

Warning: If you try to initialize the triangle with empty parentheses, triangle t();, this will seem to work, but in fact will not call any default constructor, or even create any object. The code will compile, but you will receive the warning empty parentheses were disambiguated as a function declaration. What this means is that, in the absence of an appropriate constructor, the compiler interpreted triangle t(); as a function declaration: it's a function named t which takes no arguments (hence the empty parentheses) and returns a triangle object as output! This is called the "most vexing parse".

Let us, then, define a default constructor for the triangle class; and while we're at it, let us also define constructors for each possible number of arguments. The class will now have four constructors:

  • The constructor with no arguments will create a degenerate triangle with all sides equal to zero.
  • The constructor with one argument will create an equilateral triangle with all sides of the same length.
  • The constructor with two arguments will create a right triangle with the third side given by the Pythagorean theorem (c2 = a2 + b2), using the function sqrt() from the header <cmath>.
  • The constructor with three arguments will be the one we defined previously.

All constructors will use change_sides() to validate the input. The final result is:

#include <cmath>
#include <iostream>
#include <stdexcept>
using namespace std;

class triangle
{
public:
    // Constructor with no arguments: define a degenerate triangle with all sides equal to zero.
    triangle()
    {
        change_sides(0, 0, 0);
    }

    // Constructor with one argument: define an equilateral triangle with all sides equal to a.
    triangle(const double &_a)
    {
        change_sides(_a, _a, _a);
    }

    // Constructor with two arguments: define a right triangle with sides a and b. The third side will be determined by the Pythagorean theorem.
    triangle(const double &_a, const double &_b)
    {
        change_sides(_a, _b, sqrt(_a * _a + _b * _b));
    }

    // Constructor with three arguments: define an arbitrary triangle with sides a, b, c.
    triangle(const double &_a, const double &_b, const double &_c)
    {
        change_sides(_a, _b, _c);
    }

    // Change (or initialize) the sides of the triangle, after making sure the values are valid.
    void change_sides(const double &_a, const double &_b, const double &_c)
    {
        if ((_a < 0) or (_b < 0) or (_c < 0))
            throw invalid_argument("Sides cannot be negative!");
        if ((_a > _b + _c) or (_b > _c + _a) or (_c > _a + _b))
            throw invalid_argument("Triangle inequality must be satisfied!");
        a = _a;
        b = _b;
        c = _c;
    }

    // Print the sides of the triangle.
    void print()
    {
        cout << '(' << a << ", " << b << ", " << c << ")\n";
    }

private:
    // The lengths of the sides.
    double a = 0, b = 0, c = 0;
};

int main()
{
    try
    {
        triangle degenerate_triangle;
        degenerate_triangle.print();
        triangle equilateral_triangle(1);
        equilateral_triangle.print();
        triangle right_triangle(3, 4);
        right_triangle.print();
        triangle arbitrary_triangle(5, 6, 7);
        arbitrary_triangle.print();
    }
    catch (const invalid_argument &e)
    {
        cout << "Error: " << e.what() << '\n';
    }
}

We also added comments to explain how to class works, since it has become quite large. The output is:

(0, 0, 0)
(1, 1, 1)
(3, 4, 5)
(5, 6, 7)

5.1.8 Separating member functions from the class ^

To make the code (arguably) more readable, we can move the code for the member functions outside of the class, and only include their prototype declarations inside it. When we define a member outside of the class, we must add triangle:: before the name of the member, so that the compiler knows which class it belongs to:

#include <cmath>
#include <iostream>
#include <stdexcept>
using namespace std;

class triangle
{
public:
    // Constructor with no arguments: define a degenerate triangle with all its sides equal to zero.
    triangle();
    // Constructor with one argument: define an equilateral triangle with all sides equal to a.
    triangle(const double &_a);
    // Constructor with two arguments: define a right triangle with sides a and b, and the third side given by the Pythagorean theorem.
    triangle(const double &_a, const double &_b);
    // Constructor with three arguments: define an arbitrary triangle with sides a, b, c.
    triangle(const double &_a, const double &_b, const double &_c);
    // Change (or initialize) the sides of the triangle, after making sure the values are valid.
    void change_sides(const double &_a, const double &_b, const double &_c);
    // Print the sides of the triangle.
    void print();

private:
    // The lengths of the sides.
    double a = 0, b = 0, c = 0;
};

triangle::triangle()
{
    change_sides(0, 0, 0);
}

triangle::triangle(const double &_a)
{
    change_sides(_a, _a, _a);
}

triangle::triangle(const double &_a, const double &_b)
{
    change_sides(_a, _b, sqrt(_a * _a + _b * _b));
}

triangle::triangle(const double &_a, const double &_b, const double &_c)
{
    change_sides(_a, _b, _c);
}

void triangle::change_sides(const double &_a, const double &_b, const double &_c)
{
    if ((_a < 0) or (_b < 0) or (_c < 0))
        throw invalid_argument("Sides cannot be negative!");
    if ((_a > _b + _c) or (_b > _c + _a) or (_c > _a + _b))
        throw invalid_argument("Triangle inequality must be satisfied!");
    a = _a;
    b = _b;
    c = _c;
}

void triangle::print()
{
    cout << '(' << a << ", " << b << ", " << c << ")\n";
}

Now, anyone who reads our code will immediately see that there are four constructors and one member function, and the comments will explain what they do. This is the external interface of the class, the part that the user is going to make use of. The details of the internal implementation of the class, that is, the code itself, are kept separate, as they are generally not of any interest to the user.

5.1.9 Inline functions ^

Note that it is not just a matter of taste and/or readability whether to include the member function definitions inside or outside the class. Functions that are included inside a class are automatically interpreted as inline functions, which means that the compiler will attempt to generate a copy of the function "inline" in the machine code, rather than a call to a function located elsewhere.

Inlining can provide a performance boost, as calling a function takes time. On the other hand, it means that the code itself will be larger and occupy more memory, and that might actually hurt performance. Generally, functions should only be inlined if they consist of no more than one or two lines, but this is not a rule that applies to every situation.

If we want to move a function outside of the class, but still have it be an inline function, we can add the keyword inline. This is added only to the definition of the function, i.e. the part that contains the code, and not to the prototype declaration of the function inside the class itself, which is only meant to convey information about the interface. Whether the function is inline or not is part of the implementation, not the interface, so it is not of interest to the user. For example:

class triangle
{
public:
    triangle();
    // ...
}

inline triangle::triangle()
{
    change_sides(0, 0, 0);
}

However, note that the inline keyword is only a suggestion; most compilers will inline short functions anyway as part of the optimization process. We will talk more about inline functions and performance optimization later in the course.

5.1.10 Splitting a project into separate files ^

The code for the triangle class has become quite long. It may be easier to handle if we split it into separate files. The standard way to do this in C++ is to split the class into two files: a header file (.hpp extension) containing the prototype declarations, and a source file (.cpp extension) containing the function definitions. We also split the class from the main program. Thus we split the project into three files:

  • main.cpp will contain the main() function and possibly some other functions that are unrelated to the triangle class.
  • triangle.cpp will contain the triangle class and the definitions of all its member functions.
  • triangle.hpp will contain the prototype declarations of the triangle class and all its member functions and variables, but not any actual code.

What happens then isn't simply that the code for both files is compiled together. Instead, main.cpp and triangle.cpp will be compiled separately, and then linked together; we will discuss this in more detail later. main.cpp must know how the functions in triangle.cpp were declared in order to use them, e.g. what kind of arguments they get and what type of values they return. This is achieved by including triangle.hpp, since it contains the required information.

You may ask why the compiler can't just look inside triangle.cpp and figure out how the functions work on its own. Theoretically that may be possible, but the idea is that in C++, different .cpp files must be able to compile independently of each other. So they compiler doesn't know or care about triangle.cpp when you compile main.cpp. In fact, sometimes you may not even have access to the source code for triangle, only the pre-compiled machine code. As long as you have the appropriate header file, that will not be a problem.

Here are the contents of the three files.

main.cpp:

#include "triangle.hpp"

int main()
{
    triangle t(1, 2, 3);
    t.print();
}

Note that in this simple example we didn't have to include the lines #include <iostream>, #include <cmath>, or using namespace std in main.cpp since they are never needed there; we only use functions from the C++ standard library within the triangle class. However, we did need to write #include "triangle.hpp" so that we have access to the class itself.

triangle.hpp:

class triangle
{
public:
    // Constructor with no arguments: define a degenerate triangle with all its sides equal to zero.
    triangle();
    // Constructor with one argument: define an equilateral triangle with all sides equal to a.
    triangle(const double &_a);
    // Constructor with two arguments: define a right triangle with sides a and b, and the third side given by the Pythagorean theorem.
    triangle(const double &_a, const double &_b);
    // Constructor with three arguments: define an arbitrary triangle with sides a, b, c.
    triangle(const double &_a, const double &_b, const double &_c);

    // Change (or initialize) the sides of the triangle, after making sure the values are valid.
    void change_sides(const double &_a, const double &_b, const double &_c);
    // Print the sides of the triangle.
    void print();

private:
    // The lengths of the sides.
    double a = 0, b = 0, c = 0;
};

This header file contains only the interface of the class, with no actual code. It will be used, separately, by both main.cpp and triangle.cpp, and neither of them will compile without it. Therefore, both .cpp files must contain the line #include "triangle.hpp". Furthermore, this is the file that the user will read in order to understand how the class works, so it should contain comments to explain the role of each member of the class.

triangle.cpp:

#include <cmath>
#include <iostream>
#include <stdexcept>
#include "triangle.hpp"
using namespace std;

triangle::triangle()
{
    change_sides(0, 0, 0);
}

triangle::triangle(const double &_a)
{
    change_sides(_a, _a, _a);
}

triangle::triangle(const double &_a, const double &_b)
{
    change_sides(_a, _b, sqrt(_a * _a + _b * _b));
}

triangle::triangle(const double &_a, const double &_b, const double &_c)
{
    change_sides(_a, _b, _c);
}

void triangle::change_sides(const double &_a, const double &_b, const double &_c)
{
    if ((_a < 0) or (_b < 0) or (_c < 0))
        throw invalid_argument("Sides cannot be negative!");
    if ((_a > _b + _c) or (_b > _c + _a) or (_c > _a + _b))
        throw invalid_argument("Triangle inequality must be satisfied!");
    a = _a;
    b = _b;
    c = _c;
}

void triangle::print()
{
    cout << '(' << a << ", " << b << ", " << c << ")\n";
}

This file contains the actual implementation of triangle.

Before we can compile the project with these three separate files, we will need to make some small modifications to the configuration files of our Visual Studio Code project (located in the .vscode subdirectory). First, open the file tasks.json. Currently, the args field should look something like this:

"args": [
    "${file}",
    "-o",
    "${fileDirname}\\${fileBasenameNoExtension}.exe",
    "-Wall",
    "-Wextra",
    "-Wconversion",
    "-Wsign-conversion",
    "-Wshadow",
    "-Wpedantic",
    "-std=c++20",
    "-ggdb3"
],

(If you don't have these arguments in your JSON file, you should add them now - I explained their purpose in the beginning of the course and the meaning of -std=c++20 in the beginning of this chapter.)

Notice the highlighted string "${file}". Currently, VS Code is configured to only compile the active file; ${file} is a placeholder that will get replaced by that file's name. If we try to compile the program now, only the active file will be compiled, either main.cpp or triangle.cpp, but not both. If main.cpp is the active file, then it won't know about the class triangle and the compilation will fail. If triangle.cpp is the active file, then the compilation will also fail because there is no main function in that file.

We can fix this in one of two ways: either we write the names of the files explicitly, i.e. replace ${file} with main.cpp triangle.cpp, or simply replace ${file} with ${workspaceFolder}\\*.cpp, which will automatically compile all of the files in the workspace folder. * is a wildcard which will match any file name, so *.cpp will match any file with the extension .cpp.

The latter is usually the preferred option, since it means that we do not have to modify tasks.json every time we add a new source file or change its name. Of course, this means that all of the files in the workspace folder must belong to the same project, since they will be compiled and linked together, and conversely, all files to be linked must be placed in the same folder.

The second highlighted string determines the name of the executable file. Again, the default is to name the file after whatever source file is currently open in the editor. That is not a good idea, since if we are editing main.cpp the executable file will be main.exe and if we're editing triangle.cpp the executable file will be triangle.exe, even though in both cases the same executable file will be generated.

To solve this problem, let us replace the string "${fileDirname}\\${fileBasenameNoExtension}.exe" with "${workspaceFolder}\\CSE701.exe". Of course, you can choose any other name for the executable file, and you can put it in any other folder you want; in fact, for big projects it makes sense to put the source, header, and executable files all in different folders, but there's no reason to do that for our simple project.

Note: On Linux, the executable file should not have the extension .exe, and the path should have a slash / instead of a double backslash \\ (this is actually just one backslash, but since a backslash is used to escape special characters, such as \n for a newline character, we need to escape the backslash itself as well). Use ${workspaceFolder}/*.cpp for the first highlighted line and "${workspaceFolder}/CSE701" for the second.

Now let us open the launch.json file. It should look something like this:

{
    "version": "0.2.0",
    "configurations": [
        {
            // ...
            "program": "${fileDirname}\\${fileBasenameNoExtension}.exe",
            // ...
        }
    ]
}

Replace the highlighted string with "${workspaceFolder}\\CSE701.exe", so that VS Code will know to execute the file CSE701.exe when we enter debug mode. On Linux, use "${workspaceFolder}/CSE701" instead.

Lastly, open the file c_cpp_properties.json, which should look like this:

{
    "configurations": [
        {
            // ...
            "includePath": [
                "${workspaceFolder}/**"
            ],
            // ...
        }
    ],
    "version": 4
}

Make sure the includePath field includes the folder ${workspaceFolder}/**. This means that VS Code will look for include files (.h or .hpp) in the workspace folder and all its subfolders. The wildcard ** matches not only any file name, but also any file in any subfolder.

The .json files that I used to compile this program on my computer are as follows:

tasks.json:

{
    "version": "2.0.0",
    "tasks": [
        {
            "type": "cppbuild",
            "label": "C/C++: g++.exe build active file",
            "command": "C:/Users/barak/mingw64/bin/g++.exe",
            "args": [
                "${workspaceFolder}\\*.cpp",
                "-o",
                "${workspaceFolder}\\CSE701.exe",
                "-Wall",
                "-Wextra",
                "-Wconversion",
                "-Wsign-conversion",
                "-Wshadow",
                "-Wpedantic",
                "-std=c++20",
                "-ggdb3"
            ],
            "options": {
                "cwd": "C:/Users/barak/mingw64/bin"
            },
            "problemMatcher": [
                "$gcc"
            ],
            "group": {
                "kind": "build",
                "isDefault": true
            },
            "detail": "compiler: C:/Users/barak/mingw64/bin/g++.exe"
        }
    ]
}

launch.json:

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "g++.exe - Build and debug active file",
            "type": "cppdbg",
            "request": "launch",
            "program": "${workspaceFolder}\\CSE701.exe",
            "args": [],
            "stopAtEntry": false,
            "cwd": "${workspaceFolder}",
            "environment": [],
            "externalConsole": false,
            "MIMode": "gdb",
            "miDebuggerPath": "C:\\Users\\barak\\mingw64\\bin\\gdb.exe",
            "setupCommands": [
                {
                    "description": "Enable pretty-printing for gdb",
                    "text": "-enable-pretty-printing",
                    "ignoreFailures": true
                }
            ],
            "preLaunchTask": "C/C++: g++.exe build active file"
        }
    ]
}

c_cpp_properties.json:

{
    "configurations": [
        {
            "name": "Win32",
            "includePath": [
                "${workspaceFolder}/**"
            ],
            "defines": [
                "_DEBUG",
                "UNICODE",
                "_UNICODE"
            ],
            "windowsSdkVersion": "10.0.20348.0",
            "compilerPath": "C:/Users/barak/mingw64/bin/g++.exe",
            "intelliSenseMode": "windows-gcc-x64",
            "cStandard": "c17",
            "cppStandard": "c++20"
        }
    ],
    "version": 4
}

I'm only including these files here for reference; they will not work on your system in their current form, unless your name is also Barak, you're also using Windows, and your compiler is also located in the folder C:/Users/barak/mingw64/bin.

Now we are ready to run the program! No matter which file is currently open in the editor, pressing F5 will compile both main.cpp and triangle.cpp, link them into the executable file triangle.exe, and then execute that file in debug mode. You should see the output (1, 2, 3) in the terminal.

Finally, a note about inline functions when using multiple files in a project. An inline function is copied as-is into the code, so the compiler needs to have the source code for the function. Therefore, an inline function must be defined in the header file, because if triangle.cpp is already compiled and we don't have the source for it, then the compiler won't know what code to use for the inline function.

5.1.11 Const vs. non-const member functions ^

Some member functions of a class modify an object's data, while others do not. We can let the compiler know that a member function does not modify the data by adding the const keyword to its definition.

In the case of triangle, print() should be a const member function, since it does not modify the data. However, change_sides() should not be const, since it obviously does modify the data. So we should change the declaration of print() in triangle.hpp to:

void print() const;

and in triangle.cpp to:

void triangle::print() const

Let us define two new member functions:

  • scale will multiply each of the triangle's side lengths by a scalar. This changes the data, so will not be a const member function.
  • area will return the area of the triangle as calculated from the side lengths (using Heron's formula). This does not change the data, so it will be a const member function.

Add the following to triangle.hpp, in the public section:

// Scale the triangle by the given scalar.
void scale(const double &);
// Calculate the area of the triangle.
double area() const;

Add the following to triangle.cpp:

void triangle::scale(const double &s)
{
    if (s < 0)
        throw invalid_argument("Scalars cannot be negative!");
    a *= s;
    b *= s;
    c *= s;
}

double triangle::area() const
{
    const double s = (a + b + c) / 2;
    return sqrt(s * (s - a) * (s - b) * (s - c));
}

To test these functions, we can use the following main.cpp:

#include <iostream>
#include "triangle.hpp"
using namespace std;

int main()
{
    triangle t(3, 4, 5);
    cout << "Triangle sides: ";
    t.print();
    cout << "Triangle area: ";
    cout << t.area() << '\n';
    const double s = 2;
    t.scale(s);
    cout << "Triangle sides after scaling by " << s << ": ";
    t.print();
    cout << "Triangle area after scaling by " << s << ": ";
    cout << t.area();
}

It is important to declare member functions which do not change the object's data as const for two main reasons. The first is readability: const provides information about how the function behaves, not only the compiler, but also to the user who reads the header file.

The second reason is that if the user defines a const object, the compiler will generate an error when a non-const member function is called. You can check this by changing the line triangle t(3, 4, 5) to const triangle t(3, 4, 5); the compiler will let you call area(), but not scale().

5.1.12 Enumeration classes ^

Enumerations in C++ work very similarly to enumerations in C. However, in C, an enum just assigned labels to integers, and any variable defined with that enum was secretly an int, which could lead to bugs if we're not careful - for example, by assigning to it an integer value that does not have a corresponding label. In C++, by defining an enumeration as an enum class, we define an entirely new type. For example, consider an improvement of our color enumeration from the C example:

enum class color
{
    red,
    green,
    blue
};

Notice that now we write the labels in lowercase, since they are no longer global constants, as they were in C. Instead, they are accessible using color::red, color:green, and color::blue. Also, we define a variable of this type using color c instead of enum color c; in C++, you don't need to write the enum directly (same as for struct).

In the following example we are using a "plain" enum instead of an enum class (also called a scoped enumeration):

#include <iostream>
using namespace std;

enum color
{
    red,
    green,
    blue
};

int main()
{
    color c = red;   // Note that red is in the global scope, causing potential collisions.
    int x = c;       // Works; enum is implicitly converted to an int.
    cout << x + red; // Also works; can add int and color, which doesn't make sense. Prints 0.
}

This is a bit problematic. First, just like with C enumerations, the labels red, green, and blue are in the global scope. Usually in C++ we prefer to separate names into different namespaces. Furthermore, when we create a new int and assign the value of c to it, that value is implicitly converted to an int, and the same happens when we try to add an int and a color. That doesn't make sense; as I explained regarding C enumerations, the integer values of each label should never be used explicitly.

Let us now use enum class instead:

#include <iostream>
using namespace std;

enum class color
{
    red,
    green,
    blue
};

int main()
{
    color c = color::red;   // Note that red is in the color:: scope, not the global scope.
    int x = c;              // Error: a value of type "color" cannot be used to initialize an entity of type "int".
    cout << x + color::red; // Error: cannot add an int and a color.
}

We see that color is now indeed an entirely new type, which can only take the specific values we assigned as labels; it is not an int, nor can it be implicitly converted to an int.

It is always better to use enum class, as it prevents bugs due to incorrectly comparing an enum with an int - or even with another enum. For example, consider this code:

#include <iostream>
using namespace std;

enum color
{
    red,
    green,
    blue
};

enum shape
{
    triangle,
    circle,
    square
};

int main()
{
    color c = red;
    shape s = triangle;
    if (c == s) // Comparing is possible with enum, but not with enum class
        cout << "Values are the same!";
}

The first thing to notice here is that if we still used the triangle class we defined above, it would clash with the label triangle. This is exactly why it is better to put things in separate namespaces.

Furthermore, this program will print out "Values are the same!" because both of them are secretly equal to 0, as both are the first labels in their respective enumerations, and there is an implicit conversion taking place here. (The compiler warns about it, but still compiles.)

However, if we change both enums to enum class, and correspondingly red to color::red and triangle to shape::triangle, we will get an error - as we should, since this is very bad code - and the program won't compile.

Finally, note that both in the case of enum and enum class, it is still not possible to write something like c = 0, as we could in C. (Well, you could write c = (color)0 to explicitly typecast 0 to color if you really wanted, but you shouldn't.)

5.1.13 Static class members ^

Static class members, defined with the keyword static, are a generalization of static function variables. They are members that exist in the class itself, independently of any specific objects of that class.

For example, we can use a static member variable to keep count of how many objects of a class have been created. This is illustrated in the following program:

#include <iostream>
using namespace std;

class counter
{
public:
    counter()
    {
        total_count++;
        my_count = total_count;
    }

    void print_count() const
    {
        cout << "I am object #" << my_count << ".\n";
    }

    inline static uint64_t total_count = 0;
    uint64_t my_count;
};

int main()
{
    const counter first;
    const counter second;
    const counter third;

    cout << "Created " << counter::total_count << " objects in total.\n";

    first.print_count();
    second.print_count();
    third.print_count();
}

The output is:

Created 3 objects in total.
I am object #1.
I am object #2.
I am object #3.

We see that the static member variable total_count keeps count of how many objects were created in total, and it is accessed not via a specific object, but rather as a name in the namespace counter, i.e. counter::total_count. This member exists independently of any specific objects of that class - in fact, it would still exist if we did not create any objects at all.

In addition to static, we also declared total_count as inline. The reason is that this allows us to initialize it within the class itself. Without the inline keyword, we would have had to initialize it outside the class definition, or rely on the user to initialize it manually; in both cases it is possible that due to oversight total_count will not be properly initialized, and thus will start the count with some garbage value.

We can also define static member functions. For example, it's a good idea to make total_count and my_count private members, since the class invariants in this case are that:

  • total_count must be equal to the total number of objects we created,
  • my_count must be consecutive and unique for each counter.

So we don't want to user to manually change the values of these variables and break the invariant. However, if total_count is private, then we should make a public static member function get_total_count() to allow read-only access to the variable:

#include <iostream>
using namespace std;

class counter
{
public:
    counter()
    {
        total_count++;
        my_count = total_count;
    }

    void print_count() const
    {
        cout << "I am object #" << my_count << ".\n";
    }

    static uint64_t get_total_count()
    {
        return total_count;
    }

private:
    inline static uint64_t total_count = 0;
    uint64_t my_count;
};

int main()
{
    const counter first;
    const counter second;
    const counter third;

    cout << "Created " << counter::get_total_count() << " objects in total.\n";

    first.print_count();
    second.print_count();
    third.print_count();
}

This will have the same output as before. Note that static member functions are accessed similar to static member variables: with the class namespace prefix, e.g. counter::get_total_count(). Also note that we did not need to declare get_total_count as a const member function, since it cannot access member variables of any specific objects anyway, only static member variables of the class itself, and thus there is no situation in which a const object can accidentally be modified by the function.

Warning: While it is possible to access static members as if they were members of a specific object, e.g. first.total_count or first.get_total_count(), this should be avoided, as it may incorrectly imply to the reader that total_count has a value specific to that object. For maximum readability, always access static members using the namespace of the class, i.e. counter::total_count or counter::get_total_count().

5.2 Operator overloading ^

5.2.1 Introduction to operator overloading

Operators such as + and - have a well-defined meaning for fundamental types such as int and double. An extremely useful feature of C++ is the ability to give meaning to such operators for user-defined types (that is, classes) as well. For example, if we have a matrix class, we could define + to be the operation of matrix addition and * to be matrix multiplication. This is called operator overloading.

We can overload almost every operator, as long as it is an existing operator in C++; we cannot create new operators with new symbols. The full list of operators that can be overloaded is:

  • Arithmetic operators:
    • Binary infix: + (add), - (subtract), * (multiply), / (divide), % (modulo), += (add and assign), -= (subtract and assign), *= (multiply and assign), /= (divide and assign), %= (modulo and assign)
    • Unary prefix: + (positive), - (negative)
    • Unary prefix or postfix: ++ (increment), -- (decrement)
  • Bit manipulation:
    • Binary infix: & (bitwise AND), | (bitwise OR), ^ (bitwise XOR), << (bitwise left shift), >> (bitwise right shift), &= (bitwise AND and assign), |= (bitwise OR and assign), ^= (bitwise XOR and assign), <<= (bitwise left shift and assign), >>= (bitwise right shift and assign)
    • Unary prefix: ~ (bitwise NOT)
  • Comparison and logic:
    • Binary infix: == (equals), != (does not equal), < (less than), > (greater than), <= (less than or equal), >= (greater than or equal), && (and), || (or), <=> (the "spaceship operator" for three-way comparison, since C++20)
    • Unary prefix: ! (not)
  • Member access:
    • Binary infix: [] (subscript / array element), -> (member of pointer), ->* (pointer to member of pointer)
    • Unary prefix: * (pointer dereference), & (address of)
  • Miscellaneous:
    • Binary infix: = (assign), , (comma expression)
    • N-ary infix: () (function call)

Infix means the operator comes between the two operands (e.g. x + y), prefix means the operator precedes the operand (e.g. -x), and postfix means the operator follows the operand (e.g. x++). Binary means there are two operands (e.g. x + y) and unary means there is one operand (e.g. -x).

The function call operator () is the only one that allows more than two operands, i.e. f(x, y, z, ...). Of course, it is possible to write something like x + y + z, but the + operator is still binary, since this expression will be evaluated as (x + y) + z, i.e. two binary operators evaluated one after the other.

Some of these operators have not been used so far in this course. Let us quickly explain what they do:

  • The unary prefix + usually doesn't actually do anything; +x is equal to x.
  • When ++ comes as a prefix, i.e. ++x, it is a "pre-increment" operator, which increments the variable and returns the incremented value. When ++ comes as a postfix, i.e. x++, it is a "post-increment" operator, which increments the variable but returns the original value. Same goes for --.
    • To remember which is which, notice that they are executed from left to right: ++x means "first increment, then return x" while x++ means "first return x, then increment". So int x = 1; y = ++x; will result in x = 2 and y = 2, but int x = 1; y = x++; will result in x = 2 and y = 1.
  • We didn't mention the bit manipulation operators before because they are not used very often. They perform the usual bit operations AND, OR, XOR, and NOT bit-by-bit. The shift operators << and >> are respectively equivalent to multiplying and dividing by powers of 2. You can read more about them on Wikipedia.
    • Note that the most common use of << and >> in C++ is not for bit manipulation; instead, these operators are overloaded to deal with input/output streams. Whenever you write cout << or cin >>, you are using overloaded operators!
  • The three-way comparison operator <=> was introduced in C++20. To use it, you need to enable C++20 support in the compiler (see above) and #include <compare> in your program. We will not discuss this operator in our course.
  • The comma operator , is not the usual comma such as in a function call f(x, y), it is used to evaluate several expressions and keep only the result of the last one. For example, the statements int x = 0; cout << (++x, ++x); will output 2 since the first ++x increments x by 1 and gives the result 1 which is discarded, while the second ++x increments x by 1 and gives the result 2 which is then printed.
  • We have seen above that the operator -> is used to access a member of a struct or class when the latter is given as a pointer. The expression x->y is equivalent to (*x).y. Similarly, for the operator ->*, the expression x->*y is equivalent to (*x).*y.

Note that the operators :: (scope resolution), . (member of object), .* (pointer to member of object), and ?: (ternary conditional operator) cannot be overloaded.

Operators cannot be overloaded for fundamental types; you cannot redefine how + works for int, for example. At least one of the operands must be a user-defined type, although it is possible to combine a user-defined type with a fundamental type - for example, we could overload * so that if it is used with one double and one user-defined matrix object, it multiplies the entire matrix by the number.

Apart from the overloaded operators << and >> that we discussed above, we already encountered another example of an overloaded operator when we talked about the C++ string class. To concatenate strings, we simple write string1 + string2; this works because the operator + has been overloaded with a function that performs concatenation of strings.

Overloaded operators retain their existing syntax and number of operands. For example, ! (not) must still act on one object and < (less than) must still compare two objects. The syntax for operator overloading for unary operators such as ! and ++, which act on one operand, is:

output_type operator@(input_type operand)
{
    // Function body
}

Here, @ should be replaced with the actual operator, e.g. operator! to replace the ! operator. For example, !x will call the function operator!(x).

The syntax for operator overloading for binary operators such as + and *, which act on two operands, is:

output_type operator@(input_type1 operand1, input_type2 operand2)
{
    // Function body
}

Again, @ should be replaced with the actual operator, e.g. operator+ to replace the + operator. For example, x + y will call the function operator+(x, y).

Here are some important rules of thumb for how to use operator overloading:

  • The meaning of the operators should be retained when overloading. For example, if the class is a matrix, then + should be overloaded with matrix addition. If you overload + with matrix multiplication, your code will be very confusing.
  • The usual behavior of the operators should be retained when overloading. For example, the user will expect that x + y - x should be equal to y. Similarly, the user will expect that if x < y and y < z, then x < z.
  • If an operator is defined, then related operators should also be defined. For example, if you define + you should also define += and if you define < you should also define >.
    • Generally, in such cases, one operator will refer to the other. For example, it is customary to use += to define + (as we will do below), and to use < to define > (or vice versa).
  • Overloaded operators should modify their operands if and only if the original operator does. For example, a + b is not expected to modify a or b, but a++ is expected to modify a.
  • Operators should get their arguments by reference whenever possible. If the operator does not modify an operand, it should get it as a const reference, and if it does, it should get it as a non-const reference.
    • However, if a temporary copy must be created anyway, it is often convenient to get the operand by value, as that eliminates the need to manually create a copy. See below for examples.
  • Only use overloaded operators when it makes sense to do so. For example, it makes sense to define a subtraction operator between two dates, which will give the time difference between them. However, adding or multiplying two dates doesn't make sense.
Warning: If you do not follow these rules of thumb when overloading operators, your code may become very confusing. Remember that operator overloading is just a convenience, not a necessity; you can always just use normal functions to operate on objects.

5.2.2 Overloading the << operator ^

Let us now consider a simple example. If we try to pass a vector to cout in order to print it, we will get an error:

#include <iostream>
#include <vector>
using namespace std;

int main()
{
    const vector<double> a = {1, 2, 3};
    cout << a; // Error: no match for 'operator<<'
}

The compiler is telling is that there is no match for operator<< with the given operands, one of which is an ostream (that is the type of cout) and the other is a vector<double>. This operator is defined when the second operand is, for example, a number or a string, but there is no built-in way to print out vectors in C++. Therefore, we need to define this operator ourselves using operator overloading:

#include <iostream>
#include <vector>
using namespace std;

ostream& operator<<(ostream& out, const vector<double>& vec)
{
    out << '(';
    for (size_t i = 0; i < vec.size() - 1; ++i)
        out << vec[i] << ", ";
    out << vec[vec.size() - 1] << ')';
    return out;
}

int main()
{
    const vector<double> v = {1, 2, 3};
    cout << v; // (1, 2, 3)
}

Let us explain each piece of the line ostream& operator<<(ostream& out, const vector<double>& vec):

  • ostream&: The function we are defining returns a reference to an ostream object.
  • operator<<: This function will act whenever we use the operator <<.
  • (ostream& out, const vector<double>& vec): This operator will have two operands: one is a reference to an ostream object and the other is a reference to a vector<double> object.
  • The first argument is not const, because we want to change it. The second argument is a const, because we do not want to change it.

When we call cout << v, this a << operator with two operands. The first is cout, which is an ostream object. The second is v, which is a vector<double> object. The compiler looks for a suitable operator overload, and finds our function, which is executed.

Note that if the return type was void instead of ostream &, the statement cout << v would have still worked, but we would have not been able to chain the << operator - as in, for example, cout << v << v. Returning a reference to the stream out allows the same stream to be used again in a subsequent operation.

The function itself is pretty simple:

  • We put a left parenthesis ( into out.
  • We iterate over all the elements of the vector except the last one using a for loop, and print each one of them followed by a comma and a space.
  • We output the last element of the vector followed by a right parenthesis ).

(If we didn't want the parentheses or the commas, we could have used a much more compact range-based for loop of the form for (const double& i : vec) out << i << ' '.)

In the following sections, we will make use of the << operator overload to print out vectors in order to demonstrate the other operator overloads we will create. To save space, I will create a header file vector_overloads.hpp which will include the above code except for the main() function, and include it in each example. Every time I define a new overload, I will add it to this header file as well, and in the end we will have one big header file with all of our overloads.

5.2.3 Overloading the == and != operators ^

The next operators we will overload are the comparison operators == and !=. Note that these operators are actually already overloaded in the standard library, but we will write our own overloads anyway, for pedagogical reasons.

Clearly, two vectors are equal to each other if all their elements are the same. Therefore, the comparison must involve comparing every single element of both vectors. However, we should first check if the vectors are the same size; if not, then they are clearly not equal, and we do not need to check any elements at all.

Furthermore, there is no point in defining two completely independent operators for == and !=; one can simply be written in terms of the other. In fact, in C++20, the compiler will generate the != operator automatically if the == operator is defined for the same operands, but we will write it explicitly anyway.

Here is the code:

#include "vector_overloads.hpp"

bool operator==(const vector<double>& lhs, const vector<double>& rhs)
{
    if (lhs.size() != rhs.size())
        return false;
    for (size_t i = 0; i < lhs.size(); ++i)
        if (lhs[i] != rhs[i])
            return false;
    return true;
}

bool operator!=(const vector<double>& lhs, const vector<double>& rhs)
{
    return !(lhs == rhs);
}

int main()
{
    const vector<double> a = {1, 2, 3};
    const vector<double> b = {1, 2, 3};
    const vector<double> c = {1, 2, 3, 4};
    const vector<double> d = {5, 6, 7};
    cout << (a == a) << ' ' << (a != a) << '\n'; // 1 0
    cout << (a == b) << ' ' << (a != b) << '\n'; // 1 0
    cout << (a == c) << ' ' << (a != c) << '\n'; // 0 1
    cout << (a == d) << ' ' << (a != d) << '\n'; // 0 1
}

Notice that, of course, a == a. We could potentially include something like this in the beginning of operator==():

if (&lhs == &rhs)
    return true;

This will save us time if we compare a vector to itself, because we won't have to redundantly compare every single element to itself. However, comparing an object to itself is done very rarely if at all, and running this check every single time we're comparing any two vectors can have a detrimental effect on performance too, especially if we are comparing many different vectors.

Importantly, when overloading the == operator for any class T, the prototype must always be bool operator==(const T& lhs, const T& rhs), and similarly for !=. You can find the prototypes for other comparison operators here.

5.2.4 Overloading the + and += operators ^

Next, we would like to introduce vector addition using the operators + and +=. These operators will add two vectors element-by-element. The canonical way to implement addition or other arithmetic operators on any class is to first define += and then reuse it in +, similarly to how we first defined == and then reused it in !=. The reason we don't do it the opposite way - define + and then reuse it in += - will be explained shortly.

Obviously, we cannot add two vectors of different sizes. Therefore, our addition operation must throw an exception if the sizes of the vectors don't match. Here is the code:

#include "vector_overloads.hpp"
#include <stdexcept>

vector<double>& operator+=(vector<double>& lhs, const vector<double>& rhs)
{
    if (lhs.size() != rhs.size())
        throw invalid_argument("Cannot add vectors of different sizes!");
    for (size_t i = 0; i < lhs.size(); ++i)
        lhs[i] += rhs[i];
    return lhs;
}

vector<double> operator+(vector<double> lhs, const vector<double>& rhs)
{
    lhs += rhs;
    return lhs;
}

int main()
{
    try
    {
        vector<double> a = {1, 2, 3};
        const vector<double> b = {4, 5, 6};
        const vector<double> c = {7, 8, 9};
        const vector<double> d = {1, 2, 3, 4};
        a += b;
        cout << a << '\n';         // (5, 7, 9)
        cout << b + c << '\n';     // (11, 13, 15)
        cout << a + b + c << '\n'; // (16, 20, 24)
        cout << a + d << '\n';     // Error: Cannot add vectors of different sizes!
    }
    catch (const invalid_argument& e)
    {
        cout << "Error: " << e.what() << '\n';
    }
}

Let's go over operator+=() line by line:

  • The operator takes two arguments:
    1. vector<double>& lhs: The vector that we want to assign the sum to. Note that we must pass it by (non-const) reference so we can change it; if we passed it by value, we would only be changing a copy of it.
    2. const vector<double>& rhs: The vector that we want to add to lhs. We pass it by const reference because we don't want to change it (hence the const) but we also don't want to copy it, since that would be a waste of time (hence the reference).
  • Importantly, when overloading the += operator for any class T, the prototype must always be T& operator+=(T& lhs, const T& rhs), and similarly for all other compound assignment operators. You can find the prototypes for other assignment operators here.
  • First, we compare the sizes of the two vectors, and throw an exception if they don't match.
  • Next, we go over each element of lhs and add the corresponding element of rhs, storing the result in lhs using the built-in += operator.
  • Finally, we return a reference to lhs. Of course, we must return a reference, since we do not want to make a copy of it.

Next, operator+():

  • The operator takes two arguments:
    1. vector<double> lhs: The first vector to add. Note that we pass it by value, even though the usual prototype for + is T operator+(const T& lhs, const T& rhs) (as you can see here). The reason is that in this case, we actually want to make a copy of lhs!
      • This is because we cannot change either of the two vectors; we just want to add their elements and return a new vector with the result. We could pass both vectors as const references and create a new temporary vector to store the result, but that requires allocating and initializing the temporary vector. It's more efficient to just let the compiler make the copy automatically by passing one of the vectors by value, and this also lets us reuse our += operator, making the + operator trivial.
    2. const vector<double>& rhs: The vector that we want to add to lhs, passed by const reference.
  • In the body of the function we simply use the operator +=, which we already defined, to add lhs to rhs and store the result in lhs.
  • The operator returns lhs by value. This has nothing to do with the original lhs, it is essentially a new vector containing the result of adding lhs and rhs. Returning it by value allows us to chain addition operations, as demonstrated in the example by a + b + c.

5.2.5 Overloading the - and -= operators ^

Since we defined addition, we should also define subtraction. Note that there are two different - operators: an unary and a binary one. The unary - is easy: it which simply returns a copy of the vector with all its elements replaced with their negatives. The binary - can be implemented in terms of -= just as we did for + and +=; in fact, we only need to replace the + with a -. Here is the result:

#include "vector_overloads.hpp"

vector<double>& operator-=(vector<double>& lhs, const vector<double>& rhs)
{
    if (lhs.size() != rhs.size())
        throw invalid_argument("Cannot subtract vectors of different sizes!");
    for (size_t i = 0; i < lhs.size(); ++i)
        lhs[i] -= rhs[i];
    return lhs;
}

vector<double> operator-(vector<double> lhs, const vector<double>& rhs)
{
    lhs -= rhs;
    return lhs;
}

vector<double> operator-(vector<double> vec)
{
    for (size_t i = 0; i < vec.size(); ++i)
        vec[i] = -vec[i];
    return vec;
}

int main()
{
    try
    {
        vector<double> a = {1, 2, 3};
        const vector<double> b = {4, 5, 6};
        const vector<double> c = {7, 8, 9};
        const vector<double> d = {1, 2, 3, 4};
        a -= b;
        cout << a << '\n';         // (-3, -3, -3)
        cout << -a << '\n';        // (3, 3, 3)
        cout << b - c << '\n';     // (-3, -3, -3)
        cout << a + b - c << '\n'; // (-6, -6, -6)
        cout << a - d << '\n';     // Error: Cannot subtract vectors of different sizes!
    }
    catch (const invalid_argument& e)
    {
        cout << "Error: " << e.what() << '\n';
    }
}

Note that we could have just defined the binary - operator as return lhs + (-rhs), taking advantage of the fact that we already defined an unary - operator. Indeed, in linear algebra, subtraction of vectors is actually defined as adding the additive inverse of the second vector to the first. However, this would mean that the program must run two loops, one to calculate -rhs and another to calculate lhs + (-rhs). By writing a single subtraction loop explicitly, we essentially cut the time it would take to subtract vectors by half.

5.2.6 Overloading the * operator ^

The overload for multiplication should return the dot product of the two vectors. As with addition and subtraction, we cannot multiply vectors of different sizes, so we should throw an exception in that case. In addition, the return value of * should be a scalar, not a vector. Therefore, *= is not a valid operator, as it would assign a scalar to a vector. Note also that there is no division of vectors, so we will not overload the / operator. Here is the code:

#include "vector_overloads.hpp"

double operator*(const vector<double>& lhs, const vector<double>& rhs)
{
    if (lhs.size() != rhs.size())
        throw invalid_argument("Cannot take the dot product of vectors of different sizes!");
    double result = 0;
    for (size_t i = 0; i < lhs.size(); ++i)
        result += lhs[i] * rhs[i];
    return result;
}

int main()
{
    try
    {
        const vector<double> a = {1, 2, 3};
        const vector<double> b = {4, 5, 6};
        const vector<double> c = {1, 2, 3, 4};
        cout << a * b << '\n';                        // 32
        cout << b * a << '\n';                        // 32 (the operation is commutative)
        cout << b * vector<double> {7, 8, 9} << '\n'; // 122 (used a literal vector)
        cout << (a - b) * a << '\n';                  // -18 (chained different operations)
        cout << a * c << '\n';                        // Error: Cannot take the dot product of vectors of different sizes!
    }
    catch (const invalid_argument& e)
    {
        cout << "Error: " << e.what() << '\n';
    }
}

Here we used vector<double> {7, 8, 9} as a literal vector. What we mean by that is that instead of first declaring, for example, vector<double> d = {7, 8, 9} and then writing cout << b * d, we wrote the vector directly. This is the same as writing the number 7 directly in a statement instead of first declaring int x = 7 and using x. If we're just going to use an object once, it's better to use it as a literal instead of declaring a specific symbol for it. Objects created in this way are called temporary objects; since we didn't give vector<double> {7, 8, 9} a name, it only exists temporary in that line, and then automatically discarded.

5.2.7 Combining fundamental and user-defined types ^

In addition to the dot product of two vectors, we should also define multiplication of a vector by a scalar. This means that we should overload * for the case where one operand is a double and the other is a vector<double>. Since the two operands are of different types, we will need to overload * twice: one overload for the case where the vector is on the left and another when it's on the right. Also, in this case it does make sense to overload *= too, meaning that we assign to the vector its product with the given scalar. Here are the new overloads:

#include "vector_overloads.hpp"

vector<double>& operator*=(vector<double>& lhs, const double rhs)
{
    for (size_t i = 0; i < lhs.size(); ++i)
        lhs[i] *= rhs;
    return lhs;
}

vector<double> operator*(vector<double> lhs, const double rhs)
{
    lhs *= rhs;
    return lhs;
}

vector<double> operator*(const double lhs, vector<double> rhs)
{
    rhs *= lhs;
    return rhs;
}

int main()
{
    vector<double> a = {1, 2, 3};
    cout << a * 3 << '\n'; // (3, 6, 9)
    cout << 4 * a << '\n'; // (4, 8, 12)
    a *= 5;
    cout << a << '\n'; // (5, 10, 15)
}

5.2.8 The complete vector overloads: a header-only library ^

The operator overloads that we defined above for vectors are very useful for doing linear algebra. Therefore, we should put them in portable form, so that they may be easily incorporated into different programs. In cases where there is not too much code to warrant a separate .cpp file (which will then need to be complied separately), C++ programmers often share code as a header-only library, meaning that the entire library of classes and/or functions is located in the header file itself. Then all one needs to do is to include that header file, and that's it.

The header file, vector_overloads.hpp, is as follows:

#include <iostream>
#include <stdexcept>
#include <vector>
using namespace std;

ostream& operator<<(ostream& out, const vector<double>& vec)
{
    out << '(';
    for (size_t i = 0; i < vec.size() - 1; ++i)
        out << vec[i] << ", ";
    out << vec[vec.size() - 1] << ')';
    return out;
}

bool operator==(const vector<double>& lhs, const vector<double>& rhs)
{
    if (lhs.size() != rhs.size())
        return false;
    for (size_t i = 0; i < lhs.size(); ++i)
        if (lhs[i] != rhs[i])
            return false;
    return true;
}

bool operator!=(const vector<double>& lhs, const vector<double>& rhs)
{
    return !(lhs == rhs);
}

vector<double>& operator+=(vector<double>& lhs, const vector<double>& rhs)
{
    if (lhs.size() != rhs.size())
        throw invalid_argument("Cannot add vectors of different sizes!");
    for (size_t i = 0; i < lhs.size(); ++i)
        lhs[i] += rhs[i];
    return lhs;
}

vector<double> operator+(vector<double> lhs, const vector<double>& rhs)
{
    lhs += rhs;
    return lhs;
}

vector<double>& operator-=(vector<double>& lhs, const vector<double>& rhs)
{
    if (lhs.size() != rhs.size())
        throw invalid_argument("Cannot subtract vectors of different sizes!");
    for (size_t i = 0; i < lhs.size(); ++i)
        lhs[i] -= rhs[i];
    return lhs;
}

vector<double> operator-(vector<double> lhs, const vector<double>& rhs)
{
    lhs -= rhs;
    return lhs;
}

vector<double> operator-(vector<double> vec)
{
    for (size_t i = 0; i < vec.size(); ++i)
        vec[i] = -vec[i];
    return vec;
}

double operator*(const vector<double>& lhs, const vector<double>& rhs)
{
    if (lhs.size() != rhs.size())
        throw invalid_argument("Cannot take the dot product of vectors of different sizes!");
    double result = 0;
    for (size_t i = 0; i < lhs.size(); ++i)
        result += lhs[i] * rhs[i];
    return result;
}

vector<double>& operator*=(vector<double>& lhs, const double rhs)
{
    for (size_t i = 0; i < lhs.size(); ++i)
        lhs[i] *= rhs;
    return lhs;
}

vector<double> operator*(vector<double> lhs, const double rhs)
{
    lhs *= rhs;
    return lhs;
}

vector<double> operator*(const double lhs, vector<double> rhs)
{
    rhs *= lhs;
    return rhs;
}

We can now use these overloads in any program by including vector_overloads.hpp in the program. Here is an example of a main.cpp making use of our library:

#include "vector_overloads.hpp"
#include <iostream>
#include <stdexcept>
#include <vector>
using namespace std;

int main()
{
    try
    {
        vector<double> v = {1, 2, 3};
        vector<double> w = {4, 5, 6};
        vector<double> u = {1, 1, 1};
        cout << v + w << '\n'; // (5, 7, 9)
        cout << v * w << '\n'; // 32
        cout << -v << '\n';    // (-1, -2, -3)
        v += w;                //
        cout << v << '\n';     // (5, 7, 9)
        cout << v - w << '\n'; // (1, 2, 3)
        w -= u;                //
        cout << w << '\n';     // (3, 4, 5)
        cout << 2 * v << '\n'; // (10, 14, 18)
        cout << v * 3 << '\n'; // (15, 21, 27)
    }
    catch (const invalid_argument& e)
    {
        cout << "Error: " << e.what() << '\n';
    }
}

Note that, unlike with the triangle class, in this case there is no separate .cpp file; everything is in the header file vector_overloads.hpp. When you write #include "vector_overloads.hpp", what happens is simply that the compiler includes all the code in the header file as-is in your source code. There is no separate compilation or linking taking place. For compact and lightweight C++ packages, this is often the desired format, as you only need to worry about one extra file.

However, header-only libraries are only suitable for small code. Libraries which contain tens of thousands of lines of code are usually distributed as separate .hpp and .cpp files, and often, many such files. The reason is that compilation takes time, sometimes even several minutes or longer for large projects. By placing the library's code in a separate .cpp file, we only need to compile it once, and subsequently we can just link the already-compiled code. We will explain in more detail how that works later.

5.2.9 Summary: the matrix class ^

So far, we have overloaded all operators as non-member functions. However, for user-defined classes, it is also possible to overload operators using member functions. For a unary operator @, the non-member operator@(x) is equivalent to the member x.operator@(). For a binary operator @, the non-member operator@(x, y) is equivalent to the member x.operator@(y).

The operators =, (), [], and -> must be overloaded using member functions. On the other hand, if we are overloading a class written by someone else, such as when overloading << for ostream, it is more convenient to overload using non-member functions. In other cases, it's mostly a matter of taste whether to implement overloaded operators as member functions or not. (Personally, I think overloading operators as non-member functions produces clearer and more organized code.)

As an example, I will now define a class for matrices. Since in this case I am not just overloading operators for someone else's class (as in the vector overloads example), it makes sense to overload the compound assignment operators +=, -=, and *= (for multiplication by scalar) as member functions. This is because the matrix on the left is being assigned the result, so there is an asymmetry between the two operands. On the other hand, the binary operators +, -, and * themselves should still be implemented as non-member functions, as in this case there is a symmetry between the operands.

This class will also include an overloaded operator () used to access the matrix elements. Instead of m[x][y], which is what we would have if the matrix was a multi-dimensional array, I use the more convenient notation m(x, y). The operator () is overloaded using a member function.

Note that there will be two versions of the () overload: one is not a const and returns a non-const reference, and the other is a const and returns a const reference. The first version allows modification of the element being accessed. For example, m(0, 0) = 1 changes the top-left element to 1. This is possible because m(0, 0) is a reference to that element in memory, and this allows the user to access that element directly for both reading and writing.

On the other hand, when the second version is used, it returns a constant reference to the element, so the user cannot modify that element. (We could also have returned a copy, but as usual, returning a reference is faster since copying a large object takes time.)

Similarly, the member functions get_rows() and get_cols() allow the user to obtain the number of rows and columns, but not change it, therefore they are marked as const.

If you remove the const keyword from any of the constant member functions, the program will not compile, since the << operator overload takes a const matrix & argument, so it cannot call any member functions that are not const. This is exactly why we needed two versions of the () overload. The overloaded operator << accepts a const matrix &m, so it will call the second (const) version of the () overload. On the other hand, the main function calls the first (non-const) version.

I wrote this matrix class to illustrate everything we learned about C++ so far. We will further improve it later in the course. Please refer to the comments in the header file for more information.

matrix.hpp:

#include <initializer_list>
#include <iostream>
#include <stdexcept>
#include <vector>
using namespace std;

class matrix
{
public:
    // Constructor to create a zero matrix.
    // First argument: number of rows.
    // Second argument: number of columns.
    matrix(const size_t, const size_t);

    // Constructor to create a diagonal matrix from a vector.
    // Argument: a vector containing the elements on the diagonal.
    // Number of rows and columns is inferred automatically.
    matrix(const vector<double>&);

    // Constructor to create a diagonal matrix from an initializer_list.
    // Argument: an initializer_list containing the elements on the diagonal.
    // Number of rows and columns is inferred automatically.
    matrix(const initializer_list<double>&);

    // Constructor to create a matrix from a vector.
    // First argument: number of rows.
    // Second argument: number of columns.
    // Third argument: a vector containing the elements in row-major order.
    matrix(const size_t, const size_t, const vector<double>&);

    // Constructor to create a matrix from an initializer_list.
    // First argument: number of rows.
    // Second argument: number of columns.
    // Third argument: an initializer_list containing the elements in row-major order.
    matrix(const size_t, const size_t, const initializer_list<double>&);

    // Member function to obtain (but not modify) the number of rows in the matrix.
    size_t get_rows() const;

    // Member function to obtain (but not modify) the number of columns in the matrix.
    size_t get_cols() const;

    // Overloaded operator () to access matrix elements WITHOUT range checking.
    // The indices start from 0: m(0, 1) would be the element at row 1, column 2.
    // Non-const version: allows modification of the element.
    double& operator()(const size_t, const size_t);

    // Overloaded operator () to access matrix elements WITHOUT range checking.
    // The indices start from 0: m(0, 1) would be the element at row 1, column 2.
    // const version: does not allow modification of the element.
    const double& operator()(const size_t, const size_t) const;

    // Member function to access matrix elements WITH range checking.
    // The indices start from 0: m.at(0, 1) would be the element at row 1, column 2.
    // Non-const version: allows modification of the element.
    double& at(const size_t, const size_t);

    // Member function to access matrix elements WITH range checking.
    // The indices start from 0: m.at(0, 1) would be the element at row 1, column 2.
    // const version: does not allow modification of the element.
    const double& at(const size_t, const size_t) const;

    // Overloaded binary operator += to add another matrix to this matrix.
    matrix& operator+=(const matrix&);

    // Overloaded binary operator -= to subtract another matrix from this matrix.
    matrix& operator-=(const matrix&);

    // Overloaded binary operator *= to multiply this matrix by a scalar.
    matrix& operator*=(const double);

    // Exception to be thrown if the number of rows or columns given to the constructor is zero.
    inline static invalid_argument zero_size = invalid_argument("Matrix cannot have zero rows or columns!");

    // Exception to be thrown if the vector of elements provided to the constructor is of the wrong size.
    inline static invalid_argument initializer_wrong_size = invalid_argument("Initializer does not have the expected number of elements!");

    // Exception to be thrown if two matrices of different sizes are added or subtracted.
    inline static invalid_argument incompatible_sizes_add = invalid_argument("Cannot add or subtract two matrices of different dimensions!");

    // Exception to be thrown if two matrices of incompatible sizes are multiplied.
    inline static invalid_argument incompatible_sizes_multiply = invalid_argument("Two matrices can only be multiplied if the number of columns in the first matrix is equal to the number of rows in the second matrix!");

    // Exception to be thrown if trying to access an element out of range.
    inline static invalid_argument out_of_range = invalid_argument("Tried to access an element out of range!");

private:
    // The number of rows.
    size_t rows = 0;

    // The number of columns.
    size_t cols = 0;

    // A vector storing the elements of the matrix in flattened (1-dimensional) form.
    vector<double> elements;
};

// Overloaded binary operator << to easily print out a matrix to a stream.
ostream& operator<<(ostream&, const matrix&);

// Overloaded binary operator == to compare two matrices.
bool operator==(const matrix&, const matrix&);

// Overloaded binary operator != to compare two matrices.
bool operator!=(const matrix&, const matrix&);

// Overloaded binary operator + to add two matrices.
matrix operator+(matrix, const matrix&);

// Overloaded unary operator - to take the negative of a matrix.
matrix operator-(const matrix);

// Overloaded binary operator - to subtract two matrices.
matrix operator-(matrix, const matrix&);

// Overloaded binary operator * to multiply two matrices.
matrix operator*(const matrix&, const matrix&);

// Overloaded binary operator * to multiply a matrix on the left and a scalar on the right.
matrix operator*(matrix, const double);

// Overloaded binary operator * to multiply a scalar on the left and a matrix on the right.
matrix operator*(const double, matrix);

matrix.cpp:

#include "matrix.hpp"
#include <initializer_list>
#include <iostream>
#include <stdexcept>
#include <vector>
using namespace std;

matrix::matrix(const size_t rows_, const size_t cols_) : rows(rows_), cols(cols_)
{
    if (rows == 0 or cols == 0)
        throw zero_size;
    elements = vector<double>(rows * cols);
}

matrix::matrix(const vector<double>& diagonal_) : rows(diagonal_.size()), cols(diagonal_.size())
{
    if (rows == 0)
        throw zero_size;
    elements = vector<double>(rows * cols);
    for (size_t i = 0; i < rows; ++i)
        elements[(cols * i) + i] = diagonal_[i];
}

matrix::matrix(const initializer_list<double>& diagonal_) : matrix(vector<double>(diagonal_)) {}

matrix::matrix(const size_t rows_, const size_t cols_, const vector<double>& elements_) : rows(rows_), cols(cols_), elements(elements_)
{
    if (rows == 0 or cols == 0)
        throw zero_size;
    if (elements_.size() != rows * cols)
        throw initializer_wrong_size;
}

matrix::matrix(const size_t rows_, const size_t cols_, const initializer_list<double>& elements_) : matrix(rows_, cols_, vector<double>(elements_)) {}

size_t matrix::get_rows() const
{
    return rows;
}

size_t matrix::get_cols() const
{
    return cols;
}

double& matrix::operator()(const size_t row, const size_t col)
{
    return elements[(cols * row) + col];
}

const double& matrix::operator()(const size_t row, const size_t col) const
{
    return elements[(cols * row) + col];
}

double& matrix::at(const size_t row, const size_t col)
{
    if ((row > rows - 1) or (col > cols - 1))
        throw out_of_range;
    return elements[(cols * row) + col];
}

const double& matrix::at(const size_t row, const size_t col) const
{
    if ((row > rows - 1) or (col > cols - 1))
        throw out_of_range;
    return elements[(cols * row) + col];
}

matrix& matrix::operator+=(const matrix& other)
{
    if ((rows != other.rows) or (cols != other.cols))
        throw incompatible_sizes_add;
    for (size_t i = 0; i < rows * cols; ++i)
        elements[i] += other.elements[i];
    return *this;
}

matrix& matrix::operator-=(const matrix& other)
{
    if ((rows != other.rows) or (cols != other.cols))
        throw incompatible_sizes_add;
    for (size_t i = 0; i < rows * cols; ++i)
        elements[i] -= other.elements[i];
    return *this;
}

matrix& matrix::operator*=(const double scalar)
{
    for (size_t i = 0; i < rows * cols; ++i)
        elements[i] *= scalar;
    return *this;
}

ostream& operator<<(ostream& out, const matrix& mat)
{
    for (size_t i = 0; i < mat.get_rows(); ++i)
    {
        out << "( ";
        for (size_t j = 0; j < mat.get_cols(); ++j)
            out << mat(i, j) << '\t';
        out << ")\n";
    }
    return out;
}

bool operator==(const matrix& lhs, const matrix& rhs)
{
    if ((lhs.get_rows() != rhs.get_rows()) or (lhs.get_cols() != rhs.get_cols()))
        return false;
    for (size_t i = 0; i < lhs.get_rows(); ++i)
        for (size_t j = 0; j < lhs.get_cols(); ++j)
            if (lhs(i, j) != rhs(i, j))
                return false;
    return true;
}

bool operator!=(const matrix& lhs, const matrix& rhs)
{
    return !(lhs == rhs);
}

matrix operator+(matrix lhs, const matrix& rhs)
{
    lhs += rhs;
    return lhs;
}

matrix operator-(matrix mat)
{
    for (size_t i = 0; i < mat.get_rows(); ++i)
        for (size_t j = 0; j < mat.get_cols(); ++j)
            mat(i, j) = -mat(i, j);
    return mat;
}

matrix operator-(matrix lhs, const matrix& rhs)
{
    lhs -= rhs;
    return lhs;
}

matrix operator*(const matrix& lhs, const matrix& rhs)
{
    if (lhs.get_cols() != rhs.get_rows())
        throw matrix::incompatible_sizes_multiply;
    matrix c(lhs.get_rows(), rhs.get_cols());
    for (size_t i = 0; i < lhs.get_rows(); ++i)
        for (size_t j = 0; j < rhs.get_cols(); ++j)
            for (size_t k = 0; k < lhs.get_cols(); ++k)
                c(i, j) += lhs(i, k) * rhs(k, j);
    return c;
}

matrix operator*(matrix lhs, const double rhs)
{
    lhs *= rhs;
    return lhs;
}

matrix operator*(const double lhs, matrix rhs)
{
    rhs *= lhs;
    return rhs;
}

Sample main.cpp:

#include "matrix.hpp"
#include <exception>
#include <iostream>
#include <vector>
using namespace std;

int main()
{
    try
    {
        // Constructor with two integers: create a 3x4 matrix of zeros.
        matrix A(3, 4);
        cout << "A:\n" << A;
        // Constructor with one vector: create a 3x3 matrix with 1, 2, 3 on the diagonal.
        matrix B(vector<double>{1, 2, 3});
        cout << "B:\n" << B;
        // Constructor with one initializer_list: create a 4x4 matrix with 1, 2, 3, 4 on the diagonal.
        matrix C{1, 2, 3, 4};
        cout << "C:\n" << C;
        // Constructor with two integers and one vector: create a 2x3 matrix with the given elements in row-major order.
        matrix D(2, 3, vector<double>{1, 2, 3, 4, 5, 6});
        cout << "D:\n" << D;
        // Constructor with two integers and one initializer_list: create a 2x2 matrix with the given elements in row-major order.
        matrix E(2, 2, {1, 2, 3, 4});
        cout << "E:\n" << E;

        // Demonstration of some of the overloaded operators.
        D(0, 2) = 7;
        cout << "D after D(0, 2) = 7:\n" << D;
        matrix F = D * B;
        cout << "F = D * B:\n" << F;
        cout << "D + F:\n" << D + F;
        cout << "7 * B:\n" << 7 * B;
        matrix G(3, 3, {1, 0, 0, 0, 2, 0, 0, 0, 3});
        cout << "B == G: " << (B == G) << '\n';
        cout << "B == F: " << (B == F) << '\n';
        D *= 2;
        cout << "D after D *= 2:\n" << D;
        cout << "D * 3:\n" << D * 3;
        cout << "4 * D:\n" << 4 * D;

        // initializer_list constructor will be used: create a 2x2 diagonal matrix with 1, 2 on the diagonal.
        cout << "matrix{1, 2}:\n";
        cout << matrix{1, 2};
        // (size_t, size_t) constructor will be used: create a 1x2 zero matrix.
        cout << "matrix(1, 2):\n";
        cout << matrix(1, 2);

        // Demonstration of range checking; the range of D is 0-1 for rows and 0-2 for columns.
        cout << "Range checking:\n";
        cout << "D.at(0, 0): " << D.at(0, 0) << '\n';
        cout << "D.at(1, 0): " << D.at(1, 0) << '\n';
        cout << "D.at(2, 0): " << D.at(2, 0) << '\n'; // Error: Tried to access an element out of range!
    }
    catch (const exception& e)
    {
        cout << "Error: " << e.what() << '\n';
    }
}

Output:

A:
( 0     0       0       0       )
( 0     0       0       0       )
( 0     0       0       0       )
B:
( 1     0       0       )
( 0     2       0       )
( 0     0       3       )
C:
( 1     0       0       0       )
( 0     2       0       0       )
( 0     0       3       0       )
( 0     0       0       4       )
D:
( 1     2       3       )
( 4     5       6       )
E:
( 1     2       )
( 3     4       )
D after D(0, 2) = 7:
( 1     2       7       )
( 4     5       6       )
F = D * B:
( 1     4       21      )
( 4     10      18      )
D + F:
( 2     6       28      )
( 8     15      24      )
7 * B:
( 7     0       0       )
( 0     14      0       )
( 0     0       21      )
B == G: 1
B == F: 0
D after D *= 2:
( 2     4       14      )
( 8     10      12      )
D * 3:
( 6     12      42      )
( 24    30      36      )
4 * D:
( 8     16      56      )
( 32    40      48      )
matrix{1, 2}:
( 1     0       )
( 0     2       )
matrix(1, 2):
( 0     0       )
Range checking:
D.at(0, 0): 2
D.at(1, 0): 8
D.at(2, 0): Error: Tried to access an element out of range!

The two versions of the at() member function access the elements of the vector using its own pre-defined member function at(), which throws the exception std::out_of_range if the element being accessed is out of the range of the vector. We utilize this to indirectly implement range checking for matrix as well.

For the second and fourth constructors, we use initializer_list, which is defined in the header file <initializer_list>. An initializer_list object is simply a list of elements in the format used to initialize a C-style array, i.e. {element0, element1, ...}, with the addition of a few member functions such as size.

When we tell the constructor to take a const initializer_list<double>& as an argument, it will expect to take a list of this form, with the elements being of type double. It will then simply convert that list to a vector<double>, and delegate the construction of the matrix to the appropriate constructor that takes a vector<double> as an argument. Note the syntax for delegating constructors: there is a : followed by the appropriate constructor, followed by {} to indicate that the delegating constructor is an empty function.

Generally, an initializer_list should be used as input whenever you want to quickly create a new matrix with specific elements, since the syntax is shorter and more convenient. A vector should be used as input whenever you want to create a new matrix using elements you previously collected into a vector.

The initializer_list constructor takes precedence over any other constructor. Therefore, matrix{1, 2} will be a diagonal matrix with 1 and 2 on the diagonal, since the constructor matrix(const initializer_list<double>&) will be used, while matrix(1, 2) will be a 1x2 zero matrix, since the constructor matrix(const size_t, const size_t) will be used. This is illustrated at the end of the sample main.cpp.

For convenience, we defined some ready-made exceptions (all of type invalid_argument) as inline static elements of the matrix class. This also allows the user to know which exceptions to expect the class to throw. Recall that static means they are stored only once in the class itself, and not in individual objects, so this doesn't cause any waste of space. Also recall that inline simply means we can initialize the static element within the class definition.

Since matrix throws two types of exceptions, out_of_range and invalid_argument, we wrote catch (const exception& e) in order to catch any kind of exception (as long as it is derived from the standard exception class). We will explain how exactly this works later.

5.3 Input and output stream classes ^

5.3.1 Formatting output

In C++, you can still use printf if you want; it's in the header <cstdio>. However, using stream objects such as cout allows much more flexibility, especially because you do not have to worry about format placeholders matching the precise type of the argument. For example, to print an integer using printf you would need to specify %d if it's signed or %u if it's unsigned, and add l or ll according to its bit width. It is much easier to just insert it into cout with <<.

To format output when using cout and other streams, we can use the following member functions:

  • precision(n) sets the maximum number of digits displayed for a floating point number, counting both the digits before and after the decimal point. precision() returns the current precision.
  • width(n) sets the character width of the next output, padding with spaces if necessary. Note that it only works for one output, so it needs to be called every time you want to print a number with the desired width. width() returns the current width.

In addition, streams can be formatted using manipulators. These are simply objects that you insert into the stream with the usual << operator, which modify how the stream works for any subsequent objects inserted into it. Some useful manipulators include:

  • boolalpha: Displays Boolean true and false values as text. noboolalpha returns the stream to the default setting, which displays true as 1 and false as 0.
  • showpos: Displays the + sign on positive numbers. noshowpos returns the stream to the default setting, which only displays the - sign on negative numbers, with no sign for positive numbers.
  • hex: Displays numbers in hexadecimal. dec returns the stream to the default setting, which displays numbers in decimal.
  • fixed: Displays floating point numbers with a fixed number of digits as given by precision, adding zeros after the decimal if necessary. defaultfloat returns the stream to the default setting, which does not add zeros.
  • scientific: Displays floating point numbers in scientific notation. defaultfloat returns the stream to the default setting.
  • showpoint: Displays the decimal point for all floating point numbers, including whole numbers. It also adds trailing zeros to all numbers up to the number of digits given by precision. noshowpoint returns the stream to the default setting.
  • uppercase: Displays the letters in hexadecimal numbers and scientific notation in uppercase. nouppercase returns the stream to the default setting, which displays these letters in lowercase.
  • left: Adjusts the output to the left. right returns the stream to the default setting, which adjusts to the right. Used in conjunction with width.

The following program demonstrates formatting with streams:

#include <iostream>
using namespace std;

int main()
{
    cout << true << '\n' // 1
         << boolalpha
         << true << '\n' // true
         << noboolalpha << '\n';

    cout << 2 << ", " << -2 << '\n' // 2, -2
         << showpos
         << 2 << ", " << -2 << '\n' // +2, -2
         << noshowpos << '\n';

    cout << 2.0 << ", " << 2.1 << '\n' // 2, 2.1
         << showpoint
         << 2.0 << ", " << 2.1 << '\n' // 2.00000, 2.10000
         << noshowpoint << '\n';

    cout << 1234.56 << '\n' // 1234.56
         << fixed
         << 1234.56 << '\n' // 1234.560000
         << scientific
         << 1234.56 << '\n' // 1.234560e+03
         << uppercase
         << 1234.56 << '\n' // 1.234560E+03
         << nouppercase << defaultfloat << '\n';

    cout.precision(5);
    cout << 1.23456789 << '\n'; // 1.2346
    cout.precision(10);
    cout << 1.23456789 << '\n'; // 1.23456789
    cout.precision(20);
    cout << 1.23456789 << '\n'; // 1.2345678899999998901
    cout.precision(6);
    cout << '\n';

    cout << 255 << '\n' // 255
         << hex
         << 255 << '\n' // ff
         << uppercase
         << 255 << '\n' // FF
         << nouppercase << dec << '\n';

    cout << "| ";
    cout << 123 << " |" << '\n'; // | 123 |
    cout << "| ";
    cout.width(10);
    cout << 123 << " |" << '\n'; // |        123 |
    cout << "| ";
    cout << left;
    cout.width(10);
    cout << 123 << " |" << '\n' // | 123        |
         << right << '\n';
}

Note that the member function precision and width can be replaced with the manipulators setprecision and setw respectively, but only if you include the header file <iomanip>. This is especially useful for setting the width, since it allows you to do everything in one line. Here is an example:

#include <iomanip>
#include <iostream>
using namespace std;

int main()
{
    cout << setprecision(5)
         << 1.23456789 << '\n' // 1.2346
         << setprecision(10)
         << 1.23456789 << '\n' // 1.23456789
         << setprecision(20)
         << 1.23456789 << '\n' // 1.2345678899999998901
         << '\n';

    cout << "| " << 123 << " |" << '\n'                      // | 123 |
         << "| " << setw(10) << 123 << " |" << '\n'          // |        123 |
         << "| " << left << setw(10) << 123 << " |" << '\n'; // | 123        |
}

Finally, note that these manipulators also work for input; for example, sending hex into cin will instruct it to expect numbers to be input in hexadecimal.

5.3.2 I/O streams and files ^

In general, any type of input and output in C++ is done using streams. We have seen above that cout belongs to the class ostream (output stream). Similarly, cin belongs to the class istream (input stream). Both are defined in the header file <iostream>.

File input and output in C++ is also handled using streams: input using ifstream (input file stream), output using ofstream (output file stream), or both input and output using fstream. These are defined in the header file <fstream>.

To open a file, we create an object of the desired type and initialize it with the file name. For example:

ifstream input("input.txt");

or:

ofstream output("output.txt");

We can check if the file was opened correctly using the member function is_open():

ifstream input("input.txt");
if (!input.is_open())
    cout << "Error opening file!";

When the object goes out of scope, its destructor closes the file automatically. However, it is a good programming practice to close files manually nonetheless, using the member function close, e.g. input.close().

Once we opened a file as a stream, we can read and write to it using the << and >> operators, just as for cout and cin. For example:

#include <fstream>
#include <iostream>
#include <string>
using namespace std;

int main()
{
    ifstream input("main.cpp");
    if (!input.is_open())
    {
        cout << "Error opening input file!";
        return -1;
    }

    ofstream output("out.txt");
    if (!output.is_open())
    {
        cout << "Error opening output file!";
        return -1;
    }

    string s;
    while (input >> s)
        output << s << '\n';

    output.close();
    input.close();
}

Note that in the while loop, input >> s will be false when we input something that is not a string, but that will only happen when we reach the end of the file, since anything can be a string. If the name of the source file for this program is main.cpp, then the file out.txt will contain the following output:

#include
<fstream>
#include
<iostream>
(etc...)

The reason is that strings are expected to be separated by whitespace characters, which include spaces, tabs, and newlines, so #include and <fstream> in the line #include <fstream> are actually considered to be separate strings. We can fix that in one of two ways:

  1. Read individual characters using get.
  2. Read individual lines using getline.

Here is a program that uses get (we now output to the terminal instead of a file, for simplicity):

#include <fstream>
#include <iostream>
using namespace std;

int main()
{
    ifstream input("main.cpp");
    if (!input.is_open())
    {
        cout << "Error opening file!";
        return -1;
    }

    char c;
    while (input.get(c))
        cout << c;

    input.close();
}

Note that you can't just write input >> c (as you would for the cin stream), because the >> operator reads formatted input, so it will automatically skip whitespace characters. Therefore, for example, #include <fstream> will become #include<fstream>. On the other hand, get reads unformatted input, so it treats all characters the same, including whitespace. We can similarly use put to put just one character to an output stream.

Using getline as a member function of an ifstream object is not recommended, since we have to specify a maximum number of characters to read, and then store them in an old-fashioned C-style string, i.e. an array of chars. That is, we need to replace the read loop by something like

char s[100];
while (input.getline(s, 100)) // Limited to reading strings of up to 100 characters!
    cout << s << '\n';

The problem is that if a line happens to have more than 100 characters, then getline will not read the entire line. This is a problem that we would have had to deal with in C; luckily, in C++ we have the modern string class, which makes everything much easier!

Instead of using getline as a member function of the class ifstream, we can use a different getline that is defined in the <string> header file. This function takes a stream as its first argument, and a string as its second argument. Here is the full code:

#include <fstream>
#include <iostream>
#include <string>
using namespace std;

int main()
{
    ifstream input("main.cpp");
    if (!input.is_open())
    {
        cout << "Error opening file!";
        return -1;
    }

    string s;
    while (getline(input, s))
        cout << s << '\n';

    input.close();
}

5.3.3 File stream modes ^

The following modes can be specified as the second argument in the initialization list of a file stream:

  • ios::in: Read.
  • ios::out: Write.
  • ios::app: Append.
  • ios::ate: At end; opens the file and then seeks to the end of the file.
    • The difference between ios::app and ios::ate is that ios::app only allows you to write at the end of the file, while ios::ate allows you to seek to an earlier position; see below.
  • ios::binary: Binary mode; see below.
  • ios::trunc: Truncate the file to zero length.

These modes can be combined with the | (bitwise or) operator. For example, ios::in | ios::out indicates opening for both reading and writing.

  • The default mode for ifstream is ios::in.
  • The default mode for ofstream is ios::out | ios::trunc.
  • The default mode for fstream is ios::in | ios::out.
Warning: If an existing file is opened for output with the default settings, its contents will be permanently destroyed with no way to recover them!

The following program illustrates overwriting, truncating, and appending to a file:

#include <fstream>
#include <iostream>
#include <string>
using namespace std;

int main()
{
    string filename = "out.txt";

    // ofstream default mode: Existing file contents will be overwritten and truncated (i.e. destroyed).
    ofstream output(filename);
    if (!output.is_open())
    {
        cout << "Error opening file " << filename << "!\n";
        return -1;
    }
    output << "12345";
    output.close();

    // ofstream with ios::app: Appends to what we wrote previously, without overwriting or truncating.
    // Note that the member function open() can be used to open another file with the same stream object.
    output.open(filename, ios::app);
    output << "67890";
    output.close();

    // fstream default mode: Existing file contents will be overwritten but NOT truncated.
    fstream inout(filename);
    if (!inout.is_open())
    {
        cout << "Error opening file " << filename << "!\n";
        return -1;
    }
    // The characters "123" will be overwritten, but the rest will remain untouched.
    inout << "ABC";
    inout.close();

    ifstream input(filename);
    if (!input.is_open())
    {
        cout << "Error opening file " << filename << "!\n";
        return -1;
    }
    string s;
    input >> s;
    cout << s; // Prints "ABC4567890"
    input.close();
}

5.3.4 Seeking ^

To seek to a different position in the file, we can use the member functions seekg (seek get) for input files or seekp (seek put) for output files. The syntax is:

file.seekg(offset, direction);

And similarly for seekp. direction specifies where the offset is calculated with respect to:

  • ios::beg: The offset is the position relative to the beginning of the file. This is the default.
  • ios::cur: The offset is the position relative to the current position.
  • ios::end: The offset is the position relative to the end of the file.

Here is an example:

#include <fstream>
#include <iostream>
#include <string>
using namespace std;

void print_current_char(ifstream &input)
{
    char c;
    input.get(c);
    cout << c;
}

int main()
{
    ifstream input("input.txt"); // Contents of input file: 1234567890ABCDEFGHIJ
    if (!input.is_open())
    {
        cout << "Error opening file!";
        return -1;
    }

    print_current_char(input); // Prints "1"
    print_current_char(input); // Prints "2"

    input.seekg(0);            // Seeks to the beginning of the file
    print_current_char(input); // Prints "1" again

    input.seekg(10);           // Seeks to position 10 from the beginning of the file
                               // Equivalent to input.seekg(10, ios::beg);
    print_current_char(input); // Prints "A"

    input.seekg(5, ios::cur);  // Seeks 5 characters ahead of the current position
    print_current_char(input); // Prints "G"

    input.seekg(-2, ios::end); // Seeks to 2 characters before the end of the file
    print_current_char(input); // Prints "I"

    input.close();
}

For ofstream we would use seekp instead. If a file is opened for both input and output with fstream, you can use either one.

5.3.5 String streams ^

We have seen two types of streams so far: the terminal or "standard" input/output (istream, ostream) and files (ifstream, ofstream). A string stream is a stream that allows you to read or write into a string stored in memory the same way you do with any other stream. String streams are defined in the header file <sstream> and correspond to the types istringstream for input, ostringstream for output, and stringstream for both input and output.

Essentially, you use string streams whenever you want to take advantage of the functionality of a stream. For example, streams have well-defined input >> and output << operators, and these operators work on string streams as well, which means we can use a string stream to write formatted data into a string. The actual string can then be accessed using the member function str. This is illustrated in the following program:

#include <iostream>
#include <sstream>
#include <string>
using namespace std;

int main()
{
    ostringstream out;
    out << "Here are some numbers: " << 0.5 << ", " << 7 << ", " << hex << 255 << '\n';
    string s = out.str();
    cout << s; // Prints "Here are some numbers: 0.5, 7, ff"
}

This is especially convenient when we want to first process all the data in memory, and then output it all at once, either to the terminal or to a file.

Another convenient usage of string streams is for converting a list of numbers separated by spaces into a vector:

#include <iostream>
#include <sstream>
#include <string>
#include <vector>
using namespace std;

ostream &operator<<(ostream &out, const vector<double> &v)
{
    out << '(';
    for (uint64_t i = 0; i < v.size() - 1; i++)
        out << v[i] << ", ";
    out << v[v.size() - 1] << ')';
    return out;
}

vector<double> read_numbers(const string &in)
{
    vector<double> v;
    string s;
    istringstream string_stream(in);
    try
    {
        while (getline(string_stream, s, ' '))
            v.push_back(stod(s));
    }
    catch (const invalid_argument &e)
    {
        throw invalid_argument("Expected a number!");
    }
    catch (const out_of_range &e)
    {
        throw out_of_range("Number is out of range!");
    }
    return v;
}

int main()
{
    try
    {
        cout << read_numbers("1 5.7 3.2 18.99"); // Prints "(1, 5.7, 3.2, 18.99)"
    }
    catch (const exception &e)
    {
        cout << "Error: " << e.what() << '\n';
    }
}

The operator<< overload for vectors is the same one we used above. In the function read_numbers(), we used the fact that getline takes a delimiter character as its third argument; by default this argument is '\n', so it reads until it reaches the end of the line, but if we replace it with ' ' it will instead read until it reaches a space character. We loop on each substring read in this way until the end of the string stream is reached, and we convert each substring into a double with the function stod (string to double).

Of course, we need to validate the input string, as it may not be of the appropriate form. According to the C++ reference, stod can throw two exceptions: invalid_argument if it encountered something other than a number, or out_of_range if the value of out of the range of the double data type (as we found above, the range is roughly between 2.2e-308 and 1.8e+308).

We could catch these exceptions directly, but the problem is that the error messages obtained by what() are not very illuminating (on my computer the error is simply "stod".) Therefore, we catch these exceptions inside read_numbers(), and then throw the same exceptions with more informative error messages. You can test this by putting some letters inside the string (which will throw invalid_argument), or a large number such as 1e500 (which will throw out_of_range).

5.3.6 Buffered output ^

The computer's memory is usually much faster than any hard drives. Therefore, the data that is written to a file stream by the program will sometimes be automatically stored in a memory buffer before it is actually written to the disk. It is possible that data from many different write operations will be kept in the buffer. At some point the data will finally be written to the file, all at once. This is called flushing the buffer.

Although using a buffer improves performance, it also means that if the program crashes, the crash could happen before the buffer is flushed. In this case, the data that was supposed to be written to the file (including the results of your very complicated 20-hour calculation!) may be lost.

The buffer can be flushed manually, either by sending the manipulator flush to the output stream or by using the member function flush(). In addition, the manipulator endl can be used to send a newline character \n and then flush the buffer. However, endl should not be used every time you want to send a newline character; if you flush the buffer after every single line you write to the output stream, the program may run slower due to continuously accessing the disk. It's better to just use \n and, if desired, flush the buffer manually using flush.

Generally, you should let the buffer be flushed automatically to ensure maximum performance. However, to prevent data loss, it is a good idea to flush the buffer manually after writing important data such as the result of a long calculation.

5.3.7 I/O error handling ^

One example of an I/O error that you may encounter happens when your program expects to read data of one type, but gets another type instead. This is especially common when getting input from the user via the terminal. For example, the user may misunderstand the instructions and write letters when asked for a number.

However, as I stressed before, you should generally not get input from the terminal when you are doing scientific programming. All the input should be taken from files and/or from command line arguments, for two reasons:

  1. If your program does calculations that take hours or even days, you can't expect the user to sit by the computer for the entire run time of the program in order to input data.
  2. Your program will often be executed multiple times with different data each time. This can be automated using a script, but only if the program takes its data from a file; again, you can't expect the user to manually input different data each time the program runs.

However, even when reading data from a file, it is possible that the file will not be formatted correctly, especially if the file was manually written by the user (rather than being automatically generated by another program). C++ provides error handling for streams using the following member functions:

  • good returns true if everything is okay.
  • bad returns true if a fatal error has occurred. This includes, for example, the file no longer being accessible.
  • fail returns true if a non-fatal error has occurred. This includes, for example, finding a letter when expecting a number. Note that bad implies fail, but not the other way around.
  • eof returns true if the end of the file has been reached.
  • clear restores to stream to a good state once the error has been handled by the program.

Here is an example of simple error checking:

#include <fstream>
#include <iostream>
using namespace std;

int main()
{
    ifstream input("input.txt");
    if (!input.is_open())
    {
        cout << "Error opening file!";
        return -1;
    }

    double n = 0;
    while (input >> n)
        cout << "Found a number: " << n << ".\n";

    if (input.eof())
        cout << "Reached end of file.\n";
    else if (input.fail())
        cout << "Encountered input that was not a number.\n";
}

If the file input.txt only contains numbers (separated by spaces or newlines), these numbers will be displayed on the screen until the input reaches the end of the file, in which case input.eof() will be true. However, if input.txt contains something that is not a number at some point, the while loop will terminate prematurely, and input.fail() will be true.

In addition, the C++ standard library offers the following character classification functions in the header file <cctype>, which return true if the input argument is a character of the indicated type:

  • islower(): Lowercase letters abcdefghijklmnopqrstuvwxyz.
  • isupper(): Uppercase letters ABCDEFGHIJKLMNOPQRSTUVWXYZ.
  • isdigit(): Digits 0123456789.
  • isxdigit(): Hexadecimal digits 0123456789abcdefABCDEF.
  • ispunct(): Punctuation characters !"#$%&'()*+,-./:;<=>?@[\]^_`{|}~.
  • isalpha(): Alphabet characters, which includes lowercase letters and uppercase letters.
  • isalnum(): Alphanumeric characters, which includes lowercase letters, uppercase letters, and digits.
  • isprint(): Any printable character, which includes lowercase letters, uppercase letters, digits, punctuation characters, and space ' '.
  • isspace(): Whitespace characters, which includes space ' ', form feed '\f', new line '\n', carriage return '\r', horizontal tab '\t', and vertical tab '\v'.

As a side note, the same header file also provides the functions tolower() to convert a character to lowercase and toupper() to convert a character to uppercase.

Here is an example of a program that checks whether its input is a word (i.e. only composed of letters) or not:

#include <fstream>
#include <iostream>
#include <cctype>
#include <string>
using namespace std;

bool is_word(const string &s)
{
    for (const char &c : s)
        if (!isalpha(c))
            return false;
    return true;
}

int main()
{
    ifstream input("input.txt");
    if (!input.is_open())
    {
        cout << "Error opening file!";
        return -1;
    }

    string s;
    while (input >> s)
        if (is_word(s))
            cout << "Found a word: " << s << ".\n";
        else
            cout << "Not a word: " << s << ".\n";
}

So, what should your program do if invalid input was supplied by the user? In scientific programming, usually the best thing to do is to notify the user that the input is invalid, and then just terminate the program. Your program shouldn't try to guess what the user meant and fix their mistakes for them (like Google fixing your typos in searches), because that may lead to running the program with the wrong input. Scientific data analysis and calculations can sometimes run for days at a time, and you don't want to do that only to find out in the end that the user actually wanted to do something else.

5.3.8 Reading and writing binary files ^

Essentially, the only difference between binary mode ios::binary (see above) and the default file I/O mode, or text mode, is that binary mode treats all characters equally, while text mode treats newline characters (and only newline characters) in an OS-specific way, so if you write \n (newline) to a file, the program may actually end up writing \r (carriage return) or \r\n, depending on the operating system. This is the only difference as far as the C++ file stream modes are concerned.

Apart from that, there is a more fundamental difference in the way data is stored inside a file, which has nothing to do with ios::binary. Files are composed of bytes, which have 8 bits each. Each character in a string usually takes up one byte (it can take more than that if you are using wide character formats, which would happen, for example, if you use Unicode characters). The number of bytes taken up by other data types is equal to the bit width of the type, so for example, int32_t will take up 4 bytes while int64_t will take up 8 bytes.

When you write the 32-bit integer 1 as text, it will take up only 1 byte, because you just need to write the character 1. However, if you write it as binary, it will take up 4 bytes. On the other hand, the 32-bit integer 1234567890 will take up 10 bytes as text, one byte for each of the digits, but still only 4 bytes as binary.

Many file formats, such as image (e.g. PNG), audio (e.g. MP3), video (e.g. MP4), and so on, are binary formats. Such files have the benefit of being smaller in size, but they are not readable by humans. In scientific computing, we generally want files to be human-readable, so it is recommended to never use binary formats unless you really have to.

Generating large files is usually not a problem, since files can be easily compressed, for example using ZIP. (Of course, the ZIP file itself will be a binary file.) Furthermore, all modern operating systems can be configured to automatically store files in compressed format "behind the scenes", so that the file is human-readable when it is opened by any program, but actually takes up much less space on the disk.

If you want to get input from a specific type of binary file, then there are open-source C++ libraries that you can #include in your program and will take care of reading the file for you; you can easily find such libraries online. Generally, you don't need to write your own code to handle binary files, unless you are dealing with a binary format for which a C++ library does not already exist, or you want low-level access to the contents of the file.

As an example, let us consider how we might read and write a vector<double> to and from a file. C++ doesn't provide that functionality on its own, so we have to do it ourselves. We will first do this in text format, and then in binary format.

If we want to read and write vectors in human-readable format (which, again, is the preferred way in scientific programming!), then we can simply overload << and >> with the appropriate behavior:

#include <fstream>
#include <iostream>
#include <sstream>
#include <string>
#include <vector>
using namespace std;

ostream &operator<<(ostream &out, const vector<double> &v)
{
    for (const double &i : v)
        out << i << ' ';
    out << '\n';
    return out;
}

istream &operator>>(istream &in, vector<double> &v)
{
    string line, s;
    getline(in, line);
    istringstream st_stream(line);
    while (getline(st_stream, s, ' '))
        v.push_back(stod(s));
    return in;
}

int main()
{
    vector<double> v = {1.2, 3.4, 5.6, 7.8, 9.0};
    vector<double> w = {3, 5, 7, 9};
    cout << "v: " << v
         << "w: " << w;

    string filename = "vectors.txt";
    ofstream output(filename);
    if (!output.is_open())
    {
        cout << "Error opening file " << filename << " for output!";
        return -1;
    }
    output << v;
    output << w;
    output.close();

    vector<double> a, b;

    ifstream input(filename);
    if (!input.is_open())
    {
        cout << "Error opening file " << filename << " for input!";
        return -1;
    }
    input >> a;
    input >> b;
    input.close();

    cout << "a: " << a
         << "b: " << b;
}

Input and output of binary files in C++ is somewhat awkward. Here is one way to do it for vector<double>:

#include <fstream>
#include <iostream>
#include <string>
#include <vector>
using namespace std;

ostream &operator<<(ostream &out, const vector<double> &v)
{
    for (const double &i : v)
        out << i << ' ';
    out << '\n';
    return out;
}

void write_binary_vector(ofstream &out, const vector<double> &v)
{
    uint64_t s = v.size();
    out.write(static_cast<char *>((void *)&s), sizeof(uint64_t));
    for (const double &i : v)
        out.write(static_cast<char *>((void *)&i), sizeof(double));
}

vector<double> read_binary_vector(ifstream &in)
{
    uint64_t s = 0;
    in.read(static_cast<char *>((void *)&s), sizeof(uint64_t));
    vector<double> v(s);
    for (uint64_t i = 0; i < s; i++)
        in.read(static_cast<char *>((void *)&v[i]), sizeof(double));
    return v;
}

int main()
{
    vector<double> v = {1.2, 3.4, 5.6, 7.8, 9.0};
    vector<double> w = {3, 5, 7, 9};
    cout << "v: " << v
         << "w: " << w;

    string filename = "vectors.bin";
    ofstream output(filename, ios::binary);
    if (!output.is_open())
    {
        cout << "Error opening file " << filename << " for output!";
        return -1;
    }
    write_binary_vector(output, v);
    write_binary_vector(output, w);
    output.close();

    vector<double> a, b;
    ifstream input(filename, ios::binary);
    if (!input.is_open())
    {
        cout << "Error opening file " << filename << " for input!";
        return -1;
    }
    a = read_binary_vector(input);
    b = read_binary_vector(input);
    input.close();

    cout << "a: " << a
         << "b: " << b;
}

In this program, we are using the fstream member functions read() and write() to read and write binary data. They take two arguments: the first is a pointer to a C-style string, i.e. an array of chars, and the second is the length of the string, i.e. the number of bytes it takes up in memory. They then read or write from the string into the file or vice versa.

Here we are using a trick: instead of the first argument being a pointer to a string, we give a pointer to the raw data we want to write, which we cast to look like a pointer to a string using static_cast. The syntax static_cast<new_type> expression casts expression into the data type new_type. So in the statement

static_cast<char *>((void *)&s)

we first cast &s, which is a pointer to a uint64_t, into void * in order to "hide" the fact that it's a pointer to a uint64_t, and then use static_cast<char *> to further cast it into a pointer to a char *, i.e. a C-style string. The end result is that the actual memory representation of s is read from or written to the file. Similarly, static_cast<char *>((void *)&i) does the same for &i, which is a pointer to a double.

In text mode, we could simply format our file such that a newline character indicates the end of the vector, and thus we can infer the length of the vector from the number of elements in the line. However, in binary mode, there is no such thing as a newline. Everything we write is binary, and the binary representation of a newline character is also the binary representation of many other things - including a specific number represented as a double - so there is no way to distinguish a newline from actual vector elements.

The way I chose to solve this problem here (which is certainly not the only way) is to first read or write the length s of the vector, which will take up 8 bytes since it's a uint64_t, and then read or write the actual contents of the vector. So the file format is: (length of vector 1) (elements of vector 1) (length of vector 2) (elements of vector 2) and so on.

If you want the see the binary data that was written to the file, you can install the Microsoft Hex Editor extension for Visual Studio Code, and use it to open the file vectors.bin by right-clicking and selecting Open With... > Hex Editor. What you will find is the actual binary representation of each floating-point number, which we discussed above.

For more information about file I/O in C++, please refer to the C++ reference or Microsoft's C++ reference.

5.4 Class friendship and inheritance ^

5.4.1 Friend functions

Normally, private members of a class are only accessible to member functions of that class, and inaccessible to the rest of the program. However, functions external to a class can be given access to its private members by declaring them within the class with the friend keyword. Such functions are called friend functions.

A friend function is not considered a member function of the class; it is just an external function that is given special access privileges. It can be declared anywhere in the class declaration, and the declaration is unaffected by access control keywords such as private or public.

Note that a function cannot just declare itself as a friend function to any class; only the actual implementation of the class can do that. Otherwise, this would undermine encapsulation, as it would be trivial to access private members of any class.

Friend functions are used when we want to implement a certain function independently as an external function, rather than as a member function. For example, consider the point class we defined earlier. The print() and scale() member functions need to access the private members x and y. If we want to implement them as non-member functions, we can do this by defining them as friend functions as follows:

#include <iostream>
using namespace std;

class point
{
    friend void print(const point &);
    friend void scale(point &, const double &);

public:
    point(const double &_x, const double &_y) : x(_x), y(_y) {}

private:
    double x = 0, y = 0;
};

void print(const point &p)
{
    cout << '(' << p.x << ", " << p.y << ")\n";
}

void scale(point &p, const double &s)
{
    p.x *= s;
    p.y *= s;
}

int main()
{
    point p(1, 2);
    print(p); // (1, 2)
    scale(p, 5);
    print(p); // (5, 10)
}

If you delete the two lines starting with the keyword friend, you will get an error from the compiler saying that p.x and p.y are inaccessible.

Of course, we could have also defined the print function as an external function without it being a friend function, if we added member functions get_x and get_y that could be used to read the coordinates, but not change them:

class point
{
public:
    point(const double &_x, const double &_y) : x(_x), y(_y) {}
    double get_x() const { return x; }
    double get_y() const { return y; }

private:
    double x = 0, y = 0;
};

In this case, we could have written print as follows:

void print(const point &p)
{
    cout << '(' << p.get_x() << ", " << p.get_y() << ")\n";
}

However, this solution is not always possible, for two reasons:

  1. Functions often need to not only read but also modify private member variables, as is the case for scale in our example.
  2. Encapsulation sometimes requires that the private data of a class is inaccessible not just for writing, but also for reading. For example, if the user can read a pointer, then they can directly modify the internal data it points to, potentially violating the invariant.

5.4.2 Uses for friend functions ^

A common reason to define functions as friend is when overloading operators as non-member functions. For example, here is a version of our previous program which works more intuitively using overloaded operators:

#include <iostream>
#include <cmath>
using namespace std;

class point
{
    friend ostream &operator<<(ostream &, const point &);
    friend point operator*(const double &, const point &);

public:
    point(const double &_x, const double &_y) : x(_x), y(_y) {}

private:
    double x = 0, y = 0;
};

ostream &operator<<(ostream &out, const point &p)
{
    out << '(' << p.x << ", " << p.y << ")\n";
    return out;
}

point operator*(const double &s, const point &p)
{
    return point(s * p.x, s * p.y);
}

point operator*(const point &p, const double &s)
{
    return s * p;
}

point operator*=(point &p, const double &s)
{
    p = s * p;
    return p;
}

int main()
{
    point p(1, 2);
    cout << p; // (1, 2)
    point q = 2 * p;
    cout << q; // (2, 4)
    p *= 5;
    cout << p; // (5, 10)
}

Note that only the << and *(double, point) overloads needed to be defined as friend. The other two overloads simply call the *(double, point) overload, so they don't need to access any private members.

In the *(double, point) overload itself, notice that we constructed the scaled point object within the return statement itself. This means we don't construct a temporary object and then overwrite it, which would be slower as it would involve first initializing the object and then overwriting its contents. Later I'll show you how to avoid such redundant initializations, which can improve performance (but also introduce bugs if you're not careful).

(In the case of the vector and matrix +/-/* overloads we accepted the argument by value, thus creating a temporary copy, and then performed the addition/subtraction/multiplication on that copy. This is a bit more optimal than creating a temporary copy pointlessly initialized to zeros and then overwriting those zeros, but not by much. Again, later we'll see how to do this in the most optimal way.)

A friend function doesn't have to be an independent function; it can also be a member function of another class. For example, in the following code, we defined a class named printer which can be used to store any ostream object (such as cout) and print to it. The member function print_point prints a point into the specified stream, but it needs access to the coordinates of the point, so it must be declared a friend function:

#include <iostream>
using namespace std;

class point;

class printer
{
public:
    printer(ostream &_out) : out(_out) {}

    void print_point(const point &);

private:
    ostream &out;
};

class point
{
    friend void printer::print_point(const point &);

public:
    point(const double &_x, const double &_y) : x(_x), y(_y) {}

private:
    double x = 0, y = 0;
};

void printer::print_point(const point &p)
{
    out << '(' << p.x << ", " << p.y << ")\n";
}

int main()
{
    point p(1, 2);
    printer r(cout);
    r.print_point(p); // (1, 2)
}

Notice that we had to first declare point using forward declaration, so that printer knows it exists - otherwise we could not have declared print_point, since it takes a point as input.

In addition to friend functions, we can also declare an entire class as a friend class. If B is a friend class of A, then all member functions of B are automatically friend functions of A. However, note that class friendship is not mutual; if B is a friend class of A, this doesn't mean A is a friend class of B, unless we explicitly declare it as such. Similarly, class friendship is not transitive; if B is a friend class of A and C is a friend class of B, this doesn't mean C is a friend class of A.

An as example, if we change the line

friend void printer::print_point(const point &);

in the previous example to

friend class printer;

then all member functions of printer, including print_point and any other functions we may define in the future, will automatically be friend functions of point.

Friend functions must be used whenever a function needs to access the private members of more than one class, which would otherwise be impossible since a function can only be a member function of at most one class. Since print_point cannot be a member function of both point and printer, it must either be a member function of one of them and a friend of the other, or an external function and a friend of both. In the example above, we used the first option. Here is an example of the second option:

#include <iostream>
using namespace std;

class point;

class printer
{
    friend void print_point(const printer &, const point &);

public:
    printer(ostream &_out) : out(_out) {}

private:
    ostream &out;
};

class point
{
    friend void print_point(const printer &, const point &);

public:
    point(const double &_x, const double &_y) : x(_x), y(_y) {}

private:
    double x = 0, y = 0;
};

void print_point(const printer &r, const point &p)
{
    r.out << '(' << p.x << ", " << p.y << ")\n";
}

int main()
{
    point p(1, 2);
    printer r(cout);
    print_point(r, p); // (1, 2)
}

5.4.3 Inheritance and derived classes ^

A derived class is a class that is derived from another class, called the base class. The derived class inherits the members of the base class, while also adding or replacing members as needed. An object in the derived class is considered to be an object in the base class, but not vice versa.

We have already seen examples of derived classes when we talked about input and output streams. For example, the output file stream class ofstream is derived from the output stream class ostream. This means that member functions and overloaded operators of ostream can be used with any ofstream object, but ofstream provides additional functionality that ostream doesn't have, namely handling files.

In particular, since our printer class defined above takes an ostream object, it can also take an ofstream object. You can verify this by adding #include <fstream> and replacing the line printer r(cout); with

ofstream f("test.txt");
printer r(f);

Similarly, an output string stream ostringstream is also derived from ostream, so printer can take an ostringstream as an argument as well.

Importantly, derived classes cannot access private members of their base class, only public members. This has to be the case, because otherwise, any user of your class could easily gain access to all of its private members simply by deriving a class from it, which would render the label private essentially meaningless and go against the principle of encapsulation.

The syntax for defining a derived class is:

class derived_class : access_mode base_class
{
    // ...
}

access_mode controls how members of the base class will be accessible as members of the derived class. If the member is private in the base class, then it is always inaccessible outside of the base class, regardless of access_mode. However, if the member is public in the base class, then its access mode as a member of the derived class will be access_mode. So if access_mode is public, then public members will stay public, but if access_mode is private, then public members will be converted to private.

This is illustrated in the following code:

class base_class
{
public:
    int32_t public_member = 0;

private:
    int32_t private_member = 0;
};

class derived_public_class : public base_class
{
};

class derived_private_class : private base_class
{
};

int main()
{
    base_class base;
    derived_public_class der_pub;
    derived_private_class der_priv;

    base.public_member = 1;  // Allowed: member is public
    base.private_member = 1; // NOT allowed: member is private

    der_pub.public_member = 1;  // Allowed: member is still public
    der_pub.private_member = 1; // NOT allowed: member is still private

    der_priv.public_member = 1;  // NOT allowed: member is now private
    der_priv.private_member = 1; // NOT allowed: member is private
}

In most cases, your derived class will simply add new specialized functionality on top of the base class, so all public members of the base class should still be accessible as public members of the derived class. Therefore, access_mode is usually set to public. That way, both classes can share the same interface.

The derived class must include its own constructors. In many cases, these will simply refer to the constructors of the base class. For example, let us add a new class, vec (for vector), which derives from point. A vector is not a point, but rather, an arrow connecting two points. A vec is assumed to be a vector from the origin to the coordinates (x, y). Thus, it has a property that a point does not have: a magnitude, which can be calculated using the member function magnitude.

#include <cmath>
#include <iostream>
using namespace std;

class point
{
public:
    point(const double &_x, const double &_y) : x(_x), y(_y) {}

    void print() const
    {
        cout << '(' << x << ", " << y << ")\n";
    }

    double x = 0, y = 0;
};

class vec : public point
{
public:
    vec(const double &_x, const double &_y) : point(_x, _y) {}

    double magnitude() const
    {
        return sqrt(x * x + y * y);
    }
};

int main()
{
    vec v(3, 4);
    v.print();             // (3, 4)
    cout << v.magnitude(); // 5
}

Note that:

  • vec's constructor, simply calls the constructor of point. In this case, there is nothing to add to the constructor, since we are just adding new functionality on top of the base class.
  • vec inherited the member function print() from point, so we can print the vector using v.print(), as if v was a point.
  • vec defines the new member function magnitude(), which only works for vec objects. A point object will not have access to magnitude().

Note that friendship is not inherited; if point declares a function or class as a friend, they will not be inherited by vec. (Of course, here it doesn't matter since there are no private members.)

5.4.4 Deriving from a derived class ^

Let us now recall our triangle class defined above. An isosceles triangle is a special case of a triangle which has two sides of equal length. Therefore, we may want to derive a specialized class isosceles from the base class triangle.

An equilateral triangle, in turn, is a special case of an isosceles triangle which has three sides of equal length. Therefore, we may also want to derive a specialized class equilateral from the class isosceles. In this case, isosceles is both a derived class (of triangle) and a base class (of equilateral):

#include <cmath>
#include <iostream>
#include <stdexcept>
using namespace std;

class triangle
{
public:
    triangle(const double &_a, const double &_b, const double &_c)
        : a(_a), b(_b), c(_c)
    {
        if ((a < 0) or (b < 0) or (c < 0))
            throw invalid_argument("Sides cannot be negative!");
        if ((a > b + c) or (b > c + a) or (c > a + b))
            throw invalid_argument("Triangle inequality must be satisfied!");
    }

    double area() const
    {
        const double s = (a + b + c) / 2;
        return sqrt(s * (s - a) * (s - b) * (s - c));
    }

    void print() const
    {
        cout << '(' << a << ", " << b << ", " << c << ")\n";
    }

private:
    double a = 0, b = 0, c = 0;
};

class isosceles : public triangle
{
public:
    // Construct an isosceles triangle with two sides equal to the first argument and the third side equal to the second argument.
    isosceles(const double &_two_sides, const double &_one_side)
        : triangle(_two_sides, _two_sides, _one_side) {}
};

class equilateral : public isosceles
{
public:
    // Construct an equilateral triangle with all three sides equal to the argument.
    equilateral(const double &_all_sides)
        : isosceles(_all_sides, _all_sides) {}
};

int main()
{
    cout << "Arbitrary triangle:\n";
    triangle t(3, 4, 5);
    t.print();                            // (3, 4, 5)
    cout << "Area: " << t.area() << '\n'; // Area: 6

    cout << "Isosceles triangle:\n";
    isosceles i(3, 4);
    i.print();                            // (3, 3, 4)
    cout << "Area: " << i.area() << '\n'; // Area: 4.47214

    cout << "Equilateral triangle:\n";
    equilateral e(3);
    e.print();                            // (3, 3, 3)
    cout << "Area: " << e.area() << '\n'; // Area: 3.89711
}

Note that the member function area() was inherited all the way down from triangle to its "grandchild", equilateral.

5.4.5 Deriving from two base classes ^

A class can be derived from more than one base class. The syntax for that is

class derived_class : access_mode1 base_class1, access_mode2 base_class2, ...
{
    // ...
}

In the following example we have a variation on the two classes point and printer we defined previously. The class point includes the member variables x and y, which store the coordinates of the point, and the member function scale(), which scales the point. The class printer includes the member variable out, which stores an output stream, and the member function print_string(), which prints a string into the stream.

We combine these two classes into one derived class, point_with_printer, which inherits the members of both classes. This class then defines a new member function, print_point(), which uses print_string() to print the coordinates:

#include <iostream>
#include <string>
using namespace std;

class point
{
public:
    point(const double &_x, const double &_y) : x(_x), y(_y) {}

    void scale(const double &s)
    {
        x *= s;
        y *= s;
    }

    double x = 0, y = 0;
};

class printer
{
public:
    printer(ostream &_out) : out(_out) {}

    void print_string(const string &s)
    {
        out << s;
    }

    ostream &out;
};

class point_with_printer : public point, public printer
{
public:
    point_with_printer(const double &_x, const double &_y, ostream &_out)
        : point(_x, _y), printer(_out) {}

    void print_point()
    {
        print_string('(' + to_string(x) + ", " + to_string(y) + ")\n");
    }
};

int main()
{
    point_with_printer p(1, 2, cout);
    p.print_point(); // (1.000000, 2.000000)
    p.scale(3);
    p.print_string("After scaling by 3:\n");
    p.print_point(); // (3.000000, 6.000000)
}

Note that we used to_string() to convert double into a string, so we can pass the coordinates as a string to print_string. This also has the unfortunate side effect that the digits after the decimal point don't get truncated for integers, which the overloaded operator << does but to_string does not do. We could fix this e.g. by using a string stream, but we won't bother with that for this simple example.

5.4.6 Protected members ^

In addition to the labels public and private, a third label, protected, can be used to make members accessible to derived classes while keeping them inaccessible to the rest of the program. Essentially, protected is equivalent to private, except that protected members of a class can also be accessed by any classes derived from it.

The access_mode specified in the inheritance syntax class derived_class : access_mode base_class can also be protected. The general rule is that if access_mode is more strict than the original access mode in the base class, then the new access mode in the derived class will be determined by access_mode, otherwise it stays the same. Therefore:

  • If the member is private in the base class, then it is always inaccessible outside of the base class, regardless of access_mode.
  • If the member is protected in the base class, then its access mode as a member of the derived class will be private if access_mode is private. Otherwise, it stays protected.
  • If the member is public in the base class, then its access mode as a member of the derived class will be access_mode.

As an example, consider the vec class derived from the point class, as we defined above. Let's say that we want to hide the members x and y from the rest of the program, but still keep them accessible to vec. Then we could define them as protected:

#include <cmath>
#include <iostream>
using namespace std;

class point
{
public:
    point(const double &_x, const double &_y) : x(_x), y(_y) {}

    void print() const
    {
        cout << '(' << x << ", " << y << ")\n";
    }

protected:
    double x = 0, y = 0;
};

class vec : public point
{
public:
    vec(const double &_x, const double &_y) : point(_x, _y) {}

    double magnitude() const
    {
        return sqrt(x * x + y * y);
    }
};

int main()
{
    vec v(3, 4);
    v.print();             // (3, 4)
    cout << v.magnitude(); // 5
}

If you try to access v.x or v.y in the main() function, you will not be able to, as they are inaccessible to any functions outside of point and vec. Also, if you change protected to private inside point, then the member function magnitude() will not be able to access x and y, so the program won't compile.

Generally, it is best not to use protected unless you really have to. If a member of your class is protected, then anyone can access it simply by defining a class derived from your class, potentially breaking encapsulation and violating the invariant. The safest thing to do is to have derived classes only access an object's data via public member functions that guarantee preservation of the invariant - just like the rest of this program.

We could also, as we did above, create public member functions get_x() and get_y() that allow the user to only read the values, but not modify them. Now magnitude() can simply use get_x() and get_y(), so it doesn't need access to x and y themselves, and they can be made private:

#include <cmath>
#include <iostream>
using namespace std;

class point
{
public:
    point(const double &_x, const double &_y) : x(_x), y(_y) {}

    void print() const
    {
        cout << '(' << x << ", " << y << ")\n";
    }

    double get_x() const { return x; }
    double get_y() const { return y; }

private:
    double x = 0, y = 0;
};

class vec : public point
{
public:
    vec(const double &_x, const double &_y) : point(_x, _y) {}

    double magnitude() const
    {
        return sqrt(get_x() * get_x() + get_y() * get_y());
    }
};

int main()
{
    vec v(3, 4);
    v.print();             // (3, 4)
    cout << v.magnitude(); // 5
}

5.4.7 Virtual functions ^

A derived class often replaces public member functions of the base class with new ones that have the same name, but additional or different functionality. In this case, the member functions that were replaced should be declared as virtual in the base class. Let us illustrate why this is needed. Consider the following program:

#include <iostream>
using namespace std;

class point
{
public:
    point(const double &_x, const double &_y) : x(_x), y(_y) {}

    void print() const
    {
        cout << '(' << x << ", " << y << ")\n";
    }

    double x = 0, y = 0;
};

class complex_point : public point
{
public:
    complex_point(const double &_x, const double &_y) : point(_x, _y) {}

    void print() const
    {
        cout << x << " + " << y << "i\n";
    }
};

void print_point(const point &p)
{
    p.print();
}

int main()
{
    point p(1, 2);
    p.print(); // Prints "(1, 2)", as expected.

    complex_point c(3, 4);
    c.print(); // Prints "3 + 4i", as expected.

    print_point(c); // Prints "(3, 4)", because print_point() doesn't know that c is a complex_point!
}

The class complex_point overrides the member function print() with another function that prints the coordinates as a complex number instead of a tuple. This works when we call the member function directly, c.print(), since the compiler knows that c is a complex_point.

The function print_point() takes a point as an argument, and since complex_point is derived from point, print_point() can take a complex_point as an argument as well. However, since it assumes the argument is a point, it calls the print() member function for point, not for complex_point.

Perhaps this could be solved by changing the argument to be a complex_point, but then the function won't work on a regular point! Remember that an object of a derived class counts as an object of the base class, but not vice versa.

The solution is to simply add the keyword virtual to the definition of print in point:

virtual void print() const
{
    cout << '(' << x << ", " << y << ")\n";
}

This will instruct the program to track down exactly what kind of object is calling this member function, and use the correct version of the function for that particular object.

You may be wondering why member functions are not virtual by default. The main reason is performance. If a function is not virtual, then the compiler decides which version of it to call at compilation time, but if it is virtual, then that decision is made at run time. Therefore, virtual introduces additional overhead, which may hurt performance.

Warning: As we demonstrated here, not using the keyword virtual in the base class when redefining a member function in a derived class can lead to errors and unexpected behavior. Always make sure to use virtual if needed.

Finally, note that if we redefined a member function and we want to ensure the member function of the correct class is called, we can add the class name followed by :: as a prefix. So for example, c.point::print() will always print (3, 4) while c.complex_point::print() will always print 3 + 4i. Similarly, if any member function of complex_point wants to call the old version of print(), it can do so by simply calling point::print().

5.4.8 Creating custom exceptions ^

Now that we know how class inheritance works, we can easily create our own exceptions by deriving them either from the basic std::exception class itself, or from classes derived from it, such as std::invalid_argument. Let us do so for the triangle class as an example:

#include <iostream>
#include <stdexcept>
using namespace std;

class triangle
{
public:
    triangle(const double &_a, const double &_b, const double &_c)
        : a(_a), b(_b), c(_c)
    {
        if ((a < 0) or (b < 0) or (c < 0))
            throw negative_sides();
        if ((a > b + c) or (b > c + a) or (c > a + b))
            throw triangle_inequality();
    }

    void print()
    {
        cout << '(' << a << ", " << b << ", " << c << ")\n";
    }

    class negative_sides : public invalid_argument
    {
    public:
        negative_sides() : invalid_argument("Sides cannot be negative!"){};
    };

    class triangle_inequality : public invalid_argument
    {
    public:
        triangle_inequality() : invalid_argument("Triangle inequality must be satisfied!"){};
    };

private:
    double a = 0, b = 0, c = 0;
};

int main()
{
    try
    {
        triangle t1(4, 2, 5);
        t1.print();
        triangle t2(6, -7, 8);
        t2.print();
        triangle t3(2, 2, 5);
        t3.print();
    }
    catch (const triangle::negative_sides &e)
    {
        cout << "Oops - used a negative side!\n";
    }
    catch (const triangle::triangle_inequality &e)
    {
        cout << "Oops - did not satisfy the triangle inequality!\n";
    }
}

The exceptions we defined are very simple: they derive from invalid_argument, and they have default constructors that construct an invalid_argument object with the desired error message. We then throw them by constructing a new object within the throw statement itself using the default constructor. Defining custom exceptions like this has several advantages:

  • The exceptions have their own unique names in the namespace of the triangle class, so they do not collide with any other names defined elsewhere.
  • Before, both exceptions were just invalid_argument objects with different what() strings, so the only way the user could have distinguished between them was to check that string, which is not a good idea since the string is meant to be read by the end user, not by the program itself. Now, each exception has a unique name, so the user can catch each specific exception individually and do something different in each case. In the example above, the hypothetical user took advantage of this to print their own custom error message instead of the what() error message supplied by our class.
  • On the other hand, since we derived our exceptions from invalid_argument, the user can still catch the parent class invalid_argument, as in the previous version of the triangle class, if they don't need to distinguish between the two exceptions.

6 Numerical aspects of C++ ^

6.1 The C++ numerics library

6.1.1 Mathematical functions

The header <cmath> includes many common mathematical functions such as abs (absolute value), max (return the larger of two numbers), exp (exponential), log (logarithm), pow (power), sqrt (square root), sin (sine), cos (cosine), sinh (hyperbolic sine), cosh (hyperbolic cosine), erf (error function), tgamma (gamma function), ceil (ceiling), floor (floor), and many others.

Since C++17, <cmath> also includes some special functions. Examples include riemann_zeta() for the Riemann zeta functions, legendre() for the Legendre polynomials, beta() for the Euler beta function, and cyl_bessel_j() for (cylindrical) Bessel function of the first kind. Please see the C++ reference for more information.

All of these functions are overloaded, so they can accept any floating-point or integer argument, similar to the ones in the C header file <tgmath.h>. For the full list, please see the C++ reference.

6.1.2 Numeric algorithms ^

The header file <numeric> provides some useful numeric algorithms, including:

  • gcd(a, b): Returns the greatest common divisor of the integers a and b.
  • lcm(a, b): Returns the least common multiple of the integers a and b.
  • midpoint(a, b) (since C++20): Returns the average of the two numbers a and b. This is equal to (a + b) / 2, but midpoint guarantees that no overflows occur if a + b is greater than the maximum value of the appropriate data type.

I will not be giving any examples, since these are pretty straightforward. You can read more about these algorithms in the C++ reference. There are more algorithms which I will list below, after we learn about iterators.

6.1.3 The complex number class ^

The header file <complex> contains the class complex (more precisely, a class template), used to do calculations with complex numbers. A complex number can be declared using:

complex<type> c;

or

complex<type> c(real, imaginary);

Here, type is the data type used to represent the real and imaginary parts. Typically you would use double, but it can be any type that has arithmetic operators overloaded. If the real and imaginary parts are not specified, they will be initialized to default values (zero for numeric types).

This class has all the operator overloads you would expect, such as comparison, addition, multiplication, and even >> and << for stream input and output in the format (real,imaginary). It also has the member functions real and imag, used to access the real and imaginary parts respectively.

The following non-member functions operate on complex numbers:

  • abs(z) returns the absolute value (magnitude) of z, i.e. the r in z=reiθ.
  • arg(z) returns the argument (phase angle) of z, i.e. the θ in z=reiθ.
  • norm(z) returns the squared magnitude of z.
  • conj(z) returns the complex conjugate of z.
  • real(z) and imag(z) return the real and imaginary parts of z; equivalent to z.real() and z.imag().
  • polar(r, theta) returns the complex number reiθ.

In addition, common mathematical functions such as exp, sin, pow, and so on have overloads that let them work on complex numbers as well. Finally, if you add the statement using namespace complex_literals, you can write imaginary numbers by simply adding an i suffix, so for example 1.0 + 2.0i will represent the corresponding complex number:

#include <complex>
#include <iostream>
using namespace std;
using namespace complex_literals;

int main()
{
    complex<double> c = 1.0 + 2.0i;
    cout << c << '\n';                               // (1,2)
    cout << (c + 3.0 + 4.0i) * (5.0 + 6.0i) << '\n'; // (-16,54)
}

Note that 1 + 2i will not work, because 1 is interpreted as an int while 2i is a double. However, 1.0 + 2i will work.

For more information about the complex class, please see the C++ reference.

6.1.4 Mathematical constants ^

C++20 added many useful mathematical constants in the header <numbers>. Examples include e (Euler's number), pi (pi), and phi (the golden ratio). These constants are defined under the namespace std::numbers, so you access them using e.g. numbers::e, numbers::pi, and numbers::phi. (You could also invoke using namespace numbers, but that is not recommended since the names might clash with other names in your program!)

For example:

#include <numbers>
#include <iostream>
using namespace std;

int main()
{
    cout.precision(10);
    cout << "e = " << numbers::e << '\n';
    cout << "pi = " << numbers::pi << '\n';
    cout << "phi = " << numbers::phi << '\n';
}

For the full list of available constants, please see the C++ reference.

6.2 Random numbers ^

6.2.1 True random number generation

Random numbers are frequently used in scientific computing, for example in simulations or when sampling values out of a certain probability distribution. Random number generators come in two forms: true random and pseudo-random.

True random number generators usually rely on the external randomness of the physical environment and/or an appropriate piece of hardware to generate random numbers. For example, the source of randomness can be thermal noise, or even the user's keyboard and mouse movements.

Most modern computers and operating systems only come with limited capabilities for generating true random numbers. The measure of how much randomness is available to a true random number generator is called entropy. Usually, only a few random numbers can be generated using the available pool of entropy before the generator has to "harvest" more of it. This means that generating many true random numbers is a slow process.

Pseudo-random number generators are actually deterministic, which means they are not truly random. An algorithm uses an initial seed to produce a sequence of numbers whose distribution appears random, but the entire sequence is completely predetermined by the seed. However, generating pseudo-random numbers is much faster, and it turns out to be good enough for most uses, as long as you are able to reliably choose different seeds in each run.

C++ inherits basic random number generation capabilities from C, in the header file <cstdlib>. However, the quality of that generator depends on the implementation, and there is no guarantee that a good algorithm is used. Therefore, the <cstdlib> random number generator is not recommended for scientific programming, and you should use the functionality provided in the header file <random> instead.

The C++ header file <random> offers a true random number generator in the class random_device. In principle, there is no guarantee that random_device produces true random numbers, as that is up to the implementation. However, with the GCC compiler, random_device should be a true random number generator on Windows and Linux-based operating systems.

Since random_device is a true random number generator, it has a limited pool of entropy, and its performance is significantly degraded once that pool is exhausted. Therefore, unless we only need to generate very few numbers, we generally do not use it to generate random numbers directly. Instead, we usually use it to seed a deterministic pseudo-random number generator.

In old programs, people often used the computer's clock as a seed, but that meant if two people ran the program at the same exact moment, they would get the same exact sequence of random numbers. When using random_device to seed a deterministic generator, you guarantee that the results will be unpredictable, since even though they are deterministic, the initial seed is truly random.

To use random_device, we declare a new object:

random_device name;

Once we create it, we can use the following member functions:

  • The () operator, i.e. name(), returns the next random number in the sequence. Usually, you will only use this once or twice.
  • min and max return the minimum and maximum values that can be generated. As the generated values will be an unsigned 32-bit integer, min is always 0 and max is always 4294967295 (or 232-1).
  • entropy, in principle, returns an estimate of the bits of entropy left in the device. This is a floating-point number between 0 and 32, with 0 indicating no entropy (i.e. the device is deterministic). However, in practice this number is usually meaningless. For example, GCC will always return a value of 0, while MSVC will always return 32, and in both cases this number is fixed and has nothing to do with the actual entropy of the generator.

We demonstrate how to use random_device in the following example:

#include <iostream>
#include <random>
using namespace std;

int main()
{
    random_device rd;
    cout << "rd.entropy() = " << rd.entropy() << '\n';
    cout << "rd.min() = " << rd.min() << '\n';
    cout << "rd.max() = " << rd.max() << '\n';
    cout << "3 random numbers: " << rd() << ", " << rd() << ", " << rd() << '\n';
}

Sample output:

rd.entropy() = 0
rd.min() = 0
rd.max() = 4294967295
3 random numbers: 2795223888, 78377749, 1584923679

6.2.2 Pseudo-random number generation ^

As we said above, random_device should be used to seed a pseudo-random number generator. <random> provides a variety of such generators, but the most commonly used one - in C++ and in general - is the Mersenne Twister algorithm, accessed via the class mt19937 for unsigned 32-bit integers or mt19937_64 for unsigned 64-bit integers.

To use mt19937, we declare a new object and give the seed (from random_device) as an argument to the constructor:

mt19937 mt(seed);

Once we create it, we can use the following member functions:

  • The () operator, i.e. mt(), returns the next random number in the sequence. You will use this every time you need a new random number.
  • min and max return the minimum and maximum values that can be generated. min is always 0, but mt19937 has a max value of 4294967295 (or 232-1) and mt19937_64 has a max value of 18446744073709551615 (or 264-1).
  • seed can be used to re-seed the generator, or provide the initial seed if we declared it without passing an argument to the constructor. Usually there is no need to use this function, since the generator only needs to be seeded once, and that is best done via the constructor, otherwise you might forget to do it later.

We demonstrate how to use mt19937 in the following example:

#include <iostream>
#include <random>
using namespace std;

int main()
{
    random_device rd;

    mt19937 mt32(rd());
    cout << "mt32.min() = " << mt32.min() << '\n';
    cout << "mt32.max() = " << mt32.max() << '\n';
    cout << "3 random numbers: " << mt32() << ", " << mt32() << ", " << mt32() << '\n'
         << '\n';

    mt19937_64 mt64(rd());
    cout << "mt64.min() = " << mt64.min() << '\n';
    cout << "mt64.max() = " << mt64.max() << '\n';
    cout << "3 random numbers: " << mt64() << ", " << mt64() << ", " << mt64() << '\n';
}

Sample output:

t32.min() = 0
mt32.max() = 4294967295
3 random numbers: 3365933155, 1249942142, 4161793284

mt64.min() = 0
mt64.max() = 18446744073709551615
3 random numbers: 14228424373019279719, 7136662969432099652, 6887900356391559377
Warning: If you create a pseudo-random number generator with mt19937 and do not seed it, either by passing an argument to the constructor or using the seed member function, it will generate the same numbers every time you run the program. The same will happen if you seed it using a fixed value instead of a value that changes every time the program runs. The best way to avoid this problem is to always pass a seed generated by random_device to the constructor, as in the example I provided here.

Here is a demonstration of the consequences of not adhering to the warning above:

#include <iostream>
#include <random>
using namespace std;

int main()
{
    mt19937 unseeded_1;
    mt19937 unseeded_2;
    // Both generators will be seeded with some fixed *default* value.
    // The same number will be generated by both every time the program runs.
    cout << "First number from unseeded_1: " << unseeded_1() << '\n';
    cout << "First number from unseeded_2: " << unseeded_2() << '\n';

    mt19937 fixed_seed_1(0);
    mt19937 fixed_seed_2(0);
    // Both generators will be seeded with a fixed *specific* value, namely 0.
    // Again, the same number will be generated by both every time the program runs.
    cout << "First number from fixed_seed_1: " << fixed_seed_1() << '\n';
    cout << "First number from fixed_seed_2: " << fixed_seed_2() << '\n';
}

The following output will be generated every time you run this program:

First number from unseeded_1: 3499211612
First number from unseeded_2: 3499211612
First number from fixed_seed_1: 2357136044
First number from fixed_seed_2: 2357136044

6.2.3 Probability distributions ^

As we have seen, mt19937 and mt19937_64 always generate numbers in the ranges 0 to 232-1 and 0 to 264-1 respectively. Generating numbers in a different range can be achieved using the uniform_int_distribution class:

uniform_int_distribution<type> name(min, max);

This distribution will generate values in the range [min, max] (inclusive), uniformly distributed. type can be omitted, in which case it defaults to int, but as always, it is best to be specific for maximum readability and portability. We can use the following member functions:

  • The () operator with a generator as an argument, i.e. name(generator), returns the next random number in the sequence. You will use this every time you need a new random number. Usually, the generator will be an mt19937 object.
  • min and max return the minimum and maximum values that can be generated. There are the same values you entered when you created the object.

For example:

#include <iostream>
#include <random>
using namespace std;

int main()
{
    random_device rd;
    mt19937 mt(rd());
    uniform_int_distribution<int64_t> uid(-10, 10);
    cout << "uid.min() = " << uid.min() << '\n';
    cout << "uid.max() = " << uid.max() << '\n';
    cout << "3 random numbers: " << uid(mt) << ", " << uid(mt) << ", " << uid(mt) << '\n';
}

Possible output:

uid.min() = -10
uid.max() = 10
3 random numbers: 6, 5, -8
Warning: The type passed to uniform_int_distribution must match the desired range. For example, if the type is uint32_t and the range contains negative integers, or exceeds 232-1, this would lead to unexpected behavior.

<random> provides a wide range of other probability distributions that we can use, with the same syntax. These include, for example:

  • uniform_real_distribution<type>(min, max): a uniform distribution of real (or more precisely, floating-point) numbers in the specified range.
  • normal_distribution<type>(mean, std_dev): a normal (or Gaussian) distribution with the specified mean and standard deviation.

In both cases, type must be a floating-point type (double by default). We illustrate these two distributions in the following example:

#include <iostream>
#include <random>
#include <vector>
using namespace std;

int main()
{
    vector<double> v(100);
    random_device rd;
    mt19937 mt(rd());

    cout << "uniform_real_distribution(-25, 25):\n";
    uniform_real_distribution<double> urd(-25, 25);
    for (double &i : v)
        i = urd(mt);
    sort(v.begin(), v.end());
    for (const double &i : v)
        cout << round(i * 10) / 10 << ' ';

    cout << "\n\n";
    cout << "normal_distribution(0, 10):\n";
    normal_distribution<double> nd(0, 10);
    for (double &i : v)
        i = nd(mt);
    sort(v.begin(), v.end());
    for (const double &i : v)
        cout << round(i * 10) / 10 << ' ';
}

Here I used sort() to sort the generated numbers; I will explain how that works later. A possible output is:

uniform_real_distribution(-25, 25):
-24.8 -24 -23.9 -23.3 -22.2 -21.6 -21.2 -21.1 -20.7 -20.6 -20.5 -20.1 -19.8 -19.8 -19.7 -19.5 -19 -18.7 -18.5 -18.3 -18 -17.8 -16.9 -16 -15.5 -14.9 -14.9 -13.9 -12.8 -12.3 -11.2 -11.1 -11 -10.9 -10.5 -10.3 -10.2 -10.2 -10 -8.4 -8 -6.7 -5.2 -5 -4.6 -4.4 -3 -2.6 -2.3 -2.1 -2 -1.8 -0.9 0.3 0.5 1.3 1.6 2.9 3.2 3.8 3.8 3.9 4 4.4 5.5 5.9 6.6 6.7 7.8 7.9 8.2 8.9 9 9.8 10.4 10.5 10.6 12.1 13.4 13.4 13.5 14 14.4 14.8 14.8 16.7 16.8 17.1 17.1 17.3 17.4 17.6 17.6 18.1 18.3 18.8 19.6 21 21 22.8

normal_distribution(0, 10):
-30.4 -25.3 -24.4 -17.9 -15.6 -13.7 -13.5 -12.9 -12.3 -11.3 -11.3 -11.2 -10.4 -10.3 -8.9 -8.6 -7.7 -7.5 -7.4 -6.7 -6.7 -6.6 -6.4 -6.4 -5.9 -5.7 -5.4 -4.9 -4.9 -4.6 -4.5 -4.3 -4.2 -4.2 -4.2 -4 -3.6 -3.2 -1.9 -1.9 -1.8 -1.7 -1.6 -1.2 -1.1 -0.9 -0.8 -0.7 -0.3 0.4 0.4 0.7 0.9 0.9 1 1.1 1.2 1.3 2.3 2.6 2.7 2.8 2.9 3.2 3.5 3.8 4.5 4.7 4.7 4.8 4.9 5.5 5.8 6.1 6.2 6.3 6.3 6.4 6.6 7.3 7.3 7.4 7.8 7.9 8.1 8.5 8.8 8.9 9.1 9.3 9.5 10.4 11.8 12.4 14.3 16.6 17.8 18.6 20.2 32.6

A full list of the available distributions may be found in the C++ reference and Microsoft's C++ reference.

7 Templates and the standard template library ^

7.1 Templates

7.1.1 Introduction to templates

C++ is a strongly-typed language. This means that all variables must have a specific type at compilation time. Indeed, so far, we have always defined classes and functions with variables of specific data types. For example, our matrix class only stores elements of type double, and its member functions and overloaded operators only take and/or return values of type double. But what if we want to use other data types such as float, long double, int, complex, or even our own user-defined types instead - all using the same code?

This can be done using templates. Essentially, a template is just what its name suggests: it's a template for making a generic class or a function, which is not defined for any particular data type. Later, we choose a data type, and the compiler automatically generates a class or a function that uses that data type. The template simply specifies the operations on the variables, but it does not care about their data type.

In fact, we have already been using templates. As I mentioned above in passing, the standard library vector is not a class, but a template, and when we write vector<type>, with type being any fundamental or user-defined data type, the compiler generates a specific class for vectors which store data of that type.

Templates are declared using the following syntax:

template <typename T>

typename is a special keyword which declares a "variable" that "stores" a data type as its "value". Here we used T for the name of that "variable" (as is the convention), but you can use any name you want. Any instance of the placeholder T in the function or class definition that follows this statement will be replaced with a concrete data type, either specified by the user or deduced by the compiler.

We could also write class instead of typename, and the two keywords are completely equivalent; the reason typename is preferred is because it makes it clearer that the type does not have to be a class - it can also be a fundamental type like double or int.

As an example, consider a function maximum that takes two int64_t arguments and returns the larger of the two:

#include <iostream>
using namespace std;

int64_t maximum(const int64_t &a, const int64_t &b)
{
    if (a > b)
        return a;
    else
        return b;
}

int main()
{
    cout << maximum(4, 3); // 4
}

Now, let us convert it into a generic template:

#include <iostream>
using namespace std;

template <typename T>
T maximum(const T &a, const T &b)
{
    if (a > b)
        return a;
    else
        return b;
}

int main()
{
    cout << maximum(4.5, 3.7); // 4.5
}

We added the modifier template <typename T>, and replaced int64_t with T whenever it appears. Then, when we called maximum with two doubles, the compiler automatically deduced that T should be replaced with double in the function's definition. We can also explicitly tell the compiler what T should be replaced with, using angled brackets (as with vector):

cout << maximum<double>(4.5, 3.7); // 4.5

Note that T can be any type, including user-defined classes - it doesn't have to be a numeric type. However, in this example, since maximum uses the > operator, the class T must have the > operator overloaded, or you will get a compilation error.

Template parameters behave much like function arguments. You can specify more than one type parameter:

template <typename T1, typename T2>

And you can even specify default arguments:

template <typename T1, typename T2 = double>

Furthermore, you can specify additional parameters that are not typename - as long as they are known at compilation time. For example, here is a template for a class that stores a vector of the size given by the parameter L, and initializes it to the value given in the constructor:

#include <iostream>
#include <vector>
using namespace std;

template <typename T, uint64_t L>
class vector_of_value
{
public:
    vector_of_value(const T &x)
    {
        zeros = vector<T>(L, x);
    }

    void print() const
    {
        for (const T &i : zeros)
            cout << i << ' ';
    }

private:
    vector<T> zeros;
};

int main()
{
    vector_of_value<double, 100> v(7);
    v.print();
}

7.1.2 Function template example: the vector operator overloads ^

Above we defined a collection of useful operator overloads for the vector class. However, we only defined them for vector<double>. In order to illustrate function templates, let us now expand this collection to work with vectors of any type using templates.

We originally wrote the collection of vector overloads as a header-only library, instead of separating it into an .hpp and a .cpp file. This is good, because templates must be provided in a header file. The compiler creates functions for specific types from the template on-the-fly at compilation time, so it needs to know which data types to do that for - but that information is in main.cpp, which makes use of specific instances of the templates.

Therefore, we cannot put the overload templates in a separate vector_overloads.cpp file, since that file would need to be compiled separately, and the compiler can't know in advance which instances of the templates need to be compiled. Hence, the templates must be in a single vector_overloads.hpp file, which will then be included in its entirety in the code of main.cpp by the compiler at complication time.

To convert the overloads into templates, we make two straightforward modifications to the code: we add template <typename T> before each of the overloads, and we replace every instance of double with T. Also, we take this opportunity to replace the exceptions in the original code with own custom exceptions, since now we know how to do that.

The end result will be the following vector_overloads.hpp file:

#include <iostream>
#include <stdexcept>
#include <vector>
using namespace std;

namespace vector_overloads
{
class add_different_sizes : public invalid_argument
{
public:
    add_different_sizes() : invalid_argument("Cannot add vectors of different sizes!"){};
};

class subtract_different_sizes : public invalid_argument
{
public:
    subtract_different_sizes() : invalid_argument("Cannot subtract vectors of different sizes!"){};
};

class dot_different_sizes : public invalid_argument
{
public:
    dot_different_sizes() : invalid_argument("Cannot take the dot product of vectors of different sizes!"){};
};
} // namespace vector_overloads

template <typename T>
ostream& operator<<(ostream& out, const vector<T>& vec)
{
    out << '(';
    for (size_t i = 0; i < vec.size() - 1; ++i)
        out << vec[i] << ", ";
    out << vec[vec.size() - 1] << ')';
    return out;
}

template <typename T>
bool operator==(const vector<T>& lhs, const vector<T>& rhs)
{
    if (lhs.size() != rhs.size())
        return false;
    for (size_t i = 0; i < lhs.size(); ++i)
        if (lhs[i] != rhs[i])
            return false;
    return true;
}

template <typename T>
bool operator!=(const vector<T>& lhs, const vector<T>& rhs)
{
    return !(lhs == rhs);
}

template <typename T>
vector<T>& operator+=(vector<T>& lhs, const vector<T>& rhs)
{
    if (lhs.size() != rhs.size())
        throw vector_overloads::add_different_sizes();
    for (size_t i = 0; i < lhs.size(); ++i)
        lhs[i] += rhs[i];
    return lhs;
}

template <typename T>
vector<T> operator+(vector<T> lhs, const vector<T>& rhs)
{
    lhs += rhs;
    return lhs;
}

template <typename T>
vector<T>& operator-=(vector<T>& lhs, const vector<T>& rhs)
{
    if (lhs.size() != rhs.size())
        throw vector_overloads::subtract_different_sizes();
    for (size_t i = 0; i < lhs.size(); ++i)
        lhs[i] -= rhs[i];
    return lhs;
}

template <typename T>
vector<T> operator-(vector<T> lhs, const vector<T>& rhs)
{
    lhs -= rhs;
    return lhs;
}

template <typename T>
vector<T> operator-(vector<T> vec)
{
    for (size_t i = 0; i < vec.size(); ++i)
        vec[i] = -vec[i];
    return vec;
}

template <typename T>
T operator*(const vector<T>& lhs, const vector<T>& rhs)
{
    if (lhs.size() != rhs.size())
        throw vector_overloads::dot_different_sizes();
    T result = 0;
    for (size_t i = 0; i < lhs.size(); ++i)
        result += lhs[i] * rhs[i];
    return result;
}

template <typename T>
vector<T>& operator*=(vector<T>& lhs, const T rhs)
{
    for (size_t i = 0; i < lhs.size(); ++i)
        lhs[i] *= rhs;
    return lhs;
}

template <typename T>
vector<T> operator*(vector<T> lhs, const T rhs)
{
    lhs *= rhs;
    return lhs;
}

template <typename T>
vector<T> operator*(const T lhs, vector<T> rhs)
{
    rhs *= lhs;
    return rhs;
}

Notice that I put all the exceptions inside the namespace vector_overloads to avoid any potential collisions with names defined elsewhere in the program.

We can test this file with the same sample main.cpp file as we had before, for example replacing double with long double:

#include "vector_overloads.hpp"
#include <iostream>
#include <stdexcept>
#include <vector>
using namespace std;

int main()
{
    try
    {
        vector<long double> v = {1, 2, 3};
        vector<long double> w = {4, 5, 6};
        vector<long double> u = {1, 1, 1};
        cout << v + w << '\n';    // (5, 7, 9)
        cout << v * w << '\n';    // 32
        cout << -v << '\n';       // (-1, -2, -3)
        v += w;                   //
        cout << v << '\n';        // (5, 7, 9)
        cout << v - w << '\n';    // (1, 2, 3)
        w -= u;                   //
        cout << w << '\n';        // (3, 4, 5)
        cout << 2.0L * v << '\n'; // (10, 14, 18)
        cout << v * 3.0L << '\n'; // (15, 21, 27)
    }
    catch (const invalid_argument& e)
    {
        cout << "Error: " << e.what() << '\n';
    }
}

Note that for multiplication by a scalar, we had to explicitly write 2.0L * v to ensure that 2.0L is interpreted as a long double, since 2 * v would have been interpreted as multiplying an int with a vector of long double and 2.0 * v would have been interpreted as multiplying a double with a vector of long double, neither of which have operator overloads defined for them.

If we wanted to avoid this, we could allow multiplication of a vector by a scalar of a different type by replacing the operator* overloads with:

template <typename T_scalar, typename T_vector>
vector<T_vector>& operator*=(vector<T_vector>& lhs, const T_scalar rhs)
{
    for (size_t i = 0; i < lhs.size(); ++i)
        lhs[i] *= rhs;
    return lhs;
}

template <typename T_scalar, typename T_vector>
vector<T_vector> operator*(vector<T_vector> lhs, const T_scalar rhs)
{
    lhs *= rhs;
    return lhs;
}

template <typename T_scalar, typename T_vector>
vector<T_vector> operator*(const T_scalar lhs, vector<T_vector> rhs)
{
    rhs *= lhs;
    return rhs;
}

Now 2 * v and v * 3 will work. However, this would mean that, for example, vector{3, 3} * 2.5 would result in the vector (7, 7), because the output vector takes its type from the type of the input vector, and 3 is interpreted as int by default! This is probably not what you intended to get, and could lead to bugs, therefore I do not recommend doing this. (But note that if you enable the -Wconversion compiler flag, you will get a warning when something like this happens.)

Similarly, for addition to work, the two vectors must have the same type. We could define:

template <typename T1, typename T2>
vector<T1>& operator+=(vector<T1>& lhs, const vector<T2>& rhs)
{
    if (lhs.size() != rhs.size())
        throw vector_overloads::add_different_sizes();
    for (size_t i = 0; i < lhs.size(); ++i)
        lhs[i] += rhs[i];
    return lhs;
}

template <typename T1, typename T2>
vector<T1> operator+(vector<T1> lhs, const vector<T2>& rhs)
{
    lhs += rhs;
    return lhs;
}

However, this would mean that, for example, vector{1, 1} + vector{1.9, 1.9} would result in the vector (2, 2), since 1 is interpreted as int and thus T1 is int, but T1 is the type of the output vector. Again, this is most likely not what you intended. Personally, I would prefer to be restricted to always adding two vectors of the same type in order to ensure such errors cannot possibly happen. Generally, mixing types in C++ is never a good idea unless you are 100% sure you know what you're doing and you explicitly cast from one type to another.

One potential solution to this problem could be to define specific overloads which produce a vector of the correct type, for example a specific overload for adding a vector<int32_t> with a vector<double> which will explicitly return a vector<double>. However, this means we would have to explicitly write overloads for all possible combinations, which is the exact opposite of what templates are meant to achieve.

7.1.3 Class template example: the matrix class again ^

Above we defined the matrix class with double elements. Let us now generalize it to a template. Here are the changes we made:

  1. We placed all of the code in one header file, matrix.hpp, so that the complete template implementation is accessible to the compiler (for reasons explained in the previous section).
  2. We added template <typename T> in front of the class definition and the definitions of the operator overloads.
  3. We replaced double with T everywhere.
  4. We replaced matrix with matrix<T> in all function arguments and return types.
  5. We replaced the exceptions with derived exceptions, and replaced all throw statements so that they throw objects constructed in place instead of the static objects we had before (by simply adding () after the object name).
  6. In the operator overloads, we added the keyword typename before each exception in the throw statement. This is needed because the exceptions are defined as part of a template.
  7. Since now we know about friend functions, we defined some of the overloaded operators as friends, to enable easier access to private members. This isn't strictly necessary in this case, but is more convenient, less verbose, and also faster since there are fewer function calls (e.g. instead of calling get_rows() we can just access rows directly, instead of calling operator() we can just access elements directly). However, operators which do not access any members, such as operator+(), don't need to be friends.

The file matrix.hpp now takes the form:

#include <initializer_list>
#include <iostream>
#include <stdexcept>
#include <vector>
using namespace std;

template <typename T>
class matrix
{
public:
    // Constructor to create a zero matrix.
    // First argument: number of rows.
    // Second argument: number of columns.
    matrix(const size_t rows_, const size_t cols_) : rows(rows_), cols(cols_)
    {
        if (rows == 0 or cols == 0)
            throw zero_size();
        elements = vector<T>(rows * cols);
    }

    // Constructor to create a diagonal matrix from a vector.
    // Argument: a vector containing the elements on the diagonal.
    // Number of rows and columns is inferred automatically.
    matrix(const vector<T>& diagonal_) : rows(diagonal_.size()), cols(diagonal_.size())
    {
        if (rows == 0)
            throw zero_size();
        elements = vector<T>(rows * cols);
        for (size_t i = 0; i < rows; ++i)
            elements[(cols * i) + i] = diagonal_[i];
    }

    // Constructor to create a diagonal matrix from an initializer_list.
    // Argument: an initializer_list containing the elements on the diagonal.
    // Number of rows and columns is inferred automatically.
    matrix(const initializer_list<T>& diagonal_) : matrix(vector<T>(diagonal_)) {}

    // Constructor to create a matrix from a vector.
    // First argument: number of rows.
    // Second argument: number of columns.
    // Third argument: a vector containing the elements in row-major order.
    matrix(const size_t rows_, const size_t cols_, const vector<T>& elements_) : rows(rows_), cols(cols_), elements(elements_)
    {
        if (rows == 0 or cols == 0)
            throw zero_size();
        if (elements_.size() != rows * cols)
            throw initializer_wrong_size();
    }

    // Constructor to create a matrix from an initializer_list.
    // First argument: number of rows.
    // Second argument: number of columns.
    // Third argument: an initializer_list containing the elements in row-major order.
    matrix(const size_t rows_, const size_t cols_, const initializer_list<T>& elements_) : matrix(rows_, cols_, vector<T>(elements_)) {}

    // Member function to obtain (but not modify) the number of rows in the matrix.
    size_t get_rows() const
    {
        return rows;
    }

    // Member function to obtain (but not modify) the number of columns in the matrix.
    size_t get_cols() const
    {
        return cols;
    }

    // Overloaded operator () to access matrix elements WITHOUT range checking.
    // The indices start from 0: m(0, 1) would be the element at row 1, column 2.
    // Non-const version: allows modification of the element.
    T& operator()(const size_t row, const size_t col)
    {
        return elements[(cols * row) + col];
    }

    // Overloaded operator () to access matrix elements WITHOUT range checking.
    // The indices start from 0: m(0, 1) would be the element at row 1, column 2.
    // const version: does not allow modification of the element.
    const T& operator()(const size_t row, const size_t col) const
    {
        return elements[(cols * row) + col];
    }

    // Member function to access matrix elements WITH range checking.
    // The indices start from 0: m.at(0, 1) would be the element at row 1, column 2.
    // Non-const version: allows modification of the element.
    T& at(const size_t row, const size_t col)
    {
        if ((row > rows - 1) or (col > cols - 1))
            throw out_of_range();
        return elements[(cols * row) + col];
    }

    // Member function to access matrix elements WITH range checking.
    // The indices start from 0: m.at(0, 1) would be the element at row 1, column 2.
    // const version: does not allow modification of the element.
    const T& at(const size_t row, const size_t col) const
    {
        if ((row > rows - 1) or (col > cols - 1))
            throw out_of_range();
        return elements[(cols * row) + col];
    }

    // Overloaded binary operator += to add another matrix to this matrix.
    matrix<T>& operator+=(const matrix<T>& other)
    {
        if ((rows != other.rows) or (cols != other.cols))
            throw incompatible_sizes_add();
        for (size_t i = 0; i < rows * cols; ++i)
            elements[i] += other.elements[i];
        return *this;
    }

    // Overloaded binary operator -= to subtract another matrix from this matrix.
    matrix<T>& operator-=(const matrix<T>& other)
    {
        if ((rows != other.rows) or (cols != other.cols))
            throw incompatible_sizes_add();
        for (size_t i = 0; i < rows * cols; ++i)
            elements[i] -= other.elements[i];
        return *this;
    }

    // Overloaded binary operator *= to multiply this matrix by a scalar.
    matrix<T>& operator*=(const T scalar)
    {
        for (size_t i = 0; i < rows * cols; ++i)
            elements[i] *= scalar;
        return *this;
    }

    // Overloaded binary operator << to easily print out a matrix to a stream.
    friend ostream& operator<<(ostream& out, const matrix<T>& mat)
    {
        for (size_t i = 0; i < mat.rows; ++i)
        {
            out << "( ";
            for (size_t j = 0; j < mat.cols; ++j)
                out << mat(i, j) << '\t';
            out << ")\n";
        }
        return out;
    }

    // Overloaded binary operator == to compare two matrices.
    friend bool operator==(const matrix<T>& lhs, const matrix<T>& rhs)
    {
        if ((lhs.rows != rhs.rows) or (lhs.cols != rhs.cols))
            return false;
        for (size_t i = 0; i < lhs.rows * rhs.cols; ++i)
            if (lhs.elements[i] != rhs.elements[i])
                return false;
        return true;
    }

    // Overloaded binary operator != to compare two matrices.
    friend bool operator!=(const matrix<T>& lhs, const matrix<T>& rhs)
    {
        return !(lhs == rhs);
    }

    // Overloaded unary operator - to take the negative of a matrix.
    friend matrix<T> operator-(matrix<T> mat)
    {
        for (size_t i = 0; i < mat.rows * mat.cols; ++i)
            mat.elements[i] = -mat.elements[i];
        return mat;
    }

    // Overloaded binary operator * to multiply two matrices.
    friend matrix<T> operator*(const matrix<T>& lhs, const matrix<T>& rhs)
    {
        if (lhs.cols != rhs.rows)
            throw incompatible_sizes_multiply();
        matrix<T> c(lhs.rows, rhs.cols);
        for (size_t i = 0; i < lhs.rows; ++i)
            for (size_t j = 0; j < rhs.cols; ++j)
                for (size_t k = 0; k < lhs.cols; ++k)
                    c(i, j) += lhs(i, k) * rhs(k, j);
        return c;
    }

    // Exception to be thrown if the number of rows or columns given to the constructor is zero.
    class zero_size : public invalid_argument
    {
    public:
        zero_size() : invalid_argument("Matrix cannot have zero rows or columns!"){};
    };

    // Exception to be thrown if the vector of elements provided to the constructor is of the wrong size.
    class initializer_wrong_size : public invalid_argument
    {
    public:
        initializer_wrong_size() : invalid_argument("Initializer does not have the expected number of elements!"){};
    };

    // Exception to be thrown if two matrices of different sizes are added or subtracted.
    class incompatible_sizes_add : public invalid_argument
    {
    public:
        incompatible_sizes_add() : invalid_argument("Cannot add or subtract two matrices of different dimensions!"){};
    };

    // Exception to be thrown if two matrices of incompatible sizes are multiplied.
    class incompatible_sizes_multiply : public invalid_argument
    {
    public:
        incompatible_sizes_multiply() : invalid_argument("Two matrices can only be multiplied if the number of columns in the first matrix is equal to the number of rows in the second matrix!"){};
    };

    // Exception to be thrown if trying to access an element out of range.
    class out_of_range : public invalid_argument
    {
    public:
        out_of_range() : invalid_argument("Tried to access an element out of range!"){};
    };

private:
    // The number of rows.
    size_t rows = 0;

    // The number of columns.
    size_t cols = 0;

    // A vector storing the elements of the matrix in flattened (1-dimensional) form.
    vector<T> elements;
};

// Overloaded binary operator + to add two matrices.
template <typename T>
matrix<T> operator+(matrix<T> lhs, const matrix<T>& rhs)
{
    lhs += rhs;
    return lhs;
}

// Overloaded binary operator - to subtract two matrices.
template <typename T>
matrix<T> operator-(matrix<T> lhs, const matrix<T>& rhs)
{
    lhs -= rhs;
    return lhs;
}

// Overloaded binary operator * to multiply a matrix on the left and a scalar on the right.
template <typename T>
matrix<T> operator*(matrix<T> lhs, const T rhs)
{
    lhs *= rhs;
    return lhs;
}

// Overloaded binary operator * to multiply a scalar on the left and a matrix on the right.
template <typename T>
matrix<T> operator*(const T lhs, matrix<T> rhs)
{
    rhs *= lhs;
    return rhs;
}

We can test this file with a main.cpp file similar to the one we had before, except that we now need to add the type name (here we chose to use long double) inside <> whenever we create a new matrix. We also had to replace 7 with 7.0L in 7 * B and so on, so that both the scalar and the matrix will be long double and will match the operator overload:

#include "matrix.hpp"
#include <exception>
#include <iostream>
#include <vector>
using namespace std;

int main()
{
    try
    {
        // Constructor with two integers: create a 3x4 matrix of zeros.
        matrix<long double> A(3, 4);
        cout << "A:\n" << A;
        // Constructor with one vector: create a 3x3 matrix with 1, 2, 3 on the diagonal.
        matrix<long double> B(vector<long double>{1, 2, 3});
        cout << "B:\n" << B;
        // Constructor with one initializer_list: create a 4x4 matrix with 1, 2, 3, 4 on the diagonal.
        matrix<long double> C{1, 2, 3, 4};
        cout << "C:\n" << C;
        // Constructor with two integers and one vector: create a 2x3 matrix with the given elements in row-major order.
        matrix<long double> D(2, 3, vector<long double>{1, 2, 3, 4, 5, 6});
        cout << "D:\n" << D;
        // Constructor with two integers and one initializer_list: create a 2x2 matrix with the given elements in row-major order.
        matrix<long double> E(2, 2, {1, 2, 3, 4});
        cout << "E:\n" << E;

        // Demonstration of some of the overloaded operators.
        D(0, 2) = 7;
        cout << "D after D(0, 2) = 7:\n" << D;
        matrix<long double> F = D * B;
        cout << "F = D * B:\n" << F;
        cout << "D + F:\n" << D + F;
        cout << "7.0L * B:\n" << 7.0L * B;
        matrix<long double> G(3, 3, {1, 0, 0, 0, 2, 0, 0, 0, 3});
        cout << "B == G: " << (B == G) << '\n';
        cout << "B == F: " << (B == F) << '\n';
        D *= 2.0L;
        cout << "D after D *= 2.0L:\n" << D;
        cout << "D * 3.0L:\n" << D * 3.0L;
        cout << "4.0L * D:\n" << 4.0L * D;

        // initializer_list constructor will be used: create a 2x2 diagonal matrix with 1, 2 on the diagonal.
        cout << "matrix{1, 2}:\n";
        cout << matrix<long double>{1, 2};
        // (size_t, size_t) constructor will be used: create a 1x2 zero matrix.
        cout << "matrix(1, 2):\n";
        cout << matrix<long double>(1, 2);

        // Demonstration of range checking; the range of D is 0-1 for rows and 0-2 for columns.
        cout << "Range checking:\n";
        cout << "D.at(0, 0): " << D.at(0, 0) << '\n';
        cout << "D.at(1, 0): " << D.at(1, 0) << '\n';
        cout << "D.at(2, 0): " << D.at(2, 0) << '\n'; // Error: Tried to access an element out of range!
    }
    catch (const exception& e)
    {
        cout << "Error: " << e.what() << '\n';
    }
}

7.1.4 The standard template library ^

The C++ standard template library (STL) consists of templates for a variety of different containers. Since they are templates, they can contain elements of any type. The containers in the STL can be divided into two groups:

  • Sequence containers store elements in a specific order. For each element, there is always a next element in the sequence. These containers include array, vector, deque, list, and forward_list.
  • Associative containers store elements in non-sequential order: the elements are accessed using keys. These containers include set, map, and unordered and/or multi variations of them.

We will discuss these containers in the next few sections. For more information, please see the C++ reference and Microsoft's C++ reference.

7.2 The array container: static (fixed-size) contiguous array ^

7.2.1 Introduction to STL arrays

An array, defined in the header file <array>, is essentially a C-style fixed-size array, with the exact same performance, but with some extra features on top. We declare an array with size elements of the data type type as follows:

array<type, size> name;

Note that size must be known at compilation time, since this a template. If type is a user-defined class, then the elements will be initialized using the constructor, but if it is just a fundamental type such as int or double, the elements will be uninitialized, as for a C-style array.

To initialize an array, we use the same syntax as for C-style arrays:

array<type, size> name = {element0, element1, ...};

If the number of elements in the list is less than the size of the array, and the array is of a numeric type, then the remaining elements will be initialized to zero. So {0} will initialize the entire array to zero. Note that the member function fill can be used to assign the same value to all the elements in the array.

This is demonstrated by the following program:

#include <array>
#include <iostream>
using namespace std;

// Print the elements of an array. Note that this function must be a template, since array is a template.
template <typename T, uint64_t s>
void print_elements(const array<T, s> &a)
{
    for (const T &i : a)
        cout << i << ' ';
    cout << '\n';
}

int main()
{
    // WARNING: Uninitialized array! Elements will have garbage values.
    array<uint32_t, 10> a;
    print_elements(a);
    // Initialize all elements to zero.
    array<uint32_t, 10> b = {0};
    print_elements(b);
    // Initialize only the first three elements, the rest will be initialized to zero.
    array<uint32_t, 10> c = {1, 2, 3};
    print_elements(c);
    // Initialize all elements to specific values.
    array<uint32_t, 10> d = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    print_elements(d);
    // Create an uninitialized array and fill it with 7's.
    array<uint32_t, 10> e;
    e.fill(7);
    print_elements(e);
}

Sample output:

1937765920 134 1856969352 32758 2362293616 627 0 1 0 0
0 0 0 0 0 0 0 0 0 0
1 2 3 0 0 0 0 0 0 0
1 2 3 4 5 6 7 8 9 10
7 7 7 7 7 7 7 7 7 7

The elements of an array, just like the elements of a C-style array, are stored contiguously in memory, i.e. one after the other in sequence. This means that the memory can be accessed directly using the [] operator as for C-style arrays, and there is no performance penalty when accessing elements (which would have been the case if accessing each element required first figuring out where in memory it is located).

The number of elements in the array is given by the member function size. If the array is empty, then the member function empty will return true. The actual memory address where the array elements are stored can be obtained using the member function data, which returns a pointer that can be used in exactly the same way as a C-style array.

The member functions front and back can be used to access the first and last element respectively, so a.front() is equivalent to a[0] and a.back() is equivalent to a[a.size() - 1], where a refers to an array object.

The elements can also be accessed using the member function at, which throws the exception std::out_of_range if you try to access an out-of-range element - at the cost of a small performance overhead, since it has to check if the element is in range every time it is called. Using at is safer than using [], but on the other hand, usually there is no ambiguity regarding the number of elements contained in the array (since it's a fixed-size array), so in most cases you can just use [] instead.

This is demonstrated in the following program:

#include <array>
#include <iostream>
using namespace std;

int main()
{
    array<uint32_t, 5> a = {2, 4, 8, 16, 32};

    cout << "First element: " << a.front() << '\n';
    cout << "Second element: " << a[1] << '\n';
    cout << "Third element: " << a.at(2) << '\n';
    cout << "Fourth element: " << a.data()[3] << '\n';
    cout << "Last element: " << a.back() << '\n';

    try
    {
        cout << "Sixth element: " << a.at(5) << '\n';
    }
    catch (const out_of_range &e)
    {
        cout << "N/A\n";
    }

    cout << "Size: " << a.size() << '\n';
    cout << "Empty: " << boolalpha << a.empty() << '\n';
}

Output:

First element: 2
Second element: 4
Third element: 8
Fourth element: 16
Last element: 32
Sixth element: N/A
Size: 5
Empty: false

Two array objects can be compared with each other using (overloaded) comparison operators, but only if both arrays have the same number of elements. == and != check if the arrays have exactly the same elements. Inequality operators such as < or >= compare the elements lexicographically. For example:

#include <array>
#include <iostream>
using namespace std;

int main()
{
    array<uint32_t, 5> a = {2, 4, 8, 16, 32};
    array<uint32_t, 5> b = {2, 4, 8, 16, 32};
    array<uint32_t, 5> c = {2, 4, 8, 16, 33};
    array<uint32_t, 5> d = {2, 4, 8, 15, 32};

    cout << boolalpha;
    cout << "a == b: " << (a == b) << '\n';
    cout << "a < b: " << (a < b) << '\n';
    cout << "a > b: " << (a > b) << '\n';
    cout << "a == c: " << (a == c) << '\n';
    cout << "a < c: " << (a < c) << '\n';
    cout << "a > c: " << (a > c) << '\n';
    cout << "a == d: " << (a == d) << '\n';
    cout << "a < d: " << (a < d) << '\n';
    cout << "a > d: " << (a > d) << '\n';
}

Output:

a == b: true
a < b: false
a > b: false
a == c: false
a < c: true
a > c: false
a == d: false
a < d: false
a > d: true

Finally, as of C++20, if you have an old-fashioned C-style array, you can convert it to a more convenient STL array using the member function to_array. The type and size are inferred automatically:

#include <array>
#include <iostream>
using namespace std;

int main()
{
    uint32_t C_array[] = {2, 4, 8, 16, 32};
    array a = to_array(C_array);
    for (const uint32_t &i : a)
        cout << i << ' ';
    cout << '\n';
}

7.2.2 Iterators ^

An iterator is a pointer-like object that points to a specific element in any STL container. Once you have an iterator, you can:

  • Access the element it points to by dereferencing it with the * operator.
  • Increment it to the next element using the ++ operator.
  • Compare it with another iterator using the == or != operators to see if both point to the same element.

These three operators are the bare minimum supported by iterators for all containers. They are described by the category LegacyInputIterator. However, many containers have iterators that support additional operations, depending on the type of the container. In particular, array has iterators that belong to the category LegacyRandomAccessIterator. With such an iterator, you can also:

  • Decrement it to the previous element using the -- operator.
  • Add a positive integer n to it using the + operator to skip n elements forwards.
  • Subtract a positive integer n from it using the - operator to skip n elements backwards.
  • Compare it with another iterator using the < or > operators to see which iterator points to an element that appears earlier in the sequence.

In addition, the operators +=, -=, <=, and >= act as expected, and i[n] is equivalent to *(i + n) (as for C-style arrays).

The member function begin returns an iterator to the first element of the array, while end returns an iterator to the element following the last element of the array. The member functions cbegin and cend do the same, except that they return iterators that do not allow modifying the elements. So for example, *a.begin() = 7 will change the first element of the array a to 7, but *a.cbegin() = 7 will result in a compilation error.

Here is an example:

#include <array>
#include <iostream>
using namespace std;

int main()
{
    array<uint32_t, 8> a = {0, 1, 2, 3, 4, 5, 6, 7};

    // The iterator b will point to the first element.
    array<uint32_t, 8>::iterator b = a.begin();
    cout << "Value of b:        " << b << '\n';
    cout << "Value of *b:       " << *b << '\n';
    cout << "Value of b + 1:    " << b + 1 << '\n';
    cout << "Value of *(b + 1): " << *(b + 1) << '\n';

    // The iterator e will point to the element following the last element.
    // Note that this element doesn't exist, so e should never be dereferenced!
    array<uint32_t, 8>::iterator e = a.end();
    cout << "Value of e:        " << e << '\n';
    cout << "Value of *e:       " << *e << '\n'; // WARNING: Will be the garbage value a[8]!
    cout << "Value of e - 1:    " << e - 1 << '\n';
    cout << "Value of *(e - 1): " << *(e - 1) << '\n';
}

Note that the type iterator lives in the namespace array<uint32_t, 8>. The output will be different every time, since both the memory addresses and the garbage value a[8] will change:

Value of b:        0x5ce9fff7b0
Value of *b:       0
Value of b + 1:    0x5ce9fff7b4
Value of *(b + 1): 1
Value of e:        0x5ce9fff7d0
Value of *e:       3925866448
Value of e - 1:    0x5ce9fff7cc
Value of *(e - 1): 7

Why do we need iterators if we can already access the elements of the array directly? Iterators are used in STL algorithms, which we will discuss below. The use of iterators allows these algorithms to be generic, since they can take iterators for any kind of container that supports iterators - even ones that are not sequential, and even those you define yourself (as long as you define proper iterators for them).

For example, in the following program, we use iterators to print the elements of the array:

#include <array>
#include <iostream>
using namespace std;

int main()
{
    array<uint32_t, 5> a = {2, 4, 8, 16, 32};
    for (array<uint32_t, 5>::iterator i = a.begin(); i != a.end(); i++)
        cout << *i << ' ';
}

This for loop will actually work on any kind of container that supports iterators, since it only uses the operators *, ++, and !=, which are defined for all types of iterators.

7.2.3 Performance and memory considerations with STL arrays ^

The array container is the simplest one - essentially, it's just a C-style array that knows its own size. Therefore, it provides the best performance (as long as you don't use at). However, it also has a significant disadvantage: since the size of the array is a template parameter, it must be known at compilation time. Therefore, you cannot use array to define an array whose size is only known at run time. For that, you must use vector.

Furthermore, array cannot be used for very large arrays. Recall that the stack is a small portion of memory used to store all of the variables for which memory has been automatically allocated at compilation time, while the heap is a large portion of memory used to store arrays and object which we dynamically allocate at run time.

It is important to note that an array allocates memory on the stack, just like a C-style array. This is in contrast with a vector, or the new operator, which allocate memory on the heap. Allocating and accessing memory on the heap is generally slower than on the stack, so an array is faster than a vector, and potentially even faster than a dynamically-allocated C-style array using new.

On the other hand, the stack is very small, typically only a few MB in size. Therefore, you may only use array for small arrays. For example, a double takes up 8 bytes, so an array<double, 1000000> will use 8 MB, which is more than the stack can hold on most operating systems. In such cases you must use vector (safest option) or allocate memory with new (fastest option) instead.

7.2.4 The vector operator overloads for arrays ^

As an example, let us convert the the vector operator overload templates to work with arrays. In doing so, we can now also take advantage of the fact that an array can be uninitialized to increase performance.

Recall that most of the vector overloads work by creating a new vector object and storing the results of the calculation in that object. However, vector initializes itself to zeros. So this means we actually write all the elements twice: once to initialize, and another time to populate with the actual values. Since an array can be uninitialized, we can effectively double the performance of these overloads (although this won't be noticeable unless you're adding very large arrays and/or a very large number of small arrays).

Furthermore, since the size of the array is part of the template type, we do not need to check that the sizes of two arrays match anymore - if they don't, then the compiler will simply not be able to find an appropriate template, as all of the templates in the code assume that both arrays have the same size, and your program won't compile. Therefore, we don't need to define or throw any exceptions in this case.

One final simplification is that we don't need to use the size() member function anymore to determine how far loops should go; this is already pre-determined at compilation time as one of the template parameters, which we will call N.

These overloads are thus both faster and simpler. The only downsize is that you have to know the size of the arrays at compilation time, and you cannot use arrays that are too big for the stack to handle. We will call the header file array_overloads.hpp:

#include <array>
#include <iostream>
using namespace std;

template <typename T, uint64_t N>
ostream &operator<<(ostream &out, const array<T, N> &a)
{
    out << '(';
    for (uint64_t i = 0; i < N - 1; i++)
        out << a[i] << ", ";
    out << a[N - 1] << ')';
    return out;
}

template <typename T, uint64_t N>
array<T, N> operator+(const array<T, N> &a, const array<T, N> &b)
{
    array<T, N> c;
    for (uint64_t i = 0; i < N; i++)
        c[i] = a[i] + b[i];
    return c;
}

template <typename T, uint64_t N>
T operator*(const array<T, N> &a, const array<T, N> &b)
{
    T p = 0;
    for (uint64_t i = 0; i < N; i++)
        p += a[i] * b[i];
    return p;
}

template <typename T, uint64_t N>
array<T, N> operator+=(array<T, N> &a, const array<T, N> &b)
{
    a = a + b;
    return a;
}

template <typename T, uint64_t N>
array<T, N> operator-(const array<T, N> &a)
{
    array<T, N> c;
    for (uint64_t i = 0; i < N; i++)
        c[i] = -a[i];
    return c;
}

template <typename T, uint64_t N>
array<T, N> operator-(const array<T, N> &a, const array<T, N> &b)
{
    array<T, N> c;
    for (uint64_t i = 0; i < N; i++)
        c[i] = a[i] - b[i];
    return c;
}

template <typename T, uint64_t N>
array<T, N> operator-=(array<T, N> &a, const array<T, N> &b)
{
    a = a - b;
    return a;
}

template <typename T, uint64_t N>
array<T, N> operator*(const T &s, const array<T, N> &a)
{
    array<T, N> c;
    for (uint64_t i = 0; i < N; i++)
        c[i] = s * a[i];
    return c;
}

template <typename T, uint64_t N>
array<T, N> operator*(const array<T, N> &a, const T &s)
{
    return s * a;
}

It can be checked with the following main.cpp:

#include <array>
#include <iostream>
#include <stdexcept>
#include "array_overloads.hpp"
using namespace std;

int main()
{
    array<double, 3> a = {1, 2, 3};
    array<double, 3> b = {4, 5, 6};
    array<double, 3> c = {1, 1, 1};
    cout << a + b << '\n';   // Prints "(5, 7, 9)"
    cout << a * b << '\n';   // Prints "32"
    cout << -a << '\n';      // Prints "(-1, -2, -3)"
    a += b;                  //
    cout << a << '\n';       // Prints "(5, 7, 9)"
    cout << a - b << '\n';   // Prints "(1, 2, 3)"
    b -= c;                  //
    cout << b << '\n';       // Prints "(3, 4, 5)"
    cout << 2.0 * a << '\n'; // Prints "(10, 14, 18)"
    cout << a * 3.0 << '\n'; // Prints "(15, 21, 27)"
}

7.3 The vector container: dynamic contiguous arrays ^

7.3.1 Introduction to STL vectors

vector is the most commonly used STL container. We have already utilized it several times in this course; we introduced it above, defined overloads for it, and used it in our matrix class template. Here we will take a deeper dive into the details of the vector container.

The vector container is essentially an extension of the array container which:

  1. Allows creating an array whose size is not known at compilation time,
  2. Allows dynamically resizing the array after it was created,
  3. Allocates and reallocates memory automatically on the heap as needed.

Since most of the arrays you are going to create will probably be of a size determined at run time (usually based on the program's input), you will probably use vector rather than array for most tasks which require an array.

As an extension of array, the vector container supports all of the member functions and operators we described above for array, including at, [], front, back, data, size, empty, begin/cbegin, end/cend, and comparison operators. However, it does not support the member function fill (since there's no notion of "filling" a variable-sized array), and you cannot use to_array (since it's not an array).

In addition to these, vector introduces new functionality that relates to its role as a dynamic array. First we have the member functions which modify the elements of the vector:

  • clear() clears the contents of the array and resets its size to zero.
  • push_back(value) appends value to the end of the array and increases its size by one.
  • pop_back() removes the last element of the array and decreases its size by one.
  • resize(n) resizes the array to n elements. If this decreases the size, any existing elements beyond the new size are discarded. If this increases the size, any new elements beyond the old size will have their default values (e.g. zero for numbers).

Next we have member functions related to the capacity of the vector:

  • max_size() returns the maximum possible number of elements the vector can theoretically store; this is usually of the order of 261 for 64-bit systems, although of course the actual maximum size will be limited by the amount of RAM available on the computer.
  • capacity() returns the number of elements for which the vector has allocated memory. This will typically be more then the size of the vector, for performance reasons. Resizing a vector is slow, so usually the vector prefers to preallocate more memory than is needed, in order to allow adding more elements later without spending time on reallocating memory.
  • shrink_to_fit() decreases the capacity of the vector to its current size, so that the space it takes up in memory is the minimum space required to hold all of the elements currently in the array. Of course, this means adding more elements in the future will be slower, so only use this when you know you will not be adding any more elements, or if you must prioritize memory usage over speed.
  • reserve(n) increases the capacity of the vector to n, allocating more memory if necessary, while not changing the actual size of the vector. Use this when you know you are going to be adding more and more elements gradually up to a known maximum size, to avoid having to reallocate memory multiple times as the elements are added, which could have a significant performance penalty.
Warning: reserve only reserves memory for additional elements - it does not initialize that memory. The only elements in the vector which are initialized are those in the first size places. If you try to access any elements beyond the vector's size, you will get garbage - even if you are still within the vector's capacity!

All of the above member functions are illustrated in the following program:

#include <cmath>
#include <iostream>
#include <vector>
using namespace std;

int main()
{
    vector<uint64_t> v;
    cout.precision(0);
    cout << "Maximum possible size: " << v.max_size()
         << " (approximately " << scientific << (double)v.max_size()
         << " or 2^" << fixed << log2(v.max_size()) << ").\n";

    constexpr uint64_t max_size = 1000;
    uint64_t c = 0;
    for (uint64_t i = 0; i < max_size; i++)
    {
        v.push_back(i);
        if (c != v.capacity())
        {
            c = v.capacity();
            cout << "At size " << v.size() << ", capacity increased to " << c << ".\n";
        }
    }
    cout << "At size " << v.size() << ", capacity is " << v.capacity() << ".\n";

    v.shrink_to_fit();
    cout << "After shrink_to_fit(), size is " << v.size() << " and capacity is " << v.capacity() << ".\n";
    v.pop_back();
    cout << "After pop_back(), size is " << v.size() << " and capacity is " << v.capacity() << ".\n";
    v.resize(2000);
    cout << "After resize(2000), size is " << v.size() << " and capacity is " << v.capacity() << ".\n";
    v.reserve(2222);
    cout << "After reserve(2222), size is " << v.size() << " and capacity is " << v.capacity() << ".\n";
    v.clear();
    cout << "After clear(), size is " << v.size() << " and capacity is " << v.capacity() << ".\n";
}

The output on my computer is:

Maximum possible size: 1152921504606846975 (approximately 1e+18 or 2^60).
At size 1, capacity increased to 1.
At size 2, capacity increased to 2.
At size 3, capacity increased to 4.
At size 5, capacity increased to 8.
At size 9, capacity increased to 16.
At size 17, capacity increased to 32.
At size 33, capacity increased to 64.
At size 65, capacity increased to 128.
At size 129, capacity increased to 256.
At size 257, capacity increased to 512.
At size 513, capacity increased to 1024.
At size 1000, capacity is 1024.
After shrink_to_fit(), size is 1000 and capacity is 1000.
After pop_back(), size is 999 and capacity is 1000.
After resize(2000), size is 2000 and capacity is 2000.
After reserve(2222), size is 2000 and capacity is 2222.
After clear(), size is 0 and capacity is 2222.

7.3.2 Iterators and iterator invalidation ^

vector contains member functions which modify the elements using iterators:

  • insert(pos, value) inserts value into the vector at the position given by the iterator pos, with all the elements following that position shifted forward to make space for the new element, so that the size of the vector increases by 1.
    • For example, v.insert(v.begin() + n, value) will insert value at element number n (counting from zero as usual), while v.insert(v.end() - n, value) will insert value at element number n from the end (with n == 0 the latter is equivalent to push_back(value)).
    • insert(pos, count, value) inserts count elements with the specified value.
    • insert(pos, {value1, value2, ...}) inserts all the values {value1, value2, ...} in order.
  • erase(pos) erases the element at the position given by the iterator pos, with all the elements following that position shifted backward, so that the size of the vector decreases by 1.
    • erase(first, last) erases all the elements starting from the iterator first and ending at the element before the iterator last, i.e. in the range [first, last).

These are illustrated below:

#include <iostream>
#include <vector>
using namespace std;

template <typename T>
void print_elements(const vector<T> &v)
{
    for (const T &i : v)
        cout << i << ' ';
    cout << '\n';
}

int main()
{
    vector<uint32_t> v = {1, 1, 1, 1, 1};
    cout << "Initial vector:                        ";
    print_elements(v);

    v.insert(v.begin(), 2);
    cout << "v.insert(v.begin(), 2):                ";
    print_elements(v);

    v.insert(v.end(), 3);
    cout << "v.insert(v.end(), 3):                  ";
    print_elements(v);

    v.insert(v.begin() + 2, 3, 4);
    cout << "v.insert(v.begin() + 2, 3, 4):         ";
    print_elements(v);

    v.insert(v.end() - 3, {5, 6, 7});
    cout << "v.insert(v.end() - 3, {5, 6, 7}):      ";
    print_elements(v);

    v.erase(v.begin());
    cout << "v.erase(v.begin()):                    ";
    print_elements(v);

    v.erase(v.begin() + 1, v.begin() + 4);
    cout << "v.erase(v.begin() + 1, v.begin() + 4): ";
    print_elements(v);
}

The output is:

Initial vector:                        1 1 1 1 1
v.insert(v.begin(), 2):                2 1 1 1 1 1
v.insert(v.end(), 3):                  2 1 1 1 1 1 3
v.insert(v.begin() + 2, 3, 4):         2 1 4 4 4 1 1 1 1 3
v.insert(v.end() - 3, {5, 6, 7}):      2 1 4 4 4 1 1 5 6 7 1 1 3
v.erase(v.begin()):                    1 4 4 4 1 1 5 6 7 1 1 3
v.erase(v.begin() + 1, v.begin() + 4): 1 1 1 5 6 7 1 1 3

It is important to note that after some operations on a vector, old iterators may no longer be valid. This is called iterator invalidation, and is a result of the fact that an iterator is a pointer to a particular address in memory, and some operations change where elements are stored in memory. Specifically, the operations that invalidate iterators are:

  • clear: Always invalidates all iterators, since it removes all of the elements.
  • reserve, shrink_to_fit: If the vector changed its capacity, all iterators are invalidated, since the elements may now be stored in a completely different location in memory.
  • erase: Invalidates iterators to the erased elements and all following elements.
  • push_back: If the vector changed its capacity, all iterators are invalidated. If not, only end is invalidated, since an element has been added at the end.
  • insert: If the vector changed its capacity, all iterators are invalidated. If not, only iterators to the elements at or after the insertion point are invalidated.
  • resize: If the vector changed its capacity, all iterators are invalidated. If not, only iterators to elements that were erased are invalidated.
  • pop_back: Invalidates iterators to the element that was erased and to end.

Read-only operations never invalidate any iterators.

Warning: Using invalidated iterators will lead to unexpected results. Always keep track of operations that may invalidate iterators, and redefine iterators if needed.

The following program illustrated iterator invalidation:

#include <iostream>
#include <vector>
using namespace std;

int main()
{
    vector<int32_t> v;
    vector<int32_t>::iterator b = v.begin();
    v.insert(b, 1);
    v.insert(b, 1); // WARNING: Will crash, since b has been invalidated!
}

7.3.3 Interlude: measuring performance with chrono ^

In the next section, I would like to compare the time it would take to populate vectors with values in different ways. For that, I need to introduce an accurate way of measuring the execution time of various operations. C++ provides this functionality with the chrono library, available in the header file <chrono>. Note that C++ also provides the time function from C, available in the header file <ctime>. However, time only measures time in whole seconds, so it cannot be used for accurate time measurements.

We will not discuss the full scope of the chrono library here; instead we will just mention the essential information we will need in order to measure execution time.

chrono includes the class chrono::steady_clock, which represents a monotonic clock. Other clocks (e.g. chrono::system_clock) are not monotonic, since the time on the clock may be freely adjusted, either automatically by the operating system (e.g. when synchronizing the clock with other computers) or manually by the user. This means that the value of the clock at a later time may turn out to actually correspond to an earlier time. In contrast, chrono::steady_clock is monotonic, which means it is guaranteed to never decrease, and thus it can be used to measure time intervals reliably.

chrono::steady_clock only has one member function: now, which returns the current value of the clock. The return type is chrono::time_point, a class template which represents a point in time. The template's argument is the type of clock used, so the full class would be chrono::time_point<chrono::steady_clock>, but in some cases the argument can be inferred automatically by the compiler.

chrono::time_point has the operator - overloaded, so once we have two time points, we can simply calculate the elapsed time by subtracting them. The class template chrono::duration is used to represent a time interval (in contrast with a time point). The template allows us to choose what kind of data type we want to represent the interval with; chrono::duration<double> is usually a good choice, but integer types can be used if we only care about integer values.

An optional second template argument to chrono::duration allows us to choose the units. The default is seconds, but one can also use nano, micro, or milli to represent nanoseconds, microseconds, or milliseconds respectively; for example, chrono::duration<double, micro>. Once we have a chrono::duration object, we can use the member function count to get the numerical value of the duration in the chosen units.

Finally, the function template chrono::duration_cast can be used to convert a chrono::duration to different units, where the argument is a specific chrono::duration, for example chrono::duration_cast<chrono::duration<double, nano>>.

This is demonstrated in the following program, which measures how much time it takes to add the first 100 million integers (note that the notation 100'000'000 is just for human readability; the 's are ignored):

#include <chrono>
#include <iostream>
using namespace std;

int main()
{
    chrono::time_point start_time = chrono::steady_clock::now();
    uint64_t sum = 0;
    for (uint64_t i = 0; i <= 100'000'000; i++)
    {
        sum += i;
    }
    chrono::time_point end_time = chrono::steady_clock::now();
    cout << "Sum: " << sum << '\n';
    chrono::duration<double> elapsed_time_seconds = end_time - start_time;
    chrono::duration<double, milli> elapsed_time_milli = end_time - start_time;
    cout << "Elapsed time: " << elapsed_time_seconds.count() << " seconds, ";
    cout << elapsed_time_milli.count() << " milliseconds, ";
    cout << (chrono::duration_cast<chrono::duration<double, micro>>(elapsed_time_seconds)).count() << " microseconds, or ";
    cout << (chrono::duration_cast<chrono::duration<int64_t, nano>>(elapsed_time_seconds)).count() << " nanoseconds.\n";
}

The output on my computer is:

Sum: 5000000050000000
Elapsed time: 0.0928417 seconds, 92.8417 milliseconds, 92841.7 microseconds, or 92841700 nanoseconds.

For convenience, I created a simple class that can be used to easily keep track of the execution times of various operations. Save the class in its own header file timer.hpp:

#include <chrono>
using namespace std;

class timer
{
public:
    void start()
    {
        start_time = chrono::steady_clock::now();
    }

    void end()
    {
        elapsed_time = chrono::steady_clock::now() - start_time;
    }

    double seconds() const
    {
        return elapsed_time.count();
    }

private:
    chrono::time_point<chrono::steady_clock> start_time = chrono::steady_clock::now();
    chrono::duration<double> elapsed_time = chrono::duration<double>::zero();
};

When a timer object is initialized, the current time is saved in the member variable start_time, and elapsed_time is initialized to zero (note that we can't just write 0, we have to use chrono::duration<double>::zero() which stands for a duration of zero). Then, when the member function end() is called, the elapsed time is stored in elapsed_time. The elapsed time in seconds can then be read using seconds(). The timer can be restarted by calling the member function start().

Here is the previous program, converted to using the timer class:

#include <iostream>
#include "timer.hpp"
using namespace std;

int main()
{
    timer t;
    uint64_t sum = 0;
    for (uint64_t i = 0; i <= 100'000'000; i++)
    {
        sum += i;
    }
    t.end();
    cout << "Sum: " << sum << '\n';
    cout << "Elapsed time: " << t.seconds() << " seconds.\n";
}

7.3.4 Performance considerations with vectors ^

To illustrate some potential performance issues when using vector, let us run a program comparing execution time for various methods of populating a vector with values. In the last step we will use dynamic memory allocation, even though we haven't learned it yet, just to illustrate the performance gains from not initializing the vector; we will not explain how this works, but don't worry, we will explain it in detail later.

#include <iostream>
#include <vector>
#include "timer.hpp"
using namespace std;

void compare_durations(const timer &t1, const timer &t2)
{
    cout << "This was " << t1.seconds() - t2.seconds() << " seconds shorter than the previous operation, making it ";
    cout << (t1.seconds() - t2.seconds()) / t1.seconds() * 100 << "% faster.\n";
}

int main()
{
    constexpr uint64_t size = 300'000'000;
    cout.precision(2);
    cout << fixed;

    // Write 8 * s bytes to memory starting with an empty vector of size zero.
    // The vector will increase in capacity multiple times during the loop's execution, causing significant slowdown.
    timer v1_t;
    {
        vector<uint64_t> v1;
        for (uint64_t i = 0; i < size; i++)
            v1.push_back(i);
    }
    v1_t.end();
    cout << "With push_back() but without reserve(), the operation took " << v1_t.seconds() << " seconds.\n";

    // This time we still start with an empty vector of size zero, but preallocate the required memory using reserve().
    // The vector will NOT increase in capacity during the loop's execution. However, there is still significant overhead.
    timer v2_t;
    {
        vector<uint64_t> v2;
        v2.reserve(size);
        for (uint64_t i = 0; i < size; i++)
            v2.push_back(i);
    }
    v2_t.end();
    cout << "With both push_back() and reserve(), the operation took " << v2_t.seconds() << " seconds.\n";
    compare_durations(v1_t, v2_t);

    // This time we start with a vector already of size s, initialized to zeros.
    // Populating is now much faster, but we also need to take the initialization time into account.
    // Initialization is wasted time in this case, since we are populating the vector with other values anyway.
    timer v3_t;
    {
        vector<uint64_t> v3(size);
        for (uint64_t i = 0; i < size; i++)
            v3[i] = i;
    }
    v3_t.end();
    cout << "With pre-initialized vector and direct access to elements, the operation took " << v3_t.seconds() << " seconds.\n";
    compare_durations(v2_t, v3_t);

    // Finally, we do the same with a manually-allocated C-style array.
    // This will, of course, be the fastest operation, since:
    // 1. We do not waste time initializing twice,
    // 2. We modify the elements directly, so there is no overhead due to push_back().
    timer a_t;
    {
        int64_t *const a = new int64_t[size];
        for (uint64_t i = 0; i < size; i++)
            a[i] = i;
        delete[] a;
    }
    a_t.end();
    cout << "With manually-allocated C-style array, the operation took " << a_t.seconds() << " seconds.\n";
    compare_durations(v3_t, a_t);
}

Note how each vector is defined within its own {} code block, so that its memory is deallocated once the code block ends and the vector goes out of scope. This ensures that we do not use too much memory, as each vector occupies 2.5 GB of memory. For the C-style array, we have to deallocate memory manually as will be explained later.

The output on my computer is:

With push_back() but without reserve(), the operation took 3.16 seconds.
With both push_back() and reserve(), the operation took 2.60 seconds.
This was 0.56 seconds shorter than the previous operation, making it 17.76% faster.
With pre-initialized vector and direct access to elements, the operation took 1.43 seconds.
This was 1.17 seconds shorter than the previous operation, making it 45.08% faster.
With manually-allocated C-style array, the operation took 0.60 seconds.
This was 0.83 seconds shorter than the previous operation, making it 57.85% faster.

For v1, we started from an empty vector, and did not preallocate memory. This means that the vector had to be resized and memory had to be reallocated multiple times during the loop's execution - every time the size of the vector exceeded the next power of 2. Reallocating memory usually also involves copying the elements to a completely different location in memory, which is a time-consuming operation. This is why v1 is the slowest to populate.

For v2, we again started from an empty vector, but this time we preallocated memory in advance using reserve. This provided a mild boost to performance, since no reallocations were necessary. However, v2 is still excruciatingly slow; there is still some overhead due to the fact that push_back does some operations behind the scenes every single time is it called, such as checking the vector's capacity and increasing its size. Note also that using reserve was possible in this case because we knew the maximum size of the vector in advance, but in other situations we may not have that information ahead of time.

For v3, instead of starting from an empty vector and growing it gradually, we started from a vector of size s, initialized (automatically) to zeros. In addition to eliminating the need to reallocate memory as the vector is populated, this also eliminated the overhead due to push_back. This results in a significant boost to performance. However, note that we effectively initialized v3 twice: once to zeros, and then a second time to the values we actually want it to have. Unfortunately, since a vector cannot be in an uninitialized state, this is the best we can do.

Lastly, for a, which is not a vector but a manually-allocated C-style array (again, we will learn exactly how this works later), we eliminated the need to initialize twice, since C-style arrays can be uninitialized. This, unsurprisingly, results in around half the execution time and a very significant boost to performance - since we are initializing the elements only once, instead of twice!

As a side note, the above numbers may change drastically when using compiler optimizations, as we will learn later in the course, or when using another compiler. However, the numbers would still generally be in decreasing order; v1 is always the slowest, and a is always the fastest.

7.4 Other sequence containers: deque, forward_list, and list ^

7.4.1 Templates of templates: the auto keyword

To streamline the rest of this chapter, I would like to write a generic function template which will print out all of the elements in a container, whether it's an array, a vector, or any other container, and no matter what kind of data type it holds. This turns out to be a bit tricky.

First of all, no template can match both array<A, B> and vector<A> for any A and B. I can either write something like

template <typename T, typename A>
void print_elements(const T<A> &c)

which will capture containers with one argument, or something like

template <typename T, typename A, typename B>
void print_elements(const T<A, B> &c)

which will capture containers with two arguments. Therefore, I must instead write something like

template <typename T>
void print_elements(const T &c)

where now T can be any container. However, in this case, how will I know which type is stored in the container? The solution is as follows:

#include <array>
#include <iostream>
#include <vector>
using namespace std;

template <typename T>
void print_elements(const T &c)
{
    for (const auto &i : c)
        cout << i << ' ';
    cout << '\n';
}

int main()
{
    array<double, 5> a = {1.1, 2.2, 3.3, 4.4, 5.5};
    vector<char> v = {'a', 'b', 'c', 'd', 'e'};
    print_elements(a);
    print_elements(v);
}

Here, T will be replaced with the container, which would be array<double, 5> for a and vector<char> for v. However, because I don't know what the actual data type inside the container is, I used the keyword auto in the for loop. Whenever the compiler sees auto, it replaces it with an automatically inferred data type. In this case, auto will be replaced with double for a and with char for v.

In fact, you could in principle (although it is not recommended, see warnings below) use auto anywhere you want, even if you do not use templates, as long as you initialize the variable with a value from which the type can be inferred. For example:

#include <cmath>
#include <iostream>
using namespace std;

int main()
{
    // Will automatically detect that x is a double.
    auto x = 1.9;
    // Will automatically detect that y is an int.
    auto y = 2;
    // Will automatically detect that z is a double, since that is the default output type of sqrt().
    auto z = sqrt(2);

    cout << "x = " << x << ", y = " << y << ", z = " << z;
}
Warning: Although using auto is very convenient, mentioning the type explicitly will make your program clearer to the reader and avoid any possible confusion regarding the types of variables. It should only be used in cases where the type must be automatically inferred, as with templates of templates, or when you just want to write a few temporary lines of code to quickly test something. In all other cases, auto will decrease your code's readability, and should be avoided!
Warning: Using auto may lead to unexpected behavior. For example, auto x = 1 will detect x to be an int, that is, a signed 32-bit integer (on most systems), even though we might actually want x to be unsigned, have a different bit width, or even be a floating-point variable. Again, you should not use auto unless it is absolutely necessary.

7.4.2 The deque container: double-ended queue ^

A deque (pronounced like "deck", short for double-ended queue), defined in the header file <deque>, is similar to a vector is that it is a linear sequence of elements which can be accessed, inserted, and deleted. However, unlike a vector, the elements of a deque are not stored contiguously in memory. Instead, they are usually distributed among several different individually-allocated memory blocks.

When inserting elements at the end of a vector, if its capacity is exceeded, it may need to reallocate memory - which, as we've discussed, could mean copying the entire array to a new place in memory. Similarly, inserting elements at the beginning of a vector means the entire array needs to be shifted forward. In both cases, inserting elements into a vector can incur a significant performance penalty.

On the other hand, inserting elements to a deque is much faster, since it never copies or shifts elements - it simply puts inserted elements in different locations in memory as needed. Therefore, deque is preferred to vector if you plan to do many insertions and deletions.

However, deque has two significant drawbacks. The first is that, since the elements are not stored contiguously in memory, accessing them is more costly. To access an element of a vector we simply need to dereference a pointer, but to access an element of a deque we need to dereference two pointers: one to the specific block where the element is located, and the other to the element's location in that block. The second drawback is that since deque allocates different memory blocks, it uses more memory than a vector.

deque supports most of the member functions and operators supported by array, including including at, [], front, back, size, empty, begin/cbegin, end/cend, and comparison operators. However, like vector, it does not support fill or to_array. It also does not support data, since there is no contiguous array to return a pointer to.

In addition, deque supports member functions supported by vector, including clear, push_back, pop_back, resize, max_size, shrink_to_fit, insert, erase, emplace, and emplace_back. However, it does not support capacity or reserve since it allocates memory in a different way.

Perhaps the biggest advantage of deque is its ability to efficiently insert and remove elements not just at the end but also at the beginning of the container, without shifting or copying. For this reason, it adds the following new member functions:

  • push_front inserts an element at the beginning.
  • pop_front removes an element from the beginning.
  • emplace_front constructs an element in place at the beginning.

The following program illustrates the difference between deque and vector:

#include <deque>
#include <iomanip>
#include <iostream>
#include <vector>
using namespace std;

template <typename T>
void print_elements_and_addresses(const T &d)
{
    cout << "Address       | Value\n";
    for (const auto &i : d)
        cout << setw(13) << &i << " | " << i << '\n';
}

int main()
{
    cout << left;

    cout << "Deque:\n";
    deque<int32_t> d = {1, 2};
    print_elements_and_addresses(d);
    d.push_back(3);
    print_elements_and_addresses(d);
    d.push_front(0);
    print_elements_and_addresses(d);

    cout << "\nVector:\n";
    vector<int32_t> v = {1, 2};
    print_elements_and_addresses(v);
    v.push_back(3);
    print_elements_and_addresses(v);
    v.insert(v.begin(), 0);
    print_elements_and_addresses(v);
}

Here we expanded print_elements() into a function print_elements_and_addresses() which prints out the memory addresses of each element as well. Here is one possibility for the output:

Deque:
Address       | Value
0x1bc72ca6ab0 | 1
0x1bc72ca6ab4 | 2
Address       | Value
0x1bc72ca6ab0 | 1
0x1bc72ca6ab4 | 2
0x1bc72ca6ab8 | 3
Address       | Value
0x1bc72ca6edc | 0
0x1bc72ca6ab0 | 1
0x1bc72ca6ab4 | 2
0x1bc72ca6ab8 | 3

Vector:
Address       | Value
0x1bc72c897d0 | 1
0x1bc72c897d4 | 2
Address       | Value
0x1bc72c88bb0 | 1
0x1bc72c88bb4 | 2
0x1bc72c88bb8 | 3
Address       | Value
0x1bc72c88bb0 | 0
0x1bc72c88bb4 | 1
0x1bc72c88bb8 | 2
0x1bc72c88bbc | 3

Note that deque keeps the elements in the exact same place in memory throughout the run time of the program. For example, 1 is always located at 0x1bc72ca6ab0 and 2 is always located at the subsequent address 0x1bc72ca6ab4.

Adding 3 at the end places it at the subsequent memory address 0x1bc72ca6ab8. However, adding 0 at the beginning places it at a completely different memory block, at the address 0x1bc72ca6edc, which is actually after the address of 3. We see that the array is definitely not contiguous.

Meanwhile, vector initially places 1 and 2 at the addresses 0x1bc72c897d0 and 0x1bc72c897d4 respectively, but when we add 3 at the end, it actually copies the entire array to a different memory block starting at 0x1bc72c88bb0 (recall that above we saw that reallocation happens when the size exceeds the next power of 2).

Then, when we add 0 at the beginning, it is added at the beginning of the block, at 0x1bc72c88bb0, and the other three elements are shifted to make place for it, preserving the contiguity of the array - at the cost of rewriting the entire array, which can significantly hurt performance for large arrays!

7.4.3 The forward_list and list containers: singly-linked and doubly-linked list ^

The two list containers, forward_list and list, are sequence containers that can store each element in a completely different location in memory - essentially, the complete opposite of a vector. As you might expect, performance is opposite as well: accessing an element is slow, but inserting elements is fast.

In a forward_list, each element in the sequence contains information about the location of the next element, so that they are all linked together, but only if you're going forward. In a list, each element in the sequence contains information about the location of both the next element and the previous element, so you can go both forward and backward.

Due to the nature of these containers, the elements are not numbered, so you cannot access elements using [] or at. Instead, the only way to specifically access element number n is to start from the beginning and work your way up using iterators. Furthermore, if using forward_list, there are no push_back, pop_back, or size member functions; a forward_list doesn't know where its last member is, or even how large it is.

The following program is similar to the one we used above for deque, and demonstrates how the elements of a list or forward_list are stored in memory:

#include <forward_list>
#include <iomanip>
#include <iostream>
#include <list>
using namespace std;

template <typename T>
void print_elements_and_addresses(const T &d)
{
    cout << "Address       | Value\n";
    for (const auto &i : d)
        cout << setw(13) << &i << " | " << i << '\n';
}

int main()
{
    cout << left;

    cout << "Forward List:\n";
    forward_list<int32_t> f = {1, 2};
    print_elements_and_addresses(f);
    f.insert_after(++f.begin(), 3);
    print_elements_and_addresses(f);
    f.push_front(0);
    print_elements_and_addresses(f);

    cout << "\nList:\n";
    list<int32_t> l = {1, 2};
    print_elements_and_addresses(l);
    l.push_back(3);
    print_elements_and_addresses(l);
    l.push_front(0);
    print_elements_and_addresses(l);
}

One possible output on my computer is:

Forward List:
Address       | Value
0x22987a49778 | 1
0x22987a49868 | 2
Address       | Value
0x22987a49778 | 1
0x22987a49868 | 2
0x22987a498a8 | 3
Address       | Value
0x22987a49968 | 0
0x22987a49778 | 1
0x22987a49868 | 2
0x22987a498a8 | 3

List:
Address       | Value
0x22987a499b0 | 1
0x22987a49a00 | 2
Address       | Value
0x22987a499b0 | 1
0x22987a49a00 | 2
0x22987a48b50 | 3
Address       | Value
0x22987a48ba0 | 0
0x22987a499b0 | 1
0x22987a49a00 | 2
0x22987a48b50 | 3

I won't go into any more details about these containers, but you can find more information in the C++ reference and Microsoft's C++ reference.

7.5 Associative containers: sets and maps ^

7.5.1 Sets

A set, defined in the header file <set>, is simply a set (as in mathematical set theory) to which elements can be added in no particular order. An element can either be or not be in a set, and that's it. You insert an element with insert(value) and check if it's in the set with count (which will return either 0 or 1, since each element is unique). You can go over the elements of the set with iterators or range-for loops as usual.

The elements in a set are ordered lexicographically, which is useful for many algorithms. For example, if you're searching for Carol starting from the beginning, and you've reached Dave, you immediately know Carol is not in the set, since she should have appeared before Dave. However, this also means inserting elements is slow, since every insertion also automatically sorts the set.

A multiset, also defined in the header file <set>, is similar to a set, except that there can now be multiple elements with the same value. Therefore, count can now be any non-negative integer. unordered_set and unordered_multiset, defined in the header file <unordered_set>, are similar to set and multiset, except that the elements are unordered.

This is all illustrated in the following program:

#include <iomanip>
#include <iostream>
#include <set>
#include <string>
#include <unordered_set>
using namespace std;

template <typename T>
void print_elements_and_addresses(const T &d)
{
    cout << "Address       | Value\n";
    for (const auto &i : d)
        cout << setw(13) << &i << " | " << i << '\n';
}

template <typename T>
void is_invited(const T &i, const string &s)
{
    if (i.count(s) > 1)
        cout << s << " has been invited " << i.count(s) << " times.\n";
    else if (i.count(s) == 1)
        cout << s << " has been invited once.\n";
    else
        cout << s << " has not been invited.\n";
}

int main()
{
    cout << left;

    cout << "Set:\n";
    set<string> invited_set;
    invited_set.insert("Alice");
    invited_set.insert("Carol");
    // A set is automatically sorted, so Bob will be inserted before Carol
    invited_set.insert("Bob");
    // A set does not allow multiple elements with the same name
    // Therefore, this has no effect
    invited_set.insert("Alice");
    print_elements_and_addresses(invited_set);
    is_invited(invited_set, "Alice");
    is_invited(invited_set, "Bob");
    is_invited(invited_set, "Dave");

    cout << "\nMultiset:\n";
    multiset<string> invited_multiset;
    invited_multiset.insert("Alice");
    invited_multiset.insert("Carol");
    // A multiset is automatically sorted, so Bob will be inserted before Carol
    invited_multiset.insert("Bob");
    // A multiset allows multiple elements with the same name, so Alice will appear twice
    // Both instances will be the top of the list, since it is sorted
    invited_multiset.insert("Alice");
    print_elements_and_addresses(invited_multiset);
    is_invited(invited_multiset, "Alice");
    is_invited(invited_multiset, "Bob");
    is_invited(invited_multiset, "Dave");

    cout << "\nUnordered Set:\n";
    unordered_set<string> invited_unordered_set;
    invited_unordered_set.insert("Alice");
    invited_unordered_set.insert("Carol");
    // An unordered_set is not automatically sorted, so this time Bob will be inserted after Carol
    invited_unordered_set.insert("Bob");
    // An unordered_set does not allow multiple elements with the same name
    // However, this will move Alice to the bottom
    invited_unordered_set.insert("Alice");
    print_elements_and_addresses(invited_unordered_set);
    is_invited(invited_unordered_set, "Alice");
    is_invited(invited_unordered_set, "Bob");
    is_invited(invited_unordered_set, "Dave");

    cout << "\nUnordered Multiset:\n";
    unordered_multiset<string> invited_unordered_multiset;
    invited_unordered_multiset.insert("Alice");
    invited_unordered_multiset.insert("Carol");
    // An unordered_multiset is not automatically sorted, so this time Bob will be inserted after Carol
    invited_unordered_multiset.insert("Bob");
    // A unordered_multiset allows multiple elements with the same name, so Alice will appear twice
    // Both instances will appear in the bottom of the list
    invited_unordered_multiset.insert("Alice");
    print_elements_and_addresses(invited_unordered_multiset);
    is_invited(invited_unordered_multiset, "Alice");
    is_invited(invited_unordered_multiset, "Bob");
    is_invited(invited_unordered_multiset, "Dave");
}

On my computer, one possible output is:

Set:
Address       | Value
0x21f5ae69d00 | Alice
0x21f5ae68bb0 | Bob
0x21f5ae66a20 | Carol
Alice has been invited once.
Bob has been invited once.
Dave has not been invited.

Multiset:
Address       | Value
0x21f5ae6a400 | Alice
0x21f5ae688f0 | Alice
0x21f5ae68880 | Bob
0x21f5ae6a470 | Carol
Alice has been invited 2 times.
Bob has been invited once.
Dave has not been invited.

Unordered Set:
Address       | Value
0x21f5ae86d58 | Bob
0x21f5ae86cf8 | Carol
0x21f5ae68c08 | Alice
Alice has been invited once.
Bob has been invited once.
Dave has not been invited.

Unordered Multiset:
Address       | Value
0x21f5ae86f18 | Bob
0x21f5ae86eb8 | Carol
0x21f5ae86f78 | Alice
0x21f5ae86db8 | Alice
Alice has been invited 2 times.
Bob has been invited once.
Dave has not been invited.

7.5.2 Maps ^

A map, defined in the header <map>, holds key-value pairs, such that the values can be accessed via the corresponding keys. Think of the keys as a generalization of array indices; instead of accessing an element via its corresponding index - a non-negative integer - it can be accessed via any object of any type, fundamental or user-defined. You can also think of a map as a dictionary which translates one object to another.

There also also three additional containers, similar to the set containers. multimap, also in <map>, permits multiple values assigned to the same key. unordered_map and unordered_multimap, in <unordered_map>, are the unordered versions.

To declare a map, we use the following syntax:

map<key_type, value_type> name;
map<key_type, value_type> name = {{key1, value1}, {key2, value2}, ...};

key_type is the data type used for the keys, and value_type is the data type used for the values. The first line creates an empty map, while the second line creates a map and initializes it with some pairs of keys and values. To assign a value to a key, or to read the value assigned to a key, we simply use the [] operator. As in some other containers, we can also use the at member function, which throws out_of_range if the key does not exist.

Whether we use [] or at, the return value will be an object of the class template pair<key_type, value_type>. The member first can then be used to access the key and the member second to access the value. This is demonstrated in the following example:

#include <iomanip>
#include <iostream>
#include <map>
#include <string>
#include <unordered_map>
using namespace std;

template <typename T>
void print_map(const T &m)
{
    cout << "Address       | Key   | Value\n";
    for (const auto &i : m)
        cout << setw(13) << &i << " | " << setw(5) << i.first << " | " << i.second << '\n';
}

template <typename T>
void age_lookup(const T &ages, const string &name)
{
    cout << name << "'s age is ";
    try
    {
        cout << ages.at(name) << ".\n";
    }
    catch (const out_of_range &e)
    {
        cout << "not listed.\n";
    }
}

int main()
{
    cout << left;

    cout << "Initial list:\n";
    map<string, uint16_t> ages = {{"Alice", 25}, {"Carol", 23}};
    print_map(ages);

    cout << "\nAfter adding Bob:\n";
    ages["Bob"] = 34;
    print_map(ages);

    cout << '\n';
    age_lookup(ages, "Alice");
    age_lookup(ages, "Dave");
    cout << "\nAfter looking up Dave:\n";
    print_map(ages);

    // Note: Looking up Dave with .at() did not modify the map, but looking up Eve with [] will add a new key! Since the value associated with the key "Eve" was undefined, it will obtain the default value of zero.
    if (ages["Eve"] != 0)
        cout << "Eve's age is set.";
    cout << "\nAfter looking up Eve:\n";
    print_map(ages);
}

On my computer, one possible output is:

Initial list:
Address       | Key   | Value
0x11a4cc197b0 | Alice | 25
0x11a4cc18b60 | Carol | 23

After adding Bob:
Address       | Key   | Value
0x11a4cc197b0 | Alice | 25
0x11a4cc18be0 | Bob   | 34
0x11a4cc18b60 | Carol | 23

Alice's age is 25.
Dave's age is not listed.

After looking up Dave:
Address       | Key   | Value
0x11a4cc197b0 | Alice | 25
0x11a4cc18be0 | Bob   | 34
0x11a4cc18b60 | Carol | 23

After looking up Eve:
Address       | Key   | Value
0x11a4cc197b0 | Alice | 25
0x11a4cc18be0 | Bob   | 34
0x11a4cc18b60 | Carol | 23
0x11a4cc1a480 | Eve   | 0

You can find more information about associative containers in the C++ reference and Microsoft's C++ reference.

7.6 The standard template library: algorithms ^

The C++ standard template library contains many useful function templates to implement a variety of algorithms on containers. They are all included in the <algorithm> header file. The algorithms are completely generic, since they are templates, and they utilize iterators. This means that any algorithm can be applied to values of any data type, stored in any kind of container that works with iterators - which includes all of the STL containers, as well as one you can write yourself. We will now list some of these algorithms.

7.6.1 General syntax and lambda expressions

Many STL algorithms have the following syntax:

algorithm(first, last, function);

function is a function that takes the appropriate number of arguments, usually one or two depending on what the algorithm does, and returns the appropriate return value. If the return value is bool, which is often the case, then the function is also called a predicate.

first is an iterator pointing to the first element on which we would like to execute function, and last is an iterator pointing to the element after the last element on which we would like to execute function. This means that the algorithm will act on the range [first, last), i.e. inclusive of first but not inclusive of last. If we want to act on the entire container c, we simply use the syntax algorithm(c.begin(), c.end(), function).

The function itself can be defined as an actual function, but in modern C++ we often specify it inline using a lambda expression or anonymous function. The simplest lambda functions have the following syntax:

[](type1 arg1, type2 arg2, ...) { /* code */ }

[] is called a capture clause; you can specify some options inside the square brackets, but we won't discuss that here (see here and here for more information). The list inside the round brackets is the usual function argument list, and the code inside the curly brackets is the usual function code.

7.6.2 Non-modifying sequence operations ^

The following algorithms act on sequences, but do not modify them.

  • all_of, any_of, and none_of:
all_of(first, last, predicate);
any_of(first, last, predicate);
none_of(first, last, predicate);

Return true if all, any, or none of the elements (respectively) in the range [first, last) satisfy predicate.

Example code:

#include <algorithm>
#include <iostream>
#include <vector>
using namespace std;

bool is_even(const uint32_t &n)
{
    return (n % 2) == 0;
}

int main()
{
    vector<uint32_t> v = {1, 2, 27, 5, 8, 9, 5, 12, 5, 13, 15};
    cout << boolalpha;
    cout << "All numbers are even? " << all_of(v.begin(), v.end(), is_even) << '\n';
    cout << "Any numbers are even? " << any_of(v.begin(), v.end(), is_even) << '\n';
    cout << "No numbers are even? " << none_of(v.begin(), v.end(), is_even) << '\n';
}

Output:

All numbers are even? false
Any numbers are even? true
No numbers are even? false

  • count and count_if:
count(first, last, value);
count_if(first, last, predicate);

Count how many elements in the range [first, last) are equal to value or satisfy predicate respectively.

Example code:

#include <algorithm>
#include <iostream>
#include <vector>
using namespace std;

int main()
{
    vector<uint32_t> v = {1, 2, 27, 5, 8, 9, 5, 12, 5, 13, 15};
    cout << "Number of times 5 appears in the container: ";
    cout << count(v.begin(), v.end(), 5);
    cout << "\nNumber of elements divisible by 3 in the container: ";
    cout << count_if(v.begin(), v.end(), [](const uint32_t &n) { return (n % 3) == 0; });
}

Output:

Number of times 5 appears in the container: 3
Number of elements divisible by 3 in the container: 4

  • find, find_if, and find_if_not:
find(first, last, value);
find_if(first, last, predicate);
find_if_not(first, last, predicate);

Find the first element in the range [first, last) which equals value, satisfies predicate, or does not satisfy predicate respectively. Return an iterator to the element, or the input argument last if the element was not found.

Example code:

#include <algorithm>
#include <cctype>
#include <iostream>
#include <vector>
using namespace std;

void find_letter(const vector<char> &v, const char &c)
{
    vector<char>::const_iterator i = find(v.begin(), v.end(), c);
    if (i != v.end())
        cout << "Found letter " << *i << ".\n";
    else
        cout << "Did not find letter " << c << ".\n";
}

void find_first_digit(const vector<char> &v)
{
    vector<char>::const_iterator i = find_if(v.begin(), v.end(), [](const char &c) { return isdigit(c); });
    if (i != v.end())
        cout << "Found first digit: " << *i << ".\n";
    else
        cout << "Did not find any digits .\n";
}

int main()
{
    vector<char> v = {'a', 'g', '4', 'k', '1', 'b', '6'};
    find_letter(v, 'g');
    find_letter(v, 'c');
    find_first_digit(v);
}

Output:

Found letter g.
Did not find letter c.
Found first digit: 4.

  • for_each:
for_each(first, last, function);

Applies function to each element in the range [first, last). Note that this is formally classified as a non-modifying algorithm, but if function gets the elements of the sequence by reference, it can nonetheless modify them.

Example code:

#include <algorithm>
#include <iostream>
#include <vector>
using namespace std;

int main()
{
    vector<uint32_t> v = {2, 4, 8, 16, 32};
    for_each(v.begin(), v.end(), [](uint32_t &n) { n++; });
    for_each(v.begin(), v.end(), [](const uint32_t &n) { cout << n << ' '; });
}

Output:

3 5 9 17 33

7.6.3 Modifying sequence operations ^

The following algorithms act on sequences and modify them.

  • copy and copy_if:
copy(first, last, destination);
copy_if(first, last, destination, predicate);

Copy the elements in the range [first, last) to the location pointed to by the iterator destination. The destination can be in the same container or another container, but it can't be between first and last. If using copy_if, only the elements satisfying predicate will be copied.

Note that copy and copy_if overwrite the existing elements of the target container; they cannot enlarge the container or shift elements around.

Example code:

#include <algorithm>
#include <iostream>
#include <vector>
using namespace std;

template <typename T>
void print_elements(const T &c)
{
    for (const auto &i : c)
        cout << i << ' ';
    cout << '\n';
}

int main()
{
    vector<uint32_t> v = {1, 2, 3, 4};
    vector<uint32_t> w = {5, 6, 7, 8, 9, 10};
    // Copy all of v to the beginning of w
    copy(v.begin(), v.end(), w.begin());
    print_elements(w);
    // Copy only the even numbers in v to the beginning of w
    copy_if(v.begin(), v.end(), w.begin(), [](const uint32_t &n) { return (n % 2) == 0; });
    print_elements(w);
}

Output:

1 2 3 4 9 10
2 4 3 4 9 10

  • fill:
fill(first, last, value);

Fills the elements in the range [first, last) with value.

Example code:

#include <algorithm>
#include <iostream>
#include <vector>
using namespace std;

template <typename T>
void print_elements(const T &c)
{
    for (const auto &i : c)
        cout << i << ' ';
    cout << '\n';
}

int main()
{
    vector<uint32_t> v = {1, 2, 3, 4};
    fill(v.begin(), v.end(), 5);
    print_elements(v);
}

Output:

5 5 5 5

  • remove and remove_if:
remove(first, last, value);
remove_if(first, last, predicate);

Remove the elements in the range [first, last) which are equal to value or satisfy predicate respectively. Return an iterator pointing to the new end of the range. Note that the result will be a container of the same size, so typically you use the container's erase member function with the return value of remove as the first argument (i.e. the beginning of the range to be removed) and the end of the container as the second argument.

Example code:

#include <algorithm>
#include <iostream>
#include <vector>
using namespace std;

template <typename T>
void print_elements(const T &c)
{
    for (const auto &i : c)
        cout << i << ' ';
    cout << '\n';
}

int main()
{
    vector<uint32_t> v = {1, 2, 3, 4, 5, 6, 7, 8};
    // Remove the number 4
    v.erase(remove(v.begin(), v.end(), 4), v.end());
    print_elements(v);
    // Remove all odd numbers
    v.erase(remove_if(v.begin(), v.end(), [](const uint32_t &n) { return (n % 2) != 0; }), v.end());
    print_elements(v);
}

Output:

1 2 3 5 6 7 8
2 6 8

  • replace and replace_if:
replace(first, last, old_value, new_value);
replace_if(first, last, predicate, new_value);

Replace any elements in the range [first, last) which are equal to old_value or satisfy predicate respectively. These elements are replaced with new_value.

Example code:

#include <algorithm>
#include <iostream>
#include <vector>
using namespace std;

template <typename T>
void print_elements(const T &c)
{
    for (const auto &i : c)
        cout << i << ' ';
    cout << '\n';
}

int main()
{
    vector<uint32_t> v = {1, 2, 3, 4, 5, 6, 7, 8};
    // Replace 4 with 0
    replace(v.begin(), v.end(), 4, 0);
    print_elements(v);
    // Replace all odd numbers with 10
    replace_if(v.begin(), v.end(), [](const uint32_t &n) { return (n % 2) != 0; }, 10);
    print_elements(v);
}

Output:

1 2 3 0 5 6 7 8
10 2 10 0 10 6 10 8

  • swap and swap_ranges:
swap(a, b);
swap_ranges(first, last, destination);

swap swaps the values of the objects a and b, and swap_ranges swaps the elements in the range [first, last) with an equal number of elements starting at the iterator destination.

Example code:

#include <algorithm>
#include <iostream>
#include <vector>
using namespace std;

template <typename T>
void print_elements(const T &c)
{
    for (const auto &i : c)
        cout << i << ' ';
    cout << '\n';
}

int main()
{
    vector<uint32_t> v = {1, 2, 3, 4, 5, 6, 7, 8};
    // Swap the positions of the first two elements
    swap(v[0], v[1]);
    print_elements(v);
    // Swap the first three elements with the last three elements
    swap_ranges(v.begin(), v.begin() + 3, v.end() - 3);
    print_elements(v);
}

Output:

2 1 3 4 5 6 7 8
6 7 8 4 5 2 1 3

  • reverse:
reverse(first, last);

Reverses the order of the elements in the range [first, last).

Example code:

#include <algorithm>
#include <iostream>
#include <vector>
using namespace std;

template <typename T>
void print_elements(const T &c)
{
    for (const auto &i : c)
        cout << i << ' ';
    cout << '\n';
}

int main()
{
    vector<uint32_t> v = {1, 2, 3, 4, 5, 6, 7, 8};
    reverse(v.begin(), v.end());
    print_elements(v);
}

Output:

8 7 6 5 4 3 2 1

7.6.4 Other useful algorithms ^

  • sort and is_sorted:
sort(first, last);
is_sorted(first, last);

sort sorts the elements in the range [first, last) in non-descending order. is_sorted checks if the elements in the range [first, last) are sorted.

Example code:

#include <algorithm>
#include <iostream>
#include <vector>
using namespace std;

template <typename T>
void print_elements(const T &c)
{
    for (const auto &i : c)
        cout << i << ' ';
    cout << '\n';
}

int main()
{
    cout << boolalpha;
    vector<uint32_t> v = {1, 2, 3, 2, 2, 3, 1, 4, 2};
    print_elements(v);
    cout << "Is sorted? " << is_sorted(v.begin(), v.end()) << '\n';
    sort(v.begin(), v.end());
    print_elements(v);
    cout << "Is sorted? " << is_sorted(v.begin(), v.end()) << '\n';
}

Output:

1 2 3 2 2 3 1 4 2
Is sorted? false
1 1 2 2 2 2 3 3 4
Is sorted? true

  • max, max_element, min, and min_element:
max(a, b);
max({a, b, c, ...});
max_element(first, last);
min(a, b);
min({a, b, c, ...});
min_element(first, last);

max returns the larger of the two objects a and b or the largest of the objects in the initializer list {a, b, c, ...}. max_element returns an iterator to the largest element in the range [first, last). min and min_element do the same for the smallest object or element respectively.

Example code:

#include <algorithm>
#include <iostream>
#include <vector>
using namespace std;

int main()
{
    vector<uint32_t> v = {1, 4, 2, 7, 3, 5};
    cout << "Smallest element: " << *min_element(v.begin(), v.end()) << '\n';
    cout << "Largest element: " << *max_element(v.begin(), v.end()) << '\n';
}

Output:

Smallest element: 1
Largest element: 7

  • is_permutation:
is_permutation(first1, last1, first2);
is_permutation(first1, last1, first2, last2);

Returns true if the elements in the range [first1, last1) are a permutation of the elements in the range [first2, last2), or in a range of the same size starting from first2 if last2 was not given.

Example code:

#include <algorithm>
#include <iostream>
#include <vector>
using namespace std;

template <typename T>
void print_elements(const T &c)
{
    for (const auto &i : c)
        cout << i << ' ';
    cout << '\n';
}

int main()
{
    cout << boolalpha;
    vector<uint32_t> v = {1, 2, 3, 4};
    vector<uint32_t> w = {3, 2, 4, 1};
    vector<uint32_t> u = {2, 4, 3, 5};
    cout << "Is w a permutation of v? " << is_permutation(v.begin(), v.end(), w.begin()) << '\n';
    cout << "Is u a permutation of v? " << is_permutation(v.begin(), v.end(), u.begin(), u.end()) << '\n';
}

Output:

Is w a permutation of v? true
Is u a permutation of v? false

In this chapter, I only presented a few of the available algorithms, which seemed to me to be especially useful and/or simple. More information, including many function templates that I did not list here, may be found in the C++ reference and Microsoft's C++ reference.

7.6.5 Algorithms from the numerics library ^

Above I mentioned a few algorithms from the header file <numeric>. Some other useful algorithms from that header file, which use iterators, include:

  • iota(first, last, value) fill the range [first, last) with the sequentially increasing values {value, value + 1, value + 2, ...}.
  • accumulate(first, last, initial) calculates the sum of the values in the range [first, last), and adds initial to the result.
  • inner_product(first1, last1, first2, initial) calculates the inner product of the values in the range [first1, last1) with the values in the range of the same size starting from first2, and adds initial to the result.

For more information, please see the C++ reference.

7.7 Further reading on C++ ^

While preparing these notes I often looked up information on the web. Unfortunately, I noticed that most free tutorial websites contain very poorly written code. These websites also usually feature old-fashioned C++ code that doesn't utilize the significant innovations of recent C++ revisions. My recommendation here will be similar to the one I made above for C: don't use these tutorials!

Stack Overflow is a great website with trustworthy content in the form of questions and answers. The C++ reference and Microsoft's C++ reference are two comprehensive and trustworthy sources I used very frequently when writing these notes, and I encourage you to use them for further reading. The C++ Resources Network is also a somewhat useful website, but unfortunately it is only updated up to C++11, which is now a decade old, so I cannot recommend using it as a primary resource.

The creator of C++, Bjarne Stroustrup, has written several textbooks at different levels. The C++ Programming Language is a very comprehensive and advanced textbook, intended for experienced programmers. Its 4th edition (2013) covers revisions of C++ up to C++11. Another book, Programming: Principles and Practice Using C++, is roughly the same length, but is intended for beginner programmers. Its 2nd edition (2014) covers revisions of C++ up to C++14. Both are excellent places to find more information, as well as to understand how to "think" properly as a C++ programmer, even if they don't cover all the latest features.

8 Development and collaboration tools ^

8.1 Optimizing your code

8.1.1 Compiler optimizations

The GCC compiler, like any decent compiler, includes many different optional optimizations that can potentially improve the performance of your program, sometimes by a very significant amount. The optimization level is set using the argument -O (note that this must be an uppercase O) followed by a digit, letter, or word.

Sorted from the smallest to the largest number of optimizations that will be applied, the options are:

  • -O0: No optimizations. This is the default, and is not recommended for use except when debugging.
  • -Og: Enables all -O1 optimizations, except those that may interfere with debugging. For example, the compiler may decide that a certain variable is unnecessary and optimize it out, resulting in the debugger not being able to access that variable. I actually find that this sometimes nonetheless interferes with my debugging, so I don't use it, and instead usually use -O0 (i.e. no optimization flag) when debugging.
  • -O1: The basic level of optimization.
  • -Os: Enables all -O2 optimizations, except those that may increase code size. This is important if you're compiling code that will run on an embedded system with very limited memory, but otherwise usually not needed.
  • -O2: A higher level of optimization. In most cases, this is the recommended optimization level that should be applied when compiling the version you intend to distribute and use.
  • -O3: The highest level of optimization. Note that -O3 is not guaranteed to produce improved performance compared to -O2, and in fact, it may sometimes actually produce slower code due to increased code size and/or increased memory usage. On rare instances, it may even break some code.
  • -Ofast: Enables all -O3 optimizations, as well as additional optimizations that are not valid for all standard-compliant programs. You should never use this option, as it may break your code. For example, among other things, this option actually changes the way floating-point calculations work, which may cause your program to produce different output than expected.

To use optimizations when compiling from Visual Studio Code, add the appropriate argument to the args section of tasks.json. Remember to also remove -ggdb3; generally you should only use -ggdb3 when debugging, and -O when compiling the release version. Note that only one optimization level may be set; if you use more than one, then they will all be ignored except the last one.

One tradeoff of increasing the optimization level is that compilation will take more time. However, this is only relevant during the debug phase, when you may be recompiling the program very often. When compiling the release version program, compilation time doesn't matter, only the performance of the program itself does.

So, which option should you use? It is tempting to use -O3, but as I said above, this is not necessarily the best option. The correct thing to do is to test your code with different levels of optimization (except -Og, which should only be used for debugging, and -Ofast, which should never be used) and see which one produces the fastest code.

Each of the optimization flags above automatically turns on a large number of different optimizations. It is also possible to manually turn specific optimizations on or off, but I won't go into details about that here. You can read many more details about GCC optimization flags here.

8.1.2 Comparing execution time with different compiler optimizations ^

Consider the following program:

#include <chrono>
#include <cmath>
#include <iostream>
#include <vector>
using namespace std;

class timer
{
public:
    void start()
    {
        start_time = chrono::steady_clock::now();
    }

    void end()
    {
        elapsed_time = chrono::steady_clock::now() - start_time;
    }

    double seconds() const
    {
        return elapsed_time.count();
    }

private:
    chrono::time_point<chrono::steady_clock> start_time = chrono::steady_clock::now();
    chrono::duration<double> elapsed_time = chrono::duration<double>::zero();
};

void populate_vector(vector<double> &vec)
{
    for (uint64_t i = 0; i < vec.size(); i++)
        vec[i] = (double)i;
}

void square_vector_with_pow(vector<double> &vec)
{
    for (double &element : vec)
        element = pow(element, 2);
}

void square_vector_without_pow(vector<double> &vec)
{
    for (double &element : vec)
        element = element * element;
}

int main()
{
    timer t;
    cout.precision(2);
    cout << fixed;
    constexpr uint64_t size = 500'000'000;

    {
        vector<double> vec(size);
        populate_vector(vec);
        t.start();
        square_vector_with_pow(vec);
        t.end();
        cout << "square_vector_with_pow() took " << t.seconds() << " seconds.\n";
    }

    {
        vector<double> vec(size);
        populate_vector(vec);
        t.start();
        square_vector_without_pow(vec);
        t.end();
        cout << "square_vector_without_pow() took " << t.seconds() << " seconds.\n";
    }
}

This program compares the execution time of two methods for squaring numbers:

  1. Using the pow() function from <cmath>,
  2. By simply multiplying the number by itself.

It measures time using the timer class I first defined above. Each vector is defined within its own {} code block, so that its memory is deallocated once the code block ends and the vector goes out of scope.

Instead of building and running this program in the VS Code debugger by pressing F5, build it in the integrated terminal (Ctrl+`) using the following command:

g++ main.cpp -std=c++20 -o CSE701.exe -O0

(As usual, on Linux, remove the .exe.) Now run it by typing CSE701 on Windows (if you're using Command Prompt for your terminal) or ./CSE701 on Linux or Windows PowerShell. This will ensure that the results obtained are not affected by the debugger.

When I run this program on my computer, it produces the following output:

square_vector_with_pow() took 16.61 seconds.
square_vector_without_pow() took 2.98 seconds.

Clearly, square_vector_with_pow() is much slower than square_vector_without_pow(); this is because pow() uses a numerical algorithm to can accept any real number as the power, and this algorithm is not optimized for integer powers.

Let us now enable some compiler optimizations. Replacing -O0 with -Og, I get:

square_vector_with_pow() took 13.14 seconds.
square_vector_without_pow() took 0.30 seconds.

There is only a small improvement in square_vector_with_pow(), but square_vector_without_pow() is considerably faster, by about a factor of 10. This is possibly due to some optimizations that have to do with looping over array elements.

Adding some more optimizations with -O1, I get:

square_vector_with_pow() took 0.28 seconds.
square_vector_without_pow() took 0.31 seconds.

Now square_vector_with_pow() is just as fast as square_vector_without_pow()! This is probably because the compiler now recognizes that we are using an integer for pow() and simply does normal multiplication instead. Actually, square_vector_with_pow() is now a bit faster than square_vector_without_pow(), by around 10% - and this is not just a statistical anomaly, since I got similar results after running the program several times. I'm not exactly sure why this happens, but it's a negligible difference in any case.

Going to the next optimization level, -Os, I get similar results. At the next level, -O2, I get:

square_vector_with_pow() took 0.27 seconds.
square_vector_without_pow() took 0.27 seconds.

Now both operations run at essentially the same speed; there was no improvement in square_vector_with_pow(), but a small improvement in square_vector_without_pow(), bringing both functions to the same speed - in fact, it is entirely possible that the machine code for both functions is now exactly the same!

Finally, at the highest optimization level, -O3, I get roughly the same results as -O2.

Importantly, most of the improvement in the performance of square_vector_with_pow() was achieved in the optimization process due to the fact that the compiler essentially replaced element = pow(element, 2) with the much faster element = element * element, which was possible because the second argument to pow() was an integer. However, if I replace 2 with, for example, 2.1, then I get, even with -O3:

square_vector_with_pow() took 36.78 seconds.
square_vector_without_pow() took 0.27 seconds.

Yes, square_vector_with_pow with 2.1 in the exponent now takes almost 37 seconds, with maximum optimizations enabled. This is much worse even compared to the case of 2 in the exponent with no optimizations at all! Essentially, pow() is now forced to calculate using a slow algorithm (e.g. using the Taylor series of ab=eb ln a), and no optimization can possibly make this algorithm any quicker.

8.1.3 Writing optimized code ^

In scientific computing, sometimes your programs will be running calculations that may take days or weeks to complete, even on a high-performance computing cluster. For this reason, writing optimized code that has the fastest performance possible is essential for most scientific application.

You should, of course, enable compiler optimizations - there is usually no reason not to, except when debugging. However, my philosophy is to never trust the compiler to do my job for me, and always write code that is optimized on its own, without relying on compiler optimizations.

Throughout this course, whenever I introduced a new concept, I often gave advice on what is the most optimized way to use it. So keep in mind everything I taught you, and if you are not sure you are implementing something in the most optimized way, go back to that section in the notes and read what I wrote about it.

It is very important to actively check the performance of your code. Try different methods and see what gives the fastest results. If you find that a particular calculation or algorithm is taking an unreasonably long time, you can use a debugger to look under the hood and try to figure out what may be the cause. If maximum performance is desired, you should use a profiler, which can, among other things, automatically measure the duration of each individual function call; but this is beyond the scope of our course.

That being said, you should also remember that in some cases, speed has a cost. One example is using uninitialized arrays. We have seen above, and will also see below that this can improve performance; in most cases, you don't actually need an array to be initialized automatically to zeros (as vector does), because you are going to populate it with other elements anyway. However, we have also seen - and I have stressed multiple times in this course - that if you accidentally use uninitialized garbage values, this can lead to serious bugs.

Another example is using double instead of long double. As I said above, calculations with double are generally much faster than with long double, but they are also less precise. If your application needs maximum precision, then you may need to sacrifice speed and use long double, at least in critical calculations where rounding errors can accumulate.

8.2 Configuring Visual Studio Code tasks ^

8.2.1 The tasks.json file

We have often modified the file tasks.json in the .vscode folder of the workspace to add compiler arguments, but we never really talked about what this file actually does. The tasks.json file allows you to configure various different tasks for integrating Visual Studio Code with external tools. Currently, the only external tool configured in tasks.json is the compiler.

The tasks.json I have been using to compile most of the C++ code in these lecture notes looks as follows:

{
    "version": "2.0.0",
    "tasks": [
        {
            "type": "cppbuild",
            "label": "Build for debugging",
            "command": "C:/Users/barak/mingw64/bin/g++.exe",
            "args": [
                "${workspaceFolder}/*.cpp",
                "-o",
                "${workspaceFolder}/CSE701.exe",
                "-Wall",
                "-Wextra",
                "-Wconversion",
                "-Wsign-conversion",
                "-Wshadow",
                "-Wpedantic",
                "-std=c++20",
                "-ggdb3"
            ],
            "options": {
                "cwd": "${workspaceFolder}"
            },
            "problemMatcher": [
                "$gcc"
            ],
            "group": {
                "kind": "build",
                "isDefault": true
            },
            "detail": "Compile all .cpp files with warning and debugging flags",
            "presentation": {
                "clear": true,
                "echo": true,
                "focus": false,
                "panel": "shared",
                "reveal": "silent",
                "showReuseMessage": true
            }
        }
    ]
}

I am using Windows 11, with GCC installed in the folder C:\Users\barak\mingw64. On your system, some arguments should be changed based on the operating system and the path to the GCC binary folder. Note that this task always creates an executable named CSE701.exe; this needs to be replaced with the actual name of the program, but since the purpose of this task was to quickly compile and create short programs for this course, I just used a default name.

The file format is JSON, which stands for JavaScript Object Notation, but it is used as a generic file format, independent of any specific programming language. It consists of objects enclosed in curly brackets {}, each containing a comma-delimited list of key-value pairs (similar to C++ maps) in the format:

{
    "key": value,
    "key": value,
    // etc..
}

The key must be a string (enclosed in quotes), but the value can be a string, a number, a Boolean value (true or false), a null, an array in the format [element1, element2, ...], or another object enclosed in curly brackets, which will then have its own key-value pairs.

In tasks.json we see that the file consists of a root object with two keys: version and tasks. The key version indicates the version of the tasks.json file, which should be 2.0.0 in the current version of VS Code. The key tasks contains an array (notice the square brackets) whose elements are the actual tasks. Each task is another object enclosed in curly brackets. Currently, there is only one task in the array.

The task object contains the following keys:

  • type: The task's type. cppbuild indicates that the task is a C++ build task, which is specific to the C/C++ extension. Other options are shell, to execute a shell command, and process, to execute a specific program (although shell can also be used to execute programs).
  • label: The task's label, which will be displayed in the user interface when you choose Terminal > Run Task... or press F1 to bring up the Command Pallette and choose Tasks: Run Task. The label should be simple and concise, since it will also be used as an argument to other tasks.
  • command: The command to execute. In this case, the value is the path to the executable file of the g++ compiler. Note that backslashes \ are escape characters, as in C strings, so you need to escape the backslash itself if you want to use it as a normal character, i.e. \\ will result in just one \. So for Windows paths, you can either use a double backslash, e.g. C:\\Users\\..., or (preferably) just a normal slash, e.g. C:/Users/....
  • args: An array of command line arguments to pass to the compiler in the specified order.
  • options: An object specifying options for the task.
    • cwd: Specifies the current working directory. Here I set it to the workspace folder.
    • shell: Specifies which shell to use.
    • env: Specifies environment variables for use in that shell, in the format {"name1": "value1", "name2": "value2", ...}.
  • problemMatcher: The option $gcc allows errors and warnings generated by the GCC compiler to be translated to problems in the Problems tab (Ctrl+Shift+M).
  • group: Defines which group the task belongs to. This can be either build or test, i.e. "group": "build" or "group": "test". Build tasks are for compiling source code, and test tasks are for everything else (in particular, tools used to test the code, like profilers or memory debuggers). In this case, since the task is also the default build task, i.e. the one executed when you choose Terminal > Run Build Task or press Ctrl+Shift+B, the value of group is instead an object with kind set to build and isDefault set to true.
  • detail: This is the text that will appear below the task's label when you choose Terminal > Run Task....
  • presentation: An object with options that control how the output of the task will be presented.
    • clear clears the terminal before running this task if set to true.
    • echo: Will write "Executing task:" followed by the task's label to the terminal if set to true.
    • focus: Will give focus to the terminal used for the task (and also always reveal it) if set to true.
    • panel: shared uses the same terminal panel for all tasks. dedicated uses a dedicated terminal panel for every run of this specific task. new creates a new terminal panel every time the task runs (which is not recommended since it will lead to many panels being created).
    • reveal: Sets when to reveal the terminal used for the task. always will reveal the terminal every time, never will never reveal it, and silent will only reveal it if there were any errors.
    • showReuseMessage will display the message "Terminal will be reused by tasks, press any key to close it" when the task ends.
  • dependsOn: Specifies a task which should run before running this task. The task should be specified using its label string. (See example below.)
    • dependsOn can also be an array of tasks (inside square brackets), in which case all tasks in the array will be executed. The key dependsOrder then specifies whether the tasks should run one after the other (sequence) or simultaneously (parallel).

These are not the only available keys; you can use VS Code's IntelliSense feature to see a full list of the possible keys by pressing Ctrl+Space to trigger code suggestions. Also, hovering with the mouse over a key will provide an explanation of what it does.

Any string of the form ${variable} will be replaced with the value of variable. For example, ${workspaceFolder} will be replaced with the currently active workspace folder, which in my case is "C:/Users/barak/CSE701". Therefore, with args configured as above, the command that will be executed is:

C:/Users/barak/mingw64/bin/g++.exe "C:/Users/barak/CSE701/*.cpp" -o "C:/Users/barak/CSE701/CSE701.exe" -Wall -Wextra -Wconversion -Wsign-conversion -Wshadow -Wpedantic -std=c++20 -ggdb3

Notice that the first argument is the name of the source file (or source files, in this case), and the -o argument (lowercase o!) indicates the name of the output executable file. VS Code is smart enough to know to add quotes in any of the variables have spaces in them, so that the spaces won't be interpreted as starting new arguments.

${workspaceFolder} is just one of many variables we can use in tasks; see here for a full list of available variables and how to use them.

8.2.2 The launch.json file ^

The file launch.json is used to configure debugging in Visual Studio Code. The launch.json I have been using to debug most of the C++ code in these lecture notes looks as follows:

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Build and debug",
            "type": "cppdbg",
            "request": "launch",
            "program": "${workspaceFolder}/CSE701.exe",
            "args": [],
            "stopAtEntry": false,
            "cwd": "${workspaceFolder}",
            "environment": [],
            "externalConsole": false,
            "MIMode": "gdb",
            "miDebuggerPath": "C:/Users/barak/mingw64/bin/gdb.exe",
            "setupCommands": [
                {
                    "description": "Enable pretty-printing for gdb",
                    "text": "-enable-pretty-printing",
                    "ignoreFailures": true
                }
            ],
            "preLaunchTask": "Build for debugging"
        }
    ]
}

version is again the version of the file, which should be 0.2.0 in the current version of VS Code. configurations is an array of objects corresponding to debugging configurations, where each object has the following keys:

  • name: The name of the configuration. It will appear in the dropdown menu on top of the Run view.
  • type: The type of the configuration. Should be cppdbg for C++ debugging with GDB (or LLDB).
  • request: The request type of the configuration. Should be launch for C++ debugging, which means a new process will be launched by the debugger. Can also be attach, which means the debugger will attach itself to an existing process.
  • program: The program to execute. Should be the same as the output executable file we specified with the -o compiler argument.
  • args: An array of command line arguments to pass to the program being debugged. This is useful if your program takes command line arguments, since you will be launching it from the debugger rather than the command line.
  • stopAtEntry: Automatically puts a breakpoint in the first line of the main function if set to true.
  • cwd: The working directory of the program being debugged. This is useful if your program opens files in that directory. Should usually be set to ${workspaceFolder}.
  • environment: An array of objects of the form {"name": "variable_name", "value": "variable_value"}, specifying any environment variables to be used when running the program.
  • externalConsole: If set to true, opens the program in an external console, rather than VS Code's integrated terminal (Ctrl+`). Can be more convenient in some cases, e.g. when you want to resize the terminal to take up the entire screen.
  • MIMode: Indicates which debug engine to connect to. Should be gdb if using GDB.
  • miDebuggerPath: Indicates the path to the debug engine.
  • setupCommands: An array of commands to execute in GDB. Currently, the option -enable-pretty-printing is set, which tells GDB to indent the structures it prints.
  • preLaunchTask: Indicates a task to run before starting the debug session. This should be a label corresponding to a build task defined in tasks.json, and that task should of course have compiler arguments appropriate for debugging, such as -ggdb3.

More information on options in launch.json relevant for C++ debugging may be found here.

8.2.3 Setting up additional tasks for your project ^

Tasks are a very useful feature in Visual Studio Code, and if used correctly can save you a lot of time and typing! As an example, let us set up the following tasks in Visual Studio Code:

  1. A test task to delete the executable file generated by the build task.
  2. The default build task defined above, for general debugging purposes, but modified to delete the executable before building.
  3. A build task to compile the program with optimization flags and no warning or debugging flags.
  4. A test task to both compile and run the optimized program.

In the end, the tasks.json file should look like this:

{
    "version": "2.0.0",
    "tasks": [
        {
            // Task 1
        },
        {
            // Task 2
        },
        {
            // Task 3
        },
        {
            // Task 4
        }
    ]
}

First, let us create a keyboard shortcut to open the list of tasks. For some reason this does not appear in the list that you get by pressing F1 > "Preferences: Open Keyboard Shortcuts" or Ctrl+K Ctrl+S. However, you can click on a button on the top right of the Keyboard Shortcuts page, or press F1 and run the command "Preferences: Open Keyboard Shortcuts (JSON)", which will open the file keybindings.json. Add the following object to the array:

{
    "key": "ctrl+f1",
    "command": "workbench.action.tasks.runTask"
}

I chose Ctrl+F1, because it is similar to F1 which brings up the Command Pallette, but you can of course choose any keybinding you like.

Now, let us create the tasks. Task #1 is a test task to delete the executable file generated by the build task:

{
    "type": "shell",
    "label": "Delete executable file",
    "command": "cmd /c \"if exist ${workspaceFolder}\\CSE701.exe del ${workspaceFolder}\\CSE701.exe\"",
    "args": [],
    "options": {
        "cwd": "${workspaceFolder}"
    },
    "group": "test",
    "detail": "Delete the executable file generated by the build task",
    "presentation": {
        "clear": false,
        "echo": true,
        "focus": false,
        "panel": "shared",
        "reveal": "silent",
        "showReuseMessage": true
    }
},

The command first checks if the executable file exists, and if so, deletes it (otherwise you will get an error message if the file does not exist). This command will work on Windows; for Linux you can use [ -f ${workspaceFolder}/CSE701 ] && rm ${workspaceFolder}/CSE701 or a similar shell command ([ -f filename ] is a quick way to check if filename exists).

Task #2 is the same build task from above, modified via the dependsOn key to delete the executable using the "Delete executable file" task before building:

{
    "type": "cppbuild",
    "label": "Build for debugging",
    "command": "C:/Users/barak/mingw64/bin/g++.exe",
    "args": [
        "${workspaceFolder}/*.cpp",
        "-o",
        "${workspaceFolder}/CSE701.exe",
        "-Wall",
        "-Wextra",
        "-Wconversion",
        "-Wsign-conversion",
        "-Wshadow",
        "-Wpedantic",
        "-std=c++20",
        "-ggdb3"
    ],
    "options": {
        "cwd": "${workspaceFolder}"
    },
    "problemMatcher": [
        "$gcc"
    ],
    "group": {
        "kind": "build",
        "isDefault": true
    },
    "detail": "Compile all .cpp files with warning and debugging flags",
    "presentation": {
        "clear": true,
        "echo": true,
        "focus": false,
        "panel": "shared",
        "reveal": "silent",
        "showReuseMessage": true
    },
    "dependsOn": "Delete executable file"
},

The reason I like to configure the build task to delete the executable first is that sometimes there are errors during the build, so a new executable will not be created, but the debugger will still run using the previously-built executable, which is generally not what you want.

Task #3 is a build task to compile the release version of the program, with the warning and debugging flags disabled, and the optimization flag -O2 enabled:

{
    "type": "cppbuild",
    "label": "Build optimized",
    "command": "C:/Users/barak/mingw64/bin/g++.exe",
    "args": [
        "${workspaceFolder}/*.cpp",
        "-o",
        "${workspaceFolder}/CSE701.exe",
        "-std=c++20",
        "-O2"
    ],
    "options": {
        "cwd": "${workspaceFolder}"
    },
    "problemMatcher": [
        "$gcc"
    ],
    "group": "build",
    "detail": "Compile all .cpp files with optimization flags",
    "presentation": {
        "clear": true,
        "echo": true,
        "focus": true,
        "panel": "shared",
        "reveal": "always",
        "showReuseMessage": true
    },
    "dependsOn": "Delete executable file"
},

I also set the terminal to always be focused and revealed, so I can make sure the build was successful.

Finally, Task #4 is a test task to first compile the program using the task "Build optimized" and then run it:

{
    "type": "process",
    "label": "Build and run optimized",
    "command": "${workspaceFolder}/CSE701.exe",
    "args": [],
    "problemMatcher": [
        "$gcc"
    ],
    "group": "build",
    "detail": "Compile all .cpp files with optimization flags and then run the program",
    "presentation": {
        "clear": true,
        "echo": false,
        "focus": true,
        "panel": "dedicated",
        "reveal": "always",
        "showReuseMessage": false
    },
    "dependsOn": "Build optimized"
},

In this case, I set the task to run in a dedicated terminal that will be automatically focused on, and without any additional messages (echo and showReuseMessage disabled), so that I can see the precise output of the program.

The task "Build for debugging" can be executed using Ctrl+Shift+B. It will also be executed automatically when you press F5, in which case VS Code will also run the built program in debug mode. To execute any of the other tasks, you need to use the shortcut key you defined before (Ctrl+F1) and choose the task from the menu. You can also define a keyboard shortcut for a specific task. For example, to compile and run the optimized program with F6, add the following to keybindings.json:

{
    "key": "f6",
    "command": "workbench.action.tasks.runTask",
    "args": "Build and run optimized"
},

8.2.4 Using shell scripts ^

With the tasks we configured in the previous section, pressing F6 runs 3 tasks in sequence:

  1. Delete executable file
  2. Build optimized
  3. Build and run optimized

It works, but it's a bit cumbersome. The problem is that Visual Studio Code's tasks are limited to performing just one command, and not a series of commands. However, there is an easy solution, offered by the operating system itself - we can create a shell script (also called a batch file on Windows) which will simply execute the commands one after the other. We can then either run the script manually from the terminal, or configure a VS Code task that will run the script with one click. This will give us much more flexibility than defining separate tasks.

Create a new file in the Explorer view of Visual Studio Code and name it build_run_optimized.cmd on Windows or build_run_optimized on Linux. VS Code will recognize that we are creating a shell script and will provide syntax highlighting.

On Windows, the contents of the file build_run_optimized.cmd should be:

@echo off
if exist CSE701.exe del CSE701.exe
g++ *.cpp -o CSE701.exe -std=c++20 -O2
CSE701

The first line, @echo off, tells the shell not to write down each command in the terminal, and instead just show the output of each command. To run the script, simply write build_run_optimized in the terminal. If the script doesn't run correctly, note that:

  • The script must be in the same directory as the .cpp files.
  • The folder where g++ is located must be in the PATH environment variable, which should already be the case if you followed my instructions above. If PATH is not configured correctly, try adding an explicit path, e.g. in my case I would replace g++ with C:/Users/barak/mingw64/bin/g++.

On Linux, the contents of the file build_run_optimized should be:

#!/bin/bash
[ -f CSE701 ] && rm CSE701
g++ *.cpp -o CSE701 -std=c++20 -O2
./CSE701

The differences are:

  • Windows determines how to run a file based on its extension. Therefore, the .cmd extension indicated to Windows to treat the file as a shell script. On Linux, the extension doesn't matter, and you specify which program should be used to execute the file by writing the program's name prefixed by the characters #! in the beginning of the file. Here we are choosing to run the script using the bash shell, which is located at /bin/bash. (Also note that VS Code will only recognize the file as a shell script after you add this line.)
  • On Windows, if you type CSE701 or build_run_optimized, it will automatically recognize that you mean CSE701.exe or build_run_optimized.cmd respectively. However, on Linux you must type the full name of the file, so it's more convenient for both executable files and shell scripts to have no extensions at all. This is why the program's executable is named CSE701 instead of CSE701.exe, and the script is named build_run_optimized without an extension.
  • Linux shells do not recognize programs in the current directory as valid shell commands. You can only run programs that are in the PATH environment variable, unless you specify the full path explicitly. For this reason, we had to write ./CSE701 instead of just CSE701, since . stands for the current directory, so adding ./ in front of the command specifies the full path of the file as being in the current directory. (Note that Windows PowerShell has the same behavior.)
  • We replaced if exist CSE701.exe with [ -f CSE701 ] &&, which is the Linux command for checking if a file exists and executing a task if it does, and del with rm, which is the Linux command for deleting a file (short for "remove").

To run the shell script on Linux, we need to make it executable first. As we explained above, on Windows, any file with an extension such as .exe or .cmd is automatically executable, but on Linux, whether a file is executable or not is independent of its extension. To make the file build_run_optimized executable, write the following in the terminal:

chmod +x build_run_optimized

The command chmod is used to change the access permissions of the file. +x means "add (+) executable (x) permissions". Once the file is marked as executable, write ./build_run_optimized to run it.

Most high-performance computing systems use some form of Linux as their operating system, so even if your own computer runs Windows, you should be familiar with some basic Linux shell commands. Wikipedia has a comprehensive list of shell commands, most of which have separate Wikipedia articles. For a beginner-friendly introduction, see for example here or here.

Even if you are already a Linux user, but you're used to graphical desktop environments such as GNOME or KDE, you will still need to learn some shell commands, as sometimes the only method of access you will get to a high-performance computing cluster will be through a textual terminal.

Finally, we can modify the "Build and run optimized" task in Visual Studio Code to use our script, since it's more convenient to press F6 than to go to the terminal and write build_run_optimized. On Windows, the task will be:

{
    "type": "process",
    "label": "Build and run optimized",
    "command": "${workspaceFolder}/build_run_optimized.cmd",
    "args": [],
    "problemMatcher": [
        "$gcc"
    ],
    "group": "build",
    "detail": "Compile all .cpp files with optimization flags and then run the program",
    "presentation": {
        "clear": true,
        "echo": false,
        "focus": true,
        "panel": "dedicated",
        "reveal": "always",
        "showReuseMessage": false
    }
}

8.3 Customizing the compilation process ^

8.3.1 The stages of compilation

For the discussion in this section, let us use the following multi-part "Hello, World!" program, consisting of 3 files:

hello.hpp:

// Function to print "Hello, World!"
void hello_world();

hello.cpp:

#include <iostream>

// Function to print "Hello, World!"
void hello_world()
{
    std::cout << "Hello, World!\n";
}

main.cpp:

#include "hello.hpp"

// Print "Hello, World!" using the hello_world() function
int main()
{
    hello_world();
}

In the process of converting this C++ source code into an executable file, it undergoes 4 stages of compilation:

Stage #1: Preprocessing. In this stage, the source code is "purified" by:

  • Removing any whitespace characters from the code,
  • Removing comments from the code,
  • Interpreting preprocessor directives (lines that start with #), such as replacing #include statements with the actual contents of the header file,
  • Performing other tasks, as detailed in the C++ reference.

In order to see the output of the preprocessing stage when compiling the program above, let us execute the following commands in the terminal:

g++ main.cpp -o main.ii -E
g++ hello.cpp -o hello.ii -E

The -o compiler argument indicates the output file, as usual, while the -E argument indicates that we are only interested in going through the preprocessing stage and not any other stages.

This will create two files, main.ii and hello.ii. The extension .ii indicates that the files contain preprocessed C++ source code. Here are the contents of main.ii:

# 0 "main.cpp"
# 0 "<built-in>"
# 0 "<command-line>"
# 1 "main.cpp"
# 1 "hello.hpp" 1

void hello_world();
# 2 "main.cpp" 2

int main()
{
    hello_world();
}

As you can see, comments have been removed, and the file hello.hpp has been explicitly included in the .ii file. Similarly, if you open hello.ii, you will see that the entire contents of the iostream header file have been explicitly included in the code (I won't show the result here since that file has thousands of lines). This means that to compile the program, we will only need the two .ii files; the individual header or source files are no longer required.

Stage #2: Compilation to assembly language. In this stage, the preprocessed C++ source code in the .ii files is checked for errors, and if it is valid, it is compiled to assembly language. This is still human-readable code, except it is written directly in terms of the instructions that the CPU will execute.

In order to see the output of this stage, let us execute the following commands in the terminal:

g++ main.ii -o main.s -S
g++ hello.ii -o hello.s -S

The -S compiler argument indicates that the compiler should stop after compilation to assembly, and not go to the next stage. In addition, since the input files have the extension .ii, the compiler automatically knows not to preprocess them, so in fact compilation to assembly language is the only stage that is executed.

This will create two files, main.s and hello.s. The extension .s indicates that the files contain code in assembly language. If you open main.s, you may see something similar to:

    .file   "main.cpp"
    .text
    .def    __main;  .scl    2;    .type    32;    .endef
    .globl  main
    .def    main;    .scl    2;    .type    32;    .endef
    .seh_proc    main
main:
.LFB0:
    pushq   %rbp
    .seh_pushreg    %rbp
    movq    %rsp, %rbp
    .seh_setframe    %rbp, 0
    subq    $32, %rsp
    .seh_stackalloc    32
    .seh_endprologue
    call    __main
    call    _Z11hello_worldv
    movl    $0, %eax
    addq    $32, %rsp
    popq    %rbp
    ret
    .seh_endproc
    .def    _Z11hello_worldv;    .scl    2;    .type    32;    .endef

This is, specifically, assembly code for the x86_64 (Intel and AMD) architecture, so if you have a CPU with the ARM architecture, the code will be different. Keywords with the . prefix are directives that are not actually executed by the CPU (much like the # prefix in C++), and keywords without this prefix are instructions.

If you are interested in reading about how assembly language works, Wikibooks has a nice book about it; in particular, this page analyzes a (simpler) "Hello, World!" program. Also, if you want syntax highlighting for assembly language in VS Code, check out this extension.

This assembly code doesn't actually do much except call the hello_world function, which is done in the line call _Z11hello_worldv. However, notice that this function itself is not defined here, but rather in the hello.s file (which I won't reproduce here, since it's a bit long). The two functions, main and hello_world, will be linked together in the last compilation stage.

Stage #3: Assembly. In this stage, the assembly language code is translated from human-readable words into low-level machine code, the binary code that the CPU will actually execute. This machine code, in addition to information about the symbols (e.g. functions) defined in the code, is stored in an object file. Each C++ source file results in a separate object file.

In order to see the output of the assembly stage, let us execute the following commands in the terminal:

g++ main.s -o main.o -c
g++ hello.s -o hello.o -c

The -c arguments indicates that the compiler should stop after the assembly stage, and not go to the next stage. In addition, since the input files have the extension .s, the compiler knows they are already in assembly language, so in fact the assembly stage is the only stage that is executed.

This will create two files, main.o and hello.o. The extension .o indicates that the files are object files. These are binary files, but you can open them using the Microsoft Hex Editor extension for Visual Studio Code to see what's inside. You will also see some text, including names of symbols like _Z11hello_worldv, and the string "Hello, World!" itself.

Stage #4: Linking. In this stage, the object files are combined into one executable file. Even though the object files contain actual machine code, they are not executable by themselves. The reason is that main.o calls the function hello_world, which is in hello.o. The linking stage links the object files, so that they can call each other's functions.

In order to see the output of the linking stage, let us execute the following command in the terminal:

g++ main.o hello.o -o hello.exe

On Linux, use hello instead of hello.exe. Note that now there is only one command, since the compiler needs to combine all of the object files together in one go. Since the input files have the extension .o, the compiler knows they are already object files, so the linking stage is the only stage that is executed. The file hello.exe is the final executable file, which we can execute by typing hello on Windows Command Prompt or ./hello on Linux or Windows PowerShell. You can also look inside with the hex editor if you want.

When we call g++ without any of the flags -E, -S, or -c, and with the .cpp source files as arguments, it actually goes through all of these four stages in order, producing the .ii, .s, and .o files corresponding to each .cpp file, and finally links all of the .o files together.

8.3.2 Preprocessor directives ^

A preprocessor directive is a line in a C++ source file that starts with the # character, followed by a keyword. Preprocessor directives are only executed in the preprocessing stage, so they are typically used to modify the source code before the actual compilation starts. The keywords include:

  • #include <filename>: Includes a file from the standard include directories, which usually contain the header files for the standard library.
  • #include "filename": Includes a file from the current folder.
  • #error message: Stops compilation, and displays message.
  • #define MACRO replacement: Replaces every occurrence of MACRO in the file with replacement. Potentially, a parameter list inside parentheses can follow MACRO. It is conventional for macros to always be in all uppercase letters in order to distinguish them from other names and keywords.
  • #undef MACRO: Undefines a previously defined macro.

Here is an example of macros:

#include <iostream>

#define MESSAGE "Macros are evil!"
#define OUTPUT(STRING) std::cout << (STRING)

int main()
{
    OUTPUT(MESSAGE);
}
Warning: Macros are evil, and you should never use them! Macros cannot be debugged, do not respect scopes or namespaces, do not comply with data types, and can cause unexpected errors in many different ways. You will often find macros in older code, which is why it's important to understand how they work, but in modern C++ they are very uncommon. Constant expressions, inline functions (example here), and/or templates provide all of the benefits of macros without any of the drawbacks, and should always be used instead.
  • #if expression: Checks if expression is true, and if so, includes any code that follows, until an #endif, #else, or #elif is encountered.
  • #else: Is to #if as else is to if. There can be at most one #else in a conditional block.
  • #elif expression: Is to #if as else if is to if. There can be an unlimited number of #elif in a conditional block.

Here is an example of conditional code inclusion:

#include <iostream>

int main()
{
#if HELLO
    std::cout << "Hello, World!\n";
#elif GOODBYE
    std::cout << "Goodbye, World!\n";
#else
#error No message to print!
#endif
}

Note that Visual Studio Code will automatically dim any lines that are not included in the code - in this case, the two cout statements. Also note that due to the use of #error, this code will not compile (compilation will stop at the preprocessing stage) unless we include either a #define HELLO true or #define GOODBYE true.

  • #ifdef MACRO: Checks if MACRO is defined, and if so, includes any code that follows, until an #endif, #else, or #elif is encountered.
  • #ifndef MACRO: Checks if MACRO is defined, and if not, includes any code that follows, until an #endif, #else, or #elif is encountered.

8.3.3 Preventing double inclusion of header files ^

Consider the following set of files:

main.cpp:

#include "print.hpp"
#include "test.hpp"

int main()
{
    print("Testing:\n");
    test();
}

print.hpp:

#include <iostream>

void print(const char *message)
{
    std::cout << message;
}

test.hpp:

#include "print.hpp"

void test()
{
    print("Test successful!\n");
}

This program won't compile, because we are including print.hpp twice, once in main.cpp and once in test.hpp, so the function print is declared twice.

A temporary solution is to remove the line #include "print.hpp" from one of the files main.cpp or test.hpp, but then we are relying on the fact that the other file includes print.hpp, which may change in the future. Furthermore, if we remove the line #include "print.hpp" from test.hpp, then other source files that also use test.hpp may break.

The best solution is to simply add the preprocessor directive #pragma once in print.hpp, which instructs the preprocessor to only include the file once, even if we try to include it more than once. The fixed version of print.hpp is:

#pragma once
#include <iostream>

void print(const char *message)
{
    std::cout << message;
}

Now the program will compile without issue. Generally, it is a good idea to automatically add #pragma once to every single header file in the project, even ones that are not included twice, to avoid problems in the future. Note that #pragma once is technically not part of the C++ standard, but it is nonetheless supported by the vast majority of modern compilers, including GCC.

In older C++ code, before use of #pragma once became widespread, people employed include guards, which used macros to obtain the same results. For example, in print.hpp, we could write:

#ifndef PRINT_HPP
#define PRINT_HPP
#include <iostream>

void print(const char *message)
{
    std::cout << message;
}
#endif // PRINT_HPP

This method has two main drawbacks:

  1. It requires using a different macro name for each file (e.g. PRINT_HPP for print.hpp and TEST_HPP for test.hpp), which may cause confusion if we rename the files and/or conflict if we accidentally use the same name twice in different files.
  2. It is cumbersome; we need to add three lines, in both the beginning and end of the file, and the lines must be specific to each file.

You will often see include guards in older code, and in code written for special compilers (e.g. for embedded systems) that may not support #pragma once. However, in modern C++, #pragma once is always preferred, since it only requires adding one fixed line to the beginning of each file, thus avoiding the two drawbacks listed above.

Furthermore, using #pragma once can improve compilation time, since the preprocessor doesn't need to read the entire file, only the first line; if you use include guards, then the preprocessor is forced to read the entire file until it gets to the #endif (although compilers often recognize include guards as a special case and optimize them accordingly).

Finally, let us note that starting from C++20, modules provide a modern alternative to header files, which eliminates many problems associated with their use, including double inclusion. Since modules are a new concept that has not yet been fully implemented by compilers or fully adopted by developers, we will not cover it in this course.

8.3.4 Using CMake ^

So far, we have been using Visual Studio Code's tasks to build our programs. This is very convenient for small and simple projects that we work on alone, but:

  1. If we are working on a large project, with many source files and complicated dependencies between them, we will need a more sophisticated build system. In particular, a task that is configured to build all .cpp files in the workspace will do exactly that every time it is executed, so even if we only changed one file, all the other files will also be recompiled upon running the task, which will be very time-consuming.
  2. If we are collaborating with others, or posting our source code online for others to use, then we must take into account that other users may not be using the same IDE, compiler, operating system, or even CPU architecture. Therefore, we must use a universal build system that will work for everyone, that is, it must be cross-platform, IDE-independent, and compiler-independent.

Issue #1 can perhaps be resolved by using a more advanced IDE, such as Microsoft Visual Studio, which can handle the complexities of large projects. However, this does not resolve issue #2 - in fact, it only makes it worse, since unlike VS Code, Visual Studio is not cross-platform.

Luckily, both issues can be resolved by using the cross-platform tool CMake. With only a single CMake configuration file, we can allow any user to compile our code using their IDE, compiler, operating system, and CPU architecture of choice. For this reason, CMake has become an essential tool for modern cross-platform C++ development.

To use CMake, first download and install the binaries for the latest CMake release (3.21.2 at the time of writing) from the CMake website. Alternatively, you may use your favorite package manager. However, note that on Ubuntu, apt will usually not provide the latest version of CMake, so it's better to install it directly from the CMake website. On Windows, using winget, you can install the latest version by simply typing winget install cmake.

To integrate CMake into Visual Studio Code, install the CMake language extension (for syntax highlighting) and the CMake Tools extension. You are now ready to use CMake in your projects. As an example, we will use the following multi-part "Hello, World!" program:

hello.hpp:

#pragma once

class hello_world
{
public:
    hello_world();
};

hello.cpp:

#include <iostream>
#include "hello.hpp"

hello_world::hello_world()
{
    std::cout << "Hello, World!\n";
}

main.cpp:

#include "hello.hpp"

int main()
{
    hello_world h;
}

To configure this program with CMake, create a file named CMakeLists.txt in the workspace folder with the following contents:

cmake_minimum_required(VERSION 3.1.0)
project("Hello World")
add_executable(hello main.cpp hello.cpp hello.hpp)

Let us go over the commands:

  • cmake_minimum_required sets the minimum version of CMake required to build the project. Here we set it to 3.1.0, but if you use any features introduced in later versions, you will need to increase the minimum version requirement.
  • project sets the name of the project to "Hello World".
  • add_executable adds an executable to the project. In this case, we have just one executable. The first argument is the target name, which is also the executable file name, and must not contain any spaces. For example, on Windows the executable file will be hello.exe, while on Linux it will just be hello. The other arguments are the source files and header files required to compile this executable.

After you save the file, press F1 to open the Command Palette and run the command "CMake: Configure". You will be prompted to select a kit, which specifies the compiler to be used to compile the program on your system. If you don't see GCC on the list, try clicking on the option "[Scan for kits]" first, and if the GCC binaries are in your PATH environment variable (as they should be), CMake Tools will find GCC on its own. Once GCC is on the list, click on it to perform the configuration.

A new icon will be added for CMake in the side bar. If you click on it, you will see the project "Hello World" and a list of the executables in the project (in this case, just one: hello) and the source files associated with them. In addition, a folder named build will automatically be created with various files required to build the project.

Warning: Do not edit any files in the build folder; they are all generated automatically, so if you change them and then reconfigure the project, the files will be replaced and your changes will be lost.

The project is now configured, and to create the executable file, we should build the project. This can be done in several ways:

  • Press F1 and execute the command "CMake: Build" from the Command Palette.
  • Go to the CMake view in the side bar and click on the Build button. (The label "Build" will appear when you hover over the button with the mouse.)
  • Or simply press F7.

The project will be built using the kit you selected, and an executable file (hello.exe on Windows or hello on Linux) will be generated in the build folder. The object files, which were previously called main.o and hello.o, will be in the subfolder build/CMakeFiles/hello.dir, under the names main.cpp.obj and hello.cpp.obj. For each .cpp file you will also find a corresponding .d file which contains its dependencies, i.e. which source files it requires in order to successfully compile.

To actually run the executable, you can:

  • Execute the command "CMake: Run Without Debugging" from the Command Palette, or press Shift+F5, to run without debugging.
  • Execute the command "CMake: Debug", or press Ctrl+F5, to debug.

However, you do not have to build the project every time you want to run it. When you run or debug the project, CMake will check if the executable exists, and if it doesn't, it will automatically build it. You can check this by cleaning up the compiled executable and object files using the command "CMake: Clean" and then pressing Shift+F5 to run the program; it will be built and then run.

Let us now discover another useful feature of CMake. First of all, try to build the project again (F7) without changing any files, and you will see in the Output panel in VS Code that nothing actually happens; the build will start at 100%. (If you don't see the Output panel, press Ctrl+Shift+U, and if you don't see the CMake output, choose CMake/Build from the menu at the top right of the panel.)

Now, change the file main.cpp - or even just press Ctrl+S to save the same file with a newer modification date. Then try to build the project again. CMake will detect that main.cpp was modified since the last build, so it will recompile only this file. hello.cpp was not modified, so there is no need to recompile it. This means that there will be a new main.cpp.obj file, but the hello.cpp.obj file will be the same one from the last build. Both files will then be linked to create the final executable.

Similarly, let us now change the file hello.cpp. When we press F7 we will see that, again, only the modified file has been recompiled. If we had 100 files, and we only changed one, then only that file (and anything that depends on it) will need to be recompiled, considerably speeding up the build time.

Finally, let us change the file hello.hpp. This time, when we build the project, both .cpp files will be recompiled, because both of them depend on the header file hello.hpp.

8.3.5 Customizing CMake ^

The file build/compile_commands.json contains the actual commands used to compile each .cpp file. Notice that the commands do not include our usual compiler flags. For example, the program does not compile with C++20 support by default. To check that, let us add the line char8_t c; to main.cpp and press F7 to build the project. Since the char8_t type was only added in C++20, the program will now fail to compile.

We cannot just tell CMake to add the flag -std=c++20 to the compilation, since other compilers might require a different flag, and that would defeat the purpose of using a cross-platform and cross-compiler build system. Instead, we should tell CMake that the program requires C++20, and it will automatically make sure the compiler gets the correct flag, no matter which compiler we use.

Let us change CMakeLists.txt to the following:

cmake_minimum_required(VERSION 3.1.0)
project("Hello World")
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
add_executable(hello main.cpp hello.cpp hello.hpp)

The command set sets the value of a variable. We added three variables:

  • CMAKE_CXX_STANDARD is a variable indicating the C++ standard used by all targets. We set it to 20, meaning C++20.
  • CMAKE_CXX_STANDARD_REQUIRED is a boolean variable which, if set to ON (which is the same as true), indicates that the files must be compiled with the given C++ standard.
  • CMAKE_CXX_EXTENSIONS is a boolean variable which, if set to OFF (which is the same as false), disables compiler-specific extensions. In the case of GCC, if we do not set this variable to OFF, CMake will use the flag -std=gnu++20, which enables GCC-specific extensions; we have not been using this flag in our course because we want our programs to be maximally portable, so we want to make sure we only use standard C++ code, which can be compiled with any standard-compliant C++ compiler. If we set this variable to OFF, CMake will use -std=c++20, which is the flag we have been using so far, and means we are only using standard C++20 and nothing else.

By default, the CMake Tools extension should reconfigure the project automatically as soon as you save the file CMakeLists.txt. If it doesn't, simply run the command "CMake: Configure". If you now open the file compile_commands.json again you will see that the -std=c++20 flag has been added to the command. If you press F7 to build the project, it will indeed compile without errors, and Shift-F5 will run it successfully.

Usually, we also want to add warning flags to the compiler. However, again, the warning flags that work for GCC won't necessarily work for other compilers. We can, however, add something like this to CMakeLists.txt:

if (CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
    add_compile_options(-Wall -Wextra -Wconversion -Wsign-conversion -Wshadow -Wpedantic)
endif()
if (CMAKE_CXX_COMPILER_ID MATCHES "MSVC")
    add_compile_options(/Wall)
endif()

Make sure to add this before the add_executable command. This code first checks if the compiler being used is GCC (GNU) or Clang, and if so, adds the flags -Wall -Wextra -Wconversion -Wsign-conversion -Wshadow -Wpedantic, which mean the same thing in both compilers. It then checks if the compiler is MSVC, and if so, adds the flag /Wall, which is roughly equivalent to -Wall in GCC.

If CMake is using another C++ compiler, no flags will be added; we could in principle add appropriate warning flags for every C++ compiler in existence, but GCC, Clang, and MSVC are the three most popular ones, so this should be enough for now.

After adding these lines, reconfigure the project, and you will see that the warning flags have been added in compile_commands.json. If you press F7 to build the project, you will now see a warning in VS Code about the variable c, which we defined above, not being used in the program - indicating that the warning flags have indeed been applied.

It's always a good idea to make sure your program compiles successfully, without any warnings or errors, using all popular compilers. If you have Clang and/or MSVC installed on your system, you can switch to them using the command "CMake: Select a Kit" and build the project to verify that the project indeed compiles.

Finally, let us give a quick example of compiling multiple executable files at once using CMake. Create a file main2.cpp with the following contents:

#include "hello.hpp"

int main()
{
    hello_world h1;
    hello_world h2;
}

Then add the following line to CMakeLists.txt:

add_executable(hello2 main2.cpp hello.cpp hello.hpp)

If you now configure and build the project, you will see two executable files in the build folder: hello and hello2 (with .exe added on Windows). You can check that running hello2 prints the "Hello, World!" message twice, as expected. Furthermore, if we change main2.cpp and rebuild the project, only hello2 will be recompiled.

CMake can do much more, but unfortunately we won't have time to go into its other features in this course. Please see the official CMake documentation for more information. There are also many tutorials that you can find online.

8.4 Documentation ^

8.4.1 Creating documentation using Markdown

Markdown is a very simple markup language that can be used to write formatted text in a plain text editor. Markdown can be displayed natively by a variety of different code editors, including Visual Studio Code. It can also be easily converted to HTML for displaying in web browsers. In fact, these notes are written entirely in Markdown!

Markdown is very commonly used when documenting source code and programs. While you may have been using Microsoft Word or LaTeX to write documents so far, these formats are intended for printing, so they are not suitable for writing documentation for code, which most people will be viewing on their computer screens.

Formatting in Markdown files is done by inserting special characters such as # for headings and * for lists. The following example illustrates some basic markdown syntax:

# Heading 1
## Heading 2
### Heading 3

Normal font, **bold** font, *italic* font

1. Ordered
2. List
3. Of
4. Items

* Unordered
* List
* Of
* Items

Inline code: `#include <iostream>`

Inline math: $\sin^2 \theta + \cos^2 \theta = 1$

Non-inline math:

$$\int f(x) \mathrm{d} x$$

Link: [the course website](https://baraksh.com/CSE701/)

Image (with alt text): ![the course logo](https://baraksh.com/CSE701/CSE701.png)

You can also add multi-line code blocks by putting them between two ```, optionally adding the name of the language (e.g. c for C or cpp for C++) after the first ``` to enable syntax highlighting for that language. For example:

```cpp
int main()
{
    std::cout << "Hello, World!\n";
}
```

will display as:

int main()
{
    std::cout << "Hello, World!\n";
}

For more Markdown syntax, the Markdown Guide provides a very useful reference. Also, for more complicated formatting, you can simply add HTML tags.

When editing a .md in Visual Studio Code, you will automatically get syntax highlighting - even for code blocks in different languages within the Markdown file itself. Furthermore, if a Markdown file is open in the editor, there will be a button in the top-right corner labeled "Open Preview to the Side", which is also accessible by pressing Ctrl+K V. This will display the formatted output, which will be updated automatically as you change the source, and scrolling either the source or the preview will scroll the other as well.

You may also be interested in the following VS Code extensions:

  • Markdown All in One includes many useful features such as keyboard shortcuts (e.g. press Ctrl+B to format text as bold), table of contents generation, automatic numbering of lists, and so on.
  • markdownlint will warn you if you are using Markdown incorrectly or violating some formatting conventions. It produces a lot of warnings, and not all of them are useful, but specific warnings can be turned off in the settings.

8.4.2 Documenting your code using Doxygen ^

Doxygen is a tool that automatically generates documentation for your code based on special comments in the code itself. Even if you don't generate the separate documentation, the Doxygen comments themselves provide a structured and well-defined way to document your code. Furthermore, Visual Studio Code can parse these comments and display them in tooltips as part of its IntelliSense feature.

Let us first install the Doxygen Documentation Generator extension for Visual Studio Code, which will make adding the Doxygen comments to the code easier by automatically generating comments on-the-fly in the appropriate format. To demonstrate how it works, let us use the following code:

#include <iostream>
using namespace std;

double add(const double &a, const double &b)
{
    return a + b;
}

int main()
{
    cout << add(1, 2);
}

Once you installed the Doxygen Documentation Generator extension, go to the line before the function add, write /**, and press Enter. This will automatically generate the following comment:

/**
 * @brief
 *
 * @param a
 * @param b
 * @return double
 */

This is a standard format for Doxygen comments, as well as comments for other automatic documentation generators, which originated with Javadoc. The comment must be a multi-line comment with /** in the first line (which has an extra * compared to a usual multi-line comment), and keywords prefixed with @ are called tags or commands. You can find a full list of the available tags in the Doxygen documentation.

Here we can see that three types of tags were automatically generated for us:

  • @brief provides a brief description of the function.
  • @param followed by the name of a function argument describes the role of that argument.
  • @return describes the return value of the function.

For our add function, let us use the following descriptions:

#include <iostream>
using namespace std;

/**
 * @brief Adds two numbers.
 *
 * @param a The first number to add.
 * @param b The second number to add.
 * @return The sum of the numbers.
 */
double add(const double &a, const double &b)
{
    return a + b;
}

int main()
{
    cout << add(1, 2);
}

If we now hover with the mouse over the add function in VS Code, we will see a nice tooltip displaying the information we entered. Now, let's go to the very first line of the file, and then type /** and Enter. A new multi-line comment template will appear:

/**
 * @file main.cpp
 * @author your name (you@domain.com)
 * @brief
 * @version 0.1
 * @date 2021-11-29
 * @copyright Copyright (c) 2021
 */

If you go to the Doxygen Documentation Generator settings (Ctrl+, and search for doxygen), under "Generic: Author Email" and "Generic: Author Name", you can enter your details, so they will be added automatically by the extension when you use this shortcut (i.e. type /** and Enter in the first line of the file). The tags @file, @author, @version, @date, and @copyright are self-explanatory. After filling out these values, the final version of the program will be:

/**
 * @file main.cpp
 * @author Barak Shoshany (baraksh@gmail.com) (https://baraksh.com)
 * @brief A program to print the result of 1 + 2.
 * @version 0.1
 * @date 2021-11-29
 * @copyright Copyright (c) 2021 Barak Shoshany
 */

#include <iostream>
using namespace std;

/**
 * @brief Adds two numbers.
 *
 * @param a The first number to add.
 * @param b The second number to add.
 * @return The sum of the numbers.
 */
double add(const double &a, const double &b)
{
    return a + b;
}

int main()
{
    cout << add(1, 2);
}

Adding Doxygen comments to your source code makes it much more readable, since it is a standard comment format that everyone knows, and it also allows IDEs such as Visual Studio Code to automatically generate nice tooltips for different elements of your code.

If you want, you can also download the Doxygen tool itself and use it to generate HTML documentation for your program directly from the comments. However, I do not recommend doing that. The documentation will just be a list of classes, functions, and other parts of your code, and it can serve as a reference, but the user can also just look in your source code itself and find the same information. It is much more instructive to create your own documentation in Markdown format and explain how to use your code in a pedagogical way, with many details and examples.

For a good example of using Markdown documentation and Doxygen comments to document a C++ library, check out my thread pool library on GitHub.

8.5 Version control ^

8.5.1 Installing and configuring Git

Visual Studio Code comes with built-in support for Git, a distributed version control system. Git provides a convenient way for many people to work on the same project, tracking all of the changes made by different people to each file. You may already be familiar with this concept from Microsoft Word or Google Docs, for example, but Git is much more sophisticated, as we will see.

Git is commonly used by software developers, and it is also used to power websites such as GitHub - but you can use it independently of any specific website, because Git is a distributed version control system, meaning that every user stores the complete codebase, including the full history of changes, on their own computer, rather than only storing that information on a central server.

Even if you don't intend to collaborate with others, Git is an extremely useful tool to track changes in your own code. Since Git saves the entire history of your code, you don't need to perform manual backups before making any substantial changes. You can save snapshots of the codebase, and revert back to old versions at any time. You can even make different branches of the project in order to try out different things without affecting the original branch.

To install Git on Windows, Linux, or Mac, follow the instructions on the downloads page. Make sure to choose "Use Visual Studio Code as Git's default editor" during the installation. For everything else, just use the recommended options. You can also use a package manager: sudo apt-get install git on Ubuntu or winget install git on Windows with winget.

Now open a terminal window (you can also use VS Code's integrated terminal) and type:

git config --global user.email "your@email.address"
git config --global user.name "Your Name"

This will set up your identity in Git. When you make changes, the name and email you specified here will be used to identify you. You only need to do this once. (If these commands don't work, make sure the folder where git was installed is in your system's PATH.)

Throughout this chapter, I will be teaching you how to use Git in two equivalent ways: using VS Code's GUI, and using terminal commands. Using a GUI is always more convenient, but knowing the corresponding terminal commands is important in case you ever need to use Git through the terminal in a remote system, such as a high-performance computing cluster, without the benefit of a GUI.

The first thing we need to do in order to use Git is to initialize a repository. In Git, a repository is simply a collection of files that will be tracked; generally, one repository will store the entirety of your project's files. A repository can be either local (stored on your computer) or remote (stored on a remote server such as GitHub).

To initialize a repository in the currently open workspace folder:

  • In VS Code: Go to the Source Control view of the Activity bar on the left, or press Ctrl+Shift+G, and click on "Initialize Repository".
  • In the terminal: Type git init.

This will create a .git folder in the workspace folder, which will be used to store the complete history of changes made to files in the workspace folder and any subfolders. If you ever decide that you do not wish to use Git anymore, simply delete the .git folder.

Warning: Never make any changes manually to the .git folder itself. This may lead to loss of data.

The .git folder will not be visible in VS Code's Explorer view, since it's not a folder you're supposed to make changes to manually, but you can see it in the operating system's File Explorer, or in the terminal by typing dir /a on Windows Command Prompt, dir -Force on Windows PowerShell, or ls -la on Linux.

The first thing we're going to do is create a file named .gitignore in the workspace folder with the following contents:

# Ignore Visual Studio Code configuration folder
.vscode/
# Ignore compiled object files and executables
*.o
*.exe

This simply instructs Git to ignore any files that match the listed patterns. Note that lines starting with # are comments. We generally don't need to include the .vscode folder in the repository, since it won't be of interest to people who are not using VS Code. We also don't want any compiled object files or executables to be included, since they won't be useful for people who use a different OS or CPU family. (Generally, people will compile the code separately on their computer, or download pre-compiled binaries that are not part of the repository.)

Assuming your workspace folder was empty, other than potentially a .vscode folder, the .gitignore file will appear in both the Explorer view (Ctrl+Shift+E) or the Source Control view (Ctrl+Shift+G) with the letter U next to it, indicating that it is untracked. You can also see this information if you write git status in the terminal, which will list .gitignore under "untracked files".

To start tracking changes in a file, we need to add it to the staging area:

  • In VS Code: Go to the Source Control view, hover over the file .gitignore, and click the button that looks like a + (or right-click and choose "Stage Changes").
  • In the terminal: Type git add .gitignore.

The file will move to the Staged Changes area of the Source Control view, and if you write git status in the terminal, you will see new file: git_test.cpp under "Changes to be committed".

To actually record the staged changes, we need to commit them:

  • In VS Code: Go to the Source Control view, write the message Created .gitignore in the text box, and then either press Ctrl+Enter or click the button above the text box that looks like a check mark.
  • In the terminal: Type git commit -m "Created .gitignore".

One we commit the changes, Git creates a permanent snapshot of the project, called a commit or revision. The message Created .gitignore will be attached to this commit; generally, the commit message should be short, and provide a concise summary of the changes that were made since the last commit.

To view a history of commits:

  • In VS Code: Go to the Explorer view (Ctrl+Shift+E) and look in the Timeline section (if you can't see it, click on the overflow menu on top and choose "Timeline"), which will show you the history of commits for the file that is currently open in the editor.
  • In the terminal: Type git log.

Right now you will see one commit, with the message Created .gitignore, along with the name of the author and when the commit was performed.

I highly recommend installing the GitLens extension for VS Code. It allows you to view a full history of commits, rather than just the commits for the currently active file, plus plenty of other extremely useful features; see the extension page for more information.

8.5.2 Using Git ^

To illustrate how to use Git, let us create a simple main.cpp which displays the powers of 2 from 0 to 10:

#include <cmath>
#include <iostream>

int main()
{
    for (uint64_t i = 0; i <= 10; i++)
        std::cout << "2^" << i << " = " << pow(2, i) << '\n';
}

You can compile and run this program; notice that if you do so, the .exe file you created will be ignored by Git. (On Linux the executable won't have an extension, so it won't be ignored by the .gitignore file we defined above, but you can just add the specific file name you want to use to .gitignore.)

Stage and commit this file with the message Created main.cpp. Now, let us make a small change. Add using namespace std; on top and remove std:: from the cout statement:

#include <cmath>
#include <iostream>
using namespace std;

int main()
{
    for (uint64_t i = 0; i <= 10; i++)
        cout << "2^" << i << " = " << pow(2, i) << '\n';
}

Once you save the file, you will notice a few things in VS Code:

  • In both the Explorer view and the Source Control view, an M appears next to the file name, indicating that the file has been modified since the last commit.
  • In the Timeline section, you will see "Uncommitted Changes" appear.
  • In the source code itself, you will see different colored bars (called gutter indicators) appear next to the line numbers 4, 5, and 9, indicating that you made changes to those lines. Clicking on these indicators will reveal the changes and allow you to stage or revert each change individually. A red triangle indicates deleted lines, a green bar indicates added lines, and a blue bar indicates modified lines.

Also, if you type git status in the terminal, you will see modified: main.cpp under "Changes not staged for commit". To see a full list of what has changed, we can:

  • In VS Code:
    • Click on the button labeled "Open Changes" on the top right of the editor.
    • Or click on the changed file in the Source Control view (or right-click and choose "Open Changes").
    • Or double-click on the "Uncommitted Changes" in the Timeline section of the Explorer view for that file (or right-click and choose "Open Changes").
  • In the terminal: Type git diff.

Stage this file as explained above (click the + or type git add main.cpp). Notice that the gutter indicators in the editor have disappeared, since they only appear for unstaged changes. However, if you go to the Timeline section and click on "Staged Changes", you will be able to see the changes. Similarly, if you type git diff in the terminal, you do not see the staged changes. However, if you type git diff --staged, you will be able to see them.

Now let us practice how to unstage a change:

  • In VS Code: Go to the Source Control view, hover over the file main.cpp, and click the button that looks like a - (or right-click and choose "Unstage Changes"),
  • In the terminal: Type git reset -- main.cpp.

Not happy with the changes? We can easily revert to the last committed version:

  • In VS Code: Click on the button labeled "Discard Changes" (looks like a curved arrow) in the Source Control view (or right-click and choose "Discard Changes").
  • In the terminal: Type git checkout -- main.cpp.

Also, as a reminder, when you click on the gutter indicators, you can view, stage, and revert individual changes.

Let us now make the changes again (or just press Ctrl+Z to undo the revert in VS Code) and then add a new file, README.md:

# Powers of 2

This program prints out the powers of 2 from 0 to 10.

In the Explorer or Source Control view, the file README.md will have a U next to it, while main.cpp will have an M next to it. This is because main.cpp is in the staging area, while README.md is not. Since main.cpp is in the staging area, the changes are tracked, and we don't need to add it to the staging area again. However, if we want to commit the changes, we still need to stage the changes using either the + button or git add.

This has the benefit that we don't have to commit all of the changes at once every time; we can pick and choose which changes we want to commit, and we can use a different message for each commit. So in this case, we could, for example, first stage README.md and commit it with the message Created README.md, and then stage main.cpp and commit it with the message Added using namespace std to main.cpp.

We could even stage and commit each of the two changes in main.cpp separately by clicking on the corresponding gutter indicator in VS Code and then clicking on the + button labeled "Stage Change"; for example, we could commit the first change as Added using namespace std to main.cpp and the second change as Changed std::cout to cout in main.cpp. However, it is usually not necessary to split a commit into such small parts.

Instead of staging each file and/or each change individually, let us now stage all of the changes at once, both in main.cpp and README.md:

  • In VS Code: Go to the Source Control view and click the button that looks like a + next to the title "Changed" (or right-click and choose "Stage All Changes"),
  • In the terminal: Type git add ..

To unstage all of the changes, we can similarly:

  • In VS Code: Go to the Source Control view and click the button that looks like a - next to the title "Staged Changed" (or right-click and choose "Stage All Changes"),
  • In the terminal: Type git reset.

In fact, if we want to save a few clicks, we can even commit all of the files in the workspace folder without staging them first:

  • In VS Code: Go to the Source Control view, write the message Created README.md and added using namespace std in the text box, press Ctrl+Enter or click the check mark, and select "Yes" when asked "Would you like to stage all your changes and commit them directly?" (note that you can also select "Always" here to do this automatically from now on).
  • In the terminal: Type git commit -m "Created README.md and added using namespace std" -a. The -a instructs Git to automatically stage and commit all of the files in the repository.

If we accidentally performed a commit, we can always undo the last commit, which means it will be as if we never performed the commit (it will not appear in the history):

  • In VS Code: Go to the Source Control view, click on the overflow menu (looks like three dots), and choose "Commit" > "Undo Last Commit".
  • In the terminal: Type git reset --soft HEAD~. The flag --soft means this is a soft reset, so the changes will still be staged - you only undid the commit itself. If you don't write --soft, the changes will not be staged. HEAD is simply a pointer to the most recent commit, and HEAD~ (equivalent HEAD~1) means "1 commit before HEAD". We could similarly use HEAD~2 to go two commits back, and so on.

After undoing the commit, you will notice that it does not appear in the commit history - not in VS Code (using the Git History extension) and also not if writing git log to the terminal.

Let us now perform the commit again. What if we want to go back to a previous commit, but not undo this one? For example, go back to the very first commit? This can be done as follows:

  • In VS Code: Using the Git History extension, when viewing the history, click "More" next to the first commit (Added .gitignore) and choose "Checkout (...) commit" from the menu, where ... will be some short hash (a hexadecimal number representing the commit).
  • In the terminal: First type git log and copy the long hash (hexadecimal number) above the first commit (Added .gitignore). Then type git checkout ... where ... is the hash you copied.

Checking out a commit means changing the state of all of the files in the workspace folder to their state when that commit was performed. You will now see that both main.cpp and README.md have been removed from the workspace folder! We reverted the repository to the very first commit, before these files were created. If you view the history, either using the Git History extension or by typing git log, you will only see one commit - the one that you reverted to.

Finally, to go back to (or checkout) the latest commit:

  • In VS Code:
    • In the left corner of the status bar on the bottom of the window, you will see the same short hash from before. Click on it and choose master from the menu. Notice that the name on the status bar will change to master (as it was before we checked out the first commit).
    • Or go to the Source Control view, click on the overflow menu, choose "Checkout to...", and then choose master from the menu.
    • Or, using the Git History extension, when viewing the history, click on the menu next to the Search button, choose "All branches", click on the green button labeled master that will appear, and confirm that you want to "checkout to branch master".
  • In the terminal: Type git checkout master.

8.5.3 Branching ^

A branch in Git is essentially a different version of the codebase that is being developed separately from the main version. By default, a Git project only has one branch, the master branch, which is the official, stable branch of the project that most people should use. In addition, it may have a development or unstable branch, which is where new features are being added and tested. Changes to the development branch do not affect the master branch. Once the new features are sufficiently stable and bug-free, the changes can then be merged into the master branch.

To list the branches currently in the repository:

  • In VS Code:
    • Click on the branch name in the left corner of the status bar.
    • Or go to the Source Control view, click on the overflow menu, and choose "Checkout to...".
    • Or, using the Git History extension, when viewing the history, click on the menu next to the Search button.
  • In the terminal: Type git branch.

In both cases, there will currently be just one branch: master. (The one with the hash that we saw before was a temporary branch, also called a "detached HEAD".)

Let us now create a separate branch for our program, which will display powers of 3 instead of 2, with the highest power being 20 instead of 10. The name for the new branch should be in lowercase, either one word or a few short words connected by a dash. We will choose powers-of-3 as the branch name:

  • In VS Code:
    • Click on the branch name in the left corner of the status bar, choose "Create new branch...", and write powers-of-3.
    • Or go to the Source Control view, click on the overflow menu, choose "Branch" > "Create Branch", and write powers-of-3.
  • In the terminal: Type git branch powers-of-3 and then git checkout powers-of-3. (VS Code automatically checks out a newly-created branch.)

We now change main.cpp to:

#include <cmath>
#include <iostream>
using namespace std;

int main()
{
    for (uint64_t i = 0; i <= 20; i++)
        cout << "3^" << i << " = " << (uint64_t)pow(3, i) << '\n';
}

(I added a type cast to uint64_t in order to display the higher powers correctly as integers instead of scientific notation.)

We also change README.md to:

# Powers of 3

This program prints out the powers of 3 from 0 to 20.

Then, we commit the changes with the message Changed base to 3 and highest power to 20. Notice that the history now shows four commits, from the first one (Added .gitignore) to the one we just made. The three previous commits will be displayed as part of the master branch, while the most recent commit will be part of the powers-of-3 branch.

Let us checkout the master branch again. We see that the files returned to their previous versions, and the new commit is gone from the history. We now make a change to the master branch - changing the highest power to 15, but leaving the base at 2 in main.cpp:

#include <cmath>
#include <iostream>
using namespace std;

int main()
{
    for (uint64_t i = 0; i <= 15; i++)
        cout << "2^" << i << " = " << pow(2, i) << '\n';
}

We also change README.md to:

# Powers of 2

This program prints out the powers of 2 from 0 to 15.

Let us commit these changes with the message Changed highest power to 15. If we look at the Git history, we will see the three previous commits and the one we just made - all in the branch master. Now let us checkout the branch powers-of-3. The base will now be 3 and the highest power 20, and there is no sign of the last commit we made in the master branch.

We can switch between the two branches master and powers-of-3 at will, and work on each branch completely independently - commits in one branch will not affect the other branch. We can similarly create other branches and work on them independently as well.

There is much more to Git, which I will unfortunately not have time to cover in this course. Please refer to the official Git documentation for more information. Also, if you want more substantial Git integration into VS Code, you should check out the extension GitLens — Git supercharged.

8.5.4 Using GitHub ^

GitHub is a website which hosts Git repositories, and provides many additional features such as access control. Using GitHub, you can collaborate with other people on a project, while also allowing the general public to download and use it.

GitHub is extremely useful for open-source projects, including many scientific computing projects, as it lets anyone in the world suggest contributions to any project through pull requests. It is then up to the project's owners to decide if they want to accept (or merge) the changes into the main codebase. Alternatively, anyone can create their own branch of any GitHub repository and start developing a completely separate project, with a different focus or with specific features added or removed.

You can publish to GitHub directly from Visual Studio Code. Instead of creating a local Git repository on your computer first, the easiest way is to simply open a folder without a Git repository in VS Code, go to the Source Control view (Ctrl+Shift+G), and click on "Publish to GitHub". This will open a browser window where you will need to authorize Visual Studio Code to access your GitHub account.

Once you do that, the repository will have two versions: a local version on your computer, and a remote one on GitHub (Git refers to a remote repository as origin by default). When you commit changes on your computer, they will only be committed locally. In order to send them to GitHub, you need to push the commits. This is done by going to the Source Control view, clicking on the overflow menu, and choosing "Push".

Similarly, if you change the remote version directly on GitHub, or if you give someone else access and they push their own changes, these changes will be made to the remote repository only. To get the latest version of the project from GitHub to your computer, you need to pull it. This is done by going to the Source Control view, clicking on the overflow menu, and choosing "Pull". When you execute the pull, the entire repository - including all commits that have been made since the last pull - will be downloaded and merged into the local repository.

GitHub has many more features that I will not have time to cover here, but for our purposes in this course - uploading the final course projects to GitHub - this short introduction will suffice. For more information, including how to access GitHub using Git from the terminal, please see the GitHub Learning Lab. For more about using GitHub from Visual Studio Code, please see the official documentation.

9 Advanced memory management in C++ ^

9.1 Dynamic memory allocation and related member functions

9.1.1 The new and delete operators

Due to the presence of the containers such as vector in C++, in most cases there is no need to worry about manually allocating and deallocating memory, which is one of the most error-prone aspects of C. If you need to dynamically allocate an array of arbitrary size, simple declare a vector of the appropriate data type.

Nevertheless, in more complicated scenarios, or when very fast speed and very low memory usage are absolutely crucial, the programmer may wish to utilize the full power of C++'s manual memory management capabilities; this is, after all, one of the reasons we are using C++ instead of a higher-level language in the first place! Therefore, let us now discuss how manual memory allocation works in C++.

Warning: The same rules we discussed above for dynamic memory allocation in C still apply in C++. The operators are easier to use, but the same potential for bugs and memory leaks still exists.

In C++, dynamic memory allocation is performed using the operators new and delete instead of malloc and free. The syntax for new is:

pointer = new type;       // Allocates a single object
pointer = new type[size]; // Allocates an array of objects

where pointer is a pointer that will point to the beginning of the allocated memory block, type is the data type, and size indicates the number of elements if we are allocating an array. If the allocation failed, then an std::bad_alloc exception is thrown. As usual, it is extremely important to catch this exception and handle the error.

For example, the following program allocates an array of 100 doubles and prints out the first element:

#include <iostream>
using namespace std;

int main()
{
    constexpr uint64_t size = 100;
    double *p = nullptr;
    try
    {
        p = new double[size];
    }
    catch (const bad_alloc &e)
    {
        cout << "Error: Failed to allocate memory!\n";
    };
    cout << p[0];
}

Here the pointer p is declared and then initialized to the result of new. However, the array itself will not be initialized, so it will contain garbage. We can initialize it to zeros simply by adding an empty pair of parentheses:

p = new double[size]();

Now the program will print 0. (However, if you really need to initialize the array to zeros, you might as well just use vector instead!)

When initializing just one object, we can use the usual initialization syntax. Consider for example allocating and initializing an object of the triangle class we defined above:

triangle *t = new triangle(2, 3, 4);

This will create a new triangle object and initialize it to have sides of lengths 2, 3, and 4.

Warning: Single objects in C++ generally do not need to be dynamically allocated. Instead, the object itself should allocate memory for its internal data, usually as part of the constructor. We will learn how to do that, and how to deal with the complications that arise, later in this chapter.
Warning: People coming from Java to C++ should not confuse the new keyword from Java with that of C++. In Java, the new keyword must be used every time you want to construct a new object. In C++, the new keyword is only used when you need to allocate memory dynamically; it is almost never used on single objects, and in fact, it is rarely used in general, due to the existence of the STL containers. Do not confuse the new keyword in C++ with the similar keyword in Java, they mean different things!

To deallocate memory, we use delete:

delete pointer;   // Deallocates a single object
delete[] pointer; // Deallocates an array of objects
Warning: In almost all cases, it is preferred to use smart pointers to deallocate memory automatically instead of using delete manually. In the following sections we will be deallocating memory manually, but when you actually write your C++ programs, you should use smart pointers instead. We will learn about smart pointers after we understand the basics of dynamic memory allocation in C++.

9.1.2 Avoiding memory leaks ^

Warning: It is extremely important to make sure all memory allocated with new is explicitly deallocated with delete in order to avoid memory leaks.

The following program demonstrates a memory leak:

#include <cstdint>

void leak(const uint64_t &s)
{
    int8_t *p = new int8_t[s]();
    // ERROR: We did not release the allocated memory!
}

int main()
{
    // Each call to leak() allocates 1 GB of memory but doesn't release it.
    // Desired behavior: Only 1 GB of memory will be used for the entire loop.
    // Actual behavior: Will allocate more and more memory until it crashes (unless you somehow have more than 1 TB of RAM).
    for (uint64_t i = 0; i < 1000; i++)
        leak(1'000'000'000);
}

(To remind you, the notation 1'000'000'000 is just a way to make large numbers more readable - adding ' in the middle of the number doesn't change its value, but can help human readers see how many zeros it has more easily. Also, I included <cstdint> because I wanted access to the fixed-width integer types; usually there is no need to do that since this header file is automatically included when you include <iostream>, but here I never used any streams.)

To see that memory is indeed leaking, first open an app that lets you monitor how much memory is used, such as Task Manager on Windows or System Monitor on Linux. You can also press F1 in VS Code, type "process", and choose "Developer: Open Process Explorer" to view memory usage from within the IDE. Alternatively, you can install the Resource Monitor VS Code extension to see CPU and memory usage directly in the status bar.

Make sure to run the code above without compiler optimizations. You will see that more and more memory keeps getting allocated. Be careful - your system may crash if too much memory is allocated! (On modern operating systems it is actually possible to allocate more than the amount of physical RAM you have, due to virtual memory, but even that has its limits.)

Now add the line delete[] p; at the end of the function leak, and run it again. You can verify that this time, only 1 GB of memory is allocated for the entire run time of the program.

Warning: A commonly overlooked case where memory leaks can happen is when using exceptions. It is easy to forget that when an exception is thrown within a function, the rest of the function is never executed - including any delete statements!

You should avoid code that looks like this:

void some_function()
{
    type *p = new type[size];
    // ...
    // Code that may throw an exception
    // ...
    delete[] p;
}

If an exception is thrown, delete never gets called, and you get a memory leak.

9.1.3 Destructors ^

In a class which stores an arbitrary amount of data, memory allocation will typically take place in the constructor. This means that memory will be allocated by the object as soon as we create it. An object may also allocate memory within various member functions during its lifetime. When this object is destroyed - for example, when its scope ends - this memory must be freed, or it will cause a memory leak.

Memory deallocation should be done in the destructor, which is a member function that is executed automatically when the object is destroyed. The destructor should also do other resource-freeing tasks if needed, such as closing files that were opened by the object.

Standard library containers such as vector, which allocate memory dynamically, have their own destructors - this is exactly why we don't have to free up memory manually when we use vector. However, for classes we define on our own, if we decide to manage memory ourselves rather than use vector, we also need to write our own destructors. Essentially, if (and only if) you used new anywhere in the class, there must be a corresponding delete in the destructor.

The compiler automatically calls the destructors in reverse order of construction when a function ends. For example, consider the program:

#include <string>
using namespace std;

int main()
{
    string a;
    string b;
}

When the program exits, b's destructor is called first, and then a's destructor. Similarly, if we create an array of objects, the objects will be destructed in reverse order of construction. For example, if we had an array of three strings, string s[3], then s[2] is destructed first, then s[1], and finally s[0]. The same principle applies to objects created in any other context: First Constructed, Last Destructed.

To define a destructor, we simply add a member function that has the same name as the class, no input or output, and the ~ prefix. The general syntax is:

class my_class
{
    // Constructor allocates memory
    my_class()
    {
        pointer = new type[size];
    }

    // Destructor deallocates memory
    ~my_class()
    {
        delete[] pointer;
    }

private:
    // The pointer to the allocated block of memory
    double *pointer = nullptr;
};
Warning: Never explicitly call the destructor of any object. It will always be called automatically when the object's scope ends, whether the scope is a function, a nested code block, or the entire program.

9.1.4 The matrix class template with manual memory allocation ^

To demonstrate how to properly use manual memory allocation in C++, let us modify our matrix class template.

Recall that vectors are automatically initialized to zeros, but in many cases we actually want to initialize the vector with other values, so we have to initialize it twice, which is a huge waste of time. As we saw above, using manually-allocated C-style arrays can actually speed up our program by a factor of 2.

If the matrix class uses a vector to store the matrix elements, then the same issue occurs. Most matrix operations create a new temporary matrix to use as output. This new matrix will be initialized to zeros automatically, and the matrix operation will then re-initialize the matrix with the desired elements. Again, this is a waste of time.

By storing the elements of the matrix inside a C-style array, we allow matrices to be constructed uninitialized. This eliminates redundant initializations, and thus speeds up matrix operations. Furthermore, we also allow the user to create an uninitialized matrix and populate it with values later, which will similarly improve performance.

If we knew, at compilation time, exactly how many matrices we want to create and what their sizes are, and these matrices did not require more than a few MB of memory (so they could be stored in the stack), then we could have used the array container instead of C-style arrays. However, we want to allow creating matrices of arbitrary size at run time, so we must use dynamic memory allocation.

In matrix.hpp, replace

vector<T> elements;

under private: in the class definition with

T *elements = nullptr;

Instead of using a vector, we will now be using a C-style array, and elements will be a pointer to the first element in the array. As usual, pointers must be initialized to a null pointer so that we never accidentally use an uninitialized pointer.

Replace the implementation of the first constructor with:

template <typename T>
matrix<T>::matrix(const uint64_t &_rows, const uint64_t &_cols)
    : rows(_rows), cols(_cols)
{
    if (rows == 0 or cols == 0)
        throw zero_size();
    elements = new T[rows * cols];
}

This will allocate a new uninitialized array of with rows * cols elements of type T.

The second constructor creates a diagonal matrix from a vector, but we are not using vectors anymore, so let's convert it to taking C-style arrays instead. However, an array doesn't know its own size, so the user is going to have to specify the length of the diagonal manually. We change the declaration to

matrix(const uint64_t &, const T *);

and the implementation to

template <typename T>
matrix<T>::matrix(const uint64_t &_size, const T *_diagonal)
    : rows(_size), cols(_size)
{
    if (rows == 0)
        throw zero_size();
    elements = new T[rows * cols];
    for (uint64_t i = 0; i < rows; i++)
        for (uint64_t j = 0; j < cols; j++)
            elements[(cols * i) + j] = ((i == j) ? _diagonal[i] : 0);
}

Here we are taking advantage of the fact that we are creating an uninitialized array, so instead of first initializing everything to zeros and then changing the diagonal elements from zero to the desired values, we populate the elements manually to either 0 or _diagonal[i] based on whether the element is on the diagonal or not (using the conditional operator ?:).

Since the user is now giving us the size of the array as the first argument, it's up to them to make sure they give the correct size, otherwise the constructor may try to access memory addresses out of the range of the array, which will cause a segmentation fault.

The third constructor creates a diagonal matrix from an initializer_list. Here we just defer to the previous constructor, using the member function size() to determine the size and begin() to get a pointer to the first element. The implementation will be:

template <typename T>
matrix<T>::matrix(const initializer_list<T> &_diagonal)
    : matrix(_diagonal.size(), _diagonal.begin()) {}

The fourth constructor creates a new matrix by copying the elements of a vector, which are assumed to be the elements of the matrix in row-major order. Again, we convert it to using a C-style array instead of a vector. The declaration should change to:

matrix(const uint64_t &, const uint64_t &, const T *);

and the implementation to:

template <typename T>
matrix<T>::matrix(const uint64_t &_rows, const uint64_t &_cols, const T *_elements)
    : rows(_rows), cols(_cols)
{
    if (rows == 0 or cols == 0)
        throw zero_size();
    elements = new T[rows * cols];
    for (uint64_t i = 0; i < rows * cols; i++)
        elements[i] = _elements[i];
}

Again, we first create an uninitialized array and then populate all of the elements manually, avoiding double initialization. Note that this constructor no longer throws the exception initializer_wrong_size, since we have no way of knowing if the actual size of the array is rows * cols. As before, it's up to the user to make sure the array is of the appropriate size, otherwise a segmentation fault may occur.

Finally, the fifth constructor creates a new matrix by copying the elements of an initializer_list. Before, this constructor simply delegated to the previous one; but since, unlike an array, an initializer_list does know its own size, we will modify this constructor to check that the size of the initializer is compatible, and throw initializer_wrong_size if not:

template <typename T>
matrix<T>::matrix(const uint64_t &_rows, const uint64_t &_cols, const initializer_list<T> &_elements)
    : rows(_rows), cols(_cols)
{
    if (rows == 0 or cols == 0)
        throw zero_size();
    if (_elements.size() != rows * cols)
        throw initializer_wrong_size();
    elements = new T[rows * cols];
    for (uint64_t i = 0; i < rows * cols; i++)
        elements[i] = _elements.begin()[i];
}

Notice that we used the begin() member function of the initializer_list class to access the elements.

In the at() member function, since a C-style array doesn't allow range checking, we must write our own code to detect if an index is out of range and throw an exception as needed. We thus replace the two versions of the at member function with:

template <typename T>
T &matrix<T>::at(const uint64_t &row, const uint64_t &col)
{
    if (row >= rows or col >= cols)
        throw matrix_out_of_range(row, col, rows, cols);
    return elements[(cols * row) + col];
}

template <typename T>
const T &matrix<T>::at(const uint64_t &row, const uint64_t &col) const
{
    if (row >= rows or col >= cols)
        throw matrix_out_of_range(row, col, rows, cols);
    return elements[(cols * row) + col];
}

And we define a new exception matrix_out_of_range as follows:

class matrix_out_of_range : public out_of_range
{
public:
    matrix_out_of_range(const uint64_t &row, const uint64_t &col, const uint64_t &rows, const uint64_t &cols) : out_of_range("Tried to access matrix element at row " + to_string(row) + ", column " + to_string(col) + ". Row must be in the range [0," + to_string(rows - 1) + "] and column must be in the range [0," + to_string(cols - 1) + "]."){};
};

Since we are now creating an uninitialized array, we must modify the overloaded operator*. Previously, it simply added the products of each of the elements in row i and column j to c(i, j), assuming that the initial value of c(i, j) was automatically initialized to zero. Now we will have to initialize c(i, j) to zero manually for each i and j:

template <typename T>
matrix<T> operator*(const matrix<T> &a, const matrix<T> &b)
{
    if (a.get_cols() != b.get_rows())
        throw typename matrix<T>::incompatible_sizes_multiply();
    matrix<T> c(a.get_rows(), b.get_cols());
    for (uint64_t i = 0; i < a.get_rows(); i++)
        for (uint64_t j = 0; j < b.get_cols(); j++)
        {
            c(i, j) = 0;
            for (uint64_t k = 0; k < a.get_cols(); k++)
                c(i, j) += a(i, k) * b(k, j);
        }
    return c;
}

Lastly, notice that we have several news but no deletes! If we don't fix that, this class will leak memory, since we will never delete the array elements. To correct this, we must add a destructor. Add a declaration for the destructor inside the public: part of the class declaration:

~matrix();

(I like to place the constructors first, the member functions in the middle, and the destructors last, since that reflects the order in which the functions will be executed.)

At the end of the file, add the code for the destructor itself:

template <typename T>
matrix<T>::~matrix()
{
    delete[] elements;
}

This will fix the memory leak. But in its current form, the matrix class is still not well-defined! We need to add a few more functions first.

9.1.5 Copy constructors ^

A copy constructor is a constructor used to create a new object as a copy of an existing object. The syntax for the copy constructor is the same as any other constructor, with the input given by a const reference to an object of the same class. For example, for the matrix class, the copy constructor will be of the form:

matrix(const matrix &m);

If you do not create your own copy constructor, the compiler generates one automatically. This is known as an implicit copy constructor, and it simply copies the data stored in the old object to the new object. For example, for the matrix class, the implicit copy constructor generated automatically by the compiler is equivalent to:

matrix(const matrix &m) : rows(m.rows), cols(m.cols), elements(m.elements) {}

Unfortunately, if the object contains pointers, as is the case with the elements pointer in our modified matrix class, then the implicit copy constructor generated by the compiler will simply copy the value of the pointer, that is, the address it points to. It will not automatically make copies of the actual values of the elements and store them elsewhere in memory.

This is almost always undesired, since it means both objects will point to the same location in memory, so if we change the elements of one matrix, it will change the elements of the other as well. To see this, run the following main.cpp program:

#include <iostream>
#include "matrix.hpp"
using namespace std;

int main()
{
    matrix<double> m1{1, 2}; // Create a new 2x2 matrix m1 with 1, 2 on the diagonal using the initializer_list constructor.
    cout << "m1 =\n"
         << m1;            // Prints the matrix we created.
    matrix<double> m2(m1); // Create a new matrix object m2 by copying the existing object m1.
    cout << "After copying m1 to m2:\n";
    cout << "m2 =\n"
         << m2;   // Prints the same matrix; copy SEEMS to be successful.
    m2(0, 1) = 3; // Change the top-right element of m2 to 3. m1 will change as well, since they both point to the SAME array of elements.
    cout << "After changing top-right element of m2:\n";
    cout << "m2 =\n"
         << m2; // Prints a matrix with 3 in the upper left.
    cout << "m1 =\n"
         << m1; // Prints the same matrix; m1 was changed too!
} // Also, the program will crash upon exit since the destructor will attempt to free the same memory address twice!

We see that copying doesn't quite work as intended. To correct this, we need to create our own copy constructor. The constructor will copy the values of rows and cols to the new object, but it will allocate new space in memory for the elements, and assign the new address to the pointer elements in the new object. Then, it will copy the old elements to the new matrix one by one.

Add this code to the matrix class in matrix.hpp (right after the other constructors):

matrix(const matrix<T> &);

and this code outside the class:

template <typename T>
matrix<T>::matrix(const matrix<T> &m)
    : rows(m.rows), cols(m.cols)
{
    elements = new T[rows * cols];
    for (uint64_t i = 0; i < rows * cols; i++)
        elements[i] = m.elements[i];
}

If you now run the program, you will find that changing m2 no longer changes m1, and that the program no longer crashes at the end. Thus we have fixed the issues we had before. However, we are still not done... There is yet another issue, which we will fix below.

9.1.6 Overloading the assignment operator ^

Let us make a few minor changes to our previous main.cpp:

#include <iostream>
#include "matrix.hpp"
using namespace std;

int main()
{
    matrix<double> m1{1, 2}; // Create a new 2x2 matrix m1 with 1, 2 on the diagonal using the initializer_list constructor.
    cout << "m1 =\n"
         << m1;              // Prints the matrix we created.
    matrix<double> m2(2, 2); // Create a new uninitialized 2x2 matrix m2.
    m2 = m1;                 // Assign m1 to m2.
    cout << "After assigning m1 to m2:\n";
    cout << "m2 =\n"
         << m2;   // Prints the same matrix; assignment SEEMS to be successful.
    m2(0, 1) = 3; // Change the top-right element of m2 to 3. m1 will change as well, since they both point to the SAME array of elements.
    cout << "After changing top-right element of m2:\n";
    cout << "m2 =\n"
         << m2; // Prints a matrix with 3 in the upper left.
    cout << "m1 =\n"
         << m1; // Prints the same matrix; m1 was changed too!
} // Also, the program will crash upon exit since the destructor will attempt to free the same memory address twice!

In the program, we use the assignment operator = to assign the contents of one matrix object to another. Doing this results in the compiler generating an implicit assignment operator, which just as in the case of the implicit copy constructor, will not work correctly if we are using pointers.

In fact, in this case the compiler will warn you about this - since we wrote an explicit copy constructor, the compiler realizes an implicit assignment operator will probably not work correctly:

implicitly-declared 'constexpr matrix<double>& matrix<double>::operator=(const matrix<double>&)' is deprecated [-Wdeprecated-copy]

To solve this problem, we simply need to define an overload of operator=. As we mentioned above, the operator overload of = must be a member function. The object that owns the member function is the target of the assignment, and the argument of the function is the source.

Let us add this declaration to matrix.hpp, right after the copy constructor:

matrix<T> &operator=(const matrix<T> &);

and this code below the class:

template <typename T>
matrix<T> &matrix<T>::operator=(const matrix<T> &m)
{
    rows = m.rows;
    cols = m.cols;
    delete[] elements;
    elements = new T[rows * cols];
    for (uint64_t i = 0; i < rows * cols; i++)
        elements[i] = m.elements[i];
    return *this;
}

If you run the program with this overloaded =, you will see that the issues we had before have been resolved. Notice that we first use delete to free up the memory we allocated for the old elements of the target matrix, and then use new to reallocate memory for the new elements.

This is because we want to allow assigning m2 = m1 even if the two matrices have different sizes. After the assignment, m2 will have the same size as m1, and the new number of elements may be smaller or larger than what we had before. We don't want to use more memory than we need to, and we definitely don't want to use areas of memory that we did not allocate, which will cause a segmentation fault.

Another option could be to only allow assigning one matrix to another if they are of the same size (and throw an exception otherwise), in which case we can be certain that we have exactly the right amount of memory already allocated, so we could use the same memory block again. However, this would limit the usability of the assignment operator.

Also notice that in the last line, we return the value *this. In C++, the this keyword provides a pointer to the object that owns the member function currently being executed. So in this case, this is a pointer to the matrix object we are assigning to (i.e. m2). We could, for example, replace the statement rows = m.rows in the first line with this->rows = m.rows if we wanted to. Returning the dereferenced pointer *this allows chaining the assignment with other operators (e.g. a = b = c), which is standard C++ syntax, so the user would expect it to be possible.

In conclusion, we have seen that there are two types of copying pointers associated to objects in C++:

  • Shallow copying means only the pointer is being copied, so that both the old object and the new object will end up pointing to the same memory address. This is what the implicit copy constructor and assignment operator created by the compiler do.
  • Deep copying means copying the values pointed to by the pointer, so that the old object and the new object will end up pointing to different memory addresses. This is what the explicit copy constructor and assignment operator we created do.

9.1.7 Move constructors and move assignments ^

The assignment operator we defined in the previous section copies the contents of one matrix to another. This is desirable in case we want to have two distinct matrix objects in the end. However, consider the following program:

#include <iostream>
#include "matrix.hpp"
using namespace std;

template <typename T>
matrix<T> generate_matrix(const uint64_t &rows, const uint64_t &cols, const T &value)
{
    matrix<T> m(rows, cols);
    for (uint64_t i = 0; i < rows; i++)
        for (uint64_t j = 0; j < cols; j++)
            m(i, j) = value;
    return m;
}

int main()
{
    matrix<double> ones = generate_matrix(5, 5, 1.0);
    cout << ones;
}

(As a side note, in generate_matrix(), the type T is inferred automatically by the compiler from the type of the third argument, so I had to write 1.0 in that argument, since 1.0 is interpreted as a double. If I wrote 1, then T would have been interpreted as an int, but you cannot assign a matrix<int> to a matrix<double>.)

The function generate_matrix() generates a matrix with all of its elements initialized to a particular value. It then returns the actual matrix m as the return value of the function. This means (in principle) that all the elements in m will be copied, which for large matrices could take a long time.

Since m is destroyed anyway as soon as its scope ends, it would be much better performance-wise if we were able to reuse the memory already allocated for m itself within generate_matrix() for the new matrix ones we then create in main().

To do this, we create a move constructor and a move assignment operator. These are similar to the copy constructor and assignment operator, except that they move the elements instead of creating a copy. They are defined exactly the same as their copy versions, except that the argument is a matrix && instead of a const matrix &. The argument is not const, because we are modifying the source object.

The notation && is called an rvalue reference and it is usually only used when declaring function arguments. Essentially, an rvalue reference && is a reference to a temporary object that will be immediately destroyed after we call the function. This is exactly what we want a move constructor or assignment to do.

An lvalue represents an object that has a memory address, and an rvalue is anything that is not an lvalue. If you can use the & operator to access the address of an object, then that object is an lvalue. In particular, variables are generally lvalues, and literals (e.g. explicit numbers like 1) are generally rvalues.

You will usually see lvalues on the left-hand side of an assignment, and rvalues on the right-hand side, because you can only assign something to an object that is actually stored in memory.

For example, x = 1 is a valid assignment, since x is an lvalue while 1 is an rvalue. However, 1 = x is not valid, since 1 is not an lvalue, so there is no place in memory to assign the value of x to. Of course, you can have lvalues on both sides, such as in x = y. However, x + 2 is a rvalue, so you can write x = x + 2 but not x + 2 = x.

Let us add the following two declarations to matrix.hpp, the first after the copy constructor and the second after the copy assignment operator:

matrix(matrix<T> &&);
matrix<T> &operator=(matrix<T> &&m);

The implementation of the move constructor will be:

template <typename T>
matrix<T>::matrix(matrix<T> &&m)
    : rows(m.rows), cols(m.cols), elements(m.elements)
{
    m.rows = 0;
    m.cols = 0;
    m.elements = nullptr;
}

First, the move constructor assigns the number of rows and columns, as well as the pointer to the elements, to the new matrix. Then, it sets the old matrix to a degenerate state, with zero elements, and sets the pointer to the elements of the old matrix to the null pointer nullptr.

This is done because when m's destructor is executed, it will delete[] elements, and if we do not change elements to a null pointer, it will deallocate the memory pointed to by the new object! Using delete on a null pointer doesn't do anything, so by replacing elements with a null pointer, we ensure that the elements do not get accidentally deleted by the destructor.

Setting the number of rows and columns to zero is not strictly necessary, but I did it because I want the class invariant - the assumption that the number of elements is equal to rows * cols - to be satisfied even in this degenerate state, just in case.

Now let us add the implementation of the move assignment operator:

template <typename T>
matrix<T> &matrix<T>::operator=(matrix<T> &&m)
{
    rows = m.rows;
    cols = m.cols;
    delete[] elements;
    elements = m.elements;
    m.rows = 0;
    m.cols = 0;
    m.elements = nullptr;
    return *this;
}

You will notice that this is essentially a combination of the move constructor with the copy assignment operator. First, we give the target matrix the same number of rows and columns as the source matrix. Then we use delete to deallocate the memory we previously allocated for the elements of the target matrix, and instead simply set elements to point to the address of the elements of the source matrix. Finally, we set the source matrix to a degenerate state, and return the target matrix.

Technically speaking, the move constructor will not actually get called in the program we wrote above, because most compilers will be able to detect that a move is required (since they can see m is going to be destroyed after the return) and optimize the code to do a move instead of a copy automatically.

However, this is only done in cases where the compiler can detect that a move is required, and it can be turned off by the user at compilation time by adding the compiler argument -fno-elide-constructors. You should always write optimized code instead of trusting the compiler's optimizations to do it for you!

9.1.8 The full code for manually allocated matrices ^

We have seen that in order to perform manual memory allocation for our matrix class, which may improve performance compared to automatic memory allocation with vector, we must define five essential member functions that we did not need before:

  1. Destructor
  2. Copy constructor
  3. Copy assignment
  4. Move constructor
  5. Move assignment

For reference, the complete matrix.hpp after all the modifications (including changing the comments where required) should be as follows:

#include <initializer_list>
#include <iostream>
#include <stdexcept>
#include <vector>
using namespace std;

// =========
// Interface
// =========

template <typename T>
class matrix
{
public:
    // Constructor to create n UNINITIALIZED matrix.
    // First argument: number of rows.
    // Second argument: number of columns.
    matrix(const uint64_t &, const uint64_t &);

    // Constructor to create a diagonal matrix from an array.
    // First argument: length of the diagonal (equal to the number of rows and columns).
    // Second argument: an array containing the elements on the diagonal. The elements will be copied into the matrix.
    matrix(const uint64_t &, const T *);

    // Constructor to create a diagonal matrix from an initializer_list.
    // Argument: an initializer_list containing the elements on the diagonal.
    // Number of rows and columns is inferred automatically.
    matrix(const initializer_list<T> &);

    // Constructor to create a matrix from an array.
    // First argument: number of rows.
    // Second argument: number of columns.
    // Third argument: an array containing the elements in row-major order. The elements will be copied into the matrix.
    matrix(const uint64_t &, const uint64_t &, const T *);

    // Constructor to create a matrix from an initializer_list.
    // First argument: number of rows.
    // Second argument: number of columns.
    // Third argument: an initializer_list containing the elements in row-major order.
    matrix(const uint64_t &, const uint64_t &, const initializer_list<T> &);

    // Copy constructor.
    matrix(const matrix<T> &);

    // Move constructor.
    matrix(matrix<T> &&);

    // Overloaded copy assignment operator.
    matrix<T> &operator=(const matrix<T> &);

    // Overloaded move assignment operator.
    matrix<T> &operator=(matrix<T> &&m);

    // Member function to obtain (but not modify) the number of rows in the matrix.
    uint64_t get_rows() const;

    // Member function to obtain (but not modify) the number of columns in the matrix.
    uint64_t get_cols() const;

    // Overloaded operator () to access matrix elements WITHOUT range checking.
    // The indices start from 0: m(0, 1) would be the element at row 1, column 2.
    // First version: allows modification of the element.
    T &operator()(const uint64_t &, const uint64_t &);

    // Overloaded operator () to access matrix elements WITHOUT range checking.
    // The indices start from 0: m(0, 1) would be the element at row 1, column 2.
    // Second version: does not allow modification of the element.
    const T &operator()(const uint64_t &, const uint64_t &) const;

    // Member function to access matrix elements WITH range checking (throws out_of_range).
    // The indices start from 0: m.at(0, 1) would be the element at row 1, column 2.
    // First version: allows modification of the element.
    T &at(const uint64_t &, const uint64_t &);

    // Member function to access matrix elements WITH range checking (throws out_of_range).
    // The indices start from 0: m.at(0, 1) would be the element at row 1, column 2.
    // Second version: does not allow modification of the element.
    const T &at(const uint64_t &, const uint64_t &) const;

    // Exception to be thrown if the number of rows or columns given to the constructor is zero.
    class zero_size : public invalid_argument
    {
    public:
        zero_size() : invalid_argument("Matrix cannot have zero rows or columns!"){};
    };

    // Exception to be thrown if the vector of elements provided to the constructor is of the wrong size.
    class initializer_wrong_size : public invalid_argument
    {
    public:
        initializer_wrong_size() : invalid_argument("Initializer does not have the expected number of elements!"){};
    };

    // Exception to be thrown if two matrices of different sizes are added or subtracted.
    class incompatible_sizes_add : public invalid_argument
    {
    public:
        incompatible_sizes_add() : invalid_argument("Cannot add or subtract two matrices of different dimensions!"){};
    };

    // Exception to be thrown if two matrices of incompatible sizes are multiplied.
    class incompatible_sizes_multiply : public invalid_argument
    {
    public:
        incompatible_sizes_multiply() : invalid_argument("Two matrices can only be multiplied if the number of columns in the first matrix is equal to the number of rows in the second matrix!"){};
    };

    // Destructor.
    ~matrix();

    // Exception to be thrown if the requested matrix element is out of range.
    class matrix_out_of_range : public out_of_range
    {
    public:
        matrix_out_of_range(const uint64_t &row, const uint64_t &col, const uint64_t &rows, const uint64_t &cols) : out_of_range("Tried to access matrix element at row " + to_string(row) + ", column " + to_string(col) + ". Row must be in the range [0," + to_string(rows - 1) + "] and column must be in the range [0," + to_string(cols - 1) + "]."){};
    };

private:
    // The number of rows.
    uint64_t rows = 0;

    // The number of columns.
    uint64_t cols = 0;

    // An array storing the elements of the matrix in flattened (1-dimensional) form.
    T *elements = nullptr;
};

// Overloaded binary operator << to easily print out a matrix to a stream.
template <typename T>
ostream &operator<<(ostream &, const matrix<T> &);

// Overloaded binary operator + to add two matrices.
template <typename T>
matrix<T> operator+(const matrix<T> &, const matrix<T> &);

// Overloaded binary operator += to add two matrices and assign the result to the first one.
template <typename T>
matrix<T> operator+=(matrix<T> &, const matrix<T> &);

// Overloaded unary operator - to take the negative of a matrix.
template <typename T>
matrix<T> operator-(const matrix<T> &);

// Overloaded binary operator - to subtract two matrices.
template <typename T>
matrix<T> operator-(const matrix<T> &, const matrix<T> &);

// Overloaded binary operator -= to subtract two matrices and assign the result to the first one.
template <typename T>
matrix<T> operator-=(matrix<T> &, const matrix<T> &);

// Overloaded binary operator * to multiply two matrices.
template <typename T>
matrix<T> operator*(const matrix<T> &, const matrix<T> &);

// Overloaded binary operator * to multiply a scalar on the left and a matrix on the right.
template <typename T>
matrix<T> operator*(const T &, const matrix<T> &);

// Overloaded binary operator * to multiply a matrix on the left and a scalar on the right.
template <typename T>
matrix<T> operator*(const matrix<T> &, const T &);

// ==============
// Implementation
// ==============

template <typename T>
matrix<T>::matrix(const uint64_t &_rows, const uint64_t &_cols)
    : rows(_rows), cols(_cols)
{
    if (rows == 0 or cols == 0)
        throw zero_size();
    elements = new T[rows * cols];
}

template <typename T>
matrix<T>::matrix(const uint64_t &_size, const T *_diagonal)
    : rows(_size), cols(_size)
{
    if (rows == 0)
        throw zero_size();
    elements = new T[rows * cols];
    for (uint64_t i = 0; i < rows; i++)
        for (uint64_t j = 0; j < cols; j++)
            elements[(cols * i) + j] = ((i == j) ? _diagonal[i] : 0);
}

template <typename T>
matrix<T>::matrix(const initializer_list<T> &_diagonal)
    : matrix(_diagonal.size(), _diagonal.begin()) {}

template <typename T>
matrix<T>::matrix(const uint64_t &_rows, const uint64_t &_cols, const T *_elements)
    : rows(_rows), cols(_cols)
{
    if (rows == 0 or cols == 0)
        throw zero_size();
    elements = new T[rows * cols];
    for (uint64_t i = 0; i < rows * cols; i++)
        elements[i] = _elements[i];
}

template <typename T>
matrix<T>::matrix(const uint64_t &_rows, const uint64_t &_cols, const initializer_list<T> &_elements)
    : rows(_rows), cols(_cols)
{
    if (rows == 0 or cols == 0)
        throw zero_size();
    if (_elements.size() != rows * cols)
        throw initializer_wrong_size();
    elements = new T[rows * cols];
    for (uint64_t i = 0; i < rows * cols; i++)
        elements[i] = _elements.begin()[i];
}

template <typename T>
matrix<T>::matrix(const matrix<T> &m)
    : rows(m.rows), cols(m.cols)
{
    elements = new T[rows * cols];
    for (uint64_t i = 0; i < rows * cols; i++)
        elements[i] = m.elements[i];
}

template <typename T>
matrix<T>::matrix(matrix<T> &&m)
    : rows(m.rows), cols(m.cols), elements(m.elements)
{
    m.rows = 0;
    m.cols = 0;
    m.elements = nullptr;
}

template <typename T>
matrix<T> &matrix<T>::operator=(const matrix<T> &m)
{
    rows = m.rows;
    cols = m.cols;
    delete[] elements;
    elements = new T[rows * cols];
    for (uint64_t i = 0; i < rows * cols; i++)
        elements[i] = m.elements[i];
    return *this;
}

template <typename T>
matrix<T> &matrix<T>::operator=(matrix<T> &&m)
{
    rows = m.rows;
    cols = m.cols;
    delete[] elements;
    elements = m.elements;
    m.rows = 0;
    m.cols = 0;
    m.elements = nullptr;
    return *this;
}

template <typename T>
uint64_t matrix<T>::get_rows() const
{
    return rows;
}

template <typename T>
uint64_t matrix<T>::get_cols() const
{
    return cols;
}

template <typename T>
T &matrix<T>::operator()(const uint64_t &row, const uint64_t &col)
{
    return elements[(cols * row) + col];
}

template <typename T>
const T &matrix<T>::operator()(const uint64_t &row, const uint64_t &col) const
{
    return elements[(cols * row) + col];
}

template <typename T>
T &matrix<T>::at(const uint64_t &row, const uint64_t &col)
{
    if (row >= rows or col >= cols)
        throw matrix_out_of_range(row, col, rows, cols);
    return elements[(cols * row) + col];
}

template <typename T>
const T &matrix<T>::at(const uint64_t &row, const uint64_t &col) const
{
    if (row >= rows or col >= cols)
        throw matrix_out_of_range(row, col, rows, cols);
    return elements[(cols * row) + col];
}

template <typename T>
ostream &operator<<(ostream &out, const matrix<T> &m)
{
    out << '\n';
    for (uint64_t i = 0; i < m.get_rows(); i++)
    {
        out << "( ";
        for (uint64_t j = 0; j < m.get_cols(); j++)
            out << m(i, j) << '\t';
        out << ")\n";
    }
    return out;
}

template <typename T>
matrix<T> operator+(const matrix<T> &a, const matrix<T> &b)
{
    if ((a.get_rows() != b.get_rows()) or (a.get_cols() != b.get_cols()))
        throw typename matrix<T>::incompatible_sizes_add();
    matrix<T> c(a.get_rows(), a.get_cols());
    for (uint64_t i = 0; i < a.get_rows(); i++)
        for (uint64_t j = 0; j < a.get_cols(); j++)
            c(i, j) = a(i, j) + b(i, j);
    return c;
}

template <typename T>
matrix<T> operator+=(matrix<T> &a, const matrix<T> &b)
{
    a = a + b;
    return a;
}

template <typename T>
matrix<T> operator-(const matrix<T> &m)
{
    matrix<T> c(m.get_rows(), m.get_cols());
    for (uint64_t i = 0; i < m.get_rows(); i++)
        for (uint64_t j = 0; j < m.get_cols(); j++)
            c(i, j) = -m(i, j);
    return c;
}

template <typename T>
matrix<T> operator-(const matrix<T> &a, const matrix<T> &b)
{
    if ((a.get_rows() != b.get_rows()) or (a.get_cols() != b.get_cols()))
        throw typename matrix<T>::incompatible_sizes_add();
    matrix<T> c(a.get_rows(), a.get_cols());
    for (uint64_t i = 0; i < a.get_rows(); i++)
        for (uint64_t j = 0; j < a.get_cols(); j++)
            c(i, j) = a(i, j) - b(i, j);
    return c;
}

template <typename T>
matrix<T> operator-=(matrix<T> &a, const matrix<T> &b)
{
    a = a - b;
    return a;
}

template <typename T>
matrix<T> operator*(const matrix<T> &a, const matrix<T> &b)
{
    if (a.get_cols() != b.get_rows())
        throw typename matrix<T>::incompatible_sizes_multiply();
    matrix<T> c(a.get_rows(), b.get_cols());
    for (uint64_t i = 0; i < a.get_rows(); i++)
        for (uint64_t j = 0; j < b.get_cols(); j++)
        {
            c(i, j) = 0;
            for (uint64_t k = 0; k < a.get_cols(); k++)
                c(i, j) += a(i, k) * b(k, j);
        }
    return c;
}

template <typename T>
matrix<T> operator*(const T &s, const matrix<T> &m)
{
    matrix<T> c(m.get_rows(), m.get_cols());
    for (uint64_t i = 0; i < m.get_rows(); i++)
        for (uint64_t j = 0; j < m.get_cols(); j++)
            c(i, j) = s * m(i, j);
    return c;
}

template <typename T>
matrix<T> operator*(const matrix<T> &m, const T &s)
{
    return s * m;
}

template <typename T>
matrix<T>::~matrix()
{
    delete[] elements;
}

We can test it with the following sample main.cpp:

#include <exception>
#include <iostream>
#include "matrix.hpp"
using namespace std;

int main()
{
    try
    {
        // First constructor (rows, cols): create an UNINITIALIZED 3x4 matrix.
        matrix<double> A(3, 4);
        cout << "A:"
             << A << '\n';
        // Second constructor (size, diagonal elements): create a 3x3 matrix with 1, 2, 3 on the diagonal.
        double diag[] = {1, 2, 3};
        matrix<double> B(3, diag);
        cout << "B:"
             << B << '\n';
        // Third constructor (initializer_list): create a 4x4 matrix with 1, 2, 3, 4 on the diagonal.
        matrix<double> C{1, 2, 3, 4};
        cout << "C:"
             << C << '\n';
        // Fourth constructor (rows, cols, elements): create a 2x3 matrix with the given elements.
        double elements[] = {1, 2, 3, 4, 5, 6};
        matrix<double> D(2, 3, elements);
        cout << "D:"
             << D << '\n';
        // Fifth constructor (rows, cols, initializer_list): create a 3x2 matrix with the given elements.
        matrix<double> E(3, 2, {7, 8, 9, 10, 11, 12});
        cout << "E:"
             << E << '\n';

        // Clarification of the difference between the {} and () constructors. Note that this may lead to errors if the wrong type of brackets is used!
        // First constructor (rows, cols) will be used: create an UNINITIALIZED 1x2 matrix.
        cout << "matrix<double>(1, 2):";
        cout << matrix<double>(1, 2) << '\n';
        // Third constructor (initializer_list) will be used: create a 2x2 diagonal matrix with 1, 2 on the diagonal.
        cout << "matrix<double>{1, 2}:";
        cout << matrix<double>{1, 2} << '\n';

        // Demonstration of some of the overloaded operators.
        D(0, 2) = 7;
        cout << "D after D(0, 2) = 7:"
             << D << '\n';
        matrix<double> F = D * B;
        cout << "F = D * B:"
             << F << '\n';
        cout << "D + F:"
             << D + F << '\n';
        cout << "7.0 * B:"
             << 7.0 * B << '\n';

        // Any number of operations can be chained together.
        cout << "3.0 * B - 4.0 * E * D:"
             << 3.0 * B - 4.0 * E * D << '\n';
    }
    catch (const exception &e)
    {
        cout << "Error: " << e.what() << '\n';
    }
}

See the comments for details. A sample output is as follows:

A:
( -4.8367e-26   -4.8367e-26     -4.8367e-26     -4.8367e-26     )
( -4.8367e-26   -4.8367e-26     -4.8367e-26     -4.8367e-26     )
( -4.8367e-26   -4.8367e-26     -4.8367e-26     -4.8367e-26     )

B:
( 1     0       0       )
( 0     2       0       )
( 0     0       3       )

C:
( 1     0       0       0       )
( 0     2       0       0       )
( 0     0       3       0       )
( 0     0       0       4       )

D:
( 1     2       3       )
( 4     5       6       )

E:
( 7     8       )
( 9     10      )
( 11    12      )

matrix<double>(1, 2):
( -4.8367e-26   -4.8367e-26     )

matrix<double>{1, 2}:
( 1     0       )
( 0     2       )

D after D(0, 2) = 7:
( 1     2       7       )
( 4     5       6       )

F = D * B:
( 1     4       21      )
( 4     10      18      )

D + F:
( 2     6       28      )
( 8     15      24      )

7.0 * B:
( 7     0       0       )
( 0     14      0       )
( 0     0       21      )

3.0 * B - 4.0 * E * D:
( -153  -216    -388    )
( -196  -266    -492    )
( -236  -328    -587    )

9.1.9 emplace and emplace_back ^

The vector container has two member functions that have the same functionality as insert() and push_back(), but they construct objects in place rather than first constructing an object and then copying it.

  • emplace(pos, args) inserts an object into the vector at the position given by the iterator pos, with the object constructed in place. args are the arguments passed to the constructor.
  • emplace_back(args) does the same, but inserts at the end of the vector.

Note that emplace() and emplace_back() have the same iterator invalidation rules as insert() and push_back() respectively.

insert() and push_back() will first create a temporary object at some location in memory, and then move it into the vector, which is stored at another location in memory. This takes time. emplace() and emplace_back(), on the other hand, create the object in place at the memory address reserved by vector, so a move constructor does not need to be called. This is illustrated by the following program:

#include <iostream>
#include <vector>
using namespace std;

class my_class
{
public:
    // Constructor
    my_class(const int64_t &in) : i(in)
    {
        cout << "Constructor called!\n";
    }

    // Move constructor
    my_class(my_class &&m) : i(m.i)
    {
        cout << "Move constructor called!\n";
    }

private:
    int64_t i = 0;
};

int main()
{
    vector<my_class> v;
    v.reserve(2);
    v.push_back(my_class(1));
    cout << "push_back() successful.\n\n";
    v.emplace_back(2);
    cout << "emplace_back() successful.\n";
}

The output is:

Constructor called!
Move constructor called!
push_back() successful.

Constructor called!
emplace_back() successful.

We see that using push_back() requires calling the move constructor, while using emplace_back() does not. Therefore, whenever the object to be inserted needs to be constructed on the spot, emplace() or emplace_back() should be used instead of insert() or push_back().

9.2 Memory debugging and smart pointers ^

9.2.1 Memory debugging with Dr. Memory

Above we wrote a program to demonstrate memory leaks. We "detected" the leaks by manually monitoring how much memory is used by the program. This naturally only allows detecting very big leaks, and it also doesn't tell us any specific information about where the leak is coming from. Let us now use demonstrate how to use a tool to automatically detect memory leaks, as well as many other types of memory-related errors.

The tool we will use is called Dr. Memory. It is fully cross-platform, so it will work on every student's computer regardless of which OS or CPU architecture they use. Dr. Memory can detect a variety of memory errors, including:

  • Memory leaks,
  • Reading from uninitialized memory,
  • Accessing unallocated memory,
  • Attempting to free the same memory block more than once,
  • And more.

To use Dr. Memory, download the latest version from the relevant link in the Download section of the website, and install it using the relevant instructions in the Documentation section of the website. We will use the following test program:

int main()
{
    double *p = new double[1000];
    if (p[0] == 0) // ERROR: Reading from uninitialized memory address!
        p[1] = 0;
    p[1000] = 0; // ERROR: Writing to unallocated memory address!
    // ERROR: We did not free the allocated memory!

    double *t = new double[1000];
    delete[] t;
    delete[] t; // ERROR: Attempting to free the same memory block twice!
}

Compile this program with the argument -ggdb3, as usual when debugging, plus the following additional arguments:

  • -static-libgcc and -static-libstdc++ instruct the compiler to place any code for the C and C++ standard libraries directly in the executable file itself (as a statically linked library), instead using a separate file shared by all C++ programs (a shared library). This will significantly increase the size of the executable file, but will allow the memory debugger to access the code for the standard library directly.
  • -fno-inline will disable function inlining, which can interfere with memory debugging.
  • -fno-omit-frame-pointer instructs the compiler not to omit a certain pointer called the frame pointer in functions, which can also interfere with memory debugging.

After adding these arguments, the args field of your tasks.json should look similar to this:

"args": [
    "${workspaceFolder}\\*.cpp",
    "-o",
    "${workspaceFolder}\\CSE701.exe",
    "-Wall",
    "-Wextra",
    "-Wconversion",
    "-Wsign-conversion",
    "-Wshadow",
    "-Wpedantic",
    "-std=c++20",
    "-ggdb3",
    "-static-libgcc",
    "-static-libstdc++",
    "-fno-inline",
    "-fno-omit-frame-pointer"
],

You can compile the program with these arguments without actually running it, by choosing Terminal > Run Build Task... or pressing Ctrl+Shift+B. Note that you will not get any warnings from the compiler, even though there are 4 very serious errors in the program! If you run the program, it will crash due to the double deletion of the array t. But we actually won't run it directly; we will run it through Dr. Memory in order to detect the memory issues.

To run Dr. Memory, first create a subfolder called drmemory in your workspace folder, and then go to the VS Code integrated terminal (Ctrl+`) and type:

drmemory -logdir ./drmemory -batch -- CSE701.exe

On Linux or Mac you should remove the .exe extension. Also, this assumes that Dr. Memory's binary folder has been added to your system's PATH environment variable, which should be performed automatically by the installer. If it doesn't work, you may have to add that folder to the PATH manually (I described how to add a folder to the PATH above).

The argument -logdir ./drmemory will instruct Dr. Memory to store its logs in the drmemory subfolder, instead of in another folder outside of the workspace. -batch will instruct Dr. Memory not to automatically open the results file after it runs. Finally, -- CSE701.exe indicates that the debugger should run the program CSE701.exe.

The terminal will display a lot of information, including any errors detected in the program. After the execution is complete, some new files and folders will be created under the drmemory subfolder. There will be a folder named DrMemory-CSE701.exe.XXXXX.000 with XXXXX being some number. The first time you run Dr. Memory, it may restart itself in order to automatically generate some required files, in which case there will be two such folders with different numbers.

Open the folder DrMemory-CSE701.exe.XXXXX.000, and then open the file results.txt. This file includes the same information that was output to the terminal, but it's more convenient to read in the form of a file. If you have two folders, then one of them will contain an empty results.txt file that you can safely ignore.

Here is what I get in results.txt:

Error #1: UNADDRESSABLE ACCESS beyond top of stack: reading 0x000000000065fab0-0x000000000065fab8 8 byte(s)
# 0 .text                                   [C:/_/M/mingw-w64-crt-git/src/mingw-w64/mingw-w64-crt/crt/pesect.c:230]
# 1 _pei386_runtime_relocator               [C:/_/M/mingw-w64-crt-git/src/mingw-w64/mingw-w64-crt/crt/pseudo-reloc.c:477]
# 2 __tmainCRTStartup                       [C:/_/M/mingw-w64-crt-git/src/mingw-w64/mingw-w64-crt/crt/crtexe.c:279]
# 3 .l_start                                [C:/_/M/mingw-w64-crt-git/src/mingw-w64/mingw-w64-crt/crt/crtexe.c:212]
# 4 KERNEL32.dll!BaseThreadInitThunk
Note: @0:00:00.401 in thread 14880
Note: 0x000000000065fab0 refers to 744 byte(s) beyond the top of the stack 0x000000000065fd98
Note: instruction: or     $0x0000000000000000 (%rcx) -> (%rcx)

Error #2: UNINITIALIZED READ: reading register xmm0
# 0 main               [C:/Users/barak/CSE 701/main.cpp:4]
Note: @0:00:00.425 in thread 14880
Note: instruction: ucomisd %xmm0 %xmm1

Error #3: UNADDRESSABLE ACCESS beyond heap bounds: writing 0x0000000001d54620-0x0000000001d54628 8 byte(s)
# 0 main               [C:/Users/barak/CSE 701/main.cpp:6]
Note: @0:00:00.427 in thread 14880
Note: instruction: movsd  %xmm0 -> (%rax)

Error #4: INVALID HEAP ARGUMENT to free 0x0000000001d54640
# 0 replace_operator_delete_array_nothrow               [d:\drmemory_package\common\alloc_replace.c:2999]
# 1 main                                                [C:/Users/barak/CSE 701/main.cpp:11]
Note: @0:00:00.444 in thread 14880
Note: memory was previously freed here:
Note: # 0 replace_operator_delete_array_nothrow               [d:\drmemory_package\common\alloc_replace.c:2999]
Note: # 1 main                                                [C:/Users/barak/CSE 701/main.cpp:10]

Error #5: POSSIBLE LEAK 26 direct bytes 0x0000000001d401c0-0x0000000001d401da + 0 indirect bytes
# 0 replace_malloc                    [d:\drmemory_package\common\alloc_replace.c:2577]
# 1 msvcrt.dll!realloc               +0x193    (0x00007ffb13fb9f44 <msvcrt.dll+0x19f44>)
# 2 msvcrt.dll!unlock                +0x40c    (0x00007ffb13fdb68d <msvcrt.dll+0x3b68d>)
# 3 msvcrt.dll!_getmainargs          +0x30     (0x00007ffb13fa7a01 <msvcrt.dll+0x7a01>)
# 4 pre_cpp_init                      [C:/_/M/mingw-w64-crt-git/src/mingw-w64/mingw-w64-crt/crt/crtexe.c:171]
# 5 msvcrt.dll!initterm              +0x42     (0x00007ffb13fda553 <msvcrt.dll+0x3a553>)
# 6 __tmainCRTStartup                 [C:/_/M/mingw-w64-crt-git/src/mingw-w64/mingw-w64-crt/crt/crtexe.c:269]
# 7 .l_start                          [C:/_/M/mingw-w64-crt-git/src/mingw-w64/mingw-w64-crt/crt/crtexe.c:212]
# 8 KERNEL32.dll!BaseThreadInitThunk

Error #6: LEAK 8000 direct bytes 0x0000000001d526e0-0x0000000001d54620 + 0 indirect bytes
# 0 replace_operator_new_array               [d:\drmemory_package\common\alloc_replace.c:2929]
# 1 main                                     [C:/Users/barak/CSE 701/main.cpp:3]

FINAL SUMMARY:

DUPLICATE ERROR COUNTS:
    Error #   1:      2

SUPPRESSIONS USED:

ERRORS FOUND:
      2 unique,     3 total unaddressable access(es)
      1 unique,     1 total uninitialized access(es)
      1 unique,     1 total invalid heap argument(s)
      0 unique,     0 total GDI usage error(s)
      0 unique,     0 total handle leak(s)
      0 unique,     0 total warning(s)
      1 unique,     1 total,   8000 byte(s) of leak(s)
      1 unique,     1 total,     26 byte(s) of possible leak(s)
ERRORS IGNORED:
      4 unique,     4 total,  74793 byte(s) of still-reachable allocation(s)
         (re-run with "-show_reachable" for details)
Details: C:\Users\barak\CSE 701\drmemory\DrMemory-CSE701.exe.6900.000\results.txt

Let us go over the errors:

  • Error #1 and Error #5 are, as far as I can tell, not errors in our program itself. They could be false positives.
  • Error #2 is an "UNINITIALIZED READ", detected at line 4 of main.cpp. This is the line if (p[0] == 0), which reads the first element of the uninitialized array, which means it will read a garbage value.
  • Error #3 is an "UNADDRESSABLE ACCESS beyond heap bounds" of 8 bytes (one double), detected at line 6 of main.cpp. This is the line p[1000] = 0, which writes to element number 1001 (counting from zero) of a 1000-element array, thus writing beyond the bounds of the allocated memory.
  • Error #4 is an "INVALID HEAP ARGUMENT to free", detected at line 11 of main.cpp. This is the second delete[] t, which attempts to free a memory block that has already been freed. Indeed, the log tells us that the memory was previously freed in line 10.
  • Error #6 is a "LEAK" of 8000 bytes (1000 doubles, i.e. the entire array p), detected at line 3 of main.cpp. This is the line double *p = new double[1000], which allocates the array. Indeed, we forgot to delete this array.
Warning: As previously emphasized, C and C++ let the programmer manage memory manually, instead of managing it automatically like most higher-level languages, and this can result in a substantial performance increase, but it is also extremely prone to errors. Even if you don't explicitly use dynamic memory allocation in your program, you may still be, for example, using uninitialized values or accessing memory out of the bounds of an array. Therefore, it is important to use memory debugging tools such as Dr. Memory to check for memory-related errors.

Fix all the errors (I'll let you figure out how to do that on your own), recompile the program, and run Dr. Memory again. You will see that all of the errors (aside from any potential false positives, such as #1 and #5 above) will disappear.

Once you are done with memory debugging, you should remove all the new compiler arguments you added to tasks.json at the beginning of this section.

9.2.2 "Resource Acquisition Is Initialization" ^

Resource Acquisition Is Initialization (RAII) is a very important concept in C++ programming. Essentially, it means that each object should manage its own resources, and resources should only be managed in this way. These resources can include memory, files, disk space, network connections, and anything else that exists in limited supply.

You can think of RAII as an extension of the concepts of encapsulation and class invariants:

  1. Each object encapsulates not only the data and the functions that process that data, but also any and all resources needed to store and process the data.
  2. Resource allocation is considered a class invariant, so if done correctly, it can always be assumed that the resources have been allocated and are available for use.

One common situation where memory leaks may happen is if you allocate memory in one class, and then pass the pointer to the allocated memory block to another class. In such a case, it would be hard to guarantee that the memory will be properly deallocated; it may end up never being deallocated at all, causing a memory leak, or being deallocated more than once, causing a crash.

Instead, you should follow the RAII principle by both allocating memory and accessing the allocated memory only within a single class. The memory should be allocated as part of the constructor, and deallocated as part of the destructor. Other classes should never access that memory directly, and instead do so only through member functions of the memory-managing class. This will both greatly simplify the way memory allocation works in your program, and reduce the chance of mistakes that may lead to memory leaks and other memory-related bugs.

A good example of following this principle is in my matrix class template with dynamic memory allocation; the matrix class manages its own memory, separately for each object, and no other class has direct access to this memory. The only way to access the managed memory is through the member functions operator() or at(). This ensures that memory management is 100% encapsulated by the class.

9.2.3 Smart pointers ^

One way to ensure that memory leaks do not happen is to use STL vectors, which automatically allocate, reallocate, and deallocate memory on the heap as needed. However, we also saw that this incurs a performance penalty. A more sophisticated and optimized way of avoiding memory leaks in modern C++ is using smart pointers.

When you allocate memory with the new operator, instead of storing the result in a raw pointer that you must make sure to delete later, you store it in a smart pointer. The smart pointer object now owns the raw pointer, and when the smart pointer goes out of scope, for example when a code block or function ends, it automatically deallocates the associated memory block.

Smart pointers are C++'s alternative to garbage collection, but unlike garbage collection in higher-level languages, smart pointers have essentially no performance overhead - so there is absolutely no reason not to use them.

Using smart pointers, you don't need to worry about deallocating the memory manually with delete anymore, and in particular, you don't have to worry about the delete statement not being reached due to an exception or some other unforeseen circumstance, which is usually how memory leaks occur. No matter what happens, the memory is guaranteed to be deallocated automatically.

There are three types of smart pointers, defined in the header file <memory>:

  • unique_ptr is a unique smart pointer, which means that the same raw pointer cannot be owned by more than one unique_ptr object. The reason the smart pointer needs to be unique is that if a raw pointer is managed by two different smart pointer objects, then they will both try to delete the same raw pointer when they go out of scope, which will cause the program to crash. A unique_ptr is essentially just as efficient as a raw pointer, and is the most commonly used smart pointer.
  • shared_ptr is a shared smart pointer, which means that the same raw pointer can be owned by more than one shared_ptr object. By keeping track behind the scenes of how many shared_ptr objects refer to the same raw pointer, it can be guaranteed that the raw pointer will only be deleted once, after all of its owners have gone out of scope. However, this process, called reference counting, adds complexity and impedes performance, so it is not recommended to use shared_ptr unless you have to.
  • weak_ptr may be used when you want to access an existing shared_ptr without participating in reference counting.

We will only discuss unique_ptr in this course. It is used as follows:

unique_ptr<T> pointer(new T);

Here, T is the type of the object we are allocating, and pointer is the name of the smart pointer. We can also allocate memory for an (uninitialized) array:

unique_ptr<T[]> pointer(new T[size]);

where size is the size of the array. If we want to zero-initialize the array, we can do so by adding () to the new operator as usual:

unique_ptr<T[]> pointer(new T[size]());

Once we have a unique_ptr defined, we can carry on as normal, knowing that when the smart pointer goes out of scope (which usually means the object that declared it is destroyed), the object or array we allocated will be automatically deallocated for us.

We can use the following member functions:

  • get() returns the raw pointer owned by the smart pointer, or nullptr if no raw pointer is owned.
  • The bool operator (i.e. the result of putting the smart pointer in an if statement) returns true if the smart pointer owns an object, or false otherwise.
  • reset(pointer) frees up the currently owned pointer (if a pointer is owned) and instructs the smart pointer to own the raw pointer pointer instead.
  • The assignment operator = is a move operator used to transfer ownership between two unique_ptr objects. It must be used in conjunction with the function move(). The syntax is p1 = move(p2), which will result in p1 taking ownership of the raw pointer previously owned by p2, and p2 not owning any raw pointer anymore.

In addition:

  • If the smart pointer points to a single object, i.e. the template is of the form unique_ptr<T>, the raw pointer can be dereferenced using * and members of the object can be accessed using ->, as for any pointer to an object.
  • If the smart pointer points to an array, i.e. the template is of the form unique_ptr<T[]>, the elements can be accessed using [], as for any array.

We demonstrate the use of smart pointers in the following program:

#include <iostream>
#include <memory>
#include <string>
using namespace std;

template <typename T>
void print_smart_pointer(const T &ptr, const string &name, const uint64_t &size)
{
    if (ptr)
    {
        cout << "The smart pointer " << name << " owns a raw pointer with the address " << ptr.get() << ".\n";
        cout << "The elements of the array at that address are: ";
        for (uint64_t i = 0; i < size; i++)
            cout << ptr[i] << ' ';
        cout << '\n';
    }
    else
        cout << "The smart pointer " << name << " does not own a raw pointer.\n";
}

template <typename T>
void print_smart_pointers(const T &ptr1, const T &ptr2, const uint64_t &size)
{
    print_smart_pointer(ptr1, "ptr1", size);
    print_smart_pointer(ptr2, "ptr2", size);
}

template <typename T>
void set_elements(const T &ptr, const uint64_t &size)
{
    for (uint64_t i = 0; i < size; i++)
        ptr[i] = i * i;
}

int main()
{
    constexpr uint64_t size = 5;

    unique_ptr<int64_t[]> ptr1, ptr2;
    cout << "Initial state:\n";
    print_smart_pointers(ptr1, ptr2, size);

    ptr1.reset(new int64_t[size]);
    cout << "\nAfter ptr1.reset(new int64_t[size]):\n";
    set_elements(ptr1, size);
    print_smart_pointers(ptr1, ptr2, size);

    ptr2 = move(ptr1);
    cout << "\nAfter ptr2 = move(ptr1):\n";
    print_smart_pointers(ptr1, ptr2, size);
}

Output:

Initial state:
The smart pointer ptr1 does not own a raw pointer.
The smart pointer ptr2 does not own a raw pointer.

After ptr1.reset(new int64_t[size]):
The smart pointer ptr1 owns a raw pointer with the address 0x1a23dffa030.
The elements of the array at that address are: 0 1 4 9 16
The smart pointer ptr2 does not own a raw pointer.

After ptr2 = move(ptr1):
The smart pointer ptr1 does not own a raw pointer.
The smart pointer ptr2 owns a raw pointer with the address 0x1a23dffa030.
The elements of the array at that address are: 0 1 4 9 16
Warning: Creating a raw pointer with new and only later assigning it to a smart pointer via a constructor or the reset() member function should be avoided whenever possible, since there is always the chance that some error will occur between new and assigning the raw pointer to the smart pointer, resulting in a memory leak. Always prefer to use new directly inside the argument to the constructor or the reset() member function of a smart pointer, to guarantee that the smart pointer will immediately own the raw pointer.

In other words, avoid doing something like this:

double *raw_pointer = new double[1000];
do_some_things();
// BAD: An error could have occurred before we assigned the smart pointer!
unique_ptr<double[]> smart_pointer(raw_pointer);

or

unique_ptr<double[]> smart_pointer;
double *raw_pointer = new double[1000];
do_some_things();
// BAD: An error could have occurred before we assigned the smart pointer!
smart_pointer.reset(raw_pointer);

Instead, do this:

// GOOD: Use new directly inside the argument to the constructor, so the smart pointer is assigned immediately, with no intermediate steps.
unique_ptr<double[]> smart_pointer(new double[1000]);
// We can still use the raw pointer, but only AFTER assigning the smart pointer, using get():
double *raw_pointer = smart_pointer.get();

or this:

unique_ptr<double[]> smart_pointer;
// GOOD: Use new directly inside the argument to the reset member function, so the smart pointer is assigned immediately, with no intermediate steps.
smart_pointer.reset(new double[1000]);
// We can still use the raw pointer, but only AFTER assigning the smart pointer, using get():
double *raw_pointer = smart_pointer.get();

9.2.4 Performance of smart pointers ^

We can compare the performance of vector, unique_ptr, and raw pointers using the following program:

#include <chrono>
#include <iostream>
#include <memory>
#include <vector>
using namespace std;

class timer
{
public:
    void start()
    {
        start_time = chrono::steady_clock::now();
    }

    void end()
    {
        elapsed_time = chrono::steady_clock::now() - start_time;
    }

    double seconds() const
    {
        return elapsed_time.count();
    }

private:
    chrono::time_point<chrono::steady_clock> start_time = chrono::steady_clock::now();
    chrono::duration<double> elapsed_time = chrono::duration<double>::zero();
};

int main()
{
    const uint64_t size = 1'000'000'000;
    timer tmr;
    cout.precision(2);
    cout << fixed;

    {
        tmr.start();
        vector<int64_t> test(size);
        for (uint64_t i = 0; i < size; i++)
            test[i] = i;
        tmr.end();
    }
    cout << "vector, access via []:              " << tmr.seconds() << " seconds.\n";

    {
        tmr.start();
        vector<int64_t> test(size);
        int64_t *ptr_to_test = test.data();
        for (uint64_t i = 0; i < size; i++)
            ptr_to_test[i] = i;
        tmr.end();
    }
    cout << "vector, access via raw pointer:     " << tmr.seconds() << " seconds.\n";

    {
        tmr.start();
        unique_ptr<int64_t[]> test(new int64_t[size]);
        for (uint64_t i = 0; i < size; i++)
            test[i] = i;
        tmr.end();
    }
    cout << "unique_ptr, access via []:          " << tmr.seconds() << " seconds.\n";

    {
        tmr.start();
        unique_ptr<int64_t[]> test(new int64_t[size]);
        int64_t *ptr_to_test = test.get();
        for (uint64_t i = 0; i < size; i++)
            ptr_to_test[i] = i;
        tmr.end();
    }
    cout << "unique_ptr, access via raw pointer: " << tmr.seconds() << " seconds.\n";

    {
        tmr.start();
        int64_t *test = new int64_t[size];
        for (uint64_t i = 0; i < size; i++)
            test[i] = i;
        tmr.end();
        // Note: Here we must free the memory manually since we are not using a smart pointer!
        delete[] test;
    }
    cout << "Pure raw pointer:                   " << tmr.seconds() << " seconds.\n";
}

The timer class I used here is the same one used above when we discussed compiler optimizations. Each array is 8 GB in size (1 billion elements of 8 bytes each), to get more statistically significant measurements. If your system has less than 16 GB of RAM in total, you should reduce the size of the array accordingly before running this code.

Since the program allocates 32 GB of memory in total, which would use up all the RAM I have on my computer, I placed each part inside a code block {}. When the code blocks for the vector or unique_ptr objects end, they go out of scope, and the memory is deallocated automatically thanks to the smart pointer; you can easily see this if you keep an eye on the memory usage as I explained above. I placed the manually-allocated array in its own code block as well, just for consistency; it will of course not be automatically deallocated when that code block ends, which is why I added a manual delete.

Running this program without any compiler optimizations, I find:

vector, access via []:              3.88 seconds.
vector, access via raw pointer:     3.76 seconds.
unique_ptr, access via []:          8.26 seconds.
unique_ptr, access via raw pointer: 2.35 seconds.
Pure raw pointer:                   2.20 seconds.

We see that unique_ptr is essentially just as fast as a raw pointer, but only if we access the raw pointer directly, rather than via the [] operator of the smart pointer. In the latter case, it is actually twice as slow as vector! This is probably due to overhead that accumulates every time [] is called. Therefore, it is a good idea to instead call get() once, and use the obtained raw pointer to assign values to the array, as we did here.

One way to implement this optimization in a class that allocates an array is to store both a smart pointer and a raw pointer obtained via get() as class members. The smart pointer will be used to ensure the memory is deallocated properly, while the raw pointer will be used to access the actual array elements. This is the approach I will take in the updated matrix class in the next section.

We also see that accessing a vector via a raw pointer (obtained via data) is slightly faster than accessing it via the [] operator. However, even then it is still almost twice as slow as accessing a manually-allocated array. This is mostly because, as I stressed before, a vector automatically initializes all its elements to zero upon construction, which is a waste of time since we then re-initialize them to other values anyway.

Interestingly, if I add () to all the new statements, i.e. new int64_t[size](), in order to force them to initialize to zero as well, I find similar results. This may be because the compiler is optimizing the initialization away (even though no optimization flags are enabled).

If I enable compiler optimizations, things change drastically. After employing the -O3 argument in tasks.json, which instructs GCC to utilize all available optimizations, the differences between the two access methods ([] vs. raw pointer) vanish, both for vector and for unique_ptr:

vector, access via []:              1.25 seconds.
vector, access via raw pointer:     1.23 seconds.
unique_ptr, access via []:          0.93 seconds.
unique_ptr, access via raw pointer: 0.93 seconds.
Pure raw pointer:                   0.90 seconds.

The reason is that the compiler now recognizes that we are using [] in the first and third cases, and as part of the optimization process, it automatically does exactly what I did manually in the second and fourth cases. unique_ptr is now essentially just as fast as a pure raw pointer in all cases, although vector still lags behind considerably, as the double initializations are not optimized away even at maximum optimization.

As I said before, my philosophy for performance optimizations is to never trust the compiler to do my job for me, and always write code that is optimized on its own, without relying on compiler optimizations. Therefore, my recommendation in cases where maximum performance is required is to always use unique_ptr, but access the array via a raw pointer.

In conclusion, smart pointers provide the exact same performance as raw pointers, but they are 100% safe to use, so there is simply no reason to ever use new and delete manually in modern C++, except in special cases which you will probably never encounter in scientific programming.

Note also that there are many good reasons to use vector instead of smart pointers, since a vector knows its own size, can easily be resized, provides iterators that can be used with STL algorithms, works with range-based for loops, and has many other convenient features.

Pretty much the only downside of vector is the reduced performance due to the double initializations, but in many cases you actually do want to initialize it to zeros, and in any case the penalty to performance is really only relevant when initializing or resizing the array - accessing it after it has been initialized is just as fast.

Finally, let me comment that the performance-related behavior we encountered in this section seems to be specific to GCC. On both Windows and Linux, when compiling with g++ and no compiler optimizations, I get similar results. However, when compiling with MSVC (the Microsoft Visual C++ compiler) with all optimizations disabled (/Od), I get very different results:

vector, access via []:              3.07 seconds.
vector, access via raw pointer:     2.91 seconds.
unique_ptr, access via []:          2.52 seconds.
unique_ptr, access via raw pointer: 2.23 seconds.
Pure raw pointer:                   2.01 seconds.

This could be because MSVC implements vectors and smart pointers differently, which in this particular case results in better performance out of the box. With maximum optimizations (/O2), I get similar results to what I got with -O3 in GCC. The lesson is that you should always do your own performance tests to see what works best with the specific compiler and operating system you are using.

You can read more about smart pointers in the C++ reference or Microsoft's C++ reference.

9.2.5 The matrix class template with smart pointers ^

Now that we have learned about smart pointers, we can write the "ultimate" version of our matrix class template), which will feature the fastest performance due to avoiding double initializations, while also being protected against memory leaks due to the use of smart pointers.

First, we add the line #include <memory> at the top. Then, we add a unique_ptr member named smart to the class, but we still keep the raw pointer elements, which will be the one we will actually use to access the elements, for maximum performance. The smart pointer will be declared at the very end of the class, after elements:

unique_ptr<T[]> smart = nullptr;

In the constructors, we replace each of the 6 instances of

elements = new T[rows * cols];

with

smart.reset(new T[rows * cols]);
elements = smart.get();

Note that by first assigning the raw pointer to smart using reset() and only then storing the raw pointer obtained with get() in elements, we are ensuring that elements never exists - even for a fraction of a second - as a raw pointer without being managed by smart.

We also remove the destructor ~matrix(), since the smart pointer will take care of deallocating memory for us; if you don't remove the destructor, you will notice that if you try to create a matrix, the program crashes due to trying to deallocate memory twice, once automatically via the smart pointer and once manually via delete! There are two other places where we execute delete[] elements: in the copy and move assignment operators, so we should remove those as well.

In the move constructor, we must first move the smart pointer using smart = move(m.smart), which transfers the raw pointer from being owned by m.smart to being owned by smart; this means m.smart will no longer own anything, so we don't need to reset it manually. Then we store the raw pointer itself using elements = smart.get(); we could also just write elements = m.elements, but that wouldn't work if m.elements was somehow corrupted, so using smart.get() is safer. Finally, we set the moved matrix to a degenerate state. Thus the new move constructor is:

template <typename T>
matrix<T>::matrix(matrix<T> &&m)
    : rows(m.rows), cols(m.cols)
{
    smart = move(m.smart);
    elements = smart.get();
    m.rows = 0;
    m.cols = 0;
    m.elements = nullptr;
}

Finally, we must do the same thing in the move assignment operator:

template <typename T>
matrix<T> &matrix<T>::operator=(matrix<T> &&m)
{
    rows = m.rows;
    cols = m.cols;
    smart = move(m.smart);
    elements = smart.get();
    m.rows = 0;
    m.cols = 0;
    m.elements = nullptr;
    return *this;
}

I also made some additional tweaks for this final version:

  1. I declared all overloaded operators as friend functions, so they don't have to use get_rows() and get_cols() to access the number of rows and columns - they just have direct access to the rows and cols member functions. This both shortens and simplifies the code, and avoids a few extra function calls. I moved the declarations of the operators into the class declaration, and changed the template parameter from T to U since the same template parameter cannot be used in nested templates.
  2. I declared the short functions get_rows(), get_cols(), operator(), at(), operator+=, operator-=, and the third version of operator*, as inline functions for increased performance. The compiler will most likely implement them as inline anyway due to optimizations, but since I don't like to rely on the compiler doing my job for me, I declared them manually.
  3. I improved the operator<< overload to use the manipulator setw instead of tabs for formatting. The overload figures out how many characters to use by sending the elements into a string stream and then finding the maximum width of the resulting strings. Note that this required including the header files <iomanip> and <sstream>. Furthermore, the overload now prints out degenerate matrices with zero rows and columns, which are the result of a move constructor or assignment, as () - instead of not printing anything at all, which was the behavior so far.

The end result is the following matrix.hpp file:

#include <initializer_list>
#include <iomanip>
#include <iostream>
#include <memory>
#include <sstream>
#include <stdexcept>
#include <vector>
using namespace std;

// =========
// Interface
// =========

template <typename T>
class matrix
{
public:
    // === Constructors ===

    // Constructor to create n UNINITIALIZED matrix.
    // First argument: number of rows.
    // Second argument: number of columns.
    matrix(const uint64_t &, const uint64_t &);

    // Constructor to create a diagonal matrix from an array.
    // First argument: length of the diagonal (equal to the number of rows and columns).
    // Second argument: an array containing the elements on the diagonal. The elements will be copied into the matrix.
    matrix(const uint64_t &, const T *);

    // Constructor to create a diagonal matrix from an initializer_list.
    // Argument: an initializer_list containing the elements on the diagonal.
    // Number of rows and columns is inferred automatically.
    matrix(const initializer_list<T> &);

    // Constructor to create a matrix from an array.
    // First argument: number of rows.
    // Second argument: number of columns.
    // Third argument: an array containing the elements in row-major order. The elements will be copied into the matrix.
    matrix(const uint64_t &, const uint64_t &, const T *);

    // Constructor to create a matrix from an initializer_list.
    // First argument: number of rows.
    // Second argument: number of columns.
    // Third argument: an initializer_list containing the elements in row-major order.
    matrix(const uint64_t &, const uint64_t &, const initializer_list<T> &);

    // Copy constructor.
    matrix(const matrix<T> &);

    // Move constructor.
    matrix(matrix<T> &&);

    // === Member functions ===

    // Overloaded copy assignment operator.
    matrix<T> &operator=(const matrix<T> &);

    // Overloaded move assignment operator.
    matrix<T> &operator=(matrix<T> &&m);

    // Member function to obtain (but not modify) the number of rows in the matrix.
    uint64_t get_rows() const;

    // Member function to obtain (but not modify) the number of columns in the matrix.
    uint64_t get_cols() const;

    // Overloaded operator () to access matrix elements WITHOUT range checking.
    // The indices start from 0: m(0, 1) would be the element at row 1, column 2.
    // First version: allows modification of the element.
    T &operator()(const uint64_t &, const uint64_t &);

    // Overloaded operator () to access matrix elements WITHOUT range checking.
    // The indices start from 0: m(0, 1) would be the element at row 1, column 2.
    // Second version: does not allow modification of the element.
    const T &operator()(const uint64_t &, const uint64_t &) const;

    // Member function to access matrix elements WITH range checking (throws out_of_range).
    // The indices start from 0: m.at(0, 1) would be the element at row 1, column 2.
    // First version: allows modification of the element.
    T &at(const uint64_t &, const uint64_t &);

    // Member function to access matrix elements WITH range checking (throws out_of_range).
    // The indices start from 0: m.at(0, 1) would be the element at row 1, column 2.
    // Second version: does not allow modification of the element.
    const T &at(const uint64_t &, const uint64_t &) const;

    // === Friend functions ===

    // Overloaded binary operator << to easily print out a matrix to a stream.
    template <typename U>
    friend ostream &operator<<(ostream &, const matrix<U> &);

    // Overloaded binary operator + to add two matrices.
    template <typename U>
    friend matrix<U> operator+(const matrix<U> &, const matrix<U> &);

    // Overloaded binary operator += to add two matrices and assign the result to the first one.
    template <typename U>
    friend matrix<U> operator+=(matrix<U> &, const matrix<U> &);

    // Overloaded unary operator - to take the negative of a matrix.
    template <typename U>
    friend matrix<U> operator-(const matrix<U> &);

    // Overloaded binary operator - to subtract two matrices.
    template <typename U>
    friend matrix<U> operator-(const matrix<U> &, const matrix<U> &);

    // Overloaded binary operator -= to subtract two matrices and assign the result to the first one.
    template <typename U>
    friend matrix<U> operator-=(matrix<U> &, const matrix<U> &);

    // Overloaded binary operator * to multiply two matrices.
    template <typename U>
    friend matrix<U> operator*(const matrix<U> &, const matrix<U> &);

    // Overloaded binary operator * to multiply a scalar on the left and a matrix on the right.
    template <typename U>
    friend matrix<U> operator*(const U &, const matrix<U> &);

    // Overloaded binary operator * to multiply a matrix on the left and a scalar on the right.
    template <typename U>
    friend matrix<U> operator*(const matrix<U> &, const U &);

    // === Exceptions ===

    // Exception to be thrown if the number of rows or columns given to the constructor is zero.
    class zero_size : public invalid_argument
    {
    public:
        zero_size() : invalid_argument("Matrix cannot have zero rows or columns!"){};
    };

    // Exception to be thrown if the vector of elements provided to the constructor is of the wrong size.
    class initializer_wrong_size : public invalid_argument
    {
    public:
        initializer_wrong_size() : invalid_argument("Initializer does not have the expected number of elements!"){};
    };

    // Exception to be thrown if two matrices of different sizes are added or subtracted.
    class incompatible_sizes_add : public invalid_argument
    {
    public:
        incompatible_sizes_add() : invalid_argument("Cannot add or subtract two matrices of different dimensions!"){};
    };

    // Exception to be thrown if two matrices of incompatible sizes are multiplied.
    class incompatible_sizes_multiply : public invalid_argument
    {
    public:
        incompatible_sizes_multiply() : invalid_argument("Two matrices can only be multiplied if the number of columns in the first matrix is equal to the number of rows in the second matrix!"){};
    };

    // Exception to be thrown if the requested matrix element is out of range.
    class matrix_out_of_range : public out_of_range
    {
    public:
        matrix_out_of_range(const uint64_t &row, const uint64_t &col, const uint64_t &rows, const uint64_t &cols) : out_of_range("Tried to access matrix element at row " + to_string(row) + ", column " + to_string(col) + ". Row must be in the range [0," + to_string(rows - 1) + "] and column must be in the range [0," + to_string(cols - 1) + "]."){};
    };

private:
    // The number of rows.
    uint64_t rows = 0;

    // The number of columns.
    uint64_t cols = 0;

    // An array storing the elements of the matrix in flattened (1-dimensional) form.
    T *elements = nullptr;

    // A smart pointer to manage the memory allocated for the matrix elements.
    unique_ptr<T[]> smart = nullptr;
};

// ==============
// Implementation
// ==============

// === Constructors ===

template <typename T>
matrix<T>::matrix(const uint64_t &_rows, const uint64_t &_cols)
    : rows(_rows), cols(_cols)
{
    if (rows == 0 or cols == 0)
        throw zero_size();
    smart.reset(new T[rows * cols]);
    elements = smart.get();
}

template <typename T>
matrix<T>::matrix(const uint64_t &_size, const T *_diagonal)
    : rows(_size), cols(_size)
{
    if (rows == 0)
        throw zero_size();
    smart.reset(new T[rows * cols]);
    elements = smart.get();
    for (uint64_t i = 0; i < rows; i++)
        for (uint64_t j = 0; j < cols; j++)
            elements[(cols * i) + j] = ((i == j) ? _diagonal[i] : 0);
}

template <typename T>
matrix<T>::matrix(const initializer_list<T> &_diagonal)
    : matrix(_diagonal.size(), _diagonal.begin()) {}

template <typename T>
matrix<T>::matrix(const uint64_t &_rows, const uint64_t &_cols, const T *_elements)
    : rows(_rows), cols(_cols)
{
    if (rows == 0 or cols == 0)
        throw zero_size();
    smart.reset(new T[rows * cols]);
    elements = smart.get();
    for (uint64_t i = 0; i < rows * cols; i++)
        elements[i] = _elements[i];
}

template <typename T>
matrix<T>::matrix(const uint64_t &_rows, const uint64_t &_cols, const initializer_list<T> &_elements)
    : rows(_rows), cols(_cols)
{
    if (rows == 0 or cols == 0)
        throw zero_size();
    if (_elements.size() != rows * cols)
        throw initializer_wrong_size();
    smart.reset(new T[rows * cols]);
    elements = smart.get();
    for (uint64_t i = 0; i < rows * cols; i++)
        elements[i] = _elements.begin()[i];
}

template <typename T>
matrix<T>::matrix(const matrix<T> &m)
    : rows(m.rows), cols(m.cols)
{
    smart.reset(new T[rows * cols]);
    elements = smart.get();
    for (uint64_t i = 0; i < rows * cols; i++)
        elements[i] = m.elements[i];
}

template <typename T>
matrix<T>::matrix(matrix<T> &&m)
    : rows(m.rows), cols(m.cols)
{
    smart = move(m.smart);
    elements = smart.get();
    m.rows = 0;
    m.cols = 0;
    m.elements = nullptr;
}

// === Member functions ===

template <typename T>
matrix<T> &matrix<T>::operator=(const matrix<T> &m)
{
    rows = m.rows;
    cols = m.cols;
    smart.reset(new T[rows * cols]);
    elements = smart.get();
    for (uint64_t i = 0; i < rows * cols; i++)
        elements[i] = m.elements[i];
    return *this;
}

template <typename T>
matrix<T> &matrix<T>::operator=(matrix<T> &&m)
{
    rows = m.rows;
    cols = m.cols;
    smart = move(m.smart);
    elements = smart.get();
    m.rows = 0;
    m.cols = 0;
    m.elements = nullptr;
    return *this;
}

template <typename T>
inline uint64_t matrix<T>::get_rows() const
{
    return rows;
}

template <typename T>
inline uint64_t matrix<T>::get_cols() const
{
    return cols;
}

template <typename T>
inline T &matrix<T>::operator()(const uint64_t &row, const uint64_t &col)
{
    return elements[(cols * row) + col];
}

template <typename T>
inline const T &matrix<T>::operator()(const uint64_t &row, const uint64_t &col) const
{
    return elements[(cols * row) + col];
}

template <typename T>
inline T &matrix<T>::at(const uint64_t &row, const uint64_t &col)
{
    if (row >= rows or col >= cols)
        throw matrix_out_of_range(row, col, rows, cols);
    return elements[(cols * row) + col];
}

template <typename T>
inline const T &matrix<T>::at(const uint64_t &row, const uint64_t &col) const
{
    if (row >= rows or col >= cols)
        throw matrix_out_of_range(row, col, rows, cols);
    return elements[(cols * row) + col];
}

// === Friend functions ===

template <typename T>
ostream &operator<<(ostream &out, const matrix<T> &m)
{
    out << '\n';
    if (m.rows == 0 && m.cols == 0)
        out << "()\n";
    else
    {
        ostringstream ss;
        uint64_t max_width = 0;
        for (uint64_t i = 0; i < m.rows; i++)
            for (uint64_t j = 0; j < m.cols; j++)
            {
                ss << m(i, j);
                max_width = max(max_width, ss.str().size());
                ss.str("");
            }
        for (uint64_t i = 0; i < m.rows; i++)
        {
            out << "( ";
            for (uint64_t j = 0; j < m.cols; j++)
                out << setw((int)max_width) << m(i, j) << ' ';
            out << ")\n";
        }
    }
    return out;
}

template <typename T>
matrix<T> operator+(const matrix<T> &a, const matrix<T> &b)
{
    if ((a.rows != b.rows) or (a.cols != b.cols))
        throw typename matrix<T>::incompatible_sizes_add();
    matrix<T> c(a.rows, a.cols);
    for (uint64_t i = 0; i < a.rows; i++)
        for (uint64_t j = 0; j < a.cols; j++)
            c(i, j) = a(i, j) + b(i, j);
    return c;
}

template <typename T>
inline matrix<T> operator+=(matrix<T> &a, const matrix<T> &b)
{
    a = a + b;
    return a;
}

template <typename T>
matrix<T> operator-(const matrix<T> &m)
{
    matrix<T> c(m.rows, m.cols);
    for (uint64_t i = 0; i < m.rows; i++)
        for (uint64_t j = 0; j < m.cols; j++)
            c(i, j) = -m(i, j);
    return c;
}

template <typename T>
matrix<T> operator-(const matrix<T> &a, const matrix<T> &b)
{
    if ((a.rows != b.rows) or (a.cols != b.cols))
        throw typename matrix<T>::incompatible_sizes_add();
    matrix<T> c(a.rows, a.cols);
    for (uint64_t i = 0; i < a.rows; i++)
        for (uint64_t j = 0; j < a.cols; j++)
            c(i, j) = a(i, j) - b(i, j);
    return c;
}

template <typename T>
inline matrix<T> operator-=(matrix<T> &a, const matrix<T> &b)
{
    a = a - b;
    return a;
}

template <typename T>
matrix<T> operator*(const matrix<T> &a, const matrix<T> &b)
{
    if (a.cols != b.rows)
        throw typename matrix<T>::incompatible_sizes_multiply();
    matrix<T> c(a.rows, b.cols);
    for (uint64_t i = 0; i < a.rows; i++)
        for (uint64_t j = 0; j < b.cols; j++)
        {
            c(i, j) = 0;
            for (uint64_t k = 0; k < a.cols; k++)
                c(i, j) += a(i, k) * b(k, j);
        }
    return c;
}

template <typename T>
matrix<T> operator*(const T &s, const matrix<T> &m)
{
    matrix<T> c(m.rows, m.cols);
    for (uint64_t i = 0; i < m.rows; i++)
        for (uint64_t j = 0; j < m.cols; j++)
            c(i, j) = s * m(i, j);
    return c;
}

template <typename T>
inline matrix<T> operator*(const matrix<T> &m, const T &s)
{
    return s * m;
}

We can check it using the following main.cpp

#include <iostream>
#include "matrix.hpp"
using namespace std;

int main()
{
    matrix<double> m1{1, 2};
    cout << "m1 ="
         << m1 << '\n';

    // Test the copy constructor.
    matrix<double> m2(m1);
    cout << "After copy construction of m2 from m1:\n";
    cout << "m1 ="
         << m1 << '\n';
    cout << "m2 ="
         << m2 << '\n';
    cout << "After changing the top-right element of m2 to 3:\n";
    m2(0, 1) = 3;
    cout << "m1 ="
         << m1 << '\n';
    cout << "m2 ="
         << m2 << '\n';

    // Test the copy assignment operator.
    matrix<double> m3(2, 2);
    m3 = m2;
    cout << "After copy assignment of m3 from m2:\n";
    cout << "m2 ="
         << m2 << '\n';
    cout << "m3 ="
         << m3 << '\n';
    cout << "After changing the bottom-left element of m3 to 4:\n";
    m3(1, 0) = 4;
    cout << "m2 ="
         << m2 << '\n';
    cout << "m3 ="
         << m3 << '\n';

    // Test the move constructor.
    matrix<double> m4 = move(m3);
    cout << "After move construction of m4 from m3:\n";
    cout << "m3 ="
         << m3 << '\n';
    cout << "m4 ="
         << m4 << '\n';
    cout << "After changing the top-left element of m4 to 5:\n";
    m4(0, 0) = 5;
    cout << "m3 ="
         << m3 << '\n';
    cout << "m4 ="
         << m4 << '\n';

    // Test the move assignment operator.
    matrix<double> m5(2, 2);
    m5 = move(m4);
    cout << "After move assignment of m5 from m4:\n";
    cout << "m4 ="
         << m4 << '\n';
    cout << "m5 ="
         << m5 << '\n';
    cout << "After changing the bottom-right element of m5 to 6:\n";
    m5(1, 1) = 6;
    cout << "m4 ="
         << m4 << '\n';
    cout << "m5 ="
         << m5 << '\n';
}

The output should be:

m1 =
( 1 0 )
( 0 2 )

After copy construction of m2 from m1:
m1 =
( 1 0 )
( 0 2 )

m2 =
( 1 0 )
( 0 2 )

After changing the top-right element of m2 to 3:
m1 =
( 1 0 )
( 0 2 )

m2 =
( 1 3 )
( 0 2 )

After copy assignment of m3 from m2:
m2 =
( 1 3 )
( 0 2 )

m3 =
( 1 3 )
( 0 2 )

After changing the bottom-left element of m3 to 4:
m2 =
( 1 3 )
( 0 2 )

m3 =
( 1 3 )
( 4 2 )

After move construction of m4 from m3:
m3 =
()

m4 =
( 1 3 )
( 4 2 )

After changing the top-left element of m4 to 5:
m3 =
()

m4 =
( 5 3 )
( 4 2 )

After move assignment of m5 from m4:
m4 =
()

m5 =
( 5 3 )
( 4 2 )

After changing the bottom-right element of m5 to 6:
m4 =
()

m5 =
( 5 3 )
( 4 6 )

10 Epilogue ^

After 36 hours of lectures, more than 750,000 (!) characters and 80,000 words in these lecture notes, and three comprehensive course projects, our course has finally come to an end. I hope that the knowledge and skills you obtained in this course will be useful to you for the rest of your scientific careers, and that these lecture notes will continue to serve you as a reference in your future scientific programming projects.

© 2024 Barak Shoshany