PONYλM2Modula-2

Python.CodeCompared.To/C++

An interactive executable cheatsheet comparing Python and C++

Python 3.14 C++23 (GCC)
Hello World & Building
Hello, World
Four of the five lines on the right are ceremony: a header to make output exist at all, an entry point for the operating system to call, and a status code for it to collect. Only one line is the program.
print("Hello, World!")
#include <iostream> int main() { std::cout << "Hello, World!" << std::endl; return 0; }
The C++ file is not executed — it is translated, and the thing that runs afterwards is a machine-code file that no longer mentions C++. That is the difference the whole page turns on: everything the C++ compiler is going to check, it checks before the program exists, and everything it cannot check it does not check at all. std::endl writes a newline and flushes; \n alone writes the newline without the flush and is what you want in a loop.
The build step
Both programs print the same number. The difference is what happens to the line that never runs — uncomment the commented-out line on the right and the program stops existing, while the Python version happily ships the same mistake to production.
# Edit, run. The interpreter reads the file each time. # # python3 report.py # # A name misspelled inside a branch you never take is not an error # until the day that branch runs. total = 0 for value in [3, 1, 4, 1, 5]: total += value if total > 1000: print(mispelled_name) # never evaluated, never complained about print("total:", total)
// Edit, compile, link, run — and the misspelling stops you at step two: // // g++ -std=c++23 -Wall report.cpp -o report // ./report // // The compiler reads every branch, including the ones that never run. #include <iostream> #include <vector> int main() { int total = 0; for (int value : {3, 1, 4, 1, 5}) { total += value; } if (total > 1000) { // std::cout << mispelled_name; // ← uncomment: it will not build } std::cout << "total: " << total << std::endl; return 0; }
This is the trade in one row, and it cuts both ways: C++ finds the typo, and C++ also makes you wait for the compiler every time you want to see anything at all. A Python developer coming to C++ usually underestimates how much of their working rhythm was "run it and look" — a habit that costs a second in Python and thirty in a real C++ build.
Where the program starts
Python has no entry point, so it invents one by convention. C++ has a real one, and the consequence runs the other way: nothing at file scope executes, so there is no such thing as import-time work.
def main(): print("running as a program") # Every top-level statement runs on import, so the guard is what # separates "somebody imported me" from "somebody ran me". print("this line runs even on import") if __name__ == "__main__": main()
#include <iostream> void greet() { std::cout << "running as a program" << std::endl; } // There is nothing above main() that "runs". A definition is not a // statement, and no code outside a function body is executed. int main() { greet(); return 0; }
The if __name__ == "__main__" guard has no C++ counterpart because the problem it solves does not exist — including a header cannot print anything, register anything or open a connection, because a header is declarations. The nearest thing to import-time work is a global object whose constructor runs before main, and it is widely considered a mistake to rely on: the order across files is unspecified, a fact experienced C++ programmers refer to as the static initialization order fiasco.
Names, Values & Copies
Assignment copies the whole thing
This is the single most important row on the page, and everything in the Memory section later is a consequence of it. Read what each program prints for first after only second was appended to.
first = [1, 2, 3] second = first # a second NAME for one list second.append(4) print("first: ", first) print("second:", second) print("same object?", first is second)
#include <iostream> #include <vector> int main() { std::vector<int> first{1, 2, 3}; std::vector<int> second = first; // a second VECTOR, copied element by element second.push_back(4); std::cout << "first size: " << first.size() << std::endl; std::cout << "second size: " << second.size() << std::endl; std::cout << "same object? " << (&first == &second) << std::endl; return 0; }
In Python = binds a name; in C++ = copies a value. So the Python list grows to four elements because there was only ever one list, and the C++ vector does not because there were always two. Nothing about the syntax says so — this is why a Python programmer reading C++ consistently under-counts how much work the program is doing, and why a C++ programmer reading Python consistently over-counts how safe it is to hand a list to a function.
Asking for Python's behavior on purpose
One ampersand. std::vector<int>& alias is not a new vector, it is another name for the one that already exists — which is exactly what every Python assignment gives you for free.
# You get aliasing whether or not you wanted it, and you opt OUT # by copying explicitly. original = [1, 2, 3] alias = original # aliased copy = list(original) # copied alias.append(4) copy.append(99) print("original:", " ".join(str(value) for value in original)) print("copy: ", " ".join(str(value) for value in copy))
#include <iostream> #include <vector> int main() { std::vector<int> original{1, 2, 3}; std::vector<int>& alias = original; // the & is the whole difference std::vector<int> copy = original; alias.push_back(4); copy.push_back(99); std::cout << "original: "; for (std::size_t index = 0; index < original.size(); ++index) { std::cout << (index ? " " : "") << original[index]; } std::cout << "\ncopy: "; for (std::size_t index = 0; index < copy.size(); ++index) { std::cout << (index ? " " : "") << copy[index]; } std::cout << std::endl; return 0; }
The defaults are mirror images: Python aliases unless you say list(...) or copy.deepcopy, C++ copies unless you say &. A C++ reference must be bound when it is created and can never be re-seated afterwards, so it is closer to a permanent second name than to a Python variable — you cannot point it somewhere else later.
What a function receives
Python has exactly one calling convention and it surprises people in both directions: the function can mutate what you passed, but cannot replace it. C++ makes you choose, in the signature, and the caller cannot tell which you chose by looking at the call.
def add_one(values): values.append(1) # the caller's list, mutated def rebind(values): values = [99] # only the local name moves return values numbers = [] add_one(numbers) rebind(numbers) print("after both calls:", numbers)
#include <iostream> #include <vector> // By value: the callee gets its own copy, and the caller never sees a change. void add_one_to_a_copy(std::vector<int> values) { values.push_back(1); } // By reference: the callee gets the caller's vector. void add_one(std::vector<int>& values) { values.push_back(1); } int main() { std::vector<int> numbers; add_one_to_a_copy(numbers); std::cout << "after the by-value call: " << numbers.size() << std::endl; add_one(numbers); std::cout << "after the by-reference call: " << numbers.size() << std::endl; return 0; }
A C++ signature is a contract about copying, which is why you will see const std::vector<int>& everywhere in real code — it means "I need to read this, I will not copy it, and I will not change it". Passing a large container by value is a real and common performance bug, and it is invisible at the call site: process(data) looks identical either way.
is vs ==
C++ has no is operator because it does not need a word for it: identity is just whether two addresses are the same, and & is how you ask for an address.
first = [1, 2, 3] second = [1, 2, 3] print("equal? ", first == second) print("same object?", first is second) third = first print("third is first?", third is first)
#include <iostream> #include <vector> int main() { std::vector<int> first{1, 2, 3}; std::vector<int> second{1, 2, 3}; std::cout << "equal? " << (first == second) << std::endl; std::cout << "same object? " << (&first == &second) << std::endl; std::vector<int>& third = first; std::cout << "third is first? " << (&third == &first) << std::endl; return 0; }
Note what happened to the third case. In Python third = first makes third is first true, and in C++ that is only true because the ampersand was written — without it third would be a copy sitting at a different address. Also note that == on a C++ container compares elements, exactly as Python does; it is == on a pointer that compares addresses, which is the trap on the other side.
None, nullptr and "no value"
std::optional<int> is "an int, or nothing" as a type. It is not nullptr: a null pointer says there is no object anywhere, while an optional holds the int inside itself when there is one.
def find_even(values): for value in values: if value % 2 == 0: return value return None found = find_even([1, 3, 5]) if found is None: print("nothing found") else: print("found", found)
#include <iostream> #include <optional> #include <vector> std::optional<int> find_even(const std::vector<int>& values) { for (int value : values) { if (value % 2 == 0) return value; } return std::nullopt; } int main() { std::optional<int> found = find_even({1, 3, 5}); if (!found.has_value()) { std::cout << "nothing found" << std::endl; } else { std::cout << "found " << *found << std::endl; } return 0; }
Python answers "there is no answer" with None, which any function may return from any branch and which the caller may forget to check. C++ makes the absence part of the return type, so a caller who forgets does not compile — found + 1 is an error until the optional is unwrapped. nullptr still exists and still means an empty pointer, but returning one to signal "not found" is the older style and reads as a smell in new code.
Static Types
Declaring a variable
The type is written before the name, and it belongs to the variable rather than to the value. Once count is an int it is an int for the rest of its life, and there is no operation that can change that.
count = 42 ratio = 0.5 label = "widget" enabled = True count = "now a string" # perfectly legal print(count, ratio, label, enabled)
#include <iostream> #include <string> int main() { int count = 42; double ratio = 0.5; std::string label = "widget"; bool enabled = true; // count = "now a string"; // ← uncomment: the build fails here auto deduced = 42; // auto asks the compiler to work out "int" std::cout << count << ' ' << ratio << ' ' << label << ' ' << enabled << ' ' << deduced << std::endl; return 0; }
auto is not Python's dynamism arriving late — it asks the compiler to write the type down for you, and the type is still fixed and still checked. Note that bool printed as 1, not true: streaming a bool gives you the integer unless you ask for std::boolalpha. This is the first of many places where C++ shows you the machine and Python shows you the idea.
Integers have edges now
A Python integer is an object that allocates more space as it grows. A C++ integer is a fixed number of bits in a register, and going past the end is not an error you can catch.
# Python integers are arbitrary precision. They grow until memory runs out. big = 2 ** 62 print("2**62 =", big) print("2**62 * 4 =", big * 4) print("factorial-ish:", 2 ** 200)
#include <cstdint> #include <iostream> #include <limits> int main() { std::int64_t big = 1LL << 62; std::cout << "2**62 = " << big << std::endl; std::cout << "int64 max = " << std::numeric_limits<std::int64_t>::max() << std::endl; // Unsigned arithmetic WRAPS, and the standard says so. std::uint64_t wrapped = std::numeric_limits<std::uint64_t>::max(); std::cout << "uint64 max + 1 = " << wrapped + 1 << std::endl; // Signed overflow is a different and worse thing: undefined behavior. // The compiler may assume it never happens and optimize on that basis. return 0; }
🚨 The two failure modes are different and the second one is the dangerous one. Unsigned overflow wraps, which is defined, surprising and at least predictable. Signed overflow is undefined behavior: the compiler is entitled to assume it cannot happen, so a loop guarded by if (index + 1 < index) may be deleted entirely as dead code. If you have written Python for years, the habit of trusting an integer to just be a number is the single most expensive one to carry across.
Division rounds a different way
Look at the negative cases rather than the positive ones. Both languages agree that 7 divided by 2 is 3 when integers are involved; they disagree about which way to round when the answer is negative.
print("7 / 2 =", 7 / 2) # true division, always a float print("7 // 2 =", 7 // 2) # floor division print("-7 // 2 =", -7 // 2) # floors toward negative infinity print("-7 % 2 =", -7 % 2) # sign follows the divisor
#include <iostream> int main() { std::cout << "7 / 2 = " << 7 / 2 << std::endl; // integer division std::cout << "7 / 2.0 = " << 7 / 2.0 << std::endl; // one double makes it float std::cout << "-7 / 2 = " << -7 / 2 << std::endl; // truncates toward zero std::cout << "-7 % 2 = " << -7 % 2 << std::endl; // sign follows the dividend return 0; }
Python floors (-7 // 2 is -4) and C++ truncates toward zero (-7 / 2 is -3), and the remainders follow: Python's % takes the sign of the divisor, C++'s takes the sign of the dividend. Any code that indexes a ring buffer or bucket by value % size with possibly-negative values changes behavior when ported, silently and only for some inputs. The other half of the row is that / on two C++ integers is integer division — 7 / 2 is 3, not 3.5 — so a stray integer literal quietly discards your fractional part.
Type hints are not types
The Python annotation says list[int] and the second call passes a list of strings. Watch it run anyway — string repetition is a real operation, so the program does not even fail.
def scale(values: list[int], factor: int) -> list[int]: return [value * factor for value in values] # Nothing checks this at run time. mypy would complain; Python will not. print(scale([1, 2, 3], 2)) print(scale(["a", "b"], 3)) # runs, and does something surprising
#include <iostream> #include <vector> std::vector<int> scale(const std::vector<int>& values, int factor) { std::vector<int> result; for (int value : values) result.push_back(value * factor); return result; } int main() { for (int value : scale({1, 2, 3}, 2)) std::cout << value << ' '; std::cout << std::endl; // scale({"a", "b"}, 3); // ← uncomment: no such function, no program return 0; }
This is the row that explains why C++ people distrust annotations. In Python a hint is documentation that a separate tool may or may not be pointed at; in C++ the signature is the only thing the call site is matched against, and a mismatch is not a warning but the absence of a program. The upside is fewer four-in-the-morning surprises; the cost is that generic code needs real machinery, which is what the Templates section is about.
Strings
Making and joining strings
std::string is a class in the standard library rather than a built-in, and it owns its characters — copying one copies the text. There is no .upper(); case conversion is an algorithm applied to a range.
greeting = "Hello" name = "world" joined = greeting + ", " + name + "!" print(joined) print(len(joined)) print(joined.upper())
#include <algorithm> #include <iostream> #include <string> int main() { std::string greeting = "Hello"; std::string name = "world"; std::string joined = greeting + ", " + name + "!"; std::cout << joined << std::endl; std::cout << joined.size() << std::endl; std::string shouted = joined; std::ranges::transform(shouted, shouted.begin(), ::toupper); std::cout << shouted << std::endl; return 0; }
The missing method is the point: C++ keeps a small string class and a large algorithm library, so anything shaped like "do this to every element" lives outside the type. std::ranges::transform writing into shouted.begin() is the idiom, and it is deliberately explicit about which buffer receives the result. What C++ does not have here is a mutable-versus-immutable distinction — a std::string can be modified in place, so there is no equivalent of Python building a new string for every operation.
f-strings and std::format
The mini-language inside the braces is the same one, deliberately — std::format was modelled on Python's, so :.2f, :>10 and :04d mean exactly what you already know.
name = "widget" count = 7 price = 12.5 print(f"{count} x {name} at {price:.2f}") print(f"{name:>10}|") print(f"{count:04d}")
#include <format> #include <iostream> #include <string> int main() { std::string name = "widget"; int count = 7; double price = 12.5; std::cout << std::format("{} x {} at {:.2f}", count, name, price) << std::endl; std::cout << std::format("{:>10}|", name) << std::endl; std::cout << std::format("{:04d}", count) << std::endl; return 0; }
The difference is that C++ cannot interpolate a variable by name: the braces are empty or numbered and the values come afterwards, because the format string is parsed at compile time and there is no way to look a local variable up by its spelling. That compile-time parsing is a genuine gain — a mismatched placeholder is a build error rather than a run-time exception. std::print("…", args) from <print> is the shorter spelling where your library has it.
len() counts characters; size() counts bytes
The same five-letter word. Python says its length is 5, C++ says 6, and neither is wrong — they are answering different questions because é takes two bytes in UTF-8.
text = "héllo" print("characters:", len(text)) print("bytes: ", len(text.encode("utf-8"))) print("third char:", text[2])
#include <iostream> #include <string> int main() { std::string text = "héllo"; // UTF-8 bytes in the source file std::cout << "bytes: " << text.size() << std::endl; std::cout << "third byte: " << text[2] << std::endl; // There is no "third character" without deciding what a character is. // std::string does not know it is holding UTF-8 at all. return 0; }
A Python str is a sequence of code points and the encoding is a separate concern handled at the edges. A std::string is a sequence of char, which is to say bytes, and it has no idea what encoding they are in — indexing, slicing and length are all byte operations. Any Python code that iterates characters, truncates to a width or reverses a string has to be rethought rather than translated, and this is the most common source of mojibake in ported code.
split and join, by hand
There is no split in the C++ standard library, and this seven-line loop is the ordinary way to write it. std::getline reading from a std::stringstream with a delimiter is the trick everybody uses.
line = "alpha,beta,gamma" fields = line.split(",") print(" | ".join(fields))
#include <iostream> #include <sstream> #include <string> #include <vector> int main() { std::string line = "alpha,beta,gamma"; std::vector<std::string> fields; std::stringstream stream(line); std::string field; while (std::getline(stream, field, ',')) { fields.push_back(field); } for (std::size_t index = 0; index < fields.size(); ++index) { if (index > 0) std::cout << " | "; std::cout << fields[index]; } std::cout << std::endl; return 0; }
This is the row that changes how much code you expect to write. C++ standardizes containers and algorithms, not conveniences, so a one-liner that exists in every scripting language is assembled from parts here — and every C++ codebase of any size has its own split in some utility header. std::views::split from C++20 does exist and is more elegant, but converting its pieces back into strings is verbose enough that most code still writes the loop.
Collections
list becomes vector
A vector holds one type, written in the angle brackets, and every element sits next to the last in one contiguous block of memory — which is exactly why it is fast and exactly why it cannot hold mixed types.
values = [3, 1, 4] values.append(1) values.insert(0, 9) print(values) print(len(values), values[0], values[-1])
#include <iostream> #include <vector> int main() { std::vector<int> values{3, 1, 4}; values.push_back(1); values.insert(values.begin(), 9); for (int value : values) std::cout << value << ' '; std::cout << std::endl; std::cout << values.size() << ' ' << values.front() << ' ' << values.back() << std::endl; return 0; }
Two habits to unlearn. There is no negative indexing, so values[-1] does not mean the last element — it reads memory before the vector and is undefined behavior; use .back(). And values[10] on a three-element vector does not raise IndexError, it simply reads whatever is there; .at(10) is the checked version that throws. A Python list of mixed types has no direct equivalent at all — the nearest thing is std::vector<std::variant<…>>, with the alternatives listed in advance.
dict becomes unordered_map
Two containers do the job of dict: std::unordered_map is the hash table and matches Python's performance, while std::map is a sorted tree and is used here so the output has a defined order.
counts = {"apple": 2, "pear": 5} counts["plum"] = 1 print(counts["apple"]) print("fig" in counts) print(counts.get("fig", 0)) for key in sorted(counts): print(key, counts[key])
#include <iostream> #include <map> #include <string> int main() { std::map<std::string, int> counts{{"apple", 2}, {"pear", 5}}; counts["plum"] = 1; std::cout << counts["apple"] << std::endl; std::cout << counts.contains("fig") << std::endl; // std::map iterates in sorted key order, which is why it is used here. for (const auto& [key, value] : counts) { std::cout << key << ' ' << value << std::endl; } return 0; }
🚨 The trap is operator[]. Looking up a missing key with square brackets does not raise KeyError — it inserts the key with a value-initialized value and hands it back, so a read-only-looking loop can grow the map. Use .contains(key) to ask, .at(key) to read with a throw on absence, and .find(key) when you want the answer and the value in one lookup. Also note that Python dictionaries preserve insertion order and neither C++ map does: unordered_map has no order at all, and map is sorted by key.
Tuples, structs and unpacking
auto [a, b] = … is a structured binding, and it unpacks a struct or a tuple the same way Python unpacks a tuple. The struct is the idiomatic choice because the fields have names.
def divide(numerator, denominator): return numerator // denominator, numerator % denominator quotient, remainder = divide(17, 5) print(quotient, remainder) point = (3, 4) x, y = point print(x, y)
#include <iostream> #include <tuple> struct DivisionResult { int quotient; int remainder; }; DivisionResult divide(int numerator, int denominator) { return {numerator / denominator, numerator % denominator}; } int main() { auto [quotient, remainder] = divide(17, 5); std::cout << quotient << ' ' << remainder << std::endl; std::tuple<int, int> point{3, 4}; auto [x, y] = point; std::cout << x << ' ' << y << std::endl; return 0; }
Where Python reaches for an anonymous tuple, C++ usually declares a small struct — it costs three lines and buys named fields, so the call site says result.remainder instead of result[1]. std::tuple exists and matches Python more literally, but reading it back needs std::get<1>(point) with the index inside angle brackets, because the index has to be known at compile time to know the type it returns.
Slices become spans
There is no slice syntax. std::span is the closest thing, and it is deliberately the opposite of a Python slice: it copies nothing and points into the original.
values = [10, 20, 30, 40, 50] middle = values[1:4] # a NEW list print(middle) middle[0] = 99 # the original is untouched print(values)
#include <iostream> #include <span> #include <vector> int main() { std::vector<int> values{10, 20, 30, 40, 50}; std::span<int> middle(values.begin() + 1, 3); // a VIEW, no copying for (int value : middle) std::cout << value << ' '; std::cout << std::endl; middle[0] = 99; // writes through to values for (int value : values) std::cout << value << ' '; std::cout << std::endl; return 0; }
So the two programs disagree about the last line, and the disagreement is the lesson: a Python slice is a new list, a span is a window. That makes spans free and makes them dangerous — the span is only valid while the vector it points into is alive and has not reallocated, and push_back during a span's lifetime can leave it pointing at freed memory. If you want Python's behavior, construct a new vector from the two iterators.
Sets
As with maps there are two: std::unordered_set is the hash set that matches Python's, and std::set is a sorted tree, chosen here so that iterating gives a defined order without a sort step.
seen = {3, 1, 4, 1, 5} seen.add(9) print(len(seen)) print("yes" if 3 in seen else "no") print(" ".join(str(value) for value in sorted(seen)))
#include <iostream> #include <set> int main() { std::set<int> seen{3, 1, 4, 1, 5}; // duplicates collapse on insertion seen.insert(9); std::cout << seen.size() << std::endl; std::cout << (seen.contains(3) ? "yes" : "no") << std::endl; bool first = true; for (int value : seen) { // sorted, by construction std::cout << (first ? "" : " ") << value; first = false; } std::cout << std::endl; return 0; }
The set operations Python spells with operators — |, &, - for union, intersection and difference — are free functions in <algorithm> (std::set_union and friends) that write into an output iterator, and they require sorted input, which is one reason std::set earns its keep. There is no frozenset; a const std::set is the same idea.
Iteration
for x in things
The range-based for reads almost the same and does the same thing. The three-part loop underneath it is the older form, and it is still what you write when you need the index itself.
for value in [10, 20, 30]: print(value) for index in range(3): print("index", index)
#include <iostream> #include <vector> int main() { for (int value : {10, 20, 30}) { std::cout << value << std::endl; } for (int index = 0; index < 3; ++index) { std::cout << "index " << index << std::endl; } return 0; }
Write for (const auto& value : things) rather than for (auto value : things) when the elements are anything bigger than a number — the second form copies every element as it goes, which is invisible, legal and a common source of a program being mysteriously slow. There is no range object to pass around; std::views::iota(0, 3) is the nearest equivalent.
enumerate and zip
Both of these are index loops in C++, and the index has type std::size_t — an unsigned integer — which matters the moment you count downwards, because it never goes below zero and wraps to something enormous instead.
names = ["alpha", "beta", "gamma"] scores = [10, 20, 30] for index, name in enumerate(names): print(index, name) for name, score in zip(names, scores): print(name, score)
#include <iostream> #include <string> #include <vector> int main() { std::vector<std::string> names{"alpha", "beta", "gamma"}; std::vector<int> scores{10, 20, 30}; for (std::size_t index = 0; index < names.size(); ++index) { std::cout << index << ' ' << names[index] << std::endl; } // Two containers walked together, by index. for (std::size_t index = 0; index < names.size(); ++index) { std::cout << names[index] << ' ' << scores[index] << std::endl; } return 0; }
C++23 does add std::views::enumerate and std::views::zip, which read almost exactly like the Python. They are deliberately not used here because they are not yet in every shipping standard library — this page's own local toolchain has neither, which is the kind of gap that decides what a real codebase can use. When your compiler and library both have them, prefer them: for (auto [index, name] : std::views::enumerate(names)).
Comprehensions become pipelines
The pipe operator composes views left to right, so the order you read them in is the order they happen — which is the opposite of the comprehension, where the transformation is written first and the filter last.
values = [1, 2, 3, 4, 5, 6] squares_of_evens = [value * value for value in values if value % 2 == 0] print(" ".join(str(value) for value in squares_of_evens))
#include <iostream> #include <ranges> #include <vector> int main() { std::vector<int> values{1, 2, 3, 4, 5, 6}; auto squares_of_evens = values | std::views::filter([](int value) { return value % 2 == 0; }) | std::views::transform([](int value) { return value * value; }); bool first = true; for (int value : squares_of_evens) { std::cout << (first ? "" : " ") << value; first = false; } std::cout << std::endl; return 0; }
The important difference is that the C++ version is lazy and produces no container: nothing is computed until the loop pulls values through, and squares_of_evens is a recipe rather than a vector. That makes it closer to a Python generator expression than to a list comprehension. Add | std::ranges::to<std::vector>() when you actually want the list.
yield has no cheap equivalent
A generator suspends in the middle of a function and resumes there later. C++ has no way to do that cheaply before C++20 coroutines, so the ordinary answer inverts the control flow: instead of the caller pulling values out, the producer pushes them into a callback.
def first_squares(limit): value = 1 while value <= limit: yield value * value value += 1 for square in first_squares(4): print(square)
#include <functional> #include <iostream> // The portable stand-in for a generator: hand each value to a callback // as it is produced, so nothing is buffered. void first_squares(int limit, const std::function<void(int)>& emit) { for (int value = 1; value <= limit; ++value) { emit(value * value); } } int main() { first_squares(4, [](int square) { std::cout << square << std::endl; }); return 0; }
C++23 defines std::generator, which is a real coroutine and reads very much like the Python — co_yield value * value inside the function and a range-based for at the call site. It is not used here because libc++ does not yet ship it, and that is the practical situation for a lot of C++23: the language feature landed years before the library support you need to use it. The callback form has a genuine advantage, though — it does not allocate a coroutine frame.
Functions
Overloading instead of keyword arguments
C++ has default arguments but no keyword arguments, so "skip the middle one" is impossible — you write a second function with the same name instead, and the compiler picks by the argument types.
def describe(name, count=1, loud=False): text = f"{count} x {name}" return text.upper() if loud else text print(describe("widget")) print(describe("widget", loud=True)) print(describe("widget", count=3))
#include <algorithm> #include <format> #include <iostream> #include <string> // Default arguments exist, but they are positional: you cannot skip one. std::string describe(const std::string& name, int count = 1, bool loud = false) { std::string text = std::format("{} x {}", count, name); if (loud) std::ranges::transform(text, text.begin(), ::toupper); return text; } // Overloading: the same name, a different parameter list. std::string describe(const std::string& name, bool loud) { return describe(name, 1, loud); } int main() { std::cout << describe("widget") << std::endl; std::cout << describe("widget", true) << std::endl; std::cout << describe("widget", 3) << std::endl; return 0; }
Overload resolution is doing real work here, and it is worth knowing it can surprise you: describe("widget", true) matches the two-parameter overload, but had the second overload not existed, true would have converted to int and printed 1 x widget without a word of complaint. Implicit conversions between arithmetic types make overload sets subtler than they look. Python's single-function-with-keywords approach has no equivalent trap, which is a real advantage of the dynamic side.
Lambdas say what they capture
The square brackets are the capture list, and they are the part with no Python equivalent: [factor] copies the value when the lambda is created, [&factor] keeps a reference to the variable itself.
factor = 3 scale = lambda value: value * factor # closes over factor automatically print(scale(5)) factor = 10 print(scale(5)) # sees the NEW factor — it closed over the variable
#include <iostream> int main() { int factor = 3; auto scale_by_value = [factor](int value) { return value * factor; }; auto scale_by_reference = [&factor](int value) { return value * factor; }; std::cout << scale_by_value(5) << std::endl; std::cout << scale_by_reference(5) << std::endl; factor = 10; std::cout << scale_by_value(5) << std::endl; // still 3 — copied at creation std::cout << scale_by_reference(5) << std::endl; // 10 — reads the variable return 0; }
Python only has the second behavior, which is why the late-binding closure surprise exists — a lambda made inside a loop sees the loop variable's final value. C++ lets you say which you meant, and the copying form is the safer default because a captured reference outliving its variable is a dangling reference. [=] and [&] capture everything used, by copy and by reference respectively; the second is the one to be careful with when the lambda is stored rather than called immediately.
*args becomes a parameter pack
The three dots are a parameter pack, and (values + ... + 0) is a fold expression — it expands to value1 + value2 + value3 + 0 at compile time.
def total(*values): result = 0 for value in values: result += value return result print(total(1, 2, 3)) print(total(1.5, 2.25))
#include <iostream> // A parameter pack: any number of arguments, of any types that support +. template <typename... Values> auto total(Values... values) { return (values + ... + 0); // fold expression } int main() { std::cout << total(1, 2, 3) << std::endl; std::cout << total(1.5, 2.25) << std::endl; return 0; }
The mechanism is completely different even though the call sites match. Python collects the arguments into a tuple at run time and loops over it; C++ generates a separate function for each combination of argument types it sees, with the additions written out. That is why the second call returns a double without anything being said about types, and why a call with an argument that cannot be added is a compile error rather than a TypeError. There is no **kwargs equivalent at all.
Returning a container is cheap now
Returning a vector by value used to be the classic C++ performance mistake and has not been one for over a decade. The compiler constructs the result directly in the caller's variable, so nothing is copied.
def build(count): return [value * value for value in range(count)] values = build(5) print(" ".join(str(value) for value in values))
#include <iostream> #include <vector> std::vector<int> build(int count) { std::vector<int> result; result.reserve(count); for (int value = 0; value < count; ++value) { result.push_back(value * value); } return result; // moved, not copied } int main() { std::vector<int> values = build(5); for (std::size_t index = 0; index < values.size(); ++index) { std::cout << (index ? " " : "") << values[index]; } std::cout << std::endl; return 0; }
This matters because older C++ advice — take an output parameter by reference, return an error code — is still all over the internet and is no longer the right default. Write the signature that says what the function means. reserve is worth the line when you know the size: a vector that grows by push_back reallocates and copies its contents several times on the way, which is the kind of cost Python's list also pays but nobody profiles.
Classes
A class, and where self went
The colon after the constructor's parameter list starts a member initializer list — it constructs value from start rather than assigning to it afterwards, which is the idiomatic form and the only option for members that cannot be reassigned.
class Counter: def __init__(self, start=0): self.value = start def increment(self, by=1): self.value += by return self.value counter = Counter(10) counter.increment() print(counter.increment(5))
#include <iostream> class Counter { public: Counter(int start = 0) : value(start) {} int increment(int by = 1) { value += by; // no self. — members are in scope return value; } private: int value; }; int main() { Counter counter(10); counter.increment(); std::cout << counter.increment(5) << std::endl; return 0; }
There is no self parameter because the object is passed invisibly, and members are simply in scope inside a method. The bigger change is private: it is enforced by the compiler rather than signalled by an underscore, so code outside the class cannot touch value at all. Everything in a class is private until you say otherwise, which is the only difference between class and struct in C++.
The shape of an object is fixed
A C++ object is a fixed block of memory whose size the compiler computed — which is what sizeof prints — so there is nowhere for a new attribute to go.
class Point: def __init__(self, x, y): self.x = x self.y = y point = Point(1, 2) point.z = 3 # a new attribute, on this instance only Point.describe = lambda self: f"({self.x}, {self.y})" # a new method, on the class print(point.z, point.describe())
#include <format> #include <iostream> #include <string> struct Point { int x; int y; // Every member and every method is decided here, at compile time. std::string describe() const { return std::format("({}, {})", x, y); } }; int main() { Point point{1, 2}; // point.z = 3; // ← uncomment: 'struct Point' has no member named 'z' std::cout << point.describe() << std::endl; std::cout << "bytes per Point: " << sizeof(Point) << std::endl; return 0; }
The whole family of Python techniques that depend on adding to an object later has no C++ equivalent: no monkey patching, no setattr, no mixin applied at run time, no mock that replaces one method on one instance. In exchange, a member access compiles to an offset from a pointer rather than a dictionary lookup, and a misspelled member is a build error. The const after describe() is a promise the method does not modify the object, and the compiler holds you to it.
Overriding only works if the base said so
🚨 virtual is the word that makes overriding work. Delete it from the base class and the program still compiles, still runs, and prints area: 0 — the base version — because the call is resolved from the static type.
class Shape: def area(self): return 0 class Square(Shape): def area(self): return 4 def report(shape): print("area:", shape.area()) report(Square())
#include <iostream> struct Shape { virtual double area() const { return 0; } // virtual — dispatch at run time virtual ~Shape() = default; // needed to delete through a base pointer }; struct Square : Shape { double area() const override { return 4; } }; void report(const Shape& shape) { std::cout << "area: " << shape.area() << std::endl; } int main() { Square square; report(square); return 0; }
Python resolves every method call by looking in the instance's type at the moment of the call, so overriding always works. C++ resolves non-virtual calls at compile time from the declared type of the expression, so a base class has to opt in to being overridden. override on the derived method is not required but should always be written: it makes the compiler check that you really are overriding something, which catches a changed signature in the base class. The virtual destructor is the other half — deleting a Square through a Shape* without it is undefined behavior.
Dunder methods become operators
Printing is not a method on the class — it is an overload of << taking the output stream on the left, which is why it is written as a free function and declared friend so it may read the private field.
class Money: def __init__(self, cents): self.cents = cents def __add__(self, other): return Money(self.cents + other.cents) def __str__(self): return f"${self.cents / 100:.2f}" print(Money(150) + Money(275))
#include <format> #include <iostream> class Money { public: explicit Money(int cents) : cents_(cents) {} Money operator+(const Money& other) const { return Money(cents_ + other.cents_); } friend std::ostream& operator<<(std::ostream& stream, const Money& money) { return stream << std::format("${:.2f}", money.cents_ / 100.0); } private: int cents_; }; int main() { std::cout << (Money(150) + Money(275)) << std::endl; return 0; }
The correspondence is close: __add__ is operator+, __eq__ is operator==, __len__ has no equivalent because size is a method. Two things Python has no need of: explicit on the constructor, which stops Money being created accidentally from a bare integer in a function call, and the fact that you may overload operators for types you did not write — the << above extends std::ostream without touching it, which is a kind of open-classes power C++ does have.
Properties become plain functions
There is no @property, so a computed value is a method and the call site says so with parentheses.
class Rectangle: def __init__(self, width, height): self.width = width self.height = height @property def area(self): return self.width * self.height rectangle = Rectangle(3, 4) print(rectangle.area) # no parentheses — it looks like a field
#include <iostream> class Rectangle { public: Rectangle(int width, int height) : width_(width), height_(height) {} int area() const { return width_ * height_; } // parentheses, always private: int width_; int height_; }; int main() { Rectangle rectangle(3, 4); std::cout << rectangle.area() << std::endl; return 0; }
This is a small syntactic loss and a real design gain: in C++ you can always tell by looking whether rectangle.area is a stored field or arbitrary code, and in Python you cannot. The consequence for a library author is the opposite of Python's advice — you cannot start with a public field and quietly turn it into a property later, because every call site would have to gain parentheses, so C++ codebases tend to write the accessor from the beginning.
Templates vs Duck Typing
Generic code, checked in advance
A template is not one function that accepts anything — it is a recipe from which the compiler writes a separate function for each type you actually use. Both calls below produce different machine code.
def largest(values): best = values[0] for value in values[1:]: if value > best: best = value return best print(largest([3, 9, 2])) print(largest(["pear", "apple", "plum"]))
#include <iostream> #include <string> #include <vector> template <typename Element> Element largest(const std::vector<Element>& values) { Element best = values[0]; for (const Element& value : values) { if (value > best) best = value; } return best; } int main() { std::cout << largest<int>({3, 9, 2}) << std::endl; std::cout << largest<std::string>({"pear", "apple", "plum"}) << std::endl; return 0; }
This is duck typing moved to compile time, and the duck test is the same one: the template works for any type that supports > and copying. The difference is when you find out. Python tells you at the moment the comparison happens, possibly in production; C++ tells you at build time, in an error message that is famously enormous because it explains the failure in terms of the instantiated types. Concepts, in the next row, exist to make that message readable.
Concepts are Protocols with teeth
A concept names the requirement — here, "you can write a < b and get something bool-ish" — and the compiler checks a type against it before instantiating anything.
from typing import Protocol class Comparable(Protocol): def __lt__(self, other) -> bool: ... def smallest(values: list[Comparable]) -> Comparable: best = values[0] for value in values: if value < best: best = value return best print(smallest([3, 9, 2]))
#include <concepts> #include <iostream> #include <vector> // The requirement, written down and enforced by the compiler. template <typename Element> concept LessThanComparable = requires(Element a, Element b) { { a < b } -> std::convertible_to<bool>; }; template <LessThanComparable Element> Element smallest(const std::vector<Element>& values) { Element best = values[0]; for (const Element& value : values) { if (value < best) best = value; } return best; } int main() { std::cout << smallest<int>({3, 9, 2}) << std::endl; return 0; }
The two ideas line up almost exactly, and the difference is enforcement: a Protocol is checked by mypy if you run it, while a concept is checked by the compiler always. The practical payoff is the error message. Without the concept, passing a type with no < produces pages of instantiation backtrace; with it, the compiler says the constraint was not satisfied and names it. This is the single biggest quality-of-life change in modern C++ for anyone writing generic code.
Work done before the program runs
constexpr means "this may be evaluated at compile time", and assigning to a constexpr variable means "it must be". static_assert then checks the answer before the program exists.
def factorial(number): return 1 if number <= 1 else number * factorial(number - 1) # Computed every time the program runs. print(factorial(10))
#include <iostream> constexpr int factorial(int number) { return number <= 1 ? 1 : number * factorial(number - 1); } int main() { // Computed by the COMPILER; the binary contains the number 3628800. constexpr int result = factorial(10); static_assert(result == 3628800, "checked at build time"); std::cout << result << std::endl; return 0; }
There is no Python analog, and this is one of the genuinely different capabilities rather than a syntax difference: lookup tables, parsers for format strings and entire unit systems get evaluated away, leaving constants in the binary. Modern C++ has pushed this a long way — most of the standard library is usable in a constexpr context, including std::vector and std::string. The failure mode to know about is that a static_assert failure is a compile error with no stack trace, because there is no stack.
Memory & Lifetime
The closing brace destroys things
Watch the order of the three lines each program prints. The C++ destructor runs at the closing brace, always, before anything after the call.
class Noisy: def __init__(self, name): self.name = name print("made", self.name) def __del__(self): print("collected", self.name) def work(): thing = Noisy("inner") print("working") work() print("after work()")
#include <iostream> #include <string> struct Noisy { std::string name; Noisy(std::string name) : name(std::move(name)) { std::cout << "made " << this->name << std::endl; } ~Noisy() { std::cout << "destroyed " << name << std::endl; } }; void work() { Noisy thing("inner"); std::cout << "working" << std::endl; } // ← thing is destroyed HERE, deterministically int main() { work(); std::cout << "after work()" << std::endl; return 0; }
CPython's reference counting usually gets you the same order, which is why this row looks like agreement — but it is a promise Python does not make. A reference cycle, an exception holding a traceback, or simply a different interpreter defers __del__ indefinitely, which is why Python has with statements for anything that must be released on time. C++ has no with because the destructor is the mechanism, and it fires even when an exception unwinds through the function.
with becomes a destructor
The bare braces are the whole construct. A scope is opened, the object is created, and the closing brace ends its life — which is what a with statement is spelled out longhand.
class Section: def __init__(self, name): self.name = name def __enter__(self): print("begin", self.name) return self def __exit__(self, *unused): print("end", self.name) with Section("load"): print(" doing the work") print("after the block")
#include <iostream> #include <string> class Section { public: Section(std::string name) : name_(std::move(name)) { std::cout << "begin " << name_ << std::endl; } ~Section() { std::cout << "end " << name_ << std::endl; } private: std::string name_; }; int main() { { Section section("load"); std::cout << " doing the work" << std::endl; } std::cout << "after the block" << std::endl; return 0; }
This pattern has a name, RAII, and it is the organizing idea of the whole language: a file handle, a lock, a database transaction and an allocation are all objects whose destructors release what they hold. It is why C++ does not need finally, why it does not need with, and why "who closes this" is answered by the type rather than by the caller remembering. A Python programmer's instinct to write cleanup at the end of a function is the habit to drop.
Ownership, written down
std::unique_ptr is a pointer that owns what it points at and cannot be copied — so at any moment exactly one variable is responsible for freeing the object, and the compiler enforces it.
class Resource: def __init__(self, label): self.label = label # Every name is an equal co-owner; the object dies when the last one goes. first = Resource("buffer") second = first del first print("still alive:", second.label)
#include <iostream> #include <memory> #include <string> struct Resource { std::string label; ~Resource() { std::cout << "freed " << label << std::endl; } }; int main() { auto owner = std::make_unique<Resource>("buffer"); std::cout << "alive: " << owner->label << std::endl; // auto second = owner; // ← will not compile: not copyable auto second = std::move(owner); // ownership transferred, explicitly std::cout << "moved. owner is now empty: " << (owner == nullptr) << std::endl; std::cout << "second holds: " << second->label << std::endl; return 0; }
std::move does not move anything; it is a cast that says "you may take this from me", and afterwards the source is empty and must not be used except to reassign or destroy it. Notice what happened without a garbage collector: the resource is freed exactly once, at the end of main, with no reference counting at run time. This is the closest thing C++ has to Rust's ownership, arrived at from the other direction and without a borrow checker.
shared_ptr is CPython's refcount, made visible
Every CPython object carries a reference count that the interpreter maintains for you. std::shared_ptr is the same mechanism, opted into per object, with the count visible.
import sys class Node: pass node = Node() alias = node # The count is an implementation detail of CPython, and it is +1 here # because getrefcount's own argument is a reference too. print("references:", sys.getrefcount(node) - 1)
#include <iostream> #include <memory> struct Node { ~Node() { std::cout << "node destroyed" << std::endl; } }; int main() { auto node = std::make_shared<Node>(); std::cout << "references: " << node.use_count() << std::endl; { auto alias = node; std::cout << "references: " << node.use_count() << std::endl; } std::cout << "references: " << node.use_count() << std::endl; return 0; }
Understanding this makes the C++ memory model feel much less alien: you already live in a reference-counted world, you simply never had to write it down. The two also share the same weakness — a cycle of shared_ptrs never reaches zero and leaks, exactly as a CPython cycle would if the cycle collector did not exist. C++ has no cycle collector, so it has std::weak_ptr, which you break the cycle with by hand. The other half of the comparison is cost: a shared pointer's count is atomic, so copying one is more expensive than copying a raw pointer, which is why unique_ptr is the default choice and shared_ptr is for genuinely shared ownership.
Growing a container can invalidate what points into it
A vector keeps its elements in one contiguous block. When it runs out of room it allocates a bigger block and moves everything, and every reference, pointer and iterator into the old block becomes garbage — which is what the two capacity numbers are showing you.
values = [1, 2, 3] # A Python name always refers to the object, so appending changes nothing # about how you reach the first element. first = values[0] values.append(4) print("first is still", first) print("list is", values)
#include <iostream> #include <vector> int main() { std::vector<int> values{1, 2, 3}; std::cout << "capacity before: " << values.capacity() << std::endl; // A reference INTO the vector is only valid while the storage stays put. int& first = values[0]; std::cout << "first is " << first << std::endl; values.push_back(4); // may reallocate the whole buffer std::cout << "capacity after: " << values.capacity() << std::endl; // Reading first here is undefined behavior if the capacity changed. std::cout << "first is now " << values[0] << std::endl; // safe: re-read return 0; }
This is the bug class with no Python counterpart, and it is why the example re-reads values[0] instead of using first after the push. A Python name cannot dangle: it holds the object, and the object moves nowhere. In C++ the rule to internalize is that anything derived from a container's storage — references, pointers, iterators, spans — has a lifetime tied to that storage, and the operations that end it are documented per container. std::list and std::map do not invalidate on insertion; std::vector does.
Error Handling
try/except becomes try/catch
Catch by const reference, always. Catching by value copies the exception and can slice a derived type down to its base, losing the very information you were catching it for.
def parse(text): if not text.isdigit(): raise ValueError(f"not a number: {text}") return int(text) try: print(parse("42")) print(parse("oops")) except ValueError as error: print("caught:", error)
#include <iostream> #include <stdexcept> #include <string> int parse(const std::string& text) { for (char character : text) { if (character < '0' || character > '9') { throw std::invalid_argument("not a number: " + text); } } return std::stoi(text); } int main() { try { std::cout << parse("42") << std::endl; std::cout << parse("oops") << std::endl; } catch (const std::invalid_argument& error) { std::cout << "caught: " << error.what() << std::endl; } return 0; }
The mechanics line up, but the culture does not. Python uses exceptions for ordinary control flow — StopIteration ends every loop — while C++ reserves them for genuinely exceptional conditions, partly because the cost is paid when one is thrown and partly because whole domains (games, embedded, some finance) compile with exceptions disabled entirely. There is no except: catching everything by accident; catch (...) exists and is deliberately ugly.
There is no finally
The destructor is the finally. When the exception unwinds out of work, every object that was constructed on the way in is destroyed on the way out, in reverse order.
def work(): try: print("acquiring") raise RuntimeError("boom") finally: print("released") # runs whether or not it raised try: work() except RuntimeError as error: print("caught:", error)
#include <iostream> #include <stdexcept> struct Held { Held() { std::cout << "acquiring" << std::endl; } ~Held() { std::cout << "released" << std::endl; } }; void work() { Held held; // destructor runs during unwinding throw std::runtime_error("boom"); } int main() { try { work(); } catch (const std::runtime_error& error) { std::cout << "caught: " << error.what() << std::endl; } return 0; }
This is why the language never grew a finally clause, and it is a better answer than Python's in one specific way: the cleanup is written once, in the type, rather than at every place the resource is used. A function that acquires three things does not need three nested try blocks. The obligation it puts on you is real, though — any type that holds something must have a correct destructor, and writing one badly is how leaks and double-frees happen.
Errors as return values
std::expected<int, std::string> is "an int, or an error" — one return value holding either, with the error type spelled out in the signature.
def parse(text): try: return int(text), None except ValueError: return None, f"not a number: {text}" value, error = parse("oops") if error: print("failed:", error) else: print("parsed", value)
#include <expected> #include <iostream> #include <string> std::expected<int, std::string> parse(const std::string& text) { for (char character : text) { if (character < '0' || character > '9') { return std::unexpected("not a number: " + text); } } return std::stoi(text); } int main() { auto result = parse("oops"); if (!result) { std::cout << "failed: " << result.error() << std::endl; } else { std::cout << "parsed " << *result << std::endl; } return 0; }
C++23 added this because exceptions are not always available or wanted, and the older alternative — return a code and write the result through a pointer — loses the type. Python has no equivalent in the standard library, and the tuple-of-value-and-error convention shown on the left is borrowed from Go rather than idiomatic. What the C++ version buys is that the caller cannot reach the value without acknowledging the error case: *result on a failed expected is undefined, so the if is not optional in practice.
assert disappears in release builds
Both languages let a build flag delete assertions, and both mean the same thing by it: an assertion documents a bug, not a runtime condition you expect to happen.
def average(values): assert values, "average of an empty list" return sum(values) / len(values) print(f"{average([1, 2, 4]):.2f}") # python3 -O removes the assert entirely.
#include <cassert> #include <format> #include <iostream> #include <vector> double average(const std::vector<double>& values) { assert(!values.empty() && "average of an empty list"); double total = 0; for (double value : values) total += value; return total / values.size(); } int main() { std::cout << std::format("{:.2f}", average({1, 2, 4})) << std::endl; // Compiling with -DNDEBUG removes the assert entirely. return 0; }
The one C++ specific worth knowing is static_assert, which is checked at compile time and cannot be turned off — it is the right tool for anything about types or sizes rather than values. The shared rule matters more: never put work with side effects inside an assertion, because a release build will not run it. This bites harder in C++ than in Python, since -DNDEBUG is the default in most release configurations while python3 -O is rarely used.
Threads & the GIL
Threads that actually run at the same time
Four threads, one counter, one mutex — and on the C++ side the four threads are genuinely running on four cores. std::lock_guard is RAII again: it locks on construction and unlocks at the closing brace, including when an exception unwinds.
# CPython holds a global interpreter lock, so two threads doing # arithmetic take turns rather than running together. Under the free-threaded # build (PEP 703) that is finally changing, but it is not yet the default. # # This block is illustrative: the browser runtime has no threads at all. import threading total = 0 lock = threading.Lock() def add_many(): global total for _ in range(100000): with lock: total += 1 threads = [threading.Thread(target=add_many) for _ in range(4)] for thread in threads: thread.start() for thread in threads: thread.join() print(total)
#include <iostream> #include <mutex> #include <thread> #include <vector> int main() { long total = 0; std::mutex guard; auto add_many = [&]() { for (int step = 0; step < 100000; ++step) { std::lock_guard<std::mutex> lock(guard); ++total; } }; std::vector<std::thread> workers; for (int index = 0; index < 4; ++index) workers.emplace_back(add_many); for (std::thread& worker : workers) worker.join(); std::cout << total << std::endl; return 0; }
This is usually the reason a Python programmer is reading this page at all. The global interpreter lock means CPython threads interleave rather than overlap for anything CPU-bound, so the standard advice is multiprocessing and its copying costs. C++ threads are operating-system threads with shared memory and no interpreter lock — which is the speedup and also the danger, because a data race in C++ is undefined behavior rather than a wrong number. Python 3.13 introduced a free-threaded build that removes the lock; it is opt-in and still maturing.
async/await and where it went
std::async starts the work and hands back a std::future; calling .get() waits for the answer. It is the closest thing in the standard library to asyncio.gather, and it uses real threads rather than an event loop.
import asyncio async def work(name, seconds): await asyncio.sleep(seconds) return f"{name} done" async def main(): results = await asyncio.gather( work("first", 0.02), work("second", 0.01), ) for result in results: print(result) asyncio.run(main())
#include <chrono> #include <future> #include <iostream> #include <string> #include <thread> std::string work(const std::string& name, int milliseconds) { std::this_thread::sleep_for(std::chrono::milliseconds(milliseconds)); return name + " done"; } int main() { auto first = std::async(std::launch::async, work, "first", 20); auto second = std::async(std::launch::async, work, "second", 10); std::cout << first.get() << std::endl; // blocks until ready std::cout << second.get() << std::endl; return 0; }
C++20 added coroutines with co_await, which are much closer to async/await in spirit — but the standard library ships no event loop to drive them, so every practical use goes through a third-party framework such as Asio or cppcoro. That is the honest state of the art: the language feature exists, the batteries do not, and most production C++ concurrency is still threads, futures and a thread pool somebody wrote.
Calling C++ From Python
A pybind11 module, both halves
Two files, one boundary. The macro at the bottom is the whole registration: it names the module and exposes one function, and pybind11/stl.h is what makes a Python list arrive as a std::vector without a line of conversion code.
# What the caller writes, once the module below is built and importable. # # import fastmath # print(fastmath.total([1, 2, 3, 4])) # 10 # print(fastmath.__doc__) # # The import looks like any other. There is no ctypes-style declaration of # argument types at the call site, because the C++ side already declared them.
// Built as a shared library named fastmath.so, not as a program. // // g++ -O3 -Wall -shared -std=c++23 -fPIC \ // $(python3 -m pybind11 --includes) fastmath.cpp \ // -o fastmath$(python3-config --extension-suffix) #include <pybind11/pybind11.h> #include <pybind11/stl.h> // makes std::vector <-> list conversion automatic #include <vector> namespace pybind11_namespace = pybind11; long total(const std::vector<long>& values) { long sum = 0; for (long value : values) sum += value; return sum; } PYBIND11_MODULE(fastmath, module) { module.doc() = "arithmetic that does not hold the interpreter lock"; module.def("total", &total, "Add every element of a list of integers"); }
This is the shape of every fast Python package you use. The cost that is easy to miss is the conversion: pybind11/stl.h copies the list into a vector on the way in, so a function called in a tight loop over a large list can spend more time converting than computing — which is why NumPy-facing bindings take a buffer or an py::array_t instead, and touch the same memory the array already owns.
Releasing the interpreter lock
🚨 This is the line that turns a C++ extension from "faster" into "parallel", and it is one declaration. While the lock is released, other Python threads run.
# Why it matters, from the caller's side: # # import threading, fastmath # threads = [threading.Thread(target=fastmath.crunch, args=(50_000_000,)) # for _ in range(4)] # for thread in threads: thread.start() # for thread in threads: thread.join() # # With the lock released inside crunch, these four threads use four cores. # Without it, they take turns and the program is no faster than one thread.
#include <pybind11/pybind11.h> namespace pybind11_namespace = pybind11; double crunch(long iterations) { // The extension holds the interpreter lock on entry. Nothing else in // Python can run until it is given back. pybind11_namespace::gil_scoped_release released; double total = 0; for (long step = 1; step <= iterations; ++step) { total += 1.0 / static_cast<double>(step); } return total; // The released object is a destructor away from re-acquiring the lock — // RAII doing the same job it does everywhere else in the language. } PYBIND11_MODULE(fastmath, module) { module.def("crunch", &crunch); }
🚨 The rule that goes with it is absolute: touch nothing Python-related while the lock is released — no py::object, no reference counting, no exception into Python. Doing so is not an error message but a crash or silent corruption, and it is the most common serious bug in hand-written extensions. Note that the release is an object whose destructor re-acquires, so an early return or a thrown exception restores the lock correctly; that is RAII doing the same job it does everywhere else in the language.
ctypes: no C++ at all
extern "C" switches off name mangling for that function, so the symbol in the library is called total rather than _Z5totalPKll and ctypes can find it by name.
# The other direction: no build system, no headers, no pybind11 — # just a shared library and a declaration of what is in it. # # import ctypes # library = ctypes.CDLL("./libfastmath.so") # library.total.argtypes = [ctypes.POINTER(ctypes.c_long), ctypes.c_long] # library.total.restype = ctypes.c_long # # values = (ctypes.c_long * 4)(1, 2, 3, 4) # print(library.total(values, 4)) # # 🚨 Get argtypes wrong and there is no error — just a wrong number or a crash.
// For ctypes to find it, the symbol must not be name-mangled — so the // interface is declared extern "C" even though the body is C++. #include <numeric> #include <vector> extern "C" long total(const long* values, long count) { return std::accumulate(values, values + count, 0L); } // Compile as a plain shared library, with no Python headers anywhere: // // g++ -O3 -shared -fPIC -std=c++23 fastmath.cpp -o libfastmath.so
The trade against pybind11 is worth stating plainly. ctypes needs no build-time dependency on Python at all and works with a library you did not compile — but every type is declared twice, in two languages, with nothing checking that the two declarations agree. pybind11 generates the boundary from the real C++ signature, so it cannot drift, at the cost of compiling against the Python headers of the exact interpreter you will run on. cffi sits between them, and Cython is a fourth option that compiles a Python-like language down to the same C API.
Sharing an array instead of copying it
This is the boundary that matters at scale: array_t<double> plus request() gives C++ a pointer into NumPy's own memory, so a ten-million-element array crosses the boundary at no cost.
# The call site that makes the difference, with a NumPy array: # # import numpy, fastmath # data = numpy.arange(10_000_000, dtype=numpy.float64) # print(fastmath.total_in_place(data)) # # No list is built, nothing is converted, and the C++ function reads the # same memory NumPy already allocated.
#include <pybind11/numpy.h> #include <pybind11/pybind11.h> namespace pybind11_namespace = pybind11; double total_in_place(pybind11_namespace::array_t<double> array) { // request() hands back a description of the existing buffer: // a raw pointer, the element count, the shape and the strides. auto buffer = array.request(); const double* values = static_cast<double*>(buffer.ptr); double total = 0; for (pybind11_namespace::ssize_t index = 0; index < buffer.size; ++index) { total += values[index]; } return total; } PYBIND11_MODULE(fastmath, module) { module.def("total_in_place", &total_in_place); }
The buffer protocol is the reason the scientific Python stack works at all — NumPy, PyTorch, Polars and Arrow all agree on a way to describe a block of memory, so a C++ library can read what a Python library allocated without a copy. Two things to be careful with: check buffer.strides rather than assuming the array is contiguous, since a slice of an array usually is not, and hold the interpreter lock while the array object could be resized or freed under you.
Imports, Builds & Tooling
import brings in a module; #include pastes a file
#include is not an import. The preprocessor copies the named file into yours before the compiler sees anything, which is why headers need include guards and why compilation is slow.
import math from collections import Counter print(int(math.sqrt(16))) print(Counter("mississippi")["s"]) # The module is found on sys.path, executed once, and cached in sys.modules.
#include <cmath> // the preprocessor literally pastes these files in #include <iostream> #include <map> #include <string> int main() { std::cout << static_cast<int>(std::sqrt(16)) << std::endl; std::map<char, int> counts; for (char character : std::string("mississippi")) counts[character]++; std::cout << counts['s'] << std::endl; return 0; }
Two practical consequences. There is no namespacing by file — everything included lands in the same translation unit, which is why the standard library lives in the std namespace and why using namespace std; is discouraged in anything but a toy. And there is no equivalent of Python's single execution and cache, so the same header is re-parsed for every source file that includes it. C++20 modules fix this properly and are still rolling out unevenly across compilers and build systems.
Declaring before using
A name has to be declared before it is used, because the compiler reads the file once from top to bottom. The one-line declaration is the promise; the definition may come later, or from another file entirely.
def outer(): return inner() # inner does not exist yet, and that is fine def inner(): return "found at call time" print(outer())
#include <iostream> #include <string> std::string inner(); // a declaration: promises it exists somewhere std::string outer() { return inner(); // legal now, because of the line above } std::string inner() { return "found at call time"; } int main() { std::cout << outer() << std::endl; return 0; }
This split is the whole reason headers exist: the header holds declarations so every file can be compiled independently, and the linker matches them to definitions afterwards. It also explains the two error messages a newcomer confuses — "undeclared identifier" comes from the compiler and means it never saw the promise, while "undefined reference" comes from the linker and means the promise was never kept. Python has no such distinction because a function body is not looked at until it runs.
pip install has no equivalent
This is the part of the move that costs the most time and that no tutorial warns about. The reason there is no pip for C++ is not neglect — it is that a compiled artifact is only compatible with code built the same way.
# One command, one virtual environment, one lock file: # # python3 -m venv .venv # source .venv/bin/activate # pip install requests numpy # # Everything is a wheel: precompiled, resolved by a central index, and # installed into a directory the interpreter already searches. print("dependency handling is a solved problem here")
// There is no equivalent, and this is the honest answer. // // CMakeLists.txt, plus one of: // vcpkg install fmt (Microsoft's package manager) // conan install . (the other one) // FetchContent_Declare(...) (CMake downloads and builds the source) // git submodule add ... (vendor it and build it yourself) // // A dependency is source that YOUR build compiles with YOUR flags, because // a C++ library compiled with different flags, standard version or standard // library may not link with your code at all. #include <iostream> int main() { std::cout << "dependency handling is not a solved problem here" << std::endl; return 0; }
CMake is the closest thing to a standard, and it is a build system rather than a package manager; vcpkg and Conan sit on top of it and are both in wide use without either winning. Expect to spend real time on this, expect the first build of a new dependency to fail, and treat a library that ships a working CMakeLists.txt as a meaningful sign of quality. The one genuine advantage: because you build from source with your own flags, you can debug into your dependencies, which is a thing a wheel does not let you do.

Thank you — anything else?