1.3 C++ — for engine, graphics & performance

Phase 1 · Programming Foundations · Study time: 150–250 h

The language of engines, graphics, and high-performance code. Everything from 1.1 plus modern C++: RAII, smart pointers, move semantics, templates and the STL. Needed for engine, graphics and optimization roles.

1. What C++ adds over C, and where it is used

You just spent a whole chapter in C: memory as numbered bytes, pointers, malloc and free, the stack and the heap, and the pain of leaks and use-after-free bugs. C++ is not a different world. It is C with more tools bolted on. Almost every C program you wrote will compile as C++ with tiny changes. So you are not starting over — you are getting sharper tools for the same machine.

Here is what C++ adds that we will actually use in this chapter:

Where is C++ used? It runs the parts of games that must be fast and close to the hardware: game engines (Unreal Engine is C++; Unity's core runtime is C++), rendering and graphics code that talks to the GPU through Vulkan/DirectX/OpenGL, physics, audio, networking, and memory managers. Studios like Epic and HoYoverse ship huge C++ codebases. If you want to write an engine or the performance-critical guts of a game, C++ is the language.

Compile it the same way

C++ files usually end in .cpp. We use g++ instead of gcc, and we keep every safety flag from the C chapter switched on. Here is the first program.

#include <iostream>   // for std::cout, the C++ way to print

int main() {
    std::cout << "Hello from C++\n";
    int score = 42;
    std::cout << "score = " << score << "\n";
    return 0;
}

Compile and run:

g++ -std=c++17 -Wall -Wextra -g -fsanitize=address hello.cpp -o hello
./hello

Output:

Hello from C++
score = 42

What happened? std::cout is the standard output stream (the screen). The << operator feeds values into it, left to right. Unlike C's printf, you do not write %d or %s — you just push the value in and C++ figures out its type. The std:: prefix means "from the standard library namespace" (a namespace is a named box that groups library names so they do not clash with yours).

Tip The -std=c++17 flag picks the 2017 version of the language. It is a solid, widely-supported baseline. Keep -Wall -Wextra -g -fsanitize=address exactly as in the C chapter — the compiler warnings and AddressSanitizer are just as important now.

2. Classes and RAII: the destructor runs automatically

A class is a struct that can also hold functions and control what happens when its objects are created and destroyed. The two special functions are:

That automatic destructor is the heart of modern C++. Watch the timing:

#include <iostream>

class Guard {
public:
    // constructor: runs when a Guard is created
    Guard(const char* name) : name_(name) {
        std::cout << "open " << name_ << "\n";
    }
    // destructor: runs when a Guard is destroyed
    ~Guard() {
        std::cout << "close " << name_ << "\n";
    }
private:
    const char* name_;   // trailing underscore = a common style for members
};

int main() {
    std::cout << "start\n";
    {
        Guard g("file.txt");          // constructor runs here
        std::cout << "using the resource\n";
    }                                 // g leaves scope -> destructor runs here
    std::cout << "end\n";
    return 0;
}

Output:

start
open file.txt
using the resource
close file.txt
end

Read the output next to the code. We never called close ourselves. When g reached the closing } of the inner block, its destructor fired on its own and printed close file.txt. That is RAII: the constructor acquires the resource (open the file) and the destructor releases it (close the file). Because the destructor is automatic, the resource is released no matter how you leave the scope — a normal exit, an early return, or even an exception being thrown.

Two small pieces of syntax:

Compare with the C chapter: there you had to remember to call fclose or free at every exit path, and forgetting one caused a leak. RAII moves that job into the destructor so you cannot forget it.

3. Smart pointers: the heap that frees itself

In C, heap memory was manual and dangerous:

int* p = (int*)malloc(sizeof(int));   // ask for memory
*p = 5;
// ... if we forget free(p) here, that memory leaks forever
free(p);                              // must remember, exactly once

C++ has its own manual pair, new and delete:

int* p = new int(5);   // allocate one int on the heap, set it to 5
delete p;              // free it

But new/delete have the exact same problem as malloc/free: forget the delete and you leak; delete twice and you corrupt the heap; use p after delete and it is use-after-free. The C++ fix is to apply RAII to heap memory. A smart pointer is a small object that owns a heap pointer and runs delete for you in its destructor.

