Daniel Schniepp

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.

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 and False.
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:

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

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.

python software engineering