Going Deeper with C++
In lab 1, we introduced you to the minimum C++ features you'll need for programming work in this course. In this document, we cover additional C++ features you can use to improve your program design. We'll also talk a bit about things happening "under the hood" for you to have a deeper understanding of how the language works. C++ is a huge, featureful language, so we're not attempting to cover everything that's possible within the language. Rather, we've picked a handful of topics that we think may be most useful for the work you do in this course.
Dynamic Memory Allocation
Consider the following code:
struct Person {
std::string name;
unsigned int age;
};
void doStuff() {
Person arvind = { .name = "Arvind", .age = 20 };
Person maya = { .name = "Maya", .age = 21 };
// ...
}
}
In this example, the compiler knows how big a Person
object is (i.e. how much memory it takes up), so it allocates enough memory on the stack to store these two Person
objects.
But what if we don't know how many Person
objects we'll need to create at compile time? For example, imagine the scenario where we need to load a list of people from a file on disk. The standard way to handle this situation would be to put the Person
objects in an std::vector
:
void doStuff() {
std::vector<Person> people;
loadFromFile("people.txt", people);
// ...
}
But how is std::vector
actually implemented? It can't possibly be storing its objects on the stack, because the compiler doesn't know in advance how much stack space to allocate. Internally, std::vector
is dynamically allocating memory on the heap to store these objects. In C++, you can dynamically allocate memory using the new
operator. Here's how we would rewrite the above example to use new
directly, instead of relying on std::vector
:
void doStuff() {
// Allocate enough memory on the heap to store all the people in the text file
int numPeople = numberOfLines("people.txt");
auto people = new Person[numPeople];
loadFromFile("people.txt", people);
// ...
delete[] people;
}
The syntax new Person[numPeople]
means "allocate a continguous block of memory on the heap that can store numPeople
instances of the Person
object."
If you only wanted to allocate a single Person
object, the syntax looks a little simpler:
void doStuff() {
auto person = new Person{ .name = "Arvind", .age = 20 };
// ...
delete person;
}
Note, in both cases, the presence of the delete
operator at the end of the code. This is necessary because the compiler doesn't know when you're done using the memory you've allocated on the heap. If you don't explicitly delete
the memory you've allocated, it will stay allocated until the program exits, a situation known as a memory leak. Memory leaks are bad: in the worst case, they can cause your program to run out of memory and crash. In the best case, your program uses more memory than it needs to, which can cause performance problems (slowing down your computer; wasting battery life on laptops and mobile devices).
Managing dynamically-allocated memory manually with new
and delete
can be tricky, because you have to remember to delete
every object you new
-ed, and you have to make sure you don't delete
an object more than once. If you forget to delete
an object, you have a memory leak. If you delete
an object more than once, you have a double free error: your program can crash when it tries to access memory that's no longer allocated to it. These bugs can be so gnarly that we decided not even to teach you how to use new
and delete
in lab 1. Instead, we taught you how to use std::vector
, which manages memory for you automatically. You can absolutely get by using std::vector
for all your dynamic memory needs in this course, if you like.
There are reasons you might want to dynamically allocate memory yourself, though (like the virtual function-based runtime polymorphism that we discuss later in this document).
Smart Pointers
The C++ standard library has some features that make it easier to manage dynamic memory. In particular, it provides smart pointers: objects that behave like pointers, but automatically manage the memory they point to. For example:
void doStuff() {
auto person = std::unique_ptr<Person>{
new Person{ .name = "Arvind", .age = 20 }
};
// ...
// No need to explicitly delete the person object;
// it will be deleted automatically when the unique_ptr goes out of scope.
}
unique_ptr
is so named because you can have only one unique_ptr
referring to a particular object at a time. If you try to copy a unique_ptr
, the compiler will give you an error:
void doStuff() {
auto person = std::unique_ptr<Person>{
new Person{ .name = "Arvind", .age = 20 }
};
// ...
auto person2 = person; // error: cannot copy a unique_ptr
}
If you have a situation where you need multiple variables referring to the same dynamic object, the standard library provides another smart pointer type, shared_ptr
, which allows this. There is also a third smart pointer type, weak_ptr
, which is sometimes necessary to use in conjuction with shared_ptr
. We won't talk about shared_ptr
and weak_ptr
in this document, but you can read more about them here and here, if you're interested.
For those interested in programming language implementation:
shared_ptr
implements a form of reference counting, which is a type of automatic memory management algorithm used by some programming languages that do not allow manual memory management (e.g. Python). Such automatic memory management algorithms are also known as garbage collection algorithms.
Operator Overloading
Object-oriented programming is great, but writing out long method names when calling functions on objects can be tedious. For example, if have a vec3
class representing a 3D vector, and I want to add three vectors together, it's both tedious to type and hard to read if I have to write the code like this:
auto v1 = vec3{ 1.1, 4.2, 2.5 };
auto v2 = vec3{ 0.4, 2.6, 3.1 };
auto v3 = vec3{ 2.3, 1.2, 0.5 };
auto v4 = v1.add(v2).add(v3);
Wouldn't it be nice if we could just write v1 + v2 + v3
instead? We can, using a feature of C++ called operator overloading:
struct vec3 {
double x;
double y;
double z;
vec3 operator+(const vec3& other) {
return vec3{
.x = x + other.x,
.y = y + other.y,
.z = z + other.z
};
}
};
There are tons of operators that you can overload in C++: the assignment operator =
; the array subscript operator []
; even the "deference pointer to object and call method" operator ->
(which is how smart pointers are implemented, by the way). You can find a list of them here.
Lambda Expressions
Lambda expressions are one of the most important constructs in modern C++. It is what makes functional programming possible in the language, and it can be handy even for non-functional designs.
In other languages, lambdas are also known as anonymous functions or closures.
They can appear almost anywhere in your program: unlike regular functions, which may not appear inside other functions, lambdas can appear in any scope just like other primitives of the language such as an int
or a float
. You may assign lambdas to variables and pass them around as parameters or return values. The lambda syntax looks very similar to functions, with the addition of a capture list:
// Lambda syntax: [capture list](parameter list) { function body }
// the parameter list can be omitted if the lambda takes no arguments.
auto identity = [](auto x) { return x; }; // Must use type "auto" for a variable to which a lambda expression is assigned.
auto x = identity(42); // x = 42
The capture list controls how the lambda accesses variables in its surrounding scope. An empty capture list indicates that the lambda has no access to its surrounding scope. If access to the surrounding scope is required, the lambda may capture all the variables in its scope by value (=
) or by reference (&
):
auto x = 42;
auto f = [=] { std::cout << x; }; // captures x by value, this makes a copy of x in f.
auto g = [&] { ++x; }; // captures x by reference, x in the outer scope will be affected when g is called.
auto h = [] { /* cannot access x here */ }; // captures nothing.
f(); // prints 42
g(); // now x = 43
The capture list can also be more specific, if desired. Something like [&, x]
means x
is captured by value and everything else is captured by reference. [=, &x]
on the other hand, means x
is captured by reference and everything else is captured by value.
auto x = 42;
auto y = 2.71;
auto z = "aaa";
auto f = [&, x]() mutable { ++x; }; // x captured by value, y and z captured by reference. The “mutable” specifier is required if you need to modify something captured by value.
auto g = [=, &x] {}; // x captured by reference, y and z captured by value.
Determining the best way to capture can be tricky. For example, capturing by reference incorrectly can lead to dangling references:
auto f() {
auto x = 42;
return [&] { ++x; }; // dangling reference here: by the time that the lambda is returned, the local variable x that it references is already released.
}
As a rule of thumb, we recommend to capture by reference if the lambda is invoked within the scope where it’s defined and to capture by value if the lambda outlives the current scope (e.g. if the lambda is returned from a function).
Lambdas are useful for all sorts of things. Here's a common use case: defining a custom sorting function for a vector of objects.
For example, if we have a vector of Person
objects, we might want to sort them by their age:
struct Person {
std::string name;
unsigned int age;
};
auto people = std::vector<Person>{ /* ... */ }; // Populate list of people
// Sort people by age
// The sort function takes three arguments: the beginning and end of the range to sort, and a function that compares two elements.
std::sort(people.begin(), people.end(),
[](const Person& a, const Person& b) {
return a.age < b.age;
});
Runtime Polymorphism
Polymorphism refers to the same code being able to operate on different data types.
Needing to do this is so common that any general-purpose programming language worth its salt will implement some form of polymorphism.
You already saw one form of polymorphism in lab 1: generic functions (i.e. functions with auto
parameters, a.k.a function templates), which is known more broadly as parametric polymorphism.
While this kind of polymorphism is useful, it has some limitations.
Each time you call a generic function, the compiler checks the types of the arguments you're passing to it and generates a new version of the function that's specialized to those types.
This means that those types must be known at compile time...and this isn't always the case.
Sometimes, you'll need to write code that can handle multiple types of objects, but those types are only known at runtime--this is known as runtime polymorphism.
Consider the following example:
// Declare a vector which will hold some objects.
auto objects = std::vector<??>{};
// Load a list of object type ids from a file.
std::vector<int> ids = loadFromFile("objectTypes.txt");
// Populate 'objects' with objects of the correct type,
// according to the ids loaded from the file.
for (auto id : ids) {
switch (id) {
case 0: objects.emplace_back(Rectangle{ .width = 2, height = 3 }); break;
case 1: objects.emplace_back(Circle{ .radius = 1 }); break;
// ...
}
}
We need to be able to store multiple types of objects in the objects
vector...so what type should we replace ??
with?
Essentially, we need to create some kind of proxy type T
that can represent any of the types we want to store in the vector. In the above example, T
would be something like Shape
.
If you're familiar with Java, you've seen this design pattern before with Java's object-oriented programming features (in particular, the interface
construct).
C++, being the complex beast that it is, has multiple ways to achieve the same behavior. We'll talk about two of them here: function type proxies and virtual functions.
The latter is the "traditional" way of achieving runtime polymorphism in C++ code (older versions of this course taught it exclusively and used it in stencil code), but it has some downsides that can make it inconvenient to use (more on this later). Thus, we talk about function type proxies first, because they are more straightforward.
Function Type Proxies
Suppose that the only thing we need to be able to do with our shape objects is render them to some Canvas
object. Then we could use the type signature of this render function as our proxy type, and we'd define our shape types using lambdas:
using RenderSignature = auto(const Canvas&)->void;
using Shape = std::function<RenderSignature>;
auto Rectangle(int width, height) {
return [=](const Canvas& canvas) {
// render the rectangle to the canvas
};
}
auto Circle(int radius) {
return [=](const Canvas& canvas) {
// render the circle to the canvas
};
}
Since the lambdas returned by the Rectangle
and Circle
functions have the same type signature as RenderSignature
, we can assign them to variables of type Shape
and store them in a vector of Shape
. Note that our use of the =
capture mode allows the returned lambdas to capture the arguments to the shape constructors (e.g. width
, height
) by value, so that they can be used later when the lambdas are called.
If each shape type has multiple functions that we need to be able to call, then we can package these functions into a struct
and use the type of that struct
as our proxy type.
For example, let's say that we want to be able to render shapes and also compute their areas:
using RenderSignature = auto(const Canvas&)->void;
using AreaSignature = auto()->double;
struct Shape {
std::function<RenderSignature> render;
std::function<AreaSignature> area;
}
auto Rectangle(int width, int height) {
return Shape{
[=](const Canvas& canvas) {
// render the rectangle to the canvas
},
[=]() {
// compute the area of the rectangle
return width*height;
}
};
}
auto Circle(int radius) {
return Shape{
[=](const Canvas& canvas) {
// render the circle to the canvas
},
[=]() {
// compute the area of the circle
return 3.14*radius*radius;
}
};
}
Virtual Functions
Lambdas are a relatively new feature of C++: they were introduced in the C++11 standard (so named because it was released in 2011), but the C++ language has been around for a lot longer (since 1985). Before lambdas, we couldn't pass functions around as data, so we couldn't use the function type proxy design pattern described above. Instead, we had to use a different design pattern: virtual functions.
Virtual functions are one of C++'s built-in object-oriented programming features.
A virtual function is a function defined in a struct/class whose behavior can be overridden by child structs/classes.
Here is the same Shape
example from above, rewritten to use virtual functions:
class Shape {
public:
// The "= 0;" at the end of a function declaration means that
// the function is "pure virtual", i.e. it has no implementation in this base class.
// This is similar to Java's "abstract" keyword for methods.
virtual auto render(const Canvas& canvas)->void = 0;
virtual auto area()->double = 0;
// A class with virtual functions must declare a virtual destructor
virtual ~Shape() = default;
}
// ": public Shape" means that Rectangle inherits from Shape
class Rectangle : public Shape {
public:
double width;
double height;
// Why are we explicitly defining such a simple constructor here?
// Why not just use aggregate initialization?
// More on this later...
Rectangle(double _width, double _height): width{ _width }, height{ _height } {}
// Override the virtual destructor from the base class
~Rectangle() override = default;
auto render(const Canvas& canvas)->void override {
// render the rectangle to the canvas
}
auto area()->double override {
// compute the area of the rectangle
return width*height;
}
}
class Circle : public Shape {
public:
double radius;
Circle(double _radius): radius{ _radius } {}
~Circle() override = default;
auto render(const Canvas& canvas)->void override {
// render the circle to the canvas
}
auto area()->double override {
// compute the area of the circle
return 3.14*radius*radius;
}
}
You might prefer this design over function type proxies because it maps more directly onto object-oriented design patterns you're already familiar with from Java (or other languages).
But it is also more verbose, and it has some other disadvantages.
Chief among them: any type with at least one virtual function no longer has a standard memory layout, meaning objects of such types cannot be memcpy
-ed.
This is also the reason why we had to explicitly define the constructors for Rectangle
and Circle
above: aggregate initialization (i.e. Rectangle{ .width = 2, .height = 3 }
) is not allowed for types with virtual functions.
The most important consequence of this propery is that such types cannot be allocated on the stack: every object of such a type must be dynamically allocated and accessed through a pointer.
You must then use smart pointers or manually manage the lifetime of each object very carefully to ensure that your code has no memory leaks.
Pointers and References (Reprise)
In lab 1, we mentioned that we can access the entity that a pointer points to by dereferencing the pointer, and that a reference represents an entity at a particular memory address. Pointers and references are so similar that it seems like there should be a connection between them. And there is: when we dereference a pointer, what we get back is actually a reference:
auto x = 42;
auto px = &x;
// we can verify that dereferencing a pointer indeed gives us a reference
static_assert(std::same_as<decltype(*px), int&>);
This is precisely the reason why dereferencing a pointer allows us to modify the object it points to, the same way as how we modify something via a reference.
One problem with the basic form of references we've learned so far is that they cannot bind to values (rvalue in C++ terminology). This is because values such as 123
or 3.14
do not have a memory address because they are not stored in memory by some variable. The same applies to function parameters: we cannot pass values to reference parameters.
auto func(int& n)->void {
// empty
}
auto x = 42;
auto& refx = x; // OK, bind to a variable
auto& incorrectRefx = 42; // error, (lvalue) reference cannot bind to (r)value
func(x); // OK, reference parameter binds to 'x'
func(42); // error, (lvalue) reference parameter cannot bind to (r)value
This can be inconvenient in some cases. Ideally, we'd want something that behaves like a reference when we bind it to a variable, and like a new variable when we provide it a value. Luckily, we do have something exactly like this in C++ called forwarding references in the form of auto&&
.
auto betterFunc(auto&& n)->void {
// empty
}
auto x = 42;
auto&& refx = x; // OK, bind to a variable, same as 'auto& refx = x;'
auto&& forwardRef = 42; // OK, creates a new variable as if 'auto forwardRef = 42;'
betterFunc(x); // OK, reference parameter binds to 'x'
betterFunc(42); // OK, as if the parameter type is non-reference
Be careful, though:
&&
after a concrete type, like int&&
or std::vector<int>&&
, does not form a forwarding reference! These are rvalue references which are outside the scope of this doc. You can learn more about rvalue references here, if you're interested.
Pointer Arithmetic
We've discussed in lab 1 that a pointer is an integer storing a memory address. What will happen though, if we perform integer arithmetics on pointers? Consider the following example
auto p = reinterpret_cast<int*>(42); // cast some random integer to a pointer
p += 1;
auto x = reinterpret_cast<unsigned long long>(p); // cast pointer back to integer
std::cout << x; // what will you see here?
You would probably think x
is 43
. However depending on your hardware platform, you're likely to see 46
! Why is that?
If you have a pointer p
pointing to the address of some object of type T
, p + n
assumes that there is an array of T
s stored in contiguous memory, and p + n
evaluates to the address of the n
-th object after the object that p
points to. In the above example, x
contains the address of a (hypothetical) integer next to the (hypothetical) integer whose address is 42
. Thus, the value of x
would be 42 + sizeof(int)
; since sizeof(int)
is 4
on most common hardware platforms (i.e. an integer is 4 bytes, or 32 bits), you see 46
when you print out x
.
You can also subtract integers from pointers. For example, p - m
gives the address of the m
-th object before *p
.
Pointer arithmetic is pretty common in C/C++, and since *(p + n)
is unwieldy to type, the language defines a syntactic sugar for us to do the exact same thing: the indexing operator p[n]
is equivalent to *(p + n)
.
Secrets behind C-style arrays
If you have prior experience in C, you may be wondering why our code examples typically use std::array<T, N>
instead of the classic C-style array T[N]
. We made this hoice because C-style arrays have some "gotchas" that can be confusing to beginners. Consider the following example:
int x1[3]; // Declare a C-style array
auto x2 = std::array<int, 3>{}; // Declare a C++ std::array
auto y1 = x1; // this is a pointer! not a copy of the 'x1' array!
auto y2 = x2; // this is a copy of the 'x2' array
y1[0] = 42; // this is the same as 'x1[0] = 42'
y2[0] = 42; // this does nothing to x2, since it's a copy
The reason for this behavior is that C arrays automatically decay to (i.e. are converted to) the pointer to their first element.
When the compiler sees x1[0]
, x1
first decays to a pointer automatically, then the pointer indexing operator is invoked and then desugar-ed into *(x1 + 0)
. Pointer arithmetic happens behind the scenes every time you manipulate a C-style array!