std::unique_ptr — one owner, freed automatically

std::unique_ptr<T> is your default. It owns exactly one heap object. When the unique_ptr goes out of scope, it deletes what it owns. No manual delete anywhere.

#include <iostream>
#include <memory>   // for std::unique_ptr and std::make_unique

struct Enemy {
    int hp;
    Enemy(int h) : hp(h) { std::cout << "Enemy born hp=" << hp << "\n"; }
    ~Enemy()             { std::cout << "Enemy freed\n"; }
};

int main() {
    std::unique_ptr<Enemy> e = std::make_unique<Enemy>(100);
    std::cout << "hp is " << e->hp << "\n";   // use -> just like a raw pointer
    e->hp -= 30;
    std::cout << "hp now " << e->hp << "\n";
    return 0;   // no delete here — e's destructor frees the Enemy
}

Output:

Enemy born hp=100
hp is 100
hp now 70
Enemy freed

std::make_unique<Enemy>(100) builds an Enemy on the heap (passing 100 to its constructor) and hands back a unique_ptr that owns it. You dereference it with -> exactly like a raw pointer. The last line of main is return 0; — no delete — yet the output shows Enemy freed. That print came from the destructor, which the unique_ptr triggered on its way out. The leak is impossible to forget because you never wrote the free in the first place.

stack heap +--------------------+ +----------------------+ | e : unique_ptr | -----> | Enemy { hp = 100 } | +--------------------+ +----------------------+ | owns exactly ONE object. when e leaves scope, it runs delete on the Enemy automatically. you cannot accidentally copy e into a second owner (see below).

"One owner" is enforced by the compiler: you cannot copy a unique_ptr. If two of them owned the same object, both would try to delete it — a double free. So this line does not compile:

std::unique_ptr<Enemy> a = std::make_unique<Enemy>(50);
std::unique_ptr<Enemy> b = a;   // ERROR: cannot copy a unique_ptr

You can move ownership instead (we cover move in section 6): std::unique_ptr<Enemy> b = std::move(a); transfers the object to b and leaves a empty.

std::shared_ptr — shared ownership, use sparingly

Sometimes several parts of your program need to keep the same object alive, and none of them clearly "owns" it. std::shared_ptr<T> handles that with a reference count (a hidden counter of how many shared_ptrs point at the object). Each copy adds one; each destruction subtracts one; when the count hits zero, the object is freed.

#include <iostream>
#include <memory>

int main() {
    std::shared_ptr<int> a = std::make_shared<int>(7);
    std::cout << "count = " << a.use_count() << "\n";
    {
        std::shared_ptr<int> b = a;   // now two owners share the same int
        std::cout << "count = " << a.use_count() << "\n";
        std::cout << "*b = " << *b << "\n";
    }                                // b dies -> count drops back to 1
    std::cout << "count = " << a.use_count() << "\n";
    return 0;                        // a dies -> count 0 -> int freed
}

Output:

count = 1
count = 2
*b = 7
count = 1

The count goes 1, then 2 while b shares ownership, then back to 1 after b's block ends. The int is freed only when the last owner (a) dies. This convenience is not free: the counter costs a little memory and time, and two shared_ptrs pointing at each other form a cycle whose count never reaches zero (a leak). Reach for unique_ptr first; use shared_ptr only when ownership genuinely is shared.

Common mistake Do not build a smart pointer from a raw new like std::shared_ptr<Enemy>(new Enemy(1)). Always use std::make_unique / std::make_shared. They are safer and, for shared_ptr, faster.

4. References vs pointers

In the C chapter, to let a function change the caller's variable, you passed a pointer and dereferenced it:

// C style
void addTen(int* p) { *p += 10; }   // dereference to reach the value

int x = 5;
addTen(&x);                         // pass the address
// x is now 15

C++ gives you a cleaner tool: the reference. int& means "another name for an existing int". A reference is an alias — using it is using the original variable.

#include <iostream>

void addTen(int& n) {   // n is another name for the caller's variable
    n += 10;            // no * needed — n IS the variable
}

