Learning Python with Advent of Code Walkthroughs

Dazbo's Advent of Code solutions, written in Python

The Python Journey - Classes and Objects

Useful Links

Python ClassesDataclasses DocumentationUnderstanding DataclassesDecoratorInheritanceEnum

Here I’ll go into the basics of working with classes and objects in Python. This is not an exhaustive treatment, but should be enough to give you the basics, and should be enough to make my AoC solutions easier to follow.

Page Contents

What is a Class? What is an Object?

Think of a class as a blueprint. It is the blueprint of a thing, where that thing has:

So a class is kind of an abstract concept. But we can create an instance of the class; think of it as building something according to the blueprint. The instance is called an object.

Time for an analogy:

Attributes

The Ford Mustang class has some attributes, e.g.

Attributes are just variables. But they are variables that scoped to the class or object. The attributes above would typically be defined as class attributes, because any instance of this class will have these same attributes.

My Ford Mustang has some additional attributes, e.g.

These are called instance attributes or object attributes, because they are unique to my Mustang.

Methods

A Mustang can do some things…

All of these would be implemented as methods. Methods are just functions; but they are functions that are scoped to the object (or class).

Object-Oriented Programming (OOP)

In simplistic terms, this is simply a way of programming where we primarily use classes and objects to represent things, and we interact with classes and objects using the methods they expose.

In OOP, there are some standard concepts:

Defining a Class

Here we use the simple example of a Dog class:

class Dog():
    """ A Dog class """
    
    def __init__(self, name: str) -> None:
        """ How we create an instance of Dog. """s
        self._name = name   # note how this is intended to be a private instance attribute
        
    # Use the @property decorator to provide a public interface to a method, that resembles an attribute.
    # E.g. we can just reference my_dog.name, rather than my_dog.name().
    @property
    def name(self):
        """ The dog's name """
        return self._name
    
    def bark(self):
        return "Woof!"
    
    def __repr__(self) -> str:
        """ Unambiguously identify an instance. """
        return f"Dog(name={self._name})"
    
    def __str__(self) -> str:
        """ Friendly humand-readable representation of the instance """
        return f"Dog {self._name}"

Some notes:

def __repr__(self) -> str:
    return f"{self.__class__.__name__}(name={self._name})"

To use our Dog class:

dog_a = Dog("Fido") # Create a new dog
dog_b = Dog("Henry") # Create another dog

print(dog_a)    # Print using __str__
print(repr(dog_a))  # Print using __repr__
print(f"dog_a is named {dog_a.name}")  # Get the name using the property
print(dog_a.bark())  # Test the bark() method. Note that we don't pass "self". It is implicit.

print(f"dog_b is named {dog_b.name}")
dog_a.bark()
print(dog_a.bark())

The output looks like this:

Dog Fido
Dog(name=Fido)
dog_a is named Fido
Woof!
dog_b is named Henry
Woof!

Instance vs Class

Attributes

Instance attributes belong to a specific instance of a class, i.e. a specific object. Whereas class attributes belong to the class; this value is common across all instances of the class.

Instance attributes are prefixed with self. They should generally be initialised in the __init__() method. If instance attributes will be set in a different method, then consider first initialising them to None in the __init__() method.

Class attributes are defined outside of any method, and do not include a self prefix.

Here’s a couple of scenarios where we might want to use a class attribute:

class MyClass():
    id = 0
    brand = "Foo"
    
    def __init__(self) -> None:
        self.id = MyClass.id
        MyClass.id += 1
        
my_instance_1 = MyClass()
print(f"ID: {my_instance_1.id}, brand: {MyClass.brand}")
my_instance_2 = MyClass()
print(f"ID: {my_instance_2.id}, brand: {MyClass.brand}")

Here’s the output:

ID: 0, brand: Foo
ID: 1, brand: Foo

Methods

Similarly, it is possible for methods to instance, class methods, or even static methods.

Method Type How to Define How to Use Example Use Case
Instance def some_method(self, parms...) From within the class using self.some_method(parms); from outside the class using my_instance.some_method(parms) Methods that need access to instance attributes.
Class def some_method(cls, parms...) and decorate with @classmethod From within or outside the class using MyClass.some_method(parms) Methods that need access only to class attributes. E.g. to implement a factory method.
Static def some_method(parms...) and decorate with @staticmethod From within or outside the class using MyClass.some_method(parms) Methods that have no need to access or modify either class or instance attributes, but are otherwise logically linked to the class. This means that we could actually implement this method outside of the class; but it makes sense to method definition within the class.

Comparing Objects and Hashing

E.g. here I’ll create a Point class, which represents a point in two-dimensional space. A Point is created from an (x,y) coordinate pair:

from __future__ import annotations

class Point():
    """ Point class, which stores x and y """
        
    def __init__(self, x:int, y:int) -> None:
        self._x = x
        self._y = y
        
    @property
    def x(self):
        return self._x
    
    @property
    def y(self):
        return self._y

    def __hash__(self) -> int:
        return hash((self.x, self.y))
    
    def __eq__(self, o: Point) -> bool:
        return self.x == o.x and self.y == o.y
        
    def __repr__(self) -> str:
        return self.__str__()
    
    def __str__(self) -> str:
        return f"({self._x}, {self._y})"
    
# Now let's test our Point class...
point_a = (5, 10)
print(point_a)
point_b = (6, 5)
print(point_b)
point_c = (5, 10)
print(point_c)

print(f"point_a == point_b? {point_a == point_b}")
print(f"point_a == point_c? {point_a == point_c}")

points = set()
points.add(point_a)

