The Analyst's Path

Phase 12Finance Plus, AI and the quant-code trackFinance trackCS1.0212,908 words1 h 12 min of readingabout 22 hours of studyfree

Python II: Data Structures, Functions & Idiomatic OOP

Python gives you four general-purpose built-in containers, and picking among them is not a stylistic preference, it is the first and most consequential design decision in almost every function you will write.

Branch: CS Foundations (Quant-Code galaxy) · Node CS1.02 · ~22 focused hours · Understanding gate ≥ 85%

CS1.01 got you to the point where your code runs and gives the right answer. That is a real milestone, and it is also, on its own, worth almost nothing to a teammate who has to read what you wrote six months from now. Working code and well-shaped code are different achievements, and the gap between them is exactly what this module closes. Somewhere between your first for loop and your first class, every Python programmer writes the same accidental anti-patterns: a list doing a set's job (with a hand-rolled if x not in seen check quietly turning an O(1) problem into an O(n²) one), a function that looks like it "just returns a number" while secretly mutating the list you handed it, a def f(x, acc=[]) that works perfectly the first time you call it and corrupts itself the second, and a class that runs correctly but is completely opaque the moment you try to print it in a debugger.

Advertisement

None of these are exotic mistakes. They are the default output of learning "the syntax" without learning "the idiom", and every one of them will resurface, at scale, in the modules directly ahead of you. CS1.03 will show you the memory underneath the mutability rules this module teaches you to predict by behavior alone. CS1.04 will attach exact time-and-space cost to the container choices you are about to start making by feel. CS1.05, the CROWN module of this branch, builds the hash tables, trees, and heaps that Python's own dict and set are implemented with, you cannot appreciate what is inside that box until you have spent real hours using the box correctly from the outside. And every module in the quant-dev branch, from signal construction to a full backtesting engine, will hand you a Position, a Trade, a Portfolio object and expect you to already know how to make it behave like a first-class citizen of the language rather than an inert bag of attributes.

So here is the whole module compressed into one sentence, the same way AI0.01 compressed itself into "the model optimizes for plausible, not true": idiomatic Python optimizes for the next reader's five minutes, not your first five. Every rule below, pick the container whose built-in behavior matches what your data must never get wrong; keep functions pure so nothing has to be re-verified by hand; give a class the dunder methods that make Python's own tools (len(), in, ==, sorted(), for … in) work on it naturally, is the same rule wearing different clothes. You are never being asked to write more code. You are being asked to let the code you already write say what it means, so nobody, least of all future-you, staring at this file again after CS2.06 six weeks from now, has to reverse-engineer your intent from a pile of loops.

By the end of this module you will have built a small, testable, finance-flavored data model (a Position, a Trade, a Portfolio) over a joint India (₹, NSE) and US ($, Nasdaq/NYSE) basket, using every idiom taught here, and the CS1.02 code pack in this node's Code tab will grade the same disciplines automatically against hidden test cases your own script never saw. That auto-graded practice is not optional polish; the authoring law behind it is that 100% of this module must be completable with zero runtime, offline, via each exercise's structured fallback, the live grader is a convenience, not a requirement.


Learning objectives

By the end you can:

  1. Choose the right built-in container (list, tuple, dict, or set) for a given job by asking three questions: does order matter, do duplicates matter, and how will this be looked up (by position, by key, or by membership)?, and defend that choice in plain language (CS1.04 will attach exact Big-O numbers to the same intuition).
  2. Write idiomatic list, dict, and set comprehensions (including a filtering if clause and, where it stays readable, one level of nesting) and generator expressions, in place of manual accumulator loops; and recognize the point at which a comprehension has stopped being idiomatic and started being clever.
  3. Use slicing (start:stop:step, negative indices, slice assignment) fluently on lists, tuples, and strings, and state precisely when slicing produces an independent copy versus when plain indexing produces a shared reference (an alias).
  4. Distinguish mutable from immutable built-in types, explain aliasing (a = b shares one object) versus copying (copy.copy(), copy.deepcopy(), or a slice make a new one), and both predict and repair the classic mutable-default-argument bug before it ships.
  5. Default to writing pure functions (no mutation of arguments, no hidden reads or writes of outside state, no printing) and explain concretely why purity makes code easier to test, reorder, and trust; and recognize when an impure function (I/O, logging) is legitimate, and how to keep it at the edges of a program rather than scattered through the middle.
  6. Organize code into modules and packages: write and import from a module, structure a small package with __init__.py, use the if __name__ == "__main__": guard correctly, and avoid the commonest import mistakes (name-shadowing a stdlib module, a circular import, a wildcard import that hides where a name came from).
  7. Design a clean class: constructors (__init__), Python's encapsulation conventions (_protected, name-mangled __attr), and the core dunder methods (__repr__, __str__, __eq__, __hash__, __lt__ (ordering), __len__, __iter__, __contains__) that make a custom object behave like a native part of the language instead of an opaque blob.
  8. (Productivity objective: R10 duality / the Copilot Discipline.) Use an AI coding copilot to accelerate writing container-choice, comprehension, and dunder code for tasks like this module's, without skipping the by-hand mastery gate, and review every AI-suggested line for a hallucinated API, a subtly wrong edge case, or a silently broken invariant before it ships.
  9. Apply objectives 1–7 together to a small, testable, finance-flavored data model (Position, Trade, Portfolio classes over a joint India (₹, NSE) and US ($, Nasdaq/NYSE) basket) graded automatically by the CS1.02 code pack, hidden tests included.

The duality, stated once (R10). Like every AI-touched module in this curriculum, this node carries two objectives at once. The understanding objective (items 1–7 and 9) is what the mastery gate rewards: you earn it by writing the by-hand container choice, the pure function, the dunder pair, yourself, and by being able to explain why each one is right. The productivity objective (item 8) is the payoff you keep afterward: knowing how to point a coding copilot at exactly this kind of task once you already understand it, and knowing how to review what it hands back instead of trusting it on sight. A copilot can accelerate the rest of your career with this material. It cannot buy you the first pass through it.


Prerequisites & connections

