One month of learning Python
My goal was to learn how to write proper Python code. I didn't know much about the language and its paradigms.
The journey
As I already know how to write software, I was unsure how to start as I didn't want to waste my time following basic tutorials. Nevertheless, I started my journey by learning the basic concepts might be a good start, so I completed the Exercims Python Track. While comparing my solutions to others, I observed patterns I didn't know. At that time, a colleague mentioned that Peter Norvig's Design of Computer Programs - Programming Principles for learning Python is a good resource for learning Python. The course introduced me to language features like list comprehensions. Around that time, I learned the term Pythonic. Pythonic means using the build-in features and conventions of the language instead of writing only working and syntactically correct code. At that time, I came across the talks of Raymond Hettinger, a Python Core Developer who shows and discusses how to write Pythonic code.
- Transforming Code into Beautiful, Idiomatic Python (A bit dated but still awesome, check the comments for updates)
- The Mental Game of Python - Raymond Hettinger
- Python's Class Development Toolkit
- Beyond PEP 8 -- Best practices for beautiful intelligible code
My Notes
As so many articles address the following topics in detail, I will focus on examples that helped me understand the concepts and share what I found interesting.
Unpacking and Packing
Iterables (list, tuple, set, dict, ...) in Python can be unpacked. Unpacking is a particular type of assignment. Instead of assigning one value to a variable, iterables are destructed into single elements and are assigned to multiple variables.
Swap
Python offers an elegant way of swapping values.
>>> a = 1
>>> b = 2
# Non-Pythonic
>>> tmp = a
>>> a = b
>>> b = tmp
>>> a, b
(2, 1)
# Pythonic
>>> b, a = a, b
>>> a, b
(2, 1)
a, b
is the short form of (b, a)
.
The values of the unpacked Tuple are assigned to the variables a
and b
.
Python evaluates expressions from left to right. Notice that while evaluating an assignment, the right-hand side is evaluated before the left-hand side.
Source: Python Docs - Evaluation Order
List Head and Tail
# Non-Pythonic
def sum(nums):
head = nums[0]
tail = nums[1:]
if not tail:
return head
return head * sum(tail)
# Pythonic
def sum(nums):
head, *tail = nums
if not tail:
return head
return head * sum(tail)
We don't get an exception for single item lists because, by design, slices with no matches return an empty sequence. It feels counterintuitive, but you should not mix slice access with accessing a single element by index; instead, it is like an operation on the sequence.
if not tail
works because:
Here are most of the built-in objects considered false:
constants defined to be false:None
andFalse
.
zero of any numeric type:0
,0.0
,0j
,Decimal(0)
,Fraction(0, 1)
empty sequences and collections:''
,()
,[]
,{}
,set()
,range(0)
Source: Python Docs - Truth Value Testing
Slicing and slice assignment
Slicing and slice assignments are related, but they work differently, as you will see in the examples.
Slicing
Python relies heavily on slices for sequence destruction.
>>> nums = [1,2,3,4,5]
# nums[start:stop:step]
# Second and third element
>>> nums[1:3]
[2, 3]
# Last element
>>> nums[-1]
[5]
# Every second element
>>> nums[1::2]
[2, 4]
# Reverse
>>> nums[::-1]
[5, 4, 3, 2, 1]
# `nums[::-1]` is faster than `reversed(nums)` but slower than `nums.reverse()`.
Slice Assignments
With slice assignment, you modify the original sequence instead of creating a copy with applied modifications of the sequence.
>>> nums = [1,2,3,4,5]
[1, 2, 3, 4, 5]
# prepend
>>> nums[0:0] = [0]
[0, 1, 2, 3, 4, 5]
# replace element 4 and 5
>>> nums[3:5] = [4,3]
[0, 1, 2, 4, 3, 5]
# append
>>> nums[len(nums):0] = [6]
[0, 1, 2, 4, 3, 5, 6]
# why not appending like that?
>>> nums[-1:0] = [7]
[0, 1, 2, 4, 3, 5, 7, 6]
As you see in the last examples, slice assignments are not always intuitive. But I make sense if you see the first parameter in the slice assignment as a pointer that points to the position before the addressed element.
Speed-wise, a slice assignment for prepending an element is faster than a list insert()
, but appending with slice assignments is slower than calling append()
. Replacing all elements in a list with new values via a slice assignment is also slower. You can find more interesting discussions around that topic here:
- Why is copying a list using a slice[:] faster than using the obvious way?
- Why is slice assignment faster than
list.insert
? - Speed comparison list replacement
List and Generator Comprehensions
List and Generator Comprehensions in Python are very fast and efficient. There are plenty of tests where List and Generator Comprehensions outperform map and filter functions. A generator comprehension is the lazy (deferred) version of a list comprehension and consumes less memory.
# Non-Pythonic
res = []
for i in [23,1023,23,342,3,34,35,90]:
if i % 3 == 0:
res.append(i)
res
# Pythonic
[i for i in [23,1023,23,342,3,34,35,90] if not i % 3]
List comprehensions can become very large but still readable. The power of list comprehensions became evident when I saw Peter Norvig's solution of the zebra puzzle from his CS212 course.
Generators
Generators are functions that generate an iterable object. They can be infinite compared to sequences, which are finite by design. Generators are very memory efficient as they do not hold all values.
def fib(n):
a, b = 0, 1
for _ in range(n):
yield a
a, b = b, a + b
- Nice summary on how generators work
- Raymond Hettinger on when to use a list instead of a generator
- More exccesive example and article on generators
Decorators
When I first saw decorators, I thought they were annotations known from Java or C#. But in Python, the implementation of decorators as first-class citizens feels more like Aspects from aspect-oriented programming (AOP). Decorators enable you to write code by allowing the separation of cross-cutting concerns. Typical use cases can be caching, logging, or authorization all topics.
Logging
This is a simple logging decorator for demonstration. You would rather use a logging lib in real life instead of a print
statement.
def log(func):
@functools.wraps(func)
def wrapper(n):
print(f"Call func {func.__name__} with {n}")
result = func(n)
print(f"return: {result}")
return result
return wrapper
@log
def fib_sum(n):
if n < 0:
raise Exception("Negative value not allowed")
if n == 0:
return 0
if n == 1 or n == 2:
return 1
return fib_sum(n-1) + fib_sum(n-2)
Caching
The recursive fib
implementation makes multiple function calls with the same arguments.
That is a perfect situation for using memoization to reduce execution time.
In this case, Pythons decorators come in very handy.
def memo(func):
cache = {}
@functools.wraps(func)
def wrapper(n):
if n in cache:
return cache[n]
result = cache[n] = func(n)
return result
return wrapper
@memo
def fib_sum(n):
if n < 0:
raise Exception("Negative value not allowed")
if n == 0:
return 0
if n == 1 or n == 2:
return 1
return fib_sum(n-1) + fib_sum(n-2)
Decorators can be stacked.
More useful information about decorators can be found at Python Tips: Decorators and Python 3 Patterns, Recipes and Idioms: Decorators.
The @functools.wraps
decorator recovers the original function names as well as the docstring.
Context managers
# Non-Pythonic
file = open('work.log', 'w')
try:
file.write('Wrote some Python code.')
finally:
file.close()
# Pythonic
with open('work.log', 'w') as file:
file.write('Wrote some Python code.')
If you want to implement your own context manager, there are two approaches. First by implementing the __enter__
and __exit__
methods in your class. Second, use a generator with a decorator like @contextmanager
from the contextlib
.
More useful information can be found in the Python Docs and Python Tips: Context Managers.
Recap
Besides applying the learned and tinkering around, the talks of Raymond Hettinger were the best source of getting to know the language and its paradigms.