int main() {
    int x = 5;
    addTen(x);          // no & needed at the call
    std::cout << "x = " << x << "\n";
    return 0;
}

Output:

x = 15

Same result as the pointer version, less punctuation and less to get wrong. Inside the function there is no *, and at the call site there is no &. The reference n is simply another name for x.

The key differences

When to use each: use a reference when the thing always exists and you never need to change what it refers to — that covers most function arguments. Use a pointer (or a smart pointer) when the target might be absent (nullptr means "nothing here") or when you need to reseat it to point at different objects over time.

const references for big read-only arguments

Passing a big object by value copies the whole thing, which is wasteful if you only want to read it. A const reference lets the function look at the original without copying it and without permission to change it.

#include <iostream>
#include <string>

// const ref: no copy is made, and greet() cannot modify name
void greet(const std::string& name) {
    std::cout << "Hi " << name << "\n";
}

int main() {
    std::string player = "Mika";
    greet(player);   // the big string is not copied
    return 0;
}

Output:

Hi Mika

Rule of thumb: for anything bigger than a couple of numbers that you only need to read, take it by const T&. It is cheap and safe.

5. const-correctness, briefly

const means "I promise not to change this." Marking things const lets the compiler catch a whole class of bugs and documents your intent. You can mark variables, references, and — importantly — member functions.

#include <iostream>

struct Vec2 {
    float x, y;
    // const after the parentheses = this method does not modify the object
    float length2() const {
        return x*x + y*y;
    }
};

int main() {
    const Vec2 v{3.0f, 4.0f};        // v is const: it can never change
    std::cout << "len^2 = " << v.length2() << "\n";
    // v.x = 1.0f;                    // ERROR: cannot modify a const object
    return 0;
}

Output:

len^2 = 25

The const after length2() promises the function will not alter the Vec2. That promise matters: a const object (like v here) can only call const methods. If length2 were not marked const, the line calling it on a const Vec2 would fail to compile. Getting const right early ("const-correctness") is a habit that pays off in large codebases: it stops functions from quietly changing data they were only supposed to read.

6. Copy vs move

When one object is built from another, C++ either copies or moves:

Let us make copy and move visible. This Buffer holds a big std::vector and prints which operation happened.

#include <iostream>
#include <vector>
#include <utility>   // for std::move

struct Buffer {
    std::vector<int> data;

    Buffer(int n) : data(n, 0) {
        std::cout << "made buffer of " << n << "\n";
    }
    // copy constructor: duplicate other's data
    Buffer(const Buffer& other) : data(other.data) {
        std::cout << "COPY " << data.size() << " ints\n";
    }
    // move constructor: steal other's data (note the &&)
    Buffer(Buffer&& other) noexcept : data(std::move(other.data)) {
        std::cout << "MOVE (cheap)\n";
    }
};

int main() {
    Buffer a(1000000);
    Buffer b = a;              // a is a normal variable -> COPY
    Buffer c = std::move(a);   // std::move(a) -> MOVE, steals a's data
    std::cout << "b size " << b.data.size() << "\n";
    std::cout << "c size " << c.data.size() << "\n";
    std::cout << "a size " << a.data.size() << "\n";   // a was emptied
    return 0;
}

Output:

made buffer of 1000000
COPY 1000000 ints
MOVE (cheap)
b size 1000000
c size 1000000
a size 0

Line by line: Buffer b = a; chose the copy constructor and duplicated all one million ints. Buffer c = std::move(a); chose the move constructor, which just handed a's internal buffer to c. After the move, c has the million ints and a is empty (size 0). That is why the last line prints a size 0.

COPY (Buffer b = a): a: [ pointer ] --> [1,2,3, ... ,1000000] (unchanged) b: [ pointer ] --> [1,2,3, ... ,1000000] (a brand-new duplicate) cost: allocate + copy a million ints (slow) MOVE (Buffer c = std::move(a)): before: a: [ pointer ] --> [1,2,3, ... ,1000000] after: a: [ null ] (empty) c: [ pointer ] --> [1,2,3, ... ,1000000] (same buffer, just re-owned) cost: swap one pointer (almost free)

