Mastering Pythonic Development
Advanced Data Structures
Beyond the Basics
You've already worked with Python's basic data structures like lists and dictionaries. Now, it's time to learn more elegant and efficient ways to handle data. These techniques, often called 'Pythonic', allow you to write code that is not only faster but also easier for other developers to read and understand.
Let's start with a common task: creating a new list based on an existing one. The traditional way involves a for loop and the .append() method. For example, to get a list of squares from a list of numbers:
numbers = [1, 2, 3, 4, 5]
squares = []
for n in numbers:
squares.append(n**2)
print(squares) # Output: [1, 4, 9, 16, 25]
This works perfectly well, but there's a more compact way. A list comprehension lets you build a new list in a single, descriptive line.
# [expression for item in iterable]
numbers = [1, 2, 3, 4, 5]
squares = [n**2 for n in numbers]
print(squares) # Output: [1, 4, 9, 16, 25]
You can also add a condition. Let's say we only want the squares of even numbers.
# [expression for item in iterable if condition]
even_squares = [n**2 for n in numbers if n % 2 == 0]
print(even_squares) # Output: [4, 16]
This same concise logic applies to dictionaries. Dictionary comprehensions follow a similar pattern, using curly braces {} and a key-value pair.
# {key_expression: value_expression for item in iterable}
square_dict = {n: n**2 for n in numbers}
print(square_dict) # Output: {1: 1, 2: 4, 3: 9, 4: 16, 5: 25}
Working with Collections
Python's collections module provides specialised container data types that solve specific problems. Think of it as a toolkit with custom-made tools for jobs where a regular list or dictionary would be clumsy.
One of the most useful tools is defaultdict. Normally, if you try to access or modify a dictionary key that doesn't exist, Python throws a KeyError.
word_counts = {}
sentence = "the quick brown fox jumps over the lazy dog"
words = sentence.split()
for word in words:
if word in word_counts:
word_counts[word] += 1
else:
word_counts[word] = 1
A defaultdict simplifies this by providing a default value for any new key. You tell it what kind of default you want (like an integer, int, which defaults to 0) when you create it.
from collections import defaultdict
word_counts = defaultdict(int) # Default value for new keys will be int(), which is 0
sentence = "the quick brown fox jumps over the lazy dog"
words = sentence.split()
for word in words:
word_counts[word] += 1 # No need to check if the key exists!
# word_counts['the'] is 2, word_counts['fox'] is 1, etc.
Another handy tool is the . Plain tuples are lightweight, but you have to access their elements by index, like point[0] and point[1]. This can make code hard to read. A namedtuple gives you the best of both worlds: the efficiency of a tuple with the readability of accessing attributes by name.
from collections import namedtuple
# Create a new 'Point' class
Point = namedtuple('Point', ['x', 'y'])
p1 = Point(10, 20)
print(p1.x) # Output: 10
print(p1[1]) # Output: 20
Slicing and Copying
Slicing is a powerful way to extract parts of a sequence. You're likely familiar with basic slicing, like my_list[0:5]. But slicing has an optional third argument: the 'step'.
# list[start:stop:step]
numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
# Get every second number
print(numbers[::2]) # Output: [0, 2, 4, 6, 8]
# Get every second number from index 1 to 7
print(numbers[1:8:2]) # Output: [1, 3, 5, 7]
# A common trick to reverse a list
print(numbers[::-1]) # Output: [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
Finally, understanding how Python copies objects is crucial, especially with nested data structures like a list of lists. When you assign one variable to another, you are not creating a copy; you are just making both variables point to the same object in memory.
list_a = [1, 2, 3]
list_b = list_a
list_b.append(4)
print(list_a) # Output: [1, 2, 3, 4]
print(list_b) # Output: [1, 2, 3, 4]
Changing list_b also changed list_a because they refer to the same list. To create a separate object, you need to make a copy. A shallow copy creates a new object but fills it with references to the items in the original. This is fine for simple lists, but with nested lists, it can cause surprises.
import copy
# A list containing another list
list_a = [1, 2, [10, 20]]
# Create a shallow copy
list_b = copy.copy(list_a)
# Modify the nested list through list_b
list_b[2].append(30)
print(f"List A: {list_a}") # Output: List A: [1, 2, [10, 20, 30]]
print(f"List B: {list_b}") # Output: List B: [1, 2, [10, 20, 30]]
The nested list inside list_a was still changed! This is because the shallow copy created a new outer list, but the inner list was just a reference. To create a completely independent copy of all objects, you need a .
import copy
list_a = [1, 2, [10, 20]]
# Create a deep copy
list_c = copy.deepcopy(list_a)
# Modify the nested list through list_c
list_c[2].append(30)
print(f"List A: {list_a}") # Output: List A: [1, 2, [10, 20]]
print(f"List C: {list_c}") # Output: List C: [1, 2, [10, 20, 30]]
Now list_a is unaffected. Knowing when to use a shallow versus a deep copy is key to avoiding subtle bugs in your programs.
Which line of code correctly creates a new list containing the squares of all odd numbers from an existing list called numbers?
Consider the following code:
import copy
list_a = [1, [2, 3]]
list_b = copy.copy(list_a) # a shallow copy
list_b[1][0] = 99
After this code runs, what will list_a look like?
Using these advanced patterns will make your code more efficient and readable.