A repository for working through the Cherno's C++ YouTube series.
Why write in C++? Because we care about things like:
- Efficient Memory Management
- Runtime Performance
- Hardware Optimization
If you don't care about these things, don't choose C++!
This tutorial series will cover the following topics:
-
Introduction - How C++ Works
-
C++ Fundamentals
- (8) Variables
- (9) Functions
- (10) Header Files
- (12) Conditions and Branching
- (15) Control Flow
- (16) Pointers
- (17) References
- (19) Classes vs. Structs
- (21)
static - (23) Enums
- (25) Destructors
- (26) Inheritance
- (27) Virtual Functions
- (28) Interfaces (Pure Virtual Functions)
- (29) Visibility
- (33)
const - (34)
mutable - (35) Member Initializer Lists
- (36) Ternary Operators
- (37) How to Create/Instantiate Objects
- (38)
new - (39) Implicit Coversion and the
explicitKeyword - (40) Operators and Operator Overloading
- (41)
this - (44) Copying and Copy Constructors
- (45) The Arrow
->Operator - (48) Local Static
- (56)
auto
-
Data Structures
- (30) TODO: Arrays
- (46) Dynamic Arrays (
std::vector) - (47) Optimizing the Use of
std::vector - (57) Static Arrays (
std::array) - (64) Multidimensional Arrays (2D Arrays)
- (67) Unions
- (93) Iterators
- (100) Maps (
std::mapandstd::unordered_map)
-
Memory Management in C++
-
C++ Advanced Topics
- (52) How to Deal with Multiple Return Values
- (53) Templates
- (55) Macros
- (58) Function Pointers
- (59) Lambdas
- (61) Namespaces
- (65) Sorting
- (66) Type Punning
- (68) Virtual Destructors
- (69) Casting
- (73) Dymamic Casting
- (75) Structured Bindings
- (82) Singletons
- (88) Argument Evaluation Order
- (96) TODO: Binary and Bitwise Operators
- (97) TODO: Bitwise AND, OR, XOR and NOT
- (101) What Exactly is
NULL? - (103) Conversion Operators
-
Performance & Benchmarking
- (62) Threads
- (63) Timing
- (74) Benchmarking
- (79) How to Make C++ Run Faster with
std::async - (80) TODO: How to Make your Strings Faster
- (83) TODO: Small String Optimizations
-
Storing Multiple Types of Data
-
Move Semantics
-
Workflow and Debugging
-
Writing Our Own Data Structures
- (91) Custom Array
- (92) Dynamic Array (Vector)
- (94) Writing an Iterator
- How C++ actually works.
- Memory and pointers.
- Memory "arenas", custom allocators, smart pointers, move semantics.
- Templates: "If you know how to use templates well they're extremely powerful and will make your life a lot easier." - Cherno
- Data structures (and how to make them faster than the standard STL data structures).
- Low-level optimization via "compiler-intrinsics" and assembly.
- What we want to know: How do we go from source code (.cpp files) to an executable binary?
- Preprocessor statements (# statements, e.g.
#include) happen before compilation.#includestatements literally pre-pend the contents of another file into your file.
- Every C++ application needs an entry point, typically this is
main(). - Operators are just functions! Think of operators as functions.
- Header files do not get compiled. The contents of included files get compiled as part of the cpp files that they're included in.
- The Compiler creates an object file (
.obj) for every cpp file (each cpp file is referred to as a translation unit). - The linker "glues" the object files into an executable.
- A linker error can happen when a symbol (e.g. a function name) that was "promised" to exist (e.g. via forward-declaration) cannot be resolved or found.
- This error is referred to as an "unresolved external symbol".
- There are a few key differences between a compiled language like C++ and an interpreted language like Python:
- C++ is a statically typed language, i.e. variable types are checked/known at compile time. Python is dynamically typed, i.e. variable types are evaluated at runtime.
- The C++ compiler translates source code into bytecode prior to execution, whereas the Python interpreter performs this same translation on-the-fly at runtime, typically leading to slower execution speeds.
- The compilers job is simple: its job is to translate our code into either constant data or instructions.
- The output of the compiler is an intermediate format file called an object file. Object files are passsed from the compiler to the linker.
- The compilation process happens in three steps: pre-processing (
#includestatements and macros), tokenizing, and finally the creation of the abstract syntax tree.
- TODO:
- TODO:
- Functions prevent code duplication.
- Each time a function is called in C++, the compiler generates a "call instructions"*. A call instruction creates a stack frame for that function, meaning we have to push the function parameters and return address onto the stack. Additionally, we have to jump to a different part of our binary to execute a function, and then return to where it was called from. All of this is "expensive".
- *: this only happens if the compiler chooses not to "inline" your function.
- Header files are used to store function and/or class declarations (not definitions).
#pragma once: Only include this file once into a single "translation unit". A translation unit is a single cpp file.
ifstatements consist of two parts:- The evaluartion of the conditional and the branch(es) that can be jumped to (jumping to a different part of memory).
- "Optimized" code may attempt to avoid branching altogether to prevent jumping around in memory.
continue,break,return
- "Possibly the most important episode in the series!" - Cherno
- raw pointers, not discussing smart pointers today.
- A pointer is an integer that represents a memory address.
- Being "invalid" is a perfectly acceptable state for a pointer.
0,NULLandnullptrmean the same thing when it comes to pointers.- Note that dereferencing should always be done prior to any operations on the underlying object, e.g. if we wanted to increment an integer
valuevia anint*tovalue, we'd do(*ptr)++instead of*ptr++. The latter will increment the pointer and then attempt to derefernece it (most likely leading to a crash). - Pointers are ContiguousIterators (of an array). You can use
++to go to the next item that a pointer is pointing to, and+4to go to the 5th element.
Video #17 - References in C++
- References are really just an extension of pointers.
- References are "pointers in disguise", i.e. references are just syntacial suger on top of pointers.
- References must "reference" existing variables, whereas pointers can be created as new variables.
- Unlike pointers which have a unique identity, i.e. a distinct memory address and an amout of space that can be measured with
sizeof, references are not new variables (i.e. they don't occupy memory or have their own memory address), they are simply an alias for an existing variable. - A reference cannot be reassigned.
- You cannot create a collection (e.g.
std::vector) of references. - A pointer can be assigned to
nullptr, whereas a reference must be bound to an existing object. - The following two ways of incrementing an
intare effectively the same:void increment_via_pointer(int* ptr) { // Dereference pointer before incrementing underlying value (*ptr)++; } void increment_via_reference(int& value) { value++; }
- Visibility of member variables:
- struct:
publicby default. - class:
privateby default.
- struct:
- Context 1:
statickeyword when used outside of a class or struct.staticeffectively allows you to mark a variable as "private" for a specific translation unit (cpp file).- This prevents any other translation unit (cpp file) from finding that
staticvariable in the linking process via theexternkeyword.
- Context 2:
statickeyword when used inside a class or struct.- A
staticmember variable of a class/struct is the same across all instances of that class/struct, i.e. there will be only one instance of thestaticmember variable. - A
staticmethod of a class does not have access to the object itself, i.e. cannot access thethiskeyword. - static methods (and also variables?) cannot access non-static variables because static methods do not actually have a "class instance".
- A
- See more on
staticfrom this StackOverflow question.
- Enums provide a way to give a name to a value and organize groups of names and values that make sense together.
enumvs. Enum class?
- The destructor applies to both stack and heap allocated objects.
- If you allocate an object via the
newkeyword, when you calldeleteon that object, the destructor will be called. - When a stack-based object goes out of scope, the desctrutor will be called.
- Why write a destructor?
- Any manually-allocated memory on the heap needs to be manually cleaned up in the destructor or you will create memory leaks.
-
A note on Constructors: every constructor in the inheritance hierarchy gets called in the order of base-class -> derived-class.
-
Destructors get called in the reverse order!
-
Polymorphism:
- Achieved either via function overloading or operator overloading, e.g. you can use the
+operator to add two ints, two floats or even concatenate two strings. - All members of the base class are part of the derived class. However, the derived class can only access members that are
publicorprotected-privatemembers of the base are always inaccessible to the derived class. - A derived class can inherit from a base class in the following ways:
public,protected, orprivate
Base Class Member Access Modifier Visibility to Derived Class inheritance type -------------> publicprotectedprivatepublicpublicprotectedprivateprotectedprotectedprotectedprivateprivateinaccessible inaccessible inaccessible - A pure abstract class (OOP factory) is a means of implementing polymorphishm such that users obtain a unique pointer to a lightweight or abstract base class, the implementation details are in the derived class that overrides its virtual member functions.
- Achieved either via function overloading or operator overloading, e.g. you can use the
-
From the C++ Style Guide:
- "Limit implementation inheritance. Prefer interface inheritance (via pure virtual functions) or use composition instead."
- Interface Inheritance: The use of inheritance from exclusively abstract classses, where no values or code are inherited from the parent.
-
When dealing with derived classes, the way you consume them (by-reference or by-value) has significant implications, especially concerning polymorphism and object slicing.
- Consuming an object of a derived class by-value creates a copy of the object. The copied object will be of the base-class type, and it will be stripped of any of its derived-specific data members or member functions leading to what's known as object slicing.
- Consuming a derived-class object by-reference (or pointer) does not result in a copy. The function operates on the original object, and its type is preserved. If the derived class overrides any virtual functions of the base class, the correct derived-class function will be called.
class Base { public: virtual void print() const { std::cout << "Base" << std::endl; } }; class Derived : public Base { public: void print() const { std::cout << "Derived" << std::endl; } }; void print_by_ref(const Base& base) { base.print(); } void print_by_value(Base base) { base.print(); } Derived b; print_by_ref(b); // prints "Derived" print_by_value(b); // prints "Base"
- Virtual functions allow us to override methods in sub-classes. A method marked as
virtualin the parent class can be overridden in the child-class. - Virtual functions implement "dynamic dispatch" via a "v-table". The v-table contains a mapping of all the virtual functions in the base-class to their overridden functions in the child-class.
- Marking a function as virtual tells the compiler to create a v-table for that function.
- If you want to override a function in the child-class, you must mark the corresponding function in the parent-class as
virtual. - The
overridekeyword should be used to mark the child-class method to indicate that it is overriding a virtual method of the parent class. - Virtual functions come at the expense of creating a v-table, but in reality, the performance difference is negligible.
virtual void fcn() = 0;. The= 0;is what makes it pure virtual.- The concept of a pure virtual function in a base class allows us to define a base-class member function that does not have an implementation, thereby forcing sub-classes to implement that function.
- This is similar to an
abstractmethodin python. - An interface class (or factory) consists entirely of pure virtual methods and cannot actually be instantiated.
- A sub-class of an interface class can only be instantiated if it implements all of the pure virtual methods defined by the parent-class.
private: these member variables and functions can (and should) only be accessed by the class itself.protected: these member variables and functions can (and should) only be accessed by the class itself and any sub-class.public: these member variables and functions can (and should) only be accessed by any other class of function.
- TODO
-
NOTE: This is where I picked up after the interview. I'm taking my time getting through the content now.
-
constis a "promise" that something will not be changed (not actually strictly enforced). -
The following table is created from Cherno's explanation:
Keyword Declaration Interpretation const int* ptrValue at pointer location cannot be changed, but the pointer can be re-assigned to a new address. int* const ptrThe pointer cannot be assigned to a new address, but the value at the pointer location can be changed. const int* const ptrThe pointer cannot be re-assigned to a new address and the value at the pointer location cannot be changed. void MyClass::func() constGuarantees that this function will not modify any member variables of MyClass, i.e. this function is "read-only". -
Rule of thumb: Mark any methods that are intended to be read-only as
const, e.g.void MyClass::func() const. -
The
mutablekeyword allowsconstmethods to modify member variables. -
The following table (source: SO) summarizes the use of the
constkeyword. Remember to read the keyword delaration backwards (as driven by the Clockwise/Spiral Rule).Keyword Declaration Interpretation int*Pointer to an int int const *Pointer to a const int int * constConst pointer to an int int const * constConst pointer to a const int Note that the first
constcan occur on either side of the type, soint const * == const int*int const * const == const int * const
- Two (very different) use cases: with
constand with lambdas.- The
mutablekeyword allowsconstmethods to modify member variables. - lambda (quick definition): a throw-away function that you can assign to a variable.
- The
- A way to initialize class member function via the contstructor.
- Member initializer lists are the most memory/time efficient way to construct
an object of a class that consists of non-primitive type members, i.e. members of other class types. For an example of this, see
app/member_init.cppthat demonstrates how the Entity's Logger is effectively created twice (and thrown away once) when member initialization is used improperly.
- Effectively syntatic sugar for an if statement.
<var> = <conditional> ? <value if true> : <value if false>- e.g.
std::string rank = (level >= MASTER_LVL) ? "Master" : "Novice" - Using a ternary operator to do something like this is actually faster because of something called Return Value Optimization.
- In the example below, an empty string object called
rankis created and then overriden with either "Master" or "Novice" once the conditional is evaluated. Using the ternary operator preventsrankfrom being created as an empty string object before being re-assigned.std::string rank; if (level >= MASTER_LVL) rank = "Master"; else rank = "Novice"; - Worth noting that ternary operators can be nested, but this can quickly become confusing to read and isn't recommended.
- All objects that are created must occupy some memory.
- Memory is divided into two main areas: the stack and the heap.
- Stack objects have a pre-determined lifespan that is defined by the scope that they are created in. Once that variable/object goes out of scope , then the local stack frame gets destroyed, i.e. that memory is freed.
- So, when should you create an object on the Stack? Whenever possible! Creating an object on the stack is typically the fastest and most "resourced managed" way to create an object in C++.
- The Stack is much smaller than the heap. The size of the Stack is platform-dependent, but is typically 1-2MB.
- Heap objects don't behave in the same way.
- The
newkeyword creates an object on the Heap and returns a pointer to that object, e.g.Entity* e = new Entity(). This object does not have a pre-determined lifespan and must be deleted manually usingdelete. - Stack vs. Heap overview:
- Performance: Heap allocation takes longer than Stack allocation.
- Memory Allocation: Heap allocation requires manual freeing of that memory - freeing memory isn't nicely managed the same way that it is for objects on the Stack. This can lead to memory leaks.
- So when should you create an object on the Heap?
- Is your object really, really big? (larger than the size of the Stack)
- Do you want to manaually control the lifespan of your object?
- If no to both 1. and 2. then create your object on the Stack!
- Smart Pointers (which we'll get into later) provide a means to allocate an object on the Heap but with the memory-mangement advantages that come with Stack allocation.
newfinds a block of memory (via the free list) that is large enough to accommodate our needs, and returns a pointer to that block of memory.newdoes two things:- Allocates the memory required to store the object.
- Calls the constructor for that object.
newis actually an operator, in the same sense that the+,==or<<(stream insertion) are operators. That means thatnewcan be overloaded.- So what does
newdo under the hood? It callsmalloc(). The following two lines are almost equivalent aside from the fact thenewcalls theEntityconstructor, whereas the second line purely allocates memory and returns a pointer to that memory. This is just for instructional purposes, you don't want to be doing anything that looks like the second line.
Entity* e1 = new Entity;
Entity* e2 = (Entity*)malloc(sizeof(Entity));
newanddeleteare an inseparable pair. All newly allocated memory must eventually be deleted. This is handled in an automated way by smart pointers.newanddeletefor standard objects.new[]anddelete[]for arrays.
- Implicit Conversion allows C++ to convert between one type and another, so long as only a "single step" conversion exists between those two types.
explicitdisables implicit conversion.- Since C++11,
explicitcan be applied to more than just constructors - it's now valid when applied to conversion operators as well. - In the following example, marking the
Fooconstructorexplicitwould prevent implicit conversion from taking place in the call tobar(42);.struct Foo { // A "converting constructor" allows implicit conversion. Foo(int x) {} }; // It looks like we have to pass a Foo, but do we? void bar(Foo foo) {}; int main() { // This is allowed because of implicit conversion. bar(42); } - Also note that an
explicitconstructor will prevent copy-initialization, e.g.Foo foo = 7. Only direct-initialization,Foo foo{7}is allowed for a constructor markedexplicit. - See cppquiz.org #131 for further explanation.
- Probably won't find myself using this very often, but good to know it exists and what it does.
- Operators:
=,==,<<(stream insertion),&,&&, etc. new,deleteand()are also operators.- "Operators are functions!" - Cherno
thisis a pointer to the current object instance that the member-method belongs to.- Deferencing (getting the value or object stored at the pointer location) the
thispointer looks likethis->x_which is equivalent to(*this).x_. - Can be used to pass the current object (or a pointer to the current object) to another function, e.g.
func(*this)orfunc(this). - Don't ever
delete this.
- Copying is expensive and should be avoided if unnecessary.
- The Copy Constructor is supplied by default by C++ and is implemented as a
shallow copy. This will create problems for any objects that have heap-allocated member pointers because the pointer itself will be copied.
- This results in pointer stored by the copied object pointing to the same memory address as the original object. When that memory is freed by the original object, attempting to free that same memory by the copied object will cause hard-to-diagnose errors.
- In order to implement a deep copy of an object, we must rewrite the Copy Constructor ourselves:
<Type>(const <Type>& other){} - Best Practice: Prefer passing objects by const reference (
const <type>&) to prevent unnecessary copying.
- The arrow operator
->is used to dereference a pointer. - The following lines are equivalent (the () are required in the first line because of operator precedence, i.e. dereferencing must take place before accessing the member method):
(*ptr).member_method(); ptr->member_method(); - Because the
->is an operator, it can be overloaded. This can useful if we choose to write our own scoped pointer class, e.g.ScopedPtr. - Bonus functionality: The arrow operator can be used to determine the offset in memory of a particular member variable, e.g:
struct Vec3 { // Floats are 4 bytes in length float x, y, z; } int main() { // Starting at zero (nullptr) give me the offset of the member y int offset = (int)&((Vec3*)nullptr)->y; <!-- int offset = (int)&((Vec3*)0)->y; --> std::cout << offset << std::endl; } Terminal Output: 4
- Can declare a variable as
staticin a local scope - this is different from the other two use cases of static that we've seen already. - Declaring a variable as
staticwithin a local scope (e.g. within a function) restricts access to that variable to that local scope, but extends its lifetime to the lifetime of the program.void f() { static int i = 0; i++; std::cout << "i: << i << std::endl; } int main() { f(); f(); f(); } Terminal Output: i: 1 i: 2 i: 3 - Often use of local
staticvariables are discouraged. - One possible use case is for Singleton classses, i.e. a class that should only have one instance in existance.
- Type deduction (use of the
autokeyowrd) allows C++ to become a psuedo weakly-typed language. - When to use
auto?- For very long types that are annoying to write out and you don't want to
usingto create an alias. - For iterators in for loops:
std::vector<std::string> strings; // This is ugly and long for (std::vector<std::string>::iterator it; strings.begin(), it != strings.end(), it++) { // do something } // This is much cleaner and easier to read for (auto it; strings.begin(), it != strings.end(), it++) { // do something }
- For very long types that are annoying to write out and you don't want to
- If you need a reference, use
auto&. - From the Google C++ Style Guide:
- "Use type deduction only to make the code clearer or safer, and do not use it merely to avoid the inconvenience of writing an explicit type."
- ✅:
auto foo = std::make_unique<Foo>();- it's obvious thatfoois of typeFoo. - ❌:
auto foo = myWdigetFactory();- the type offoois unclear.
- Time to get accustomed with the standard template library (STL)!
std::vectoris actually a dynamic array (list) - dynamic in the sense that it can be resized, e.g. extended, appended to, etc.- When you exceed the allocated size of a particular
std::vectorinstance, it creates a new array in memory, copies the contents of the original vector into the new vector and deletes the original vector. In practice, this re-allocation can occur quite often and can result in performance losses. - Dynamic arrays store their memory contiguously (in line, not fragmented in memory). This is optimal for any operation that requires iterating over the vector.
- When this can become non-optimal is when you anticipate that your vector will need to be resized frequently because this will require copying the objects themselves over and over. Moving instead of copying largely solves this issue, but not entirely because there is still some copying involved.
- Question: Should I be storing pointers to heap-allocated objects in my vectors (lists), or should I store the stack-allocated objects themselves?
- Answer: It depends. The primary consideration is that it is technically more optimal to store the objects themselves in the list because storing the objects themselves requires that the memory allocated for those objects is inline (contiguous). A vector of pointers can be optimal in the case when that vector may need to be resized frequently.
- Best Practice: Prefer passing dymanic arrays by
constreference to avoid uncessary copying. - The
sizeof(std::vector<T>)is independent of the number of elements currently stored in the vector.- It's usually a small, fixed size, often
24or32bytes on 64-bit systems. - This is because
std::vectoris implemented as three pointers: one to the beginning of the vector, one to the end, and one to the current capacity of the allocated storage.
- It's usually a small, fixed size, often
std::arrayallocates its memory on the stack, whereasstd::vectorallocates its memory on the heap.
- We can use
std::vector.reserve(n)to allocate enough memory fornobjects without actually wasting time constructing those objects before we're ready to push them onto the vector. - We can also use
std::vector.emplace_back()to construct the object being added to the vector in place (at the location in memory allocated by the vector) as opposed to creating it in the local stack frame and then having to copy it to the memory location allocated by the vector as is done bystd::vector.push_back().
-
Static arrays have a pre-defined size and their size cannot be changed.
-
Very similair to C-style arrays:
int arr[10];can be rewritten as a static array likestd::array<int, 10> arr;. -
Question: How do we take a
std::arrayas a function argument if we don't know the size?// Template that doesn't assume type or size template<typename T, size_t s> void f(std::array<T, s> arr) { for (const auto& item : arr) { // do stuff } } // Template that assumes the type (and int array), but not the size template<size_t s> void f(std::array<int, s> arr) { for (const int& item : arr) { // do stuff } } -
std::arrayallocates its memory on the stack, whereasstd::vectorallocates on the heap. -
Best Practice: Prefer
std::arrayto old C-style arrays.std::arraysupports STL features likesize()and iterators (begin()andend()).- Can apply STL algorithms like
std::sorttostd::array. - No additional performance cost associated with
std::arraysince thesizeis stored as a template argument.
-
The following table is an overview of the differences between
std::vectorandstd::array.
| Feature | std::vector |
std::array |
|---|---|---|
| Allocation | Heap for elements, stack for container object | Stack (usually), or static storage |
| Size | Dynamic, can change at runtime | Fixed at compile time |
| Resizing | Possible | Not possible |
| Memory Overhead | Higher, due to managing heap allocation | Low, only stores elements |
| Access Speed | Slightly slower to to possible indirection | Fast, direct access to elements |
| Use Cases | Variable-size data, flexibilty required | Fixed-size data, performance-critical code |
- n-dimensional arrays (when to use them, and when not to use them!)
- An array is actually just a pointer to the beginning of the array. Extending that concept to a 2D array would mean that a 2D array is actually just an array of pointers, where each pointer is the starting location of a single array of the larger 2D array.
- Allocating a 2D array might look something like
int** arr_2d = int*[50];. We can readint**as(int*)*or "a pointer to a collection of integer pointers". Each element ofarr_2dwill be an integer pointer, so we can do something likearr_2d[idx] = nullptr; - Deleting a multidimensional array isn't trivial either. Say we declare the following 5x10 2D array then attempt to delete it:
int** arr_2d = new int*[5]; for (int i = 0; i < 5; i++) { arr_2d[i] = new int[10]; } delete[] arr_2d;
That will only delete the array of pointers pointing to each of the 50 arrays of integers. The integers themselves will not be deleted and become a memory leak. What we have to do to delete a 2D array without leaking memory is:
for (int i = 0, i < 5; i++) {
delete[] arr_2d[i];
}
delete[] arr_2d;
- In the example above, there is no guarantee that each of the 5 blocks of 10 integers will be contiguous in memory. Which can make our array slower to iterate over than an array that had all 5 blocks of 10 integers allocated contiguously in memory.
- Best Practice: Because of this issue, it may not be a good idea to use 2D arrays for things like images or textures where we want access to each pixel to be fast. So instead, prefer to store an image as a 1D array and be smart about how you iterate over it.
- A Union is a bit like a class or struct type, but it can only occupy the memory of one member at a time. In other words, ... (these are kind of confusing).
- "Put differently, a union of multiple members places each member at the same starting address. This greatly saves on memory used, but the downside is that you can only use one member at a time because they all start at the same address. As you can imagine these were enormously helpful in the 90's when memory was limited. They still have use cases today, but you have to be careful because it can feel like you're working with separate unique variables when in reality you're working with different variables all occupying the same starting address. Unions can tend to trip you up if you're not careful leading to really wacky bugs but they can be a powerful tool if used seldomly and correctly."
- Useful for when:
- We want to give two different names to the same variable, e.g. it may be useful to think of a three-element vector (x, y, z) as a color (RGB) where x maps to R and so forth.
- Iterators are used to traverse data structures, and if we're writing our own data structures, we probably want to support functionality like idexing and iteration.
- Range-based for loops (available since C++11) are made possible by iterators.
std::vectorimplements both thebegin()andend()functions that each return an iterator that points to a particular position (the beginning and the past-the-end element) in the vector.std::vector<int> values = { 1, 2, 3, 4, 5 }; for (int& val : values) { std::cout << "value: << val << std::endl; } - Traversing a container by using its iterator explicitly is also possible, but less common because a range-based iteration is essentially shorthand for this. But in some situations, e.g. erasing or inserting new elements, you may want to manipulate the iterator.
for (std::vector<int>::iterator it = values.begin(); it != values.end(); it++) { // Dereference the iterator (bc it's a pointer) to get the value std::cout << *it << std::endl; } - See
app/93_iterators.cppfor more examples, including iteration over non-indexable types, e.g. an unordered map (dictionary), via structured bindings.
- Maps allow us to associate a key-value pair (a dictionary in Python).
std::mapis an ordered map that is a "self-balancing binary search tree," typically a "red-black" tree.- In a tree data structure, elements are sorted via comparison (typically using a less than operator). That way, when you iterate over a map, you're iterating over the elements in a sorted order.
std::unordered_mapis a hash-table. "It uses a hash function to hash the key and generate an index to figure out what which "bucket" your value is in." - Cherno. Because it is unordered, value retrieval has the potential to be faster than withstd::map.- Best Practice: Because of this performance difference, prefer using
std::unordered_mapoverstd::mapunless you need your elements to be sorted. - Requirements of the key type choice:
std::unordered_map: The key must be hashable. Note that pointers are always hashable because a pointer is simply a 64-bit integer, i.e.T* == uint64_t.- A hash function is not required to return a unique hash. Two things with the same hash will be stored in the same bucket, which is inefficient for lookups, but ut hash collisions will not compromise the map.
std::map: The key type must implement the less than operator,operator<. The less than operator not only plays the role of comparison for an ordered map, it also defines a unique key within the map. So if we attempt to add a new element that evaluates as "equal to" an existing element in the map, that new element will not be added.
- Note that the index
[]operator for a map works strictly as an insertion operator. There is noconstversion of the index operator, i.e.[]works only as a setter, and not as a getter.std::unordered_map<std:string, CityRecord> cities; // The following will return a reference to the value stored by the "Berlin" key // if it exists, otherwise, it will create the "Berlin" key with a possibly // non-initialized CityRecord instance. CityRecord& berlin_data = cities["Berlin"]- This can actually be useful in terms of creating an object in-place rather than having to create it in the local stack frame and then copy it into the map.
- If we want to retrieve data without inserting it, we need to use
at(). - Best Practice: Prefer
at()for retreiving map elements because it works for both non-constandconstmaps.
- Iteration: recall that
std::vectorstores its data in contiguous memory and this improves the efficiency of iterating over a vector. Iterating over the elements of a vector will always (typically by an order of magnitude or more) be faster than iterating over the elements of a map.- Note that it is not guaranteed that the elements of a
std::unordered_mapwill be kept in the order in which they were inserted.
- Note that it is not guaranteed that the elements of a
- Will be taking a look at the lifetime of stack-based variables.
- Each time a new scope is entered, a new stack frame is pushed onto the stack. The stack frame consists of any variables declared within that scope (and possibly other data?) When the scope is exited, that stack frame is deleted and the memory on the stack is freed.
- What is meant by scope? Basically anything declared within
{}. - All stack-based variables/objects have a scope-based lifetime, i.e. once a stask-based variable/object goes out of scope, it's memory is freed.
- Common mistake: Attempt to create a stack-based variable within a function and then return a pointer to that variable. Once the function returns and that variable goes out of scope, that variable no longer exists and the pointer that is returned now points to a freed memory location that doesn't contain the data we expect it to.
int* create_array(const int size) { // This creates an array on the stack (which is scope-based) int array[size]; return array; } - How can take advantage of the lifespan of stack-based (scope based) variables? Yes. "Scoped" classes like smart pointers and scoped locks take advantage of this.
- A Smart Pointer is effectively a wrapper around a raw pointer that heap-allocates some memory on creation and then deletes that pointer upon destruction (when the smart pointer itself goes out of scope?)
- Mutex Locking: In the context of threading, a scoped mutex lock allows us to "lock" a function upon entry (and unlock at exit) such that mutliple threads cannot access the function (and also the data manipulted by that function?) at the same time.
-
std::unique_ptr,std::shared_ptr,std::weak_ptr -
newallocates memory on the heap anddeleteus used to free it. -
Smart pointers are a way to abstract the the
new/deleteparadigm away. Some programmers even go so far as to say you should never use thenewanddeletekeywords. -
Smart pointers are effectively wrappers around raw pointers.
-
When you make a smart pointer, it will call
newand allocate memory, and then (based on which type of smart pointer you use) that memory will automatically be freed when the smart pointer goes out of scope. -
A
std::unique_ptris a scoped pointer that cannot be copied. -
A
std::shared_ptrstores a reference count and the object will only be deleted when that reference count goes to zero. Each time a newshared_ptris made to an existing object, the reference count increases by one. -
A
std::weak_ptrdoes not increase the reference count. Having aweak_ptrto an object is basically a way of saying "I want to know if this object exists, but I don't want to be the reason that it exists or continues to exist." -
The rules for applying
constto smart pointers and their underlying types looks similar to those same rules for raw pointers.Smart Pointer Declaration Raw Pointer Declaration Analogous Declaration shared_ptr<T>T *- const shared_ptr<T>const * T- shared_ptr<const T>T const *const T *const shared_ptr<const T>T const * constconst T * const
- The Stack has a much smaller pre-defined size (~2MB), whereas the Heap is much larger. Both exist in RAM, however the Stack may be hot in the cache because it is being accessed more frequently.
- Each program/process has its own Stack and Heap.
- Each thread will create its own stack, but the heap is shared among threads (hence the need for thread-safety in multi-threaded applications).
- Stack vs. Heap allocation:
// Allocate an int and an int array on the stack int value = 10; int array[10]; // Allocate an int and an int array on the heap int* heap_value = new int; *heap_value = 10; int* heap_arr = new int[10]; // Must manually free heap-allocated memory delete heap_value; delete[] heap_arr; - Reminder: The lifetime of a stack-allocated variable is scope-based - whenever a scope is exited, all stack-allocated memory within that scope is freed.
- Freeing memory on the stack is the same thing as resetting the stack pointer back to the beginning of the stack.
- Heap allocation via the
newkeyword effectively callsmalloc()under the hood and returns a pointer to a free portion of memory that is maintained by the free list. - Heap memory
==dynamic memory. - Takeaway:
- Allocating memory on the stack is effectively one CPU instruction, whereas allocating on the heap is much more expensive: call
new-> callmalloc-> consult the free list_ -> update the free list -> ... -> eventually delete the memory. - The performance difference is the allocation. Access after allocation is approximately equivalent (cache misses for heap-allocated memory can be the difference here).
- Allocating memory on the stack is effectively one CPU instruction, whereas allocating on the heap is much more expensive: call
- Safe programming aims to prevent things like crashes, memory leaks (forgetting to free heap-allocated memory) and access violations. This video will focus on pointers and heap allocation.
- Why do we care? Because we want to write real-time, performance-critical production C++ code.
- With C++11, smart pointers were introduced to support this goal. In reality, the entire goal of smart pointers is to automate the use of
delete. - Note:
shared_ptris not thread safe. Why?
- TODO:
Video #105 - Weak Pointers in C++ (std::weak_ptr)
- "Weak pointers - the pointers for those of you who are just not quite strong enough to use proper pointers." - Cherno
- Weak pointers,
std::weak_ptr, are intended to be used with shared pointers,std::shared_ptr. - Shared pointers (
std::shared_ptr) refresher:- Recall that a shared pointer is a wrapper around a raw pointer to a heap-allocated object that implements a reference counter.
// Create a new heap-allocated Object managed by a raw pointer Object* obj = new Object(); // Instead, use a shared pointer to automate the lifetime management (scope) of the Object instance. std::shared_ptr<Object> obj1 = std::make_shared<Object>(); - The real magic of
std::shared_ptris that the object that the shared pointer manages will not go out of scope (be deleted) untill every reference to that shared pointer has gone out of scope. - Shared pointers maintain a strong reference to the object that they're managing, i.e. the reference is strong enough to keep the underlying object instance alive (until the reference counter reaches zero).
- Recall that a shared pointer is a wrapper around a raw pointer to a heap-allocated object that implements a reference counter.
- One strong use case for weak pointers is in the case of cyclical references, i.e. when you have 2 or more classes that maintain references to instances of the other class(es). If we choose to use
std::shared_ptrto maintain those cyclical references then the instances ofAandBthat we create will never go out of scope (their destructors will never be called):// Forward declare B struct B struct A { std::shared_ptr<B> ptr_b ~A() { std::cout << "A destroyed!" << std::endl; } } struct B { std::shared_ptr<A>ptr_a ~B() { std::cout << "B destroyed!" << std::endl; } } int main() { std::shared_ptr<A> a = std::make_shared<A>(); std::shared_ptr<B> b = std::make_shared<B>(); a->ptr_b = b; b->ptr_a = a; } - (from YouTube comments) "Writing your own smart pointers is probably one of the best learning exercises for C++ in my opinion... it teaches you a lot about templating, object lifetime and operator overloading, just to scratch the surface."
- (from YouTube comments) "The ability to write your own smart pointer should be a fundamental skill that one learns when learning C++. Knowing how to do this teaches you RAII, which is useful for many things besides memory management. It also forces you the better understand the overhead of managing memory. If you can write a smart pointer for memory, you can do the same for managing any other resource as well."
- See
app/105_weak_pointers.cppfor a more detailed example.
- How to deal with tuples and pairs.
- In C++, a function can return only one value.
- Option 1: One way to get around this is to have your function return
voidand instead pass in references to the objects you want to assign and set them via reference instead of actually returning anything.void f(std::string& out_str1, strd::string& out_str2) { out_str1 = "one"; out_str2 = "two"; } int main() { std::string a, b; f(a, b) } - Option 1b: As an alternative to passing by reference, we can pass a pointer, and this allows us the addional option of passing
nullptrwhen we don't actually want to set that value.void f(std::string* out_str1, strd::string* out_str2) { if (out_str1) { out_str1 = "one"; } if (out_str2) { out_str2 = "two"; } } int main() { std::string a, b; f(nullptr, &b) } - Option 2: We can return a heap-allocated
std::array. Not really great option. Cherno isn't a fan of this method, or of usingstd::array.#include <array> std::array<std::string, 2> f() { return std::array<std::string, 2>("one", "two"); } int main() { std::string* result_array = f(); } - Option 3: Using Tuples and Pairs.
- Ugly because accessing members of a tuple can only be done via
std::get<index>(tuple_var)ortuple_var.firstand we may want to be more explicit that this and actually name the members of the tuple for the sake of code readabilty.
#include <tuple> std::tuple<std::string, std::string> f() { return std::make_pair("one", "two") } int main() { std::tuple<std::string, std::string> result = f(); // Now get the first (index zero) string from the tuple (2 options) std::string first = std::get<0>(result); std::string first = result.first; }- Note that
std::get<T>(std::tuple)can only be used on a tuple which has exactly one element of typeTin it.
- Ugly because accessing members of a tuple can only be done via
- Option 4: (Cherno's favorite) create a struct (a named tuple in Python) that excplicitly contains the items that we want to return.
struct FileInfo { std::string directory; std::string filename; std::string extension; } FileInfo f() { // Take advatage of "implicit" conversion to create the FileInfo object return {"local_dir", "my_file", "txt"}; } int main() { FileInfo file_info = f(); filename = file_info.filename; } - Refer to Structured Bindings for a better/cleaner/more modern way of dealing with multiple return values.
- A template allows us to get the compiler to write code for us based on a set of rules.
- A template is not a class or a function. A template is a pattern that the compiler uses to generate a family of classes or functions.
- In
template<typename T>the keywordstypenameandclasscan almost be used interchangeably. Buttypenameis preferred. - At compile-time, a templated function only gets created (and linked) for the particular types that it's actually called within the source code.
- If a templated function is never called in your code, the the compiler never actually creates any versions of the function, and it's possible to have errors in a templated function that can go undetected.
- The STL (Standard Template Library) is a collection of standarized templated classes.
- Best Practice: When to use templates:
- Logging systems, buffers than need to contain various types.
- Best Practice: When not to use templates:
- ...
- Macros allow us to use the pre-processor to automate, "macro-ize", some aspects of our code.
- All
#statements are known as preprocessor directives. The Preprocessor step comes before compilation. - Templates v. Macros:
- Templates get "evaluated" at compile-time.
- Macros are evaluated in the preprocessor step and strictly consist of "pure text replacement" which comes before compilation.
- Cherno doesn't like overusing macros.
- TODO: Finish adding a command line CMake variable to enable/disable macros in my code.
- This video will focus on C-style raw function pointers.
- Function pointers allow us to assign a function to a variable. Allows us to apply logic to functions and pass functions as arguments to other functions.
- Since functions are effectively just a set of instructions stored somewhere within our binary (executable), the start of those instructions will have a memory address. Thus, we can create a function pointer like:
- Lambdas are functions that are anonymous functions that are declared inline that can be useful when you don't want to define a separate function.
- Anywhere that you use or require a function pointer in C++, you can use a lambda instead. But when is it good idea to do so?
- Declaring a lambda can look like:
auto lambda = [ captures ]( params ) { body }; auto lambda = [ captures ] { body }; - Captures:
[a, &b]capturesaby copy andbby reference.[this]captures the current object (*this) by reference.[&]captures all automatic variables used in the body of the lamda by reference and the current object by reference if it exists.[=]captures all automatic variables used in the body of the lamda by copy and the current object by reference if it exists.[]captures nothing.
- Lambda are also known as a closure. Closures are unnamed function objects. Hence, we need
autoto deduce the type of a closure. We don't know its type but the compiler does. std::functionis a general-purpose polymorphic function wrapper.- Lambdas closures as function objects can be converted to their respective
std::functionobjects:std::function<void(int, int)> f = [](int some, int some2) { // do something }
- Lambdas closures as function objects can be converted to their respective
- Lambdas can also be declared like:
where the return type is inferred from the type of the operance of its return statement, in this case, the return type is
auto L = [](auto flag) -> auto { return flag ? Foo{"a"} : Fpp{"b"}; };Foo.
- Standard
Cdoes not support namespaces and so often times in C and C++ compatible libraries, like OpenGL, you'll see function names that have prepended the library name, e.g.gl_get_vertex() - Namespaces exist to avoid naming conflicts. That's really it.
::is the scope resolution operator.- Classes are themselves namespaces.
- You can create an alias for a namespace that may be too long to use efficiently, e.g.
using rlln = ReallyLongLibraryName; - Namespaces can be nested, e.g.
outer_ns::inner_ns::func() - Can do
namespace ns_name::namesince C++17. - Best Practice:
- Avoid
using namespace stdat all costs! - If you must
using namespacetry to confine it to as small a scope as possible. Only as a last resort, use it at the top-level of a file. - NEVER
using namespacein a header file! This is an easy way to create naming conflicts.
- Avoid
std::sortis C++'s built-in sorting algorithm.- See
app/sorting.cppfor an example usingstd::sort.
- Even though C++ is a strongly-typed language, type punning is just a fancy way of getting around the type system of C++.
- Something that C++ is really good at is raw memory operations (memory maipulation), and we can take advantage of that to do some kind of ridiculous operations. See
app/type_punning.cppfor an example. - This can get us into trouble most of the time, but it can also be incredibly powerful. For example, type punning is used in Quake's notoriously fast inverse square root function - it converts a float into a long in order to use bit manipulation.
- Virtual destructors are useful when dealing with polymorphism.
- Remember the order of operations when it comes to instantiating and destroying an instance of a derived class - what we'll notice for the derived class is that the Base constructor is called first, followed by the Derived constructor. When the Derived instance is destroyed, the reverse order occurs.
- In order to guarantee that the Base destructor is called, we can mark the Base destructor as
virtual(seeapp/virtual_destructors.cppfor an example). - Best Practice: When creating an class that we intend to be extended, i.e. derived from or sub-classed, we need to declare its destructor as
virtual. Otherwise, we cannot safely extend that class in any case where we want to treat the derived class as if it were an instance of the base class, e.g. when passing a derived object to a function that may delete the derived instance via a base class pointer. - Best Practice: When should we not use a
virtualdestructor?- When we have no intention on deriving from the class.
- The class will never be instantiated on the heap.
- No intention to delete an derived instance via a base-class pointer. (But how can know that the codebase won't be extended in some way in the future in such a way that would delete a derived instance via a base class pointer?)
Guideline #4: (Herb Sutter) "A base class destructor should be either
publicandvirtual, orprotectedand nonvirtual."
- C-style vs. C++ style casting. C style casts are simple and look something like:
double a = 5.25; double b = (int)a + 5.3 // b = 10.3 - C++ style casts are different and include
static_cast,reinterpret_cast(i.e. type punning),dynamic_castandconst_cast. C++ style casts don't actually do anything that C-style casts can't achieve, but they are more or less syntactical sugar on tope of C-style casting. static_casthelps to avoid removing constness that can be easily done through C-style casting.- Say we have multiple derived classes from a single base class, and we're using a base class pointer to deal with the derived objects. A
dynamic_castcan help us determine which derived type we're actually working with:class Base { ... } class Derived : public Base { ... } class AnotherDerived : public Base { ... } int main() { Derived* derived = new Derived() Base* obj = derived; // 'another' will evaluate to NULL because obj is not a AnotherDerived instance Derived* another = dynamic_cast<AnotherDerived>(obj); }
- Dynamic casting is a C++ style cast that acts as somewhat of a "safety net" which ensures that the casting we're doing is "valid".
dynamic_castcan only be used when Runtime Type Information (RTTI) is enabled.- If RTTI is enabled, dynamic casting happens at runtime, not at compile time which means it comes with some performance cost.
- Other managed langagues, like Python, have built-ins like
isinstance()to achieve the same behavior. - See example from Video #69 README for how the failure of a
dynamic_castcan be useful.
- Structed Bindings are a new feature to C++17 that allow us to handle multiple return values in a cleaner way.
- Structured bindings allow us to "cleanly" return things like Tuples and Pairs.
- In Video #52, Cherno said that he preferred returning an instance of a struct that contains the members he wanted to return, but his opinion has changed somewhat to prefer returning tuples and/or pairs via structured bindings.
- Recall that when returning a
std::tuple(orstd::pair), accessing the tuple members was a bit ugly, and we had to usestd::getand an index that wasn't very human-readable:std::tuple<std::string, int> create_person(std::string name, int age) { return { name, age }; } std::tuple<std::string, int> person = create_person("Cherno", 24); std::string& name = std::get<0>(person); int age = std::get<1>(person); std::tieoffers a slightly cleaner implentation that we didn't touch on before, but returning a struct with named members is still probably a better idea than this.std::string name; int age; std::tie(name, age) = create_person("Cherno", 24);- This is where structured bindings come to the rescue!
auto [name, age] = create_person("Cherno", 24); - The advantage with using structured bindings is that we don't necessarily need to create all sorts of unnecessary struct types that may only be used in a handleful of places. This allows us to declutter our namespaces and remove unnecessary types.
- See
app/93_iterators.cppandapp/100_maps.cppfor examples of using structured bindings.
- A singleton (a type of design pattern) is a class (or struct) that you intend to only ever have a single instance of.
- Examples of types of singleton classes:
- A random number generator: Typically, we instantiate a random number generator once with a seed and then use that single instance (and seed) to generate sequences of random numbers.
- A renderer: In a rendering or scene generation application, typically a single renderer will render all renderable objects (point sources, meshes, etc.) in the scene for each frame.
- Singleton classes really just behave like a namespace, i.e. a single class is just a set of "global" variables and static functions that may (or may not) act upon those global variables.
- Worth noting one important functional difference between a namespace and a singleton class. In a namespace, all of the data is initialized (loaded into memory when the program first starts). This might be undesirable sometimes when we don't want all the data initialized until we actually need it. In contrast, singleton instances are only loaded during the first call to the
Get()method. - See tamasdemjen4242's comment for even more detail on the functional difference between singleton classses and namespaces, including thread-safe vs. non-thread-safe behavior.
- See
app/singleton.cppfor an example.
- Consider the following simple example. What do we think will be printed? It turns out that in C++, this toy scenario results in undefined behavior, i.e. the behavior will be different from compiler to compiler.
void print_sum(int a, int b) { std::cout << a << " + b << " = " << a + b << std::endl; } int main() { int val = 0; // Any of the following will produce "undefined behavior". print_sum(val++, val++); print_sum(++val, ++val); } - Note that the C++ compiler can evaluate certain expressions at compile-time, e.g. the C++ compiler is smart enough to replace
int a = 1 + 2;withint a = 3;at compile-time rather than do the sum operation at runtime. - In C++17, the C++ standard added rules for the evaluation of postfix-expressions, e.g. the post-increment operator
++, that state that multiple post-fix expressions must be evaluated sequentially (rather simultaneously at compile-time) but this rule doesn't actually lead to a deterministic evaluation ofprint_sum(val++, val++);.
- TODO
- TODO
Video #101 - What Exactly is NULL?
- In managed languages like C# or Java, if you try to use an object that's
NULL, you'll get aNullReferenceException(C#) or aNullPointerExeption(Java). That's because these languages do some "hand holding" to help you from fucking up too badly. But what about C++? - What is the value of
NULLin C++?void* value = nullptr;- If we take a look at the value of
value, we'll see that it's0x00000000, i.e. 8 bytes of all zeros. - Why 8 bytes? Because this example was built on a 64 bit system, and any pointer (or memory address) on a 64 bit system will always be 8 bytes. On a 32 bit system, pointers are 4 bytes.
- What about
NULL(as opposed tonullptr)?NULLis from C, however, it's totally acceptable to use it in C++ too.- Let's look at the definition of
NULL:#ifndef NULL #ifdef __cplusplus #define NULL 0 #else #define NULL (void *)0) #endif #endif - So really,
NULL(in C++) is just an integer defined as0. - But be careful, because
0and0x00000000are not the same thing. One is an integer (not a valid memory address) and the other is a memory address (a pointer).
- Let's look at an interesting example that will teach us more about the role of the C++ compiler. Consider the code from the
app/101_null.cppapp.class Entity { public: Entity() = default; const std::string& name() const { return name_; } void print_type() { std::cout << "Entity\n"; } private: Entity* parent_; std::string name_; };- The code above is what we're used to in C++. But to understand the role of the compiler better, it's useful to understand how this class would be implemented in C (where classes do not exist).
- In C, since there are no classes, we would create
EntityDataas a struct and then implement its member functions separately because structs in C cannot have member functions. - Surprisingly, this separation of data and member functions is exactly what the C++ compiler creates for us!
- In C++, the compiler converts member functions of a class to "regular" free-floating functions (that exist outside of the class) that take in an instance of the class as their first argument. That instance is referred to as the
thiskeyword!const std::string& name(const EntityData* self) { return self->name_; } void print_type(EntityData* self) { // Note that if self == nullptr, this has no effect below std::cout << "Entity\n"; } - So now we understand that when we do the following, we're not actually calling a function that exists within with Entity class. We're actually calling out to a stand-alone function at some location within our compiled binary, and calling that particular function is totally valid and will not crash since
selfis never dereferenced by theprint_type()member function.Entity* entity = nullptr; entity->print_type();
Video #103 - Conversion Operators in C++
- Let's consider a simple example.
struct Orange { operator float() { return 5.5f; } } void print_float(float value) { std::cout << value << "\n"; } int main() { Orange orange; // Conversion operators allow for the following to work print_float(orange) std::cout << float(orange) << std::endl; } - (from YouTube comments) "Pretty much 99% of conversions operators should be marked
explicit(even boolean ones) to prevent weird bugs and unexpected conversions from happening." - (from YouTube comments) "Like with single argument constructors, the default should be to add the
explicitkeyword and only omit if absolutely necessary." - See
app/103_conversion_operators.cppfor more detailed examples.
- Parallelization!
- Use
std::threadto start some process, e.g. call a function, in a new thread. - Use
std::thread::jointo wait for a worker thread to complete before resuming execution of the current thread, i.e. the thread that the worker thread was kicked off from. - See
app/threading.cppfor an example.
- Since C++11, we have
std::chronoto help us with timing, i.e. understanding how much time has elapsed between various lines of code. - See
app/threading.cppto see how theTimerstruct is used as a scope-based (lifetime based) timer to function profiling. - The topic of benchmarking will come later and will be more in depth. Will discuss instrumentation, i.e. modifying source code to contain profiling tooling.
- Performance differences between
std::make_sharedvs.std::shared_ptr<type> (new type)vs.std::make_unique. - Important to perform this benchmarking in Release mode and not Debug.
- Note that we expect
std::make_sharedto be faster thanstd::shared_ptrbecausestd::make_sharedperforms one heap-allocation, whereas calling thestd::shared_ptrconstructor performs two. But as per the results ofapp/benchmarking.cpp, this isn't always true.
- How can we take advantage of parallel processing, i.e. use multiple CPU cores?
- MSVS supports an interesting visualization called "Parallel Stacks".2
- The following is an example snippet from Cherno's game engine that will asynchronously load a list of textures in parallel.
#include <future> #include <string> static std::mutex s_meshes_mutex; // Note that filepath is passed by copy because there's a possibility that the // 'mesh_filepaths' variables from 'EditiorLayer::LoadMeshes()' may have gone // out of scope while this function is still being called asynchonously. static void load_mesh(std::vector<Ref<Mesh>>* meshes, std::string filepath) { auto mesh = Mesh::load(filepath) // Q: Can we actually 'push_back' onto a vector concurrently? No, this is // where "mutuex" and "locks" come into play in order to "lock" our resource // (the vector) while one thread accesses/modifies it to prevent another // thread from doing the same. std::Lock_guard<std::mutex> lock(s_meshes_mutex); meshes->push_back(mesh); // Note that std::Lock_gaurd is scope-based and will "release" the lock on // the s_meshes_mutex once the lock goes out of scope (the end of this // function). } class EditorLayer : Layer { public: enum class PropertyFlag { None = 0, ColorProperty = 1 }; EditorLayer(); virtual ~EditorLayer(); ... private: std::vector<std::future<void>> m_futures; } void EditiorLayer::LoadMeshes() { std::ifstream stream("data/models.txt); std::string line; std::vector<std::string> mesh_filepaths; while (std::getline(Stream line)) { mesh_filepaths.push_back(line); } // Load each mesh in serial for (const auto& filename : mesh_filepaths) { m_meshes.push_back(Mesh::load(file)); } // Load each mesh in parallel for (const auto& filename : mesh_filepaths) { // std::async actually returns a std::future object, and we need to store // that "future" object. Why? ... m_futures.push_back( std::async(std::launch::async, load_mesh, &m_meshes, file)); } }
- TODO:
- TODO:
- New to C++17 is
std::optional. std::optional<T>cannot be used with references. However,boost::optional<T>can handle references. This is very helpful if you want to check for existence of a bigger type in a map likeboost::optional<const Element&> GetElementAtPos(int x, int y). Withstd::optionalyou cannot do that and would have to return a pointer. The reason why it's unavailable instdis a bigger topic itself and you can read more online.value_or(default_value)is also designed with backwards compatibility in mind. If new code parts want to usestd::optional<T>, but need passTto an old method (where a magic value represents "not set") you can passvalue_or(magic_value)to it to still be compatible with this old code part.boost::optionalbrings this concept even further and provides a constructorboost::optional<T>(bool isSet, T value)that can be used to filter out magic values on construction. So if an old method returnsT(with either a value or a magic value "not set") you can initiate your variable likeboon b.st::optional<T>(result != magic_value, result). This constructor is sadly not available instd::optionalthough.
- New to C++17 is
std::variant. Similarstd::optionalin the sense that is allows us to not worry so much about the underlying data type, and be more concerned with if that data is actually available or not. - Allows us to create a variable that can be one of multiple types, e.g. we can declare a value that will either be a string or an int:
#include <variant> std::variant<std::string, int> data; - We can use
std::variant::indexto determine which type the data actually is. In the example abovedata.index()=0means thatdatais astd::stringanddata.index()=1meands thatdatais anint. - Alternatively, we can use
std::variant::get_ifto return a pointer to our data that will beNULLif the data is not the type we requested.auto value = std::get_if<std::string>(&data); - Are variants just "type-safe unions"? Short answer: not exactly. The
sizeof()a union will be equal to the size of its largest type, whereas the size of a variant will be the combined size of all of its types, e.g.sizeof(std::variant<std::string, int>) == sizeof(std::string) + sizeof(int). - Best Practice: Prefer variants to unions because they are type safe.
- Could use
std::variantas an alternative tostd::optionalwhen we want to be more specific about what may have gone wrong when evaluating a function. Seeapp/optional.cppfor an example of usingstd::variantto possibly return an error code enum type.
- New to C++17 is
std::any. We can use it store any type of data in a single variable (technically possible with avoid*, but this is a C++17-safe way of doing it). - Remember,
std::variantis effectively a type-safestd::union, but they differ in size. However,std::anybehaves differently for "small" and "large" types. For small types,std::anystores its data as if it were a union, but for large types (< 32bytes on MSVC),std::anywill perform a dynamic memory allocation to store the larger data type (unecesary heap alloccations are something we want to avoid). - Best Practice: Probably don't ue
std::any. "If you need to store multiple data types in a single variable, usestd::variantbecause it's type-safe and it won't perform dynamic memory allocation. If you actually need a variable that can store any type of data, probably rethink you're program design." - Best Practice: "Use
std::anywhere in the past you would have usedvoid*orshared_ptr<void>(which solves the problem of lifetime management thatvoid*has). Which is to say, ideally, almost nowhere." - StackOverflow - Further discussion
- (and also l-value and r-values references).
- Often (but not always) an l-value is on the left of the
=sign and an r-value is on the right of the=sign. For example, inint i = 10,iis an l-value and10is an r-value. - An l-value has a location in memory, and an r-value is simply a temporary value that has no memory allocated to it. An r-value can be a literal, like
10or it can be the return value of a function. In all cases, we cannot assign another r-value to an r-value. - You cannot create an l-value reference, e.g.
int&from an r-value. You can only create an l-value reference from an existing l-value. - A special rule (related to
const): You can create aconstl-value reference from an r-value, e.g.const int& val = 10;. This allows us to pass either an l-value or an r-value to a function like:void set_value(const int& value) { // do something with value } int main() { // Create an l-value (i) int i = 10; // Call set_value with an l-value set_value(i); // Call set_value with an r-value (only possible because set_value takes a // const l-value reference) set_value(10); } - Let's look at one more example where
firstandlastare l-values, butfirst + lastis an r-value because it is a temporary object that gets created and then assigned to the l-valuefull.void print_name(const std::string& name) { std::cout << name << std::endl; } std::string first = "Yan"; std::string last = "Chernikov"; // Assign the r-value "first + last" to the l-value "full". std::string full = first + last; print_name(full); // Only works if print_name accepts a const l-value reference. print_name(first + last);- Note that a function that accepts both l-values and r-values MUST be written to accept a
constl-value reference. This is why you'll see a lot of const references being used in C++. - Do we have a way to write a function that only accepts temporary objects (r-values)? Yes! We need to use something called an r-value reference. We can modify the code above such that
print_nameaccepts an r-value reference.
void print_name(std::string&& name) { std::cout << name << std::endl; } // The line below will throw the error "An rvalue reference cannot be bound to an lvalue." print_name(full); - Note that a function that accepts both l-values and r-values MUST be written to accept a
- Being able to distinguish an r-value from an l-value is important in the context of move semantics and optimization. If we know that we are dealing with a temporary object (an r-value reference), then we don't have to worry about things like making sure we keep it alive, etc.
- C++11 introduced r-value references which are necessary for implementing move semantics.
- Consider the case where we need to create an object and then pass it to some function that will take ownership of that object. Prior to C++11, this would require us to create a "throw away" object in the current stack frame and then copy that object to the fuction that's receiving it. This creates unnecessary copying and modern C++ should allow us to avoid unnecessary copies.
- So how do we move an object rather than copying it?
- We must implement a move constructor for the class that we wish to support moving.
- We need to use
std::moveto invoke that class's move constructor. - See
app/move_semantics.cppand theStringclass intypes.hfor an implementation of a _move constroctor _and use ofstd::move.
-
The move constructor,
Type(Type&& other)is invoked when constructing a new object and passing it as an r-value reference. -
The move assignment operator is invoked when we want to move an existing object (an x-value near the end of its lifetime?) into another existing object.
- If we define a move constructor for our class, we should also define the move assignment operator. This is referred to as the Rule of Fifths. More on this later.
-
std::moveis really just a cast and is used in place of(Type&&)sourceto cast an l-value to an r-value (actually an x-value) and deduces the moved-from type at compile-time rather than requiring the user to cast the l-value to an r-value manually, i.e.(Type&&). -
std::moveandconst:- A
constobject, by definition, cannot be modified. This includes transferring its resources to another object, as that would essentially be modifying it. - Attempting to
std::moveaconstobject will result in a copy operation unless the move contructor for that type is explicitly deleted, then it will result in a compilation error.
const std::string str = "Hello"; std::string other = std::move(str); // This is a copy, not a move! - A
Add a section that groups together videos about workflow and debugging.
- The ethos: If you download my repo from github, that repo should contain everything you need for it to compile and run.
- This video: Learning to link against binaries.
- Static linking happens at compile time.
- TODO:
- TODO:
- TODO:
- TODO:
- How do we write better code, i.e. code that produces fewer bugs.
- How do we use a static analyzer to improve our code?
- TODO: Finish this video.
- Finally time to take what we've learned and write out own data structures, e.g. arrays, lists, sets, maps, trees, etc. The STL implements most of these data structures for us, but we can learn a lot by trying to implement our own (and maybe even make them fast/ more efficient than the STL data structures).
- In this video, we'll be implementing our own version of
std::array- a fixed-size, stack-allocated array data structure in C++. - Recall some differences between
std::arrayandstd::vector:std::vectoralways allocates on the heap and can be dynamically resized, whereas a fixed-sizestd::arrayallocates its memory on the stack.- Best Practice: If you don't need heap allocations, don't use them because they'll just slow things down.
std::vectorcan be dynamically allocated, i.e. its size does not have to be determined at compile time, but astd::arraymust define its size at compile-time:// This is just fine size_t size = get_size(); int* heap_arr = new int[size]; // This throws an error: "Expression must have a constant value." int array[size]; // However, we can use the size of an array to set another array's size std::array arr1<int, 10>; std::array arr1<float, arr1.size()>;
- See
include/array.handapp/custom_array.cppfor implementation and use of our custonArrayclass.
- STL's
std::vectorhas the following important characteristics:std::vectoris a re-sizeable array.std::vectoris heap-allocated (as opposed tostd::arraywhich is stack-allocated and fixed in size at compile-time).
- What do we need to implement our own vector (dynamic array) class?
- A pointer to the beginning of a block of heap-allocated memory.
- The ability to resize our vector when we run out of room to push back a new element. This requires allocating a new block of memory on the heap, copying over the contents of the current vector, and then freeing the memory that was copied from.
- Resizing strategies:
- Maybe revisit Video #47 - Optimizing the Usage of
std::vector. - Instead of copying, we can move the contents of the old vector into the newly resized vector.
- (13 -10) Note that in more sophisticated dynamic array (vector) implementations, you have the option of specifying a custom allocator that may not necesarily hit the heap each time it needs to resize. This is beyond the scope of this video, but it can be really useful if you're writing a custom piece of software.
- Maybe revisit Video #47 - Optimizing the Usage of
- Two options for adding elements to our Vector:
push_back: Used to add an element to the Vector container by either copying or moving. Moving should be preferred.emplace_back: Rather that constructing an instance of the element in the stack frame of the calling function and then moving it into ourVectordata storage, instead we construct the new element in place, i.e. in the memory that we've already allocated for it inVector::data_by simply taking the arguments that we'll pass to the constructor the element type.- Best Practice: Prefer placement
newfor truly constructing objects in place.
- Best Practice: Prefer placement
- Operator new and Operator delete: (
::operator newand::operator delete)- "A lot of care needs to be taken when you manually call the destructor of the objects in your container" - Cherno.
- We do this for both
Vector::pop_backandVector::clear. For a type that doesn't perform any heap allocation, like theVec3class, we won't really run into any issues calling~Vec3manually, but as soon as type that your container supports does do some sort of heap allocation, you can very quickly run into issues. - "When you write
p = new T[N], the compiler generates code that callsoperator new[]to allocate enough memory forNobjects of typeTplus whatever book-keeping information it needs. When you subsequently calldelete[] p, the compiler calls the destructor for each of theNelements in the array thatppoints to, and then callsoperator delete[]to release the memory that it got fromoperator new[]." - SO
- Choosing to include the Copy Constructor and CopyAssignment operator for the
Vec3class (when Cherno decided to delete them) forced me to revisit some topics related to move semantics.- The Rule of Three: If a class defines any of the following then it should probably explicitly define all three.
- Desstructor
- Copy Constructor
- Copy Assignment Operator
- The Copy-And-Swap Idiom: Make a copy (typically of a heap-allocated member variable), swap the contents with the copy, and then get rid of the copy by leaving the scope.
- RAII: There is tie-in to the concept of RAII (Resource Acquisition is Initialization) here in the sense that in the
Vec3class, we have chosen to manually manage our own memory vianew[]anddelete[].
- The Rule of Three: If a class defines any of the following then it should probably explicitly define all three.
Video #94 - Writing an Iterator in C++
- We'll be adding an iterator to our custom vector class (from video #92).
- An aside: How do we actually get better as a c++ developer?
- More focus/emphasis on reading and writing real-world code rather than just focusing on textbooks and tutorials.
- Eventually you'll get to the point where you'll only continue to learn and get better by looking at and working with an existing codebase.
- For example, the
std::vectortemplate class implements an iterator. So if we want to implement an iterator for our own custom class, we should be looking at the STL for guidance and using it as an example.
- In this video, we've written an interator for our custom
Vectorclass, but this isn't particulary difficult because it really just boils down to incrementing a pointer. However, the concept of an iterator applies to any data structure, e.g. a graph, tree, map, etc.- For example, with a graph data structure, we may want to update the
++operator to visit a child node and move down (or up) the data structure in some hierarchical way. - TODO: Implement an iterator for our custom
Arrayclass,include/array.h. - TODO: Take a look at the iterator for
std::unordered_map.
- For example, with a graph data structure, we may want to update the
- What should I do next in my C++ learning journey? A simple answer: open source projects.