Dazbo's Advent of Code solutions, written in Python
Python ClassesDataclasses DocumentationUnderstanding DataclassesDecorator
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.
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:
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.
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).
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:
Vehicle
is a very abstract thing. It can accelerate, decelerate, and turn.Car
is a subclass (i.e. inherits from or extends) Vehicle. A Car
adds some additional attributes and methods. E.g. a Car
has four wheels. Whereas a Bike
has two.Mustang
is a subclass (i.e. inherits from or extends) of Car
. It adds some additional attributes and methods. For example, it has two doors. It is made by Ford.Mustang 5.0GT
is a subclass (i.e. inherits from or extends) of Mustang
. It adds some additional attributes and methods. For example, it has a 5L V8 engine._
(underscore) character.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.
Args:
name (str): The name of the dog
"""
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:
class SomeClassName():
my_variable_name
), upper camel case is used for class names (e.g. MyClassName
).self
, in the class.self
as the first argument. However, when we call these methods, the self
is implicit.__init__()
method to initialise any new instances of a class. I.e. whenever we create a new Dog
, this is the method that gets called. We can define which attributes are required (or are optional) to the initialiser.__init__()
, we expect a single explicit parameter to be passed, which is the name
of the Dog
. We’re using type hinting to tell the Python compiler that the name
argument should be a str
. If we try to run code that passes anything else, our linter will warn us we’ve probably done something wrong.__init__()
method initialises a single instance variable when an instance (object) is created. This is the _name
instance variable. Note that it is intended to be a private variable.name()
which returns the name of a Dog instance. However, to make it easier to get our Dog’s name, we can use the @property
decorator to expose the name as if it were a public attribute.__repr__()
method that can be used to unambiguously identify a given instance. This can be useful in debugging.__str__()
method, which is used to generate a friendly, human-readable representation of our Dog
.To exercise 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!
==
, we need to implement the __eq__()
method.if thing in things
- then we also need to implement the __hash__()
method. This should always return a different int
value for any unequal instances of immutable objects. (Mutable objects are unhashable). Common ways to achieve such as hash include:
hash
of a tuple
(since tuples are immutable) of some instance attributes.hash
of the __repr__()
, assuming that __repr__()
returns a unique value for different objects.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
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
:
__init__()
method is created implicitly for us. All we need to do is define the variables that we want to be initialised when the object is created.dataclass
as immutable by simply adding frozen=True
. If we then try to change an instance variable, a TypeError
will be thrown.eq=True
(which is the default), then an implicit __eq__()
method is created for us, which compares objects by generating a tuple
from all its fields. But we can even specify which fields to include in the comparison, and which to ignore!eq=True
and frozen=True
, then an implicit __hash__()
method is created for us.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
.