Fluent Python Cheat Sheet for Newbies
High-level ML frameworks and libraries (e.g., PyTorch, JAX, TensorFlow, NumPy, Triton, and many more) are mostly based on Python.
I’ve known Python for a while, but I’ve never learned it to a professional degree and wouldn’t say I’m good at Python programming. So, I decided to read Fluent Python (which seems to be one of the ‘bible’ figures of Python) to cover some topics and improve my Python programming skills.
One thing that was hard for me while reading this book was that it was too dense to just understand and skip. I needed to type in the code, but even that wasn’t enough. I figured this is a dictionary-like book that is hard to read repeatedly, so I decided to write a much more compact, digestable, and straightforward course note that works as a cheat sheet but also keeps the context as much as possible.
Note: This is not designed to be a replacement of the book, but for a handy review note or cheat sheet used daily.
Let’s start.
Part 2. Data Structures
Chapter 2. Sequences
A sequence in Python is a general term for an ordered collection of items. This means the items have a specific order, and you can access them by their position (their index).
We can divide sequences into types that can hold items of different type(Container sequence) or types that can’t (Flat sequence). Flat sequences are more compact but are limited to holding primitive values.
Another way to divide is by mutability(Mutable sequences vs Immutable sequences).
Listcomps and Genexps
List Comprehension, or Listcomp
this creates list with single line without using for
loop or .append()
>>> arr = [1,2,3]
>>> ex = [-x for x in arr]
>>> ex
[-1, -2, -3]
listcomp is even faster than map
or filter
at least in some cases.
(Try running this in Python Shell if you are curious!)
Cartesian Product
this is just nested listcomp (double for loop).
>>> colors = ['black', 'white']
>>> sizes = ['S', 'M', 'L']
>>> tshirts = [(color, size) for color in colors for size in sizes]
>>> tshirts
[('black', 'S'), ('black', 'M'), ('black', 'L'), ('white', 'S'),
('white', 'M'), ('white', 'L')]
Generator Expression, or Genexps
what this is/does: similar to listcomps but uses parantheses instead of square brackets. This saves memory because it yields items one by one using the iterator protocol instead of building a whole list and proceeding.
listcomp is expensive but reusable, generator is cheap but cannot be used again.
Tuples
Tuples as Records
Since tuples are immutable, they (to be more accurate, their location) can be used as records.
Tuple Unpacking
we can unpack tuples that have two or more values by assigning items from an iterable to a tuple of variables.
This is an example of tuple used as a record and unpacking it.
>>> names = [('junu', 'park'), ('john', 'doe')]
>>> for first_name, _ in names:
... print(first_name)
...
junu
john
we can use *args
while unpacking tuple to extract the part(s) of what we want.
>>> names = [('junu', 'park'), ('john', 'doe'), ('ruby', 'onRails'), ('elon', 'musk'), ('the', 'primagen')]
>>> a, b, *c = names
>>> a
('junu', 'park')
>>> b
('john', 'doe')
>>> c
[('ruby', 'onRails'), ('elon', 'musk'), ('the', 'primagen')]
note that we can only use one args each time.
Nested Tuple Unpacking
If the expression of nested tuples match, Python will match the nesting structure and unpack it properly.
this is a non-realistic example but you will understand how nested tuple works by this example:
>>> nested_tuple = [('a', ('b', ('c', ('d')))), ('e', ('f', ('g', ('h'))))]
>>> for (x, (y, (z, (w)))) in nested_tuple:
... print(x, y, z, w)
...
a b c d
e f g h
Named Tuples
This enhances the names(field names and a class name) of tuple.
>>> from collections import namedtuple
>>> Name = namedtuple('Name', 'first_name last_name')
>>> junu = Name('Junu', 'Park')
>>> elon = Name('Elon', 'Musk')
>>> prime = Name('The', 'Primagen')
>>> junu
Name(first_name='Junu', last_name='Park')
>>> elon
Name(first_name='Elon', last_name='Musk')
>>> prime
Name(first_name='The', last_name='Primagen')
As you can see there are two parameters for namedtuple: first one is the class name and second are field names seperated by single space.
We can use additional methods supported in namedtuples such as,
Name(first_name='The', last_name='Primagen')
>>> prime.last_name
'Primagen'
>>> prime._asdict()
{'first_name': 'The', 'last_name': 'Primagen'}
Slicing
You can slice not only lists but other sequence types like strings or tuples.
Why slices and range exclude last item
because it works well with the zero-based(starting from 0) indexing used in Python.
More specifically, it’s
- easy to see the length of a slice or range when only the stop position is given.
- easy to compute the length of a slice or range when start and stop are given by subtracting them.
- easy to split a sequence in two parts at any index
x
using the samex
without overlapping.
Using $+$ and $*$ with Sequences
Multiplying list with integer copies of the same sequence works.
>>> l = [1,2,3]
>>> l2 = l * 3
>>> l2
[1, 2, 3, 1, 2, 3, 1, 2, 3]
>>> id(l)
139738068947456
>>> id(l2)
139738067406976
Building Lists of Lists
The problem appears when the elements in the list are mutable items. The most common case is building lists of lists.
>>> board1 = [['_'] * 3 for i in range(3)]
>>> board1
[['_', '_', '_'], ['_', '_', '_'], ['_', '_', '_']]
>>> board1[1][2] = 'x'
>>> board1
[['_', '_', '_'], ['_', '_', 'x'], ['_', '_', '_']]
>>> id(board1[0])
139738042877824
>>> id(board1[1])
139738043505344
>>> id(board1[2])
139738043329344
board1 works fine, meaning it has different address for each sub arrays.
but if we multiply list, it just references the same list:
>>> board2 = [['_'] * 3] * 3
>>> board2
[['_', '_', '_'], ['_', '_', '_'], ['_', '_', '_']]
>>> board[1][2] = 'x'
>>> board2
[['_', '_', '_'], ['_', '_', '_'], ['_', '_', '_']]
>>> board2[1][2] = 'x'
>>> board2
[['_', '_', 'x'], ['_', '_', 'x'], ['_', '_', 'x']]
>>> id(board2[0])
139738065760064
>>> id(board2[1])
139738065760064
>>> id(board2[2])
139738065760064
which means it just appends the same row three times.
Augmented Assignments (+=
and *=
)
augmented assignments are NOT the same as adding or multiplying.
While +
internaly uses special function __add__()
, +=
uses special function __iadd__()
.
Only if __iadd__()
is not implemented in the class, it works the same as __add__()
.
so, when does it change?
In mutable sequences (such as lists), __iadd__()
will be changed in place (using less memory) but in other cases, it will work as __add__()
which means a = a + b
.
>>> l = [1,2,3]
>>> id(l)
139738065799168
>>> mul_list = l * 3 # __mul__()
>>> id(mul_list)
139738068947456 # different address
>>> l *= 3 # __imul__()
>>> id(l)
139738065799168 # changed in place
A+= Assignment Puzzler
>>> t = (1, 2, [30, 40])
>>> t[2] += [50, 60]
This is a corner case that I won’t go into details since it seems too peripheral.
To explain breifly, this will both make an error but also augment the list into [30, 40, 50, 60]
.
• Putting mutable items in tuples is not a good idea.
• Augmented assignment is not an atomic operation—we just saw it throwing an exception after doing part of its job.
• Inspecting Python bytecode is not too difficult, and is often helpful to see what is going on under the hood.
Sorting
There are mainly two built-in sorting functions, which is list.sort
and sorted
.
list.sort
does create a copy, but sort the list in place. It returns None
to remind it.
Note: This is a nice pattern to know about Python API conventions: Python usually outputs None
to make it clear the function doesn’t create a copy but changes the object in place.
sorted
creates a new list and returns it. Therefore, takes additional memory. This also means it can take any kinds of iterable object including immutable ones, such as tuples. Note that it always outputs a new list.
Both of the functions take two arguments which are reversed: bool
and key: string
.
>>> l = ['a', 'b', 'c']
>>> sorted_l = sorted(l) # example of `sorted`
>>> id(l)
139738067138496
>>> sorted_l
['a', 'b', 'c']
>>> id(sorted_l)
139738065799168 # another array created
>>> l.sort() # example of `list.sort`
Sorting is very helpful because once the sequence is sorted, each elements can be very efficiently searched.
Managing Ordered Sequence with bisect
It is good to keep the sorted sequence since sorting is expensive.bisect.bisect
and bisect.insort
helps a much efficient way to search and insert than list
functions.
Binary Search Algorithm is provided via bisect
module in Python standard library.
Bisect is an efficient way to search and insert elements into a sequence while keeping the order in an ordered sequence.
bisect()
returns the location where the element should be inserted in order to maintain the order while insort()
finds the location and inserts the element.
a quick minimal example would be:
>>> import bisect
>>> l = [1, 2, 3, 4, 5]
>>> l
[1, 2, 3, 4, 5]
# bisect only tells you the location
>>> bisect.bisect(l, 6)
5
>>> bisect.bisect(l, 3)
3
>>> bisect.bisect(l, 4)
4
# insort actually inserts the array
# this is an in-place exchange so only one block of
# memory will be added
>>> bisect.insort(l, 3)
>>> l
[1, 2, 3, 3, 4, 5]
>>> bisect.bisect(l, 3)
4
When a List is Not the Answer
Arrays
When creating an array,
- you provide a typecode
- a letter to determine the underlying C type used to store each item in the array.
For example
>>> from array import array
>>> from random import random
>>> floats = array ('d', (random() for i in range(2**10)))
‘b’: signed char
’d’: double
whatever you assign to, the array will interpret as the type you assigned to it.
Fast loading and saving are available via .frombytes
and .tofile
Binary file can be 60x faster than reading numbers in text file.
Memory Views
A memoryview is essentially a generalized NumPy array structure in Python itself (without the math). It allows you to share memory between data-structures (things like PIL images, SQLlite databases, NumPy arrays, etc.) without first copying.
NumPy and SciPy
Deque
deque
(not dequeue) stands for ‘double-ended queue’.
Removing items from the middle of a deque
is not as fast. It’s optimized for appending and popping from the end. Used in LIFO queue.
deque internally is implemented as a doubly linked list of fixed-size blocks. Therefore traversing to the middle would be more ineffecient than accesing the ends.
## Chapter 3. dicts and sets
Hash tables are the engines behind Python’s high-performance dicts.
All mapping types in the std library use the basic dict in their implementation, which means all keys(while values are not required) must be hashable.
This means, in order to understand how dictionary or set in Python works under the hood, we should understand about hash first.
About hash
What is hash and what does it mean to be hashable?
The hash() built-in function works directly with built-in types and falls back to calling hash for user-defined types. If two objects compare equal, their hash values must also be equal, otherwise the hash table algorithm does not work.
A hash is a numeric value that Python calculates for a hashable object.
It’s like a unique, short fingerprint for that specific object.
hashable means it can be hashed.
Why does hash make searching faster?
When we feed in a hashable key-value pair, Python will take the (hashable) key and feed it to a hash function. This function will quickly compute an integer number called hash value.
This hash value is then mathematically mapped to a specific index (or bucket) within an internal array that the dictionary uses to store its items.
This makes direct memory access available. This means, hash table design allows Python to use key to go directly to the calculated index in its internal value, without needing to scan other parts of the memory.
What are the hashable types?
- atomic immutable types (such as
str
) frozen set
tuple
is hashable only if all its items are hashable- If we look at the example below, unlike
tf
,tl
will produce an error since there is an unhashable list inside the tuples.
- If we look at the example below, unlike
>>> tl = (1,2,[30,40])
>>> hash(tl)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'list'
>>>
>>> tf = (1,2,frozenset([30,40]))
>>> hash(tf)
5149391500123939311
What is a hash table?
Hash table is a sparse array. Cells in hash table are often called buckets
Python tries to keep at least 1/3 of the buckets empty; if the hash table becomes too crowded, it is copied to a new location with room for more buckets (dynamic memory allocation)
When different hash values result in the same bucket, it is called hash collision. There are several ways to deal with hash collision. Python uses open addressing which uses a complex internal algorithm to assign the next bucket. The main goal is to reduce hash collision as much as possible.
set vs frozenset
The difference is mutability.
frozenset is an immutable version of set. You can’t add, remove, or change its elements.
Variations of dicts
There are multiple ways to create dicts.
>>> a = dict(one=1, two=2, three=3)
>>> b = {'one': 1, 'two': 2, 'three': 3}
# combine multiple iterable objects element-wise
>>> c = dict(zip(['one', 'two', 'three'], [1,2,3]))
>>> d = dict([('two',2), ('one', 1), ('three', 3)])
>>> e = dict({'three': 3, 'one': 1, 'two': 2})
>>> a == b == c == d == e
True
Handling Missing Keys with setdefault
When looking up table and checking if it’s key is missing, it’s more efficient to use setdefault
than d.get(k, default)
.
Mappings with Flexible Key Lookup
This is used to return some made up value when missing key is searched.
We can do this either by 1. default dict
instead of dict
or 2. any other mapping type + __missing__
method.
defaultdict
index = collections.defaultdict(list)
if default factory is not provided, KeyError
will be raised for missing keys.
Variations of dict
collections.OrderedDict
Maintains keys in insertion order
FIFO when it’s default, but LIFO if my_odict.popitem(last=True)
collections.ChainMap
dict and set under the hood
list vs set/dict
because there is no hash table to support searches with the in operator on a list, so a full scan must be made, resulting in times that grow linearly with the size of the haystack.
dicts have significant memory overhead
When you use dictionaries, Python has to do extra work behind the scenes to make them fast for looking things up. This extra work involves creating a “hash table,” which is like a special, spacious grid for organizing data.
hash tables must be sparse to work, which means it’s NOT space efficient
If you have many dictionaries, you are using memory-intensive hash tables. Tuples might be a better choice to avoid significant memory overhead.
Key Search is very fast
dict is trading space for time. They provide fast access regardless of the size of the dict.
When hash collisions occur, whether a
bumps b
to a different memory location (A-B case) or b
bumps a
(B-A case), the key point is while Python has enough memory space to handle these adjustments, they are considered the same.
Python’s ==
(equality) check for dictionaries focuses solely on whether the same key-value pairs exist in both dictionaries. It doesn’t care about the internal order of keys or the specific memory addresses where they’re stored. As long as key_A
maps to value_A
and key_B
maps to value_B
in both dictionaries, they are considered equal, regardless of the behind-the-scenes memory acrobatics.
Adding items to a dict may change the order of existing keys
Dynamic memory allocation may happen as more items are added to the hash table (in order to keep it sparse). During this process, new but different hash collisions may happen while copying the existing keys which changes the order of them.
This is why modifying the contents of a dict
while iterating is generally a bad idea.
If you need to scan and add items to a dictionary, first read all of the elements (without modification) and collect the needed additions in a second dict
. Then update the original dict by appending the new one.
How Sets work
set is similar to dict but the only difference is that each bucket holds only a reference to the element, while dict holds both key and value.
Basically, set is a dictionary but does not have value attached.
Only keys.
## Chapter 4. Text vs Bytes
As mentioned in page 97 (“In the end, most of the issues covered in this chapter do not affect programmers who deal only with ASCII text.”), I will skip most of the parts in this chapter since these does not seem to be my immediate interest for now.
Byte Essentials
two types of binary sequences:
- mutable
bytearray
- immutable
bytes
>>> cafe = bytes('café', encoding='utf-8')
>>> cafe
b'caf\xc3\xa9'
>>> cafe[0]
99
>>> cafe[:1]
b'c'
bytes can be built from a str
, given an encoding.
All the elements in utf-8 encoded bytes are int
s of range(256) (0~256).
Check the Note below why cafe[0]
is int
but cafe[:1]
isn’t.
Note: The fact that cafe[0]
retrieves an int but cafe[:1]
returns a bytes object of length 1 should not be surprising. The only sequence type where s[0] == s[:1]
is the str type. Al though practical, this behavior of str
is exceptional. For every other sequence, s[i]
returns one item, and s[i:i+1]
returns a sequence of the same type with the s[1]
item inside it
Handling Text Files
The best pracitce of handling text file is to follow the “Unicode sandwitch” principle. It means that bytes
should be decoded to str
as early as possible on input, processed as str
, and encoded to bytes
as late as possible.
Part 3. Functions as Objects
Chapter 5. First-Class Function
Functions in Python are first-class objects.
Programming language theorists define a “first-class object” as a program entity that can be:
• Created at runtime
• Assigned to a variable or element in a data structure
• Passed as an argument to a function
• Returned as the result of a function
Treating Function Like an Object
Example:
>>> def factorial(n):
... '''returns n'''
... return 1 if n < 2 else n * factorial(n-1)
...
>>> factorial(42)
1405006117752879898543142606244511569936384000000000
>>> factorial
<function factorial at 0x7f66ffe57880>
>>> fact = factorial
>>> fact
<function factorial at 0x7f66ffe57880>
>>> fact(42)
1405006117752879898543142606244511569936384000000000
>>> list(map(fact, range(11)))
[1, 1, 2, 6, 24, 120, 720, 5040, 40320, 362880, 3628800]
Higher-Order Functions
Higher-Order Function is function that takes a function as argument or returns function as the result. (ex. map
, filter
, reduce
, sorted
…)
For example, we can take functions as elements for the key=
parameter in sorted
function:
>>> fruits = ['strawberry', 'fig', 'apple', 'cherry', 'rasberry', 'banana']
# using len
>>> sorted(fruits, key=len)
['fig', 'apple', 'cherry', 'banana', 'rasberry', 'strawberry']
# using custom function(reverse)
>>> def reverse(word):
... return word[::-1]
>>> sorted(fruits, key=reverse)
['banana', 'apple', 'fig', 'rasberry', 'strawberry', 'cherry']
Note that key is only used to determine the sorting order, but does NOT change the content.
Modern Replacements for map, filter, and reduce
We can use listcomp and genexp instead of map.
For example,
# map and listcomp
>>> a1 = list(map(factorial, range(6)))
>>> a2 = [factorial(x) for x in range(6)]
>>> a1
[1, 1, 2, 6, 24, 120]
>>> a2
[1, 1, 2, 6, 24, 120]
map vs generator
map
and generator
are two different types of iterators.
>>> a3 = map(factorial, range(6))
>>> a4 = (factorial(x) for x in range(6)
>>> type(a3)
<class 'map'>
>>> type(a4)
<class 'generator'>