Introduction
Overview
Teaching: 10 min
Exercises: 0 minQuestions
What is C++?
When is C++ the right language?
How do I get started in C++?
Objectives
Understand the problem domain where C++ is the best solution.
Be able to compile and run a simple C++ hello, world program.
Welcome
Welcome to the HSF training module on C++ fundamentals. This module will walk you through the core concepts of modern C++ and give you a good grounding in the key ideas and syntax that you will need to follow more advanced modules in the overall HSF C++ course.
Why the C++ Language
The C++ language has been around for a long time (since the 1980s). Yet, despite it’s age C++ is still a very popular language. That’s because it has an excellent combination of lower-level features that help to get extremely high performance from modern computing hardware and higher level features that help to abstract the logical structure of the code and manage the overall design coherently. In particular, C++ is an extremely popular language for data intensive sciences (physics, astronomy, chemistry and engineering). C++ is also used (with some extensions) for programming devices like GPUs and is the language used for many of the high-performance modules in other languages like Python.
All of this makes C++ an excellent language to learn for people who need to of get maximum performance for their code. And that’s why we wrote this course!
C++ in this course
Over it’s lifetime C++ has evolved a lot from the original versions that provided some extensions on top of the C programming language and it has been though numerous revisions and extensions (you might see these referred to as C++98, C++11, C++14, etc.). In this course we’ll consider all of these old revisions uninteresting for the modern student and we’ll dive right in to teaching you modern C++ programming and best practice.
However, to be precise, here we shall base the course on the C++17 standard. Occasionally we may point out places where this standard differs significantly from earlier versions, but this is really only to help you where you might have to look at older code.
Getting started with C++
Compiler
The essential ingredient for working with C++ is a program called a compiler. Written C++ source code describes what we want our program to do, but it isn’t yet in a form that the CPU can do anything with. The compiler makes the translation from one form to another for us and it outputs binary code that our computer can then execute.
A compiler for this course
We assume that you have a modern C++ compiler available on your system - either the GNU g++ compiler or the LLVM clang++ compilers will do the job very nicely as long as you have g++ version 8 or higher, or clang++ version 5 or higher.
Editor
Our first job when writing or adapting C++ code will be to work with the source files. That requires us to use a text editor. There are a bewildering array of editors available depending on which system you are using. There’s also a strong subjective element here as well as people can have personal preferences for their editor.
As a programmer a good editor will help hugely when it understands C++ as a language - many helpful features and syntax checks can be performed as you write your code and they will make you much more productive. The most advanced editors are actually part of an IDE suite (Integrated Development Environment) that will also include full integration with the compiler and the debugger. However, setting up these is beyond the scope of this tutorial.
An editor for this course
We can recommend highly the Microsoft VS Code editor for being easy to use, very functional and being available on most platforms (OS X, Windows, Linux).
My first C++ program
Alright, let’s get started then… using your editor let’s write the canonical starting program in C++, the venerable hello, world.
#include <iostream>
int main() {
std::cout << "hello, world" << std::endl;
return 0;
}
Don’t worry about the pieces of the code here that you don’t understand yet - by the end of the module you’ll know them all.
Save this file as hello.cpp
.
Now, open a terminal on your system and let’s compile this program and run it:
$ g++ -std=c++17 hello.cpp -o hello
$ ./hello
hello, world
$
If you managed to get that to work then you have successfully started your journey as a C++ programmer. Well done!
Now let’s go on to look more systematically at some of the things that make up the C++ programing language.
C++ Resources
Here are some useful resources for C++ programmers:
Resource | Link | Description |
---|---|---|
Cpp Reference | https://en.cppreference.com/w/ | Essential reference to the C++ standard and standard library |
C++ Standards | https://isocpp.org/ | The C++ Standards Organisaion, but in itself a great source of resources about C++ |
Key Points
C++ is a compiled language, source code is translated by the compiler to machine specific binaries.
Well written C++ can achieve very high performance.
Using the compiler on the command line I can simple and run a simple C++ program.
Core Syntax and Types
Overview
Teaching: 10 min
Exercises: 10 minQuestions
What are the basic syntactical elements of C++
What are C++ types and which basic types exist
Objectives
Learn to ‘read’ basic C++ code
Understand basic types and the distinction to other types
Comments
Let’s start with the most important in C++, i.e. comments.
// simple comment for integer declaration
int i;
/* multiline comment
* in case we need to say more
*/
double d;
/**
* Best choice : doxygen compatible comments
* \fn bool isOdd(int i)
* \brief checks whether i is odd
* \param i input
* \return true if i is odd, otherwise false
*/
bool isOdd(int i);
There are various ways of commenting the code.
- If the comment is only on a single line it needs to start with double forward slash
//
. - If the comment spans multiple lines it needs to start with slash-star
/*
and end with star-slash*/
(The star in the second line of the multi-line comment is just for beautification and can be omitted). - The last example denotes a specific syntax which is used by external tools doxygen tool for creating code documentation and is used widely by C++ developers. Those comments look similar to multi-line comments with the difference that they start with a slash-double-star
/**
.
Comments are removed from the source code by the compiler during the translation process into machine code.
Basic Syntax
Let’s start with the standard programming “hello world” example in C++
#include <iostream>
// This is a function
void print(int i) {
std::cout << "Hello World " << i << std::endl;
}
int main(int argc, char** argv) {
int n = 3;
for (int i = 0; i < n; i++) {
print(i);
}
return 0;
}
The code block above contains already several key ingredients of C++ syntax.
Right at the top you can see the #include
statement which will include more code which is necessary to compile the example below. Here the included file is inbetween angular braces <>
which denotes standard headers, in our case the iostream
file. Another possibility is to include files between double quotes (e.g. "MyHeader.h"
).
The next text line contains a single line comment we discussed above.
The rest of the code contains two functions print
and main
. Each of the functions has a return value (e.g. main returns an int
type) and arguments (e.g. argc
and argv
of type int
and char**
).
The main function in addition is a special function, in the sense that this is the entry point for the executable to run and every C++ executable can only contain one main
function.
The code that is executed within a function is contained between an opening and closing curly brace, e.g. the std::cout << ...
statement in the print function.
Compling the above code in a file helloworld.cpp
and running the executable, yields
~ % vi helloworld.cpp
~ % g++ helloworld.cpp -o hello
~ % ./hello
Hello World 0
Hello World 1
Hello World 2
~ %
Basic Types
bool b = true; // boolean, true or false
char c = 'a'; // 8 bits ASCII character
char* s = "a C string"; // array of chars ended by \0
std::string t = "a C++ string"; // class provided by the STL
char c = -3; // 8 bits signed integer
signed char c = -3; // 8 bits signed integer
unsigned char c = 4; // 8 bits unsigned integer
short int s = -444; // 16 bits signed integer
unsigned short s = 444; // 16 bits unsigned integer
short s = -444; // int is optional
int i = -123456; // 32 bits signed integer
unsigned int i = 1234567; // 32 bits signed integer
long l=0L; // 32 or 64 bits (ptr size)
unsigned long l = 0UL; // 32 or 64 bits (ptr size)
long long ll = 0LL; // 64 bits unsigned integer
unsigned long long l = 0ULL; // 64 bits unsigned integer
float f = 1.23f; // 32 (23+7+1) bits float
double d = 1.23E34; // 64 (52+11+1) bits float
The code snippet above denotes the most basic C++ types, also called fundamental types.
The variable in the first line is of type boolean (bool
). The value for this type can only be true
or false
.
The next block denotes character variables. The char
type can hold any ASCII character. The first line in this block of type char contains a single character. Note the single parenthesis to contain the value. The second line denotes a pointer to char (we will learn about pointers (*
) in a later section) and can contain multiple characters. While char*
is also used in C the
std::string` type is a more powerful C++ object and can also contain multiple characters. Note that both values are surrounded by double parenthesis.
char
is also an 8 bit signed integer (see third block). The string signed
can be omitted in the declaration so the first and the second line are syntactically the same. The third line in this block though denotes the unsigned version of the char type (so has 8 bits for the value).
The signed
string can be omitted for any of the basic types.
The next block denotes integer types which are 16 bits long (short int
) and again exist in signed (15 bits for the value, 1 bit for the sign) and unsigned (16 bit value) versions.
The 32 bit version of the integer value is called int
and again exists in signed and unsigned versions.
The next longer integer type is called long int
and depending on the architecture holds 32 or 64 bit values. While the long long int
type guarantees to hold a 64 bit integer type.
For short
, long
and long long
integer types the string int
can be omitted.
The last two types are floating point types. float
fits into 32 bits with 23 bits of mantissa, 7 bits for the exponent and 1 bit for the sign. double
takes 64 bits with 52 bits mantissa, 11 bits exponent and 1 bit for the sign.
Guaranteed Length Arithmetic Types
#include <cstdint>
int8_t c = -3; // 8 bits, replaces char
uint8_t c = 4; // 8 bits, replaces unsigned char
int16_t s = -444; // 16 bits, replaces short
uint16_t s = 444; // 16 bits, replaces unsigned short
int32_t s = -0674; // 32 bits, replaces int
uint32_t s = 0674; // 32 bits, replaces unsigned int
int64_t s = -0x1bc; // 64 bits, replaces long long
uint64_t s = 0x1bc; // 64 bits, replaces unsigned long long
In case you want to be sure about the size of your arithmetic types, you can include the <cstdint>
header and use the type names of the code snippet above.
Key Points
Introdution into basic syntactical concepts of C++
Definition and explanation of the most basic C++ types
Arrays and Vectors
Overview
Teaching: 20 min
Exercises: 20 minQuestions
How can I create collections of things in C++
How can I get and set values in a collection?
If I don’t know the size of the collection in advance, what do I do?
Objectives
Understand static arrays of objects in C++ for creating collections.
Know how to use the standard library vector to create variable sized collections.
Be able to access members of the collection and update, insert and delete values.
N.B.
I feel after having written this episode that it would be very useful to have covered
for
loops at this point, even just to be able to write an example where one
can loop over the container.
It’s also appropriate to introduce container iteration loops alongside containers.
Also, any iterator type really, really should be handled with auto
…
Introduction
We saw previously how to create variables that represented the basic types of C++. However, usually in scientific and data intensive problems we need to deal a huge amount of data and these are usually grouped into collections of numbers rather than being created and accessed one by one (which would be incredibly tedious!).
Fixed Size Containers: Arrays
In C++ we have the possibility to create arrays of the basic types (well, we’ll see later this can be extended to virtually any object in C++). Here’s a simple example:
#include <iostream>
#include <array>
int main() {
std::array<double, 4> four_vector {2., 3., 4., -10.};
std::cout << "This 4-vector has time component " << four_vector[3] << std::endl;
four_vector[3] = 7.5;
std::cout << "This 4-vector now has time component " << four_vector[3] << std::endl;
return 0;
}
What do we see here?
First, arrays in modern C++ are part of the standard library
and first we need to tell the compiler we want to use that
functionality by including the header file array
.
Second, we define an array in C++ using a syntax like this:
array<TYPE, NUMBER>
This is actually an example of what’s called a template in C++, which we’ll study in more detail later, but in this simple example it’s pretty clear how to use it passing these two arguments in.
We’re also showing you a way to initialise multiple value objects like arrays
using a standard C++ syntax called uniform initialization: {a, b, c, ... }
.
Third, data in the array is accessed using the [n]
notation where
n
counts from 0. So an array of size N
has elements 0
, 1
,
2
, ...
, N-1
.
When we compile and run we get:
$ g++ -std=c++17 array-basic.cpp -o array-basic
$ ./array-basic
This 4-vector has time component -10
This 4-vector now has time component 7.5
$
Note that arrays are always a fixed size in C++.
Array indexes
As we saw, array indexes run from
0
toN-1
for an array of sizeN
. If you accidentally try to access a value outside of this range then the results are undefined behavior - you’re program might crash, or worse it might silently just produce wrong answers. So this is something to be very careful of.Arrays also support an access method called
at
which will check that a valid data element is being accessed:
four_vector.at(3)
will access the third element, but will fail (throwing an exception - more on them latter) if the third element wasn’t valid.
If you needed to know the number of elements in an array, this code will return that value to you:
four_vector.size()
EXERCISE FOR ARRAYS HERE
Blah blah blah
Variable Size Containers: Vectors
Arrays are great to use when the data size is known up-front. However, in many cases we might not know how large a container we need at the beginning. In this case C++ comes to our aid with a variable sized container type called a vector.
One can use a vector in much the same way as an array:
#include <iostream>
#include <vector>
int main() {
std::vector<double> n_vector {2., 3., 4., -10.};
std::cout << "This n-vector has time component " << n_vector[3] << std::endl;
n_vector[3] = 7.5;
std::cout << "This n-vector now has time component " << n_vector[3] << std::endl;
n_vector[0]++;
std::cout << "The first element of the vector is " << n_vector.front() <<
" and the last is " << n_vector.back() << std::endl;
std::cout << "The vector size is " << n_vector.size() << std::endl;
return 0;
}
This looks very like our array code above and one of the nice things about these C++ containers is that they are used in very similar ways!
- Instead of including the
array
header, this time we usevector
. - When we define our vector we don’t need to give the size, as this is mutable.
- In this case, we initialised the vector with 4 elements the size of the vector is 4.
- Accessing the elements of a vector uses the same
[]
notation as arrays.- Vectors also have the methods
front()
andback()
that access the start and the end of the container.
- Vectors also have the methods
- The size of the vector is returned by
size()
, just like it was for an array.
Compiling and running the code produces the expected results:
$ g++ --std=c++17 -Wall vector-basic.cpp -o vector-basic
$ ./vector-basic
This n-vector has time component -10
This n-vector now has time component 7.5
The first element of the vector is 3 and the last is 7.5
The vector size is 4
$
Adding and Deleting Elements from Vectors
Adding
To add a new element to a vector there is a the push_back()
method:
#include <iostream>
#include <vector>
int main() {
std::vector<double> n_vector {};
n_vector.push_back(1.5);
n_vector.push_back(2.7);
std::cout << "Vector elements are " << n_vector[0] << " and " << n_vector[1] <<
" and the size is " << n_vector.size() << std::endl;
n_vector.push_back(3.9);
std::cout << "Vector now has " << n_vector.size() << " elements and the last value is " << n_vector[n_vector.size()-1] << std::endl;
return 0;
}
The example shows that the push_back()
extends the container by one element, adding the argument
to as the last value.
$ g++ --std=c++17 -Wall vector-push-back.cpp -o vector-push-back
$ ./vector-push-back
Vector elements are 1.5 and 2.7 and the size is 2
Vector now has 3 elements; and the last value is 3.9
If you need to add an element to an arbitrary position in a vector then you can use the
insert()
method:
#include <iostream>
#include <vector>
int main() {
std::vector<double> n_vector {-1.0, -2.0, -3.0};
n_vector.insert(n_vector.begin(), 100.0);
std::cout << "The first vector element is " << n_vector[0] <<
" and the size is " << n_vector.size() << std::endl;
n_vector.insert(n_vector.begin()+1, 200.0);
std::cout << "The second vector element is " << n_vector[1] <<
" and the size is " << n_vector.size() << std::endl;
return 0;
}
Here we used the method begin()
to get the start of the vector and you can see
with the second insert
that adding values from this starting point
work as you would expect (begin()+3
, for example, is the 4th element position).
$ g++ --std=c++17 -Wall vector-insert.cpp -o vector-insert
$ ./vector-insert
The first vector element is 100 and the size is 4
The second vector element is 200 and the size is 5
Iterator arithmetic
Formally in C++ the vector
v.begin()
method returns what is called an iterator that can be used to identify a position inside a container. Adding and subtracting then moves the access point up and down the vector, respectively, e.g.,
auto second_element = v.begin()+1
Just be careful that you don’t try to insert into an invalid place in the vector as bad things will happen, i.e., inserting into a location before the beginning of the vector or inserting into a place more than one step beyond the end. Inserting right at the end is valid and makes
insert()
behave likepush_back()
:
v.insert(v.end(), VALUE)
behaves the same as
v.push_back(VALUE)
Note that
v.end()
is used a lot in C++ and returns the position in the container one step beyond the last valid value. (Thus in loops the comparison is always less than:still_valid < v.end()
.)
EXERCISE FOR VECTORS HERE
Blah blah blah
Deleting
If you want to delete the final element of the vector the pop_back()
method will
do this. Note this method returns nothing, i.e., it’s a void
function in C++.
#include <iostream>
#include <vector>
int main() {
std::vector<double> n_vector {-1.0, -2.0, -3.0};
n_vector.pop_back();
std::cout << "After pop the last vector element is " << n_vector.back() <<
" and the size is " << n_vector.size() << std::endl;
return 0;
}
$ g++ --std=c++17 -Wall vector-pop-back.cpp -o vector-pop-back
$ ./vector-pop-back
After pop the last vector element is -2 and the size is 2
And to delete an arbitrary position, use the iterator position that we
saw above with erase()
.
#include <iostream>
#include <vector>
int main() {
std::vector<double> n_vector {-1.0, -2.0, -3.0, -4.0, -5.0};
n_vector.erase(n_vector.begin()+2);
std::cout << "After single erase the third vector element is " << n_vector[2] <<
" and the size is " << n_vector.size() << std::endl;
n_vector.erase(n_vector.begin()+2, n_vector.end());
std::cout << "After a block erase the vector size is " << n_vector.size() << std::endl;
return 0;
}
$ g++ --std=c++17 -Wall vector-erase.cpp -o vector-erase
$ ./vector-erase
After single erase the third vector element is -4 and the size is 4
After a block erase the vector size is 2
Here we illustrated two ways to use erase
, first with a single element
and then with a range where all the elements between the starting
point and before the end point are removed.
If you want to delete all of the current entries in a vector then use
the clear()
method:
#include <iostream>
#include <vector>
int main() {
std::vector<double> n_vector {-1.0, -2.0, -3.0, -4.0, -5.0};
std::cout << "Vector initial size is " << n_vector.size() << std::endl;
n_vector.clear();
std::cout << "After a clear() the vector size is " << n_vector.size() << std::endl;
return 0;
}
$ g++ --std=c++17 -Wall vector-clear.cpp -o vector-clear
$ ./vector-clear
Vector initial size is 5
After a clear() the vector size is 0
EXERCISE FOR VECTORS EXTENSION HERE
Blah blah blah
How vector storage works in C++
It’s useful to know they way in which a vector works in C++ so you understand how to use it most effectively. Vectors are guaranteed in C++ to occupy contiguous memory, which means that processing a vector’s elements one after another is usually rather efficient on modern CPUs.
However, that does mean that if elements are inserted at the beginning or in the middle of a vector then all the elements after it have to be moved, which is slower. The advice is always try to fill up vectors at the end!
As a vector’s size is mutable the library will usually allocate some more space for the vector elements than are currently used. If there is spare space in the underlying allocation then
push_back()
will be very quick. However, when the underlying storage is exhausted the library has to reallocate memory in order to add a new element. Roughly this process is:
- Call to
push_back()
- Realise that the current storage if full.
- Allocate a new, larger, storage block.
- Copy each element in turn from the old block to the new one.
- Add the final, new, element.
- Free the old storage.
This is slow, and gets slower as the vector grows in size. To offset this problem, use the
reserve(N)
method of a vector that will tell the library to pre-allocate enough space forN
elements in advance. e.g.,
vector<double> v;
v.reserve(1000000);
for (unsigned int i=0; i<1000000; ++i) v.push_back(i);
will avoid a lot of unnecessary reallocations and copying compared to the same code without the
reserve()
call.
Key Points
C++ can assign fixed sized collections as arrays.
C++ can manage variable sized collections as vectors of objects.
For vectors, data elements can be added and removed, as well as updated.
Basic C++ operators
Overview
Teaching: 10 min
Exercises: 0 minQuestions
What is specific in basic C++ operators ?
Objectives
Know about basic C++ operators specificities.
Binary & Assignment Operators
int i = 1 + 4 - 2 ; // 3
i *= 3; // 9
i /= 2; // 4
i = 23 % i; // modulo => 3
Increment / Decrement
int i = 0; i++; // i = 1
int j = ++i; // i = 2, j = 2
int k = i++; // i = 3, k = 2
int l = --i; // i = 2, l = 2
int m = i--; // i = 1, m = 2
Be careful with those operators !
Bitwise and Assignment Operators
int i = 0xee & 0x55; // 0x44
i |= 0xee; // 0xee
i ^= 0x55; // 0xbb
int j = ~0xee; // 0xffffff
int k = 0x1f << 3; // 0x78
int l = 0x1f >> 2; // 0x7
Boolean Operators
bool a = true;
bool b = false;
bool c = a && b; // false
bool d = a || b; // true
bool e = !d; // false
Comparison Operators
bool a = (3 == 3); // true
bool b = (3 != 3); // false
bool c = (4 < 4); // false
bool d = (4 <= 4); // true
bool e = (4 > 4); // false
bool f = (4 >= 4); // true
Precedences
c &= 1+(++b)|(a--)*4%5^7; // ???
Do not rely on complex precedence rules ! Use parenthesis.
Key Points
Be careful with increment operators, which have a left-side and a right-side version.
Prefer using explicit parentheses over relying on complex precedence rules.
There is no predefined power operator.
Compound datatypes
Overview
Teaching: 0 min
Exercises: 0 minQuestions
How do we combine existing types into new types that are greater than the sum of their parts?
What are classes, and how do we define and use them?
Objectives
Understand product types.
Understand the basics of classes.
Introduction
In the course so far, we have seen that most computation in C++ is built up of
primitive datatypes like int
, float
, double
, and bool
. It is safe to say
that these types lie at the foundation of the definition of data, and of course
we cannot have useful computations without data. However, the power of types
does not end there. In fact, there is an entire field of computer science called
type theory which studies the power of types in computer programs. The
expressive power of types is truly extraordinary, although C++ only allows us a
small taste of it. Nevertheless, combining types into new types is still a key
part of C++, and you will need to master it in order to write code that is
performant and, perhaps more importantly, maintainable.
Motivation
Let us start by reviewing an extraordinarily simple function, the function which
takes as its input a single integer, and returns the absolute value of that
integer. Since it takes an integer as its input, and returns an integer as its
output, the signature of this function is int → int
. It can be defined as
follows in the C++ programming language:
int abs(int i) {
return i >= 0 ? i : -i;
}
This function is perfectly sensible, and aside from the fact that there is a
well-tested std::abs
method in the standard library, there is nothing wrong
with it. Let us now write a function with two integer arguments, representing a
position in a two-dimensional space, which calculates the Manhattan distance to
that point. The type of this function is (int, int) → int
:
int manhattan(int x, int y) {
return std::abs(x) + std::abs(y);
}
Once again, there is nothing really wrong with this function. You would find code written like this in many production code-bases, but it does serve to motivate the use of compound types. At the risk of seeming pedantic, we can identify two distinct problems with this code. Firstly, this method of designing methods risks exploding the number of arguments, which makes it harder to use the function (because the user must do more typing), and it also makes it harder to reason about the function (because there is more mental load on anyone reading this code to keep track of all the arguments). Secondly, there is no semantic information available in this code that indicates that the two arguments of this function are linked in any way. A coordinate in our space is only truly meaningful in conjunction with another one: by itself it is nothing more than an integer. The function shown above relies on the understanding of the user to provide two separate numbers that meaningfully combine to represent a coordinate in our space, and the compiler does nothing to stop the user from making mistakes by loading in non-related quantities:
int result = manhattan(grams_of_cheese_for_breakfast, number_of_cats_owned);
The example above is somewhat contrived, but we find more gripping examples of the principles described all over high-energy physics code. Consider, for example, a function that operates on a particle track state, which is described as an n-tuple and an n×n covariance vector. Let’s assume n=5 for now:
double myfun(double x, double y, double eta, double phi, double qop, double[] cov) {
...
}
Defining such a function is tedious, and using it even more so. If this function is commonly used, dozens of programmers will have to write out all six arguments to this function thousands of times, and the large amount of typing these programmers have to do is sure to attract more bugs. Indeed, someone might switch around the η and φ arguments, which would be very difficult to debug. The idea that having more code to write increases the number of bugs that appear in that code is sometimes referred to the surface area of the code which bugs can latch onto.
In the rest of this episode, we will see that we can group together the parameters of the functions given into meaningful units which will make the code easier to understand and easier to use. At the end of the episode, we will understand how to write equivalent methods that look like this:
int manhattan(Point p) {
return std::abs(p.x) + std::abs(p.y);
}
double myfun(TrackState ts) {
...
}
Product types
The simplest, most common, and most useful type of compound data type is the
product type. These types are conveniently the solution to the problems
addressed in the previous paragraph. Recall that we wanted to write our
manhattan
method to take a single Point
type instead of two int
values. To
achieve this, we will define a product type of two int
values, which we do
using the struct
keyword in C++:
struct Point {
int x;
int y;
};
What this snippet of code achieves, is that it informs the compiler about the
existence of a new type called Point
, which contains one integer called x
and another integer called y
. Wherever we have an instance of this new type,
we can access the x
and y
values contained within to retrieve the
corresponding integers.
We can now start creating instances of our new type, in much the same way as we
create instances of primitive data types, although there is some special syntax
involved. The following code creates a Point
object representing the
coordinates (5, 7). Note that this is not the only way to initialize and assign
values to data types, but we will return to this topic in the next paragraph.
Point p = {5, 7};
Accessing a member of our newly created point is equally simple. We use the
so-called member accessor operator, which is represented by a full stop (.
) in
C++. For example, if we have our instance p
defined above, the expression
p.x
will give us the value of x
inside that instance, and p.y
will give us
the value of y
. We can also insert new values into our instance using the same
syntax in addition to the assignment operator. For example, the following code
will update the y
value of our instance to 9
:
p.y = 9;
To tie everything together, here is a minimal program which defines a product
type, initializes an instance of it, modifies one of the values, and then prints
the values contained within to the screen. The output of this program should be
The point is: (5, 9)
(if it is not, please contact your nearest compiler
engineer).
#include <iostream>
struct Point {
int x;
int y;
};
int main(void) {
Point p = {5, 7};
p.y = 9;
std::cout << "The point is: (" << p.x << ", " << p.y << ")" << std::endl;
return 0;
}
As we have seen, defining and using product types is not too difficult! Most of the syntax is reminiscent of the way we use primitive datatypes, but with some extra member accessor operators thrown in. Now, let us discuss how we can initialize values of our compound datatype, and how we can use them to solve the problems we disucussed earlier.
Why product types are called product types
Each type represents a set of values it can contain.
int
corresponds to the set of integers between -2.1 billion and +2.1 billion,uint
corresponds to the set of integers between 0 and 4.2 billion,bool
corresponds to the set{true, false}
, anddouble
corresponds to a very strangely defined set of real numbers defined by the IEEE 754 standard. In any case, the set of values for each type is well-defined, even if it may be too large to usefully enumerate. When we create a product type of two typesa
andb
, the set of values that type represents is exactly equal to the Cartesian product of the sets of values represented bya
andb
. This is why we often write this product type asa×b
. The only primitive product type that we can enumerate in this short text bubble isbool×bool
, which is represented by the set{(true, true), (true, false), (false, true), (false, false)}
.
Initializing compound types
Creating instances of our compound types is critical to our ability to use them. Indeed, if we never produce any points, all the code that uses them will sit in our code base looking pretty while it collects dust since it can never be used. This paragraph will be somewhat more technical, and you do not necesserily need to understand it to make good use of product types. However, it will give you a better understanding of what is happening under the hood, and it will allow you to reason more effectively about what is happening when the compiler compiles certain statements and expressions.
So far, we have seen used syntax of the form Point p = {5, 7};
to initialize
our instances. This is reminiscent of how we might initialize a primitive
datatype with an expression of the form int a = 5;
. However, there are some
differences. In both cases, these statements are syntactic sugar, and the
compiler will desugar them to read the following:
// Point p = {5, 7};
Point p;
p = {5, 7};
// int a = 5;
int a;
a = 5;
Here, the initialization of our values has been split into an explicit
declaration and an assignment. But what exactly is the meaning of the
declaration statement Point p;
? In fact, what is the meaning of int a;
? It
is here that we first encounter the concept of constructors. A constructor is
a recipe for creating an instance of a type, and one of the fundamental
differences between primitive data types and compound data types is that the
former don’t have constructors while the latter do. When we write int a;
, we
instruct the compiler to reserve enough memory for us to store an integer, and
we will be able to use the name a
later to conveniently reference that memory.
When declaring an integer or any other primitive data type, the compiler does
absolutely nothing to prepare that memory for use - that happens only when we
assign a value to it later.
Declaring instances without initializing them
Try declaring an integer type without any initialization, and then print the value of that integer to the screen. Before you execute your program, what do you think will happen? And why? Now run your program and see what happens. Did that match your intuition? If not, why not?
When we declare an instance of a compound data type, however, the compiler will implicitly add code to prepare that instance for use: the constructor. The compiler has helpfully created an implicit default constructor for us, saving us the hassle of writing our own. We will see later how we can influence the compiler’s decision making process when designing default constructors, and we will also learn how to create our own constructors.
Constructors are usually called using a syntax reminiscent of function calls, and this will become important later when we create constructors that have multiple arguments. However, we do not need to provide the usual function call parentheses for the default constructor (and, in fact, we are not allowed to provide them, as the meaning of this is ambiguous). Here are a few more ways to declare, initialize, and assign compound data types:
// Default constructor:
Point a;
// Default constructor with two assignments:
Point b;
b.x = 5;
b.y = 7;
// Default constructor with one assignment:
Point c;
c.y = 7;
// Initialization with empty initializer list:
Point d {};
// Initialization with non-empty initializer list:
Point e {5, 7};
// Initialization with named initializer list:
Point f {.x=5, .y=7};
// Initialization with partial named initializer list:
Point g {.y=7};
// Assignment with initializer list:
Point h;
h = {.x=5, .y=7};
Try out different ways of declaring and initializing types
Copy the different declarations and initializations into a C++ file and print values of the points to the screen. Can you predict what each one will contain before running the program? Then run the program and verify your intuition. If you got it wrong, don’t fret: even experienced C++ programmers get tripped up by the many way to initialize values.
Default member values
So far, we have relied on the compiler to generate a default constructor for us. This is very helpful, because it saves us the work of defining one. However, it also leaves many of the design choices up to the compiler. For example, the compiler will not try to initialize any of the integer values in our structure. Luckily, we can exert some control over the default constructor without having to define the whole thing. The most common way to do this is by specifying default values for members. Consider the following C++ code:
struct Point {
int x = 5;
int y = 10;
};
This code instructs the compiler to initialize the value of x
to five, and the
value of y
to ten. The compiler will incorporate our wishes into the default
constructor, and it will implicitly insert code that sets these values anywhere
that we construct a Point
instance. It is also possible to specify default
values for some members while omitting them for others. For example, the
following complete program will always give a value of ten for the y
coordinate, while the x
will be unitialized, and its value will therefore be
undefined.
#include <iostream>
struct Point {
int x;
int y = 10;
};
int main(void) {
Point p;
std::cout << "The point is: (" << p.x << ", " << p.y << ")" << std::endl;
return 0;
}
Passing around compound types
Now that we know how to define, declare, initialize, and assign our custom compound data types, we can finally return to our lamentations about passing structured data to objects. You will be happy to learn that there is nothing special about compound types when it comes to defining functions that take them as arguments, or when passing them into those functions. Indeed, with the knowledge we have already, extending our functions to use compound data types is trivial:
#include <iostream>
struct Point {
int x;
int y;
};
int manhattan(Point p) {
return std::abs(p.x) + std::abs(p.y);
}
int main(void) {
Point p = {-11, 17};
std::cout << "The Manhattan distance is: " << manhattan(p) << std::endl;
return 0;
}
Take a moment to appreciate how compound datatypes allow us to write elegant
code. Firstly, all the arguments to the manhattan
function are now grouped
together into a single sensible unit. Secondly, it is immediately clear to the
user what the meaning is of these values (indeed, the type is named Point
, and
not HeightAndWeight
) - we have encoded additional semantic information into
the function signature using our types. Thirdly, the number of arguments to the
function is reduced, giving both the user and the function author less work to
do.
Be careful with large types
When you pass arguments to functions, the compiler will copy the data from the call site to the callee. This is not usually a problem with primitive data types as they are generally small. In most implementations, the largest primitive data type will be eight bytes. However, compound datatypes are not limited in their size, and one could feasibly define a compound data type that occupy tens of thousands of bytes. In such cases, passing the instance by value, as it the default, can incur large performance penalties. In such cases it is wise to use references, which we will cover later.
Sum types
Although they are much less common than product types, sum types are another
important family of types. A sum type represents a choice between two types
instead of the combination of two types represented by a product. For example,
the sum type of a boolean and an unsigned integer (uint+bool
) represents
exactly one value in the set {true, false, 0, 1, .., 4294967295}
. This type
can be written in C++ using the following syntax, which we call C-style unions:
union IntBool {
bool b;
uint i;
};
This type is simulatenously a boolean and an unsigned integer, and without external bookkeeping it is impossible to know which state it is currently in. There is a comparison to be made to quantum mechanics, although collapsing a wave form does not cause undefined behaviour (the root of all evil). To understand what happens inside a union, consider the following complete C++ program:
#include <iostream>
#include <bitset>
union IntBool {
bool b;
uint i;
};
int main(void) {
IntBool v;
v.i = 1234567;
std::cout << "i1 = " << std::bitset<32>(v.i) << " (" << v.i << ")" << std::endl;
v.b = true;
std::cout << "i2 = " << std::bitset<32>(v.i) << " (" << v.i << ")" << std::endl;
return 0;
}
This program will set the integer member of the union to the value 1234567
and
then print it, along with the binary representation of that number. If you
execute this program, you will see that the first print correctly indicates the
value 1234567
, and the binary representation
00000000000100101101011010000111
. Next, the program sets the boolean member of
the union to true, and then it prints the integer member again. However, because
the integer member and the boolean member occupy the same memory, setting the
boolean value has modified the integer member, and we see that the new value
is 1234433
, with binary representation 00000000000100101101011000000001
.
00000000 00010010 11010110 10000111
00000000 00010010 11010110 00000001
Notice how the only bits that have changed are the bottom eight. On the machine
that we tested this on, the size of a byte is one byte (eight bits), and as such
the operation v.b = true;
has assigned the bits 00000001
to that byte, also
changing the integer member in the process.
C-style unions are not type safe
C-style unions are not type safe, and the use of such unions is very dangerous. In modern C++, it is virtually always a bad idea to use unions like this, and one should always use the type-safe sum types defined in the standard library. This snippet of code is included purely for educational purposes to help you understand the concept of sum types.
Reading this, you may think sum types are a fundamentally bad idea, but they are not. To truly appreciate them, we must split the semantics of unions from their implementation. The semantics of unions are, as we will see, extremely useful, but the C-style implementation is thoroughly lacking. We will see examples of type-safe sum types later.
If we discard any notion of implementation and look at sum types from a sufficiently abstract point of view, it is immediately clear how useful they are. For example, consider a classic division function:
int divide(int n, int d) {
return n / d;
}
This function is not safe. If the value of d
is zero, and then we are diving
by zero. This will crash our program. But how do we make our function safe? We
can return some default value in the case that d
is zero, as follows:
int divide(int n, int d) {
if (d == 0) {
return -1;
}
return n / d;
}
But this is also a bad idea, because there is now no way to differentiate
between the function returning -1
to indicate a division by zero versus a
division which legitimately returns -1
. C++ provides an exception mechanism
which can be used to handle these errors, but it is ham-fisted and very
uncomfortable to use. The ideal solution is to use a sum type:
int+Error divide(int n, int d) {
if (d == 0) {
return Error{DivByZero};
}
return n / d;
}
Although this is not quite valid C++ syntax, this serves to demonstrate the
value of sum types. The error values exist completely outside the realm of
legitmate return values, but they are easily distinguished. At the same time,
both the integer return values as well as the error state exist within the same
type, so the program type checks without error. One could also imagine a program
which divides two numbers and returns an integer value if the denominator
devides the numerator, and a double otherwise. Or one could represent optional
value of type a
using the sum type a+void
.
Let us return now to the real world, and consider implementation. If we don’t care about space or performance, we can represent any sum type as a product type, just by storing one additional value to represent which of the summed types is currently represented by the instance. However, the size of a struct is equal to the sum of the sizes of all its members, while the size of a union is equal to the size of the biggest of its members. A struct containing members of sizes 50, 100, 100, 20, 4, and 8 bytes will total 282 bytes, while a union of the same types will be only 100 bytes in size. Memory is the only valid reason to overlap values in a union like C does, but unfortunately memory is often of critical importance, and thus we overlap. This is where the horrible behaviour we saw above come from. Remember, though, that this is an implementation detail of the union keyword in C++, and it is possible to get the full semantic power of sum types without having to result to such terrible implementation tricks.
Product types in the standard library
In C++, product types are well represented by structs and unions. The standard library also provides some useful implementations, and we will cover two of them in this section. Note that all these types can accept non-primitive types without question, so you should feel free to experiment with them using more complex types.
std::pair
Firstly, the std::pair
class represents a 2-tuple (or a couple), which is a
product type between two types. In C++, std::pair<A, B>
is the product type
A×B
. Using this type saves you the hassle of defining your own custom data
type. Here is a minimal program showcasing the pair
type:
#include <utility>
#include <iostream>
int main(void) {
std::pair<int, double> v {5, 11.73};
v.first = 8;
std::cout << "Integer part: " << v.first << ", double part: " << v.second << std::endl;
return 0;
}
std::tuple
Secondly, std::tuple
generalizes the concept of std::pair
to an arbitrary
number of types. Whereas a pair has exactly two types, a tuple can have however
many you want. Here is a program that does the same thing as above, but with
an integer, a double, a boolean, and a float.
#include <tuple>
#include <iostream>
int main(void) {
std::tuple<int, double, bool, float> v {5, 11.73, true, -5.81};
std::get<0>(v) = 8;
std::get<2>(v) = false;
std::cout << "Integer part: " << std::get<0>(v) << std::endl;
std::cout << "Double part: " << std::get<1>(v) << std::endl;
std::cout << "Boolean part: " << std::get<2>(v) << std::endl;
std::cout << "Float part: " << std::get<3>(v) << std::endl;
return 0;
}
Key Points
Will follow
Functions
Overview
Teaching: 10 min
Exercises: 0 minQuestions
How to define a function ?
What are the different ways to pass input arguments ?
What are the different ways to get back the results ?
Objectives
Know about ordinary C++ functions.
Know about references and const references.
Be aware of return value optimization and structured bindings.
Different examples of input arguments and output results
With return type
int square(int a) {
return a*a;
}
Multiple parameters
int mult(int a, int b) {
return a*b;
}
No parameter
void hello() {
std::cout << "Hello World" << std::endl;
}
No return
void log(char* msg) {
std::cout << msg << std::endl;
}
Different ways to exchange arguments
By value
Each time the function is called, the value given as argument is duplicated within the function. If the function modify the argument, only the internal copy is modified, not the original (which is often what we want). The duplication may take time when the input argument is big.
By reference
If you want the function to modify the original value, you must declare the argument as a reference (postfix with &
).
By constant reference
If you do want the function to modify the original value, but you would like to avoid the cost of the copy, you can declare the argument as a constant reference (prefix with const
and postfix with &
).
This pratice is not worth for small builtin types such as int
, double
, or the standard libray iterators, which are usually passed by value.
Different ways to return results
By value… and only by value !
We have seen that one can pass a variable as reference to a function, and the function modify it : it was the old way to proceed when you have several results, or big ones you want to avoid to duplicate.
Nowadays, whenever you can, simply return the result by value, as would do a mathematical function.
Do not be afraid of returning a big value, object, array, etc. Most of the time, if not every time, the compiler will avoid the copy and directly write the result in the client memory area. This is called RVO (Return Value Optimization).
NEVER return a reference, unless you are a C++ great master !
Returning a composite result
Even if you have multiple results, it is more and more easy to return them all together, using a std::tuple
.
The example above will be even simpler when we will introduce auto
later on.
Key Points
Const references avoid the cost of the copy for input arguments.
You should not be afraid any more of returning big results.
You should not be afraid any more of returning a bunch of results.
References
Overview
Teaching: 10 min
Exercises: 0 minQuestions
How do we use references in C++?
Objectives
Understand C++ references.
Know about references and const references.
Be aware of return value optimization and structured bindings.
Introduction
When we order items from online retailers, we are required to give them our residential address so they ship the items to us. We send them a trivially small amount of information so they can identify our homes and then operate on them (in this case by shipping stuff to them). In C++, we pass around information too: between functions for example. Take the following code as an example:
int double(int i) {
return i * 2;
};
When the function double
is called, we are instructing the computer to copy
(barring compiler optimisations) an integer to a new location, operate on it,
and then copy the result back to where we came to. This data movement is faily
fundamental to the concept of function calls in C++ as well as other languages.
We usually refer to this as pass-by-value, as a value is passed to the
function in its entirety. Let’s consider a new example:
struct Behemoth {
int vals[1000000];
};
int double_first(Behemoth i) {
return i.vals[0] * 2;
}
When we call our new double_first
function, we are once again passing some
data by value, and the computer will copy the data to the function that needs
it. In this case, however, our data is very large! On most (but certainly not
all) modern machines, an integer is four bytes, so in our first example we only
had to copy four bytes into the double
function and then four bytes back as
the return value. In this second example, our structure is four million bytes
in size! At that point, we’re spending so much time and effort copying
information that the performance of our program will decrease dramatically. It
is the C++ equivalent of ordering something online and then moving your entire
house to the retailer’s warehouse! It’s ruthlessly inefficient. Thankfully, C++
gives us a way to refer to large objects efficiently. In this chapter, we will
learn more about so-called references.
Basic references
Virtually all data we work with in C++ lives somewhere in our memory, which is a large adressable space which we can index numerically. The obvious real-world analogue are street adresses, where we might have a house at address 12, and then the next house at address 13 (or 14). The idea of a reference is to store data not directly, but rather as a small numeric value which points us to where in memory we can find the value we need. Creating references in C++ is easy, and not restricted to function calls:
#include <iostream>
int main(void) {
// Create a new integer with value 5.
int a = 5;
// Create a reference b that points to a.
int & b = a;
// This prints 5!
std::cout << b << std::endl;
return 0;
};
The ampersand (&
) is the common syntax for denoting references. Appending this
symbol to the end of a type essentially transforms it into a new type. In this
case, we go from the type integer to the type reference to an integer. We
can create references to virtually all types, and since references refer to a
location in memory rather than a value in itself, updating the value a reference
refers to will alter the value we can read from that reference, too:
#include <iostream>
int main(void) {
int a = 5;
int & b = a;
// Note we are not directly changing b!
a = 7;
// This prints 7!
std::cout << b << std::endl;
return 0;
};
The reverse is also true; if we change a reference, then the value it points to is also changed:
#include <iostream>
int main(void) {
int a = 5;
int & b = a;
// Note we are not directly changing a!
b = 9;
// This prints 9!
std::cout << b << std::endl;
return 0;
};
This spooky action at a distance of data may be a surprising idea at first, and depending on the school of programming you subscribe to it may be rather disturbing, but we will come to talk about uses of this property later, as well as how we can avoid it using const references. First, we will talk about the two fundamental properties of references; their transparency, their non-nullability, and their non-reseatability.
Transparency
A wonderfully comfortable property of references is that they are transparent,
which is to say that they behave just like non-reference values. You can
subscript them like normal values, you can call member functions on them like
with normal values, and you can access their data members as normal with the .
operator. This is in contrast to pointers where you need to use the dereference
(*
) or dereferencing accessor (->
) operators. In most cases, code that works
syntactically with normal values can be adapted to use references without too
much hassle.
Non-nullability
In C++, references must always point to something. This fact is either profoundly confusing if you are familiar with C pointers, or completely sensible if you’re not. In C, as well as C++, there is the concept of a null pointer, which points to some special location at the very start of the memory space to indicate that the pointer doesn’t point at anything. This is not possible with references, and a reference always points to something. The following is invalid C++:
void f(void) {
int & a;
}
The compiler will complain at you that the reference is not initialized, which is illegal. It is a malformed program.
Non-reseatability
It is also impossible to change where a reference points. Once a reference is created, it points to a specific location in memory, and you can never make it point anywhere else. This is another comforting property, but it is important to note that it does not mean that the value we point at cannot change. Recall from our introduction that we were, in fact, making changes to a reference.
void f(void) {
// This integer lives at 0x7ffcdbbde5dc
int a = 5;
// This integer lives at 0x7ffcdbbde5d8
int b = 7;
// This integer reference lives at 0x7ffcdbbde5d4, points to 0x7ffcdbbde5dc
int & c = a;
// This integer reference lives at 0x7ffcdbbde5d0, points to 0x7ffcdbbde5dc
int & d = a;
// After this, c still lives at 0x7ffcdbbde5d4
// And c still points to 0x7ffcdbbde5dc
// But the value at 0x7ffcdbbde5dc has been updated to 8.
c = 8;
};
Using references as function arguments
A common use of references is as function arguments. Recall that we were trying to pass a very large data structure to a function earlier, and the cost of copying that data was prohibitive. References allow us to pass-by-reference instead of pass-by-value, which allows us to use even the largest data structures as function arguments without worrying about performance. On most modern systems, a reference is eight bytes in size, even to very large data structures. Here is an updated example where we pass a very large data structure to a function:
#include <iostream>
struct Behemoth {
int vals[1000000];
};
int double_first(Behemoth & i) {
return i.vals[0] * 2;
}
int main(void) {
Behemoth b;
int r = double_first(b);
std::cout << r << std::endl;
return 0;
}
When this code is executed, an eight-byte reference will be passed to the
double_first
function, and the function will retrieve the data is needs itself
without any unnecessary copying. If you would like to examine the performance
difference between the two methods, try the following fully formed program:
#include <iostream>
#include <chrono>
struct Behemoth {
int vals[1000000];
};
int double_pbv(Behemoth i) {
return i.vals[0] * 2;
}
int double_pbr(Behemoth & i) {
return i.vals[0] * 2;
}
int main(void) {
Behemoth b;
b.vals[0] = 10;
auto t1 = std::chrono::high_resolution_clock::now();
int r1 = double_pbv(b);
auto t2 = std::chrono::high_resolution_clock::now();
int r2 = double_pbr(b);
auto t3 = std::chrono::high_resolution_clock::now();
auto nano_pbv = std::chrono::duration_cast<std::chrono::nanoseconds>(t2 - t1).count();
auto nano_pbr = std::chrono::duration_cast<std::chrono::nanoseconds>(t3 - t2).count();
std::cout << "Pass by val found " << r1 << " and took " << nano_pbv << "ns." << std::endl;
std::cout << "Pass by ref found " << r1 << " and took " << nano_pbr << "ns." << std::endl;
return 0;
}
You do not need to spend much time trying to understand what this code is doing, but the difference in time should be significant! On my testing machine, the pass-by-value implementation took roughly eight million nanoseconds (or eight milliseconds) while the pass-by-reference implementation took roughly one hundred nanoseconds!
Const references
The action at a distance effect described at the beginning of this episode is not always what we want. Sometimes we want to ensure that we a value cannot be changed through a reference. In such cases, we can use const references which block any attempt to modify the value they point at. Making const references is trivial:
int main(void) {
int a = 5;
const int & b = a;
return 0;
}
In this case, we can use the reference b
only to read, not write. The
following code is malformed and will not compile:
int main(void) {
int a = 5;
const int & b = a;
b = 7;
return 0;
}
Note that we can create const references to non-const values! Essentially, we are creating a read-only view of otherwise mutable data. This can have some potentially unexpected effects:
#include <iostream>
int main(void) {
int a = 5;
const int & b = a;
std::cout << b << std::endl;
a = 7;
std::cout << b << std::endl;
return 0;
}
In this case, the value pointed at by our const reference changes between two read accesses. This is usually not a problem in well-designed problems, but you should be aware that it can happen, and having a constant reference to some data does not mean that data will never change.
Using references as output variables
One rather unfortunate use of references which stems from older and mostly deprecated C++ programming practices is the use of references as so-called output variables. It used be to be common practice to pass references to functions with the intention of having the function write to them without reading from them. These arguments were commonly called output parameters. Sometimes arguments are used both as input and output, in which case they are referred to as input-output arguments. The following function is an example of output parameters in use:
void sincos(double in, double & sin, double & cos) {
sin = std::sin(in);
cos = std::cos(in);
}
Because this method takes two of its arguments as references, it can influence the outside world without actually returning anything. Here is an example of us using this method:
int main(void) {
double sin, cos;
sincos(0.3 * 3.1415, sin, cos);
std::cout << sin << ", " << cos << std::endl;
return 0;
}
Here, the main
method is relying on the fact that sincos
modifies the values
of sin
and cos
. Otherwise, it would be printing uninitialized values.
In general, usage of output variables should be avoided as much as possible in modern C++. The compiler does a much better job optimizing code which returns using the standard return interface, it makes the code more referentially transparent, and it makes the code clearer to programmers.
Memory safety
The fact that a reference is always bound to something (by way of their non-nullability) is a great boon for memory safety, but it is not impossible for a reference to point to invalid memory. The power of references is that, within the scope of well-written code using only references, this doesn’t really happen. The main risk is receiving invalid references from outside or, in multi-threaded programs, having another thread free some memory your references are pointing too. In these cases, you can safely point fingers at someone else and be reasonably assured that your code is not at fault. Still, be aware that code using references can still suffer from, for example segmentation faults!
Knowing more
References are an extremely important part of C++, and they form the foundation for a lot of what we do in the language. However, references are also an incredibly complex topic. We have just scratched the surface in this episode, and it is designed to give you basic working knowledge to read and write code that uses references. There is much more to learn about references, including lvalue references, rvalue references, reference reduction rules, special rules for const references, and much more. If you continue programming in C++, you will gradually learn more and more about these topics.
Key Points
Coming up.
Control Instructions
Overview
Teaching: 20 min
Exercises: 10 minQuestions
How do I execute certain lines of code but not others?
How do I reuse code and execute it many times?
Objectives
Understand the syntax of conditional statements (if-else, switch, ? operator) and loops (for, while, do-while).
Understand how conditionals and loops may stop early or skip over lines (break, continue, return, default).
What are Control Instructions
Control instructions are a core component of an imperative programming language. An imperative programming language is one in which algorithms are described as a sequence of commands to be executed by the computer. Sometimes, the program specifies that certain commands are only executed conditionally, dependent on the result of other commands. C++, C, and python are all examples of imperative languages, and they all feature some form of the same types of control instructions.
Are there languages without control instructions? Yes! For example, Haskell is a pure functional language in which control more or less tackled by defining piece-wise functions that will execute different mathematical statements conditional on the argument.
If Statements
An example of a block of code using the if
and else
reserved words is shown below.
if (condition1) {
Instructions1;
}
else if (condition2) {
Instructions2;
}
else {
Instructions3;
}
The else
and else if
blocks are optional, and an arbitrary number of else if
blocks may appear after the if
code block. The braces may be omitted when the instruction is one line, as our instructions are; however, the parenthesis are mandatory. When the computer executes this portion of a program, only one of the three instructions will be executed, dependent on whether conditions 1 or 2 are satisfied.
A practical example of using if-else
statements is the following block of code. Read carefully and understand which lines will execute for a given input value a.
int collatz(int a) {
if (a<=0) {
std::cout << "Not supported" << std::endl;
return 0;
} else if (a == 1) {
return 1;
} else if (a%2 == 0) {
return collatz(a/2);
} else {
return collatz(3*a+1);
}
}
Conditional Operator
The syntax for the conditional operator is shown in the code block below.
test ? expression1 : expression2;
If the statement test is true, then expression1
is evaluated and its result is returned; otherwise, expression2
is evaluated and its result is returned.
The conditional operator ?
allows some conditional statements, namely when a return value is conditional, to be condensed into more compact syntax. In the collatz
function above, the result of the if
statement was simply to return a different value. We can write this block of code in one line with the conditional operator.
int collatz(int a) {
return a==1 ? 1 : collatz(a%2 ? 3*a+1 : a/2);
}
We made use of the conditional operator twice: to check if a
is precisely 1 and if a
is even. You might think after reading this line of code that the explicit if-else
clauses are easier to understand, and you would be right. The conditional operator should not be abused, meaning they should only be used when the effect is obvious and they are not nested.
Switch Statements
switch
statements provide a similar functionality to if
and else
but they are different in a subtle way that causes bugs for many users: the cases are entry points, not independent pieces. Let’s look at an example to understand this point.
switch (identifier) {
case c1 : instructions1; break;
case c2 : instructions2; break;
case c3 : instructions3; break;
...
default : instructiond; break;
}
In this block of code, if identifier
equals c1
then instructions1
executes and then the break
statement prevents any of the other instructions from executing. The code executes similarly if identifier
equals c2
or c3
. If we omit the break
after instructions1
then instructions2
will also execute when identifier
equals c1
. This brings us back to the point mentioned before, which bears repeating: the cases are entry points, not independent pieces. The break
is not mandatory, but omitting it can lead to frustrating debugging sessions. For your own benefit, do not make use of non-breaking cases. Finally, the default
instruction executes if identifier
is not equal to any of the provided cases.
Typically switch statements use an enum
, int
or char
as the identifier
. Here is an example of how to use enum
and switch
to create a very readable conditional statement.
enum Lang { FRENCH, GERMAN, ENGLISH, OTHER};
...
switch (language) { // language is one of the elements of the Lang enum
case FRENCH:
printf("Bonjour");
break;
case GERMAN:
printf("Guten tag");
break;
case ENGLISH:
printf("Good morning");
break;
default:
printf("I do not speak your language");
break;
}
The [[fallthrough]] Attribute
For expert users, the [[fallthrough]]
attribute will suppress the compiler warnings produced by non-broken cases, for example:
switch (c) {
case 'a':
f();
[[fallthrough]]; // Warning suppressed when this line is added
case 'c':
h();
}
However, remember that we recommend using break
anyways.
Init Statements for if and switch
If an object is needed within the blocks of an if-else
statement, or the cases of a switch
statement, one option is to instantiate the object on a separate line as shown below:
Value val = GetValue();
if (condition(val)) {
// on success ...
}
else {
// on failure ...
}
However, we can condense this statement and declare the object val
in the local scope of the conditional statement by use of an init-statement. (By local in scope we mean that val
will be avaliable only within the if
and else
blocks, not in the next lines of code).
if (Value val = GetValue(); condition(val)) {
// on success ...
}
else {
// on failure ...
}
Challenge
What will happen if we try to compile and run the following program?
int a = 3; switch (a) { case 1: printf("a == 1"); case 3: printf("a == 3"); case 4: printf("a == 4"); default: printf("a is not 1, 3, or 4"); }
Solution
When we compile the code, we will see a warning for each of the non-terminated cases. When we run the code, we will see three printouts, saying that a is 3, a is 4, and a is not 1, 3, or 4.
For Loops
At this point, we switch gears in the middle of a rather long lesson. Up until now we focused on how to tell the computer to execute one line of code instead of an alternative. Loops will allow the same lines of code to be executed repeatedly. The first loop we examine will be the for
loop.
for (initializations; conditions; increments) {
instructions;
}
The initializations may include declarations (i.e. size_t i = 0
). Multiple initializations or increments are comma separated. As with previous conditional statements, the braces are optional if the instructions are only a single line of code. A practical example using multiple initializations and increments is shown below.
for (int i = 0, j = 0; i < 10; i++, j = i*i) {
std::cout << i << "^2 is " << j << "\n";
}
This will print out ten lines, starting with 0^2 is 0
and ending with 9^2 is 81
. To keep code readable, make sure your foor loop statement (including initializations, conditions, and increments) fits in 1-3 lines.
Range Based for Loops
for (type iteration_variable : container) {
// body using iteration_variable
}
Range based loops make iterating over many containers extremely easy, including arrays and std::vector
s. Many standard library objects support range based loops, and you can even write your own objects to be compatible with this syntax. An example code using this syntax is:
int v[4] = {1,2,3,4};
int sum = 0;
for (int a : v) { sum += a; }
This syntax saves us the hassel of declaring some intermediate variable to index the array position; rather, we cut straight to the contents of the array.
While Loops
Another way to repeatedly execute code is the while
loop. The syntax is as follows:
// while loop
while (condition) {
instructions;
}
// or a do-while loop
do {
instructions;
} while (condition);
The while
loop repeatedly executes the instructions
until the condition
is false. The do-while
variant will be sure to execute the instructions at least once, and then starts checking whether the condition is true or false. A practical example of the while loop, which again computes the collatz
function, is as follows.
while (n != 1)
if (0 == n%2) n /= 2;
else n = 3 * n + 1;
Note that the if-else
clause here still counts as being “one line” of code, and the braces are omitted.
Be careful with while loops! While loops can cause infinite repeated execution if the condition is not properly defined. It is also possible to have infinite repeated execution in for
loops, but a less common problem.
Challenge
What will happen when we try to compile and execute the following program?
int a = 0; do a-=10; std::cout << a << std::endl; while (a != 0);
Solution
This code will not compile. The
do
clause must have braces before thewhile
when multiple statements are present. If we did fix this issue by adding braces, it might appear at first glance like the code in the brances would not execute because our variablea
is initialized to not meet the conditiona != 0
. However, because thedo
causes the code to execute once before checking the condition, the first number printed will be -10 and then this loop will repeat infinitely, continuing to subtract 10 froma
.
Commands
Various commands halt execution of a conditional statement or function to differing degrees. The main culprits are:
- A
continue
statment prevents and further execution of that iteration, but the loop is allowed to keep executing the next iteration. - We have already seen how the
break
command interacts with aswitch
block by preventing further execution. It has a similar effect on loops as well. When in a loop, if abreak
statement is executed the loop immediately terminates, but the function is allowed to keep executing. - A
return
statment not only breaks out of the loop, but breaks out of the current function as well, and possibly returns a value (if the return type of the function is notvoid
).
An example of using these commands within a loop to compute the collatz
function is shown.
while (1) { // naively, the loop will never finish (unless a break or return is reached)
if (n == 1) break;
if (0 == n%2) {
std::cout << n << "\n";
n /= 2;
continue;
}
n = 3 * n + 1; // will not execute unless n is odd
}
Challenge
The while loop we just provided is vulnerable to infinite execution. Can you come up with an integer value of n that will cause infinite execution?
Solution
The most apparent example is if
n
is zero. In this case, on every iteration of the loop we keep dividingn
by 2, which doesn’t change its value. There is also a possible loop for negative integers, for instance -2 will be divided by 2 on the first loop to setn=-1
and then on the second loop n will be set back to -2. If you found a positive integer solution, go claim your $500.
Challenge
What will happen when we try to compile and execute the following program?
int a = 10; for (;;) { std::cout << a << std::endl; a--; if (a == 0) break; }
Solution
The program compiles and runs, printing integers from 10 descending to 1. The 3 expressions in a
for
loop are optional. Usually, we do want them there because the syntax is much clearer, for example we could write the same program in a single line as:for (int a = 10; a != 0; --a) std::cout << a << std::endl;
However, both programs will produce the same output. Note that if you omit the termination condition in a for loop you risk running into an infinitely repeating loop.
Challenge
One of your colleagues writes the following function and called it several times to test it. The function is designed to return the maximum of three floating point values.
float max(float a, float b, float c) { if (a < b) { if (b > c) return b; else return c; } else if (b > c) return b; else return a; } std::cout << max(0, 1, 2) << std::endl; std::cout << max(0, 2, 1) << std::endl; std::cout << max(1, 2, 0) << std::endl;
Your collaborator tells you that they are sure the function works because they tested it and no matter what order they put the numbers in, it always found that the max was 2.
Are they correct? Or are there other cases you should test before being so sure?
Solution
You should test a few more cases. The bare minimum should be to test the function enough so that each line executes, and in the test cases provided
a
is always less thanb
so the lineelse return a;
is never executed. When using control flow with much larger blocks of code, it becomes dangerous to push code to some shared repository if it has not been tested.Further, if
c > a > b
, then the function erroneously returnsa
, notc
. With control instructions different parts of the program are executed, so you must be careful when testing the program to cover all parts of the input phase space.
Key Points
Control instructions make your code powerful and versatile.
You may need to test your program against different inputs for some code blocks to execute.
Headers and Interfaces
Overview
Teaching: 10 min
Exercises: 0 minQuestions
What is an interface?
Why separate some of the code into header files?
Objectives
Understand the difference between header and implementation files
Comprehend code using preprocessor directives
Headers and Interfaces
An interface describes functionality without providing any implementation. An interface is defined in a header file, which has a different extension (usually .hpp
or just .h
) than the implementation file (which will usually be .cpp
or .cc
).
In a header file hello.hpp
, you may see a function declaration with no implementation such as:
void printHello();
And then used in another file myfile.cpp
:
#include "hello.hpp"
int main() {
printHello();
}
But how does the computer know what to execute when it gets to the printHello()
statement? When we compile, we must include an implementation file (or link against a library) that implements the function.
But why do we do this? Why not just keep all the code in one file? There are several answers to this question, but here are a few of them:
- Yes, for this example with one line of code a header does seem contrived. Header files become an absolute necessity when you introduce multiple functions or classes.
- When recompiling a large project, if the interface is kept the same but the implementation changes, the compilation process can be sped up significantly.
- One of the mantras of object-oriented programming is program to interfaces, not implementations. This is a deep concept we are only touching on at a surface level for now, so don’t worry if it seems a little confusing at the moment. The idea is that as you continue to add functionality to a large code project, it is easier to work with the abstraction of the function or class and not depend on the nuts and bolts of its implementation.
Preprocessor Directives
Preprocessor directives use the #
character. These lines are not statements, and thus do not need a semicolon to terminate the line. Rather, they are terminated by a newline character, similar to single line comments // like this
.
// file inclusion
#include "hello.cpp"
// macro defines a constant
#define MY_GOLDEN_NUMBER 1746
// compile time decision
#ifdef USE64BITS
typedef uint64_t myint;
#else
typedef uint32_t myint;
#endif
The #define
directive will effectively replace all instances of the token MY_GOLDEN_NUMBER
with 1746
before compiling. The #ifdef
, #else
and #endif
allow the compiler to check whether the identifier USE64BITS
is defined and conditionally declare a typedef depending on the desired size of an integer in this architecture.
Preprocessor directives are used in only restricted cases, namely
- Including header files
- Hardcoding constants
- Portability to 32 or 64 bit architectures
You might also see preprocessor directives in header files to prevent the compiler from reading a header file twice and compilaing that the function printHello
is declared twice, as in this example:
#ifndef PRINT_HELLO_HPP
#define PRINT_HELLO_HPP
void printHello();
#endif
This uses directives to define the PRINT_HELLO_HPP
identifier only once and prevent passing the header file to the compiler twice if it is included by many files in a large code project.
Key Points
Preprocessor directives are useful for making compile-time decisions
By separating header files and implementation files we can program to interfaces, not implementations.
Templates
Overview
Teaching: 10 min
Exercises: 10 minQuestions
How to factorize the code of similar functions and classes, where only few types and sizes are changing ?
Objectives
Know about basic template features.
Templates
Template definition
Sometimes, several chunk of codes differs only about some types involved, or about some integral values which are known at compilation time, such as the fixed size of some arrays. The C++ template
feature is a way to factorize the code of those similar functions or classes, declaring the types and fixed integers as additional parameters. Let’s see an example of function template, and an example of class template :
template<typename T>
T abs( T val ) {
const T ZERO = 0 ;
if ( val > ZERO ) { return val ; }
else { return (-val) ; }
}
template<typename T, int sz>
class Vector {
public:
T data[sz] ;
};
Template use
Wherever one calls such a template, one must clarify the lacking types and values. The compilation of the resulting template instance is finalized on-the-fly:
Vector<int,2> v = { 2*2*2, -3*3 } ;
std::cout << abs<int>(v.data[0]) << std::endl ;
std::cout << abs<int>(v.data[1]) << std::endl ;
Especially for function templates, the compiler is sometimes able to infer the lacking types from the values given as arguments to the function call, and one do not need to explicit them:
Vector<int,2> v = { 2*2*2, -3*3 } ;
std::cout << abs(v.data[0]) << std::endl ; // T is infered to be int
std::cout << abs(v.data[1]) << std::endl ; // T is infered to be int
Alias template
The keyword using
, which can define an alias name for any type, can also receive some template parameters. The two containers defined below are perfectly equivalent:
template<int sz>
using VectorDouble = Vector<double,sz> ;
VectorDouble<10> v1 ;
Vector<double,10> v2 ;
Beware of code bloat
A template body cannot be compiled separately once for all : it must stay entirely in the header file where you declare the template item, and it will be somehow duplicated and substituted with the parameter values, wherever you use it.
Beware of this : you may rather simply and unconsciously instanciate (i.e. duplicate) your template items with many different parameters, trigger many copies and feed the so-called “code bloat”. For each line below, there will be a different class generated, and they may be additionally duplicated in any body file where such lines appear:
Vector<int,2> v1 ;
Vector<int,3> v2 ;
Vector<double,10> v3 ;
Vector<double,20> v4 ;
Vector<double,30> v5 ;
.....
Exercise
Consider the following example:
#include <iostream> template<typename T, int sz> class Vector { public: T data[sz] ; }; template<int sz> using VectorDouble = Vector<double,sz> ; template<typename T> using Vector3 = Vector<T,3> ; // COMPLETE HERE int main() { Vector3<int> v1 = { 6*7, 3*14, 2*21 } ; VectorDouble<2> v2 = { 3.14, 1.62}; display(v1) ; display(v2) ; }
Instructions:
- add the lacking
display()
function, which print the values of the inputVector
ontostd::cout
, one after the other,- the expected output is:
42 42 42 3.14 1.62
Solution
#include <iostream> template<typename T, int sz> class Vector { public: T data[sz] ; }; template<int sz> using VectorDouble = Vector<double,sz> ; template<typename T> using Vector3 = Vector<T,3> ; template<typename T, int sz> void display( const Vector<T,sz> & v ) { for ( int i = 0 ; i < sz ; ++i ) { std::cout << v.data[i] << ' ' ; } std::cout << std::endl ; }; int main() { Vector3<int> v1 = { 6*7, 3*14, 2*21 } ; VectorDouble<2> v2 = { 3.14, 1.62}; display(v1) ; display(v2) ; }
Specialization
One can define a specific implementation for some specific values of a template parameter. This is called a specialization.
Function template specialization
Below, we give a general implementation for equal
, and a specialized one for double
:
template<typename T>
T abs( T val ) {
const T ZERO = 0 ;
if ( val > ZERO ) { return val ; }
else { return (-val) ; }
}
template<typename T>
bool equal( T v1, T v2 ) {
return (v1==v2) ;
}
template<>
bool equal( double v1, double v2 ) {
const double EPSILON = 1e-13 ;
return (abs(v1-v2)<EPSILON) ;
}
From the point of view of the client code, this is a single template with some special cases. Try the code below, with or without the specialization:
int main() {
double val = 1.0, tenth = val/10, sum = 0.0 ;
for ( int i = 0 ; i<10 ; ++i ) {
sum += tenth ;
}
if (equal(val,sum)) {
std::cout<<"1. == 1./10 + 1./10 + ..." ;
}
else {
std::cout<<"1. != 1./10 + 1./10 + ..." ;
}
}
Class template specialization & traits
The same kind of specialization can be applied to class templates, with an important surprising characteristic : one is completely allowed to change the public interface of the class !
Sometimes, it even makes sense to define a default template class which is empty, and only its specialization will provide some content. This usually called traits
, and is used to externally extend some existing types with new attributes.
In this example, we extend float
and double
with a new constant EPSILON
:
#include <iostream>
template<typename T>
class Traits {};
template<>
class Traits<float> {
public:
static constexpr float EPSILON = 1e-6;
};
template<>
class Traits<double> {
public:
static constexpr double EPSILON = 1e-13;
};
int main() {
std::cout << Traits<float>::EPSILON << std::endl;
std::cout << Traits<double>::EPSILON << std::endl;
}
Writing Traits<int>::EPSILON
would be invalid, because the general implemetation of Traits
does not include any EPSILON
constant. This behavior may be what we want : asking the EPSILON
for int
does not make sense in this design, and we are happy that the compiler will complain.
Variable templates
C++ has been given recently the ability to define variable templates, i.e. variables whose type is a template parameter. This is typically used to define a constant with a varying precision:
#include <iostream>
#include <cmath>
template<typename T>
T constexpr PI = std::acos(-T(1)) ;
int main() {
// 3 . 14159 26535 89793 23846
std::cout.precision(20) ;
std::cout<< PI<int> <<"\n";
std::cout<< PI<float> <<"\n";
std::cout<< PI<double> <<"\n";
std::cout<< PI<long double> <<"\n";
}
When combined with specialization, this offers another way to define a value depending on the type:
#include <iostream>
template<typename T>
const T EPSILON =0;
template<>
const float EPSILON<float> = 1e-6;
template<>
const double EPSILON<double> = 1e-13;
int main() {
std::cout << EPSILON<int> << std::endl;
std::cout << EPSILON<float> << std::endl;
std::cout << EPSILON<double> << std::endl;
}
As compared with the previous example, this is a lot more simple, from the syntax point of view. But the expression EPSILON<T>
is valid for any T
, for example int
, and one must give a default value to the default non-specialized implementation of the variable template (here 0
).
Exercise
In the program below, if one replace
double
withfloat
, the value1.0
is not any more equivalent to the sum of ten values0.1
, because theEPSILON
value (1.e-13
) is too small forfloat
precision.#include <iostream> template<typename T> T abs( T val ) { const T ZERO = 0 ; if ( val > ZERO ) { return val ; } else { return (-val) ; } } template<typename T> bool equal( T v1, T v2 ) { return (v1==v2) ; } // COMPLETE HERE: EPSILON definition for float and double // REPLACE BELOW: the single specialization with two // specializations, one for float, and the other for double using real = double ; template<> bool equal( real v1, real v2 ) { const real EPSILON = 1e-13 ; return (abs(v1-v2)<EPSILON<double>) ; } // REPLACE BELOW: real by float, so to validate // that the equality is now true also for float int main() { real val = 1.0, tenth = val/10, sum = 0.0 ; for ( int i = 0 ; i<10 ; ++i ) { sum += tenth ; } if (equal(val,sum)) { std::cout<<"1. == 1./10 + 1./10 + ..." ; } else { std::cout<<"1. != 1./10 + 1./10 + ..." ; } }
Instructions:
- replace
real
, and check the result,- inject into the program a definition of
EPSILON
which would be1e-13
fordouble
computation, and1e-6
forfloat
computation,- specialize the function
equal
both fordouble
andfloat
, using the previous definitions,- check, when using
float
numbers inmain()
, that1.0
is now equivalent to the sum of ten values0.1
.
Solution
#include <iostream> template<typename T> T abs( T val ) { const T ZERO = 0 ; if ( val > ZERO ) { return val ; } else { return (-val) ; } } template<typename T> const T EPSILON =0; template<> const float EPSILON<float> = 1e-6; template<> const double EPSILON<double> = 1e-13; template<typename T> bool equal( T v1, T v2 ) { return (v1==v2) ; } template<> bool equal( float v1, float v2 ) { return (abs(v1-v2)<EPSILON<float>) ; } template<> bool equal( double v1, double v2 ) { return (abs(v1-v2)<EPSILON<double>) ; } int main() { float val = 1.0, tenth = val/10, sum = 0.0 ; for ( int i = 0 ; i<10 ; ++i ) { sum += tenth ; } if (equal(val,sum)) { std::cout<<"1. == 1./10 + 1./10 + ..." ; } else { std::cout<<"1. != 1./10 + 1./10 + ..." ; } }
Few hassles
In real code, you mix templates with other C++ features, and the compiler sometimes fails to understand what you obviously mean…
Implicit conversions
Because they belong to different stages of the compilation process, the compiler cannot both infer a type parameter and apply an implicit conversion. Locate the implicit conversion in this program:
#include <iostream>
class Vector2 {
public:
double x, y ;
};
void display( Vector2 v ) {
std::cout << v.x << '/' << v.y << std::endl ;
}
int main() {
display({ 2*2*2, -3*3 }) ;
}
In this example, one cannot simply transform double
into a template parameter, or the compiler will not known how to compile display({ 2*2*2, -3*3 })
:
#include <iostream>
template<typename T>
class Vector2 {
public:
T x, y;
};
template<typename T>
void display( Vector2<T> v ) {
std::cout << v.x << '/' << v.y << std::endl;
}
int main() {
display({ 2*2*2, -3*3 });
}
In such a situation, the developer must help the compiler with an explicit conversion:
int main() {
display(Vector2<int>{ 2*2*2, -3*3 });
}
Nested dependent types
The compiler will refuse to compile the following code:
template<typename Container>
void inspect( Container & container )
{
Container::iterator * x ; // DOES NOT COMPILE !
//...
}
Actually, the compiler does not know yet if iterator
is some Container
nested type, or some static member variable. By default, it is assuming the latter (strange choice !), and Container::iterator * x
is seen as the multiplication of Container::iterator
and x
, which does not exist…
The fact that Container::iterator
is expected to be a type can be specified with the keyword typename
:
template<typename Container>
void inspect( Container & container )
{
typename Container::iterator * x ;
//...
}
Surprisingly enough, the compiler will also refuse to compile the following code:
template<typename Val>
void inspect( std::vector<Val> & container )
{
std::vector<Val>::iterator * x ; // DOES NOT COMPILE!
//...
}
Well, std::vector
is known, but what if there exists a nasty specialization for a certain value of Val
,
which changes the meaning of std::vector<Val>::iterator
? Remember: when specializing a class, one can freely modify the class interface ! Again, the use of typename
will appease the compiler.
Inheritance
When a class inherits from a class template, the compiler suspects some possible nasty specialization, and does not apply the inheritance as is !
template<typename Val>
struct MyContainer
{
int size() { return 0 ; } ;
} ;
template<typename Val>
struct MyExtendedContainer : public MyContainer<Val>
{
int extended_size()
{ return size() + 1 ; } // DOES NOT COMPILE!
} ;
Three ways permit to restore the expected inheritance:
- making the attribute visible with a help of instruction using:
... using MyContainer<Val>::size ; ...
, - calling the attribute with a pointer this:
... return this->size() ...
, - prefixing the attribute by the class name:
... return MyContainer<Val>::size() ...
.
The last approach should be avoided, because it inhibits possibly virtual methods.
Yet, templates are a killing feature for library developers
Despites a tricky syntax and well-know drawbacks, templates are a key feature for C++ expressivity and performance. Perhaps you will never write yours, but certainly you will use more and more of them, provided by expert libraries. In C++20, the new concepts features will make templates even more pervasive. Get ready !
Key Points
There are function and class templates.
Template parameters can be types or integers known at the compile time.
This is all managed at compile time.
All the code must stay in header files.
Beware of code bloat.
Specialization enable to handle special parameter values, but also imply some hassle, especially when mixing templates with inheritance or implicit conversions.
Before C++20, there is no simple way to define constraints on the allowed values for template parameters.
Type inference
Overview
Teaching: 10 min
Exercises: 0 minQuestions
Should I repeat again and again the obvious type of everything ?
Objectives
Know about
auto
anddecltype
.
Static typing and type inference
In C++, the developer must say the type of every piece of data which is handled, and the compiler will fiercely check that all your declarations are consistent. That is “static typing”.
Yet, there are more and more situations where you can ask the compiler to infer the types from the context. There are several benefits:
- it avoids to repeat redundant information, sometimes cumbersome and error-prone,
- it makes easier to replace a type with another, because only the explicit types require to be updated manually.
There is a drawback : when going above a threshold, which is rather subjective, you do not know any more which variable pertains to which type, and the code readability is damaged.
Old school type inference for function templates
When one call a function template, the compiler will consider the arguments given to the function, and can deduce the type parameters of the template. This is the first form of type inference introduced in C++. In the example below, the compiler will deduce that within main
, for the call to sum
, Num
should deduced to be int
.
Type inference for class templates
Type inference is now also available for classes. Below, notice how the compiler is able to deduce the type of col
in main
.
New auto
keyword, when initializing a variable
It is recommended to give an initial value to any variabe you declare. If the type of the variable is the same as the one of the initial value, do not repeat the type : use auto
instead.
Rather than this:
Write this:
In the latter, no risk to make an error with the type of sz
, and if you change your array from integers to doubles, no need to change the declaration of first
or elem
.
Yet, what to do with sum
, which we want to initiate with the value 0
(of type int
), but that should be of the same type as first
(possibly double
) ?
New decltype
keyword
If you do not have a valid initial value for your new variable, you can rely on decltype
to reuse the type of another variable.
Keyword auto
remove any const
or &
Be aware that auto
gets rid of any const
or &
modifier to the type of the original value, which is what you expect in most of the cases. In the example below, we obviously do not want res
to be of type const int &
.
If you want to preserve the possible const
and/or &
from the initial value, use decltype
instead of auto
.
Willingly adding const
and/or &
On the contrary, you must sometimes add const
and/or &
to a deduced type which lacks those modifiers. For example, if you want to modify the elements of some collection. In the example below, notice the auto & elem
.
Keyword auto
as a return type
The compiler can also deduce the return type of a function from the return
instructions within the body of the function. This also works with several return
instructions, provided they all return a value of the same type.
This only works if the declaration and the definition (body) of the function are in the same file (i.e. inline or template functions).
Soon in C++20 : auto
for function arguments
Logically enough, we should be able, soon, to use auto
for function arguments…. Actually, this is hiding templates, and as above, it only works for functions which have their declaration and description in the same file.
If you look at the final implementation of accumulate
above, you will probably agree that too much auto
makes the code probably more generic, but also less readable.
Key Points
The new keyword
auto
avoid you the error-prone typing of complex long type names.A collateral benefit is that the code is more generic.
Yet, do not overuse
auto
, because the types contribute to the readability of the code.
Classes
Overview
Teaching: 0 min
Exercises: 0 minQuestions
What are classes, how do they differ from structs, and how do we build them?
Objectives
Understand how to define and implement classes.
Understand how encapsulation and invariants can improve our code.
Classes
As boring as historical introductions to programming languages usually are, please bear with us just once. When C++ was first being developed in Denmark in the 1979, the language was not called “C++”, but rather “C with Classes”. Indeed, classes are one of the most fundamental features of C++, and they are the raison d’être for the entire programming language. Classes allow us to take simple types like the structs we have seen earlier, and augment them with special functions that operate on them.
Classes and structs are (almost) the same
The existence of the
struct
keyword in C++ is a vestige of C, and it must be said that classes and structs are almost the same thing in C++. However, we have chosen to denote simple product types withstruct
as this is more in line with how the keyword is used in C. Perhaps this will help you if you ever have the misfortune of reading C code; you will know that C structs are much less powerful than classes in C++.
Motivation
The motivation behind classes is a strict superset of the motivation behind simple structs. Everything that motivates structs also motivates classes, but classes give us a few more desirable properties, mainly encapsulation and the ability to enforce invariants about our code.
Encapsulation
When we talked about structures, we mentioned the value of grouping information together into senbible units that we can operate on. We noted that this provides valuable semantics for human readers of the code. But we can go one step further and hide information from the user if they don’t need to see it. Indeed, when you define a class, you cannot access the values stored inside it by default! This might seem like a ridiculous at first, but there are some extremely enticing benefits to this practice, which we call encapsulation. If you’ve ever driven a car, you will be familiar with using the steering wheel, the gas and brake pedals, and perhaps the stick shift. What you do not encounter during your daily commute is the incredibly complex machinery that happens under the hood of your car. The car designer has cleverly hidden the inner workings of the car from you, encapsulating the stuff you don’t need to know about under the bonnet, and exposed only a simple interface for steering and accelerating to you. This is the same idea that drives the desire for encapsulation in software.
Let us return to the example of a point in two-dimensional space that we used
earlier. Before, we used a Cartesian coordinate system, representing the point
in space as an x-coordinate and a y-coordinate. However, the Cartesian
system is not our only choice, and we could opt for a polar coordinate system
instead, where we represent points using an angle and a distance from the
center. These two approaches can be used to represent the same points, but the
implementations are different. The benefit of encapsulation in this case is that
we can hide the internal implementation of our class from the user, and expose
methods to retrieve the coordinates in either a polar system or a Cartesian
system. The implementation of this class can either have Cartesian coordinates
or polar coordinates on the inside, and the user doesn’t have to care, because
they will interact only with the interface we expose. This also allows us to
freely change the implementation if the need comes up, without the user
noticing. If we changed up the coordinate system in our earlier Point
struct,
we would have many angry users at our office within hours!
Invariants
Imagine we have our points in a Cartesian coordinate system, and we want our class to represent points on the unit circle. That is to say, the distance from the origin should always be one. The requirement that the length of our vector should always be one is called an invariant, and the ability to enforce such invariants is both a continuation of encapsulation as well as another desirable property of classes. Imagine if we wanted to enforce this unit requirement on the point struct we defined before: anyone can freely set the values inside that struct, and there is nothing stopping them from inserting values that do not have unit length! Our invariant could be broken almost immediately unless our programmers took great care to only ever insert the right values! By enforcing our invariants at the class level, we help the user protect themselves against bugs.
In the coming few paragraphs, we will demonstrate how we can implement the encapsulation described above, as well as these invariants.
Defining classes
Let us now create our first class. We’ll once again use the point example we gave above, in which we have two coordinates in space, but we’ll use doubles this time, to change things up:
class Point {
double x;
double y;
};
If you’re squinting your eyes, trying to spot the difference between the structs we defined earlier and this class (except for the fact that we used a different keyword), don’t worry: the syntax is virtually the same!
Access control
Recall that, by default, members of a class are not accessible from the outside by default in order to facilitate encapsulation. To see this in action, consider the following complete C++ program which will not compile:
#include <iostream>
class Point {
double x;
double y;
};
int main(void) {
Point p;
std::cout << p.x << ", " << p.y << std::endl;
return 0;
}
If you were to try to compile this code, the compiler would show you an error.
The x
and y
members of the Point
class are hidden to the outside world by
default: they are private. Only functions inside the class (which we will
cover in the next paragraph) are able to access them. In general, this is a
great property to have, as it encourages encapsulation. However, there are some
scenarios where you absolutely must access a member from the outside world. In
such cases, we can use access modifiers. Two common access modifiers are
private
and public
. The public access modifier allows anyone to access the
member, and breaks encapsulation. There is also the protected
modifier, which
we do not yet have the ability to understand, and as such we will skip over it
for now. The following code compiles by making the data members public:
#include <iostream>
class Point {
public:
double x;
double y;
};
int main(void) {
Point p;
std::cout << p.x << ", " << p.y << std::endl;
return 0;
}
As a general rule of thumb, you should use the strictest access modifier you can
get away with (that is to say, private
over protected
over public
). Try
to expose data using public functions rather than public data members, and only
make data members public if you absolutely do not have a choice.
Member functions
A useful feature of classes is that they can contain methods. Methods are
functions which only operate on an instance of the class on which it is defined.
Let’s add some methods to our Point
class:
#include <iostream>
class Point {
public:
double getX() {
return x;
}
double getY() {
return y;
}
void setCartesian(double xn, double yn) {
x = xn;
y = yn;
}
private:
double x;
double y;
};
int main(void) {
Point p;
p.setCartesian(5.0, 9.0);
std::cout << p.getX() << ", " << p.getY() << std::endl;
return 0;
}
There is a lot to say about this code! First of all, it might seem a little verbose, especially compared to the code we had when the data members were public. This is true! For now, it seems like we have added a lot of unnecessary code for little gain, but we will benefit from the encapsulation later.
Secondly, note that we can still access the data members of the class from the
outside using the getX
and getY
methods, but only indirectly. This is
possible because these methods are public even though the data members are
private. Since the getX
and getY
methods are defined in the class, they can
access private members. Note how this means the class still has full agency on
how it handles calls to that function. This alternative implementation always
writes the number 42.0
to the coordinates, regardless of what the user
requests. This is not particularly useful, of course, but demonstrates that the
class designer is in control of what happens, and is free to inforce invariants
if they please.
class Point {
...
void setCartesian(double xn, double yn) {
x = 42.0;
y = 42.0;
}
...
};
Finally, we should take note of the fact that the methods defined in the class only work on instances of that class. The following code would not compile, for example:
double a = 5.0;
a.getX();
Now, let us unlock the full magic of encapsulation. In the following code, we
will completely redesign the internals of the class to use polar coordinates
instead of Cartesian coordinates. Take note of the fact that the user code (in
the main
function) is identical between the two examples! This is how well we
can hide implementation: the user need not care at all about the insides of
the class.
#include <iostream>
#include <cmath>
class Point {
public:
double getX() {
return r * std::cos(phi);
}
double getY() {
return r * std::sin(phi);
}
void setCartesian(double xn, double yn) {
phi = std::atan2(yn, xn);
r = std::hypot(yn, xn);
}
private:
double phi;
double r;
};
int main(void) {
Point p;
p.setCartesian(5.0, 9.0);
std::cout << p.getX() << ", " << p.getY() << std::endl;
return 0;
}
Information hiding (encapsulation) is the core concept of object-oriented programming, and of C++. It allows us to build modular code, and it allows us to abstract ugly class internals away from the user. This is the jaw-dropping power of encapsulation, and it is why you should focus on making your software as encapsulated as possible.
Finally, let’s quickly cover invariants. We will change our Point class to use
Cartesian coordinates again, like it was before, but we will not enforce that
the distance from the center of the coordinate space must always be one. That is
to say, the setCartesian
method should fail if we try to set it to a point not
on the unit circle.
#include <iostream>
#include <cmath>
class Point {
public:
double getX() {
return x;
}
double getY() {
return y;
}
bool setCartesian(double xn, double yn) {
if (std::abs(1.0 - std::hypot(yn, xn)) < 0.0001) {
x = xn;
y = yn;
return true;
} else {
return false;
}
}
private:
double x;
double y;
};
int main(void) {
Point p;
bool r1 = p.setCartesian(0.866025404, 0.5);
std::cout << "Attempt 1 " << (r1 ? "succeeded" : "failed") << std::endl;
bool r2 = p.setCartesian(6.0, 0.0);
std::cout << "Attempt 2 " << (r2 ? "succeeded" : "failed") << std::endl;
std::cout << "Point is (" << p.getX() << ", " << p.getY() << ")" << std::endl;
return 0;
}
This time, we have altered the setCartesian
method to check whether the given
point is on the unit circle. If it is, the values are updated as normal, and a
true
value is returned to indicate success. If the point is not on the unit
circle, the values are not updated and false
is returned to indicate
failure. Hopefully, this example demonstrates succinctly how classes allow us to
enforce invariants: try as the user might, the object will never lie off the
unit circle. Both the user and the developer can rest assured that whichever
values are inside this class, that requirement will always hold…
Constructors
…or will it? There is one last weakness in our plan, and the following bit of code reveals it:
int main(void) {
Point p;
std::cout << "Point is (" << p.getX() << ", " << p.getY() << ")" << std::endl;
return 0;
}
When I ran this code, the result was (7.89628e-317, 3.03428e-86)
, which is
clearly not on the unit circle! Due to the way our objects are initialized,
there is still a chance that we might end up with a point that is not on the
unit circle. This weakens our invariant tremendously, but thankfully there is a
way around it: constructors. We learned earlier that constructors are a way for
us to control the initialization of our class. Currently, we are using a
compiler-generated default constructor which does not know about our invariants,
but we can write our own constructor that does:
#include <iostream>
#include <cmath>
class Point {
public:
Point() {
x = 1.0;
y = 0.0;
}
double getX() {
return x;
}
double getY() {
return y;
}
bool setCartesian(double xn, double yn) {
if (std::abs(1.0 - std::hypot(yn, xn)) < 0.0001) {
x = xn;
y = yn;
return true;
} else {
return false;
}
}
private:
double x;
double y;
};
int main(void) {
Point p;
std::cout << "Point is (" << p.getX() << ", " << p.getY() << ")" << std::endl;
return 0;
}
The constructor is defined just like a member method, but it does not have a
return type and its name is equal to the class name. Here, we have defined the
zero-argument constructor (which is the one used in main
) to initialize the
point to (1.0, 0.0)
, which of course is on the unit circle. This way, we are
guaranteed that our invariant will hold from the moment the object is created.
An alternative syntax for this constructor is the following:
Point() : x(1.0), y(0.0) {
}
Finally, let’s create a constructor which takes two arguments representing a pair of polar coordinates, and initializes the instance with those coordinates. The code might look something like this:
Point(double phi, double r): x(std::cos(phi)), y(std::sin(phi)) {
}
Note that we can throw away the value for r, since we want to have points on
the unit circle only. Discarding the value for r essentially scales our vector
to length 1 anyway. A full example that calls this new constructor is given
here. It gives us the point (0.125465, 0.992098)
, and once again we are
guaranteed that any point we can create now lies on the unit circle. The
invariant holds.
#include <iostream>
#include <cmath>
class Point {
public:
Point() : x(1.0), y(0.0) {
}
Point(double phi, double r): x(std::cos(phi)), y(std::sin(phi)) {
}
double getX() {
return x;
}
double getY() {
return y;
}
bool setCartesian(double xn, double yn) {
if (std::abs(1.0 - std::hypot(yn, xn)) < 0.0001) {
x = xn;
y = yn;
return true;
} else {
return false;
}
}
private:
double x;
double y;
};
int main(void) {
Point p(1.445, 6);
std::cout << "Point is (" << p.getX() << ", " << p.getY() << ")" << std::endl;
return 0;
}
Key Points
Will follow
Sum types
Overview
Teaching: 0 min
Exercises: 0 minQuestions
How do sum types differ from product types?
Objectives
Understand sum types.
Sum types
Although they are much less common than product types, sum types are another
important family of types. A sum type represents a choice between two types
instead of the combination of two types represented by a product. For example,
the sum type of a boolean and an unsigned integer (uint+bool
) represents
exactly one value in the set {true, false, 0, 1, .., 4294967295}
. This type
can be written in C++ using the following syntax, which we call C-style unions:
union IntBool {
bool b;
uint i;
};
This type is simulatenously a boolean and an unsigned integer, and without external bookkeeping it is impossible to know which state it is currently in. There is a comparison to be made to quantum mechanics, although collapsing a wave form does not cause undefined behaviour (the root of all evil). To understand what happens inside a union, consider the following complete C++ program:
#include <iostream>
#include <bitset>
union IntBool {
bool b;
uint i;
};
int main(void) {
IntBool v;
v.i = 1234567;
std::cout << "i1 = " << std::bitset<32>(v.i) << " (" << v.i << ")" << std::endl;
v.b = true;
std::cout << "i2 = " << std::bitset<32>(v.i) << " (" << v.i << ")" << std::endl;
return 0;
}
This program will set the integer member of the union to the value 1234567
and
then print it, along with the binary representation of that number. If you
execute this program, you will see that the first print correctly indicates the
value 1234567
, and the binary representation
00000000000100101101011010000111
. Next, the program sets the boolean member of
the union to true, and then it prints the integer member again. However, because
the integer member and the boolean member occupy the same memory, setting the
boolean value has modified the integer member, and we see that the new value
is 1234433
, with binary representation 00000000000100101101011000000001
.
00000000 00010010 11010110 10000111
00000000 00010010 11010110 00000001
Notice how the only bits that have changed are the bottom eight. On the machine
that we tested this on, the size of a byte is one byte (eight bits), and as such
the operation v.b = true;
has assigned the bits 00000001
to that byte, also
changing the integer member in the process.
C-style unions are not type safe
C-style unions are not type safe, and the use of such unions is very dangerous. In modern C++, it is virtually always a bad idea to use unions like this, and one should always use the type-safe sum types defined in the standard library. This snippet of code is included purely for educational purposes to help you understand the concept of sum types.
Reading this, you may think sum types are a fundamentally bad idea, but they are not. To truly appreciate them, we must split the semantics of unions from their implementation. The semantics of unions are, as we will see, extremely useful, but the C-style implementation is thoroughly lacking. We will see examples of type-safe sum types later.
If we discard any notion of implementation and look at sum types from a sufficiently abstract point of view, it is immediately clear how useful they are. For example, consider a classic division function:
int divide(int n, int d) {
return n / d;
}
This function is not safe. If the value of d
is zero, and then we are diving
by zero. This will crash our program. But how do we make our function safe? We
can return some default value in the case that d
is zero, as follows:
int divide(int n, int d) {
if (d == 0) {
return -1;
}
return n / d;
}
But this is also a bad idea, because there is now no way to differentiate
between the function returning -1
to indicate a division by zero versus a
division which legitimately returns -1
. C++ provides an exception mechanism
which can be used to handle these errors, but it is ham-fisted and very
uncomfortable to use. The ideal solution is to use a sum type:
int+Error divide(int n, int d) {
if (d == 0) {
return Error{DivByZero};
}
return n / d;
}
Although this is not quite valid C++ syntax, this serves to demonstrate the
value of sum types. The error values exist completely outside the realm of
legitmate return values, but they are easily distinguished. At the same time,
both the integer return values as well as the error state exist within the same
type, so the program type checks without error. One could also imagine a program
which divides two numbers and returns an integer value if the denominator
devides the numerator, and a double otherwise. Or one could represent optional
value of type a
using the sum type a+void
.
Let us return now to the real world, and consider implementation. If we don’t care about space or performance, we can represent any sum type as a product type, just by storing one additional value to represent which of the summed types is currently represented by the instance. However, the size of a struct is equal to the sum of the sizes of all its members, while the size of a union is equal to the size of the biggest of its members. A struct containing members of sizes 50, 100, 100, 20, 4, and 8 bytes will total 282 bytes, while a union of the same types will be only 100 bytes in size. Memory is the only valid reason to overlap values in a union like C does, but unfortunately memory is often of critical importance, and thus we overlap. This is where the horrible behaviour we saw above come from. Remember, though, that this is an implementation detail of the union keyword in C++, and it is possible to get the full semantic power of sum types without having to result to such terrible implementation tricks.
Sum types in the standard library
In C++, product types are well represented by structs and unions, but sum types are not type safe by default. Luckily, the standard library provides some useful type-safe sum types, and we will cover two of them now.
std::optional
The std::optional
type represents the sum type of a given type and the void
type (also called the unit type in more enlightened languages). You can use this
type when you want to support cases where you are not sure your value will
actually be populated. Here is the division example from earlier, but in a way
that actually compiles as C++:
#include <optional>
#include <iostream>
std::optional<int> divide(int n, int d) {
if (d == 0) {
// Return a "nothing" value.
return {};
}
return n / d;
}
int main(void) {
std::optional<int> r1 = divide(9, 3);
std::optional<int> r2 = divide(9, 0);
if (r1) {
std::cout << "Result 1: " << r1.value() << std::endl;
} else {
std::cout << "Result 1: INVALID!" << std::endl;
}
if (r2) {
std::cout << "Result 2: " << r2.value() << std::endl;
} else {
std::cout << "Result 2: INVALID!" << std::endl;
}
return 0;
}
std::variant
Secondly, there is std::variant
, which represents a sum type of an arbitrary
number of types. We won’t go into detail about using std::variant
here,
because it is far harder to use than the other three types shown here. In fact,
many C++ developers consider std::variant
a failure of the standard committee,
and it is often given as an negative example of the ever-increasing complexity
of C++. Still, it is good to be aware of its existence, and to know what it
represents should you encounter it while reading existing code.
Key Points
Will follow
Code inclusion
Overview
Teaching: 0 min
Exercises: 0 minQuestions
How can I include code from an external file in my lesson
Objectives
Include external code
Code includes
Might be good for unit testing code snippets and for having syntax highlighting etc. when writing longer bits. Also great when you want to give students the opportunity to download the full files.
This is a full include:
This is a partial include with line numbers
You can also include from a string to a string:
Key Points
Put your code snippets in
_episodes/code/
There are three different ways to include code.