Introduction to Data Structures

Previously, we have learned about five primitive types: integers, floats, strings, booleans, and the special None value. In this chapter, we get a new type and an entire system for making additional types that greatly extend our capabilities. First we will learn about dataclasses, that essentially allow us to combine different types of data into a single new type. Then we will learn about lists, which is a type that allows us to group together values of the same type into a single value. Both types have mutability, which introduces an exciting new way for our programs to go wrong.

Dataclasses

from dataclasses import dataclass

@dataclass
class Food:
    name: str
    calories: int
    cost: float

milk = Food("Jug of Milk", 2400, 4.33)
apples = Food("Bag of Apples", 285, 1.31)

print(milk.calories//milk.cost)
print("Would you like", apples.name+"?")

Dataclasses are basically collections of variables that can be combined to represent a single thing. To be clear, dataclasses are not a monolithic type themselves, but a way to make new types. In the example program shown here, we create a new Food dataclass and then make instances of the new type, storing them in variables named milk and apples. The syntax for creating dataclasses is a little tricky, requiring not only an import but also a new annotation called a decorator (the @dataclass bit above the class keyword). Forgetting any part of the syntax will prevent the dataclass from being properly formed, and can cause quite a few errors. In addition to learning the new syntax, you also have to learn quite a bit of terminology. Defining the dataclass, or class, creates not only a new type but also gives you a constructor function with the same name, which is what is called to create instances of the dataclass. Afterwards, you can access fields (also known as attributes) that were passed into the constructor, by using a period and the name of the field.

Dataclasses in Action

from dataclasses import dataclass
from bakery import assert_equal

@dataclass
class Food:
    name: str
    calories: int
    cost: float

def calories_per_dollar(a_food: Food) -> float:
    return a_food.calories // a_food.cost

apples = Food("Bag of Apples", 285, 1.31)
assert_equal(calories_per_dollar(apples), 217.0)

A primary use case for dataclasses is that we can pass them into or return them from functions, reducing the number of parameters we need and letting our functions do more. This requires us to use the dataclass’s name as a type in our parameters and header return types, but is otherwise no different than any other type of value being used as a parameter. Dataclasses really only support two kinds of operations, the equality operator and the attribute lookup with the period. However, that attribute lookup is extremely powerful, allowing us to access and modify data in the dataclass. In general, attribute lookup is just another kind of expression that results in a value, and can be used in math operations, function calls, and return statements like anything else.

Lists

# list[int]
scores = [100, 95, 33, 85]

# list[str]
animals = ["dog", "cat", "gerbil"]

# list
empty = []

The other data structure in this chapter is the list, which is used to combine multiple values of the same type. Lists are created using square brackets with the elements inside separated by commas. The elements should all be of the same type, which becomes the element type of the list. In the example code shown, we have the type that would be used for the list if it were used as a parameter or a return value.

List Operations

animals = ["dog", "cat", "gerbil"]
# Indexing
print(animals[0])
print(animals[-1])
# Subscripting
print(animals[:2])
# Membership test
print("cat" in animals)
print("rabbit" in animals)
# Appending
animals.append("snake")
print(animals)

Much more so than dataclasses, lists introduce many operations that you can use to manipulate and access their contents. Most of the operators are similar to ones we have previously seen with strings. However, there are also methods that need to be used in precise ways when it comes to adding new elements to the list. The list operations we cover in this chapter are indexing, subscripting, membership tests, append, and pop.

Mutability

def add_5(items: list[int], number: int):
    items.append(5)
    number = number + 5

values = [1, 2, 3]
age = 1
print(values, age)

# Only `values` changes!
add_5(values, age)
print(values, age)

In the final section, we confront one of the great challenges associated with our new data structures: mutability. Unlike the primitive types we learned before, both dataclasses and lists are mutable. That means you can manipulate the actual underlying value of the data as opposed to only creating new values in their place. When you modify a string or an integer, you were never actually modifying those values - you were actually creating new values and changing the variable to hold those new values instead. But with lists and dataclasses, the actual data changes. This has serious implications when it comes to functions, which can suddenly manipulate data outside of their scope rules.

Drafter

from drafter import *
from dataclasses import dataclass

@dataclass
class State:
    name: str
    age: int

@route
def index(state: State) -> Page:
    return Page(state, Div(
        TextBox("name", state.name),
        TextBox("age", state.age),
        Button("Change", "update")
    ))

@route
def update(state: State, name: str, age: int) -> Page:
    state.name = name
    state.age = age
    return index(state)

start_server(State("Alice", 30))

This chapter also has two lessons that touch on how composite data structures can be used in Drafter. In fact, Drafter has used dataclasses from the beginning, since all its components (e.g., Page, Div) are actually just dataclasses. However, dataclasses are also useful to manage complex application state when building interactive web apps with the Python Drafter framework (through the drafter convention of a State dataclass). Since dataclasses can contain multiple fields, such as a user’s name and age, routes can read and modify several values at once. Drafter supports both mutable and immutable approaches: you can either change a dataclass instance directly or create a new one with updated data, giving developers flexibility but requiring care to avoid bugs. Later, in the lists half of this chapter, we can return longer, more complicated Page content by returning a list instead of a single component.