Chapter 03
Python essentials
The idioms that make Python code look like Python: comprehensions, generators, type hints, dataclasses and the traps every beginner meets once.
Comprehensions
Also called list comprehension, dict comprehension
- What
- A compact expression that builds a list, dictionary or set by transforming and filtering another iterable, in one line.
- Why
- They replace a four-line loop that appends to an empty list, and they read like the sentence "the square of each n where n is even".
squares = [n * n for n in range(10)]
evens = [n for n in nums if n % 2 == 0]
by_id = {user.id: user for user in users}
domains = {email.split("@")[1] for email in emails}Watch out
Nesting two or three comprehensions with conditions; once it needs a second read, write a normal loop.
Generators and yield
Also called iterators, lazy evaluation, generator expression
- What
- A function that uses
yieldproduces values one at a time, pausing between them, instead of building a whole list in memory. - Why
- Generators process files or data streams far larger than memory, and they let you chain steps (read, filter, transform) without temporary lists.
def non_empty_lines(path: str):
with open(path, encoding="utf-8") as f:
for line in f:
if line.strip():
yield line.rstrip("\n")
total = sum(len(line) for line in non_empty_lines("big.log"))Watch out
Looping over the same generator twice: it is used up after the first pass and the second loop sees nothing.
Truthiness and is None
Also called falsy values, is vs ==
- What
- In a condition, empty collections,
0,""andNonecount as false and almost everything else as true.ischecks whether two names are the same object;==checks whether values are equal. - Why
if not items:is the idiomatic empty check.is Noneis the correct way to test for a missing value.
items = []
if not items:
print("empty")
count = 0
if count is not None:
print("count was given, even though it is 0")Watch out
Writing if count: when 0 is a valid value, so a real zero is treated as "not given".
Mutable default arguments
- What
- Default values are created once, when the function is defined, not on every call. A default list or dictionary is therefore shared by every call that uses it.
- Why
- It is the classic Python surprise: data leaks between calls. Knowing the rule tells you to use
Noneas the default and create the list inside.
def add_tag(tag, tags=[]):
tags.append(tag)
return tags
add_tag("a")
print(add_tag("b")) # ['a', 'b']
def add_tag_fixed(tag, tags=None):
tags = [] if tags is None else tags
tags.append(tag)
return tagsWatch out
Writing def f(items=[]) or def f(config={}) and appending to it.
Type hints
Also called type annotations, mypy, Pyright, typing
- What
- Annotations that state the expected types of parameters, return values and variables. Python ignores them at runtime; checkers such as mypy and Pyright, and your editor, read them.
- Why
- Hints catch wrong-type bugs before you run the code, power autocomplete and document what a function expects. Python 3.12 added the short
def f[T]generic syntax.
from collections.abc import Iterable
type UserId = int
def first[T](items: list[T]) -> T | None:
return items[0] if items else None
def total(prices: Iterable[float]) -> float:
return sum(prices)Watch out
Assuming hints validate data at runtime: a function typed int still accepts a string unless a checker or a library such as Pydantic enforces it.
Dataclasses
Also called @dataclass
- What
- A decorator that writes
__init__,__repr__and__eq__for a class from its annotated fields. - Why
- Use one for records of data (a task, an order line) instead of a dictionary with string keys: you get typo-proof attributes, readable printing and equality for free.
from dataclasses import dataclass, field
@dataclass
class Task:
title: str
done: bool = False
tags: list[str] = field(default_factory=list)
task = Task("write tests")
print(task) # Task(title='write tests', done=False, tags=[])Watch out
Giving a field a mutable default like tags: list = []; use field(default_factory=list).
The main guard
Also called if __name__ == "__main__", entry point
- What
- The check
if __name__ == "__main__":is true only when the file is run directly, not when another file imports it. - Why
- It lets a module be both a runnable script and an importable library, so tests can import your functions without running the whole program.
def main() -> None:
print("running as a script")
if __name__ == "__main__":
main()Watch out
Putting program logic at the top level of a module, so importing it for a test starts reading input or writing files.