Builds on. CS1.01 (Programming from Zero. Python I). This module assumes fluency with variable assignment; the built-in scalar types (int, float, str, bool); arithmetic and string operators; if/elif/else; for/while loops; writing and calling functions with positional and keyword arguments; and basic console I/O (print, input). If any of that feels shaky, go back to CS1.01 first, this module builds directly on top of it and will not re-teach it (R1: reference, don't duplicate).

Feeds forward.

  • CS1.03 (how computers really work, plus a first look at C) takes the mutability and aliasing vocabulary you build here ("a name is a reference to an object") and shows you the memory underneath it: id(), sys.getsizeof, and exactly what an alias looks like at the byte level.
  • CS1.04 (algorithmic thinking, recursion, Big-O) formalizes the container intuition this module teaches you to feel: why a set/dict membership check is O(1) on average and a list membership check is O(n), and what that means once your book has fifty thousand rows instead of five.
  • CS1.05 CROWN (data structures & algorithms) opens the hood on the very containers you are about to treat as black boxes (hash tables, trees, heaps) implemented from scratch. You cannot appreciate what is inside that box until you have used the outside of it correctly for a while, which is this module's entire job.
  • CS2.01–CS2.06 assume the idiomatic Python built here as a baseline; CS2.06's dedicated unit on reviewing AI-generated code picks up exactly where this module's Copilot Discipline callouts leave off, with harder, more adversarial examples.
  • DA1.01 (NumPy) contrasts hand-written comprehension loops against vectorized array operations, you cannot appreciate why a vectorized operation is fast until you have felt, by hand, the cost of the pure-Python loop it replaces.
  • Every class you write from QD1.01 onward. Position, Trade, Portfolio, and eventually Strategy objects recur across the whole quant-dev branch; the dunder discipline taught here (a correct __eq__/__hash__ pair, a __repr__ that actually tells you something) is what keeps those objects debuggable forty modules and two years from now.

This module owns implementation, not statistical or machine-learning theory (C-3: the boundary between this galaxy and G1's QM branch). Later modules that build models over these same containers (DA2.01 onward) will send you to QM for the derivation of why a method works; this module only teaches you to hold and shape the data correctly before anything gets modeled.


Core teaching content

Choosing a container: the four built-ins, and the three questions that pick between them

Python gives you four general-purpose built-in containers, and picking among them is not a stylistic preference, it is the first and most consequential design decision in almost every function you will write. Ask three questions about the data before you write a single line:

  1. Does order matter? Do you need to say "the third element," or walk the items in a specific sequence?
  2. Do duplicates matter? Can the same value legitimately appear twice, or does the set of distinct values fully describe what you have?
  3. How will you look this up? By position (items[2]), by an associated key (prices['TCS']), or only ever by asking "is this value present at all"?
Questionlisttupledictset
Ordered / positional access?yesyesinsertion-ordered (3.7+), not positionalno
Mutable?yesnoyesyes
Duplicates allowed?yesyeskeys: no · values: yesno
Typical access patterniterate / indexunpack a fixed recordlook up by key, O(1) averagemembership test, O(1) average
Finance-flavored examplea day's NSE closing prices for TCS, in orderone immutable trade record ('BUY', 'TCS', 10, 3500.0)ticker → latest price, across your whole bookthe set of tickers that traded today, on either exchange

A list is your default when you have an ordered, changeable sequence: a day's closing prices, a chronological trade blotter, anything you will grow, shrink, reorder, or index into by position. A tuple is what you reach for when the data is a fixed-shape record you want to guarantee stays unchanged (one OHLC bar (open, high, low, close), one immutable trade tick) and, because tuples are immutable (and therefore hashable, when their contents are), a tuple can do something a list never can: sit inside a set or serve as a dict key. A dict is the answer the instant your access pattern is "give me the value that goes with this identifier" (ticker → price, ticker → Position) because dict lookup is O(1) on average regardless of how many entries you have, where scanning a list for the same answer costs you one comparison per element, every time. A set is the answer when the only question you will ever ask is "have I seen this, yes or no" (the set of tickers that traded on the NSE today, the set of tickers your compliance rules forbid) and it comes with genuine set algebra (& intersection, | union, - difference) built in, which turns "which tickers traded on both exchanges today" into one line instead of a nested loop.

One quiet requirement threads through the last two rows of that table: dict keys and set elements must be hashable, which in practice means immutable (or at least built entirely from immutable pieces), you can use a str, an int, or a tuple of hashable things as a dict key or set element, but never a list or a plain dict. Hold that fact loosely for now; it resurfaces, with teeth, when you design your own classes later in this module, because a custom object is hashable only if you make it so.

Comprehensions: the loop you don't write

A list comprehension ([expr for item in iterable if condition]) builds a new list by evaluating expr once for every item in iterable that satisfies the optional if condition, and it is exactly equivalent to the accumulator loop you already know how to write:

# the loop
market_values = []
for p in positions:
    if p['qty'] > 0:
        market_values.append((p['ticker'], p['qty'] * p['price']))

# the same thing as a comprehension
market_values = [(p['ticker'], p['qty'] * p['price']) for p in positions if p['qty'] > 0]

Both versions produce the identical list. The comprehension is not "more powerful", it is the exact same computation, written as one expression instead of four statements, which is precisely why it is idiomatic: fewer moving parts for a reader to trace means fewer places for a bug to hide. The same pattern gives you a dict comprehension, {key_expr: val_expr for item in iterable}, for building a lookup table in one line (a 10% markup table keyed by ticker: {ticker: price * 1.1 for ticker, price in ticks}), and a set comprehension ({expr for item in iterable}) for building a deduplicated collection the same way. Nest a comprehension one level (a comprehension inside a comprehension, e.g. flattening a list of daily trade lists into one list) and it is often still readable; nest it two, and the honest move is to write the outer loop explicitly and keep the comprehension for the inner step, the entire point of the idiom is that a reader parses it in one pass, and a comprehension that needs a comment to explain what it is doing has stopped paying for itself.

A close cousin deserves its own paragraph: the generator expression, (expr for item in iterable), round parentheses instead of square brackets. Where a list comprehension builds the entire list in memory immediately, a generator expression produces values lazily, one at a time, on demand, and never materializes the whole sequence at once. For a small list of ten positions the difference is invisible. For a tick stream with several million rows, the difference is the entire ballgame: sum(qty * price for qty, price in ticks) streams through the data and holds one running total, while sum([qty * price for qty, price in ticks]) first builds a multi-million-element list just to immediately throw it away. Whenever a comprehension's only consumer is a single pass: sum(...), any(...), max(...), a single for loop, prefer the generator expression; you will feel exactly why this matters once DA1.01 introduces NumPy's vectorized arrays as the next rung up the same ladder.

{
  "skill": "Refactoring an accumulator loop into an idiomatic comprehension (list/dict/set) without changing behavior",
  "use": "Have a coding copilot draft two or three comprehension versions of a loop you already understand, then pick and verify the clearest one -- using AI to speed up the idiomatic rewrite, never to skip understanding the loop it replaces.",
  "tools": ["GitHub Copilot", "Claude", "Cursor / AI-enabled editors", "A general chat assistant, paste-in / run-locally (per R10 -- the app itself never calls an AI at runtime)"],
  "prompt": "Here is a working Python loop: <PASTE LOOP>. Rewrite it as an idiomatic comprehension, or as a generator expression if the result is only ever consumed once. Give me two options if there is a genuine readability trade-off, and flag if nesting would make a comprehension LESS readable than the original loop.",
  "verify": "Read every character of the suggested comprehension against the original loop's logic -- comprehensions are notoriously easy to get subtly wrong (a swapped key/value in a dict comprehension, a filter placed on the wrong side of the for-clause, reversed nesting order in a nested comprehension). Run both versions against the same test inputs, including an edge case such as an empty input or a duplicate key, before trusting the rewrite (Copilot Discipline law 3: always review).",
  "diy": "You must already be able to perform the loop-to-comprehension refactor by hand, on paper, for a simple case -- that is exactly what this section teaches. A copilot accelerates the idiom once you own it; it cannot install the understanding of why the comprehension is equivalent to the loop it replaced.",
  "market": "IN"
}

Slicing: precise windows into a sequence

Slicing, seq[start:stop:step], is how you carve a window out of a list, a tuple, or a string without writing a loop. start and stop default to the beginning and end when omitted; a negative index counts from the end (-1 is the last element, -2 the second-to-last); a negative step walks backward, so seq[::-1] reverses the sequence in one expression. On a ten-day closing-price list, closes[-3:] gets you the last three days, closes[:3] the first three, closes[::2] every other day starting from the first, and closes[::-1] the whole series in reverse chronological order, four common analyst operations, four slices, zero loops.

The fact worth committing to memory, because a whole class of bugs lives downstream of forgetting it, is this: slicing always returns a new object (a fresh list (or tuple, or string) holding copies of the references in that range) while plain indexing returns the element itself, a direct reference, aliased if that element happens to be mutable. closes[1:3] gives you an independent two-element list; mutating that returned list (.append(...), item assignment) never touches the original closes. But positions[0] gives you the actual first dict in the list, not a copy of it, and if you then mutate that dict (positions[0]['qty'] = 20), you have mutated the one and only dict that positions was already holding, because indexing handed you a reference, not a duplicate. Slicing also supports assignment: seq[1:3] = ['x', 'y', 'z'] replaces that sub-range in place and can change the list's length, which is a legitimate, sometimes very convenient operation on a list, and simply illegal on a str or a tuple, because both are immutable; a string behaves like a sequence for every read operation (s[2:5], s[::-1]) but refuses any write.

Mutability, aliasing, and the copy you actually meant

Every Python value is an object, and every variable name is a reference to an object, not a box that directly contains it. This single fact explains almost every surprising bug a beginner hits, so make it precise. list, dict, set, and most classes you will write yourself are mutable, you can change what they contain without creating a new object. int, float, str, bool, and tuple are immutable, any operation that looks like it "changes" one actually produces a brand-new object and rebinds the name to it. (A caveat worth one sentence: immutability is a property of the container, not transitively of everything inside it, a tuple cannot be reassigned at a given position, but if one of its positions holds a mutable list, that inner list can still be mutated freely. t = ([1, 2], 'fixed'), you cannot do t[0] = [3, 4], but t[0].append(5) works, and it mutates the very list t is holding.)

The line book2 = book1, when book1 is a list, does not copy anything, it binds a second name to the exact same list object. This is called aliasing, and once you see it, the earlier "surprising" bugs stop being surprising: book2.append('SELL 5 INFY') changes book1 too, because there was only ever one list, wearing two names. The fix, when you genuinely need an independent copy, is to ask for one explicitly: a slice (book1[:]) or list(book1) makes a shallow copy, a new outer list, holding the same inner references. A shallow copy is exactly enough when your list holds immutable elements (numbers, strings), but the instant your list holds mutable elements (a list of dicts, say, one per position), a shallow copy still shares every one of those inner dicts with the original: positions_copy = positions[:] gives you a new outer list, but positions_copy[0] is the same dict as positions[0], so positions_copy[0]['qty'] = 20 silently corrupts positions too. When you need full independence through every level of nesting, reach for copy.deepcopy(), from the standard-library copy module, which recursively duplicates everything.

Now the single most common bug this section exists to inoculate you against: the mutable default argument. Python evaluates a function's default argument values exactly once, at the moment the def statement runs, not fresh on every call. That is invisible and harmless for an immutable default like n=0, and a live landmine for a mutable one:

def append_trade(trade, book=[]):        # DANGER: this [] is created ONCE, ever
    book.append(trade)
    return book

append_trade('BUY 10 TCS')     # -> ['BUY 10 TCS']
append_trade('SELL 5 INFY')    # -> ['BUY 10 TCS', 'SELL 5 INFY']  !! the first call's trade is still there

Every call that omits book shares, and silently mutates, the one list object that was built when Python first read the def. The fix is the idiom, not a clever workaround: default to None, and build the real mutable value fresh inside the function body, every call:

def append_trade(trade, book=None):
    if book is None:
        book = []
    book.append(trade)
    return book

Now every call that omits book gets a brand-new empty list, and a call that does pass a book still appends to (and returns) that same list, exactly as intended. This exact fix is what the CS1.02 code pack's append_trade exercise grades, its hidden tests call the function repeatedly with no book argument and check that each call's result is fully independent of the last, which is precisely the check that a shared-list bug fails and a correct fix passes.

Purity by default: writing functions with no surprises

A pure function has two properties, and both matter: its output depends only on its inputs (call it twice with the same arguments and you get the same answer, every time), and it produces no observable side effects, it does not mutate any argument, does not read or write anything outside itself (a global variable, a file, the console), and does not print. total_market_value(positions), which sums qty * price across a list of position dicts, is pure the moment you write it as sum(p['qty'] * p['price'] for p in positions) and impure the moment you let it, say, .sort() the input list before summing "for convenience", the sum comes out identical either way (addition doesn't care about order), which is exactly what makes this bug so easy to ship: the function's return value looks perfectly correct while it has quietly scrambled a list some other part of the program was depending on staying in its original order.

Purity earns its keep in three concrete ways. Testing gets simpler, because a pure function's behavior is fully determined by its arguments, no hidden setup or teardown of shared state is needed, and "call it twice, get the same answer" is a property you can assert directly. Reasoning gets simpler, because you can understand a pure function completely by reading its body and its inputs, never the rest of the program's history, nothing that happened three functions ago can have changed what this one returns. And reordering and reuse get simpler, because if nothing shared is being mutated, the order calls happen in stops mattering, which is exactly the property later modules will lean on when they parallelize or batch this kind of computation.

None of this means impure functions are illegal, printing a report, writing a log line, saving a file are all legitimate, necessary things a program must eventually do. The idiom is to keep them at the edges: structure a program as a functional core (pure logic, computing totals, filtering positions, building comprehensions) wrapped in a thin imperative shell (the one place that actually prints, logs, or saves), rather than scattering print() calls and in-place mutations through the middle of your business logic. The most common way a function becomes accidentally impure without anyone intending it is by mutating an argument it was handed: calling .append(), .sort(), or .pop() directly on a list or dict that was passed in, rather than building and returning a new one. The reflex this section is installing is simple to state and easy to check: before you ship a function that is supposed to "just compute a value," ask whether calling it twice, with the same input object, leaves that input bit-for-bit identical both times. If it doesn't, you have found a hidden side effect.

Organizing code: modules, packages, and the import system

Every .py file is a module: a self-contained namespace. import pricing runs pricing.py once (the first time it is imported anywhere in a program) and binds the name pricing to the resulting namespace, so you reach its contents as pricing.total_market_value(...). from pricing import total_market_value instead pulls that one name directly into your current namespace, so you call it bare, as total_market_value(...), convenient, but it means a reader has to know which module a bare name came from, which is exactly why a wildcard import (from pricing import *) is discouraged: it can silently shadow names you already had, and it hides, for every single name it imports, where that name actually came from.

A directory containing multiple related modules, plus an __init__.py file (even an empty one), becomes a package: analyst_toolkit/pricing.py and analyst_toolkit/positions.py living together let you write from analyst_toolkit import pricing, from analyst_toolkit import positions, or, from inside the package itself, a relative import like from . import positions. Two mistakes recur constantly at this stage of learning Python, and both are easy to sidestep once named: shadowing a standard-library module by naming your own file the same thing (a file called random.py in your project directory will hijack import random project-wide, since Python searches your own directory before the standard library), and a circular import (module A imports module B, which imports module A back), which either fails outright or silently hands you a half-initialized module depending on exactly where in each file the import statement sits.

Finally, every module carries a special attribute, __name__, and Python sets it to the literal string "__main__" only in the one file you actually executed directly (python pricing.py), every module that gets imported has its own __name__ set to its module name instead. That single fact is the entire reason for the ubiquitous guard:

def main():
    book = build_demo_portfolio()
    print(total_market_value(book))

if __name__ == "__main__":
    main()

Code inside that if block runs when you execute the file directly (a demo, a command-line entry point) and does not re-run every time some other module imports this one for its functions, without the guard, importing a module for one helper function would also silently re-execute whatever "run this as a script" logic lives at the top level of that file, which is confusing at best and, in a codebase with several interdependent modules, a genuine source of circular-import chaos at worst.

Designing a clean class: constructors and encapsulation

A class statement defines a blueprint; __init__(self, ...) is where you set up a new instance's attributes the moment it is created (technically __new__ allocates the object and __init__ initializes it, but for everyday class design you can treat __init__ as "the constructor"). self is the instance itself, passed explicitly as the first parameter of every ordinary method. Python has no implicit this the way some other languages do; you write self.ticker, not a bare ticker, whenever you mean "this particular instance's attribute." Attributes you set inside __init__ (self.ticker = ticker) are instance attributes, one independent copy per object; an attribute set directly inside the class body, outside any method, is a class attribute, shared by every instance unless a given instance shadows it, and if that shared class attribute happens to be mutable (a list, say), you have recreated the mutable-default-argument trap at the class level: every instance that hasn't set its own copy is silently sharing (and can silently corrupt) the one class-level list.

Python has no true "private" attribute, encapsulation here is a set of conventions, not enforcement. A single leading underscore (self._internal_cache) is the universally understood signal for "internal, please treat this as an implementation detail, not part of the public interface", nothing stops external code from reading it, but nothing well-behaved does. A double leading underscore (self.__secret) triggers name mangling: Python internally renames it to _ClassName__secret, which exists mainly to prevent an accidental attribute clash when a subclass reuses the same name, not to provide real privacy. And the @property decorator lets you expose a computed value through the exact same syntax as a plain attribute: position.market_value reads like an attribute access from the outside, while underneath it is a method (def market_value(self): return self.qty * self.price) that recomputes on every access, guaranteeing it can never drift out of sync with qty and price the way a manually-updated stored field could.

class Position:
    def __init__(self, ticker, qty, price):
        self.ticker = ticker
        self.qty = qty
        self.price = price

    @property
    def market_value(self):
        return self.qty * self.price

Dunders: making your object a first-class citizen of Python

Python's own built-in operators and functions: len(...), for x in ..., x in ..., ==, <, str(...), repr(...), hashing, all work by looking for a correspondingly named dunder ("double underscore") method on the object in front of them. Implement the right one, and your own class starts interoperating with the whole language, not just the methods you happened to write; skip it, and Python falls back to defaults that are rarely what you want.

Start with the two that control how your object prints. __repr__ is for developers and debuggers, unambiguous, ideally close to something you could paste back into Python to reconstruct the object (Position('TCS', 10, 3500.0)); __str__ is for end users, a friendlier display string. If you define only __repr__, Python uses it automatically wherever __str__ would otherwise be needed (print(obj) included), which is exactly why the rule is always define __repr__, even before you write your first test. A class with no __repr__ prints as <Position object at 0x7f2a1c0b3d90> the first time you try to inspect it in a debugger, which tells you nothing, at the exact moment you most need to see what you built.

Next, __eq__ defines what == means for your class, almost always, "these represent the same value," compared field by field, not "these are the same object in memory" (that second question is what the is operator, and Python's default __eq__, already answer for you). And here is the rule with real teeth, worth reading twice: the moment you define __eq__ on a class, Python automatically sets that class's __hash__ to None, making every instance unhashable, unless you also define __hash__ yourself. This is not an oversight, it exists to stop you from accidentally breaking the invariant every hash-based container relies on, that two objects which compare equal must produce the same hash. If you want your objects usable inside a set or as dict keys (and for a value-like object such as a Position, you usually do), you must define __hash__ explicitly, deriving it from exactly the same fields __eq__ compares:

def __eq__(self, other):
    if not isinstance(other, Position):
        return NotImplemented
    return (self.ticker, self.qty, self.price) == (other.ticker, other.qty, other.price)

def __hash__(self):
    return hash((self.ticker, self.qty, self.price))

Round out the picture with the dunders that make an object behave like a native container or an orderable value: __len__ backs len(obj); __iter__ backs for x in obj: (returning an iterator over whatever the object holds internally); __contains__ backs x in obj, and if you skip it, Python falls back to using __iter__ and comparing every element with ==, which is correct but can be far slower than an explicit __contains__ backed by, say, an internal set; and __lt__ (return a boolean comparing self to other on whatever field defines "smaller") is what sorted() needs to order a list of your objects at all, without it, sorted(positions) raises a TypeError, because Python has no way to know which of two Position instances should come first. Resist the temptation to overload operators for cleverness's sake, a Portfolio.__add__ that merges two books is a defensible, readable use of the protocol; a Portfolio.__add__ that quietly means "subtract fees" is the opposite of what dunders are for. The whole point of this protocol is that it lets your object tell the truth about what it is, using vocabulary every Python programmer already reads fluently.

One shortcut is worth knowing once you have written the __repr__/__eq__/__hash__ trio by hand at least once: the standard-library @dataclass decorator (from dataclasses import dataclass) will generate __init__, __repr__, and __eq__ for you from a short field declaration, and (with frozen=True) a correct __hash__ too:

from dataclasses import dataclass

@dataclass(frozen=True)
class Position:
    ticker: str
    qty: int
    price: float

    @property
    def market_value(self):
        return self.qty * self.price

This is not a different idiom from the one this section just taught, it is the same dunder contract, generated mechanically instead of typed by hand, and frozen=True additionally makes every field immutable after construction (assigning p.qty = 5 raises an error), which is exactly the guarantee you want on a value-like record such as a trade or a position. The reason this module still asked you to write the trio by hand first is the reason @dataclass is safe to reach for afterward and not before: you cannot tell, by reading @dataclass(frozen=True), why it is correct to make __hash__ depend on immutability unless you already understand the __eq__/__hash__ agreement it is quietly enforcing on your behalf. Once you do, @dataclass is simply the idiomatic way to stop retyping a pattern you have already proven you understand.

{
  "skill": "Scaffolding a correct __repr__/__eq__/__hash__ trio for a new data class",
  "use": "Ask a coding copilot to draft the standard dunder trio for a class shape you specify, then verify the __eq__/__hash__ pair actually agree with each other -- using AI to save typing on a well-understood, highly formulaic pattern, never to skip understanding why the pair must agree in the first place.",
  "tools": ["GitHub Copilot", "Claude", "Cursor / AI-enabled editors", "the stdlib @dataclass decorator, which generates this exact trio correctly whenever your class shape fits it"],
  "prompt": "Write __repr__, __eq__, and __hash__ for a Python class named <NAME> with fields <FIELD LIST>. __eq__ should compare all fields for value equality; __hash__ must be derived from exactly the same fields as __eq__. Do not include a mutable field (like a list) in the hash. Explain in one line why __hash__ must agree with __eq__.",
  "verify": "Never trust a bare claim that '__hash__ is correct' -- instantiate two equal instances yourself and confirm hash(a) == hash(b) and that {a, b} collapses to one element in a set; instantiate two unequal instances and confirm they do NOT collapse. A common hallucination here is hashing a mutable field, or an __eq__ that quietly compares object identity (falling back to the default) instead of the stated fields (Copilot Discipline law 3: always review, and check no API was invented).",
  "diy": "You must already be able to explain, unprompted, why defining __eq__ without __hash__ makes an object unhashable in Python 3 -- that is this section's core lesson. Only once you can state the rule yourself should a copilot be trusted to save you the typing of applying it.",
  "market": "US"
}

Common mistakes & how experts think differently

  1. "Lists are the default; I'll just use a list for everything." A list standing in for something whose defining property is uniqueness (a set of tickers already seen) or key-based lookup (ticker → price) is a bug waiting for its first edge case (an accidental duplicate, an O(n) scan standing in for what should be an O(1) lookup) even on the day it "works." Experts pick the container whose built-in rules match the data's actual shape, because the choice is the documentation: a teammate reading seen_tickers: set[str] in a signature instantly knows more than the same data typed as list[str].
  2. "== and is are basically interchangeable." == asks "are these equal in value"; is asks "are these the literal same object in memory." CPython caches small integers and short strings, so a is b can look True by accident for tiny values and mislead a beginner into thinking is performs value comparison. Experts reserve is for singleton checks (x is None) and always reach for == (including on their own __eq__-bearing classes) for anything that means "the same value."
  3. "new_list = old_list made me a copy." That line binds a second name to the same list object: nothing is duplicated. Experts reach for a slice (old_list[:]), list(old_list), or copy.deepcopy() (for nested mutable structures) the instant they actually need independence, and they know which of the three the situation calls for.
  4. "A default argument is evaluated fresh on every call." Defaults are evaluated exactly once, when the def statement runs, a mutable default becomes shared, silently accumulating state across every call that omits it. Experts default to None and build the real mutable value inside the function body, reflexively, without needing to be reminded twice.
  5. "If it fits on one line as a comprehension, it's better code." A comprehension nested three levels deep, or one whose filter needs a comment to parse, has stopped being idiomatic and started being clever. Experts revert to an explicit loop the moment a comprehension needs re-reading twice, because the entire point of the idiom was readability, not compression.
  6. "Any function that returns the right answer is pure." Purity is about the absence of side effects, not merely a correct return value, a function that mutates its argument in place, or reads a global, can return the "right" number while quietly breaking every caller's assumption that their data survived the call unchanged. Experts test purity explicitly: call the function twice on the same input and confirm the input itself is bit-for-bit identical both times, not just that the output looked fine once.
  7. "I defined __eq__, so my object works in a set now." Python sets __hash__ to None the instant you define __eq__ without also defining __hash__, specifically to stop equal-but-differently-hashed objects from corrupting a set or dict's internal bucketing. Experts define both together, deriving the hash from exactly the fields the equality check uses, every time, as one unit.
  8. "It ran once and gave the right number, so the class is done." A class with no __repr__ is invisible in a debugger, <Position object at 0x7f2a1c> tells you nothing about what you built. Experts write __repr__ before they write the first test, because the very first thing they do with a new object is print it to see its shape, and an unhelpful repr costs a whole debugging session later for the two minutes it would have taken now.

The thread through all eight: an expert's Python is not "code that happened to produce the right output once." It is code whose container choices, purity, and dunder protocol all tell the truth about the data's shape and intent, so the next read (yours, in six months, or a teammate's, tomorrow) costs minutes instead of hours. That is the whole content of this module's payoff line: idiomatic Python optimizes for the next reader's five minutes, not your first five, idiom is not decoration, it is documentation you never have to keep in sync by hand, because it is the code itself.


Worked examples

Worked example 1: The O(n²) you didn't mean to write (India, ₹)

Meera is building a quick end-of-day report: given the raw stream of every ticker that traded on the NSE today (with heavy repetition, thousands of ticks, one entry per trade), she needs the sorted list of distinct tickers that traded at all. Her first draft looks entirely reasonable:

seen = []
for ticker in raw_ticks:            # raw_ticks: thousands of entries, heavy repeats
    if ticker not in seen:
        seen.append(ticker)
result = sorted(seen)

It produces the right answer on a small test list (say raw_ticks = ['TCS','INFY','TCS','RELIANCE','INFY','TCS','HDFCBANK'] gives result == ['HDFCBANK','INFY','RELIANCE','TCS']) and Meera ships it.

Solution. Step 1, name what the code is actually doing. if ticker not in seen is a membership test on a list, and a list's membership test has to scan every element already in it, one at a time, until it finds a match or reaches the end. For each of the thousands of incoming ticks, in the worst case, Python is rescanning an ever-growing list, the classic hidden O(n²) hiding inside code that "looks like" a simple loop.

Step 2: recognize the container mismatch. The task Meera actually has is pure membership testing ("have I already recorded this ticker?") with zero interest in order or duplicates during accumulation, that is precisely the definition of a job for a set, not a list. A set's membership test is O(1) on average, so recording each of the thousands of ticks becomes a constant-time operation instead of a scan.

Step 3, apply the fix, and check it against both markets. unique_sorted_tickers(tickers) = sorted(set(tickers)) replaces the entire loop, does the same de-duplication, and finishes at, roughly, linear cost instead of quadratic. On the same NSE-heavy sample: sorted(set(['TCS','INFY','TCS','RELIANCE','INFY','TCS','HDFCBANK'])) == ['HDFCBANK','INFY','RELIANCE','TCS'], matching Meera's original output exactly, just without the hidden cost. On a US-flavored sample the identical function does the identical job: sorted(set(['AAPL','XOM','JPM','AAPL','MSFT','XOM'])) == ['AAPL','JPM','MSFT','XOM'].

Read it. Nothing about Meera's output was wrong, the bug was invisible on a five-item test list and would have stayed invisible until the day the exchange feed handed her fifty thousand ticks instead of seven, at which point the report that used to take a blink would start taking seconds, then minutes, with no error message anywhere to blame. This is exactly what CS1.04 will teach you to predict on paper before you ever run the code; here, you only need to feel that a list's in and a set's in are not the same operation wearing different syntax.

Worked example 2: The trade blotter that remembered everything (US, $)

Chris is writing a small paper-trading helper: log_fill(fill, blotter=[]) appends one fill string to a blotter and returns it, meant to be called once per fill with no blotter argument, letting each call start its own fresh list unless the caller explicitly passes one in to keep building on.

def log_fill(fill, blotter=[]):
    blotter.append(fill)
    return blotter

morning = log_fill('BUY 100 AAPL')
afternoon = log_fill('SELL 50 MSFT')
print(afternoon)   # Chris expects: ['SELL 50 MSFT']

The printed result is ['BUY 100 AAPL', 'SELL 50 MSFT'], the morning trade is still there, contaminating an unrelated call that never asked for it.

Solution. Step 1, locate the mechanism, not just the symptom. blotter=[] is a default argument, and Python creates that empty list exactly once, when the def log_fill(...) statement is first executed, not once per call. Both morning and afternoon are the same list object, returned twice.

Step 2: predict where else this bites. Any function signature with a mutable default (=[], ={}, =set()) has the identical exposure, regardless of what the function is named or what it is nominally "for." The bug is dormant the moment the function is defined and detonates the second time anyone calls it without supplying their own container.

Step 3, apply the standard fix and re-verify on both markets. Replace the default with None, and build the real list fresh, inside the function body, only when nothing was supplied:

def log_fill(fill, blotter=None):
    if blotter is None:
        blotter = []
    blotter.append(fill)
    return blotter

Repeating Chris's exact sequence now gives morning == ['BUY 100 AAPL'] and afternoon == ['SELL 50 MSFT']: fully independent, as intended. Run three independent India-side calls the same way (log_fill('BUY 10 RELIANCE'), log_fill('BUY 4 HDFCBANK'), log_fill('SELL 2 ITC')) and each returns a single-element list of its own trade, with zero leakage between calls; this is exactly the anti-hardcode check the CS1.02 code pack's append_trade exercise runs as a hidden test, precisely because a solution that merely "looks right" on one call but still shares state across repeated calls fails it immediately.

Read it. This bug is popular specifically because it is invisible in the most common way people first test a function, call it once, see the right answer, ship it. It only shows up on the second unguarded call, which is exactly why the fix belongs in muscle memory rather than in a checklist you consult after something has already broken in production.

Worked example 3: The copy that wasn't (India, ₹)

Divya wants to try a "what if I trimmed this position" scenario without touching her real book, so she writes what looks like a defensive copy:

positions = [{'ticker': 'TCS', 'qty': 10, 'price': 3500.0},
             {'ticker': 'INFY', 'qty': 5, 'price': 1500.0}]
scenario = positions[:]              # "a copy," Divya believes
scenario[0]['qty'] = 4               # trim the TCS position, just for the what-if
print(positions[0]['qty'])           # Divya expects: 10 (unchanged)

The printed value is 4, not 10. Her "real" book has been silently trimmed too.

Solution. Step 1, separate the two levels that got copied differently. positions[:] is a slice, and slicing genuinely does create a new outer list, scenario and positions are two distinct list objects, and appending to one does not affect the other. But a slice is a shallow copy: it copies the references the outer list holds, not the objects those references point to. Both lists' first element is a reference to the exact same dict.

Step 2, predict the failure before checking further. Any mutation that goes through the outer list (append, remove, reorder) is safely independent between positions and scenario. Any mutation that reaches into a shared inner dict (scenario[0]['qty'] = 4) mutates the one dict both lists point at, which is exactly what happened.

Step 3, fix it, and confirm independence both ways. When the elements themselves are mutable, use copy.deepcopy, which recursively duplicates every level:

import copy
scenario = copy.deepcopy(positions)
scenario[0]['qty'] = 4
assert positions[0]['qty'] == 10     # untouched now
assert scenario[0]['qty'] == 4       # the what-if, safely isolated

The same shallow-versus-deep distinction applies identically to a US book, deepcopying a list of {'ticker': 'AAPL', ...} dicts before running a scenario protects the original exactly the same way, currency and exchange notwithstanding.

Read it. "I sliced it, so it's independent" is true and false at the same time, one level apart, and that is exactly what makes this bug so easy to ship, the outer list genuinely is a fresh object, so a quick sanity check (scenario is not positions) reports True and looks reassuring, while the corruption is waiting one level deeper, in the shared inner dicts a shallow copy never touched.

Worked example 4: The set that wouldn't collapse (US, $)

Sam builds a Position class for a small Nasdaq paper-trading tool, defines __eq__ so that two positions with the same ticker, quantity, and price compare equal, and later tries to deduplicate a batch of positions by dropping them into a set:

class Position:
    def __init__(self, ticker, qty, price):
        self.ticker, self.qty, self.price = ticker, qty, price

    def __eq__(self, other):
        return (self.ticker, self.qty, self.price) == (other.ticker, other.qty, other.price)

p1 = Position('AAPL', 15, 190.0)
p2 = Position('AAPL', 15, 190.0)
unique = {p1, p2}     # Sam expects one element, since p1 == p2

This raises TypeError: unhashable type: 'Position' before it ever gets to compare anything.

Solution. Step 1: name the mechanism. The moment a class defines __eq__, Python automatically sets that class's __hash__ to None, unless __hash__ is also defined explicitly. This is deliberate: without it, p1 == p2 could be True while hash(p1) != hash(p2), which would silently corrupt any set or dict that relies on equal objects hashing equal. Python refuses to let that combination exist by default; it would rather raise a clear error than risk a subtly corrupted hash table.

Step 2, supply the missing half, from the same fields. __hash__ must be derived from exactly the fields __eq__ compares, anything else reintroduces the very inconsistency the language was protecting against:

def __hash__(self):
    return hash((self.ticker, self.qty, self.price))

Step 3, verify hashability and correctness together, not just one. With __hash__ added, {p1, p2} now collapses to a single element (len({p1, p2}) == 1), because the two positions are equal and hash equal. A third position with a different price, Position('AAPL', 15, 195.0), must NOT collapse with the first two (len({p1, p2, p3}) == 2), checking only the collapse case and not the non-collapse case would let a bug slip through where __hash__ ignores a field __eq__ cares about (say, hashing only on ticker), which would wrongly merge two genuinely different positions. The same pair-then-verify discipline applies identically on the India side: Position('HDFCBANK', 20, 1600.0) built twice with identical fields must collapse in a set exactly the same way AAPL does.

Read it. The error message here is a gift, not an obstacle. Python is telling you, precisely, which half of a two-part contract you forgot. Experts read TypeError: unhashable type on their own class as "I defined equality without defining hashing," not as a mystery to route around.

Worked example 5: The sort that hid inside a sum (India, ₹)

Kabir writes total_market_value(positions) for a daily NAV report, it looks entirely reasonable, and it returns the right number every time:

def total_market_value(positions):
    positions.sort(key=lambda p: p['ticker'])   # "just tidying up before I sum"
    return sum(p['qty'] * p['price'] for p in positions)

book = [{'ticker': 'RELIANCE', 'qty': 4, 'price': 2500.0},
        {'ticker': 'HDFCBANK', 'qty': 20, 'price': 1600.0},
        {'ticker': 'ITC', 'qty': 100, 'price': 430.0}]
total = total_market_value(book)     # 85000.0 -- correct!

total is exactly 85000.0 (4×2500 + 20×1600 + 100×430 = 10,000 + 32,000 + 43,000). Weeks later, a downstream report that prints book (expecting the original chronological trade order, RELIANCE, HDFCBANK, ITC) starts printing HDFCBANK, ITC, RELIANCE instead, and nobody can find where the reordering happened, because every function that touches book "just computes a number."

Solution. Step 1, notice that the bug hides behind a correct answer. Addition is commutative, the sum of qty * price across a list is identical no matter what order the list is in, so .sort()-ing before summing changes nothing about total_market_value's return value. That is exactly what makes this bug dangerous: the function that introduced the corruption passes every test that only checks its own return value.

Step 2: locate the actual side effect. .sort() mutates the list in place; total_market_value was handed the caller's real book, sorted it as a matter of "tidying up," and returned. Any other code holding a reference to that same list, the very definition of aliasing from earlier in this module, now sees the reordered version, permanently, with no error raised anywhere.

Step 3, fix it, and re-verify purity, not just the total. Either drop the sort entirely (it was never needed for the sum), or, if a sorted view is genuinely useful somewhere, use sorted(positions, key=...), which returns a new list and leaves the original untouched: instead of the in-place .sort():

def total_market_value(positions):
    return sum(p['qty'] * p['price'] for p in positions)   # no mutation, same 85000.0

Confirm purity explicitly, not just the number: snapshot book with copy.deepcopy before the call, run total_market_value(book), and assert book still equals the snapshot afterward. The identical discipline holds for a US book, total_market_value over AAPL/MSFT/XOM positions must leave that list exactly as it found it too; the CS1.02 code pack's hidden tests check precisely this, on both an India and a US position list, in addition to checking the returned total.

Read it. "It returns the right number" is a necessary check and, on its own, an insufficient one. The habit this example installs is the same one from Common Mistake 6: test purity as its own, separate claim (call the function twice on the same input object and confirm that object is bit-for-bit unchanged both times) rather than inferring purity from a correct return value, because the two are, provably, independent facts.

Worked example 6: The list that didn't need to exist (US, $)

Jordan is processing a large end-of-day tick file (illustrative scale: several million ticks) to compute one number (the total notional traded across the day) and writes the natural-looking comprehension:

total_notional = sum([qty * price for qty, price in ticks])

The program runs correctly and uses far more memory, and takes noticeably longer to start producing output, than Jordan expected for what is conceptually "add up some numbers."

Solution. Step 1, separate what is needed from what got built. sum(...) needs to see each qty * price value exactly once, in sequence, and accumulate a running total, it never needs the whole list to exist simultaneously. The list comprehension inside those square brackets, though, builds the entire multi-million-element list in memory first, purely so that sum() can immediately iterate over it and discard it.

Step 2, swap the container for the access pattern. This is a single-pass consumer, which is exactly the case a generator expression exists for: change the square brackets to nothing (drop them entirely: sum(...) accepts a bare generator expression as its one argument) or, equivalently, use round parentheses:

total_notional = sum(qty * price for qty, price in ticks)   # no intermediate list, ever

Step 3, verify the two versions agree, then keep the cheaper one. On any concrete tick list, an NSE-flavored sample, a Nasdaq-flavored sample, or a small mixed one you construct yourself, both versions must produce bit-for-bit the same total, because they are computing the identical sum; the only difference is whether Python ever holds every intermediate product in memory at once. Confirm equality on a small sample you can check by hand, then trust the generator version at scale precisely because you have proven the two are computing the same thing.

Read it. A list comprehension and a generator expression can look almost identical on the page (one character's difference, [ versus () while representing genuinely different resource commitments. The idiom "if it's only consumed once, don't materialize it" is not a micro-optimization to sprinkle everywhere; it's the specific case where the cheaper container is also the more honest one, because it says, correctly, "I only ever need to see this once."


Practice set

Work each problem before reading its solution. Every position in this set is illustrative synthetic sample data (seeded/fixed, not a live quote), unless stated otherwise.

P1 (guided). Pick a container, and defend it. For each of the following, name the best-fit built-in container and justify the choice using the order / duplicates / lookup framework: (a) the set of tickers that traded at all today; (b) one immutable OHLC bar; (c) ticker → latest price, across your whole book; (d) a chronological trade blotter you keep appending to.

Solution. (a) set, the only question is membership ("did this ticker trade"), duplicates and order are both irrelevant. (b) tuple, a fixed-shape record you want guaranteed not to mutate, and (being immutable) usable as a dict key or set element if needed. (c) dict, you need O(1)-average lookup by an identifier (the ticker), not a scan. (d) list, order matters (chronological), duplicates are expected (repeat trades on the same ticker), and you grow it by appending.

P2 (guided). Loop-to-comprehension refactor. Rewrite this loop as one list comprehension: result = [] then, for each position p in positions, if p['qty'] > 0, append (p['ticker'], p['qty'] * p['price']).

Solution. result = [(p['ticker'], p['qty'] * p['price']) for p in positions if p['qty'] > 0]. On positions = [{'ticker':'TCS','qty':10,'price':3500.0}, {'ticker':'INFY','qty':-5,'price':1500.0}, {'ticker':'WIPRO','qty':20,'price':400.0}], this gives [('TCS', 35000.0), ('WIPRO', 8000.0)], the negative-quantity INFY entry is correctly filtered out.

P3 (guided). Dict comprehension. Given ticks = [('TCS', 3500.0), ('INFY', 1500.0)], write a dict comprehension mapping each ticker to its price with a 10% markup applied (round to 2 decimals).

Solution. {ticker: round(price * 1.1, 2) for ticker, price in ticks} gives {'TCS': 3850.0, 'INFY': 1650.0}.

P4. Set algebra across two watchlists. An analyst maintains two synthetic watchlist sets for this exercise (illustrative constructions only, not a claim about real dual-listings): nse_today = {'TCS','INFY','RELIANCE','HDFCBANK'} and us_today = {'AAPL','MSFT','TCS','JPM'} (here 'TCS' is included purely as a shared synthetic test value). Write the expressions for: tickers in both lists; tickers only in the NSE list; tickers only in the US list; tickers in either list.

Solution. Both: nse_today & us_today → {'TCS'}. NSE-only: nse_today - us_today → {'HDFCBANK', 'INFY', 'RELIANCE'}. US-only: us_today - nse_today → {'AAPL', 'JPM', 'MSFT'}. Either: nse_today | us_today → {'AAPL', 'HDFCBANK', 'INFY', 'JPM', 'MSFT', 'RELIANCE', 'TCS'}.

P5. Slicing arithmetic. Given closes = [100,102,101,103,105,104,106,108,107,110] (a 10-day series), write the slice expressions for: the last 3 days; the first 3 days; every other day starting from day 1; the whole series in reverse.

Solution. Last 3: closes[-3:] → [108, 107, 110]. First 3: closes[:3] → [100, 102, 101]. Every other day: closes[::2] → [100, 101, 105, 106, 107]. Reversed: closes[::-1] → [110, 107, 108, 106, 104, 105, 103, 101, 102, 100].

P6. Slice copy versus index alias. Given a = [[1, 2], [3, 4]], explain why a[1:].append([9, 9]) never changes a's length, but a[1].append(9) does change a.

Solution. a[1:] is a slice: it builds a brand-new list (holding references to a's existing inner lists), so appending to that new list has no effect on a itself. a[1] is indexing, it returns the actual second inner list that a holds a reference to, not a copy of it, so .append(9) on that returned object mutates the very list a[1] refers to, and a now shows [[1, 2], [3, 4, 9]].

P7. Spot the aliasing bug. Given book1 = ['BUY 10 TCS'], then book2 = book1, then book2.append('SELL 5 INFY'), what does book1 contain afterward, and why?

Solution. book1 == ['BUY 10 TCS', 'SELL 5 INFY']. book2 = book1 bound a second name to the same list object (there is only one list in memory, wearing two names) so mutating it through book2 is visible through book1 too.

P8. Fix the mutable default. Given def add_leg(leg, legs=[]): legs.append(leg); return legs, rewrite it correctly, and explain in one sentence why the original is broken.

Solution. def add_leg(leg, legs=None): then if legs is None: legs = [] then legs.append(leg); return legs. The original is broken because legs=[] is evaluated once, when the function is defined, so every call that omits legs shares and silently accumulates state in that one list.

P9. Purity check. Is def apply_haircut(positions, pct): [p.update(price=p['price'] * (1 - pct)) for p in positions]; return positions pure? If not, name the exact side effect.

Solution. Not pure. p.update(...) mutates each dict in the input list in place (and the outer comprehension is being (ab)used purely for its side effect, discarding its own result, itself a second smell), so the caller's original positions list is altered by a function that looks like it "just returns" something.

P10. Shallow versus deep copy. Given originals = [{'ticker': 'WIPRO', 'qty': 1}], explain why snapshot = originals.copy() (or originals[:]) does not protect originals from snapshot[0]['qty'] = 99, and name the fix.

Solution. .copy() and [:] both make a shallow copy, a new outer list, but holding the same inner dict references as originals. snapshot[0] and originals[0] are one and the same dict, so mutating one mutates both. Fix: snapshot = copy.deepcopy(originals), which recursively duplicates every nested level.

P11. The __name__ guard. Module pricing.py has print("loading pricing...") at the top level (not inside any function or guard) and is imported by report.py. What prints, and when?

Solution. "loading pricing..." prints once, at the moment report.py's import pricing line executes, because module-level code (anything not inside a function or an if __name__ == "__main__": guard) runs immediately, in full, the first time a module is imported anywhere in the program, regardless of whether the importer wanted that side effect.

P12. __eq__ without __hash__. A class defines __eq__ but not __hash__. What happens when you call my_set.add(instance), and precisely why?

Solution. TypeError: unhashable type. Defining __eq__ causes Python to automatically set that class's __hash__ to None, specifically to prevent equal-but-differently-hashed instances from corrupting a set or dict's internal bucketing, you must define __hash__ yourself (from the same fields __eq__ uses) to make instances hashable again.

P13. __repr__ versus __str__. A class defines __repr__ but not __str__. What does print(obj) show?

Solution. Whatever __repr__ returns. When __str__ is absent, Python falls back to __repr__ for every operation that would otherwise use __str__, including print() and str(), which is exactly why defining __repr__ alone is a legitimate minimum, while defining neither leaves you with the unhelpful default <ClassName object at 0x...>.

P14. __contains__ fallback. A class defines __iter__ but not __contains__. Does x in obj still work? What is the performance cost of relying on the fallback versus defining __contains__ explicitly, backed by an internal set?

Solution. Yes, it still works. Python falls back to iterating the object via __iter__ and comparing each yielded element to x with ==, stopping at the first match. The cost is O(n) in the worst case (a full scan), where an explicit __contains__ backed by an internal set could answer the same question in O(1) on average, correct either way, but potentially much slower at scale without the explicit method.

P15. Ordering with __lt__. Given a Position class with a market_value property, write __lt__ so that sorted(positions) orders them by market value, and state what happens to sorted(positions) if __lt__ is never defined.

Solution. def __lt__(self, other): return self.market_value < other.market_value. Without __lt__ defined, sorted(positions) raises TypeError: '<' not supported between instances of 'Position' and 'Position', because Python has no way to determine which of two instances should be ordered first.

P16. Design decision, India + US. A portfolio holds both NSE positions (₹) and Nasdaq/NYSE positions ($). Would you store it as one flat list of position dicts/objects, or as two separate lists (or a dict keyed by market)? Argue using the order/duplicates/lookup framework, and note one thing this module deliberately leaves out of scope.

Solution. Two separate collections (e.g., a dict keyed by market, {'NSE': [...], 'US': [...]}, or simply two named lists) is the better default, because currency is not a detail to blend silently inside one combined total, summing ₹ and $ figures together without an explicit, deliberate conversion step produces a number that looks precise and means nothing. The order/duplicates/lookup framework doesn't change across the split (each side is still, most naturally, a list, ordered by trade sequence, or a dict if you need ticker-keyed lookup within one market), the framework governs within-market container choice; keeping the two markets structurally separate is a modeling decision layered on top of it. Currency conversion itself is explicitly out of scope for this module (it belongs to a later finance module) so treat "keep ₹ and $ separate until a conversion step deliberately joins them" as the correct, conservative default here.


Applied mini-project

Build a small Position/Portfolio system, verified against a joint India + US basket.

You have now covered every idiom this project needs: container choice, comprehensions, slicing, mutability/aliasing discipline, pure functions, and a clean class with the dunders that make it behave. This project asks you to assemble all of it into one small, testable system, narrated as a design exercise; the CS1.02 code pack in this node's Code tab grades the same disciplines automatically, with hidden India- and US-flavored test cases your own script will never see.

Do this, in order:

  1. Design first. Before writing a line of the class bodies, answer in a comment block: will Portfolio store its positions internally as a list or a dict keyed by ticker? Justify the choice using the order/duplicates/lookup framework from Core teaching content, there is a legitimate case for either, provided you can defend it.
  2. Build Position. __init__(self, ticker, qty, price); a read-only market_value property; __repr__; __eq__ (by ticker, qty, price); __hash__ consistent with __eq__.
  3. Build Portfolio. __init__(self, positions) storing an independent copy (mutating the caller's original list afterward must not affect the Portfolio); __len__; __iter__; __contains__ (by ticker); sorted_by_value(reverse=False) returning a new, sorted list.
  4. Write one pure function, total_market_value(portfolio_or_positions), that never mutates whatever it is handed.
  5. Populate two separate books (at least 3 NSE positions (₹) and at least 3 US positions ($)) kept apart, never summed together (currency conversion is out of scope here; see Practice P16). Print both using your __repr__.
  6. Prove the discipline, don't just assert it. Demonstrate, in a short script or REPL transcript: (a) copying a portfolio's positions into a new list and mutating the copy does not affect the original; (b) two equal Positions hash the same and collapse correctly inside a set, while two unequal ones do not; (c) total_market_value leaves its input bit-for-bit unchanged before and after the call.
  7. Grade it. Open the CS1.02 code pack (pack:CS1.02-code) in this node's Code tab and clear its exercises: CS1.02-e01 through CS1.02-e08, for XP. Every exercise ships a structured fallback, so this works fully offline if you have no live runtime available.

Self-check rubric, score yourself out of 10, pass at 8/10 with no zero on any item:

  • 2 pts: an NSE (₹) book and a US ($) book both exist, are kept structurally separate, and are never summed into one blended total.
  • 2 pts: Position correctly implements __repr__, __eq__, and __hash__ (equal positions collapse in a set; unequal ones do not).
  • 2 pts: Portfolio correctly implements __len__, __iter__, __contains__, and sorted_by_value, including the independent-copy guarantee in __init__.
  • 2 pts: total_market_value is demonstrably pure: you explicitly checked the input is unchanged before and after the call, not merely assumed it from a correct return value.
  • 2 pts: the internal storage choice inside Portfolio (list vs. dict) is justified in one sentence using the order/duplicates/lookup framework, not asserted without reasoning.

Reading & resources

Everything below is a reference to build fluency from and check yourself against, never a substitute for actually running the code. Free (✓) unless marked paid ($); level tags run beginner (▲) to advanced (▼).

The core spine:

  • Python official documentation. Data Structures tutorial (lists, tuples, dicts, sets, comprehensions in the language's own words). ✓ ▲
  • Python official documentation. Classes tutorial (the language reference's own walkthrough of self, inheritance basics, and the object model). ✓ ▲–▼
  • Raymond Hettinger, "Transforming Code into Beautiful, Idiomatic Python" (widely available conference talk), the container-choice and comprehension instincts of this module, demonstrated live. ✓ ▲–▼
  • Trey Hunner's writing on comprehensions and Python idioms: short, concrete, example-driven. ✓ ▲

The primary reference (for when you want the exact rule, not a summary):

  • Python Language Reference. Data model chapter: the authoritative contract for every dunder method this module teaches (__eq__, __hash__, __iter__, and the rest). ✓ ▼
  • PEP 8. Style Guide for Python Code: the community-standard formatting and naming conventions your idiom should sit inside. ✓ ▲
  • The standard-library copy and functools module documentation. ✓ ▲–▼

Deeper, optional (books):

  • Luciano Ramalho, Fluent Python: the standard deep dive on Python's data model, protocols, and idioms; the natural next stop after this module. $ ▼
  • Brett Slatkin, Effective Python: bite-sized, example-driven best practices covering much of this module's territory and beyond. $ ▲–▼

Do this, not just read: open a REPL and, for every container decision in this module, deliberately use the wrong container first (a list where a set belongs, a bare [] default where None belongs) and watch the bug happen in front of you before you fix it. The instinct "this smells like the wrong container" is built by feeling the wrongness once, under your own hands, not by reading a table about it.


Teach it back & journal

Feynman prompt. Explain to a friend who "already knows how to code" but reaches for a list and a loop for everything why choosing a set, a dict, or a tuple instead is not pedantry, it's the sentence "this container's own rules are doing some of my error-checking for me, permanently, for free." Use the concrete case of de-duplicating tickers with a set versus a list with a manual if x not in seen check, and the aliasing case of book2 = book1 on a mutable list. If your friend can then explain, unprompted, why a class that defines __eq__ but not __hash__ breaks the moment you put two instances in a set, and why that is Python protecting you, not an arbitrary restriction, you have installed this module's real content, not just its vocabulary.

Journal prompt. For the next several programs you write, in this course or anywhere else, force yourself to answer the three container questions on paper before writing the line that creates each new collection, and the moment you write your first class, force yourself to write __repr__ before you write anything else, including the constructor's docstring. After a few days of this, write one paragraph: which single habit, container-choice-first, purity-by-default (checking that a function leaves its inputs untouched, not just assuming it), or repr-first, most changed how much you had to re-read your own code before trusting it? And what will you do this week to make that habit automatic before CS1.03 hands you the memory model underneath it, and CS1.05 hands you the CROWN module that opens the hood on these containers for good?


This module's flashcards and mastery quiz are wired into the app: see the node's Quiz and Reviews.

Advertisement

The gate

The module's flashcards and its quiz are in the app. The quiz is the gate: you need 85% to pass it, and the next module opens when you do.