Lecture 16: Classes and Objects¶
Welcome back after a series of knitting content! Today we resume our journey with programming and enter Object-Oriented Programming (OOP), one of the most powerful ideas in programming.
By the end of this lecture, you should be able to:
- Understand what a class is and what an object (instance) is
- Write a class with
__init__and__str__methods - Create objects and access their attributes
- Recognize how OOP maps naturally onto knitting machines and yarn
Why Objects?¶
Python in fact is an object-oriented language; all entities in Python are objects, including numbers, strings, etc. What do we mean by objects? Essentially, they are containers for both data and how the data can be interacted with.
So far, we've stored data in variables and lists, and written separate functions to work on them. For example, to represent a yarn:
yarn_color = "red"
yarn_weight = "worsted"
yarn_meters = 200
def describe_yarn(color, weight, meters):
return f"{color} {weight} yarn, {meters}m"
This works, but notice that the function and the data are just floating around separately. What if we have 10 different yarns? Then do we need copy paste this code 10 times and then keep track of the variables separately?
yarn_color_1 = "red"
yarn_weight_1 = "worsted"
yarn_meters_1 = 200
yarn_color_2 = "blue"
yarn_weight_2 = "lace"
yarn_meters_2 = 400
# ... and so on for 10 different yarns
If you remember from the lecture on dictionary, using multiple lists has the drawback of being inflexible to map between these values:
# Using multiple lists
yarn_colors = ["navy blue", "red", "forest green", "beige", "pink"]
yarn_weights = ["fingering", "worsted", "bulky", "worsted", "lace"]
yarn_meters = [400, 200, 100, 300, 300]
# If we want to know the meters of the "red" yarn, we need to do the following:
index = yarn_colors.index("red")
print(yarn_meters[index])
# If we want to know the color of the yarn with 300 meters and is also worsted, we need to do the following:
index = yarn_meters.index(300)
if yarn_weights[index] == "worsted":
print(yarn_colors[index])
200 beige
but if we use dictionary in this case, multiple mappings for every pair of variables would be too cumbersome:
color_to_meters = {}
for i in range(len(yarn_colors)):
color_to_meters[yarn_colors[i]] = yarn_meters[i]
weight_to_color = {}
for i in range(len(yarn_weights)):
weight_to_color[yarn_weights[i]] = yarn_colors[i]
meters_to_weight = {}
for i in range(len(yarn_meters)):
meters_to_weight[yarn_meters[i]] = yarn_weights[i]
# can define the 3 reverse mapping as well
meters_to_color = {}
for i in range(len(yarn_meters)):
meters_to_color[yarn_meters[i]] = yarn_colors[i]
color_to_weight = {}
for i in range(len(yarn_colors)):
color_to_weight[yarn_colors[i]] = yarn_weights[i]
weight_to_meters = {}
for i in range(len(yarn_weights)):
weight_to_meters[yarn_weights[i]] = yarn_meters[i]
# Then if we try to use the maps to retrieve the information:
print(color_to_meters["red"])
print(weight_to_color[meters_to_weight[300]]) # note how this is actually different, because the mapping is not one-to-one
200 pink
Now what if we want to modify the meters value of the yarn that is "worsted" and "red"? We'd need to go through the dictionaries and modify specific entries, both keys and values. Too much trouble!
The Core Idea: Bundling Data with Behavior¶
So this is why we need objects. Objects let us bundle the data (attributes) and the functions that work on it (methods) together into one neat package.
Defining a Class: Yarn¶
Let's build a Yarn class that captures those same information as above. A class is like a blueprint: it describes what every Yarn object will look like and be able to do.
class Yarn:
"""Represents a ball of yarn with a color, weight, and length in meters."""
def __init__(self, color, weight, meters):
"""Initialize a Yarn object with its properties."""
self.color = color
self.weight = weight
self.meters = meters
def __str__(self):
"""Return a human-readable description of this yarn."""
return f"{self.color} {self.weight} yarn ({self.meters}m)"
What's happening here?¶
class Yarn:- defines a new type called
Yarn; the docstring below is the "class documentation".
- defines a new type called
__init__- the initializer method; Python calls it automatically when you create a new object. The parameters become attributes stored on
self.
- the initializer method; Python calls it automatically when you create a new object. The parameters become attributes stored on
self- refers to the specific object being created or used. Think of it as "this particular Yarn".
__str__- what Python calls when you
print()an object or convert it to a string.
- what Python calls when you
Methods like __init__ and __str__ whose names start and end with double underscores are called special methods (sometimes "dunder methods"). Of course, we can also define "non-special" methods and we will talk about that later.
help(Yarn)
Help on class Yarn in module __main__: class Yarn(builtins.object) | Yarn(color, weight, meters) | | Represents a ball of yarn with a color, weight, and length in meters. | | Methods defined here: | | __init__(self, color, weight, meters) | Initialize a Yarn object with its properties. | | __str__(self) | Return a human-readable description of this yarn. | | ---------------------------------------------------------------------- | Data descriptors defined here: | | __dict__ | dictionary for instance variables | | __weakref__ | list of weak references to the object
Methods vs Functions¶
Of course, these special methods are actually functions in the usual sense. But they belong to a class now, and therefore are termed "methods". So the only thing that makes a function a method is that it's defined as part of a class definition (see how the help of the Yarn class include the method signatures). The special methods can be accessed with the dot notation:
help(Yarn.__init__)
help(Yarn.__str__)
Help on function __init__ in module __main__:
__init__(self, color, weight, meters)
Initialize a Yarn object with its properties.
Help on function __str__ in module __main__:
__str__(self)
Return a human-readable description of this yarn.
Creating Objects (Instances)¶
To create an object, call the class like a function. You can see how it should be defined by reading the first few lines in the class help text:
class Yarn(builtins.object)
| Yarn(color, weight, meters)
|
| Represents a ball of yarn with a color, weight, and length in meters.
...
# Creating two Yarn objects
yarn1 = Yarn("red", "worsted", 200)
yarn2 = Yarn("navy blue", "lace", 400)
print(yarn1)
print(yarn2)
red worsted yarn (200m) navy blue lace yarn (400m)
Each time we call Yarn(...), Python creates a new, independent object. yarn1 and yarn2 are separate instances of the Yarn class.
Poll Time!¶
When you run yarn = Yarn("red", "worsted", 100), what best describes what Python does with __init__ and the new object?
Explanation here...
What actually happens when you write: ```python yarn1 = Yarn("red", "worsted", 100) ``` is roughly:Construct an instance: Python calls
Yarn.__new__(Yarn)(we did not define__new__, so we get the default from the object). That returns a new, emptyYarninstance.Initialize it: Python then calls
__init__on that instance with the rest of the arguments.<new instance>.__init__("red", "worsted", 100)
Under the hood that is equivalent to passing the new object as the first argument:
Yarn.__init__(<that new instance>, "red", "worsted", 100)
Accessing Attributes and Methods¶
You can access an object's attributes using dot notation: object_instance.attribute_name.
print(yarn1.color)
print(yarn1.meters)
print(yarn2.weight)
red 200 lace
The same dot notation works for methods as well: object_instance.method_name.
yarn1.__init__
<bound method Yarn.__init__ of <__main__.Yarn object at 0x107aab230>>
And here's an interesting distinction when you access the methods of the class from an object of that class versus from that class:
Yarn.__init__
<function __main__.Yarn.__init__(self, color, weight, meters)>
That is because the method for an object of that class is like a thin rapper around the actual method (function definition); we can further go to the function that is being wrapped around:
yarn1.__init__.__func__
<function __main__.Yarn.__init__(self, color, weight, meters)>
And see that they are equal:
yarn1.__init__.__func__ == Yarn.__init__
True
Exercise: Explore the Yarn class¶
Write some code to answer the following questions:
- What is the
typeofyarn1? (usetype(yarn1)) - What happens if you print
yarn1without the__str__method defined? (Try commenting the method out and run that cell again to re-define theYarnclass before creating aYarninstance and printing again.) - Create a third yarn of your own and print it.
class Yarn:
"""Represents a ball of yarn with a color, weight, and length in meters."""
def __init__(self, color, weight, meters):
"""Initialize a Yarn object with its properties."""
self.color = color
self.weight = weight
self.meters = meters
def __str__(self):
"""Return a human-readable description of this yarn."""
return f"{self.color} {self.weight} yarn ({self.meters}m)"
# Your exploration here
Defining Methods for a Class¶
Objects often represent things in the real world, and methods often correspond to the ways things in the real world interact. Let's now define some behavior that might happen to the Yarn objects.
class Yarn:
"""Represents a ball of yarn with a color, weight, and length in meters."""
def __init__(self, color, weight, meters):
"""Initialize a Yarn object with its properties."""
self.color = color
self.weight = weight
self.meters = meters
def __str__(self):
"""Return a human-readable description of this yarn."""
return f"{self.color} {self.weight} yarn ({self.meters}m)"
def _has_enough(self, needed):
"""Return True if this yarn has at least `needed` meters."""
return self.meters >= needed
def use(self, amount):
"""Use `amount` meters from this yarn ball (modifies in place)."""
if self._has_enough(amount):
self.meters -= amount
print(f"Used {amount}m. {self.meters}m remaining.")
else:
print(f"Not enough yarn! Only {self.meters}m left.")
This defines that Yarn represents a ball of yarn that can be used, and the object will keep track of whether that ball of yarn still has enough. For example, we can do the following:
yarn = Yarn("scarlet", "worsted", 200)
yarn.use(80)
print(yarn) # meters should now be 120
yarn.use(80)
print(yarn) # meters should now be 40
yarn.use(80)
print(yarn) # meters should still be 40
Used 80m. 120m remaining. scarlet worsted yarn (120m) Used 80m. 40m remaining. scarlet worsted yarn (40m) Not enough yarn! Only 40m left. scarlet worsted yarn (40m)
What's happening here?¶
Some observations here:
We first created a
Yarninstanceyarn.We then called
usemethods on the instanceyarnfor three times. Theyarngot placed into the method call asself.The
_has_enoughmethod starts with an underscore, whileusedoes not. Methods with names starting with an underscore indicate that they are private methods, private to this class, and therefore is not generally used by users.usedoes not have the underscore, and it's understood as a public method. In general, public methods are how we typically interact with objects, while private methods are not used. But there's no actual limitation on using private methods. So you can still call_has_enough()on theyarnobject.
# can still do it, but not recommended
yarn._has_enough(80)
False
This is an implicit convention respected by Python programmers.
Exercise: Add a method to Yarn class¶
Add a method called dye(new_color) to the Yarn class that changes the color of the yarn. Then test it:
y = Yarn("natural", "aran", 300)
y.dye("indigo")
print(y) # should print: indigo aran yarn (300m)
class Yarn:
"""Represents a ball of yarn with a color, weight, and length in meters."""
def __init__(self, color, weight, meters):
"""Initialize a Yarn object with its properties."""
self.color = color
self.weight = weight
self.meters = meters
def __str__(self):
"""Return a human-readable description of this yarn."""
return f"{self.color} {self.weight} yarn ({self.meters}m)"
def _has_enough(self, needed):
"""Return True if this yarn has at least `needed` meters."""
return self.meters >= needed
def use(self, amount):
"""Use `amount` meters from this yarn ball (modifies in place)."""
if self._has_enough(amount):
self.meters -= amount
print(f"Used {amount}m. {self.meters}m remaining.")
else:
print(f"Not enough yarn! Only {self.meters}m left.")
# TODO: add a dye() method here
# Test your dye method
y = Yarn("natural", "aran", 300)
y.dye("indigo")
print(y)
A More Complex Class: KnittingNeedle¶
Let's model something more specific to our domain:
class KnittingNeedle:
"""Represents a pair of knitting needles."""
def __init__(self, size_mm, material="bamboo"):
"""
size_mm: needle diameter in millimeters (e.g., 3.5, 5.0)
material: material type (default 'bamboo')
"""
self.size_mm = size_mm
self.material = material
def __str__(self):
return f"{self.size_mm}mm {self.material} needles"
def recommended_yarn_weight(self):
"""Return a recommended yarn weight based on needle size."""
if self.size_mm <= 2.5:
return "lace"
elif self.size_mm <= 3.5:
return "fingering"
elif self.size_mm <= 5.0:
return "worsted"
elif self.size_mm <= 7.0:
return "bulky"
else:
return "super bulky"
def is_compatible(self, yarn):
"""Check if this needle is compatible with the given Yarn."""
return self.recommended_yarn_weight() == yarn.weight
Side Note: Required / Optional Parameters¶
There is one thing that we did not explicitly explain before: parameters with default values.
def __init__(self, size_mm, material="bamboo"):
size_mm has no default: every call must provide a value for it. material="bamboo" means material has a default of "bamboo". If the caller leaves that argument out, Python fills in "bamboo" automatically. So size_mm is required; material is optional (in everyday terms).
size_mm is also a positional parameter, and material is a keyword parameter. These words describe how you pass arguments when you call the function. For example, all of these are valid:
KnittingNeedle(5.0)- only the required argument;
materialbecomes"bamboo".
- only the required argument;
KnittingNeedle(5.0, "metal")- second value passed positionally;
materialis"metal".
- second value passed positionally;
KnittingNeedle(5.0, material="metal")- same effect, but the optional argument is passed by keyword (name
=value). Keyword arguments are handy when there are several optional parameters, because you do not have to remember their order. For example, if you have a function that receives two keyword argumentsaandb,func(a=1,b=2)andfunc(b=2,a=1)both work because the order doesn't matter here with the keywords specified.
- same effect, but the optional argument is passed by keyword (name
Why order matters in the definition. Parameters without defaults must come before parameters with defaults, and must remain in the same order that they are defined in the function definition. That way Python can match positional arguments left to right unambiguously.
So: defaults make an argument skippable; keyword passing is an optional style at the time of calling the function for clarity and flexibility.
Back to the KnittingNeedle class...¶
Now let's actually use this class:
needle = KnittingNeedle(5.0)
print(needle)
print(needle.recommended_yarn_weight())
yarn = Yarn("scarlet", "worsted", 200)
print(needle.is_compatible(yarn)) # True
thin_yarn = Yarn("cream", "lace", 800)
print(needle.is_compatible(thin_yarn)) # False
5.0mm bamboo needles worsted True False
Exercise: Object interaction¶
Now let's try to write a function make_project(yarn, needle, name) (not a method! just a regular function) that interacts with both Yarn and KnittingNeedle objects:
- Checks if the needle is compatible with the yarn
- If yes, prints
"Starting {name} with {yarn} on {needle}" - If no, prints
"Warning: {needle} may not work well with {yarn} for {name}"
make_project(yarn, needle, "Cozy Scarf")
# Starting Cozy Scarf with scarlet worsted yarn (200m) on 5.0mm bamboo needles
make_project(thin_yarn, needle, "Delicate Shawl")
# Warning: 5.0mm bamboo needles may not work well with cream lace yarn (800m) for Delicate Shawl
def make_project(yarn, needle, name):
pass # replace with your code
# Test it
needle = KnittingNeedle(5.0)
yarn = Yarn("scarlet", "worsted", 200)
make_project(yarn, needle, "Cozy Scarf")
thin_yarn = Yarn("cream", "lace", 800)
make_project(thin_yarn, needle, "Delicate Shawl")
Now you have created a function that works with objects! What if we want to create another class that work with objects? After all, classes are for bundling data with behavior; the data doesn't have to be Python built-in data types.
Objects Containing Objects¶
Objects can have other objects as attributes, which is a natural way to model real-world relationships. For example, the knitting project involves yarns and knitting needles, so we can also define a KnittingProject class that has instances of those two classes as attributes:
class KnittingProject:
"""Represents a knitting project with a name, yarn, needle, and pattern rows."""
def __init__(self, name, yarn, needle):
self.name = name
self.yarn = yarn # a Yarn object
self.needle = needle # a KnittingNeedle object
self._check_needle_compatibility()
self.rows_completed = 0
def __str__(self):
return (f"Project: {self.name}\n"
f" Yarn: {self.yarn}\n"
f" Needle: {self.needle}\n"
f" Rows completed: {self.rows_completed}")
def _check_needle_compatibility(self):
if not self.needle.is_compatible(self.yarn):
print(f"Warning: {self.needle} may not work well with {self.yarn} for {self.name}")
else:
print(f"Starting {self.name} with {self.yarn} on {self.needle}")
def knit_rows(self, num_rows, meters_per_row):
"""Knit num_rows rows, using meters_per_row meters per row."""
total_needed = num_rows * meters_per_row
meters_before = self.yarn.meters
self.yarn.use(total_needed)
if self.yarn.meters < meters_before:
self.rows_completed += num_rows
print(f"Knit {num_rows} rows. Total: {self.rows_completed} rows.")
else:
print(f"Not enough yarn for {num_rows} rows!")
needle = KnittingNeedle(5.0)
yarn = Yarn("scarlet", "worsted", 200)
project = KnittingProject("Cozy Scarf", yarn, needle)
print(project)
print()
project.knit_rows(10, 3.5) # knit 10 rows at 3.5m each
project.knit_rows(50, 3.5) # this would fail (not enough yarn)
Starting Cozy Scarf with scarlet worsted yarn (200m) on 5.0mm bamboo needles Project: Cozy Scarf Yarn: scarlet worsted yarn (200m) Needle: 5.0mm bamboo needles Rows completed: 0 Used 35.0m. 165.0m remaining. Knit 10 rows. Total: 10 rows. Not enough yarn! Only 165.0m left. Not enough yarn for 50 rows!
Summary¶
Today we covered:
- Class: a blueprint for creating objects
- Object / Instance: a specific thing created from a class
__init__: initializer, sets up the object's attributes__str__: defines how an object looks when printed- Attributes: data stored on an object (
self.color,self.meters) - Methods: functions that belong to a class and operate on
self
Next lecture we'll look at more on how to design classes so that they can work more like a Python built-in type and blend naturally into your Python program, as well as a bit about inheritance.