Two things to understand:

Why returning a big object is cheap. A function that builds and returns a big container does not copy it out — the return is a move (or is elided entirely by the compiler). So this is fast, and you should write it plainly:

#include <vector>

std::vector<int> makeData() {
    std::vector<int> v(1000000, 7);   // a million sevens
    return v;                         // moved out, not copied
}
Common mistake After std::move(a), treat a as empty. It is still a valid object, but its contents are gone. Reading them expecting the old data is a bug.

7. Templates: one function for many types

In C, a function that finds the larger of two ints only works for int. For double you had to write a second copy. A template writes the code once with a placeholder type, and the compiler stamps out a real version for each type you actually use.

#include <iostream>

template <typename T>          // T is a placeholder for "some type"
T maxOf(T a, T b) {
    return (a > b) ? a : b;    // works for any type that supports >
}

int main() {
    std::cout << maxOf(3, 9) << "\n";        // T = int
    std::cout << maxOf(2.5, 1.5) << "\n";    // T = double
    std::cout << maxOf('a', 'z') << "\n";    // T = char
    return 0;
}

Output:

9
2.5
z

One function, three types. When you call maxOf(3, 9) the compiler generates an int version; maxOf(2.5, 1.5) generates a double version; and so on. Templates are how the standard library provides containers that hold any type. That is exactly why you write std::vector<int> and std::unique_ptr<Enemy> — the part inside the angle brackets is the type you are plugging into the template.

8. The STL: string, vector, and algorithms

The STL (Standard Template Library) is a large set of ready-made, well-tested containers and algorithms. You will use it constantly. We will meet the two containers you need first, plus a couple of algorithms.

std::string — safe, growable text

Remember C strings: raw char arrays ending in a \0, easy to overflow. std::string manages its own memory and grows as needed.

#include <iostream>
#include <string>

int main() {
    std::string s = "hp";
    s += " = 100";                                  // append; string grows itself
    std::cout << s << "\n";
    std::cout << "length " << s.size() << "\n";
    std::cout << "first char " << s[0] << "\n";
    return 0;
}

Output:

hp = 100
length 8
first char h

No buffer sizes, no manual \0, no overflow when you append. The string handles its own storage in its destructor, so there is nothing to free.

std::vector — a growable array, stored contiguously

A std::vector<T> is a dynamic array: it holds its elements contiguously (right next to each other in memory, just like the C arrays from the last chapter) and can grow with push_back. It has two sizes: size() is how many elements you have; capacity() is how many it can hold before it must move to a bigger block.

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v;                 // empty
    for (int i = 1; i <= 5; ++i) {
        v.push_back(i * 10);           // add one element at the end
        std::cout << "size " << v.size()
                  << " cap " << v.capacity() << "\n";
    }
    std::cout << "v[2] = " << v[2] << "\n";

    // contiguous: element 1 sits right after element 0 in memory
    std::cout << "addr[0] " << (void*)&v[0] << "\n";
    std::cout << "addr[1] " << (void*)&v[1] << "\n";
    return 0;
}

Output (capacities are what the common compiler libraries produce; the exact addresses differ each run):

size 1 cap 1
size 2 cap 2
size 3 cap 4
size 4 cap 4
size 5 cap 8
v[2] = 30
addr[0] 0x561f3a2c4e70
addr[1] 0x561f3a2c4e74

Look at the capacity column. It jumps 1, 2, 4, 4, 8 — the vector roughly doubles its capacity when it runs out of room. Doubling means it only has to reallocate occasionally, not on every push_back. And look at the two addresses: addr[1] is exactly 4 bytes after addr[0] (an int is 4 bytes), proving the elements sit side by side in one block.

push_back until full, then allocate a bigger block and move everything in: cap 2: [10][20] push 30 -> full! allocate cap 4, move the old elements over: cap 4: [10][20][30][ ] push 40: cap 4: [10][20][30][40] push 50 -> full! allocate cap 8, move again: cap 8: [10][20][30][40][50][ ][ ][ ] elements always sit side by side in one block (contiguous) -> the CPU cache loves this: reading them in order is fast