if point_b in points:
    print("point_b already in points")
    
if point_c in points:
    print("point_c already in points")

Output:

point_a == point_b? False
point_a == point_c? True
point_c already in points

Note the import of annotations from __future__. This allows us to reference a type that has not yet been defined. E.g. where we reference a Point argument in method definitions in the Point class. Without this import, trying to run the code above results in this:

Traceback (most recent call last):
  File "f:\Users\Darren\localdev\Python\Advent-of-Code\src\snippets\scratch.py", line 3, in <module>
    class Point():
  File "f:\Users\Darren\localdev\Python\Advent-of-Code\src\snippets\scratch.py", line 21, in Point
    def __eq__(self, o: Point) -> bool:
NameError: name 'Point' is not defined

Dataclass

This is a very cool decorator which helps us save some time and effort, by circumventing the need to write lots of repetetive boilerplate code, when we want to create a class that mostly serves the purpose of storing and exposing some data.

Cool things about a dataclass:

To make something a dataclass, simply add @dataclass before the class definition. You will also need to import dataclass.

Now I’ll recreate the above Point class, and call it in exactly the same way, but this time implement as a dataclass. Look how much shorter it is!!

from dataclasses import dataclass

@dataclass
class Point():
    """ Point class, which stores x and y """
    x: int
    y: int

# Now let's test our Point class...
point_a = (5, 10)
print(point_a)
point_b = (6, 5)
print(point_b)
point_c = (5, 10)
print(point_c)

print(f"point_a == point_b? {point_a == point_b}")
print(f"point_a == point_c? {point_a == point_c}")

points = set()
points.add(point_a)

if point_b in points:
    print("point_b already in points")
    
if point_c in points:
    print("point_c already in points")

Similar to using dataclass, Python also has something called a NamedTuple. This allows us to define a an immutable class with only attributes. Thus, a NamedTuple is very similar to a frozen (immutable) dataclass. The dataclass is a lot more powerful and flexible than NamedTuple, so I’d generally always recommend using dataclass over NamedTuple.

Inheritance

Recall that inheritance is an OO concept that allows a class to inherit properties and behaviour from a parent class.

Using our simple analogy again:

The class we inherit from is called a parent class. The class that inherits from this parent class is known as any of: child class, subclass or descendent.

In Python, the syntax for inheriting is:

class MyChildClass(MyParentClass):
    # code

Some general notes on inheritance:

Here’s a simple inheritance example:

class Foo:
    def __init__(self) -> None:
        print("Initialising a Foo")
    
    def do_foo(self):
        print(f"{self.__class__.__name__}: I know how to foo")
    
    def __repr__(self):
        return f"I am a {self.__class__.__name__}"
    
class Bar(Foo):
    def __init__(self) -> None:
        super().__init__()
        print("Initialising a Bar")
        
    def do_bar(self):
        print(f"{self.__class__.__name__}: I know how to bar")
    
    def __repr__(self):
        return f"I am a {self.__class__.__name__}"

print("Let's create a Foo...")
foo = Foo()
print(foo)
foo.do_foo()
# foo.do_bar() - We can't do this!

print("\nLet's create a Bar...")
bar = Bar()
print(bar)
bar.do_bar()
bar.do_foo() # Inherited from parent class!

Here’s the output:

Let's create a Foo...
Initialising a Foo
I am a Foo
Foo: I know how to foo

Let's create a Bar...
Initialising a Foo
Initialising a Bar
I am a Bar
Bar: I know how to bar
Bar: I know how to foo

Factory Pattern

Here I show a useful way to instantiate a class using factory method. In the example below, one factory method instantiates a Shape given a ShapeType (which is itself a subclass of Enum). Another factory method allows us to instantiate a Shape given a set of points.

from __future__ import annotations
from dataclasses import dataclass
from enum import Enum

class ShapeType(Enum):
    HLINE =       {(0, 0), (1, 0), (2, 0), (3, 0)}
    PLUS =        {(1, 0), (0, 1), (1, 1), (2, 1), (1, 2)}
    BACKWARDS_L = {(0, 0), (1, 0), (2, 0), (2, 1), (2, 2)}
    I =           {(0, 0), (0, 1), (0, 2), (0, 3)}
    SQUARE =      {(0, 0), (1, 0), (0, 1), (1, 1)}    

@dataclass(frozen=True)
class Point():
    """ Point with x,y coordinates and knows how to add a vector to create a new Point. """
    x: int
    y: int
    
    def __add__(self, other):
        """ Add other point/vector to this point, returning new point """
        return Point(self.x + other.x, self.y + other.y)     
    
    def __repr__(self) -> str:
        return f"P({self.x},{self.y})"

class Shape():
    """ Stores points that make up this shape. 
    Has a factory method to create Shape instances based on shape type. """
    
    def __init__(self, points: set[Point], at_rest=False) -> None:
        self.points: set[Point] = points   # the points that make up the shape
        self.at_rest = at_rest
    
    @classmethod
    def create_shape_by_type(cls, shape_type: str, origin: Point):
        """ Factory method to create an instance of our shape.
        The shape points are offset by the supplied origin. """
        return cls({(Point(*coords) + origin) for coords in ShapeType[shape_type].value})

    @classmethod
    def create_shape_from_points(cls, points: set[Point], at_rest=False):
        """ Factory method to create an instance of our shape.
        The shape points are offset by the supplied origin. """
        return cls(points, at_rest)
    
    def __str__(self):
        return f"Shape:({self.points})"

start = Point(0,0)
my_shape = Shape.create_shape_by_type(ShapeType.PLUS.name, start)
print(my_shape)

Examples