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:
std::unique_ptr, std::shared_ptr) that free heap memory for you, so no more forgotten free.T&) — a cleaner way to pass a variable so a function can change it.std::string, std::vector, std::sort, and more.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.
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).
-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.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:
~ in front, runs automatically when the object goes out of scope (leaves the { } block it lives in).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:
: name_(name) is a member initializer list. It sets the member name_ before the constructor body runs. Prefer it over assigning inside the body.public: and private: control who can touch each part. Outside code can use public members; private members are for the class itself. This keeps the inside safe from accidental meddling.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.
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<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.
"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.
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.
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.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.
nullptr.n refers to x, it refers to x for its whole life. A pointer can be pointed at a different variable later.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.
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.
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.
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.
Two things to understand:
Buffer&& is an rvalue reference (a reference that binds to temporaries and to things you have marked as movable). The move constructor takes one, so it only runs for temporaries or moved-from objects.std::move does not move anything by itself. It is just a cast that says "treat this as movable, its contents may be stolen." It is what makes the compiler pick the move constructor instead of the copy constructor.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
}
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.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.
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.
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.
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.
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.
push_back.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.
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.)
delete in it, stop and ask whether a unique_ptr member would remove the need entirely. Usually it does.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).
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.
~Class()) — a function that runs automatically when an object goes out of scope; cleans it up.std::unique_ptr, std::shared_ptr).T&) — an alias for an existing variable; cannot be null and cannot be reseated.T&&) — a reference that binds to temporaries and moved-from objects; used by move operations.size vs capacity.begin()/end().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
}
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.
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.#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.
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.#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.