This ties straight back to the arrays-and-cache idea from the C chapter. Because a vector's elements are contiguous, walking through them in order is very cache-friendly (the CPU pulls in a chunk of nearby memory at once, so the next elements are already there). This is a big reason games store things like particles and entities in vectors: fast, predictable iteration.

Common mistake When a vector grows, it may move its whole block to a new address. Any raw pointer or reference you kept into the old block now dangles. Do not hold a pointer into a vector across a push_back.

Algorithms: std::sort and std::find

The header <algorithm> gives you functions that work on containers through iterators (objects that mark a position, like a generalized pointer). v.begin() marks the first element; v.end() marks one past the last.

#include <iostream>
#include <vector>
#include <algorithm>   // for std::sort, std::find

int main() {
    std::vector<int> v = {40, 10, 30, 20, 50};

    std::sort(v.begin(), v.end());        // sort ascending, in place
    for (int x : v) std::cout << x << " ";  // range-for: x is each element
    std::cout << "\n";

    auto it = std::find(v.begin(), v.end(), 30);   // search for 30
    if (it != v.end())
        std::cout << "found 30 at index " << (it - v.begin()) << "\n";
    else
        std::cout << "not found\n";
    return 0;
}

Output:

10 20 30 40 50
found 30 at index 2

std::sort rearranges the elements between the two iterators into ascending order. std::find scans that range and returns an iterator to the first match, or end() if nothing matched — that is why we compare against v.end(). Subtracting v.begin() from the found iterator gives the index. Two more pieces of C++ shorthand appear here: auto tells the compiler to work out the variable's type for you (an iterator type here), and for (int x : v) is a range-for loop that visits each element in turn.

9. Rule of 0 and Rule of 5

There are five special functions that govern how an object is copied, moved, and destroyed:

The Rule of 5 says: if you manage a raw resource yourself — a raw new pointer, an open file handle, a socket — and you write any one of those five, you almost certainly need all five, done correctly. That is a lot of tricky code, and getting the copy or move wrong causes double-frees and leaks.

The Rule of 0 is the way out: do not manage raw resources yourself. Hold your resources in members that already manage themselves — std::string, std::vector, std::unique_ptr. Those types already implement all five functions correctly. If your class only holds such members, you write none of the five, and your class copies, moves, and cleans up correctly for free.

#include <string>
#include <vector>
#include <memory>

struct Player {
    std::string          name;        // manages its own text
    std::vector<int>      inventory;   // manages its own array
    std::unique_ptr<int> secret;       // manages its own heap int

    // No destructor. No copy/move code. Nothing to free by hand.
    // Each member already knows how to clean itself up.
};

When Player is destroyed, each member's destructor runs automatically, in reverse order — the unique_ptr frees its int, the vector frees its array, the string frees its text. You wrote none of it. (One neat side effect: because unique_ptr cannot be copied, a Player that holds one becomes move-only — it can be moved but not copied, which is usually exactly what you want for a game entity.)

Tip Aim for the Rule of 0 in your own classes. If you find yourself writing a destructor with a manual delete in it, stop and ask whether a unique_ptr member would remove the need entirely. Usually it does.

10. Undefined behavior still applies — sanitizers are your safety net

C++ is safer than C when you use its tools, but it is built on the same machine and it still has undefined behavior (UB — code the language does not define the meaning of; anything can happen, including a crash, wrong data, or a bug that hides for months). Smart pointers and vector remove many chances for UB, but not all. For example, v[i] does not check its bounds:

#include <vector>

int main() {
    std::vector<int> v = {1, 2, 3};
    return v[5];   // index 5 in a size-3 vector: out of bounds, UB
}

Compiled with the flags we have used all along, AddressSanitizer catches it at runtime:

g++ -std=c++17 -Wall -Wextra -g -fsanitize=address oob.cpp -o oob
./oob
=================================================================
==12345==ERROR: AddressSanitizer: heap-buffer-overflow on address ...
READ of size 4 at ...
    #0 in main oob.cpp:5
...

Same tool, same message style as the C chapter. The kinds of UB to keep watching for are exactly the ones you already know: reading past the end of a container, using memory after it was freed, reading an uninitialized value, and returning a reference or pointer to a local variable that has already died (a dangling reference).

