Unit 2: OOP in Python
1 Resources
Python for Everybody, Chapter 14: Object-Oriented Programming
We looked at the beginning of this chapter last semester, but now we should be able to understand the whole thing.
-
This is from the official Python documentation, but meant to be a readable “tutorial” rather than a complete documentation. It’s pretty good!
Focus on the few paragraphs at the top introducing classes, then all of section 9.3 on how classes work, and finally section 9.5 on inheritance.
Skip the part on namesspaces (section 9.1)
Python in a Nutshell, Chapter 4: Object-Oriented Python
This book gets into a lot more detail on how classes work. Too much detail, actually. But it’s still a good reference and fills in some gaps where the “Python for Everybody” book doesn’t go far enough.
Skip to the sections on special methods which covers all the different ways operator overloading and class customization are possible, and decorators which covers how to use
@classmethodand@staticmethod.
2 Overview
You learned about Python classes last semester in SD211, where you saw how to write and use classes that have a few class functions and object variables.
In this unit, we will recollect what we learned earlier, but get more practice and go further to understand a few new concepts, like the distinction between instance variables and class variables, the use of property getter and setter methods, inheritance (when a class extends or derives from another class), and operator overloading (how to control the way built-in python operators and functions work with classes we write).
As we dive back into classes and learn these new tricks, we want to emphasize the bigger picture as well: what are classes really about, and when would I want to use one? Object-oriented programming is a way of thinking about programs that is based around classes. The powerful thing we can do with a class is to create a new type which encompasses both data (object variables) and functions which operate on that data. For those reasons, objects (i.e., classes) are a very useful tool in organizing larger programs, and especially creating and sharing libraries in Python.
Plus, your understanding of classes will be crucial to your success in the SD311 Data Structures and Scalability course you will take next fall. So let’s get into it!
3 Class and instance
Think of a Class as a blueprint and an Instance as the actual house built from that blueprint.
3.1 Constructors
A common misconception is that every piece of data in an object must come from an argument in __init__. In reality, __init__ simply sets the initial state. Some data might be the same for every new object (like a starting score of 0).
class FootballPlayer:
def __init__(self, name, position):
# These are set by the user during instantiation
self.name = name
self.position = position
# This is set automatically for EVERY new player
self.games_played = 0
def record_game(self):
"""Update the internal state of the player"""
self.games_played += 1
print(f"{self.name} has now played {self.games_played} games.")
# Creating instances (Objects)
p1 = FootballPlayer("Lamar Jackson", "QB")
p2 = FootballPlayer("Justin Tucker", "K")
p1.record_game() # games_played for p1 becomes 1
3.2 Classes inside classes
In Data Science, we often manage collections of objects. A common pattern is a “Manager” class that holds a list of other objects.
class Team:
def __init__(self, team_name):
self.team_name = team_name
self.roster = [] # This object CONTAINS a list of other objects
def add_player(self, player_object):
self.roster.append(player_object)
def play_season_game(self):
"""A 'Manager' method that updates all objects in its list"""
print(f"--- The {self.team_name} just played a game! ---")
for player in self.roster:
player.record_game() # Calling a method belonging to the Player object
# Integrating the objects
navy = Team("Navy")
navy.add_player(p1)
navy.add_player(p2)
navy.play_season_game()
The Pandas Connection: Think of a
pandas.DataFrameas theTeamand thepandas.Series(columns) as thePlayers. When you calldf.dropna(), the DataFrame “manager” coordinates the cleanup across all its internal column objects.
4 Shared Data (Class vs. Instance Variables)
It’s time to make a distinction between two kinds of class attributes:
Instance Attributes
Defined using
self.variable_name, these are unique to each object. If we have aPizzaclass, thetoppingsfor your pizza are separate from mine.Class Attributes
Defined outside the
__init__method, these are shared by every instance of the class.
Here is an example of a class representing a single pizza sold by a shop. Notice how some attributes are specific for that instance (a single pizza), and some are part of the entire class.
class Pizza:
# Class Attribute: Every pizza comes from the same shop
shop_name = "Drydock"
total_pizzas_sold = 0
def __init__(self, size, toppings):
self.size = size
self.toppings = toppings
# Update the global counter every time a new instance is created
Pizza.total_pizzas_sold += 1
order1 = Pizza("Large", ["Pepperoni"])
order2 = Pizza("Small", ["Cheese"])
print(order1.shop_name) # "Midshipmen Pizza Co."
print(f"Total sold: {Pizza.total_pizzas_sold}") # Output: 2
Protecting the Data (Encapsulation & @property)
Encapsulation prevents the internal state of an object from being corrupted by grouping data with the methods that restrict access to it.
4.1 The “Private” Convention
Python uses a single underscore _ to signal that an attribute is internal. It’s a “gentleman’s agreement” not to touch it directly.
4.2 Getters and Setters
We use the @property decorator to allow users to read data, and a .setter to validate data before it is changed.
class SmartFridge:
def __init__(self, temp):
self._temp = temp # Internal attribute
@property
def temp(self):
"""The Getter: allows reading the value like an attribute"""
return f"{self._temp}°F"
@temp.setter
def temp(self, new_temp):
"""The Setter: acts as a gatekeeper"""
if 32 <= new_temp <= 45:
self._temp = new_temp
else:
print("Error: Temperature outside safe food range!")
my_fridge = SmartFridge(36)
my_fridge.temp = 40 # Valid change
my_fridge.temp = 100 # Triggers validation error
5 Tools of the Class (Class & Static Methods)
Class Methods (@classmethod)
A class method receives the class itself (cls) as the first argument. They are often used as “Factory Methods” to create specific presets.
class Pizza:
def __init__(self, toppings, price):
self.toppings = toppings
self.price = price
@classmethod
def pepperoni_special(cls):
return cls(["pepperoni", "cheese"], 12.99)
# Usage
standard_pizza = Pizza.pepperoni_special()
Static Methods (@staticmethod)
These are regular functions that live inside a class for organizational purposes. They don’t need access to self or cls.
class GradeBook:
@staticmethod
def letter_grade(percentage):
if percentage >= 90: return "A"
if percentage >= 80: return "B"
return "C"
print(GradeBook.letter_grade(85))
6 Making Objects “Pythonic” (Operator Overloading)
Magic Methods (Dunder methods) tell Python how our objects should behave with standard operators like +, ==, or print().
5.1 Formatting: __str__ and __repr__
__str__: The “friendly” version for users.__repr__: The “developer” version for debugging.
5.2 Math: __add__ and __len__
class Cart:
def __init__(self, items):
self.items = items
def __add__(self, other):
"""Defines behavior for the + operator"""
return Cart(self.items + other.items)
def __len__(self):
return len(self.items)
c1 = Cart(["Milk"])
c2 = Cart(["Bread"])
big_cart = c1 + c2
print(len(big_cart)) # Output: 2
7 Simple Inheritance (Specialization)
Inheritance creates an “Is-A” relationship. A child class inherits everything from a parent but can add or change functionality.
class Food:
def __init__(self, name, calories):
self.name = name
self.calories = calories
def get_label(self):
return f"{self.name}: {self.calories} cal"
class PerishableFood(Food):
def __init__(self, name, calories, expiry_days):
# super() initializes the parent part of the object
super().__init__(name, calories)
self.expiry_days = expiry_days
# Method Overriding: Changing parent behavior
def get_label(self):
base_label = super().get_label()
return f"{base_label} (Expires in {self.expiry_days} days)"
By using inheritance, we avoid rewriting common code (like name and calories) while still allowing for specialized behavior where it counts.