Lecture 17: Methods and Designing Classes¶
Last time we built our first classes (Yarn, KnittingNeedle, KnittingProject). Today we go deeper into making classes feel like natural Python types, and introduce one of the most powerful ideas in OOP: inheritance.
By the end of this lecture, you should be able to:
- Write
__eq__and__gt__to control how objects compare - Use class variables vs instance attributes appropriately
- Understand inheritance: creating a new class from an existing one
- Use
super().__init__()and method overriding
Recap From Last Lecture¶
We mostly worked with this Yarn class:
class Yarn:
def __init__(self, color, weight, meters):
self.color = color
self.weight = weight
self.meters = meters
def __str__(self):
return f"{self.color} {self.weight} yarn ({self.meters}m)"
We can create Yarn objects and print them.
yarn_a = Yarn("red", "worsted", 200)
yarn_b = Yarn("red", "worsted", 200)
print(yarn_a)
print(yarn_b)
red worsted yarn (200m) red worsted yarn (200m)
They look the same! They do hold the same data. But what happens when we try to compare two yarns?
print(yarn_a == yarn_b) # What do you expect?
print(yarn_a is yarn_b) # What about this?
Is this what you expected?
By default, == on custom objects behaves like is. is checks whether they are the same object in memory through the id(), not whether they have the same contents. Since yarn_a and yarn_b were created separately, they are different objects, so == returns False.
class Yarn:
def __init__(self, color, weight, meters):
self.color = color
self.weight = weight
self.meters = meters
def __str__(self):
return f"{self.color} {self.weight} yarn ({self.meters}m)"
def __eq__(self, other):
"""Two yarns are equal if they have the same color and weight."""
return self.color == other.color and self.weight == other.weight
Notice that __eq__ receives a second parameter other, which is the object on the right side of ==.
yarn_a = Yarn("red", "worsted", 200)
yarn_b = Yarn("red", "worsted", 400) # different meters, same color/weight
yarn_c = Yarn("blue", "worsted", 200)
print(yarn_a == yarn_b) # True (same color + weight)
print(yarn_a == yarn_c) # False (different color)
print(yarn_a is yarn_b) # False (still different objects)
True False False
We decided that two yarns are "equal" if they have the same color and weight, regardless of how many meters are left. This is a design choice! You could make a different choice and define equality differently depending on what makes sense for your program.
Also note that when we define __eq__, Python automatically gives us != (not equal) for free because it just returns the opposite of __eq__.
print(yarn_a != yarn_c) # True (different color, so not equal)
True
Ordering Objects: __gt__ and __lt__¶
What if we want to compare which yarn is "greater" than another? For instance, we might want to sort yarns by weight (from finest to bulkiest).
We can define a special method called __gt__ (greater than) to enable the > operator:
class Yarn:
STANDARD_WEIGHTS = ["lace", "super fine", "fine", "light", "medium", "bulky", "super bulky", "jumbo"]
def __init__(self, color, weight, meters):
self.color = color
self.weight = weight
self.meters = meters
def __str__(self):
return f"{self.color} {self.weight} yarn ({self.meters}m)"
def __eq__(self, other):
"""Two yarns are equal if they have the same color and weight."""
return self.color == other.color and self.weight == other.weight
def weight_index(self):
"""Return the position of this yarn's weight in the standard list (lower = finer)."""
return Yarn.STANDARD_WEIGHTS.index(self.weight.lower())
def __gt__(self, other):
"""A yarn is 'greater' if it has a heavier (bulkier) weight."""
return self.weight_index() > other.weight_index()
Notice that we also snuck in STANDARD_WEIGHTS at the top of the class; we'll talk more about this in the next section. But the important thing here is that __gt__ lets us use > with our objects.
y1 = Yarn("blue", "fine", 400)
y2 = Yarn("red", "medium", 200)
y3 = Yarn("green", "bulky", 100)
print(f"{y2} > {y1}? {y2 > y1}") # True — worsted is bulkier than fingering
print(f"{y1} > {y3}? {y1 > y3}") # False — fingering is finer than bulky
red medium yarn (200m) > blue fine yarn (400m)? True blue fine yarn (400m) > green bulky yarn (100m)? False
Once we have __gt__, Python can also automatically figure out < (less than) by flipping the arguments. So y1 < y2 works even though we didn't write a __lt__ method!
print(f"{y1} < {y2}? {y1 < y2}") # True — Python flips to y2 > y1
# We can even sort a list of yarns!
yarns = [y3, y1, y2]
yarns.sort() # uses __gt__ / __lt__ under the hood
for y in yarns:
print(y)
blue fine yarn (400m) < red medium yarn (200m)? True blue fine yarn (400m) red medium yarn (200m) green bulky yarn (100m)
And the sort() come for free just like sorting a list of numbers that Python already knows know to compare.
So in general, if we want to make the class we define more like a built-in type in Python, we can just define more of these special methods that work with Python built-in functions!
Exercise 1: Add __eq__ and __gt__ to KnittingNeedle¶
Let's take the KnittingNeedle class from last lecture. Define equality for KnittingNeedle objects (same size and material), and make them orderable by size (larger mm = "greater"). Then test that you can sort a list of needles.
n1 = KnittingNeedle(3.5, "bamboo")
n2 = KnittingNeedle(5.0, "metal")
n3 = KnittingNeedle(3.5, "bamboo")
print(n1 == n3) # True
print(n2 > n1) # True
class KnittingNeedle:
"""Represents a pair of knitting needles."""
def __init__(self, size_mm, material="bamboo"):
self.size_mm = size_mm
self.material = material
def __str__(self):
return f"{self.size_mm}mm {self.material} needles"
# TODO: add __eq__ and __gt__
# Test your implementation
n1 = KnittingNeedle(3.5, "bamboo")
n2 = KnittingNeedle(5.0, "metal")
n3 = KnittingNeedle(3.5, "bamboo")
print(n1 == n3) # should be True
print(n2 > n1) # should be True
# Try sorting!
needles = [KnittingNeedle(5.0), KnittingNeedle(2.5), KnittingNeedle(8.0), KnittingNeedle(3.5)]
needles.sort()
for n in needles:
print(n)
Class Variables vs Instance Attributes¶
You may have noticed STANDARD_WEIGHTS sitting at the top of the Yarn class, outside of any method. This is a class variable, which means that it's shared by ALL Yarn objects rather than being unique to each one.
| Class Variable | Instance Attribute | |
|---|---|---|
| Defined | Inside class, outside any method | Inside __init__ (or other method) using self. |
| Shared? | Yes — one copy for all instances | No — each object has its own copy |
| Access | ClassName.variable or self.variable |
self.attribute only |
| Use case | Constants, shared lookup tables, counters | Per-object data |
class Yarn:
# Class variable. This is shared by ALL Yarn objects.
STANDARD_WEIGHTS = ["lace", "super fine", "fine", "light", "medium", "bulky", "super bulky", "jumbo"]
def __init__(self, color, weight, meters):
# Instance attributes. These are unique to each object.
self.color = color
self.meters = meters
# Validate against the class variable
if weight not in Yarn.STANDARD_WEIGHTS:
print(f"Warning: '{weight}' is not a standard yarn weight.")
self.weight = weight
def __str__(self):
return f"{self.color} {self.weight} yarn ({self.meters}m)"
We can access the class variable directly on the class:
print("Standard weights:", Yarn.STANDARD_WEIGHTS)
Standard weights: ['lace', 'super fine', 'fine', 'light', 'medium', 'bulky', 'super bulky', 'jumbo']
Or through class instances:
y1 = Yarn("blue", "super fine", 400)
y2 = Yarn("red", "medium", 200)
print(y1.STANDARD_WEIGHTS is y2.STANDARD_WEIGHTS) # True, they are the same object!
True
In addition to the indexing, we can also use it to check whether the weight is a valid one.
# Invalid weight triggers the warning
y3 = Yarn("green", "chunky", 150)
Warning: 'chunky' is not a standard yarn weight.
Another Use Case: Counting Instances¶
Class variables can be used to track information across all objects. This is helpful for example if you need to assign unique ids to objects created, and you can easily use the count as that ids. For example, counting how many Yarn objects have been created:
class Yarn:
STANDARD_WEIGHTS = ["lace", "super fine", "fine", "light", "medium", "bulky", "super bulky", "jumbo"]
count = 0 # class variable to track how many Yarns exist
def __init__(self, color, weight, meters):
self.color = color
self.weight = weight
self.meters = meters
self.id = Yarn.count # set the unique id
Yarn.count += 1 # increment the shared counter
def __str__(self):
return f"{self.color} {self.weight} yarn ({self.meters}m)"
So now we can count how many Yarn objects have been created:
print(f"Yarns created so far: {Yarn.count}") # 0
y1 = Yarn("red", "worsted", 200)
print(y1.id)
y2 = Yarn("blue", "fingering", 400)
print(y2.id)
y3 = Yarn("green", "bulky", 100)
print(y3.id)
print(f"Yarns created so far: {Yarn.count}") # 3
Yarns created so far: 0 0 1 2 Yarns created so far: 3
Notice how we used all caps with underscores when the class variable is a constant, and the regular lower case with underscores when that variable is not.
Poll Time!¶
Let's check our understanding on class variables and instance attributes.
class NeedleBed:
MAX_NEEDLES = 250
def __init__(self, side, num_needles):
self.side = side
self.num_needles = num_needles
bed1 = NeedleBed("front", 40)
bed2 = NeedleBed("back", 40)
Given the above code snippet, which of the options is True?
class NeedleBed:
MAX_NEEDLES = 250
def __init__(self, side, num_needles):
self.side = side
self.num_needles = num_needles
bed1 = NeedleBed("front", 40)
bed2 = NeedleBed("back", 40)
print(bed1.MAX_NEEDLES is bed2.MAX_NEEDLES)
print(bed1.side is bed2.side)
print(bed1.num_needles is bed2.num_needles)
Click to reveal answer...
bed1.MAX_NEEDLES is bed2.MAX_NEEDLES because MAX_NEEDLES is a class variable so it's literally the same object for both.
bed1.num_needles is bed2.num_needles because 40 is the same as 40.
The side instance attributes are different so not all of the above are true.
Inheritance: Building on Existing Classes¶
So far each class we've written starts from scratch. But what if we want a class that's almost like an existing one, with a few additions or changes? It's kind of like a base product or design might then have variations.
This is where inheritance comes in. A child class (also called a subclass) inherits all the attributes and methods of a parent class (also called a superclass), and can add or override them.
Think about knitting machine needles. Every needle has a position and belongs to a bed (front or back). But some needles are slider needles with an extra sliding latch mechanism that can hold loops separately from the main hook. A slider needle is a special kind of needle.
So the rule of thumb is parent class is more general, and child class is more specific.
A Base Needle Class¶
Let's start with a basic Needle class that models a single needle on a knitting machine:
class Needle:
"""Represents a single needle on a knitting machine."""
def __init__(self, bed, position):
"""
bed: 'f' (front) or 'b' (back)
position: integer needle position (e.g. 0, 1, 2, ...)
"""
assert bed in ('f', 'b'), f"bed must be 'f' or 'b', got '{bed}'"
self.bed = bed
self.position = position
self.loops = [] # list of yarn colors currently held on this needle
def __str__(self):
return f"{self.bed}{self.position}"
def __repr__(self):
return f"Needle({self.bed!r}, {self.position}, loops={self.loops})"
def __eq__(self, other):
"""Two needles are equal if they're on the same bed and position."""
return self.bed == other.bed and self.position == other.position
def is_empty(self):
"""Return True if no loops are held on this needle."""
return len(self.loops) == 0
def add_loop(self, color):
"""Add a loop of yarn to this needle."""
self.loops.append(color)
def drop_loops(self):
"""Drop all loops from this needle. Returns the dropped loops."""
dropped = self.loops[:] # this makes a copy of the list
self.loops = []
return dropped
def can_hold(self):
"""Return True if this needle can currently accept a loop."""
return len(self.loops) < 4
Let's check out the behavior of this Needle class:
n = Needle('f', 5)
print(n) # f5
print(n.is_empty()) # True, no loops yet
n.add_loop("red")
n.add_loop("blue")
print(repr(n)) # Needle('f', 5, loops=['red', 'blue'])
print(n.can_hold()) # True because only two loops on there
f5
True
Needle('f', 5, loops=['red', 'blue'])
True
Creating a Child Class: SliderNeedle¶
Now let's create the child class for slider needles. A slider needle is a needle with an extra slider part. The slider can hold loops independently from the main hook. We can model this by inheriting from Needle:
class SliderNeedle(Needle): # <-- SliderNeedle inherits from Needle
The syntax is class ChildClass(ParentClass). Here, the (Needle) part says: "SliderNeedle is a kind of Needle. It gets everything Needle has, and can add more."
class SliderNeedle(Needle):
"""A needle with a slider latch that can hold loops separately."""
MAX_SLIDER_LOOPS = 1 # class variable: slider can hold at most 1 loop
def __init__(self, bed, position):
# Call the parent class's __init__ to set up bed, position, loops
super().__init__(bed, position)
# Add a new attribute specific to SliderNeedle
self.slider_loops = []
def __str__(self):
# Override: slider needles show with 's' suffix
return f"{self.bed}s{self.position}"
def __repr__(self):
return f"SliderNeedle({self.bed!r}, {self.position}, loops={self.loops}, slider={self.slider_loops})"
def add_to_slider(self, color):
"""Add a loop to the slider part (not the main hook)."""
if len(self.slider_loops) >= SliderNeedle.MAX_SLIDER_LOOPS:
print(f"Warning: slider on {self} is full!")
else:
self.slider_loops.append(color)
def can_hold(self):
"""Override: slider needles can only hold if slider is not full."""
return len(self.slider_loops) < SliderNeedle.MAX_SLIDER_LOOPS
Let's break down what's happening:
class SliderNeedle(Needle)— declares thatSliderNeedleinherits fromNeedle.super().__init__(bed, position)— calls the parent's__init__method. This sets upself.bed,self.position, andself.loopsexactly like a regularNeedle. We don't have to rewrite that code!self.slider_loops = []— adds a new attribute that onlySliderNeedleobjects have.__str__is overridden — slider needles display asfs5instead off5.can_hold()is overridden — the parent returnsTrueif the number of loops is less than 4; the child checks the slider capacity. This is called polymorphism: the same method name behaves differently depending on the object's type.is_empty(),add_loop(),drop_loops()— inherited as-is fromNeedle. We don't need to rewrite them!
So let's look at the slider needle behavior:
sn = SliderNeedle('f', 5)
print(sn) # fs5, different from needle because __str__ is overriden
print(sn.is_empty()) # True, this is inherited from Needle! No new code
# Use inherited method
sn.add_loop("red")
print(repr(sn)) # SliderNeedle('f', 5, loops=['red'], slider=[])
# Use new method that didn't exist in Needle
sn.add_to_slider("blue")
print(repr(sn)) # SliderNeedle('f', 5, loops=['red'], slider=['blue'])
# Overridden method
print(sn.can_hold()) # False, slider is full
sn.add_to_slider("green") # Warning: slider is full!
fs5
True
SliderNeedle('f', 5, loops=['red'], slider=[])
SliderNeedle('f', 5, loops=['red'], slider=['blue'])
False
Warning: slider on fs5 is full!
So children can use any methods defined in parent, but not the other way around.
n.add_to_slider("green")
--------------------------------------------------------------------------- AttributeError Traceback (most recent call last) Cell In[35], line 1 ----> 1 n.add_to_slider("green") AttributeError: 'Needle' object has no attribute 'add_to_slider'
Checking Inheritance with isinstance¶
Python provides isinstance() to check if an object belongs to a class (or any of its parent classes). This is helpful because then we can check if an object is of a specific class to know what methods are available (either through the class itself or its parent class).
n = Needle('f', 5)
sn = SliderNeedle('b', 3)
print(isinstance(n, Needle)) # True — n IS a Needle
print(isinstance(sn, Needle)) # True — SliderNeedle IS a Needle too!
print(isinstance(sn, SliderNeedle)) # True — sn IS a SliderNeedle
print(isinstance(n, SliderNeedle)) # False — a plain Needle is NOT a SliderNeedle
True True True False
Another Python built-in type has similar functionality but is more strict:
print(type(sn) == Needle) # False — type is SliderNeedle, not Needle
print(type(sn) == SliderNeedle) # True
False True
Why Inheritance Matters¶
Without inheritance, we'd have to copy-paste all of Needle's code into SliderNeedle and then add/change things. If we later fix a bug in Needle.drop_loops(), we'd have to fix it in SliderNeedle too!
With inheritance, we achieve the following:
- Reuse:
SliderNeedleautomatically getsis_empty(),add_loop(),drop_loops(),__eq__()fromNeedle. - Override:
SliderNeedleprovides its own__str__()andcan_hold()to change behavior. - Extend:
SliderNeedleaddsslider_loopsandadd_to_slider()thatNeedledoesn't have.
Exercise 2: Create a DyedYarn subclass¶
Create a DyedYarn class that inherits from Yarn. A dyed yarn has everything a regular yarn has, plus a dye_method attribute (e.g., "hand-dyed", "kettle-dyed", "space-dyed").
Requirements:
- Use
super().__init__()to initialize the base yarn attributes - Override
__str__to include the dye method __eq__should still work — two dyed yarns are equal if they have the same color, weight, AND dye method
dy = DyedYarn("teal", "fine", 400, "hand-dyed")
print(dy) # teal fine yarn (400m) [hand-dyed]
class Yarn:
STANDARD_WEIGHTS = ["lace", "super fine", "fine", "light", "medium", "bulky", "super bulky", "jumbo"]
def __init__(self, color, weight, meters):
self.color = color
self.weight = weight
self.meters = meters
def __str__(self):
return f"{self.color} {self.weight} yarn ({self.meters}m)"
def __eq__(self, other):
return self.color == other.color and self.weight == other.weight
class DyedYarn(Yarn):
# TODO: implement this class
pass
# Test your DyedYarn
dy1 = DyedYarn("teal", "fine", 400, "hand-dyed")
dy2 = DyedYarn("teal", "fine", 300, "kettle-dyed")
y = Yarn("teal", "fine", 400)
print(dy1) # teal fine yarn (400m) [hand-dyed]
print(dy1 == dy2) # should be False (different dye method)
print(isinstance(dy1, Yarn)) # True — DyedYarn IS a Yarn
Exercise 3: Overriding Behavior¶
In half-gauge knitting, you only use every other needle on the bed (the even-numbered positions). This gives more space between needles, which is useful for thicker yarns or tubular knitting with textures.
Create a HalfGaugeNeedleBed subclass of NeedleBed that:
- Uses
super().__init__()to reuse the parent's setup - Overrides
set_needleto only allow even-numbered positions (raise anAssertionErrorfor odd positions) - Overrides
__str__to show the half-gauge spacing clearly (e.g.,O . O . . .with spaces between positions)
class NeedleBed:
"""Represents one needle bed of a knitting machine."""
def __init__(self, direction, num_needles):
self.direction = direction
self.num_needles = num_needles
self.needles = [False] * num_needles
def __str__(self):
symbols = ['O' if n else '.' for n in self.needles]
return f"{self.direction.upper()}: {''.join(symbols)}"
def set_needle(self, index, occupied=True):
if 0 <= index < self.num_needles:
self.needles[index] = occupied
class HalfGaugeNeedleBed(NeedleBed):
# TODO: implement this
pass
# Test
full = NeedleBed("front", 20)
half = HalfGaugeNeedleBed("front", 10)
full.set_needle(0)
full.set_needle(1)
half.set_needle(0)
half.set_needle(2)
half.set_needle(4)
print(full) # FRONT: OO..................
print(half) # FRONT: O . O . O . . . . .
# This should raise an error because odd positions not allowed in half gauge!
half.set_needle(3)
Summary¶
Today's key ideas:
__eq__lets you define what==means for your objects__gt__lets you use>and evensort()because Python can derive<from it- Class variables are shared across all instances; instance attributes are per-object
- Inheritance lets you build a new class from an existing one:
super().__init__()calls the parent's initializer- Override methods to change behavior
- Extend with new attributes and methods
isinstance()checks if an object belongs to a class or its parents
After today, you are ready to tackle HW5! In the next lecture, we will go over Testing and Debugging OOP to learn a few more strategies in testing, and go through how assert works which is useful for HW5.