Tip If you want bounds checking, use v.at(i) instead of v[i]. It throws an exception on a bad index instead of silently running off the end. Use at while learning; switch to [] in hot loops once you are sure the index is valid.

Keep -Wall -Wextra -g -fsanitize=address on every build while you learn. The warnings catch mistakes at compile time; AddressSanitizer catches memory mistakes at run time. Between RAII, smart pointers, and these tools, most of the crashes that plagued you in C simply stop happening — but the tools only help if they are switched on.

11. Glossary

12. Exercises

Exercise 1 The snippet below leaks memory: it uses a raw new and forgets to delete. Rewrite it with std::unique_ptr so the memory is freed automatically, with no delete anywhere.
#include <iostream>

struct Sprite {
    int id;
    Sprite(int i) : id(i) { std::cout << "load sprite " << id << "\n"; }
    ~Sprite()             { std::cout << "free sprite " << id << "\n"; }
};

int main() {
    Sprite* s = new Sprite(7);
    std::cout << "using sprite " << s->id << "\n";
    return 0;   // BUG: no delete -> Sprite is never freed
}
Show answer

Replace the raw pointer with a unique_ptr built by std::make_unique. Add #include <memory>. Delete nothing.

#include <iostream>
#include <memory>

struct Sprite {
    int id;
    Sprite(int i) : id(i) { std::cout << "load sprite " << id << "\n"; }
    ~Sprite()             { std::cout << "free sprite " << id << "\n"; }
};

int main() {
    std::unique_ptr<Sprite> s = std::make_unique<Sprite>(7);
    std::cout << "using sprite " << s->id << "\n";
    return 0;   // s's destructor frees the Sprite automatically
}

Output:

load sprite 7
using sprite 7
free sprite 7

free sprite 7 now prints even though there is no delete — the unique_ptr ran it for you when s left scope. The leak is gone.

Exercise 2 Write a RAII class ScopedTimer whose constructor prints timer start: NAME and whose destructor prints timer end: NAME. Then, in main, create one in an inner { } block and another that lives for all of main, and predict the exact order of the four lines before you run it.
Show answer
#include <iostream>
#include <string>

class ScopedTimer {
public:
    ScopedTimer(const std::string& name) : name_(name) {
        std::cout << "timer start: " << name_ << "\n";
    }
    ~ScopedTimer() {
        std::cout << "timer end: " << name_ << "\n";
    }
private:
    std::string name_;
};

int main() {
    ScopedTimer outer("main");
    {
        ScopedTimer inner("inner");
    }   // inner dies here
    std::cout << "back in main\n";
    return 0;
}   // outer dies here

Output:

timer start: main
timer start: inner
timer end: inner
back in main
timer end: main

Objects are destroyed in reverse order of creation, and each dies at the end of its own scope. inner lives only inside the braces, so it ends before back in main. outer lives until the end of main, so its timer end is the very last line. Note we hold the name in a std::string member, so ScopedTimer follows the Rule of 0 — no destructor cleanup code beyond the print.

Exercise 3 Start from std::vector<int> scores = {70, 20, 90, 50, 90};. (a) Sort it into ascending order and print it. (b) Use an <algorithm> function to check whether 50 is present and print yes or no. (c) Print how many elements the vector holds.
Show answer
#include <iostream>
#include <vector>
#include <algorithm>

int main() {
    std::vector<int> scores = {70, 20, 90, 50, 90};

    std::sort(scores.begin(), scores.end());          // (a)
    for (int x : scores) std::cout << x << " ";
    std::cout << "\n";

    auto it = std::find(scores.begin(), scores.end(), 50);   // (b)
    std::cout << (it != scores.end() ? "yes" : "no") << "\n";

    std::cout << "count " << scores.size() << "\n";     // (c)
    return 0;
}

Output:

20 50 70 90 90
yes
count 5

std::sort orders the range in place. std::find returns an iterator to the first 50; because it is not end(), the value is present, so we print yes. scores.size() reports 5 elements. Note the duplicate 90 stays — sorting does not remove duplicates.

← Back to all chapters