PONYλM2Modula-2

C++.CodeCompared.To/Python

An interactive executable cheatsheet comparing C++ and Python

C++23 (GCC) Python 3.14
Hello World & Running
Hello, World
One line, no includes, no main, no braces, no semicolon, and no build step. It is worth sitting with how much of the C++ version is ceremony rather than program.
#include <iostream> int main() { std::cout << "Hello, World!" << std::endl; return 0; }
print("Hello, World!")
A Python file is executed top to bottom the moment it is handed to the interpreter, so statements at module level run immediately — there is no entry point to declare. That also means the file is both a script and a module: importing it runs every top-level statement, which is why the if __name__ == "__main__" guard exists (see the next row). print adds the newline and flushes on a terminal, so it behaves like std::cout << … << std::endl rather than like printf.
No compile step
The absence of a build is the obvious difference. The consequence that actually changes how you work is what the missing build was checking.
// Edit, compile, link, run — and a type error stops you at step two: // // g++ -std=c++23 -Wall main.cpp report.cpp -o report // ./report // // A misspelled name in a branch you never take is still a build failure. #include <iostream> #include <string> int main() { std::string name = "world"; // std::cout << naem; // caught at COMPILE time std::cout << "hello, " << name << std::endl; return 0; }
# Edit, run: # # python3 report.py # # There is a compile step — to bytecode — but it only checks SYNTAX. # A misspelled name in a branch you never take is not an error until # that branch actually runs. name = "world" if False: print(naem) # never runs, so never raises NameError print(f"hello, {name}")
Python compiles to bytecode, but the compiler only verifies that the source parses — names, attributes, argument counts and types are all resolved when the line executes. A typo on an error path therefore ships, and is found by the customer rather than the build. That single fact is why Python projects lean so hard on tests and on external checkers (mypy, pyright, ruff) which do statically what your compiler does for free. Budget for running them; they are not optional in the way they might look.
The __main__ guard
This idiom is in essentially every Python file you will read, and it exists to recover a guarantee C++ gets from the linker.
#include <iostream> // main() is special to the linker: it runs only for the executable that // defines it, never when this object is merely linked in. void report() { std::cout << "report ran" << std::endl; } int main() { report(); return 0; }
# A module has no privileged entry point, so importing this file runs # every top-level statement in it. The guard is what distinguishes # "run directly" from "imported by something else". def report(): print("report ran") if __name__ == "__main__": report()
__name__ is a string every module carries: "__main__" when the file is the one being run, otherwise the module's own name. Without the guard, importing a module for one function also executes its demo code, its argument parsing, and anything else at top level. The rule of thumb is that a module's top level should only define things — functions, classes, constants — and anything that does something belongs under the guard or inside a function.
The Object Model
Assignment binds a name, it does not copy
If you read only one row on this page, read this one. Almost every Python surprise a C++ programmer hits is a consequence of it.
#include <iostream> #include <vector> int main() { std::vector<int> first{1, 2, 3}; std::vector<int> second = first; // a COPY: two independent vectors second.push_back(4); std::cout << first.size() << " " << second.size() << std::endl; return 0; }
first = [1, 2, 3] second = first # NOT a copy: two names for one list second.append(4) print(len(first), len(second))
A Python name is a reference to an object, always — there is no value semantics anywhere in the language and no way to ask for it. second = first makes a second name for the same list, so the C++ version prints 3 4 and the Python version prints 4 4. When you do want a copy you ask: second = first.copy() or list(first) for a shallow one, copy.deepcopy(first) for a recursive one. Integers and strings appear to have value semantics only because they are immutable — there is nothing you can do through one name that another name could observe.
Everything is an object, including functions and classes
C++ has objects and it also has things that are not objects — types, functions, templates, namespaces. In Python that second category is empty.
#include <iostream> int double_it(int value) { return value * 2; } int main() { // A function has an address, but it is not a first-class value: // it has no members, cannot be extended, and its type is baked in. int (*operation)(int) = double_it; std::cout << operation(21) << std::endl; std::cout << sizeof(int) << std::endl; return 0; }
def double_it(value): return value * 2 # A function is an ordinary object: it can be bound to a name, stored in # a list, given attributes, and asked about itself at runtime. operation = double_it operation.note = "doubles its argument" print(operation(21)) print(operation.__name__, operation.note) print(type(int), isinstance(int, object))
Functions, classes, modules and even int itself are ordinary runtime objects with attributes you can read and set. This is what makes decorators, metaclasses and monkey-patching possible, and it is why introspection in Python needs no separate reflection facility — type(), dir() and getattr() are just ordinary function calls. The cost is that essentially nothing can be resolved at compile time, which is the other half of why Python is slow (see the performance section).
The mutable default argument trap
A C++ default argument is re-evaluated at every call. A Python default is evaluated once, at definition time, and the resulting object is reused forever.
#include <iostream> #include <vector> // The default is an expression evaluated at each CALL, so every call // that omits the argument gets a fresh empty vector. void collect(int value, std::vector<int> into = std::vector<int>()) { into.push_back(value); std::cout << into.size() << std::endl; } int main() { collect(1); collect(2); return 0; }
# The default is evaluated ONCE, when the def executes — so all calls # that omit the argument share one list. This is the single most famous # Python gotcha, and it is a direct consequence of the object model. def collect(value, into=None): if into is None: into = [] into.append(value) print(len(into)) collect(1) collect(2)
Written the obvious way — def collect(value, into=[]) — this prints 1 then 2, because both calls append to the same list object created when the def ran. The None sentinel above is the standard fix and you will see it constantly. The rule is simple once you know it: never use a mutable object ([], {}, set(), or an instance) as a default. Immutable defaults — numbers, strings, None, tuples — are perfectly safe, because sharing them is unobservable.
is vs ==
Both operators exist in Python and they are not interchangeable, which trips people up because for small integers and short literals they often agree by accident.
#include <iostream> #include <string> int main() { std::string first = "hello"; std::string second = "hel"; second += "lo"; // == compares VALUE; comparing addresses is the explicit thing. std::cout << std::boolalpha; std::cout << (first == second) << " " << (&first == &second) << std::endl; return 0; }
first = "hello" second = "hel" second += "lo" # == compares value; `is` compares IDENTITY — same object, not equal object. print(first == second, first is second)
== calls __eq__ and compares values; is compares object identity and is equivalent to comparing addresses. They coincide for interned objects — CPython caches small integers and some string literals — so a is b can be True for two separately-computed 5s and False for two separately-computed 500s, which is exactly the kind of behavior you must never rely on. Use is only for singletons, which in practice means is None, is True, is False.
Types & Type Hints
Types belong to values, not names
C++ types are attached to declarations. Python types are attached to objects, and a name is only a label currently pointing at one.
#include <iostream> #include <string> int main() { int value = 42; // value = "forty-two"; // error: no viable conversion std::string text = std::to_string(value); std::cout << value << " " << text << std::endl; return 0; }
value = 42 print(value, type(value).__name__) # The NAME has no type, so rebinding it to a different kind of object # is not an error — it is just another assignment. value = "forty-two" print(value, type(value).__name__)
Nothing prevents a name from referring to an int on one line and a str on the next, which is occasionally useful and much more often a bug the compiler would have caught for you. Note that "dynamically typed" is not "weakly typed": Python is strict about what operations a value supports, so "3" + 4 raises TypeError rather than guessing — unlike JavaScript, and unlike C's implicit conversions. The checking simply happens when the line runs.
Type hints are annotations, not enforcement
Type hints look like C++ declarations and do a fundamentally different job: they are documentation that a separate tool can check, not a constraint the runtime applies.
#include <iostream> #include <string> #include <vector> // The types are the contract, and the compiler enforces it. double average(const std::vector<double>& values) { if (values.empty()) { return 0.0; } double total = 0.0; for (double value : values) { total += value; } return total / static_cast<double>(values.size()); } int main() { std::cout << average({1.0, 2.0, 6.0}) << std::endl; return 0; }
# The annotations are real syntax and are stored on the function, but # the interpreter does NOT check them. mypy or pyright does, separately. def average(values: list[float]) -> float: if not values: return 0.0 return sum(values) / len(values) print(average([1.0, 2.0, 6.0])) # Passing the wrong type is not a runtime error — it works, because # sum() and len() happen to accept it. print(average([1, 2, 6])) print(average.__annotations__)
The interpreter parses annotations, stores them in __annotations__, and otherwise ignores them entirely — so a hint that is simply wrong costs nothing at runtime and is caught only if someone runs a checker. Treat them the way you would treat a const you cannot rely on: enormously valuable for readers and for editors, worthless as a guarantee. In a codebase of any size, wire mypy or pyright into continuous integration, because that is the only thing that makes the annotations mean anything.
Integers do not overflow
Python has one integer type, it is unbounded, and that removes a whole category of C++ bug at a cost you should know about.
#include <cstdint> #include <iostream> #include <limits> int main() { // Fixed width. Exceeding it is undefined (signed) or wraps (unsigned). std::int64_t big = std::numeric_limits<std::int64_t>::max(); std::cout << big << std::endl; // 2^70 does not fit in any built-in integer type. double approximate = 1.0; for (int power = 0; power < 70; power += 1) { approximate *= 2.0; } std::cout << approximate << std::endl; return 0; }
# int is arbitrary precision. There is no maximum and no overflow — # it grows until you run out of memory. big = 2 ** 63 - 1 print(big) print(2 ** 70) # exact, not approximate print(len(str(2 ** 1000)))
No overflow means no undefined behavior, no -Wsign-compare, and no need to choose a width — hashing, factorials and cryptographic arithmetic just work. The cost is that a Python int is a heap object with a sign and a digit array, so arithmetic is perhaps fifty times slower than a machine word and each value costs 28 bytes or more. When that matters the answer is not to fight it but to leave the interpreter: numpy arrays hold real fixed-width integers, and those wrap silently, which surprises people coming the other way.
Strings
Strings are immutable
A Python string cannot be modified. Every operation that looks like modification builds a new string and rebinds the name.
#include <iostream> #include <string> int main() { std::string greeting = "hello"; greeting[0] = 'H'; // in-place, no allocation greeting += ", world"; // may reallocate, but the object is the same std::cout << greeting << std::endl; return 0; }
greeting = "hello" # greeting[0] = "H" # TypeError: str does not support assignment greeting = "H" + greeting[1:] # a NEW string; the old one is discarded greeting += ", world" # also a new string print(greeting)
This is why building a string in a loop with += is O(n²) in Python — each step copies everything so far — and why the idiom is "".join(pieces) instead, which allocates once. Immutability is what makes strings hashable, so they can be dictionary keys, and it is why passing a string to a function is always safe. Indexing yields a one-character string rather than a character type, because Python has no char: greeting[0] is "h", itself a string of length one.
Formatting
C++20's std::format borrowed its grammar from Python, so the format specifiers will already look familiar. What f-strings add is putting the expression at the placeholder.
#include <format> #include <iostream> #include <string> int main() { std::string product = "widget"; int quantity = 7; double price = 3.5; std::string line = std::format("{} x{} at {:.2f}", product, quantity, price); std::cout << line << std::endl; return 0; }
product = "widget" quantity = 7 price = 3.5 # The expression goes INSIDE the braces — there is no separate argument # list to keep in sync. line = f"{product} x{quantity} at {price:.2f}" print(line) print(f"{quantity * 2 = }") # the = suffix prints the expression too
Because the expression is inline there is no argument list to fall out of order with the placeholders, which is the mistake printf and std::format both still permit. The specifiers after the colon are nearly identical to C++'s — :.2f, :>10, :,, :x — since C++ adopted them. The = suffix is a debugging convenience with no C++ counterpart: f"{value = }" prints both the expression source and its result.
Text and bytes are different types
Python 3 split text from bytes into two types that refuse to mix. C++ has one type doing both jobs, and the encoding lives in your head.
#include <iostream> #include <string> int main() { // std::string is a sequence of BYTES with no encoding attached. std::string greeting = "naïve"; std::cout << "bytes: " << greeting.size() << std::endl; std::size_t characters = 0; for (unsigned char byte : greeting) { if ((byte & 0xC0) != 0x80) { characters += 1; } } std::cout << "characters: " << characters << std::endl; return 0; }
# str is a sequence of CODE POINTS. bytes is a separate type, and the # two never mix implicitly. greeting = "naïve" print("bytes:", len(greeting.encode("utf-8"))) print("characters:", len(greeting)) # "naïve" + b"x" # TypeError: can't concat str to bytes
A str is indexed by code point, so len counts characters and greeting[2] is "ï" rather than half of it. Bytes come from .encode() and go back with .decode(), and every I/O boundary is one of those two — which is why Python 3 forced the migration that broke so much Python 2 code. The discipline it imposes is worth importing back into C++: decide at each boundary whether you hold text or bytes, and never let a function be vague about which.
Collections
list is a vector of references
A Python list is the closest thing to std::vector, with one structural difference that explains both its flexibility and its cost.
#include <iostream> #include <vector> int main() { // Contiguous storage of the VALUES themselves. Homogeneous by type. std::vector<int> readings{12, 7, 30}; readings.push_back(4); std::cout << readings.size() << " " << readings[0] << std::endl; std::cout << sizeof(readings[0]) << " bytes per element" << std::endl; return 0; }
# Contiguous storage of POINTERS to objects, so the elements may be # anything at all and none of them are stored inline. readings = [12, 7, 30] readings.append(4) readings.append("not a number") print(len(readings), readings[0]) print(readings[-1]) # negative indexing counts from the end
The array holds pointers, not values, so a list of a million integers is a million pointers plus a million separate heap objects — perhaps 20 times the memory of a std::vector<int>, with none of the cache locality. That is the price of heterogeneity, and when it matters the answer is array.array or a numpy array, both of which store values inline. Negative indexing has no C++ equivalent and is genuinely handy: readings[-1] is the last element, and slicing (readings[1:3]) makes a new list rather than a view.
dict, and insertion order
Python has one mapping type where C++ has two, and its iteration order is a third thing again — neither sorted nor arbitrary.
#include <iostream> #include <map> #include <string> #include <unordered_map> int main() { // Two types: ordered by key (std::map) or unordered (hash). std::map<std::string, int> stock; stock["widget"] = 7; stock["gadget"] = 3; // std::map iterates in KEY order, so gadget comes first. for (const auto& entry : stock) { std::cout << entry.first << "=" << entry.second << " "; } std::cout << std::endl; return 0; }
# One type. Hashed, and guaranteed to iterate in INSERTION order # since Python 3.7 — a language guarantee, not an implementation detail. stock = {"widget": 7, "gadget": 3} for name, count in stock.items(): print(f"{name}={count}", end=" ") print() print(stock.get("sprocket", 0)) # default instead of KeyError
Insertion order is guaranteed, which makes dict useful for things std::unordered_map cannot do (round-tripping JSON, ordered configuration) and means output is reproducible across runs. There is no sorted-map equivalent; when you need key order you write sorted(stock.items()). Note the two lookup styles: stock["missing"] raises KeyError — matching .at(), not operator[], since Python never inserts on read — while .get(key, default) is the non-raising form. collections.defaultdict is the one that does insert.
Comprehensions instead of algorithms
The comprehension is Python's idiomatic answer to a filter-and-transform pipeline, and its reading order is the thing to get used to.
#include <iostream> #include <ranges> #include <vector> int main() { std::vector<int> readings{1, 2, 3, 4, 5, 6}; auto pipeline = readings | std::views::filter([](int value) { return value % 2 == 0; }) | std::views::transform([](int value) { return value * value; }); for (int value : pipeline) { std::cout << value << " "; } std::cout << std::endl; return 0; }
readings = [1, 2, 3, 4, 5, 6] # The filter and the transform read in the opposite order from the # pipeline: the output expression comes FIRST. squares = [value * value for value in readings if value % 2 == 0] for value in squares: print(value, end=" ") print()
A C++ ranges pipeline reads left to right in execution order; a comprehension puts the result expression first and the source and condition after, which takes a few days to feel natural. Square brackets build a list eagerly; swapping them for parentheses gives a generator expression, which is lazy exactly like a view and is the right choice when the result is only iterated once. Dict and set comprehensions use braces: {name: len(name) for name in words}.
Tuples and unpacking
Returning several values needs a named type in C++ and needs nothing at all in Python — the comma is the tuple constructor.
#include <iostream> #include <tuple> std::tuple<int, int> divide(int numerator, int denominator) { return {numerator / denominator, numerator % denominator}; } int main() { // C++17 structured bindings — the closest equivalent. auto [quotient, remainder] = divide(17, 5); std::cout << quotient << " " << remainder << std::endl; return 0; }
def divide(numerator, denominator): return numerator // denominator, numerator % denominator # Unpacking is ordinary assignment syntax, not a special construct. quotient, remainder = divide(17, 5) print(quotient, remainder) first, *rest = [1, 2, 3, 4] # star absorbs the remainder print(first, rest) quotient, remainder = remainder, quotient # swap, no temporary print(quotient, remainder)
Unpacking is plain assignment, so it works anywhere assignment does: in a for target (for name, count in stock.items()), in function parameters, and in the swap idiom above, which needs no temporary because the right-hand side is fully evaluated first. The starred form has no C++ equivalent and is genuinely useful for splitting a head from a tail. Note // for integer division: plain / always produces a float in Python 3, so 17 / 5 is 3.4, which is a real difference from C++ integer division.
Control Flow
Indentation is the block structure
The syntax difference everyone mentions first, and the reason for it is worth more than the novelty.
#include <iostream> int main() { int temperature = 31; // Braces delimit; indentation is convention. These can disagree, // which is how the "goto fail" class of bug happens. if (temperature > 30) { std::cout << "hot" << std::endl; std::cout << "stay inside" << std::endl; } std::cout << "done" << std::endl; return 0; }
temperature = 31 # Indentation delimits. There are no braces, so the layout and the # structure cannot disagree with each other. if temperature > 30: print("hot") print("stay inside") print("done")
Because there is no second mechanism, the visual structure and the actual structure are the same thing — the class of bug where a statement looks like it is inside an if and is not simply cannot occur. The rules to know: four spaces is the near-universal convention (PEP 8), tabs and spaces must not be mixed, and a block cannot be empty — use pass where C++ would use {}. Line continuation inside brackets is implicit, so a long call may span lines without a backslash.
Truthiness
Python extends boolean conversion to every type, and the rule is broad enough to be worth memorizing rather than guessing at.
#include <iostream> #include <string> #include <vector> int main() { std::vector<int> readings; std::string name = ""; // Only numbers and pointers convert to bool. A container does not, // so emptiness is asked about explicitly. if (readings.empty()) { std::cout << "no readings" << std::endl; } if (name.empty()) { std::cout << "no name" << std::endl; } if (0) { std::cout << "unreachable" << std::endl; } return 0; }
readings = [] name = "" # Empty containers, empty strings, zero and None are all falsy. if not readings: print("no readings") if not name: print("no name") if not 0: print("zero is falsy too")
Falsy values are: False, None, zero of any numeric type, and every empty container or string. Everything else is truthy, including "0", "False" and [0]. The idiom if not readings is preferred over if len(readings) == 0. The trap to watch for is that if value: and if value is not None: differ whenever 0 or "" is a legitimate value — which is exactly when a function returns a count or a name that might be empty.
switch vs structural match
Python's match arrived in 3.10 and is not a switch: it destructures, and it matches types and shapes rather than values alone.
#include <iostream> #include <string> #include <variant> int main() { std::variant<int, std::string> message = std::string("hello"); // switch works on integral types only, so a variant needs visit. std::visit([](const auto& value) { using Held = std::decay_t<decltype(value)>; if constexpr (std::is_same_v<Held, int>) { std::cout << "number " << value << std::endl; } else { std::cout << "text " << value << std::endl; } }, message); return 0; }
message = "hello" # match (3.10+) matches on STRUCTURE and type, not just on integers. match message: case int() as number: print("number", number) case str() as text: print("text", text) case [first, *rest]: print("a list starting with", first) case {"kind": kind}: print("a mapping of kind", kind) case _: print("something else")
The patterns can test type (int()), bind names (as number), destructure sequences ([first, *rest]) and mappings ({"kind": kind}), and nest arbitrarily — which covers the ground std::visit, if constexpr and structured bindings share between them in C++. Two warnings: it is not exhaustiveness-checked, so a missing case silently falls through to nothing without a case _, and a bare name in a pattern always binds rather than compares, so case SOME_CONSTANT: does not do what it appears to.
Functions
Keyword arguments
This is the feature a C++ programmer is most likely to miss once they have used it, and it does real work beyond convenience.
#include <iostream> #include <string> // Positional only. Six booleans at a call site are unreadable and there // is nothing the language offers to fix it. void connect(const std::string& host, int port, bool secure, bool retry) { std::cout << std::boolalpha << host << ":" << port << " secure=" << secure << " retry=" << retry << std::endl; } int main() { connect("example.com", 443, true, false); return 0; }
def connect(host, port=80, *, secure=False, retry=False): print(f"{host}:{port} secure={secure} retry={retry}") # Any parameter may be passed by name, in any order. connect("example.com", 443, secure=True) connect(port=8080, host="localhost") # Everything after the bare * is keyword-ONLY, so a stray positional # boolean cannot silently land in the wrong slot. # connect("example.com", 443, True) # TypeError
Any parameter can be passed by name, so the call site documents itself and argument order stops mattering. The bare * is the part worth adopting deliberately: every parameter after it must be passed by keyword, which makes the boolean-trap call connect(host, port, True, False) a TypeError rather than a puzzle. C++ has no equivalent — the nearest approximations are a strongly-typed options struct or designated initializers, both of which cost more and give less.
Variadic arguments without templates
C++ needs a template and a fold expression for this. Python needs two sigils, and it works at runtime with ordinary values.
#include <iostream> // Variadic templates: compile-time, type-safe, and a fold expression. template <typename... Values> void report(Values... values) { ((std::cout << values << " "), ...); std::cout << std::endl; } int main() { report(1, "two", 3.0); return 0; }
def report(*values, **options): for value in values: print(value, end=" ") print(options) # *values collects extra positionals into a tuple, # **options collects extra keywords into a dict. report(1, "two", 3.0, loud=True) arguments = [1, 2, 3] report(*arguments) # and the star UNPACKS on the way in
*values collects surplus positional arguments into a tuple and **options collects surplus keyword arguments into a dict; the same two sigils unpack at a call site, so report(*arguments) spreads a list into separate parameters. This is how decorators forward arbitrary calls, and how wrapper functions stay agnostic about what they wrap. The trade against variadic templates is the familiar one: no compile-time type checking, no monomorphization, and the packing costs a real tuple and dict at every call.
Decorators
A decorator is nothing exotic: it is a function that takes a function and returns a replacement, bound back to the original name.
#include <iostream> // Wrapping a function means writing a second function under a NEW name // and then visiting every call site to use it instead. int square(int value) { return value * value; } int announced_square(int value) { std::cout << "calling square" << std::endl; return square(value); } int main() { std::cout << announced_square(7) << std::endl; // call site changed return 0; }
import functools def announced(wrapped): @functools.wraps(wrapped) def wrapper(*args, **kwargs): print(f"calling {wrapped.__name__}") return wrapped(*args, **kwargs) return wrapper # The decorator REBINDS the name, so every existing call site is # wrapped without being touched. @announced def square(value): return value * value print(square(7)) print(square.__name__)
@announced above is exactly square = announced(square), which is why no call site changes. This is only possible because functions are ordinary objects that can be passed around and rebound, and it is the mechanism behind most Python framework magic you will meet — @property, @staticmethod, @functools.cache, Flask's @app.route, pytest's fixtures. @functools.wraps copies the name and docstring onto the wrapper, without which introspection and tracebacks report the wrapper instead of the real function.
Classes
self is explicit, and there is no private
Two differences here, and the second one is a genuine philosophical divergence rather than a missing feature.
#include <iostream> class Counter { public: Counter() : total_(0) {} void increment(int by) { total_ += by; } int total() const { return total_; } private: int total_; // callers genuinely cannot reach this }; int main() { Counter counter; counter.increment(5); // counter.total_ = 99; // error: private std::cout << counter.total() << std::endl; return 0; }
class Counter: def __init__(self): self._total = 0 # leading _ means "internal", by convention def increment(self, by): # self is a real, explicit parameter self._total += by @property def total(self): # a method that reads like an attribute return self._total counter = Counter() counter.increment(5) counter._total = 99 # nothing stops this. It is just rude. print(counter.total)
self is the receiver written out — the same hidden parameter C++ calls this, made visible and required in every method signature. Access control, meanwhile, does not exist: a single leading underscore is a convention meaning "not part of the interface", and a double underscore only mangles the name rather than protecting it. Python's position is that the author of the calling code is an adult who may need the escape hatch. The compensating feature is @property, which lets a plain attribute become a computed one later without changing any caller — so the C++ habit of writing a getter for every field up front is unnecessary here.
dataclass vs the rule of five
Both languages have grown a way to stop hand-writing the boilerplate that every value type needs. They arrived at similar places from opposite directions.
#include <iostream> struct Point { int x; int y; bool operator==(const Point& other) const = default; }; std::ostream& operator<<(std::ostream& stream, const Point& point) { return stream << "Point(x=" << point.x << ", y=" << point.y << ")"; } int main() { Point origin{0, 0}; Point same{0, 0}; std::cout << std::boolalpha << origin << " " << (origin == same) << std::endl; return 0; }
from dataclasses import dataclass @dataclass class Point: x: int y: int # __init__, __repr__ and __eq__ are all generated from the annotations. origin = Point(0, 0) same = Point(0, 0) print(origin, origin == same)
The @dataclass decorator reads the class's annotations and generates __init__, __repr__ and __eq__ — the constructor, the stream operator and operator==, in C++ terms. Options extend it: frozen=True makes instances immutable and hashable, order=True adds the comparison operators, slots=True drops the per-instance dict for a real memory saving. Note that the annotations are load-bearing here in a way they usually are not — the dataclass machinery reads them at runtime even though the interpreter still does not check them.
Operator overloading via dunder methods
Python overloads operators through specially named methods, and the set of things you can hook is much wider than C++'s operator list.
#include <iostream> class Money { public: explicit Money(int cents) : cents_(cents) {} Money operator+(const Money& other) const { return Money(cents_ + other.cents_); } int cents() const { return cents_; } private: int cents_; }; int main() { Money total = Money(150) + Money(275); std::cout << total.cents() << std::endl; return 0; }
class Money: def __init__(self, cents): self.cents = cents def __add__(self, other): # the + operator return Money(self.cents + other.cents) def __repr__(self): # how it prints return f"Money({self.cents})" def __len__(self): # len() works on it return self.cents total = Money(150) + Money(275) print(total, len(total))
The double-underscore names — "dunder" methods — are the protocol the interpreter calls: __add__ for +, __eq__ for ==, but also __len__ for len(), __iter__ for for, __getitem__ for indexing and slicing, __enter__/__exit__ for with, and __call__ to make an instance callable. That means built-in functions and statements are customizable, not just operators. What Python does not let you overload is assignment (there is nothing to overload — see the object-model section) and, notably, and/or/not.
Duck Typing vs Templates
Duck typing is a template without the compile step
Duck typing is closer to templates than to inheritance, and framing it that way makes the trade obvious rather than mysterious.
#include <iostream> #include <string> // A template accepts anything that supports the operations used, and // checks that at INSTANTIATION time. template <typename Speaker> void introduce(const Speaker& speaker) { std::cout << speaker.speak() << std::endl; } struct Dog { std::string speak() const { return "woof"; } }; struct Robot { std::string speak() const { return "beep"; } }; int main() { introduce(Dog{}); introduce(Robot{}); return 0; }
# The same structural acceptance, checked when the line RUNS. # There is no declaration to write at all. def introduce(speaker): print(speaker.speak()) class Dog: def speak(self): return "woof" class Robot: def speak(self): return "beep" introduce(Dog()) introduce(Robot())
Both accept any type that structurally supports the operations used, and neither requires a common base class — a C++ template and a Python function are equally happy with unrelated types. The difference is purely when the check happens: instantiation time for the template, call time for Python. So the template produces a compile error and specialized machine code, while Python produces an AttributeError at the moment the bad call is reached and one generic implementation. C++20 concepts are the constrained version; the next row is Python's.
Concepts vs Protocols
A Protocol is the closest thing Python has to a concept: a structural interface a type satisfies by shape rather than by declaration.
#include <iostream> #include <string> // A concept names the required operations, so the constraint is // checked at the interface rather than deep in the body. template <typename Value> concept Speaking = requires(const Value value) { { value.speak() } -> std::convertible_to<std::string>; }; template <Speaking Speaker> void introduce(const Speaker& speaker) { std::cout << speaker.speak() << std::endl; } struct Dog { std::string speak() const { return "woof"; } }; int main() { introduce(Dog{}); return 0; }
from typing import Protocol, runtime_checkable # A Protocol names the required operations for a STATIC checker. # runtime_checkable additionally allows isinstance — and it tests only # that the METHOD NAMES are present, never their signatures. @runtime_checkable class Speaking(Protocol): def speak(self) -> str: ... def introduce(speaker: Speaking) -> None: print(speaker.speak()) class Dog: # no base class, no registration def speak(self) -> str: return "woof" introduce(Dog()) print(isinstance(Dog(), Speaking))
Dog satisfies Speaking without inheriting from it or registering anywhere — the match is structural, exactly as with a C++ concept. What differs is where the check lands: mypy or pyright verifies it, so a violation is a lint failure rather than a compile error. @runtime_checkable buys back an isinstance test, with a caveat worth knowing — it checks only that the method names exist, never their signatures, so a speak taking three arguments passes. Python's other interface mechanism, abc.ABC, is nominal instead — a class must explicitly inherit from it — which makes it the analogue of an abstract base class rather than of a concept.
Lifetime & Cleanup
Reference counting, and why __del__ is not a destructor
This is the second-biggest adjustment after the object model, and the trap is that __del__ usually works — which is exactly what makes relying on it dangerous.
#include <iostream> #include <string> class Logger { public: explicit Logger(std::string name) : name_(std::move(name)) { std::cout << "open " << name_ << std::endl; } ~Logger() { std::cout << "close " << name_ << std::endl; } private: std::string name_; }; int main() { { Logger logger("audit"); } // GUARANTEED to close here std::cout << "after scope" << std::endl; return 0; }
class Logger: def __init__(self, name): self.name = name print("open", self.name) def __del__(self): # NOT a destructor. No guarantee it runs. print("close", self.name) def use_logger(): logger = Logger("audit") use_logger() # refcount hits zero, so it happens to run print("after call")
CPython refcounts, so an object is usually collected the moment its last reference goes away, and __del__ usually runs promptly. Usually. A reference cycle defers it to the cycle collector, an exception traceback holds frames alive, and interpreter shutdown may skip it entirely — and other implementations such as PyPy do not refcount at all. So __del__ is a last-resort safety net, never a cleanup mechanism. The construct that does give C++-style deterministic cleanup is with, in the next row.
RAII becomes the with statement
The with statement is Python's answer to RAII, and it is a good one — but the scope it protects is the block, not the object's lifetime.
#include <iostream> #include <string> class Transaction { public: explicit Transaction(std::string name) : name_(std::move(name)) { std::cout << "begin " << name_ << std::endl; } ~Transaction() { std::cout << "end " << name_ << std::endl; } private: std::string name_; }; void work() { Transaction transaction("payment"); std::cout << "working" << std::endl; } // end runs here, exception or not int main() { work(); return 0; }
from contextlib import contextmanager @contextmanager def transaction(name): print("begin", name) try: yield finally: print("end", name) # runs on exit, exception or not with transaction("payment"): print("working")
The guarantee is genuinely comparable: the exit code runs whether the block finishes normally, returns, or raises. The difference is that it is tied to a block rather than to an object, so it does not compose through data members the way a C++ destructor does — an object owning three resources cannot simply hold three members and let the compiler sort it out; it must manage them itself or be used inside a with. This is why with open(...) as file: is the only correct way to open a file in Python, and why contextlib.ExitStack exists for the dynamic case.
Error Handling
Exceptions are ordinary control flow
The mechanism is familiar. The culture around it is not, and using C++ instincts here will make your Python un-idiomatic.
#include <iostream> #include <stdexcept> #include <string> int main() { // Exceptions are for EXCEPTIONAL cases; the cost of throwing is // high enough that using them for control flow is discouraged. try { int value = std::stoi("not a number"); std::cout << value << std::endl; } catch (const std::invalid_argument& problem) { std::cout << "not a number" << std::endl; } std::cout << "done" << std::endl; return 0; }
# Exceptions are cheap and idiomatic — "easier to ask forgiveness than # permission". Even the end of a for loop is a StopIteration exception. try: value = int("not a number") print(value) except ValueError: print("not a number") else: print("this runs only if nothing raised") finally: print("done")
Python programs use exceptions where C++ would check first, a style with the acronym EAFP — easier to ask forgiveness than permission. It is not merely tolerated but expected: iteration ends by raising StopIteration, and attribute lookup failing raises rather than returning a sentinel. Two clauses have no C++ counterpart: else runs when the try body raised nothing, which keeps the protected region narrow, and finally runs regardless — the closest thing to a destructor guarantee outside with. Catch specific types, never bare except:, which also swallows KeyboardInterrupt.
Raising and defining exceptions
Defining an exception type is a one-liner, and every exception shares a single root — which makes catching by category reliable in a way C++ cannot quite match.
#include <iostream> #include <stdexcept> #include <string> class ConfigError : public std::runtime_error { public: explicit ConfigError(const std::string& message) : std::runtime_error(message) {} }; int main() { try { throw ConfigError("port must be positive"); } catch (const std::runtime_error& problem) { std::cout << "caught: " << problem.what() << std::endl; } return 0; }
class ConfigError(Exception): """Raised when the configuration cannot be used.""" try: raise ConfigError("port must be positive") except Exception as problem: print(f"caught: {problem}") print(type(problem).__name__, isinstance(problem, Exception))
Every exception derives from BaseException, and everything you would normally catch derives from Exception, so except Exception genuinely covers the error cases while still letting KeyboardInterrupt and SystemExit through. C++ has no such guarantee: throw 42 is legal, so catch (const std::exception&) can miss things and catch (...) gives you no object. Note as problem binds the exception, that the class body here is just a docstring, and that re-raising is a bare raise which preserves the original traceback.
Iteration & Generators
The iterator protocol
Python's iterator is a single object that yields values until it raises, rather than a pair of positions compared for inequality.
#include <iostream> #include <vector> int main() { std::vector<int> readings{12, 7, 30}; // Two iterators and an inequality test — the range-for hides it, // but a custom type must supply begin(), end() and operator++. for (auto position = readings.begin(); position != readings.end(); ++position) { std::cout << *position << " "; } std::cout << std::endl; return 0; }
readings = [12, 7, 30] # One object with __next__, and a StopIteration exception to end it. # The for statement is sugar for exactly this: iterator = iter(readings) while True: try: value = next(iterator) except StopIteration: break print(value, end=" ") print()
A type becomes iterable by defining __iter__ (returning an iterator) and the iterator defines __next__ (returning the next value or raising StopIteration). There is no notion of a position, so there is no random access, no reverse iteration, no iterator arithmetic and no way to compare two iterators — but also no iterator invalidation to reason about and no way to construct a mismatched pair. This single-object design is what makes an infinite sequence expressible, which is what the next row is about.
Generators — coroutines you already understand
A generator is a coroutine with a very small surface: one keyword, and the function's local state survives between calls.
#include <iostream> #include <vector> // Producing a sequence lazily means writing a state machine class, or // building the whole thing eagerly. C++20 coroutines help, but the // std::generator type only landed in C++23 and needs the machinery. std::vector<long long> fibonacci_up_to(int count) { std::vector<long long> values; long long previous = 0; long long current = 1; for (int index = 0; index < count; index += 1) { values.push_back(previous); long long next = previous + current; previous = current; current = next; } return values; } int main() { for (long long value : fibonacci_up_to(10)) { std::cout << value << " "; } std::cout << std::endl; return 0; }
# yield turns the function into a generator: it suspends, keeps its # locals, and resumes where it left off. Nothing is precomputed. def fibonacci(): previous, current = 0, 1 while True: # genuinely infinite, and that is fine yield previous previous, current = current, previous + current from itertools import islice for value in islice(fibonacci(), 10): print(value, end=" ") print()
Reaching a yield hands a value to the caller and freezes the frame — locals, instruction pointer and all — until the next value is requested. That is what lets fibonacci be an infinite loop and still terminate: it produces one value per request and nothing is precomputed. Memory is O(1) rather than O(n), which is the usual reason to reach for one. C++23's std::generator is the direct equivalent and arrived roughly twenty years later. Note the tuple assignment doing the two-variable update with no temporary.
Modules & Packaging
import is not #include
These look like the same feature and are not remotely the same mechanism, which is why C++ build times are a topic and Python's are not.
// #include is TEXTUAL: the preprocessor pastes the file in, every // time, in every translation unit that asks. Include order can matter, // include guards are mandatory, and compile time grows with the paste. #include <iostream> #include <string> #include <vector> int main() { std::vector<std::string> names{"ada", "grace"}; std::cout << names.size() << std::endl; return 0; }
# import BINDS A NAME to a module object. The module is executed once # per process and cached in sys.modules; importing it again is a dict # lookup, and import order almost never matters. import math from collections import Counter print(math.sqrt(16)) print(Counter("mississippi")["s"]) import sys print("math" in sys.modules)
A module is executed exactly once, the first time anything imports it, and thereafter every import is a lookup in sys.modules — so there is no textual duplication, no include guard, and no combinatorial recompilation. Two consequences to plan for: because import executes the module, top-level side effects run at import time, and because modules are cached, circular imports fail in confusing partially-initialized ways rather than looping forever. Prefer from module import name over from module import *, which pollutes the namespace unpredictably.
Dependencies and virtual environments
The tooling story is the mirror image of C++'s: one obvious answer for finding libraries, and a mandatory ritual for isolating them.
// C++ has no standard package manager. The options are the system // package manager, vcpkg, Conan, a git submodule, or vendoring the // source — and a project usually ends up with more than one. // // vcpkg install fmt // find_package(fmt CONFIG REQUIRED) // target_link_libraries(app PRIVATE fmt::fmt) // // Dependencies are installed system-wide or per-toolchain; two // projects needing different versions of the same library is a // problem you solve yourself. #include <iostream> int main() { std::cout << "linked against whatever the build system found" << std::endl; return 0; }
# One package manager, one index, and per-project isolation: # # python3 -m venv .venv — a private interpreter + site-packages # source .venv/bin/activate # pip install requests — installs INTO that venv only # pip freeze > requirements.txt — pin what you got # # Two projects needing different versions of the same library is not a # problem, because they do not share a site-packages directory. import sys print(sys.version_info.major, sys.version_info.minor) print("prefix differs from base_prefix inside a venv:", sys.prefix != sys.base_prefix)
The rule to internalize is that a virtual environment is not optional — installing into the system Python breaks the system's own tools, and recent Python releases actively refuse it. Every project gets its own .venv, which is the isolation a C++ toolchain gives you by accident when each project has its own build directory. The modern packaging file is pyproject.toml, and faster front-ends such as uv and poetry are widely used, but they all install into the same per-project directory.
Performance & the GIL
What a loop actually costs
The rule of thumb is one to two orders of magnitude, and knowing why tells you which loops to worry about.
#include <iostream> int main() { // Each iteration is a few machine instructions on a register. long long total = 0; for (int index = 0; index < 1000000; index += 1) { total += index; } std::cout << total << std::endl; return 0; }
# Each iteration allocates an int object, dispatches through the # bytecode loop, and does a dictionary-free but still indirect add. # Roughly 50-100x slower than the C++ loop. total = 0 for index in range(1000000): total += index print(total) # The idiomatic fix is to not write the loop: push it into C. print(sum(range(1000000)))
Every operation goes through the interpreter loop, every intermediate value is a heap-allocated object, and every method is looked up by name at call time — none of which can be optimized away, because any of them could be replaced at runtime. The practical consequence is not "avoid Python" but "do not write the inner loop in Python": sum(range(...)) above runs the whole loop in C. This is the pattern behind numpy, pandas and every scientific library — Python orchestrates, compiled code iterates.
The GIL, and what threads are still for
This is the single most important thing a C++ programmer needs to know before designing a Python system, because it invalidates the obvious plan.
#include <iostream> #include <thread> #include <vector> int main() { std::vector<std::thread> workers; // Real parallelism: four cores, four threads, four times the work // per unit of wall-clock time. for (int worker_number = 0; worker_number < 4; worker_number += 1) { workers.emplace_back([worker_number]() { long long total = 0; for (int index = 0; index < 1000000; index += 1) { total += index; } }); } for (std::thread& worker : workers) { worker.join(); } std::cout << "4 threads, 4 cores, 4x throughput" << std::endl; return 0; }
# Illustrative — this page's runtime is single-threaded WebAssembly, # so threading here would not demonstrate anything about the GIL. # # import threading # # workers = [threading.Thread(target=work) for _ in range(4)] # # On a standard CPython build ONE thread holds the Global Interpreter # Lock and executes bytecode at a time, so four CPU-bound threads take # as long as one — sometimes longer, from lock contention. # # What the GIL does NOT block: # - I/O: the lock is released around every blocking read/write, # so threads are genuinely useful for network and disk # - C code: numpy, compressors and crypto release it around the # compute they do outside the interpreter # # For CPU-bound parallelism the tool is multiprocessing (separate # interpreters, separate GILs, data passed by pickling), or the # free-threaded build (PEP 703), optional since 3.13. print("threads: concurrency yes, CPU parallelism no")
The Global Interpreter Lock means a standard CPython process executes bytecode on one thread at a time, so threading gives you concurrency without CPU parallelism. That is still useful — the lock is released around blocking I/O and around compiled extension work, so a threaded network client or a numpy workload does scale — but four CPU-bound Python threads do not go four times faster. The answers are multiprocessing, which pays a serialization cost to get separate interpreters, or the free-threaded build from PEP 703, optional since 3.13 and still stabilizing.
Calling C++ from Python
This is usually why a C++ programmer is reading about Python in the first place, so it is worth ending on what the boundary actually costs.
// The C++ side of a pybind11 module — an ordinary function plus a // small block describing how to expose it: // // #include <pybind11/pybind11.h> // // int add(int first, int second) { return first + second; } // // PYBIND11_MODULE(fastmath, module) { // module.def("add", &add, "Add two integers"); // } // // Built as a shared library named fastmath.so, then imported from // Python as though it were an ordinary module. #include <iostream> int add(int first, int second) { return first + second; } int main() { std::cout << add(2, 3) << std::endl; return 0; }
# From Python the extension is indistinguishable from a normal module: # # import fastmath # fastmath.add(2, 3) # # The cost that matters is the BOUNDARY, not the C++ code. Each call # converts arguments, acquires the GIL, and converts the result back — # order a microsecond. So the rule is to cross it rarely with a lot of # work, not often with a little. # Standing in for the extension: math is itself written in C. import math print(math.gcd(1071, 462)) print(type(math.gcd).__name__) # builtin_function_or_method
The options in rough order of adoption are pybind11 (header-only, modern C++, the common choice), nanobind (its faster successor), Cython (compile annotated Python), and ctypes/cffi (call an existing shared library with no build step). All of them share the design rule above: a call across the boundary costs around a microsecond, so a function called once with a million-element array is free and the same function called a million times on one element is a disaster. Design the interface around bulk operations, which is exactly why numpy looks the way it does.