Branch: CS, Computer Science Foundations (Quant-Code region) · Node CS1.03 · ~18 focused hours · Standard mastery gate ≥ 85%
You have now written two modules' worth of Python (CS1.01, CS1.02): variables, functions, control flow, lists, dicts, comprehensions, clean classes. Every one of those names has felt almost weightless, you type x = 5 and it simply is 5, forever, wherever you use it, with no visible cost and no visible location. That weightlessness is a carefully built illusion, and it is a good one: it is exactly what lets you think about a discounted-cash-flow model instead of about registers. But the illusion has a cost of its own, which is that a whole category of real bugs: 0.1 + 0.2 refusing to equal 0.3, a copied list that somehow still shares its insides with the original, a function argument that silently "remembers" something across unrelated calls, a recursive function that eventually just stops with a cryptic error: become unexplainable. They read like the language is broken. It is not. Every one of them is a direct, predictable consequence of a physical fact you have not yet been shown: every value your program touches is, underneath, nothing but a fixed-width pattern of bits sitting in a specific numbered box of memory, put there by a machine that only ever does a tiny handful of primitive things, read a box, write a box, add two bit patterns, jump to a different instruction.
Advertisement
Here is the whole module compressed into one sentence you will spend eighteen hours earning the right to believe: memory comes in exactly two flavors with opposite lifetimes (a fast, disciplined stack and a flexible, chaotic heap); numbers and text are bit patterns interpreted according to a convention someone chose (two's complement for integers, IEEE-754 for reals, UTF-8 for text); and your Python source text becomes a running process only because of a second, separate journey (compile, assemble, link, load) that Python's own interpreter conveniently hides from you but a second, lower-level language makes impossible to ignore. Once "bits, in boxes, under a convention" is the actual substance you picture under every abstraction, an entire class of "impossible" bugs stops being impossible and starts being predictable, in some cases predictable enough that you can name the failure before you ever run the code.
One promise for the hours ahead. You will be able to look at 0.1 + 0.2 == 0.3 returning False and not shrug it off as "a float thing," but explain exactly which bit pattern is being compared to which, in a ₹ reconciliation and a $ invoice check alike, because money is exactly where this bug costs something real. You will be able to look at a function that silently leaks state across calls that never passed it anything, and name the CPython object-lifetime mechanism responsible, not paste a fix you half-understand from a forum. And you will be able to read a short, unfamiliar C program, a pointer, a struct, a couple of malloc calls, and correctly predict either its printed output or the precise way it misbehaves, without ever compiling it, because the environment this module's exercises run in (Pyodide, the in-browser Python this app uses) cannot compile C at all. That limitation is not a compromise this module works around; it is the point. Everything C teaches you here, you learn by reading and reasoning under uncertainty, which is exactly the skill you need for the rest of your career, the day you are handed someone else's fifteen-year-old C++ pricing library with no test suite and no author left to ask.
App link. This node's code pack (CS1.03, 8 exercises) is where the live, gradeable half of this material sits. Python exercises that execute in this app's Pyodide sandbox and JavaScript exercises that execute in its JS sandbox, each shipped with hidden tests you never see and a fully offline structured fill-in-the-blank fallback, so every exercise is completable with zero runtime if a browser cannot run code at all. The C material below is deliberately not in that pack (Pyodide cannot compile C) so every C item in this module, in both the Worked Examples and the Practice set, is trace-the-code, predict-the-output, or fill-in-the-blank, self-graded against a written solution, with a pointer to Compiler Explorer (godbolt.org) in Reading & Resources for anyone who wants to actually compile and run it outside this app.
Learning objectives
By the end you can:
- Define a bit, a byte, a word, and a memory address, and describe the two regions of memory every running program uses (the stack and the heap) stating what lives in each and why the two have opposite lifetimes and opposite failure modes.
- Represent a small integer in binary, explain two's complement and why every modern CPU uses it instead of sign-magnitude, and predict exactly how and where a fixed-width integer overflows and wraps around.
- State what endianness is (byte order) and why a raw byte-level tool like
DataViewforces you to say which one you mean, where an ordinary C variable's byte order is normally invisible. - Describe the three fields of an IEEE-754 floating-point number (sign, exponent, mantissa) and use that structure to explain, mechanically (not as folklore) why an ordinary decimal like
0.1has no exact binary representation, and predict which comparisons and sums this breaks. - Distinguish a Unicode code point from a byte, tell the ASCII → Unicode → UTF-8 story accurately, and compute the UTF-8 byte length of a string by hand, directly from its code points.
- Trace the compile → assemble → link → load → run pipeline for a compiled language, stating what each stage does and which single category of error each stage, and only that stage: is capable of catching.
- Read a short C program using pointers, arrays, and structs, and correctly predict its printed output, its
sizeof, or its undefined-behavior failure mode, by tracing alone, never by compiling. - Use Python's own introspection tools (
id,is,sys.getsizeof) to observe CPython's memory model directly and correctly: object identity versus equality, aliasing, small-integer caching versus compile-time constant folding, and the mutable-default-argument trap. - Use JavaScript typed arrays (
Int32Array,Float64Array,DataView) as a live, executable window into raw fixed-width memory, the typed, statically-sized contrast to Python's dynamic, arbitrary-precision model. - (Productivity objective: R10 duality.) Use an AI coding copilot, under the Copilot Discipline, to accelerate tracing unfamiliar low-level code and diagnosing memory-model bugs, while never letting it substitute for your own by-hand trace or your own hidden-test verification.
- State, from memory, exactly where this module's authority ends: CS1.04 takes the call stack introduced here and asks how deep and how expensive it is to use (recursion analysis, Big-O, growth rates); this module owns what the stack physically is, never how expensive it is to use it, that hand-off is deliberate, not an oversight.
The duality, stated once (R10). This module carries two objectives at once, as every module in this region does. The understanding objective (items 1–9 and 11) is what the mastery gate rewards: you earn it by tracing memory by hand and predicting behavior before you ever run anything. The productivity objective (item 10) is the payoff you keep: knowing how to let a copilot accelerate the tedious parts of reading someone else's low-level code, while your own by-hand understanding is what catches the copilot when it is confidently wrong about an address, a byte count, or a struct's layout. A tool can draft an explanation of a pointer; only you, having traced it yourself first, can tell whether that explanation is correct.
Prerequisites & connections
Builds on. CS1.01 (Programming from Zero. Python I) gave you values, variables, types, control flow, functions, and I/O; CS1.02 (Python II (Data Structures & Idiomatic OOP) gave you lists, dicts, tuples, sets, comprehensions, mutability, and clean classes) at the level of how to use them correctly. This module does not re-teach any of that usage. It goes one level down and answers the question CS1.02 deliberately left open every time it said "lists are mutable, dicts are mutable, strings are not", namely why, mechanically, in terms of where these objects actually live and what a Python variable actually is (a name bound to a reference to an object living somewhere on CPython's own heap, never a box that directly holds the value). No calculus, no hardware background, and no assembly language are assumed, only comfort with basic Python syntax from CS1.01/CS1.02 and ordinary binary/decimal number sense.
Feeds forward. CS1.04 (Algorithmic Thinking, Recursion & Big-O) takes the call stack this module introduces and asks a different question of it, not "what is a stack frame," which is answered here, but "how many stack frames does this recursive algorithm create, and how does that number grow with input size", the analytical, complexity-theoretic question this module deliberately does not ask. CS1.05 (Data Structures & Algorithms, the CS crown) builds every one of its heap-allocated structures (linked lists, trees, heaps-the-data-structure (an unfortunate name collision with heap-the-memory-region, addressed explicitly below), hash maps) on the heap-allocation model this module establishes. CS2.01 (Command Line, Environments & Reproducible Projects) will have you actually invoke a compiler toolchain from the shell; the vocabulary (compile, link, object file) is supplied here so CS2.01 can spend its time on the workflow around that toolchain rather than re-explaining what the toolchain does. CS2.04 (Systems & Data-Intensive Thinking) is the direct sequel at a larger scale: this module is one process's memory; CS2.04 is a whole machine's memory hierarchy, storage, and caching. CS2.06 (Software Engineering: Testing, Clean Code & AI-Assisted Dev, the CS crown) contains a dedicated unit on reviewing AI-generated code for hallucinated APIs, subtly wrong logic, and silent numeric bugs; this module's two ai-augment-json callouts are the first, lighter-touch instance of that exact discipline, applied here to tracing memory instead of to a full codebase, and CS2.06 is where it becomes the main event. One brief, non-duplicating link outside this branch: the floating-point mechanism taught here is why QM1.03's Monte Carlo work and DA1.05's statistics-in-code will occasionally flag numerical stability, this module is where that warning's actual cause lives; neither of those modules re-derives it, and neither does this one re-derive their statistics.
The one-sentence version of this module. A computer only ever manipulates fixed-width bit patterns sitting in one of two kinds of memory, under a handful of encoding conventions someone chose on your behalf, and everything from a float's rounding error to a copied list's shared insides to a program's very existence as a running process follows directly, and predictably, from that one physical fact.
Core teaching content
The hidden machine you have been programming against all along
Every Python name you have written since CS1.01: principal, holdings, self.qty, has behaved as if it were the value it holds: weightless, instantaneous, free to copy. It is not the value. It is a reference (under the hood, in CPython, literally the memory address of a real object living on a real heap) and the object itself is a specific, finite pattern of bits that a real CPU can read, write, and do arithmetic on, and nothing else. This is true whether you are running Python, JavaScript, C, or anything else; the language just decides how much of that machinery it shows you. Python and JavaScript show you almost none of it, that is a large part of why they feel approachable. C shows you almost all of it, addresses, sizes, allocation, deallocation, which is exactly why reading C, even without compiling a single line of it, is the fastest way to see the machine that every higher-level language is quietly standing on. That is this module's method: use Python's own introspection tools to peek at what CPython normally hides, and use C's total transparency, read only, never compiled here, to see the rest.
Bits, bytes, words, and addresses: the vocabulary of memory
A bit is the smallest unit a computer stores: a 0 or a 1. A byte is 8 bits, grouped together because 8 bits is enough to represent 256 distinct values (2^8), enough for one ASCII character, a small integer, or one channel of a color. A word is the CPU's natural chunk size for a single operation: 32 bits or 64 bits on essentially every machine you will ever program against in this course, meaning a "64-bit machine" moves and computes on 8-byte chunks natively. Every single byte of memory in a running program has an address (a plain integer, the byte's numbered position in the machine's memory space) and a pointer, whether in C or conceptually inside Python's own C implementation, is nothing more exotic than an integer that happens to be interpreted as "the address of some other piece of memory." Reading a value at an address, writing a value to an address, and doing arithmetic on the bits stored at an address are the only three things a CPU fundamentally does; every abstraction from here to a fully staffed trading desk's risk system is layers of convention built on exactly those three primitives.
Two regions, two lifetimes: the stack and the heap
Every running program divides its working memory into two regions with genuinely opposite properties, and confusing them is the source of a large share of the bugs this module exists to make unsurprising.
The stack is a strict, disciplined, last-in-first-out region. Every time a function is called, a new stack frame is pushed onto it, holding that call's local variables, its arguments, and the address to return to when it finishes; when the function returns, its frame is popped and instantly gone. This discipline buys speed (pushing and popping is just moving one pointer, the "stack pointer") and automatic cleanup (nothing has to remember to free a stack frame, it disappears the instant the function that owns it returns). The cost is a fixed, limited size, decided by the operating system when the program starts, and a rigid rule: a frame can only be removed when the function that pushed it returns, never before, and never out of order. Deep, unbounded recursion pushes one frame per call and never gets to pop any until the recursion bottoms out, which is exactly why unbounded recursion eventually exhausts the stack. Python guards you here with a default recursion limit (sys.getrecursionlimit(), 1000 by default) that raises a clean RecursionError well before the real, physical stack is exhausted; C has no such guard by default, and unbounded C recursion simply crashes (a stack overflow in the literal, original sense of that now-generic term) when it runs out of stack space, typically as an unceremonious segmentation fault with no helpful message at all.
The heap is the opposite in every respect: a large, unstructured pool of memory that lives for as long as the program (or, in a garbage-collected language, for as long as something still references it) chooses, allocated and freed in whatever order the program asks for, not in strict last-in-first-out order. Every Python object you have ever created, every list, every dict, every instance of a class you wrote in CS1.02, lives on the heap; a Python variable is never the object itself, only a stack-resident (or, for a module/class attribute, heap-resident) name bound to the heap address of that object. This is precisely why aliasing is possible in Python and not in, say, a primitive C int: two Python names can be bound to the same heap address, so a mutation through one name is visible through the other, while two C int variables are two entirely separate stack slots with no such sharing possible (Worked Example 7 and this node's copy_portfolio code-pack exercise make this concrete). The heap's freedom is also its danger: in a language without automatic memory management, nothing forces you to free heap memory when you are done with it (a memory leak), and nothing stops you from reading or writing heap memory after it has already been freed (a dangling pointer / use-after-free), two of the classic C bugs this module's trace exercises will have you spot by eye. CPython, unlike raw C, manages its own heap for you automatically via reference counting plus a cycle-detecting garbage collector, which is why Python code never calls free(), but the heap itself, underneath that management, is the exact same kind of memory region C exposes directly.
A naming collision worth flagging explicitly, because it trips up almost everyone once: "the heap" as a memory region (this section) and "a heap" as a data structure (a binary heap, the priority-queue structure CS1.05 will teach you to build) are completely unrelated ideas that happen to share an English word. A heap-the-data-structure is usually itself stored on the heap-the-memory-region, which does not help the confusion. When you meet "heap" in CS1.05, re-anchor: that heap is a shape of data; this heap is a region of memory. They are not the same thing, and neither module owes the other any explanation because of the shared name.
Numbers as bits, part 1: unsigned binary and two's complement
An unsigned byte stores an ordinary binary number: 00000000 through 11111111, i.e. 0 through 255. A signed integer needs a convention for representing negative values too, and the convention every modern CPU uses is two's complement: to negate a number, invert every bit and add 1. For an 8-bit signed integer (int8), the top bit is the sign bit, and the representable range is −128 to 127, not −128 to 128, because zero uses up one of the 256 available patterns and the positive side gives up one slot to it. 11111111 in two's complement is −1; 10000000 is −128 (the single most negative value, with no positive counterpart, a genuine asymmetry worth remembering, since naively negating the most-negative value overflows right back to itself); 01111111 is 127, the largest positive int8.
Two's complement is not the only possible scheme, sign-magnitude (reserve the top bit purely as a sign flag, exactly like a "+" or "−" written in front of the number) is the naive alternative, and it is worse in two concrete ways: it has two representations of zero (00000000 and 10000000, "positive zero" and "negative zero"), which complicates every comparison; and ordinary binary addition circuitry does not work correctly on it without special-casing the sign bit. Two's complement has exactly one zero, and, this is the entire reason it won, a CPU can add two two's-complement numbers with the exact same adder circuit it uses for unsigned numbers, and the bit pattern that comes out is correct whether you interpret the inputs (and the result) as signed or unsigned. That circuit-level simplicity, not any elegance for humans, is why every general-purpose CPU built since roughly the 1960s has standardized on two's complement.
Overflow is what happens when an operation's true result does not fit in the fixed width available. In a genuinely fixed-width register, or in a byte-oriented view of memory, like this module's own code-pack exercises (to_int8, wrapInt32) model directly, the bits simply wrap around modulo 2^n: adding 1 to the largest int8 (127) produces 10000000, which is −128, not 128. This is a completely different situation from signed overflow in the C language itself, and the distinction is one experienced programmers still get wrong: in C, unsigned-integer overflow is defined to wrap modulo 2^n, exactly as above, but signed-integer overflow in C is undefined behavior. The C standard does not merely leave the wrapped value unspecified; it permits the compiler to assume signed overflow never happens at all, and a sufficiently aggressive optimizer is allowed to delete a branch that only exists to check for it, on the reasoning that "if you wrote this check, and overflow is impossible, the check must always be false." A signed-overflow bug in C can therefore appear to "just wrap around, no big deal" at one optimization level and silently vanish an entire safety check at a higher one. Every wraparound exercise in this module's code pack models the deterministic, defined bit-level behavior (unsigned wraparound, or a raw byte/register reinterpretation) precisely because that is the one part of this story that is safe to demonstrate with a guaranteed, repeatable answer; real C signed-overflow UB is a warning to carry forward, not something to practice hands-on.
Python deliberately opts out of all of this for its own int type: Python integers have arbitrary precision, they grow as large as memory allows, and 10**30 in Python is exactly 1000000000000000000000000000000, computed exactly, with no overflow and no wraparound, ever. This is a genuine, deliberate design choice (implemented by CPython allocating more digit-chunks as a number grows), and it is also a portability trap: a Python prototype that "just works" on huge numbers can be handed to an engineer who reimplements it in a fixed-width production language (C, Rust, a database's INT column) where the exact same arithmetic silently wraps or throws. Worked Example 2 walks through exactly this failure in a US point-of-sale-style system.
Byte order: endianness, and why DataView makes you say which one
A single byte has no internal order problem, it is just 8 bits. A multi-byte number (a 32-bit integer is 4 bytes; a 64-bit double is 8 bytes) does have an order problem: which byte, in memory, comes first, the most significant or the least significant? A machine that stores the most-significant byte first is big-endian (the natural human reading order: the "big end" of the number comes first); one that stores the least-significant byte first is little-endian (the overwhelmingly common convention on the x86 and ARM processors under almost every laptop, phone, and cloud server you will ever touch). Ordinary C code almost never has to think about this, because the compiler and CPU agree on the machine's native order and every normal read of a variable comes back correctly formed without you asking. The one place endianness becomes impossible to ignore is exactly where this module's code pack puts you: byte-level tools like JavaScript's DataView, which read and write raw bytes directly, require you to state the byte order explicitly on every multi-byte read or write (view.setFloat64(offset, value, littleEndian), this module's float64Bytes exercise deliberately writes big-endian, littleEndian = false, so the byte sequence you get back matches the natural left-to-right reading of the IEEE-754 bit layout described next) precisely because there is no "native" answer once you are working with raw bytes instead of a typed variable the compiler manages for you. Endianness also matters the moment bytes cross a boundary between systems that might disagree, a network protocol, a binary file format, two different CPU architectures reading the same saved file, which is why network protocols conventionally standardize on big-endian ("network byte order") regardless of what any given machine natively prefers.
Numbers as bits, part 2: IEEE-754 floating point
Integers are exact but bounded and non-fractional; representing a fractional real number in a fixed number of bits needs an entirely different scheme, and the one essentially every modern language and CPU uses is IEEE-754, standardized by the IEEE in 1985 (and revised in 2008). A 64-bit IEEE-754 double (Python's only built-in float type, and JavaScript's only number type) divides its 64 bits into three fields: 1 sign bit (0 for positive, 1 for negative), 11 exponent bits (a power-of-two scale factor, stored with a bias of 1023 so it can represent both very large and very small magnitudes), and 52 mantissa (fraction) bits (the significant digits, interpreted as 1.mantissa in binary, the leading 1 is implicit and not stored, buying one extra bit of precision for free). This module's float64Bytes code-pack exercise lets you extract these exact 8 bytes from a real number and inspect them yourself: 1.0 is [63, 240, 0, 0, 0, 0, 0, 0] in big-endian bytes (0x3FF0000000000000) sign 0, biased exponent 01111111111 (1023, meaning "scale by 2^0"), mantissa all zero (meaning "no fraction beyond the implicit leading 1"), which is exactly 1.0 × 2^0 = 1.0.
Here is the mechanism, not folklore: 52 mantissa bits give you roughly 15–17 significant decimal digits of precision, and (critically) those bits represent a binary (base-2) fraction, not a decimal (base-10) one. A binary fraction can only ever be an exact sum of powers of two (1/2, 1/4, 1/8, ...); 0.1 in decimal is 1/10, and 1/10 has no finite binary expansion at all, it is a repeating binary fraction, the exact binary analogue of how 1/3 has no finite decimal expansion (0.333..., forever). So the double closest to 0.1 is not 0.1, it is the nearest representable value, which (measured directly, in Python, to 20 decimal places) is 0.10000000000000000555..., very slightly above 0.1. 0.2's nearest double is 0.20000000000000001110..., also slightly above. Add them, and the sum is 0.30000000000000004441..., which, when Python prints it back to you with repr(), displays as the famous 0.30000000000000004. Compare that to 0.3's own nearest double, which happens to sit slightly below 0.3 at 0.29999999999999998890..., and now 0.1 + 0.2 == 0.3 is comparing two different bit patterns that are both individually "correct roundings" of their own decimal, and they are not equal. This is not a bug, not a rounding error a smarter language would avoid, and not specific to Python, it is the direct, unavoidable consequence of representing base-10 fractions in base-2 bits, and it is identical in JavaScript, C, Java, and every other IEEE-754 language on earth. The fix used throughout this module's code pack (currency_equal) is never to compare currency floats with bare ==; convert both sides to integer minor units (paise, cents) by rounding amount × 100 to the nearest integer, and compare those integers, because integer equality has no such ambiguity.
{
"skill": "Explaining an unfamiliar low-level mechanism (bit layouts, address arithmetic) fast, without letting the explanation replace your own trace",
"use": "Have a copilot annotate an unfamiliar C snippet or a raw byte dump line by line — naming what each pointer holds, what a struct's memory layout looks like including padding, or what an IEEE-754 byte pattern decodes to — then generate two or three more short variations in the same style so you can test your own reading against a worked answer.",
"tools": ["Chat assistants — Claude, ChatGPT, Gemini", "Compiler Explorer (godbolt.org) — paste the same C snippet and actually compile and run it, since Pyodide cannot"],
"prompt": "Here is a short C snippet: <SNIPPET>. Annotate it line by line: for every pointer, state the address it holds and what it points to; for every struct, state its likely memory layout, including any padding bytes for alignment. Do not just give me the final printed output — show the memory-level reasoning that gets there. Then write two more short snippets in the same style, each with a worked answer, so I can test myself.",
"verify": "A copilot explaining unmanaged memory can itself get address arithmetic, padding rules, or endianness subtly wrong — and this app has no live C compiler to catch it (Pyodide cannot compile C). Paste the exact same snippet into Compiler Explorer or any real C compiler and run it for real; if the actual output disagrees with the copilot's trace, trust the compiler's output, not the explanation, and re-derive your own reasoning until you see why.",
"diy": "You must be able to trace a simple pointer/array/struct snippet by hand FIRST — the C sections below and Worked Examples 5-6 build exactly that. A copilot can multiply your practice reps; it cannot install the skill of reading an address by eye, and CS2.06 will later put your ability to catch a subtly-wrong AI code explanation under an actual hidden-test harness.",
"market": "US"
}
Text as bits: code points, ASCII, Unicode, and UTF-8
A character on screen and a byte in memory are not the same thing, and conflating them is the single most common text-handling bug in software. Every character has a code point, a single, fixed integer identifying it in the Unicode standard, conventionally written U+0024 for $ or U+20B9 for ₹, but a code point is an abstract identity, not a storage format. ASCII, from 1963, was the original 7-bit scheme: 128 code points, one byte each, enough for unaccented English text and nothing else. Unicode extended the identity space enormously (over a million possible code points, covering essentially every script in use), but left the storage question open, and UTF-8, designed by Ken Thompson and Rob Pike at Bell Labs in 1992, and described in their 1993 USENIX paper, is the encoding that answers it in the way that has become the web's overwhelming default. UTF-8's defining property is that it is variable-width: code points U+0000–U+007F (plain ASCII) cost exactly 1 byte; U+0080–U+07FF cost 2 bytes; U+0800–U+FFFF cost 3 bytes; and U+10000 and above (which includes most emoji) cost 4 bytes. This is precisely why UTF-8 is backward-compatible with ASCII (every plain-English document is already valid UTF-8, unchanged) while still being able to represent every script and symbol Unicode defines.
This directly produces a very real, very common bug class: "one character" is not "one byte" the instant your text leaves ASCII. The US dollar sign $ (U+0024) is ASCII, so it costs 1 byte. The Indian rupee sign ₹ (U+20B9) is well outside ASCII (it sits in the U+0800–U+FFFF band) so it costs 3 bytes in UTF-8, confirmed directly by this module's own utf8_byte_length code-pack exercise, which computes that byte count from first principles (classify each code point, sum the per-character cost) with no call to .encode() at all. A system that allocates, say, "20 bytes for the currency-formatted amount field" because someone counted len() of an ASCII test string and assumed 1 byte per character will silently corrupt or truncate the very first non-ASCII amount it receives, and because Python's own len(s) counts code points, not bytes, that same assumption quietly resurfaces even inside Python code that never looks at raw bytes directly. Worked Example 3 walks through exactly this failure with both currencies side by side.
From source text to a running process: compile, assemble, link, load, run
Every time you have run Python in this app, a large amount of machinery has been hidden from you: CPython itself (the program that runs your Python) is a compiled C program, and it interprets your .py source text one statement at a time, on the fly, translating it to its own internal bytecode as it goes. A compiled language like C takes a completely different, and far more visible, path from text to execution, and that visibility is exactly why it is this module's second language.
Preprocessing. Before real compilation starts, a preprocessor expands textual macros (#define) and inlines the contents of #included header files, pure text substitution, no understanding of C semantics at all. Compiling. The compiler proper reads the preprocessed C text, checks it against C's grammar and type rules, and translates it into assembly language (or directly into machine code) for one translation unit (one .c file) at a time, critically, in isolation, with no knowledge of any other file in the project except what a header has declared. Assembling. An assembler turns human-readable assembly instructions into raw machine code, bundled into an object file (.o), which already contains real machine instructions but is not yet runnable, because it may reference functions or variables defined in other object files it has never seen. Linking. The linker's entire job is to resolve exactly those cross-file references: it takes every object file (and any libraries the program needs) and stitches them into one executable, replacing every "call this function, defined somewhere else" placeholder with the function's real, final address. Loading and running. When you launch the resulting executable, the operating system's loader reads it into memory, sets up its stack and heap, and hands control to its entry point, and only then, for the very first time, is any of this program's logic actually executing on the CPU.
The reason this pipeline matters practically, not just historically, is that each stage can only catch the category of error it is built to see, and no other. A missing semicolon is a compile error, caught while the compiler is still looking at one file's text. A function that is called but never defined anywhere in the whole program is a link error ("undefined reference to compute_yield") and it can only appear at the link stage, because compiling each file in isolation has no way to know whether some other file defines that function; the compiler is satisfied the moment it has seen a declaration (a promise: "this function exists, here is its signature"), and only the linker checks that a matching definition (the actual function body) exists anywhere in the final program. Worked Example 6 walks through this exact pair of near-identical-looking C bugs (a forgotten declaration versus a forgotten definition) and asks you to predict, correctly, which stage catches which one, and with what error.
One further distinction worth carrying forward: static linking copies the code of every library a program depends on directly into its final executable at link time, producing one large, self-contained file with no further dependencies; dynamic linking instead records which shared libraries (.so on Linux, .dll on Windows) the program needs, and the operating system's loader locates and attaches them at the moment the program actually starts running, which is why a dynamically linked executable is smaller on disk, can share one copy of a library's code across many running programs at once, and can break at launch time with a "missing shared library" error that a statically linked program can never produce, because it never needed anything from outside itself in the first place.
A second language on purpose: reading C without a compiler
Everything above has been described in the abstract; C is where you watch it happen in actual syntax, and this module deliberately restricts every C exercise to trace, predict, or fill-in-the-blank, because Pyodide, the Python environment this app's code pack runs in, cannot compile or execute C at all. That constraint is a feature, not an accommodation: reading code you cannot run and predicting its behavior correctly, purely from understanding, is a core professional skill (the unfamiliar codebase with no working build, the security review of code you must not execute, the legacy system nobody can safely restart) that "just run it and see" never builds.
Pointers and arrays are the same operation, at different syntactic distance. In C, int arr[5] allocates 5 contiguous ints (commonly 4 bytes each), and arr[i] is defined to mean exactly *(arr + i), "take the array's base address, add i scaled by sizeof(int) [because pointer arithmetic on a T* always moves in units of sizeof(T), never raw bytes], and dereference the result." There is no special "array access" instruction underneath; there is only pointer arithmetic and dereferencing, and the bracket syntax is convenience sugar over exactly that. This is also why C achieves what looks like "pass by reference": a function that receives a pointer (int *p) can modify the value the caller's variable holds, by writing through that pointer (*p = 5;), even though C itself only ever passes arguments by value, what gets copied is the address, not the thing at the address, and modifying through a copied address still reaches the one real object both sides are pointing at. Python achieves a related-looking effect (a function can mutate a list argument's contents) for the same underlying reason (a Python argument is always a reference to a heap object, never a copy of the object itself) but Python and C reach it through different visible mechanisms: C makes you write the * explicitly; Python's single argument-passing rule ("always pass a reference, never a copy") applies uniformly to everything, so there is no special "pass by pointer" syntax to opt into or the confusable "pass by pointer vs pass by pointer-to-pointer" distinction two-level C pointers can produce.
Structs have a memory layout, and that layout can include padding you never asked for. A C struct bundles named fields into one contiguous block, but a real CPU is fastest reading a multi-byte value (a 4-byte int, an 8-byte double) from an address that is a multiple of its own size (a rule called alignment) so the compiler is permitted, and in practice required by common ABIs, to insert invisible padding bytes between fields (or after the last one) so that every field lands on a suitably aligned address. A struct { char a; int b; } is not 1 + 4 = 5 bytes on a typical modern compiler; it is commonly 8 bytes, because 3 padding bytes are inserted after a so that b starts on a 4-byte boundary. sizeof a struct, in other words, is not simply the sum of sizeof its fields, a fact several of this module's Practice-set items ask you to compute by hand, applying the alignment rule rather than guessing.
Undefined behavior is a different category of bug from "wrong output." A buffer overflow (writing past the end of an array) and a use-after-free (reading or writing heap memory after it has been freed) both frequently compile without complaint and appear to work, because C performs essentially no automatic bounds- or lifetime-checking at all, checking those things is a cost C's design philosophy explicitly declines to pay by default, leaving it to the programmer. "Compiles cleanly" and "correct" are simply not the same claim in C the way a syntax error and a logic error can feel conflated in a beginner's mental model; a program can compile perfectly, run "successfully" on your machine today, and still be riddled with undefined behavior that a different compiler, a different optimization flag, or tomorrow's slightly different memory layout turns into a crash or a silently wrong answer. This is the single fact that makes disciplined, by-hand tracing, precisely the skill this module drills, a genuine professional necessity for reading C, not merely a classroom exercise substituting for a compiler this app happens to lack.
Back to Python: what id, is, and sys.getsizeof reveal about CPython's own heap
CPython, the Python you have been running since CS1.01, is itself a compiled C program, and every Python object you create is a real object living on CPython's own heap, exactly the kind of heap the earlier section described. Three built-in tools let you inspect that heap directly, from inside Python, with no C required.
id(obj) returns a unique integer identifying an object for its lifetime; in CPython specifically (an implementation detail, not a language guarantee. Python the language never promises this) id() is literally the object's memory address. is tests whether two names refer to the exact same object (identical id()), while == tests whether two objects compare equal by value, and these are genuinely different questions: two separate [1, 2, 3] lists are == (same contents) but not is (two distinct heap objects), while a = [1]; b = a makes a is b true (the same object, reachable by two names), the exact aliasing this module's copy_portfolio exercise is built around. sys.getsizeof(obj) reports an object's size in bytes as this specific CPython build measures it, never a language guarantee, always an implementation detail that can and does vary across Python versions and platforms, but genuinely revealing for comparison: on one representative CPython 3.11 build, a small int and a plain float sit at 28 and 24 bytes respectively (already far more than the 4 or 8 raw bytes their value would need in C, every Python object carries real overhead: a reference count, a type pointer, and, for a number, the boxed numeric payload itself), an empty string is 49 bytes and an empty list is 56, and a 3-element list (88 bytes) costs meaningfully more than a 3-element tuple (64 bytes) holding the identical values, a concrete, measured reminder that a tuple's fixed-size immutability buys it a real memory saving over a list's resizable machinery, on top of the semantic difference CS1.02 already taught.
CPython also runs one deliberate optimization worth understanding precisely, because half-understanding it produces a confident, wrong mental model: it maintains a small-integer cache for every int from −5 to 256 inclusive, every reference to, say, 100, anywhere in a running program, points at the same pre-created object, so a = 100; b = 100; a is b is reliably True. This is easy to over-generalize into "Python caches small integers, so is is basically safe to use for numbers", which is exactly wrong, and dangerously so, because there is a second, unrelated mechanism that can make is look like it agrees even for large numbers: a compiler-level optimization called constant folding, which deduplicates identical literal constants that appear within the same compiled code block, regardless of their value. Write c = 1000 and d = 1000 as two literals in the same script or function, and c is d can come back True too, not because 1000 is cached (it is far outside the −5..256 range) but because the compiler happened to reuse one 1000 constant object for both literals in that one code object. Build the exact same value at runtime instead, accumulate to 1000 in a loop, called from two entirely separate invocations, and c is d for that genuinely independently-constructed 1000 comes back False, == still True, two different heap addresses. The lesson under both halves of this story is identical and non-negotiable: is is for identity checks you mean literally (x is None, "is this the same object I stored earlier") and never a substitute for == when what you actually mean is "do these have the same value", because whether it silently looks like it works depends on cache ranges and compiler internals that are implementation details, not language guarantees, and can differ across Python versions, or even across two lines of the same file, for reasons that have nothing to do with your program's logic.
Last, and most consequential in real code: a function's default argument value is created exactly ONCE, at the moment the def statement itself runs (function-definition time), and stored as part of the function object, never recreated fresh on each call. def add_transaction(amount, log=None): correctly creates a brand-new empty list inside the function body every time log is omitted, precisely because the code explicitly checks if log is None: log = [] and only then builds a fresh list, but the naive-looking def add_transaction(amount, log=[]): creates exactly one list object when the function is defined, and every subsequent call that omits log receives and mutates that same, persisting object, silently accumulating state across calls that have no business sharing anything. This is not a Python design flaw so much as a direct and unforgiving consequence of the fact that default values are ordinary objects living on the heap like any other, created at a specific moment in time, and referenced (not recreated) by every call that relies on them. Worked Example 7 walks through this bug end to end, and this module's add_transaction code-pack exercise grades exactly this fix.
{
"skill": "Turning a suspected aliasing/mutable-state bug into a fix you can actually prove correct, not merely one that sounds plausible",
"use": "Paste a suspected buggy function — a mutable default argument, a shallow-copy aliasing bug, or similar — to a copilot, ask it to name the exact mechanism and propose a fix, then prove the fix by running it against this module's own hidden tests rather than trusting the explanation on its own.",
"tools": ["Chat assistants — Claude, ChatGPT, Gemini", "This app's code runner — the hidden-test harness that actually executes your (or the copilot's) proposed fix"],
"prompt": "Here is a Python function with a suspected bug: <FUNCTION>. Name the exact mechanism — is it a mutable default argument, a shallow-copy aliasing bug, a reference-lifetime issue, or something else — explain it in terms of CPython's object model, and propose a corrected version.",
"verify": "An AI's confident bug diagnosis can itself be wrong — it may misname the mechanism, or 'fix' the symptom you showed it while leaving the actual aliasing intact for a case you did not show it. Run the proposed fix against this module's own hidden tests, not only the one example you supplied; a fix that passes the visible case but still leaks state across calls is exactly the subtly-wrong AI-generated code CS2.06 spends a full unit teaching you to catch. Read every line before you accept it, and never ship code you cannot explain back in your own words.",
"diy": "You must already be able to name 'mutable default argument' and 'shallow versus deep copy' and predict their symptoms yourself first — Worked Example 7 and the copy_portfolio/add_transaction code-pack exercises build exactly that. A copilot can draft a fix fast; only your own hidden-test run proves it, and only your own understanding lets you catch a copilot's plausible-but-wrong diagnosis before it ships.",
"diy_note": "kept intentionally close to the module's own worked material — see Worked Example 7",
"market": "IN"
}
The typed contrast: JavaScript's typed arrays as a live window into raw memory
Plain JavaScript numbers are, like Python floats, IEEE-754 doubles under the hood, and plain JavaScript has no notion of a fixed-width integer at all, which makes it, on its own, no more "typed" than Python is. What this app's JS sandbox gives you that Pyodide-in-a-browser cannot give you for C is a live, executable window into exactly the fixed-width, raw-byte memory model this module has been describing on paper: ArrayBuffer allocates a fixed-size, untyped block of raw bytes; a typed array (Int32Array, Float64Array, ...) or a DataView gives you a specific, fixed-width, typed view onto that same block, letting you read and write exactly the kind of bit patterns this module has spent its core content describing in prose. This module's own code pack uses that window twice: wrapInt32 reduces an arbitrary integer into the exact signed 32-bit two's-complement range by explicit modular arithmetic, the same wraparound story as to_int8, one register width up, and float64Bytes uses DataView's explicit-endianness setFloat64/getUint8 pair to extract the literal 8 bytes of a real IEEE-754 double, letting you check, by running actual code, that 1.0's bytes really are [63, 240, 0, 0, 0, 0, 0, 0], exactly as the IEEE-754 section above derived from the standard's field layout. Nothing here makes JavaScript "a typed language" in the way C is, you still cannot declare let x: int32, and ordinary JS arithmetic still happens in full double precision regardless of what a typed array holds, but the moment you reach for ArrayBuffer/DataView, you are working with exactly the same raw, fixed-width, endianness-sensitive memory model C exposes by default, executed live, in a language this app's sandbox can actually run.
Where this leaves you
Hold the whole shape at once: memory is stack (disciplined, fast, automatic, limited) or heap (flexible, chaotic, freed only when something says so); a number is a fixed-width bit pattern under a convention (two's complement for integers, IEEE-754 for reals) that predictably overflows or loses precision at exactly the boundaries those conventions imply; text is code points that cost a variable number of bytes once you leave ASCII; and a compiled program only exists at all because of a second journey (compile, assemble, link, load) that a language like Python quietly performs on your behalf every time you press run, and that C makes you watch. None of that understanding is optional background color: it is the reason 0.1 + 0.2 != 0.3, the reason a shared mutable default argument leaks state, the reason a copied list can still share its insides, and the reason a program can compile cleanly and still be wrong. CS1.04 picks up the stack from here and asks how expensive recursion is; CS1.05 builds every heap-allocated structure on the model this module established; CS2.06 returns to the "AI explained this code to me, is it actually right?" question this module's callouts first raised, with a full unit and a hidden-test harness behind it. This module's job was narrower and more fundamental than any of those: to make the machine underneath every abstraction visible, once, clearly, so that everything built on top of it, in this branch and beyond: stops being magic.
Common mistakes & how experts think differently
- "Python handles memory for me, so none of this is relevant to my work." CPython is itself a compiled C program, and every Python object you create is a real object on CPython's own heap; the stack/heap split, the reference-vs-value distinction, and the aliasing it enables are not optional background you can skip, they are the direct cause of the mutable-default-argument bug, the shallow-copy bug, and the
RecursionErroryou will eventually hit. The expert's first move on an "impossible" Python bug is to ask what is actually being referenced, not what the code superficially appears to say. - "
0.1 + 0.2 == 0.3returning False must be a bug in this particular language/library." It is identical in every IEEE-754 language, because it follows directly from representing a base-10 fraction in base-2 bits, not a defect any specific implementation introduced or could "fix" without abandoning IEEE-754 altogether. The expert's reflex on any currency comparison is to convert to integer minor units first and never compare floating-point money with bare==. - "Integers can't overflow in [whatever language I'm using]" or "overflow is just a theoretical edge case." True for Python's arbitrary-precision
int; false for essentially every fixed-width type in every systems language, every database integer column, and every typed array, and a Python prototype's numbers will not stay unbounded the moment someone reimplements the logic in one of those. The expert who moves a working Python calculation into a fixed-width system explicitly checks the new type's range against the values the calculation can actually produce. - "A string is basically an array of one-byte characters." True only for pure ASCII; UTF-8 is variable-width, and a single non-ASCII character like
₹costs 3 bytes, not 1, and Python's ownlen(s)counts code points, not bytes, so the assumption can resurface even without ever touching raw bytes directly. The expert measures byte length explicitly (len(s.encode('utf-8')), or reasons about it as this module'sutf8_byte_lengthexercise does) the moment a fixed-size buffer, a byte-limited field, or a wire protocol is involved. - "If the code compiles, it works" (and its cousin, "undefined behavior would have shown up by now"). Compiling only checks syntax and type rules the compiler can see; it proves nothing about buffer overflows, use-after-free, or signed-overflow UB, all of which routinely compile cleanly and "work", until a different compiler, optimization flag, or memory layout turns the same source into a crash or a silently wrong answer. The expert treats "compiles" and "correct" as entirely separate claims, especially in C.
- "Copying a list copies everything inside it." A shallow copy (
list(x),x[:],x.copy()) copies the outer container only; any mutable object nested inside (a dict, another list) is still the exact same shared heap object in both copies. The expert asks, before copying anything containing mutable elements, "shallow or deep?" and picks deliberately, the way this module'scopy_portfolioexercise forces you to. - "A default argument value is recomputed fresh on every call, like a local variable would be." It is created exactly once, at function-definition time, and every call that omits the argument shares that one persisting object, the mutable-default-argument trap. The expert's reflex, learned once and applied forever after, is: never write a mutable default (
[],{}, a class instance) directly; default toNoneand construct the fresh object explicitly inside the function body. - "An AI explained what this C snippet does, that's good enough, no need to trace it myself." A confident, fluent explanation of a pointer, a struct's layout, or a byte pattern can be subtly wrong in exactly the ways this module trains you to catch (a padding byte miscounted, an address arithmetic slip, an endianness assumption reversed), and there is no compiler in this app to catch the mistake for you, because Pyodide cannot compile C. The expert uses a copilot to accelerate a trace, per the Copilot Discipline, and then checks the result against a real compiler (Compiler Explorer) or a written solution, never against the explanation's own confident tone.
Worked examples
Worked example 1: The float that will not equal itself (India, ₹)
Meera is reconciling a brokerage statement: two ₹ line items, ₹1,450.10 (a purchase) and ₹999.90 (a second purchase), should sum to exactly ₹2,450.00 against the total the broker's own system reports. In Python she writes 1450.10 + 999.90 == 2450.00 and, to her surprise, gets False.
Solution. Step 1, name the mechanism before judging the number. 1450.10, 999.90, and 2450.00 are all ordinary decimals with a fractional part; none of them is guaranteed to have an exact binary (base-2) representation, for the identical reason 0.1 does not, a base-10 tenth is a repeating fraction in base-2, just as a third is a repeating fraction in base-10. IEEE-754 stores the nearest representable double to each literal, not the literal itself.
Step 2, predict which side of the comparison is off, and by how much. The error in any one such rounding is minuscule, on the order of 10^-16 relative to the value, but summing two independently-rounded numbers and comparing the result to a third, separately-rounded number gives those three tiny, unrelated rounding errors no reason to cancel out. The classic, verifiable case: 0.1's nearest double is very slightly above 0.1 (0.10000000000000000555...), 0.2's is also very slightly above (0.20000000000000001110...), and their sum lands at 0.30000000000000004441..., while 0.3's own nearest double sits very slightly below 0.3 (0.29999999999999998890...). Two numbers that are each individually the "correct" rounding of their own decimal can still land on opposite sides of the true value once combined, and == sees only the bit pattern, never the intent.
Step 3, apply the fix, and check it. Never compare currency floats with bare ==. Convert both sides to integer minor units, multiply by 100 (rupees → paise) and round to the nearest integer, and compare those integers: round(1450.10 * 100) + round(999.90 * 100) against round(2450.00 * 100). Run precisely this logic (this module's own currency_equal(a, b, scale=100) code-pack exercise) and the reconciliation correctly reports a match, because integer equality carries none of the binary-fraction ambiguity floats do.
Read it. The bug was never "the computer is bad at math", it was comparing two individually correct roundings without accounting for the fact that binary fractions cannot represent most decimal ones exactly. The fix costs one extra line and applies to every currency comparison you will ever write, in ₹ or in $ alike.
Worked example 2: The counter that quietly went negative (US, $)
Jordan's legacy US order-matching system stores shares_times_cents (the number of shares traded multiplied by the price in cents) in a 32-bit signed field, because the system was built decades ago when trade sizes were modest. A single large block trade computes shares_times_cents = 500,000 shares × 4,300 cents = 2,150,000,000. The very next trade, only slightly larger, pushes the running daily total to 2,200,000,000, and the field, read back, reports a large negative number.
Solution. Step 1: name the mechanism. A signed 32-bit field's representable range is exactly −2,147,483,648 to 2,147,483,647 (−2^31 to 2^31 − 1). 2,200,000,000 exceeds the positive maximum by 52,516,352. In a defined, wraparound reinterpretation of the raw 32 bits (exactly what this module's wrapInt32 code-pack exercise computes explicitly, via modular arithmetic, for any fixed-width register), the value that comes back is 2,200,000,000 − 4,294,967,296 = −2,094,967,296, a large negative number that has nothing to do with the trade's real, positive economic size.
Step 2, separate the two overflow stories this module insists you keep apart. If this system were literally storing the value in a genuinely unsigned or hardware-register sense, the wraparound above is the deterministic, guaranteed answer. If the system were instead written in C using a plain signed int and the overflow happened through ordinary signed arithmetic, the C standard does not merely say "it wraps", signed overflow in C is undefined behavior, and a real compiler is permitted to assume it never happens at all, which can produce behavior stranger than a clean wraparound (a deleted safety check, a value that behaves inconsistently across two builds of the identical source). Knowing which situation you are in (a defined bit-level reinterpretation versus genuine C signed-overflow UB) is exactly the distinction the Core content insists on, because the two demand different responses.
Step 3: apply the fix. Widen the field to 64 bits (a signed 64-bit range comfortably exceeds any realistic shares × cents product for the foreseeable future) or, if the language allows it, use an arbitrary-precision type the way Python's own int already does natively. Either way, the fix is decided before deployment, by computing the field's true maximum possible value from the business logic and checking it against the chosen type's actual range, not discovered in production after a trade quietly goes negative.
Read it. Python's own arbitrary-precision integers would never have produced this bug at all, which is exactly the portability trap Core content names: a Python prototype's numbers behave beautifully right up until the moment someone else reimplements the same logic in a fixed-width type, and the failure then depends entirely on arithmetic this module has now made predictable in advance.
Worked example 3: The byte count that assumed one character, one byte (India ₹ and US $)
A legacy fixed-width file format allocates exactly 12 bytes for a "currency amount" field, based on a US developer's test string "$1,234.56" (9 characters) fitting comfortably with room to spare. When an India-market row writes "₹1,23,456.78" into the same 12-byte field, it is silently truncated, and the parser downstream reads garbage for the last portion of the amount.
Solution. Step 1, count both strings the way the format actually stores them: in bytes, not characters. "$1,234.56" is 9 characters, every one of them ASCII ($, digits, comma, period), so it is exactly 9 bytes in UTF-8, comfortably inside the 12-byte budget, which is exactly why the US-only test never caught anything. "₹1,23,456.78" is 13 characters, but the leading ₹ (U+20B9) sits in UTF-8's 3-byte band (it is well above U+07FF and at or below U+FFFF), while every remaining character (the digits, the two commas from Indian lakh-style grouping, the decimal point) is plain ASCII at 1 byte each: 3 bytes for ₹ plus 11 more ASCII characters at 1 byte each is 14 bytes total, exceeding the 12-byte field by 2 bytes before a single digit of the actual amount is even considered a problem.
Step 2: name exactly what went wrong. The field's size was chosen by measuring len() (or eyeballing a string) on a test case that happened to be pure ASCII, silently baking in the false assumption "one character costs one byte", true by coincidence for the test data, false in general the instant a non-ASCII currency symbol is involved. This module's own utf8_byte_length code-pack exercise computes exactly this byte count from first principles (classify each code point into its UTF-8 byte-cost band and sum) specifically so that "how many bytes will this actually take" stops being a guess.
Step 3: apply the fix. Size any fixed-width text field from the worst realistic case measured in bytes, not from one convenient test string's character count, and, better still, prefer a variable-length or explicitly-length-prefixed field over a fixed-width one for any text that might contain non-ASCII currency symbols, accented names, or non-Latin scripts at all.
Read it. The bug never announced itself as a Unicode problem (it looked like "the parser breaks on Indian rows sometimes") and the discipline that finds it fast is the same one Worked Examples 1 and 2 used: name the underlying representation (bytes, not characters) before chasing the symptom.
Worked example 4: Tracing a call stack, and the two very different ways it can run out
Consider a simple recursive function computing compound growth on a ₹100,000 principal at 8% annual growth over n years: def grow(principal, rate, n): return principal if n == 0 else grow(principal * (1 + rate), rate, n - 1). Called as grow(100000, 0.08, 5), trace what actually happens on the call stack.
Solution. Step 1, draw the frames as they are pushed. Each recursive call pushes a new stack frame holding that call's own principal, rate, and n, plus the address to return to. Calling grow(100000, 0.08, 5) pushes frame 1 (n=5); since n != 0, it calls grow(108000, 0.08, 4), pushing frame 2 (n=4) on top of frame 1, which is still sitting there, paused, waiting; this continues down to frame 6 (n=0), at which point five frames are simultaneously alive on the stack, stacked in the exact order they were called, none of them finished yet.
Step 2: trace the unwind. Frame 6 (n=0) hits the base case and returns its principal directly, no further recursion. Only then does frame 5 receive that return value, itself returns it unchanged, and is popped; then frame 4; then frame 3; then frame 2; then frame 1, five pops, in the exact reverse order of the five pushes, which is the strict last-in-first-out discipline the Core content describes, made concrete.
Step 3, now ask what happens if n is 100,000 instead of 5. In Python, the interpreter's own recursion guard (sys.getrecursionlimit(), 1000 by default) raises a clean RecursionError once roughly a thousand frames have piled up unreturned, well before the real, physical stack region is exhausted. The identical recursive logic written in C, with no such guard, keeps pushing frames until the operating system's actual stack memory for that process runs out, at which point the program typically crashes outright (a genuine, literal stack overflow) with no helpful message, because C, true to its design, does not pay the cost of checking recursion depth for you.
Read it. The frames are not a metaphor, they are real memory, pushed and popped in a real, observable order, and the two languages' completely different failure behavior at depth (a clean exception versus an unceremonious crash) is a direct, predictable consequence of one guarding the same physical resource and the other not. (CS1.04 picks up from exactly this point to ask how many frames a given recursive algorithm creates as a function of its input, a different, complexity-theoretic question this module deliberately leaves for it.)
Worked example 5: Reading pointers, arrays, and a padded struct (trace only, no compiler)
Read this C fragment and predict, without compiling anything, (a) what it prints, and (b) sizeof(Trade).
struct Trade { char side; int qty; }; /* side: 'B' or 'S'; qty: number of shares */
int main(void) {
int prices[3] = {100, 105, 110};
int *p = prices;
struct Trade t = { 'B', 250 };
printf("%d\n", *(p + 1));
printf("%zu\n", sizeof(struct Trade));
return 0;
}
Solution. Step 1, the array/pointer line. p is assigned prices, which decays to a pointer to prices[0]. *(p + 1) means "advance the pointer by 1 int-width (not 1 byte, pointer arithmetic on an int* scales by sizeof(int)) and dereference", exactly prices[1], which is 105. The first printf prints 105.
Step 2, the struct's size, accounting for alignment. struct Trade holds a char (1 byte) followed by an int (commonly 4 bytes, needing to start on a 4-byte-aligned address for the CPU to read it efficiently). Laid out naively, char occupies byte 0, and int would need to start at byte 1, not a multiple of 4, so a compiler on a typical modern platform inserts 3 padding bytes after the char so the int starts at byte 4, giving a total size of 1 + 3 (padding) + 4 = 8 bytes, not the 1 + 4 = 5 a naive sum-of-fields guess would produce. The second printf prints 8.
Read it. Both answers came from applying a rule (pointer arithmetic scales by element size; struct fields are padded to their own alignment requirement), not from guessing or running anything, precisely the trace/predict discipline this module substitutes for a compiler Pyodide does not have. If you want to check this exact prediction against a real compiler, paste the fragment into Compiler Explorer (godbolt.org, in Reading & Resources) and inspect the generated assembly and its printed output directly.
Worked example 6: The declaration versus the definition: two near-identical link-stage bugs
Two small, nearly identical C changes are proposed to a two-file program (main.c, which calls a function price_bond, and bond.c, which is meant to define it). Bug A: bond.c defines price_bond, but main.c never declares it before calling it, and no shared header exists. Bug B: main.c correctly declares price_bond (via a shared header), but the build command only compiles and links main.c, forgetting to include bond.c, so price_bond is declared but never actually defined anywhere the linker can see. Predict, for each bug, which stage of the pipeline fails, and with what kind of message.
Solution. Step 1. Bug A: no declaration in scope. The C compiler processes main.c in isolation and, seeing price_bond called with no prior declaration in scope, either raises an outright error ("implicit declaration of function") under a modern strict compiler, or historically assumed a default (and often wrong) signature, either way, this is caught at compile time, while the compiler is still looking only at main.c's own text, because a declaration's whole job is to let the compiler check a call's usage before it has any idea whether or where the real definition lives.
Step 2, Bug B: declared, but never defined anywhere in the final program. main.c compiles perfectly cleanly, the compiler is fully satisfied by the header's declaration, which is exactly what a declaration is for: a promise, checked at compile time, that a correctly-typed definition exists somewhere. The failure surfaces only when the linker tries to stitch together the final executable and cannot find any object file that actually defines price_bond ("undefined reference to price_bond") a link-stage error, not a compile-stage one, because compiling main.c alone genuinely has no way to know that bond.c was left out of the build command.
Read it. The two bugs look almost identical on the surface ("the function isn't found") but are caught at two completely different stages, for a precise, structural reason: a declaration is a promise checked at compile time per-file; a definition is the thing the linker searches the whole program for afterward. Confusing "declared" with "defined" is exactly the gap this pair of bugs is built to expose.
Worked example 7: Two Python memory-model bugs, traced with id, is, and a shared list
A junior developer writes two functions that both look correct at a glance:
def scale_holding(h, factor):
h["qty"] = h["qty"] * factor
return h
def add_transaction(amount, log=[]):
log.append(amount)
return log
Called as original = {"ticker": "TCS", "qty": 100}; scaled = scale_holding(original, 2), then separately a = add_transaction(100); b = add_transaction(200). Predict what original["qty"] and scaled is original are, and what a and b are.
Solution. Step 1: trace scale_holding with id. h is bound to the same heap object original refers to (Python always passes a reference, never a copy); h["qty"] = ... mutates that one shared dict in place; and return h returns the identical reference. So scaled is original is True (same id()), and original["qty"] is now 200, the caller's dict was mutated as a side effect, whether or not that was intended. This is neither a bug nor a surprise once you know Python's one rule ("always a reference"); it is only a surprise if you expected a hidden copy that Python's calling convention never makes.
Step 2, trace add_transaction and its default argument. log=[] creates exactly one list object at the moment this def statement runs, not fresh on every call, and every call that omits log shares and mutates that same persisting object. a = add_transaction(100) appends 100 to that one shared list and returns it; b = add_transaction(200) appends 200 to the exact same list (not a new one) and returns it. So a and b are is-identical, and both equal [100, 200], almost certainly not what the caller expected from two apparently independent calls.
Step 3, the fix, and how to prove it. The fix for add_transaction is to default to None and construct a fresh list inside the function body only when needed (if log is None: log = []), exactly as this module's own add_transaction code-pack exercise grades. Prove the fix, do not just eyeball it: call the corrected function twice with log omitted and assert the two results are == to their own expected contents and is not each other, the exact pair of checks that would have caught this bug immediately, and the exact pair this node's hidden tests run against any submitted solution.
Read it. Neither bug required anything exotic, both follow directly from Python's one calling convention (always a reference) and one fact about default values (created once, at definition time) that Core content already established. id() and is did not diagnose anything mysterious; they simply made visible what was true the entire time.
Worked example 8: Inspecting raw bytes live: an int32 wraparound and a float64's bit pattern (JS sandbox)
Using this app's live JS sandbox (the one place this module's typed-memory claims can be checked by actually running code) predict, then verify, two things: what wrapInt32(2147483648) returns, and what the 8 raw bytes of 2.0 look like as an IEEE-754 double.
Solution. Step 1: predict the int32 wraparound. 2,147,483,648 is exactly one past the largest signed 32-bit value (2,147,483,647). Reducing it modulo 2^32 and re-expressing it in the signed range means it lands exactly on the most negative signed 32-bit value: 2,147,483,648 − 4,294,967,296 = −2,147,483,648. Running this module's wrapInt32 exercise confirms exactly that: the single largest positive value's very next integer wraps all the way around to the single most negative one, the identical asymmetry Core content flagged for int8 (128 → −128), one register width up.
Step 2: predict the float64 bit pattern. 2.0 in IEEE-754 double form has sign bit 0, and, one exponent step above 1.0's biased exponent of 1023 (meaning "scale by 2^0"), a biased exponent of 1024 (2^1), with a zero mantissa (no fraction beyond the implicit leading 1, since 2.0 = 1.0 × 2^1 exactly). That bit pattern, 0x4000000000000000, is 64=0x40 as the first byte and zero for the remaining seven: [64, 0, 0, 0, 0, 0, 0, 0] in big-endian byte order.
Step 3: verify by running it. This module's float64Bytes exercise, called on 2.0, returns exactly [64, 0, 0, 0, 0, 0, 0, 0], confirmed by actually executing DataView/ArrayBuffer code in the JS sandbox, not merely asserted in prose, which is precisely the difference between this exercise and every C trace exercise above: here, unlike with C, the app can run the code and let the machine itself confirm the prediction.
Read it. The two predictions used the exact same underlying facts Core content built from scratch (modular wraparound for the integer, the sign/exponent/mantissa layout for the float) and both were checkable by execution rather than trust, which is the entire reason this module gives you a live typed-array sandbox for JavaScript specifically where it gives you only trace-and-predict for C.
Practice set
Work each problem fully before reading its solution. A calculator is assumed where useful; all company/system names are illustrative unless stated otherwise (R3).
P1 (guided). Two's complement by hand. What is the signed 8-bit (int8) value of the unsigned byte 0b11110110 (decimal 246)?
Solution. 246 ≥ 128, so subtract 256: 246 − 256 = −10. Cross-check: 0b11110110 inverted is 0b00001001 (9), plus 1 is 10, the two's-complement negation rule confirms magnitude 10, and the top bit being 1 confirms the sign is negative: −10.
P2 (guided). Why does two's complement have only one zero? Sign-magnitude reserves a bit purely for sign, giving 00000000 ("+0") and 10000000 ("−0"): two patterns for one value. Explain why two's complement does not have this problem.
Solution. In two's complement, negating a number means "invert every bit and add 1." Negating 00000000 gives (invert) 11111111, (+1) 00000000, it wraps right back to itself. There is exactly one bit pattern that represents zero, and negating it produces the identical pattern rather than a second, distinct "negative zero", which is exactly why ordinary addition circuitry works uniformly across the whole range without a special case for zero's sign.
P3. Overflow at the top. An unsigned byte holds 255 (0b11111111). What does adding 1 produce, and why?
Solution. 255 + 1 = 256 mathematically, which needs 9 bits; only 8 are available, so the result wraps modulo 2^8: 256 mod 256 = 0. The byte reads back as 0, silently, this is exactly the deterministic, defined wraparound this module's code pack demonstrates for unsigned/register-level arithmetic (contrast P4).
P4. The C-specific trap. True or false, with reasoning: in C, signed integer overflow "wraps around the same way" unsigned overflow does.
Solution. False. Unsigned overflow in C is defined to wrap modulo 2^n. Signed overflow in C is undefined behavior, the standard permits the compiler to assume it never happens, which can produce results stranger than a clean wraparound (an entire safety check silently optimized away, inconsistent behavior across compilers or optimization levels). Never rely on signed C overflow "just wrapping" the way this module's own int8/int32 exercises deterministically do for unsigned/register-level reinterpretation.
P5. Endianness identification. A 16-bit value 0x1234 is stored in memory as the two bytes 0x34, 0x12 (in that order). Is this big-endian or little-endian, and how do you know?
Solution. Little-endian: the least significant byte (0x34) is stored first, at the lower address, and the most significant byte (0x12) second. Big-endian would store 0x12 first. This is the native byte order of essentially every x86/ARM machine you will program against day to day.
P6 (guided). IEEE-754 field layout. A 64-bit double has how many sign, exponent, and mantissa bits, and what does the implicit leading 1 buy you?
Solution. 1 sign bit, 11 exponent bits (biased by 1023), 52 mantissa bits. The mantissa is interpreted as 1.mantissa in binary; because every normal (non-zero) number's leading binary digit is always 1, that leading 1 never needs to be stored (it is implicit) which effectively buys 53 bits of precision (52 stored + 1 implicit) from only 52 stored bits.
P7. Why 0.1 has no exact double. Explain, from the mantissa's structure, why 0.1 cannot be represented exactly as an IEEE-754 double.
Solution. The mantissa stores a binary (base-2) fraction. 0.1 in decimal is 1/10; in binary, 1/10 is a repeating fraction (analogous to how 1/3 repeats forever in decimal), so no finite number of mantissa bits can represent it exactly, the stored value is necessarily the nearest representable double, not 0.1 itself, which is why arithmetic built from it (like 0.1 + 0.2) does not always compare equal to a separately-rounded 0.3.
P8. The currency fix. Why does converting to integer minor units (round(amount * 100)) fix the 0.1 + 0.2 != 0.3-style comparison problem, when the multiplication itself still uses floating point?
Solution. The multiplication and the round() still happen in floating point, but round()'s output is a whole number, and whole numbers well within a double's ~15–17 significant digits of precision are represented exactly, there is no repeating-fraction problem for integers the way there is for base-10 fractions. Comparing two exact integers with == carries none of the ambiguity comparing two independently-rounded fractional floats does.
P9. UTF-8 byte cost. How many bytes does the string "₹" (just the rupee sign, U+20B9) cost in UTF-8, and how many does "$" (U+0024) cost? Why the difference?
Solution. ₹ costs 3 bytes (its code point, U+20B9 = 8377 decimal, falls in UTF-8's U+0800–U+FFFF band, the 3-byte range). $ costs 1 byte (U+0024 is plain ASCII, U+0000–U+007F). The difference is purely UTF-8's variable-width design: any code point outside the original 128 ASCII values costs more than one byte, by design, so that ASCII text stays maximally compact while the rest of Unicode remains representable.
P10. Code points versus bytes. A string has len(s) == 5 in Python. Can you conclude it is 5 bytes when UTF-8 encoded? Why or why not?
Solution. No. Python's len() counts Unicode code points, not bytes. If all 5 characters are ASCII, the string is indeed 5 bytes; if even one is outside ASCII (say, one ₹), the byte count is higher (5 code points but at least 4×1 + 3 = 7 bytes if exactly one of the five is a 3-byte character). Code-point count and byte count are the same only when every character happens to be ASCII.
P11 (guided). Compile vs. link error. A C file calls bond_price(), which is declared in a shared header but whose actual .c implementation file was accidentally left out of the build command. At which stage does this fail, and with what kind of message?
Solution. The link stage. The declaration in the header fully satisfies the compiler (a declaration is exactly a promise, checked per-file, that a matching definition exists somewhere in the eventual program) so the file calling bond_price() compiles cleanly. Only when the linker tries to assemble the final executable and finds no object file that actually defines bond_price() does it fail, typically with an "undefined reference to bond_price" error.
P12. Static versus dynamic linking, one distinguishing symptom. Name one failure a dynamically linked program can exhibit at launch time that a statically linked program cannot.
Solution. A "missing shared library" (or "library not found") error at launch, because a dynamically linked executable does not contain its dependencies' code; it only records which shared libraries (.so/.dll) it needs, and the loader must locate and attach them when the program starts. A statically linked executable already contains everything it needs, baked in at link time, so it cannot fail this particular way (though it can be larger on disk and cannot share code across processes the way a dynamic library can).
P13. Struct padding by hand. A C struct is defined as struct X { char a; char b; int c; }; on a platform where char is 1 byte, int is 4 bytes, and ints must start on a 4-byte-aligned address. Predict sizeof(struct X).
Solution. a occupies byte 0, b occupies byte 1; c (an int) needs to start on a multiple of 4, so 2 padding bytes are inserted after b (bytes 2–3), and c occupies bytes 4–7. Total: 8 bytes, not the naive 1 + 1 + 4 = 6 a sum-of-fields guess would give.
P14. Buffer overflow versus a clean crash. Explain why a C buffer overflow (writing one element past the end of an array) does not necessarily crash immediately, or at all, and why this is more dangerous than an immediate crash would be.
Solution. C performs no automatic bounds-checking, so writing past an array's end simply overwrites whatever memory happens to sit adjacent to it, which might be unused padding (no visible effect at all), another variable (silent data corruption), or genuinely protected memory (an immediate crash), entirely dependent on the surrounding memory layout, which can differ across compilers, builds, or even separate runs. The danger is precisely that "no visible effect" is a possible outcome: the bug can ship, run "successfully" for a long time, and fail unpredictably later under a different layout, rather than announcing itself the moment it happens.
P15 (guided). Identity versus equality. x = [1, 2]; y = [1, 2]. What are x == y and x is y, and why do they differ?
Solution. x == y is True (same contents, compared value-by-value). x is y is False, they are two separate list objects on the heap that independently happen to hold equal contents; nothing about creating two [1, 2] literals makes Python reuse one object for both (lists, unlike small integers, are never cached this way).
P16. The small-int cache versus constant folding. a = 100; b = 100; a is b is True in CPython. c = 1000; d = 1000; c is d (written the same way, as two literals in the same script) is also commonly True. Are these the same mechanism? Explain.
Solution. No, two different mechanisms that happen to produce the same-looking result. 100 falls inside CPython's small-integer cache (−5 to 256 inclusive), so any two references to 100 genuinely share one cached object. 1000 is well outside that cache; c is d coming back True here is instead the compiler's constant folding, which deduplicates identical literal constants appearing in the same compiled code block, regardless of value. Build 1000 independently at runtime instead (e.g., accumulated in a loop, in two separate function calls) and is correctly comes back False while == stays True, proof the two True results above were never evidence of the same thing.
P17. The mutable-default-argument trap, restated. Explain, from the moment-of-creation of default argument values, why def f(log=[]): is dangerous but def f(log=None): ... if log is None: log = [] is safe.
Solution. log=[] creates exactly one list object when the def statement executes, and that same object is reused by every call that omits log, silently accumulating state across unrelated calls. log=None instead defaults to an immutable, stateless sentinel; the actual fresh list is constructed inside the function body, which runs anew on every single call, guaranteeing a brand-new list every time the argument is omitted.
P18. Shallow copy, traced. holdings = [{"qty": 10}]; dup = list(holdings); dup[0]["qty"] = 99. What is holdings[0]["qty"] afterward, and why?
Solution. 99. list(holdings) copies the outer list only, producing a new list object that still holds references to the exact same inner dict objects as the original. dup[0] and holdings[0] are is-identical (the same dict), so mutating "qty" through dup[0] is visible through holdings[0] too, the shallow-copy aliasing bug this module's copy_portfolio exercise is built to fix, by copying each inner dict as well ([dict(h) for h in holdings]).
Applied mini-project
The bits-and-bytes forensics report. Three short, connected investigations, each producing a brief written artifact, no C compiler required or expected anywhere in this project.
Part A, A currency float check, in both markets (required: ≥1 India ₹ and ≥1 US $). Pick a small ₹ reconciliation (two or three amounts that should sum to a stated total) and a small $ one. For each: (1) compute the naive == comparison and record the result; (2) compute the minor-units-safe comparison (currency_equal-style) and record that result; (3) if the two disagree, explain, citing the actual mantissa-representable-fraction mechanism from Core content, not "floating point is imprecise" as a slogan, exactly why.
Part B, Trace a C snippet by hand. Write (or adapt) a short C fragment of your own containing at least one pointer and one struct with at least two differently-sized fields. Produce a short memory diagram: for each variable, its region (stack/heap), a symbolic address, and its value; predict the program's printed output and its sizeof of the struct, showing your padding reasoning explicitly. Then, optionally, paste the exact same fragment into Compiler Explorer (godbolt.org) and note whether the real compiler's output matched your prediction, and if it did not, diagnose exactly which step of your trace was wrong.
Part C: CPython forensics. Pick three pairs of Python objects of your own choosing that probe this module's id/is/== distinctions: (i) two small integers under 256 built independently; (ii) two integers over 256 built via genuine runtime computation (not the same literal twice); (iii) two lists with equal contents built separately. For each pair, record is and ==, and write one sentence explaining what CPython actually did in each case, the cache, constant folding, or neither, citing the mechanism from Core content, not a guess.
Deliverable and self-check. One short write-up covering all three parts. Score yourself 2 points each on: (a) both required jurisdictions present in Part A (₹ and $); (b) Part A's disagreement (or agreement) is explained via the actual mantissa/representable-fraction mechanism, not a slogan; (c) Part B's padding reasoning is shown explicitly, not just asserted; (d) Part C correctly distinguishes the small-int cache from constant folding for the over-256 pair; (e) every claim in all three parts is traceable to a named mechanism from this module, not "just floats being weird" or similar hand-waving. Pass at 8/10, with no zero on any item.
Lab. Open this node's Practice tab and run the CS1.03 code pack's 8 exercises (to_int8, wrapInt32, utf8_byte_length, currency_equal, reverse_with_stack, copy_portfolio, float64Bytes, and add_transaction) each against its hidden tests. Passing all 8 is this module's applied-gate rehearsal for the mastery check below; the structured fill-in-the-blank fallback for every exercise is available offline if your browser cannot execute code at all.
Reading & resources
Everything below is an anchor to learn from and verify against, never a source to quote a byte count or a bit pattern from without deriving it yourself (R3). Free unless marked; level tags reflect the ramp from intuition to full mechanism.
The core spine (bits, memory, and the machine):
- Randall E. Bryant & David R. O'Hallaron, Computer Systems: A Programmer's Perspective (CS:APP), the standard, comprehensive treatment of integer/float representation, the memory hierarchy, and linking; this module's compressed version of a much larger book. [Paid] [Intermediate–Advanced]
- David Patterson & John Hennessy, Computer Organization and Design, the standard reference for how a CPU and its memory actually work underneath the C-level view this module teaches. [Paid] [Intermediate–Advanced]
- David Goldberg, "What Every Computer Scientist Should Know About Floating-Point Arithmetic" (1991), the classic, rigorous, freely available paper behind this module's IEEE-754 section. [Free] [Intermediate–Advanced]
- The IEEE 754 standard (IEEE Standard for Floating-Point Arithmetic, 1985, revised 2008), the primary source for the sign/exponent/mantissa layout itself. [Paid/library] [Advanced]
C, read (and, outside this app, compiled):
- Brian W. Kernighan & Dennis M. Ritchie, The C Programming Language ("K&R"), the canonical, compact reference for exactly the pointer/array/struct material this module's trace exercises draw on. [Paid] [Intermediate]
- Compiler Explorer (godbolt.org), paste any C fragment from this module, compile it with a real compiler, and inspect both the generated assembly and the actual program output; the explicit external pointer for every C prediction in this node, since Pyodide itself cannot compile C. [Free] [Beginner–Advanced]
Text encoding:
- Rob Pike & Ken Thompson, "Hello World or Καλημέρα κόσμε or こんにちは" (USENIX, 1993), the original paper describing UTF-8's design, by its own designers. [Free] [Intermediate]
- The Unicode Consortium's own UTF-8 FAQ and code-point charts, the authoritative, current reference for any code point this module or its exercises use. [Free] [Beginner–Intermediate]
CPython's own internals:
- Anthony Shaw, CPython Internals: Your Guide to the Python 3 Interpreter, a full treatment of
id, reference counting, the small-integer cache, and CPython's own C-level object model. [Paid] [Advanced] - The official Python documentation for
sys.getsizeof,id(), and the data model, the authoritative, current, free reference for exactly what this module's introspection exercises rely on. [Free] [Beginner–Intermediate]
The JS typed-memory contrast:
- MDN Web Docs (
ArrayBuffer,DataView, andTypedArrayreference pages) the authoritative, current documentation for every method this module'sfloat64BytesandwrapInt32exercises use. [Free] [Beginner–Intermediate]
Do this, not just read: open this app's JS sandbox and run float64Bytes on five or six numbers of your own choosing (including at least one negative number and one very small fraction) and watch the sign bit, exponent, and mantissa bytes change exactly the way Core content predicts. Then open Compiler Explorer, paste in Worked Example 5's struct, and check its real, compiler-reported sizeof against your own by-hand padding calculation. Ten minutes of watching real bytes and a real compiler confirm, or correct, a prediction you made on paper will teach you more than another hour of reading about it.
Teach it back & journal
Feynman prompt. Explain to a smart friend who has only ever used Python why 0.1 + 0.2 == 0.3 is False, using the actual mechanism (a repeating binary fraction, rounded to the nearest representable double, twice independently) rather than the slogan "floats are imprecise." Then, in the same breath, explain why a function like def f(log=[]): can silently leak state across unrelated calls, using the fact that a default argument value is created exactly once, at definition time. If your friend can, unprompted, predict that the identical float bug would appear in JavaScript or C too, because it follows from IEEE-754 itself, not from Python, you have installed the mechanism, which is the whole point of this node.
Journal prompt. Pick one real bug you have personally hit, or watched someone else hit, that felt like "the computer is being weird" at the time, a float comparison that should have worked, a copied list that mysteriously shared state, a function that "remembered" something it shouldn't have, a program that crashed for no visible reason. Now that you have this module's vocabulary, rewrite that bug's diagnosis from scratch: name the actual mechanism (bit representation, stack/heap, aliasing, undefined behavior, whichever applies), and write down what earlier signal, if you had known then what you know now, would have let you predict the failure before it happened rather than debug it after. Then answer this directly: which single habit from this module, converting currency to integer minor units before comparing, checking is versus == deliberately instead of by habit, or tracing a struct's padding before trusting a sizeof, would most change how a colleague reviewing your code sees its reliability, and what will you do this week to make that habit automatic?
This module's flashcards and mastery quiz are wired into the app: see the node's Quiz and Reviews.