SD 212 Spring 2026 / Notes


Unit 2: OOP in Python

1 Resources

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.DataFrame as the Team and the pandas.Series (columns) as the Players. When you call df.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 a Pizza class, the toppings for 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.