x = 5 # int
y = 3.14 # float
name = "Alice" # str
is_ok = True # bool
nothing = None # NoneType
type(x) # check type -> <class 'int'>
int("10") # convert to int
str(10) # convert to str
float("2.5") # convert to float
# int, str, and float are CLASSES, not functions: a "conversion" is
# really a constructor call — int("10") instantiates a NEW int from
# the string, exactly like calling one of your own classes.
# Variables can be reassigned freely...
x = 10 # now 10
x = x + 5 # now 15
# ...even to a completely different type (dynamic typing)
x = "now a string" # perfectly valid
x = [1, 2, 3] # and now it's a list
# Python has NO real constants. UPPER_CASE names are a CONVENTION
# meaning "please don't reassign" — nothing enforces it.
MAX_RETRIES = 3 # a "constant", by convention only
MAX_RETRIES = 5 # ...reassignment is perfectly legal
# (typing.Final adds type-checker enforcement — see the advanced sheet)
# type() reveals any value's class. It's NOT an operator, and not a
# function either: type is itself a class (like int above) — calling
# it with one argument returns the class of that argument.
type(3) # <class 'int'>
type(3.14) # <class 'float'>
type("x") # <class 'str'>
type(True) # <class 'bool'>
type(None) # <class 'NoneType'>
type([1]) # <class 'list'>
type((1,)) # <class 'tuple'> (the comma makes the tuple)
type({1}) # <class 'set'> ({} braces with no colon -> set)
type({"a": 1}) # <class 'dict'>
type(b"x") # <class 'bytes'>
# Functions are objects too, with a type of their own
def f():
pass
type(f) # <class 'function'>
type(lambda x: x) # <class 'function'> (a lambda is a plain function)
type(len) # <class 'builtin_function_or_method'>
# It works on class instances (and on classes) too
class Dog:
pass
rex = Dog()
type(rex) # <class '__main__.Dog'> (qualified by module name)
type(rex) is Dog # True
type(rex).__name__ # "Dog"
type(Dog) # <class 'type'> -> a class is itself an object
type(type) # <class 'type'> -> ...and type is its OWN typeprint("Hello") # Hello
print("Hello", "world") # Hello world (items joined by a space)
# Mixing types — print handles the conversion
age = 30
print("Age:", age) # Age: 30
# With an f-string
print(f"Age is {age}") # Age is 30
# sep: change the separator between items
print("a", "b", "c", sep="-") # a-b-c
# end: change what's printed at the end (default is a newline)
print("no newline", end=" ")
print("same line") # no newline same line
# sep and end (and file, flush) are KEYWORD-ONLY: pass them by name,
# after all the values — their order among themselves doesn't matter
print("a", "b", sep="-", end="!\n") # a-b!
print("a", "b", end="!\n", sep="-") # a-b! same thing
# Without the name it's just another value to print, not a separator:
print("a", "-") # a - -> "-" was printed, not used as sep
# Why: print is declared as
# print(*args, sep=' ', end='\n', file=None, flush=False)
# *args swallows every positional value; all the rest, coming after
# *args, is keyword-only (see Functions). file=None means sys.stdout.s = "hello world" # double or single quotes — interchangeable
s = 'hello world'
s = 'She said "hi"' # use one kind to embed the other without escaping
s = """multi
line""" # triple quotes span multiple lines
# No separate char type: a single character is just a string of length 1
c = "hello"[0] # "h" -> still a str, not a char
type(c) # <class 'str'>
s = "hello world"
len(s) # 11
s.upper() # "HELLO WORLD"
s.lower() # "hello world"
s.title() # "Hello World"
s.replace("l", "L") # "heLLo worLd"
s.split(" ") # ['hello', 'world']
"-".join(["a", "b"]) # "a-b"
s.strip() # remove surrounding whitespace
s.startswith("he") # True
s.endswith("ld") # True
s.find("o") # 4 -> index of first match (-1 if not found)
s.count("l") # 3 -> number of occurrences
"42".zfill(5) # "00042" -> pad with leading zeros
"Hi {}".format(s) # "Hi hello world" (older alternative to f-strings)
s[0] # "h" (first char)
s[-1] # "d" (last char)
s[0:5] # "hello" (slice)
# Strings are IMMUTABLE (read-only) — you can't change them in place
# s[0] = "H" # TypeError!
s = "H" + s[1:] # instead, build a NEW string
# f-strings (formatting)
name = "Alice"
age = 30
f"{name} is {age}" # "Alice is 30"
# Format specs: add ":" inside the braces
x = 3.14159
n = 1234567
f"{x:.2f}" # "3.14" 2 decimal places
f"{n:,}" # "1,234,567" thousands separator
f"{x:.1%}" # "314.2%" percentage
f"{'hi':<8}|" # "hi |" left-align in width 8
f"{'hi':>8}|" # " hi|" right-align
f"{'hi':^8}|" # " hi |" center
f"{x=}" # "x=3.14159" debug form: shows name and valueText (str) and raw bytes (bytes) are different types: a string holds
characters, bytes hold numbers 0–255. Converting between them always goes
through an encoding (UTF-8 unless you have a reason otherwise).
data = b"hello" # bytes literal: b-prefix, raw bytes, NOT text
type(data) # <class 'bytes'>
# str -> bytes: encode
b = "città".encode("utf-8") # b'citt\xc3\xa0'
len(b) # 6 -> "à" takes TWO bytes in UTF-8
# bytes -> str: decode
b.decode("utf-8") # "città"
# Indexing bytes yields INTEGERS, not 1-char strings
b"hi"[0] # 104 -> the byte value of "h"
bytes([104, 105]) # b'hi' -> and back from a list of ints
# str and bytes never mix silently
# "a" + b"b" # TypeError: can only concatenate str to strnums = [1, 2, 3]
empty = [] # an empty list (or: list())
nums.append(4) # [1, 2, 3, 4]
nums.insert(0, 0) # [0, 1, 2, 3, 4]
nums.pop() # remove & return last
nums.remove(2) # remove first matching value
nums[0] # first item
nums[-1] # last item
nums[1:3] # slice
len(nums) # length
sorted(nums) # new sorted list
nums.reverse() # reverse in place
3 in nums # membership test -> True/False
# Elements don't have to share a type — any mix is fine (heterogeneous)
mixed = [1, "two", 3.0, True, [4, 5]] # even other lists
# Indexing past the end raises (reads never auto-grow the list)
# nums[99] # IndexError: list index out of rangesequence[start:stop:step] — the stop index is excluded. Works on
strings, lists, and tuples.
s = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
s[2:5] # [2, 3, 4] start:stop
s[:3] # [0, 1, 2] from the beginning
s[7:] # [7, 8, 9] to the end
s[-3:] # [7, 8, 9] last three
s[:-2] # [0, 1, 2, 3, 4, 5, 6, 7] all but the last two
s[::2] # [0, 2, 4, 6, 8] every 2nd item (step)
s[1::2] # [1, 3, 5, 7, 9] every 2nd, starting at index 1
s[::-1] # [9, 8, 7, ... 0] reversed (step of -1)
"hello"[::-1] # "olleh" same syntax on strings
# Out-of-range slices are forgiving (unlike indexing): they clamp
s[2:100] # [2, 3, 4, 5, 6, 7, 8, 9] no IndexError, stops at the end
# Slice assignment: a slice CAN sit left of = (lists only — never on
# immutable str/tuple). It replaces the range, and the new part may be
# a DIFFERENT length, so the list grows or shrinks to fit.
s[0:2] = [100] # [100, 2, 3, 4, 5, 6, 7, 8, 9] -> 2 replaced by 1
s[1:1] = [55] # [100, 55, 2, ...] empty slice -> pure INSERT at 1
s[0:2] = [] # [2, 3, ...] assign [] -> deletes the range
# With a step, lengths must match EXACTLY
t = [0, 1, 2, 3, 4, 5]
t[::2] = ["a", "b", "c"] # ['a', 1, 'b', 3, 'c', 5]
# t[::2] = ["a"] # ValueError: size 1 into extended slice of 3person = {"name": "Alice", "age": 30}
empty = {} # an empty dict — {} is ALWAYS a dict, never a set
# The dict() constructor: empty, from keywords, or from key/value pairs
dict() # {}
dict(name="Alice", age=30) # {'name': 'Alice', 'age': 30}
dict([("a", 1), ("b", 2)]) # {'a': 1, 'b': 2}
person["name"] # "Alice"
# person["email"] # KeyError: a missing key raises on [] access
person.get("email") # None -> no error; None when key is missing
person.get("email", "n/a") # "n/a" -> supply your own default
person["email"] = "a@x.com" # add / update
person.update({"age": 31}) # merge another dict in place
del person["age"] # delete a key
person.pop("email") # remove a key and return its value
# Deleting a MISSING key raises, just like reading one:
# del person["email"] # KeyError
# person.pop("email") # KeyError
person.pop("email", "n/a") # "n/a" -> pop with a default never raises
"name" in person # check key exists -> True
# setdefault: return the value if the key exists; otherwise INSERT it
# with the given default and return that. It can MUTATE the dict.
d = {"a": 1}
d.setdefault("a", 99) # 1 -> "a" exists: returned as-is, d unchanged
d.setdefault("b", 99) # 99 -> "b" missing: d is now {"a": 1, "b": 99}
# keys(), values(), items() return live VIEW objects, not lists.
# Views are iterable and reflect later changes to the dict.
scores = {"x": 1, "y": 2}
ks = scores.keys() # dict_keys(['x', 'y'])
scores["z"] = 3
list(ks) # ['x', 'y', 'z'] -> the view saw the change
for key, val in scores.items(): # idiomatic way to iterate key/value pairs
print(key, val)
list(scores.values()) # [1, 2, 3] -> wrap in list() for a snapshot
# Dicts KEEP INSERTION ORDER (guaranteed by the language since 3.7):
# keys iterate in the order they were first added, not alphabetically.
# Updating a value doesn't move a key; delete + re-insert sends it last.
d = {"b": 1}
d["a"] = 2
d["c"] = 3
list(d) # ['b', 'a', 'c'] -> insertion order
d["b"] = 99 # update: position unchanged -> ['b', 'a', 'c']
del d["b"]
d["b"] = 1 # re-insert: goes to the end -> ['a', 'c', 'b']
# (Sets make NO such promise — never rely on their order.)
# Merge into a NEW dict with | (Python 3.9+)
combined = {"a": 1} | {"b": 2} # {"a": 1, "b": 2}point = (3, 4) # tuple (immutable)
x, y = point # unpacking
point[0] # 3 -> index like a list
# point[5] # IndexError: tuple index out of range
# Tuples mix types too — the classic use: a fixed-shape record
user = ("Alice", 30, True) # name, age, active
# Empty and one-element tuples
empty = () # an empty tuple (or: tuple())
single = (1,) # ONE element: the trailing comma is required
type((1)) # <class 'int'> -> (1) is just a grouped 1, NO tuple!
# The tuple() constructor builds one from any iterable
tuple([1, 2, 3]) # (1, 2, 3)
tuple("hi") # ('h', 'i')
s = {1, 2, 2, 3} # set -> {1, 2, 3} (unique)
no_items = set() # an EMPTY set must use set() — {} is a dict
s.add(4)
s.union({5, 6})
s.intersection({2, 3})
# Convert between them
nums = [1, 2, 2, 3, 3]
unique = set(nums) # list -> set: {1, 2, 3} (drops duplicates)
back = list(unique) # set -> list: [1, 2, 3]
# Common idiom: remove duplicates from a list
deduped = list(set(nums)) # [1, 2, 3] (note: order is NOT preserved)# Arithmetic operators
7 + 2 # 9
7 - 2 # 5
7 * 2 # 14
7 / 2 # 3.5 true division ALWAYS returns a float
7 // 2 # 3 floor division (rounds DOWN to a whole number)
7 % 2 # 1 modulo (the remainder)
7 ** 2 # 49 exponentiation (power)
-7 // 2 # -4 floor rounds toward -infinity, NOT toward zero
divmod(7, 2) # (3, 1) quotient and remainder together
# Comparison (relational) operators -> always return a bool
2 == 2 # True equal value
2 != 3 # True not equal
3 > 2 # True also <, >=, <=
# Comparisons can be chained, like in maths — most languages can't do
# this (in Java/C, 1 < x < 10 means (1 < x) < 10: a bool vs a number)
x = 5
1 < x < 10 # True same as: 1 < x and x < 10
# Logical operators
True and False # False both sides must be truthy
True or False # True at least one side truthy
not True # False negation
# GOTCHA: and/or return one of the OPERANDS, not a strict True/False,
# and short-circuit (stop as soon as the result is known).
"a" and "b" # "b" first is truthy -> yields the second
0 or "fallback" # "fallback" first is falsy -> yields the second
"" or "default" # "default" common way to supply a default value
# Identity vs equality: == asks "equal VALUE?", is asks "the SAME object?"
# Every object lives at one identity (see id()); `is` returns True only
# when both sides are literally that one object, never comparing contents.
a = [1, 2]
b = [1, 2] # a second, separate list that happens to have equal contents
c = a # an alias: c and a are two names for the SAME list
a == b # True equal contents (value comparison)
a is b # False two distinct objects, however equal
a is c # True one object, two names
# id() returns that identity: an int unique to the object for its whole
# lifetime (in CPython it's the memory address). `is` compares ids.
# Identity is assigned by the interpreter, not the class: there is no
# dunder to implement or override for it (unlike ==, which calls __eq__).
id(a) # e.g. 4344961088 — some int; different on every run
id(a) == id(c) # True what `is` actually checks: the same identity
id(a) == id(b) # False equal lists, but two different objects
# Use `is` ONLY for singletons like None — never to compare values.
# (Caching of small ints/strings can make `is` LOOK like == by accident.)
x = None
x is None # True the correct way to test for None (not ==)
# WHY not ==? Because == calls __eq__, and a class can override it to
# answer anything — so `x == None` can lie. Identity can't be fooled,
# and since None is a SINGLETON (one object ever), `is` asks exactly
# the right question: "is this that one object?"
class Weird:
def __eq__(self, other):
return True # claims equality with EVERYTHING
w = Weird()
w == None # True -> the lie: __eq__ decided the answer
w is None # False -> the truth: w is not the None objectPython has no value-type vs reference-type split (unlike Java/C#): every variable holds a reference to an object. What actually matters is whether the object is mutable (list, dict, set) or immutable (int, float, bool, str, bytes, tuple).
# Assignment NEVER copies — it gives the SAME object another name
a = [1, 2, 3]
b = a # b is an ALIAS of a, not a copy
b.append(4)
a # [1, 2, 3, 4] -> the change shows through a too
a is b # True (one object, two names)
# "Immutable" means the OBJECT itself can never change after creation.
# There is no way to turn the int 10 into an 11: operations that look
# like changes actually build a NEW object and REBIND the name to it.
x = 10
y = x # x and y -> the same int object (the 10)
x is y # True
y += 1 # no mutation: creates a NEW int 11, rebinds y to it
x # 10 -> the 10 object was never touched
x is y # False -> the names now point to different objects
# Strings work the same way: every "modification" is a new object
s = "hi"
t = s
t += "!" # t now points to a NEW string "hi!"
s # "hi" -> original untouched (strings can't change)
# So aliasing an immutable object is always harmless — nothing can
# alter it through EITHER name. That's why int/float/bool/str/bytes/
# tuple behave like Java/C# "value types", despite being references.
# Copy a list when you need independence
c = a.copy() # or a[:] or list(a) -> a NEW list
c.append(99)
a # [1, 2, 3, 4] -> unaffected
# GOTCHA: those copies are SHALLOW — nested objects are still shared
grid = [[1, 2], [3, 4]]
flat = grid.copy()
flat[0].append(99)
grid # [[1, 2, 99], [3, 4]] -> inner list was shared!
# copy.deepcopy (from the copy module) copies every level instead.
# Arguments are passed the same way: the function receives a REFERENCE,
# so mutating a passed-in list changes the caller's list
def add_item(lst):
lst.append("x")
data = [1]
add_item(data)
data # [1, 'x']
# del removes variables, attributes, and collection members alike
n = 42
del n # unbinds the NAME
# n # NameError: name 'n' is not defined
lst = [1, 2, 3, 4]
del lst[0] # remove one element -> [2, 3, 4]
del lst[1:] # or a whole slice -> [2]
# (dict keys too: del d["a"] — see Dictionaries. But NOT tuples/strings:
# del t[0] on a tuple # TypeError — immutables don't support deletion)
class Box:
pass
box = Box()
box.size = 10
del box.size # attributes can be deleted as well
# box.size # AttributeError: no attribute 'size'
# del deletes the BINDING, not the object: the object only dies when
# no references to it remain
a = [1, 2]
b = a
del a # the list lives on...
b # [1, 2] -> ...still reachable through bx = 5
if x > 10:
print("big")
elif x == 10:
print("ten")
else:
print("small")
# Ternary
label = "even" if x % 2 == 0 else "odd"
# Walrus := assigns AND returns a value in the same expression (3.8+)
if (n := len("hello")) > 3:
print(f"length is {n}") # n is assigned inside the conditionIn a boolean context (like if), values are automatically treated as
True or False. bool(x) shows what a value converts to.
# Falsy values (treated as False):
bool(False) # False
bool(None) # False
bool(0) # False (also 0.0)
bool("") # False (empty string)
bool([]) # False (empty list, dict, tuple, set)
bool({}) # False
# Everything else is truthy (treated as True):
bool(42) # True
bool("hi") # True
bool([0]) # True (non-empty, even if it contains 0)
# Common idiom: check for emptiness directly
items = []
if not items:
print("the list is empty")Structural pattern matching (Python 3.10+) — a cleaner alternative to long
if/elif chains.
command = "start"
match command:
case "start":
print("starting")
case "stop" | "halt": # | matches multiple patterns
print("stopping")
case _: # _ is the default (wildcard)
print("unknown command")
# Cases are tried TOP TO BOTTOM: the first match wins, runs its body,
# and the rest are skipped — no fall-through, no break (unlike switch).
# ORDER MATTERS with overlapping patterns: put narrow cases first.
match 5:
case int(): # broad case listed first...
print("an int") # -> wins
case 5: # ...so this never gets a chance
print("exactly five")
# If NO case matches (and there is no _), nothing runs — no error.
match "no case matches me":
case 1:
print("one") # skipped; the match just does nothing
# It can also destructure data
point = (0, 5)
match point:
case (0, 0):
print("origin")
case (0, y): # binds y to the second value
print(f"on the y-axis at {y}")
case (x, y):
print(f"at {x}, {y}")
# GOTCHA: a bare name in a case is a CAPTURE, not a comparison.
# It matches ANYTHING and binds it to that name — it does NOT compare
# against an existing variable called `expected`:
expected = "stop"
match "anything":
case expected: # always matches; expected is rebound!
print(expected) # "anything"
# A DOTTED name, however, IS compared as a constant:
class Command:
STOP = "stop"
match "stop":
case Command.STOP: # attribute access -> real comparison
print("stopping")
# Guard: attach a condition to a pattern with `if`
match (4, 4):
case (x, y) if x == y: # pattern must match AND guard be true
print(f"on the diagonal at {x}")
case (x, y):
print("elsewhere")
# Sequence pattern: destructure a list; * collects the rest
match [1, 2, 3, 4]:
case [first, *rest]:
print(first, rest) # 1 [2, 3, 4]
# Mapping pattern: matches if these KEYS exist (extra keys are fine)
match {"action": "move", "x": 10}:
case {"action": act}:
print(act) # "move"
# Class pattern: match on the type and its attribute values
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
match Point(0, 7):
case Point(x=0, y=y): # an instance of Point with x == 0
print(f"on the y-axis at {y}")nums = [1, 2, 3]
x = 3
for n in nums:
print(n)
for i in range(5): # 0,1,2,3,4
print(i)
for i, val in enumerate(nums): # index + value
print(i, val)
for i, val in enumerate(nums, start=1): # count from 1 instead of 0
print(i, val)
# zip: iterate two (or more) sequences in PARALLEL
names = ["Alice", "Bob"]
ages = [30, 25]
for name, age in zip(names, ages):
print(name, age) # Alice 30 / Bob 25 (stops at the shorter one)
# reversed: iterate backwards (works on lists, tuples, ranges, strings)
for n in reversed(nums):
print(n) # 3, 2, 1
# Iterating a dict yields its KEYS; use .items() for key/value pairs
person = {"name": "Alice", "age": 30}
for key in person:
print(key, person[key])
for key, val in person.items(): # see Dictionaries
print(key, val)
while x > 0:
x -= 1
# Python has NO do-while; the idiom is `while True` + a break inside
count = 0
while True:
count += 1
if count == 3:
break # the exit condition sits inside the body
# break -> exits the loop early
# continue -> skips to the next iteration
# break / continue in action
for n in range(10):
if n == 3:
continue # skip 3, go to next iteration
if n == 6:
break # stop the loop entirely
print(n) # prints 0, 1, 2, 4, 5
# Loops have an `else` (NOT a `finally`):
# it runs only if the loop finished WITHOUT hitting a break.
for n in nums:
if n < 0:
print("found a negative")
break
else:
print("no negatives found") # runs only if no break occurred
# GOTCHA: never modify a list WHILE looping over it — removals shift
# the elements and the loop silently SKIPS some:
doomed = [1, 2, 3, 4]
for n in doomed:
doomed.remove(n) # tries to empty the list...
doomed # [2, 4] -> half survived!
# Fix: loop over a COPY, or build a new list with a comprehension
items = [1, 2, 3, 4]
for n in items[:]: # [:] makes a copy to iterate
if n % 2 == 0:
items.remove(n)
items # [1, 3]
evens_removed = [n for n in [1, 2, 3, 4] if n % 2 != 0] # [1, 3]range produces a sequence of integers lazily (it doesn't build a list).
range(5) # 0,1,2,3,4 -> stop only
range(2, 6) # 2,3,4,5 -> start, stop (stop excluded)
range(0, 10, 2) # 0,2,4,6,8 -> start, stop, step
range(5, 0, -1) # 5,4,3,2,1 -> negative step counts down
list(range(5)) # [0,1,2,3,4] -> materialize into a list
len(range(5)) # 5
3 in range(5) # True
for i in range(3): # most common use: repeat / index
print(i)nums = [1, 2, 3, 4]
# List comprehension -> [...]: build a new list from an iterable
squares = [n**2 for n in range(5)] # [0, 1, 4, 9, 16]
# List comprehension with a filter (the trailing `if`)
evens = [n for n in nums if n % 2 == 0] # [2, 4] -> keeps only evens
# Dict comprehension -> {key: value ...}: build a new dict
lookup = {n: n**2 for n in range(3)} # {0: 0, 1: 1, 2: 4}
# Set comprehension -> {...}: build a new set (unique values)
sizes = {len(w) for w in ["hi", "ok", "hey"]} # {2, 3}
# There is NO tuple comprehension: (n for n in nums) is a GENERATOR
# expression, not a tuple. Build a tuple explicitly with tuple(...):
nums_t = tuple(n * 2 for n in nums) # (2, 4, 6, 8)Generators produce values one at a time, on demand, instead of building a whole list in memory — efficient for large or streaming data.
# Generator expression: like a comprehension, but with ()
gen = (n**2 for n in range(5)) # nothing computed yet
next(gen) # 0 (produces one value at a time)
next(gen) # 1
list(gen) # [4, 9, 16] (the rest)
# Generator function: uses `yield` to emit values lazily
def countdown(n):
while n > 0:
yield n # pauses here, resumes on next request
n -= 1
for x in countdown(3): # 3, 2, 1
print(x)
# A for loop consumes any iterator; next() steps through one manually.
# Built-ins like range(), enumerate(), zip() are lazy in the same way.Iterators vs generators:
- An iterator is any object with
__iter__()and__next__()methods (the "iterator protocol"). Everyforloop drives one behind the scenes. - A generator is the easy way to make an iterator — via a generator
function (
yield) or a generator expression(...). All generators are iterators; not all iterators are generators.
nums = [10, 20, 30]
it = iter(nums) # get an iterator from an iterable
next(it) # 10
next(it) # 20
# next() past the end raises StopIteration
# The manual (class) way to build an iterator — what generators save you
# from (the full iterator protocol is covered on the advanced sheet):
class Counter:
def __init__(self, limit):
self.n, self.limit = 0, limit
def __iter__(self):
return self # an iterator returns itself
def __next__(self):
if self.n >= self.limit:
raise StopIteration # signals "no more values"
self.n += 1
return self.n
list(Counter(3)) # [1, 2, 3]# Takes no arguments, returns nothing (implicitly returns None)
def say_hello():
print("hello")
say_hello() # prints "hello"
result = say_hello() # result is None (no return statement)
# Returns a value with `return`
def add(a, b):
return a + b
add(2, 3) # 5
# Empty function placeholder (pass = do nothing)
def todo_later():
pass# A docstring is a string literal (triple-quoted by convention) placed as
# the FIRST statement of a function, class, or module. Python stores it
# as the object's documentation — it is what help() and editors show.
def area(width, height):
"""Return the area of a rectangle."""
return width * height
class Dog:
"""A pet that can bark.
Docstrings can span multiple lines: a one-line summary first,
then details after a blank line.
"""
area.__doc__ # 'Return the area of a rectangle.'
Dog.__doc__ # 'A pet that can bark.\n\n Docstrings can span...'
# help(area) # pretty-prints the docstringA name must exist by the time the line that uses it runs — not by where it sits in the file.
# ping() # NameError: 'ping' is not defined yet
def ping():
return "pong"
ping() # works now that ping is defined
# A function may reference another defined LATER, as long as both exist
# before the top-level call actually runs:
def main():
return helper() # 'helper' isn't looked up until main() is called
def helper():
return "ready"
main() # "ready" -> both defined before this line runsdef greet(name, greeting="Hi"): # greeting has a default value
return f"{greeting}, {name}!"
greet("Alice") # "Hi, Alice!" -> uses the default greeting
greet("Bob", "Hello") # "Hello, Bob!" -> default is overridden
greet("Eve", greeting="Hey")# "Hey, Eve!" -> override by keyword name
# GOTCHA: a default value is created ONCE (at definition), not per call.
# A mutable default (list/dict) is then SHARED across calls, like a
# static variable — it accumulates instead of resetting:
def add_item(item, items=[]):
items.append(item)
return items
add_item("a") # ['a']
add_item("b") # ['a', 'b'] <- same list reused, NOT a fresh one!
# Fix: default to None and create the object inside the function
def add_item(item, items=None):
if items is None:
items = [] # fresh list every call
items.append(item)
return items
add_item("a") # ['a']
add_item("b") # ['b'] <- independent nowdef make_user(name, age, city):
return f"{name}, {age}, {city}"
# Positional: matched by ORDER
make_user("Alice", 30, "Rome")
# Keyword (named): matched by NAME, so order doesn't matter
make_user(name="Alice", city="Rome", age=30)
# Mixed: positional args must come BEFORE keyword args
make_user("Alice", city="Rome", age=30) # ok
# make_user(name="Alice", 30, "Rome") # SyntaxError# *args -> extra POSITIONAL args collected into a tuple
def total(*args):
return sum(args)
total(1, 2, 3) # 6 -> args is (1, 2, 3)
# **kwargs -> extra KEYWORD args collected into a dict
def show(**kwargs):
return kwargs
show(name="Alice", age=30) # {'name': 'Alice', 'age': 30}
# Unpacking when CALLING: * spreads a list, ** spreads a dict
nums = [1, 2, 3]
total(*nums) # 6 -> same as total(1, 2, 3)
data = {"name": "Bob", "age": 25}
show(**data) # {'name': 'Bob', 'age': 25}
# All the kinds together — ORDER MATTERS, must be:
# 1. positional-only (before /) 2. regular 3. default
# 4. *args or a bare * 5. keyword-only (may have defaults) 6. **kwargs
def f(a, b=2, *args, c, d=4, **kwargs):
print(a, b, args, c, d, kwargs)
f(1, c=5) # 1 2 () 5 4 {}
f(1, 9, 10, 11, c=5, x=7) # 1 9 (10, 11) 5 4 {'x': 7}
# f(1, 2, 3) # TypeError: missing required keyword-only 'c'
# def f(a, b=2, c): ... # SyntaxError: non-default after default
# This is exactly print's shape: print(*args, sep=' ', end='\n', ...)
# -> *args, then keyword-only parameters with defaults.
# / and * in a def are MARKERS, not parameters: they receive no value,
# they only split the parameter list into three zones —
#
# def demo(a, /, b, *, c)
# ^^^ before / POSITIONAL-ONLY: the caller cannot use the
# name (demo(a=1) is an error)
# ^^^ between / and *: NORMAL — positional or
# keyword, the caller chooses
# ^^ after *: KEYWORD-ONLY — the name is required
#
# (*args also opens the keyword-only zone, as in f above; a bare *
# does the same job when you DON'T want to accept extra positionals.)
def demo(a, /, b, *, c):
print(a, b, c)
demo(1, 2, c=3) # 1 2 3
demo(1, b=2, c=3) # 1 2 3 b sits in the middle zone: name optional
# demo(a=1, b=2, c=3) # TypeError: positional-only 'a' passed as keyword
# demo(1, 2, 3) # TypeError: takes 2 positional arguments, 3 given
# Why bother? / frees the library to RENAME its parameters without
# breaking callers (you'll see it in help() of built-ins, e.g. len(obj, /));
# * forces readable call sites for flags and options — print() uses it.
# Inside the keyword-only zone, defaults may come in ANY order:
# the "non-default after default" rule only binds positional parameters
# def f(*, c=1, d): ... # legal — d is passed by name anywaydef min_max(nums):
return min(nums), max(nums) # returns a tuple
lo, hi = min_max([3, 1, 5]) # unpack the result -> lo=1, hi=5# A lambda is a small anonymous function (a single expression)
double = lambda x: x * 2
double(5) # 10
# Often used inline, e.g. as a sort key
sorted([-3, 1, -2], key=lambda n: abs(n)) # [1, -2, -3]# Python has NO function overloading. Defining the same name twice does
# not create two versions — the second definition REPLACES the first.
def area(side):
return side * side
def area(width, height): # same name -> this REPLACES the version above
return width * height
# area(5) # TypeError: missing argument 'height' (first one is gone)
area(3, 4) # 12
# For "overloading"-like flexibility, use defaults or *args/**kwargs instead.# A function can be defined INSIDE another function. The inner one is
# local to the outer and can read the outer's variables (a closure).
def make_counter(start=0):
count = start
def increment():
nonlocal count # rebind the ENCLOSING variable (not a global)
count += 1
return count
return increment # return the inner function itself
counter = make_counter()
counter() # 1
counter() # 2 -> count persists between calls, captured by the closure
# Reading an enclosing variable needs nothing special; only REBINDING it
# requires `nonlocal` (otherwise the assignment makes a new local instead).
# The same works inside a method: a def there is just a local helper.
# Scope rules (LEGB): a name is looked up Local -> Enclosing -> Global ->
# Built-in. Use `nonlocal x` to rebind an enclosing variable, and
# `global x` to rebind a top-level (module) variable from inside a function.
count = 0
def bump():
global count # without this, `count = ...` would make a new local
count += 1
bump()
count # 1print("hi") # output (see the Printing section above)
# input("Name: ") # read user input -> ALWAYS returns a str
# age = int(input("Age: ")) # convert when you need a number
len([1, 2, 3]) # 3
range(0, 5) # 0,1,2,3,4 (lazy sequence)
sum([1, 2, 3]) # 6
min([3, 1, 2]) # 1 (max([3, 1, 2]) -> 3)
abs(-5) # 5
round(3.14159, 2) # 3.14
sorted([3, 1, 2]) # [1, 2, 3]
list(zip([1, 2], ["a", "b"])) # [(1, 'a'), (2, 'b')]
list(map(str, [1, 2, 3])) # ['1', '2', '3'] (apply fn to each)
list(filter(lambda n: n > 1, [1, 2, 3])) # [2, 3] (keep where True)A decorator is a function that wraps another function to add behavior,
without changing the original. @name above a function applies it.
def shout(func):
def wrapper(*args, **kwargs): # *args/**kwargs pass through anything
result = func(*args, **kwargs)
return result.upper()
return wrapper
@shout # same as: greet = shout(greet)
def greet(name):
return f"hi {name}"
greet("alice") # "HI ALICE" -> wrapped by shout
# GOTCHA: wrapping ERASES the function's identity — greet now reports
# the wrapper's name and loses its docstring:
greet.__name__ # "wrapper" (not "greet"!)
# Fix: decorate the wrapper with functools.wraps, which copies the
# original's name, docstring, etc. onto it. Use it in every decorator.
import functools
def shout_well(func):
@functools.wraps(func) # preserves func's identity
def wrapper(*args, **kwargs):
return func(*args, **kwargs).upper()
return wrapper
@shout_well
def cheer(name):
return f"go {name}"
cheer.__name__ # "cheer" -> identity survived
# Common built-in decorators you'll see:
# @staticmethod / @classmethod (in classes, shown below)
# @property (expose a method like an attribute;
# full treatment on the advanced sheet)
# @functools.cache (cache a function's results)class Dog:
# Class attribute (shared by all instances)
species = "Canis familiaris"
# Constructor: runs when you create an instance
def __init__(self, name, age):
self.name = name # instance attributes
self.age = age
# Method
def bark(self):
return f"{self.name} says woof!"
# String representation (used by print)
def __repr__(self):
return f"Dog({self.name}, {self.age})"
# Create instances
rex = Dog("Rex", 3)
rex.name # "Rex"
rex.bark() # "Rex says woof!"
rex.species # "Canis familiaris"
# Class attributes work on the CLASS and on INSTANCES
Dog.species # "Canis familiaris" -> on the class
rex.species # "Canis familiaris" -> on the instance
# Instance attributes only exist on instances, not the class
# Dog.name # AttributeError — only set inside __init__
# Assigning via an instance does NOT change the class attribute;
# it creates a new instance attribute that shadows it
rex.species = "Dog" # affects rex only
Dog.species # still "Canis familiaris"
Dog.species = "X" # THIS changes it for the class & all instances
# Inheritance
class Puppy(Dog):
def bark(self): # override a method
return f"{self.name} yips!"
def info(self):
base = super().bark() # call parent's version
return base
# Multiple inheritance: a class can have more than one parent
class Swimmer:
def move(self):
return "swimming"
class Walker:
def move(self):
return "walking"
class Amphibian(Walker, Swimmer): # inherits from both
pass
Amphibian().move() # "walking" -> parents checked left-to-right (MRO)
Amphibian.__mro__ # the Method Resolution Order Python follows
# There is never an ambiguity error at lookup: the MRO always yields ONE
# deterministic winner, however many parents define the same name.
# The only possible failure is at CLASS DEFINITION, if the parent list
# contradicts itself (a class before its own subclass):
class Base: pass
class Sub(Base): pass
# class Broken(Base, Sub): pass
# TypeError: Cannot create a consistent method resolution order (MRO)
# Empty class placeholder (pass = do nothing)
class Empty:
pass# Yes: a class can be defined inside another class. The inner class is
# just an attribute of the outer one (accessed as Outer.Inner) and gets
# NO special access to the outer class or its instances.
class Outer:
class Inner: # a nested type, namespaced under Outer
def hello(self):
return "hi from Inner"
Outer.Inner # the nested class object
inner = Outer.Inner() # instantiate via the outer's namespace
inner.hello() # "hi from Inner"Three kinds of methods, differing in what (if anything) they receive automatically as the first argument:
class Pizza:
count = 0 # class attribute (shared by all)
def __init__(self, size):
self.size = size # instance attribute
Pizza.count += 1 # update the shared class attribute
# Instance method: gets the INSTANCE as `self`.
# Use when you need the object's own data.
def describe(self):
return f"A {self.size} pizza"
# Class method: gets the CLASS as `cls`. Marked with @classmethod.
# Use to work with class-level data or build alternate constructors.
@classmethod
def how_many(cls):
return cls.count # reads the shared class attribute
# Static method: gets NOTHING automatic. Marked with @staticmethod.
# Just a plain function grouped inside the class for organization.
@staticmethod
def is_valid_size(size):
return size in ("small", "medium", "large")
p1 = Pizza("large")
p2 = Pizza("small")
p1.describe() # "A large pizza" (needs the instance)
Pizza.how_many() # 2 (works via the class)
Pizza.is_valid_size("xl") # False (no self/cls needed)| Method type | First arg | Decorator | Accesses |
|---|---|---|---|
| Instance | self |
(none) | instance + class data |
| Class | cls |
@classmethod |
class data only |
| Static | (none) | @staticmethod |
neither (self-contained) |
Python doesn't care about an object's type — only whether it has the method/attribute you use. "If it quacks like a duck, treat it as a duck."
class Duck:
def quack(self):
return "Quack!"
class Person:
def quack(self):
return "I'm imitating a duck!"
def make_it_quack(thing):
return thing.quack() # no type check — just calls the method
make_it_quack(Duck()) # "Quack!"
make_it_quack(Person()) # "I'm imitating a duck!"
# Works for ANY object that has a .quack() method,
# regardless of its class. Missing it -> AttributeError.
# To ENFORCE the contract, or have a type checker verify it, see the
# advanced sheet: Abstract Base Classes and Structural Typing (Protocol).You already rely on this everywhere: len(x) works on any object with a
__len__, and a for loop works on anything iterable — behavior matters,
not type.
"Dunder" (double-underscore) methods let your objects work with built-in
operations like str(), int(), ==, and <. Python calls them for you.
(The __repr__ vs __str__ subtleties get their own section on the
advanced sheet.)
class Money:
def __init__(self, amount):
self.amount = amount
# --- conversion ---
def __str__(self): # used by str() and print()
return f"${self.amount}"
def __int__(self): # used by int()
return int(self.amount)
def __bool__(self): # used by bool() / if checks
return self.amount != 0
# --- comparison ---
def __eq__(self, other): # used by ==
return self.amount == other.amount
def __lt__(self, other): # used by < (and enables sorting)
return self.amount < other.amount
# --- arithmetic ---
def __add__(self, other): # used by +
return Money(self.amount + other.amount)
a = Money(5)
b = Money(10)
str(a) # "$5"
int(a) # 5
bool(Money(0)) # False
a == Money(5) # True
a < b # True
sorted([b, a]) # sorts to [Money(5), Money(10)] thanks to __lt__
(a + b).amount # 15 -> + built Money(15) via __add__
# This is OPERATOR OVERLOADING: every operator has a dunder hook
# (+ __add__, - __sub__, * __mul__, / __truediv__ — pathlib uses that
# one to join paths). The limits: you can't invent NEW operator
# symbols or change precedence, and the operators of BUILT-IN types
# are sealed (int.__add__ = ... # TypeError: immutable type).Other common ones: __repr__ (debug representation), __len__ (len()),
__getitem__ (obj[key]), and __gt__ / __le__ / __ge__ for the
remaining comparisons.
try:
result = 10 / 0
except ZeroDivisionError:
print("Can't divide by zero")
except (TypeError, ValueError) as e: # catch several types in one clause
# check which one was actually raised
if isinstance(e, TypeError):
print(f"Type problem: {e}")
else:
print(f"Value problem: {e}")
except Exception as e: # catch-all (put last)
print(f"Error: {e}")
else:
print("No errors")
finally:
print("Always runs")
# Raise an exception yourself
try:
raise ValueError("must be non-negative")
except ValueError as e:
print(e) # "must be non-negative"# Subclass Exception (or a more specific built-in)
class InsufficientFundsError(Exception):
pass
def withdraw(balance, amount):
if amount > balance:
raise InsufficientFundsError("not enough money")
return balance - amount
try:
withdraw(50, 100)
except InsufficientFundsError as e:
print(e) # "not enough money"with opens the file as a context manager: it automatically closes the
file when the block ends — even if an error occurs inside it. This is the
recommended way, because forgetting to close a file can lose data or leak
resources. (Writing your own context managers is on the advanced sheet, as
is pathlib — the object-oriented way to handle file paths.)
# Write (creates / overwrites the file)
with open("file.txt", "w") as f:
f.write("hello")
# Read
with open("file.txt") as f:
content = f.read() # whole file
# or: lines = f.readlines()
# Append
with open("file.txt", "a") as f:
f.write("\nmore")Without with, you must close the file yourself (use try/finally so it
still closes if something fails):
f = open("file.txt")
try:
content = f.read()
finally:
f.close() # must close manuallyimport math
math.sqrt(16) # 4.0
from datetime import datetime
datetime.now()
import random as rnd
rnd.randint(1, 10)A module is just a .py file. Say you create mymath.py:
# mymath.py
PI = 3.14159
def square(n):
return n * n
def cube(n):
return n * n * nThen, from another file in the same folder, you can import it:
# main.py
import mymath
mymath.PI # 3.14159
mymath.square(4) # 16
# Import specific names
from mymath import square, cube
square(4) # 16 (no prefix needed)
# Import with an alias
import mymath as mm
mm.cube(3) # 27
# Runs only when the file is executed directly,
# not when it's imported
if __name__ == "__main__":
print(square(5))A module is a single .py file; a package is a folder of modules
containing an __init__.py file (often empty — it marks the folder as a
package and runs on first import):
mytools/ # the package
├── __init__.py # makes the folder importable (can be empty)
└── text.py # a module inside the package
# mytools/text.py
def shout(s):
return s.upper() + "!"# Import through the package with dotted names
import mytools.text
mytools.text.shout("hi") # "HI!"
from mytools.text import shout # import a specific name
shout("hi") # "HI!"
from mytools import text as t # import the module with an alias
t.shout("hi") # "HI!"A package can hold many modules and also other packages (subpackages,
each with its own __init__.py), nesting as deep as needed:
mytools/
├── __init__.py # runs ONCE, on first import of mytools
├── text.py
├── numbers.py # a package holds any number of modules
└── formats/ # a SUBPACKAGE: packages nest
├── __init__.py
└── csv.py
__init__.py may be empty, but it can contain any code — it runs once, when
the package is first imported. Typical contents: package-level constants,
and re-exports that give users a shorter import path:
# mytools/__init__.py
# (shown commented: `.text` is a RELATIVE import — the leading dot means
# "from this same package", so the line only runs inside the package)
# from .text import shout # re-export: lift a name to the package level
# VERSION = "1.0" # package-level constantimport mytools
mytools.VERSION # "1.0"
mytools.shout("hi") # "HI!" -> usable without naming .text,
# thanks to the re-export
# Reach into a subpackage with the full dotted path
from mytools.formats.csv import to_row
to_row(["a", "b", "c"]) # "a,b,c"
import mytools.numbers
mytools.numbers.double(21) # 42